Skip to content
Merged
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
14 changes: 13 additions & 1 deletion packages/lib/src/usecases/actions/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, never, StateRepoEnv>
// 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,
Expand All @@ -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)

Expand Down
183 changes: 183 additions & 0 deletions packages/lib/tests/usecases/create-project-state-sync-order.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>
}

const withTempDir = <A, E, R>(
use: (tempDir: string) => Effect.Effect<A, E, R>
): Effect.Effect<A, E, R | FileSystem.FileSystem> =>
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<string>, 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<RecordedCommand>): CommandExecutor.CommandExecutor => {
const start = (command: Command.Command): Effect.Effect<CommandExecutor.Process, never> =>
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<void, never, NodeContext>
// 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<RecordedCommand> = []
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))
)
})
Loading