Skip to content

Commit 02c2381

Browse files
authored
Merge pull request #165 from ProverCoderAI/issue-158
fix(shell): run autoSyncState before docker compose up (#158)
2 parents 94d3f2f + af32484 commit 02c2381

File tree

2 files changed

+196
-1
lines changed

2 files changed

+196
-1
lines changed

packages/lib/src/usecases/actions/create-project.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,19 @@ const runCreateProject = (
263263
const hasAgent = finalConfig.agentMode !== undefined
264264
const waitForAgent = hasAgent && (finalConfig.agentAuto ?? false)
265265

266+
// CHANGE: run autoSyncState before docker compose up to prevent bind-mount inode invalidation
267+
// WHY: git reset --hard in autoSyncState deletes and recreates .orch/auth/codex; if docker is
268+
// already running with a bind-mount on that directory, the old inode becomes unreachable
269+
// inside the container — codex fails with "No such file or directory"
270+
// QUOTE(ТЗ): n/a
271+
// REF: issue-158
272+
// SOURCE: n/a
273+
// FORMAT THEOREM: ∀p: synced(p) ∧ stable_inode(.orch/auth/codex, p) → valid_mount(docker_up(p))
274+
// PURITY: SHELL
275+
// EFFECT: Effect<void, never, StateRepoEnv>
276+
// INVARIANT: .orch/auth/codex inode is stable when docker compose up runs
277+
// COMPLEXITY: O(git_sync) before O(docker_up)
278+
yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`))
266279
yield* _(
267280
runDockerUpIfNeeded(resolvedOutDir, projectConfig, {
268281
runUp: command.runUp,
@@ -278,7 +291,6 @@ const runCreateProject = (
278291

279292
yield* _(maybeCleanupAfterAgent(waitForAgent, resolvedOutDir))
280293

281-
yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`))
282294
yield* _(maybeOpenSsh(command, hasAgent, waitForAgent, projectConfig))
283295
}).pipe(Effect.asVoid)
284296

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import * as Command from "@effect/platform/Command"
2+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { NodeContext } from "@effect/platform-node"
6+
import { describe, expect, it } from "@effect/vitest"
7+
import { Effect } from "effect"
8+
import * as Inspectable from "effect/Inspectable"
9+
import * as Sink from "effect/Sink"
10+
import * as Stream from "effect/Stream"
11+
import { vi } from "vitest"
12+
13+
import type { CreateCommand, TemplateConfig } from "../../src/core/domain.js"
14+
import { createProject } from "../../src/usecases/actions/create-project.js"
15+
16+
vi.mock("../../src/usecases/actions/ports.js", () => ({
17+
resolveSshPort: (config: CreateCommand["config"]) => Effect.succeed(config)
18+
}))
19+
20+
type RecordedCommand = {
21+
readonly command: string
22+
readonly args: ReadonlyArray<string>
23+
}
24+
25+
const withTempDir = <A, E, R>(
26+
use: (tempDir: string) => Effect.Effect<A, E, R>
27+
): Effect.Effect<A, E, R | FileSystem.FileSystem> =>
28+
Effect.scoped(
29+
Effect.gen(function*(_) {
30+
const fs = yield* _(FileSystem.FileSystem)
31+
const tempDir = yield* _(
32+
fs.makeTempDirectoryScoped({
33+
prefix: "docker-git-state-sync-order-"
34+
})
35+
)
36+
return yield* _(use(tempDir))
37+
})
38+
)
39+
40+
const commandIncludes = (args: ReadonlyArray<string>, needle: string): boolean => args.includes(needle)
41+
42+
const decideExitCode = (cmd: RecordedCommand): number => {
43+
if (cmd.command === "git" && cmd.args[0] === "rev-parse") {
44+
// Auto-sync should detect "not a repo" and exit early.
45+
return 1
46+
}
47+
48+
if (cmd.command === "docker" && cmd.args[0] === "exec") {
49+
if (commandIncludes(cmd.args, "/run/docker-git/clone.failed")) {
50+
return 1
51+
}
52+
if (commandIncludes(cmd.args, "/run/docker-git/clone.done")) {
53+
return 0
54+
}
55+
}
56+
57+
return 0
58+
}
59+
60+
const decideStdout = (cmd: RecordedCommand): string => {
61+
if (cmd.command === "docker" && cmd.args[0] === "inspect") {
62+
return ""
63+
}
64+
return ""
65+
}
66+
67+
const makeFakeExecutor = (recorded: Array<RecordedCommand>): CommandExecutor.CommandExecutor => {
68+
const start = (command: Command.Command): Effect.Effect<CommandExecutor.Process, never> =>
69+
Effect.gen(function*(_) {
70+
const flattened = Command.flatten(command)
71+
for (const entry of flattened) {
72+
recorded.push({ command: entry.command, args: entry.args })
73+
}
74+
75+
const last = flattened[flattened.length - 1]
76+
const invocation: RecordedCommand = { command: last.command, args: last.args }
77+
const exit = decideExitCode(invocation)
78+
const stdoutText = decideStdout(invocation)
79+
const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(new TextEncoder().encode(stdoutText))
80+
81+
const process: CommandExecutor.Process = {
82+
[CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId,
83+
pid: CommandExecutor.ProcessId(1),
84+
exitCode: Effect.succeed(CommandExecutor.ExitCode(exit)),
85+
isRunning: Effect.succeed(false),
86+
kill: (_signal) => Effect.void,
87+
stderr: Stream.empty,
88+
stdin: Sink.drain,
89+
stdout,
90+
toJSON: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args, exit }),
91+
[Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args }),
92+
toString: () => `[TestProcess ${invocation.command}]`
93+
}
94+
95+
return process
96+
})
97+
98+
return CommandExecutor.makeExecutor(start)
99+
}
100+
101+
const makeCommand = (root: string, outDir: string, path: Path.Path): CreateCommand => {
102+
const template: TemplateConfig = {
103+
containerName: "dg-test",
104+
serviceName: "dg-test",
105+
sshUser: "dev",
106+
sshPort: 2222,
107+
repoUrl: "https://github.com/org/repo.git",
108+
repoRef: "main",
109+
targetDir: "/home/dev/org/repo",
110+
volumeName: "dg-test-home",
111+
dockerGitPath: path.join(root, ".docker-git"),
112+
authorizedKeysPath: path.join(root, "authorized_keys"),
113+
envGlobalPath: path.join(root, ".orch/env/global.env"),
114+
envProjectPath: path.join(root, ".orch/env/project.env"),
115+
codexAuthPath: path.join(root, ".orch/auth/codex"),
116+
codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"),
117+
codexHome: "/home/dev/.codex",
118+
dockerNetworkMode: "shared",
119+
dockerSharedNetworkName: "docker-git-shared",
120+
enableMcpPlaywright: false,
121+
pnpmVersion: "10.27.0"
122+
}
123+
124+
return {
125+
_tag: "Create",
126+
config: template,
127+
outDir,
128+
runUp: true,
129+
openSsh: false,
130+
force: true,
131+
forceEnv: false,
132+
waitForClone: true
133+
}
134+
}
135+
136+
// CHANGE: verify autoSyncState probe precedes docker compose up in recorded command sequence
137+
// WHY: git reset --hard in autoSyncState deletes and recreates .orch/auth/codex; running it
138+
// after docker up invalidates the bind-mount inode inside the container
139+
// QUOTE(ТЗ): n/a
140+
// REF: issue-158
141+
// SOURCE: n/a
142+
// FORMAT THEOREM: ∀p: stateSyncProbeIndex(p) < dockerComposeUpIndex(p)
143+
// PURITY: SHELL
144+
// EFFECT: Effect<void, never, NodeContext>
145+
// INVARIANT: .orch/auth/codex inode is stable when docker compose up runs
146+
// COMPLEXITY: O(n) where n = |recorded commands|
147+
const isStateSyncProbe = (cmd: RecordedCommand): boolean =>
148+
cmd.command === "git" && cmd.args[0] === "rev-parse"
149+
150+
const isDockerComposeUp = (cmd: RecordedCommand): boolean =>
151+
cmd.command === "docker" &&
152+
cmd.args.includes("compose") &&
153+
cmd.args.includes("up")
154+
155+
describe("createProject (state sync order)", () => {
156+
it.effect("autoSyncState probe runs before docker compose up", () =>
157+
withTempDir((root) =>
158+
Effect.gen(function*(_) {
159+
const path = yield* _(Path.Path)
160+
161+
const outDir = path.join(root, "project")
162+
const recorded: Array<RecordedCommand> = []
163+
const executor = makeFakeExecutor(recorded)
164+
const command = makeCommand(root, outDir, path)
165+
166+
yield* _(
167+
createProject(command).pipe(
168+
Effect.provideService(CommandExecutor.CommandExecutor, executor)
169+
)
170+
)
171+
172+
const stateSyncProbeIndex = recorded.findIndex(isStateSyncProbe)
173+
const dockerComposeUpIndex = recorded.findIndex(isDockerComposeUp)
174+
175+
expect(stateSyncProbeIndex).toBeGreaterThanOrEqual(0)
176+
expect(dockerComposeUpIndex).toBeGreaterThanOrEqual(0)
177+
// INVARIANT: ∀p: stateSyncProbeIndex(p) < dockerComposeUpIndex(p)
178+
expect(stateSyncProbeIndex).toBeLessThan(dockerComposeUpIndex)
179+
})
180+
)
181+
.pipe(Effect.provide(NodeContext.layer))
182+
)
183+
})

0 commit comments

Comments
 (0)