Skip to content

Commit b689d34

Browse files
committed
fix(actions): remove conflicting containers on force docker up
1 parent b8e4f63 commit b689d34

File tree

5 files changed

+269
-5
lines changed

5 files changed

+269
-5
lines changed

packages/app/src/docker-git/cli/usage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ Options:
8181
--mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright)
8282
--auto[=claude|codex] Auto-execute an agent; without value picks by auth, random if both are available
8383
--active apply-all: apply only to currently running containers (skip stopped ones)
84-
--force Overwrite existing files and wipe compose volumes (docker compose down -v)
84+
--force Overwrite existing files, remove conflicting containers, and wipe compose volumes
8585
--force-env Reset project env defaults only (keep workspace volume/data)
8686
-h, --help Show this help
8787

packages/lib/src/shell/docker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const runDockerComposeDown = (
139139
export const runDockerComposeDownVolumes = (
140140
cwd: string
141141
): Effect.Effect<void, DockerCommandError | PlatformError, CommandExecutor.CommandExecutor> =>
142-
runCompose(cwd, ["down", "-v"], [Number(ExitCode(0))])
142+
runCompose(cwd, ["down", "-v", "--remove-orphans"], [Number(ExitCode(0))])
143143

144144
// CHANGE: recreate docker compose environment in the target directory
145145
// WHY: allow a clean rebuild of the container from the UI

packages/lib/src/usecases/actions/docker-up.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type * as FileSystem from "@effect/platform/FileSystem"
44
import type * as Path from "@effect/platform/Path"
55
import { Duration, Effect, Fiber, Schedule } from "effect"
66

7+
import { runCommandWithExitCodes } from "../../shell/command-runner.js"
78
import type { CreateCommand } from "../../core/domain.js"
89
import {
910
runDockerComposeDownVolumes,
@@ -15,7 +16,7 @@ import {
1516
runDockerNetworkConnectBridge
1617
} from "../../shell/docker.js"
1718
import type { DockerCommandError } from "../../shell/errors.js"
18-
import { AgentFailedError, CloneFailedError } from "../../shell/errors.js"
19+
import { CommandFailedError, AgentFailedError, CloneFailedError } from "../../shell/errors.js"
1920
import { ensureComposeNetworkReady } from "../docker-network-gc.js"
2021
import { formatEditorSshAccessDetails, resolveProjectSshAccess } from "../ssh-access.js"
2122

@@ -57,6 +58,26 @@ type DockerUpOptions = {
5758
readonly forceEnv: boolean
5859
}
5960

61+
const removeConflictingContainer = (
62+
cwd: string,
63+
containerName: string
64+
): Effect.Effect<void, never, CommandExecutor.CommandExecutor> =>
65+
runCommandWithExitCodes(
66+
{
67+
cwd,
68+
command: "docker",
69+
args: ["rm", "-f", containerName]
70+
},
71+
[0],
72+
(exitCode) => new CommandFailedError({ command: `docker rm -f ${containerName}`, exitCode })
73+
).pipe(
74+
Effect.matchEffect({
75+
onFailure: () => Effect.void,
76+
onSuccess: () => Effect.log(`Removed container before force restart: ${containerName}`)
77+
}),
78+
Effect.asVoid
79+
)
80+
6081
const checkCloneState = (
6182
cwd: string,
6283
containerName: string
@@ -172,8 +193,12 @@ const runDockerComposeUpByMode = (
172193
yield* _(ensureComposeNetworkReady(resolvedOutDir, projectConfig))
173194

174195
if (force) {
175-
yield* _(Effect.log("Force enabled: wiping docker compose volumes (docker compose down -v)..."))
196+
yield* _(Effect.log("Force enabled: removing stale containers and wiping docker compose volumes..."))
176197
yield* _(runDockerComposeDownVolumes(resolvedOutDir))
198+
yield* _(removeConflictingContainer(resolvedOutDir, projectConfig.containerName))
199+
if (projectConfig.enableMcpPlaywright) {
200+
yield* _(removeConflictingContainer(resolvedOutDir, `${projectConfig.containerName}-browser`))
201+
}
177202
yield* _(Effect.log("Running: docker compose up -d --build"))
178203
yield* _(runDockerComposeUp(resolvedOutDir))
179204
return

packages/lib/tests/shell/docker.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,77 @@
11
import { describe, expect, it } from "@effect/vitest"
22

3-
import { dockerComposeUpRecreateArgs, parseDockerPublishedHostPorts } from "../../src/shell/docker.js"
3+
import { runDockerComposeDownVolumes, dockerComposeUpRecreateArgs, parseDockerPublishedHostPorts } from "../../src/shell/docker.js"
4+
5+
import * as Command from "@effect/platform/Command"
6+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
7+
import { Effect } from "effect"
8+
import * as Stream from "effect/Stream"
9+
10+
type RecordedCommand = {
11+
readonly command: string
12+
readonly args: ReadonlyArray<string>
13+
}
14+
15+
const makeCommandRecorder = (recorded: Array<RecordedCommand>): CommandExecutor.CommandExecutor => {
16+
const start = (command: Command.Command): Effect.Effect<CommandExecutor.Process, never> =>
17+
Effect.gen(function*(_) {
18+
const flattened = Command.flatten(command)
19+
for (const entry of flattened) {
20+
recorded.push({ command: entry.command, args: entry.args })
21+
}
22+
23+
const process: CommandExecutor.Process = {
24+
[CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId,
25+
pid: CommandExecutor.ProcessId(1),
26+
exitCode: Effect.succeed(CommandExecutor.ExitCode(0)),
27+
isRunning: Effect.succeed(false),
28+
kill: (_signal) => Effect.void,
29+
stderr: Stream.empty,
30+
stdin: (_data) => Effect.void,
31+
stdout: Stream.empty,
32+
toJSON: () => ({ _tag: "DockerTestProcess" }),
33+
toString: () => "DockerTestProcess",
34+
}
35+
36+
return process
37+
})
38+
39+
return CommandExecutor.makeExecutor(start)
40+
}
41+
42+
const includesArgsInOrder = (
43+
args: ReadonlyArray<string>,
44+
expected: ReadonlyArray<string>
45+
): boolean => {
46+
let offset = 0
47+
for (const token of expected) {
48+
const foundAt = args.indexOf(token, offset)
49+
if (foundAt === -1) {
50+
return false
51+
}
52+
offset = foundAt + 1
53+
}
54+
return true
55+
}
56+
57+
it("passes docker compose down -v --remove-orphans", async () => {
58+
const recorded: Array<RecordedCommand> = []
59+
const executor = makeCommandRecorder(recorded)
60+
61+
const command = await runDockerComposeDownVolumes("/tmp").pipe(
62+
Effect.provideService(CommandExecutor.CommandExecutor, executor),
63+
Effect.runPromise
64+
)
65+
66+
expect(
67+
recorded.some(
68+
(entry) =>
69+
entry.command === "docker" &&
70+
includesArgsInOrder(entry.args, ["compose", "down", "-v", "--remove-orphans"])
71+
)
72+
).toBe(true)
73+
expect(command).toBeUndefined()
74+
})
475

576
describe("docker compose args", () => {
677
it("uses build when force-env recreates containers", () => {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 { describe, expect, it } from "@effect/vitest"
6+
import { Effect } from "effect"
7+
import * as Inspectable from "effect/Inspectable"
8+
import * as Sink from "effect/Sink"
9+
import * as Stream from "effect/Stream"
10+
import * as nodePath from "node:path"
11+
12+
import { runDockerUpIfNeeded } from "../../src/usecases/actions/docker-up.js"
13+
import type { CreateCommand } from "../../src/core/domain.js"
14+
15+
type RecordedCommand = {
16+
readonly command: string
17+
readonly args: ReadonlyArray<string>
18+
}
19+
20+
const encode = (value: string): Uint8Array => new TextEncoder().encode(value)
21+
22+
const includesArgsInOrder = (
23+
args: ReadonlyArray<string>,
24+
expected: ReadonlyArray<string>
25+
): boolean => {
26+
let offset = 0
27+
for (const token of expected) {
28+
const foundAt = args.indexOf(token, offset)
29+
if (foundAt === -1) {
30+
return false
31+
}
32+
offset = foundAt + 1
33+
}
34+
return true
35+
}
36+
37+
const isDownWithRemoveOrphans = (command: RecordedCommand): boolean =>
38+
command.command === "docker" &&
39+
includesArgsInOrder(command.args, ["compose", "down", "-v", "--remove-orphans"])
40+
41+
const isUp = (command: RecordedCommand): boolean =>
42+
command.command === "docker" &&
43+
includesArgsInOrder(command.args, ["compose", "up", "-d", "--build"])
44+
45+
const isRmContainer = (name: string) => (command: RecordedCommand): boolean =>
46+
command.command === "docker" && includesArgsInOrder(command.args, ["rm", "-f", name])
47+
48+
const fakePath: Path.Path = {
49+
join: (...segments) => nodePath.join(...segments),
50+
resolve: (...segments) => nodePath.resolve(...segments),
51+
isAbsolute: (value) => nodePath.isAbsolute(value),
52+
dirname: (value) => nodePath.dirname(value)
53+
} as Path.Path
54+
55+
const fakeFileSystem: FileSystem.FileSystem = {
56+
exists: () => Effect.succeed(false)
57+
} as FileSystem.FileSystem
58+
59+
const makeFakeExecutor = (recorded: Array<RecordedCommand>): CommandExecutor.CommandExecutor => {
60+
const start = (command: Command.Command): Effect.Effect<CommandExecutor.Process, never> =>
61+
Effect.gen(function*(_) {
62+
const flattened = Command.flatten(command)
63+
for (const entry of flattened) {
64+
recorded.push({ command: entry.command, args: entry.args })
65+
}
66+
67+
const invocation = flattened[flattened.length - 1]!
68+
const stdoutText =
69+
invocation.command === "docker" &&
70+
invocation.args.includes("inspect") &&
71+
invocation.args.includes("bridge")
72+
? "0.0.0.0\n"
73+
: ""
74+
const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText))
75+
76+
const process: CommandExecutor.Process = {
77+
[CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId,
78+
pid: CommandExecutor.ProcessId(1),
79+
exitCode: Effect.succeed(CommandExecutor.ExitCode(0)),
80+
isRunning: Effect.succeed(false),
81+
kill: (_signal) => Effect.void,
82+
stderr: Stream.empty,
83+
stdin: Sink.drain,
84+
stdout,
85+
toJSON: () => ({
86+
_tag: "DockerUpTestProcess",
87+
command: invocation.command,
88+
args: invocation.args
89+
}),
90+
[Inspectable.NodeInspectSymbol]: () => ({
91+
_tag: "DockerUpTestProcess",
92+
command: invocation.command,
93+
args: invocation.args
94+
}),
95+
toString: () => `[DockerUpTestProcess ${invocation.command}]`
96+
}
97+
98+
return process
99+
})
100+
101+
return CommandExecutor.makeExecutor(start)
102+
}
103+
104+
describe("runDockerUpIfNeeded with force", () => {
105+
it.effect("wipes compose orphans, removes container, then recreates", () =>
106+
Effect.gen(function*(_) {
107+
const commands: Array<RecordedCommand> = []
108+
const resolvedOutDir = "/tmp/docker-git-force-up"
109+
const config: CreateCommand["config"] = {
110+
containerName: "dg-force-test",
111+
serviceName: "dg-force-test",
112+
sshUser: "dev",
113+
sshPort: 2237,
114+
repoUrl: "https://github.com/org/repo.git",
115+
repoRef: "main",
116+
targetDir: "/home/dev/workspaces/org/repo",
117+
volumeName: "dg-force-test-home",
118+
dockerGitPath: `${resolvedOutDir}/.docker-git`,
119+
authorizedKeysPath: "/tmp/authorized_keys",
120+
envGlobalPath: `${resolvedOutDir}/.orch/env/global.env`,
121+
envProjectPath: `${resolvedOutDir}/docker-git.env`,
122+
codexAuthPath: `${resolvedOutDir}/.orch/auth/codex`,
123+
codexSharedAuthPath: `${resolvedOutDir}/.orch/auth/codex-shared`,
124+
codexHome: "/home/dev/.codex",
125+
geminiAuthPath: `${resolvedOutDir}/.orch/auth/gemini`,
126+
geminiHome: "/home/dev/.gemini",
127+
dockerNetworkMode: "project",
128+
dockerSharedNetworkName: "docker-git-shared",
129+
enableMcpPlaywright: true,
130+
pnpmVersion: "10.27.0",
131+
agentMode: undefined,
132+
agentAuto: false,
133+
clonedOnHostname: undefined,
134+
forkRepoUrl: undefined,
135+
gitTokenLabel: undefined,
136+
codexAuthLabel: undefined,
137+
claudeAuthLabel: undefined
138+
}
139+
140+
const recordedExecutor = makeFakeExecutor(commands)
141+
const result = yield* _(
142+
runDockerUpIfNeeded(resolvedOutDir, config, {
143+
runUp: true,
144+
waitForClone: false,
145+
waitForAgent: false,
146+
force: true,
147+
forceEnv: false
148+
}).pipe(
149+
Effect.provideService(CommandExecutor.CommandExecutor, recordedExecutor),
150+
Effect.provideService(FileSystem.FileSystem, fakeFileSystem),
151+
Effect.provideService(Path.Path, fakePath)
152+
)
153+
)
154+
155+
expect(result).toBeUndefined()
156+
157+
const downIndex = commands.findIndex(isDownWithRemoveOrphans)
158+
const rmMainIndex = commands.findIndex(isRmContainer("dg-force-test"))
159+
const rmBrowserIndex = commands.findIndex(isRmContainer("dg-force-test-browser"))
160+
const upIndex = commands.findIndex(isUp)
161+
162+
expect(downIndex).toBeGreaterThanOrEqual(0)
163+
expect(rmMainIndex).toBeGreaterThan(downIndex)
164+
expect(rmBrowserIndex).toBeGreaterThan(rmMainIndex)
165+
expect(upIndex).toBeGreaterThan(rmBrowserIndex)
166+
})
167+
)
168+
})

0 commit comments

Comments
 (0)