Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/session-sync-claude-config-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@prover-coder-ai/docker-git-session-sync": patch
---

Back up Claude Code sessions from the resolved `CLAUDE_CONFIG_DIR` (issue #422).

docker-git points Claude Code at a custom `CLAUDE_CONFIG_DIR`
(`~/.docker-git/.orch/auth/claude/<label>`), so Claude writes chat transcripts to
`$CLAUDE_CONFIG_DIR/projects` instead of `~/.claude/projects`. The session backup
only scanned the home-relative paths, so the `.claude` folder in the
`docker-git-sessions` backup repo stayed empty.

The backup now resolves each session root from its agent environment override
(`CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex) and falls back to the
home-relative directory when the override is unset, keeping the logical
`.claude/projects` / `.codex/sessions` names stable in the backup repo.
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/api-client-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import { request, requestTextStream, requestVoid } from "./api-http.js"
import { asObject, type JsonRequest, type JsonValue } from "./api-json.js"
import type { ControllerRuntime } from "./controller.js"
import type {
AuthClaudeStatusCommand,
AuthCodexImportCommand,
AuthCodexLoginCommand,
AuthCodexLogoutCommand,
AuthCodexStatusCommand,
AuthClaudeStatusCommand,
AuthGithubLoginCommand,
AuthGithubLogoutCommand,
AuthGithubStatusCommand,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ import type {
import type { ApiRequestError } from "./host-errors.js"

export {
claudeStatus,
codexImport,
codexLogin,
codexLogout,
codexStatus,
claudeStatus,
githubLogin,
githubLogout,
githubStatus,
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/docker-git/controller-compose-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,5 +133,10 @@ export const composeFilesForGpuMode = (
gpuMode === "none"
? Effect.succeed({ composePath, extraOverlayPath: null, gpuOverlayPath: null, runtimeOverlayPath: null })
: requireGpuOverlayPath(composePath).pipe(
Effect.map((gpuOverlayPath) => ({ composePath, extraOverlayPath: null, gpuOverlayPath, runtimeOverlayPath: null }))
Effect.map((gpuOverlayPath) => ({
composePath,
extraOverlayPath: null,
gpuOverlayPath,
runtimeOverlayPath: null
}))
)
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/controller-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import * as Path from "@effect/platform/Path"
import { Duration, Effect } from "effect"

import {
composeFilesForGpuMode,
type ControllerComposeFiles,
type ControllerGpuMode,
composeFilesForGpuMode,
controllerGpuModeEnvKey,
loadControllerComposeExtraPath
} from "./controller-compose-files.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/program-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Effect, Match, pipe } from "effect"

import {
type ApiTerminalSession,
claudeStatus,
codexImport,
codexLogin,
codexLogout,
codexStatus,
claudeStatus,
createAuthTerminalSession,
githubLogin,
githubLogout,
Expand Down
6 changes: 3 additions & 3 deletions packages/app/tests/docker-git/controller-compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ import {
import { runCompose } from "../../src/docker-git/controller-docker.js"
import { controllerDockerRuntimeEnvKey } from "../../src/docker-git/controller-runtime.js"
import {
type ControllerBuildSkillerFixtureMode,
type PrepareRevisionFixture,
type PreparedRevision,
assertControllerComposeProperty,
type ControllerBuildSkillerFixtureMode,
controllerDockerRuntimeEnvFixtureModeArbitrary,
controllerRevisionPattern,
expectedSkillerSubmoduleCommand,
type PreparedRevision,
type PrepareRevisionFixture,
prepareRevisionFixtureArbitrary,
prepareRevisionInTemporaryRoot,
recordedCommandExecutorLayer,
Expand Down
15 changes: 12 additions & 3 deletions packages/docker-git-session-sync/src/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
isPathWithinParent,
isChatTranscriptPath,
sessionDirNames,
sessionRootCandidatePaths,
sessionRootSpecs,
sessionWalkIgnoreDirNames,
shouldIgnoreSessionPath,
sortSessionFiles,
Expand Down Expand Up @@ -235,9 +237,16 @@

const getAllowedSessionRoots = (): ReadonlyArray<SessionDir> => {
const homeDir = os.homedir()
return sessionDirNames
.map((dirName) => ({ name: dirName, path: path.join(homeDir, dirName) }))
.filter((entry) => fs.existsSync(entry.path))
const roots: Array<SessionDir> = []
for (const spec of sessionRootSpecs) {
const existing = sessionRootCandidatePaths(spec, homeDir, process.env).find((candidatePath) =>
fs.existsSync(candidatePath)
)
if (existing !== undefined) {
roots.push({ name: spec.name, path: existing })
}
}
return roots
}
Comment on lines 238 to 250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Прямой доступ к process.env/fs в обход Effect/Layer.

Гайдлайн требует, чтобы окружение и файловая система были доступны только через Effect-based интерфейсы/Layer, а не напрямую. getAllowedSessionRoots продолжает (как и раньше) обращаться к process.env и fs.existsSync напрямую. Полный переход на Effect/Layer для этого модуля — это переработка архитектуры всего файла, а не локальный фикс, поэтому оцениваю как затратный при умеренной пользе именно в контексте этого PR.

As per coding guidelines: "All external services (database, HTTP, environment) must be accessed through Effect-based interfaces and Layer-based dependency injection; never call external APIs directly".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/docker-git-session-sync/src/backup.ts` around lines 238 - 250,
getAllowedSessionRoots is still reading environment and filesystem state
directly via process.env and fs.existsSync, which violates the Effect/Layer
access pattern. Update this helper to obtain env and file checks through the
module’s Effect-based interfaces/Layers used elsewhere in backup.ts, and thread
those dependencies into sessionRootCandidatePaths/getAllowedSessionRoots instead
of accessing globals directly.

Source: Coding guidelines


const resolveAllowedSessionDir = (
Expand Down Expand Up @@ -311,7 +320,7 @@
if (!entry.isFile()) {
continue
}
try {

Check warning on line 323 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
const stats = fs.statSync(fullPath)
const logicalName = path.posix.join(baseName, logicalRelPath)
if (!isChatTranscriptPath(logicalName)) {
Expand Down Expand Up @@ -493,7 +502,7 @@
): number => {
const verbose = context.verbose
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-sync-repo-"))
try {

Check warning on line 505 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
const sessionDirs = findSessionDirs(context.sessionDir, verbose, output)
if (sessionDirs.length === 0) {
logVerbose(verbose, output, "No session directories found")
Expand Down Expand Up @@ -594,7 +603,7 @@
if (readyFilePath === null) {
return
}
try {

Check warning on line 606 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
fs.writeFileSync(readyFilePath, `${JSON.stringify(state)}\n`, "utf8")
} catch {
// The parent process also has a timeout fallback; failure to write this
Expand Down Expand Up @@ -647,7 +656,7 @@
args.push("--verbose")
}
const command = entrypoint === null ? "docker-git-session-sync" : process.execPath
try {

Check warning on line 659 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
const child = spawn(command, args, {
cwd: context.cwd,
detached: true,
Expand Down Expand Up @@ -692,7 +701,7 @@
return 1
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "session-sync-repo-"))
try {

Check warning on line 704 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
const sessionDirs = findSessionDirs(options.sessionDir, verbose, output)
const sessionFiles = sessionDirs.flatMap((dir) => collectSessionFiles(dir.path, dir.name, verbose, output))
const prepared = prepareUploadArtifacts(
Expand Down Expand Up @@ -787,7 +796,7 @@
export const uploadFromContext = (options: UploadOptions, cwd: string, output: Output): number => {
const contextPath = path.resolve(cwd, options.contextPath)
const readyFilePath = options.readyFilePath === null ? null : path.resolve(cwd, options.readyFilePath)
try {

Check warning on line 799 in packages/docker-git-session-sync/src/backup.ts

View workflow job for this annotation

GitHub Actions / Lint Effect-TS

Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch
const parsed = parseUploadContext(JSON.parse(fs.readFileSync(contextPath, "utf8")))
if (parsed === null) {
writeBackgroundReadyState(readyFilePath, { state: "failed", message: "Invalid upload context" })
Expand Down
2 changes: 1 addition & 1 deletion packages/docker-git-session-sync/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const usageText = `Usage:
docker-git-session-sync download <snapshot-ref> [options]

Options:
--session-dir <path> Path under ~/.codex/sessions or ~/.claude/projects
--session-dir <path> Path under \${CODEX_HOME:-~/.codex}/sessions or \${CLAUDE_CONFIG_DIR:-~/.claude}/projects
--pr-number <number> Open PR number to post comment to
--repo <owner/repo> Source repository or list filter
--limit <number> Maximum snapshots to list (default: 20)
Expand Down
46 changes: 45 additions & 1 deletion packages/docker-git-session-sync/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,51 @@ export const backupDefaultBranch = "main"
export const chunkManifestSuffix = ".chunks.json"
export const maxRepoFileSize = 20 * 1000 * 1000
export const maxPushBatchBytes = 50 * 1000 * 1000
export const sessionDirNames: ReadonlyArray<string> = [".codex/sessions", ".claude/projects"]
// CHANGE: Resolve session roots from agent env overrides (CLAUDE_CONFIG_DIR / CODEX_HOME).
// WHY: docker-git points Claude Code at a custom CLAUDE_CONFIG_DIR, so chat transcripts land in
// "$CLAUDE_CONFIG_DIR/projects" rather than "~/.claude/projects". The backup only scanned the
// home-relative paths, so the Claude folder in the backup repo stayed empty (issue #422).
// QUOTE(ТЗ): "Почему сессии от claude code не созраняются ... Папка claude вообще пустая"
// REF: issue-422
// SOURCE: n/a
// FORMAT THEOREM: ∀spec,home,env: candidatePaths(spec) prefers env override base then home base.
// PURITY: CORE
// EFFECT: none
// INVARIANT: logical session name is stable regardless of which physical base is used.
// COMPLEXITY: O(1)
export interface SessionRootSpec {
// Logical name used inside the backup repo (e.g. ".claude/projects").
readonly name: string
// Environment variable that overrides the base directory, when set and non-empty.
readonly envVar: string | null
// Sub-directory holding chat transcripts within the base directory.
readonly subDir: string
// Base directory relative to the user home when the env override is absent.
readonly homeBase: string
}

export const sessionRootSpecs: ReadonlyArray<SessionRootSpec> = [
{ name: ".codex/sessions", envVar: "CODEX_HOME", subDir: "sessions", homeBase: ".codex" },
{ name: ".claude/projects", envVar: "CLAUDE_CONFIG_DIR", subDir: "projects", homeBase: ".claude" }
]

export const sessionDirNames: ReadonlyArray<string> = sessionRootSpecs.map((spec) => spec.name)

export const sessionRootCandidatePaths = (
spec: SessionRootSpec,
homeDir: string,
env: Readonly<Record<string, string | undefined>>
): ReadonlyArray<string> => {
const homePath = path.join(homeDir, spec.homeBase, spec.subDir)
const override = spec.envVar === null ? undefined : env[spec.envVar]
const trimmed = typeof override === "string" ? override.trim() : ""
if (trimmed.length === 0) {
return [homePath]
}
const overridePath = path.join(trimmed, spec.subDir)
return overridePath === homePath ? [homePath] : [overridePath, homePath]
}
Comment on lines +19 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Нет формальной TSDoc-документации для нового публичного API.

Функциональный комментарий (CHANGE/WHY/.../FORMAT THEOREM) описывает инвариант, но для SessionRootSpec и sessionRootCandidatePaths отсутствуют TSDoc-теги @precondition, @postcondition, @throws, требуемые гайдлайном для публичных сигнатур. Учитывая, что эта функция — центральная точка фикса #422, формализация предусловий/постусловий (env может быть пустым/undefined; результат — непустой массив кандидатов) повысит доказуемость.

As per coding guidelines: "Provide comprehensive TSDoc comments with mathematical notation: @pure, @effect, @invariant, @precondition, @postcondition, @complexity, @throws, and CHANGE/WHY/REF/SOURCE/FORMAT THEOREM functional comment markers".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/docker-git-session-sync/src/core.ts` around lines 19 - 62, The new
public API lacks required formal TSDoc, so add comprehensive TSDoc for
SessionRootSpec and sessionRootCandidatePaths using the guideline markers.
Document the preconditions for env/homeDir and each SessionRootSpec field, the
postcondition that sessionRootCandidatePaths always returns a non-empty ordered
candidate list with env override first when present, and include `@pure`, `@effect`,
`@invariant`, `@complexity`, and `@throws` where applicable. Keep the existing
CHANGE/WHY/REF/SOURCE/FORMAT THEOREM functional comment and align the docs with
the symbols SessionRootSpec, sessionRootSpecs, and sessionRootCandidatePaths.

Source: Coding guidelines


export const sessionWalkIgnoreDirNames: ReadonlySet<string> = new Set([".git", "node_modules", "tmp"])
export const githubEnvKeys: ReadonlyArray<string> = ["GITHUB_TOKEN", "GH_TOKEN"]

Expand Down
4 changes: 2 additions & 2 deletions packages/docker-git-session-sync/src/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export const downloadSnapshot = (options: DownloadOptions, cwd: string, output:

output.out(`Downloaded snapshot to: ${outputPath}`)
output.out("\nTo restore session files, copy them to the appropriate location:")
output.out(" - .codex/sessions/... -> ~/.codex/sessions/")
output.out(" - .claude/projects/... -> ~/.claude/projects/")
output.out(" - .codex/sessions/... -> ${CODEX_HOME:-~/.codex}/sessions/")
output.out(" - .claude/projects/... -> ${CLAUDE_CONFIG_DIR:-~/.claude}/projects/")
return 0
}
57 changes: 57 additions & 0 deletions packages/docker-git-session-sync/tests/session-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
formatTokenReduction,
isChatTranscriptPath,
maxRepoFileSize,
sessionRootCandidatePaths,
sessionRootSpecs,
shouldIgnoreSessionPath,
summarizeTokenReduction
} from "../src/core.js"
Expand Down Expand Up @@ -146,6 +148,61 @@ describe("session path filtering", () => {
})
})

describe("session root resolution", () => {
const codexSpec = sessionRootSpecs.find((spec) => spec.name === ".codex/sessions")
const claudeSpec = sessionRootSpecs.find((spec) => spec.name === ".claude/projects")

it("resolves Claude session roots from CLAUDE_CONFIG_DIR (issue #422)", () => {
expect(claudeSpec).toBeDefined()
if (claudeSpec === undefined) {
return
}
const configDir = path.join(tmpDir, ".docker-git", ".orch", "auth", "claude", "default")
const candidates = sessionRootCandidatePaths(claudeSpec, "/home/dev", {
CLAUDE_CONFIG_DIR: configDir
})

// The env override wins, but the home-relative path stays as a fallback.
expect(candidates).toEqual([
path.join(configDir, "projects"),
path.join("/home/dev", ".claude", "projects")
])
})

it("falls back to the home directory when the env override is empty", () => {
expect(codexSpec).toBeDefined()
if (codexSpec === undefined || claudeSpec === undefined) {
return
}
expect(sessionRootCandidatePaths(codexSpec, "/home/dev", {})).toEqual([
path.join("/home/dev", ".codex", "sessions")
])
expect(sessionRootCandidatePaths(claudeSpec, "/home/dev", { CLAUDE_CONFIG_DIR: " " })).toEqual([
path.join("/home/dev", ".claude", "projects")
])
})

it("resolves Codex session roots from CODEX_HOME", () => {
if (codexSpec === undefined) {
return
}
const codexHome = path.join(tmpDir, "codex-home")
expect(sessionRootCandidatePaths(codexSpec, "/home/dev", { CODEX_HOME: codexHome })).toEqual([
path.join(codexHome, "sessions"),
path.join("/home/dev", ".codex", "sessions")
])
})

it("collapses to a single candidate when the override matches the home path", () => {
if (claudeSpec === undefined) {
return
}
expect(
sessionRootCandidatePaths(claudeSpec, "/home/dev", { CLAUDE_CONFIG_DIR: "/home/dev/.claude" })
).toEqual([path.join("/home/dev", ".claude", "projects")])
})
})
Comment on lines +151 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Отсутствует property-based тест для задокументированного инварианта sessionRootCandidatePaths.

Новые тесты используют только фиксированные примеры, хотя в core.ts явно сформулирован универсальный инвариант (FORMAT THEOREM: ∀spec,home,env: ...). В этом же файле уже есть паттерн property-тестов (fc.assert(fc.property(...))), который стоит применить и здесь: для произвольных строк home/override/subDir — если trim(override) непусто и join(override, subDir) !== join(home, homeBase, subDir), результат должен быть [overridePath, homePath], иначе [homePath].

As per coding guidelines: "Write property-based tests using fast-check (fc.property) to verify mathematical invariants".

🧪 Пример property-based теста
it("candidate paths prefer the env override then fall back to home (invariant)", () => {
  fc.assert(
    fc.property(pathPartArbitrary, pathPartArbitrary, fc.option(pathPartArbitrary, { nil: undefined }),
      (home, homeBase, override) => {
        const spec: SessionRootSpec = { name: "x", envVar: "X_HOME", subDir: "sub", homeBase }
        const env = override === undefined ? {} : { X_HOME: override }
        const homePath = path.join(home, homeBase, "sub")
        const result = sessionRootCandidatePaths(spec, home, env)
        const trimmed = override?.trim() ?? ""
        if (trimmed.length === 0) return result.length === 1 && result[0] === homePath
        const overridePath = path.join(trimmed, "sub")
        return overridePath === homePath
          ? result.length === 1 && result[0] === homePath
          : result.length === 2 && result[0] === overridePath && result[1] === homePath
      }
    )
  )
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/docker-git-session-sync/tests/session-files.test.ts` around lines
151 - 204, The session root tests only cover fixed examples, but they should
also verify the documented invariant of sessionRootCandidatePaths with a
fast-check property test. Add an fc.assert(fc.property(...)) in
session-files.test.ts, using the existing property-test pattern, to generate
arbitrary home/override/subDir values and check that sessionRootCandidatePaths
returns the override path plus fallback home path when trim(override) is
non-empty and distinct, otherwise only the home path. Reference
sessionRootCandidatePaths and the existing session root resolution tests to
place the new invariant test alongside the current cases.

Source: Coding guidelines


describe("snapshot refs", () => {
it("uses stable current refs for PR and branch snapshots", () => {
expect(buildSnapshotRef("org/repo", 230, "issue-230")).toBe("org/repo/pr-230/current")
Expand Down
Loading