diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 249aff9..1f4aa72 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -263,6 +263,19 @@ const runCreateProject = ( const hasAgent = finalConfig.agentMode !== undefined const waitForAgent = hasAgent && (finalConfig.agentAuto ?? false) + // CHANGE: run autoSyncState before docker compose up to prevent bind-mount inode invalidation + // WHY: git reset --hard in autoSyncState deletes and recreates .orch/auth/codex; if docker is + // already running with a bind-mount on that directory, the old inode becomes unreachable + // inside the container — codex fails with "No such file or directory" + // QUOTE(ТЗ): n/a + // REF: issue-158 + // SOURCE: n/a + // FORMAT THEOREM: ∀p: synced(p) ∧ stable_inode(.orch/auth/codex, p) → valid_mount(docker_up(p)) + // PURITY: SHELL + // EFFECT: Effect + // INVARIANT: .orch/auth/codex inode is stable when docker compose up runs + // COMPLEXITY: O(git_sync) before O(docker_up) + yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`)) yield* _( runDockerUpIfNeeded(resolvedOutDir, projectConfig, { runUp: command.runUp, @@ -278,7 +291,6 @@ const runCreateProject = ( yield* _(maybeCleanupAfterAgent(waitForAgent, resolvedOutDir)) - yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`)) yield* _(maybeOpenSsh(command, hasAgent, waitForAgent, projectConfig)) }).pipe(Effect.asVoid) diff --git a/packages/lib/tests/usecases/create-project-state-sync-order.test.ts b/packages/lib/tests/usecases/create-project-state-sync-order.test.ts new file mode 100644 index 0000000..be8dc90 --- /dev/null +++ b/packages/lib/tests/usecases/create-project-state-sync-order.test.ts @@ -0,0 +1,183 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" +import { vi } from "vitest" + +import type { CreateCommand, TemplateConfig } from "../../src/core/domain.js" +import { createProject } from "../../src/usecases/actions/create-project.js" + +vi.mock("../../src/usecases/actions/ports.js", () => ({ + resolveSshPort: (config: CreateCommand["config"]) => Effect.succeed(config) +})) + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-state-sync-order-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const commandIncludes = (args: ReadonlyArray, needle: string): boolean => args.includes(needle) + +const decideExitCode = (cmd: RecordedCommand): number => { + if (cmd.command === "git" && cmd.args[0] === "rev-parse") { + // Auto-sync should detect "not a repo" and exit early. + return 1 + } + + if (cmd.command === "docker" && cmd.args[0] === "exec") { + if (commandIncludes(cmd.args, "/run/docker-git/clone.failed")) { + return 1 + } + if (commandIncludes(cmd.args, "/run/docker-git/clone.done")) { + return 0 + } + } + + return 0 +} + +const decideStdout = (cmd: RecordedCommand): string => { + if (cmd.command === "docker" && cmd.args[0] === "inspect") { + return "" + } + return "" +} + +const makeFakeExecutor = (recorded: Array): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + for (const entry of flattened) { + recorded.push({ command: entry.command, args: entry.args }) + } + + const last = flattened[flattened.length - 1] + const invocation: RecordedCommand = { command: last.command, args: last.args } + const exit = decideExitCode(invocation) + const stdoutText = decideStdout(invocation) + const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(new TextEncoder().encode(stdoutText)) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(exit)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args, exit }), + [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args }), + toString: () => `[TestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +const makeCommand = (root: string, outDir: string, path: Path.Path): CreateCommand => { + const template: TemplateConfig = { + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + return { + _tag: "Create", + config: template, + outDir, + runUp: true, + openSsh: false, + force: true, + forceEnv: false, + waitForClone: true + } +} + +// CHANGE: verify autoSyncState probe precedes docker compose up in recorded command sequence +// WHY: git reset --hard in autoSyncState deletes and recreates .orch/auth/codex; running it +// after docker up invalidates the bind-mount inode inside the container +// QUOTE(ТЗ): n/a +// REF: issue-158 +// SOURCE: n/a +// FORMAT THEOREM: ∀p: stateSyncProbeIndex(p) < dockerComposeUpIndex(p) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: .orch/auth/codex inode is stable when docker compose up runs +// COMPLEXITY: O(n) where n = |recorded commands| +const isStateSyncProbe = (cmd: RecordedCommand): boolean => + cmd.command === "git" && cmd.args[0] === "rev-parse" + +const isDockerComposeUp = (cmd: RecordedCommand): boolean => + cmd.command === "docker" && + cmd.args.includes("compose") && + cmd.args.includes("up") + +describe("createProject (state sync order)", () => { + it.effect("autoSyncState probe runs before docker compose up", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + + const outDir = path.join(root, "project") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const command = makeCommand(root, outDir, path) + + yield* _( + createProject(command).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor) + ) + ) + + const stateSyncProbeIndex = recorded.findIndex(isStateSyncProbe) + const dockerComposeUpIndex = recorded.findIndex(isDockerComposeUp) + + expect(stateSyncProbeIndex).toBeGreaterThanOrEqual(0) + expect(dockerComposeUpIndex).toBeGreaterThanOrEqual(0) + // INVARIANT: ∀p: stateSyncProbeIndex(p) < dockerComposeUpIndex(p) + expect(stateSyncProbeIndex).toBeLessThan(dockerComposeUpIndex) + }) + ) + .pipe(Effect.provide(NodeContext.layer)) + ) +})