Skip to content

Commit c7f9577

Browse files
committed
fix(docker-git): prefer live runtime and block duplicate identities
Return Docker stderr in command failures, prevent duplicate docker-git identities unless --force, and make open resolve the live runtime owner before falling back to up. Verified with targeted vitest suites, typecheck for app/lib/api, and build:docker-git.
1 parent bed84e8 commit c7f9577

26 files changed

+1657
-58
lines changed

packages/api/src/http.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
88
import * as HttpServerError from "@effect/platform/HttpServerError"
99
import * as ParseResult from "effect/ParseResult"
1010
import * as Schema from "effect/Schema"
11+
import { renderError, type AppError } from "@effect-template/lib/usecases/errors"
1112

1213
import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js"
1314
import {
@@ -92,6 +93,32 @@ type ApiError =
9293
| HttpServerError.RequestError
9394
| PlatformError
9495

96+
const appErrorTags = new Set<string>([
97+
"FileExistsError",
98+
"CloneFailedError",
99+
"AgentFailedError",
100+
"DockerAccessError",
101+
"DockerCommandError",
102+
"ConfigNotFoundError",
103+
"ConfigDecodeError",
104+
"ScrapArchiveInvalidError",
105+
"ScrapArchiveNotFoundError",
106+
"ScrapTargetDirUnsupportedError",
107+
"ScrapWipeRefusedError",
108+
"InputCancelledError",
109+
"InputReadError",
110+
"PortProbeError",
111+
"AuthError",
112+
"CommandFailedError"
113+
])
114+
115+
const isAppError = (error: unknown): error is AppError =>
116+
typeof error === "object" &&
117+
error !== null &&
118+
"_tag" in error &&
119+
typeof error["_tag"] === "string" &&
120+
appErrorTags.has(error["_tag"])
121+
95122
const jsonResponse = (data: unknown, status: number) =>
96123
Effect.map(HttpServerResponse.json(data), (response) => HttpServerResponse.setStatus(response, status))
97124

@@ -154,6 +181,10 @@ const errorResponse = (error: ApiError | unknown) => {
154181
return jsonResponse({ error: { type: error._tag, message: error.message } }, 500)
155182
}
156183

184+
if (isAppError(error)) {
185+
return jsonResponse({ error: { type: error._tag, message: renderError(error) } }, 400)
186+
}
187+
157188
return jsonResponse(
158189
{
159190
error: {

packages/api/src/services/projects.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
downAllDockerGitProjects,
77
listProjectItems,
88
readProjectConfig,
9+
renderError,
910
runDockerComposeUpWithPortCheck
1011
} from "@effect-template/lib"
1112
import * as FileSystem from "@effect/platform/FileSystem"
@@ -19,7 +20,7 @@ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
1920
import { Effect, Either } from "effect"
2021

2122
import type { CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js"
22-
import { ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js"
23+
import { ApiConflictError, ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js"
2324
import { ensureGithubAuthForCreate } from "./auth.js"
2425
import { emitProjectEvent } from "./events.js"
2526

@@ -308,7 +309,13 @@ export const createProjectFromRequest = (
308309
})
309310
)
310311

311-
yield* _(createProject(command))
312+
yield* _(
313+
createProject(command).pipe(
314+
Effect.catchTag("DockerIdentityConflictError", (error) =>
315+
Effect.fail(new ApiConflictError({ message: renderError(error) }))
316+
)
317+
)
318+
)
312319

313320
const project = yield* _(
314321
resolveCreatedProject(

packages/api/tests/projects.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { NodeContext } from "@effect/platform-node"
44
import { describe, expect, it } from "@effect/vitest"
55
import { Effect } from "effect"
66

7-
import { seedAuthorizedKeysForCreate } from "../src/services/projects.js"
7+
import { ApiConflictError } from "../src/api/errors.js"
8+
import { createProjectFromRequest, seedAuthorizedKeysForCreate } from "../src/services/projects.js"
89

910
const withTempDir = <A, E, R>(
1011
use: (tempDir: string) => Effect.Effect<A, E, R>
@@ -86,4 +87,50 @@ describe("projects service", () => {
8687
expect(projectContents).toBe(`${hostKey}\n`)
8788
})
8889
).pipe(Effect.provide(NodeContext.layer)))
90+
91+
it.effect("maps duplicate docker identities to API conflict for create", () =>
92+
withTempDir((root) =>
93+
Effect.gen(function*(_) {
94+
const path = yield* _(Path.Path)
95+
const projectsRoot = path.join(root, ".docker-git")
96+
97+
yield* _(
98+
withProjectsRoot(
99+
projectsRoot,
100+
withWorkingDirectory(
101+
root,
102+
createProjectFromRequest({
103+
repoUrl: "https://git.example.test/test-owner-a/openclaw_autodeployer.git",
104+
repoRef: "main",
105+
sshPort: "2237",
106+
skipGithubAuth: true,
107+
up: false
108+
})
109+
)
110+
)
111+
)
112+
113+
const error = yield* _(
114+
withProjectsRoot(
115+
projectsRoot,
116+
withWorkingDirectory(
117+
root,
118+
createProjectFromRequest({
119+
repoUrl: "https://git.example.test/test-owner-b/openclaw_autodeployer.git",
120+
repoRef: "main",
121+
sshPort: "2238",
122+
skipGithubAuth: true,
123+
up: false
124+
}).pipe(Effect.flip)
125+
)
126+
)
127+
)
128+
129+
expect(error).toBeInstanceOf(ApiConflictError)
130+
if (error instanceof ApiConflictError) {
131+
expect(error.message).toContain("Docker identities are already owned")
132+
expect(error.message).toContain("dg-openclaw_autodeployer")
133+
}
134+
})
135+
).pipe(Effect.provide(NodeContext.layer)))
89136
})

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

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

packages/app/src/docker-git/open-project.ts

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import type { ProjectItem } from "@lib/usecases/projects"
1+
import { defaultTemplateConfig } from "@lib/core/domain"
2+
import { runDockerInspectContainerRuntimeInfo, type DockerContainerRuntimeInfo } from "@lib/shell/docker"
3+
import { buildSshCommand, connectProjectSsh, probeProjectSshReady, type ProjectItem } from "@lib/usecases/projects"
24
import { Effect, pipe } from "effect"
35

46
import type { OpenCommand } from "@lib/core/domain"
@@ -12,9 +14,16 @@ import { resolveApiProjectItem } from "./project-item.js"
1214

1315
type OpenResolvedProjectSshDeps<E, R> = {
1416
readonly log: (message: string) => Effect.Effect<void, E, R>
17+
readonly resolvePreferredItem: (item: ProjectItem) => Effect.Effect<ProjectItem | null, E, R>
18+
readonly probeReady: (item: ProjectItem) => Effect.Effect<boolean, E, R>
19+
readonly connect: (item: ProjectItem) => Effect.Effect<void, E, R>
1520
readonly connectWithUp: (item: ProjectItem) => Effect.Effect<void, E, R>
1621
}
1722

23+
type ResolveOpenProjectDeps<E, R> = {
24+
readonly inspectRuntime: (containerName: string) => Effect.Effect<DockerContainerRuntimeInfo | null, E, R>
25+
}
26+
1827
const normalizeText = (value: string): string => value.trim().toLowerCase()
1928

2029
const normalizePath = (value: string): string => {
@@ -96,6 +105,36 @@ const preferSingleRunning = (
96105
return running.length === 1 ? (running[0] ?? null) : null
97106
}
98107

108+
const exactRuntimeMatches = (
109+
selector: string,
110+
projects: ReadonlyArray<ApiProjectDetails>
111+
): ReadonlyArray<ApiProjectDetails> => {
112+
const normalizedSelector = normalizeText(selector)
113+
if (normalizedSelector.length === 0) {
114+
return []
115+
}
116+
return projects.filter((project) =>
117+
normalizedSelector === normalizeText(project.containerName) ||
118+
normalizedSelector === normalizeText(project.serviceName)
119+
)
120+
}
121+
122+
const resolvesExactRuntimeSelector = (
123+
selector: string,
124+
projects: ReadonlyArray<ApiProjectDetails>
125+
): ApiProjectDetails | null => {
126+
if (projects.length === 0) {
127+
return null
128+
}
129+
130+
const matches = exactRuntimeMatches(selector, projects)
131+
if (matches.length !== projects.length) {
132+
return null
133+
}
134+
135+
return preferSingleRunning(matches) ?? matches[0] ?? null
136+
}
137+
99138
const resolveUniqueProject = (
100139
matches: ReadonlyArray<ApiProjectDetails>,
101140
notFoundMessage: string,
@@ -162,6 +201,11 @@ export const selectOpenProject = (
162201
)
163202

164203
if (directMatches.length > 0) {
204+
const exactRuntimeMatch = resolvesExactRuntimeSelector(trimmed, directMatches)
205+
if (exactRuntimeMatch !== null) {
206+
return Effect.succeed(exactRuntimeMatch)
207+
}
208+
165209
return resolveUniqueProject(
166210
directMatches,
167211
`No docker-git project matched '${trimmed}'.`,
@@ -177,6 +221,45 @@ export const selectOpenProject = (
177221
)
178222
}
179223

224+
const uniqueContainerNames = (projects: ReadonlyArray<ApiProjectDetails>): ReadonlyArray<string> =>
225+
Array.from(new Set(projects.map((project) => project.containerName)))
226+
227+
export const resolveRuntimeOwnedProject = <E, R>(
228+
projects: ReadonlyArray<ApiProjectDetails>,
229+
selector: string | undefined,
230+
deps: ResolveOpenProjectDeps<E, R>
231+
): Effect.Effect<ApiProjectDetails | null, E, R> =>
232+
Effect.gen(function*(_) {
233+
const trimmed = selector?.trim() ?? ""
234+
const matches = exactRuntimeMatches(trimmed, projects)
235+
if (matches.length === 0) {
236+
return null
237+
}
238+
239+
for (const containerName of uniqueContainerNames(matches)) {
240+
const runtime = yield* _(deps.inspectRuntime(containerName))
241+
const ownerDir = runtime?.projectWorkingDir
242+
if (ownerDir === undefined) {
243+
continue
244+
}
245+
const owner = matches.find((project) => normalizePath(project.projectDir) === normalizePath(ownerDir))
246+
if (owner !== undefined) {
247+
return owner
248+
}
249+
}
250+
251+
return null
252+
})
253+
254+
export const resolveOpenProjectEffect = <E, R>(
255+
projects: ReadonlyArray<ApiProjectDetails>,
256+
selector: string | undefined,
257+
deps: ResolveOpenProjectDeps<E, R>
258+
): Effect.Effect<ApiProjectDetails, ProjectResolutionError | E, R> =>
259+
resolveRuntimeOwnedProject(projects, selector, deps).pipe(
260+
Effect.flatMap((ownedProject) => ownedProject === null ? selectOpenProject(projects, selector) : Effect.succeed(ownedProject))
261+
)
262+
180263
const listProjectDetails = () =>
181264
Effect.gen(function*(_) {
182265
const summaries = yield* _(listProjects())
@@ -190,20 +273,96 @@ const listProjectDetails = () =>
190273
return details.filter((project): project is ApiProjectDetails => project !== null)
191274
})
192275

276+
const withProjectItemIpAddress = (
277+
item: ProjectItem,
278+
ipAddress: string
279+
): ProjectItem => ({
280+
...item,
281+
ipAddress,
282+
sshCommand: buildSshCommand(
283+
{
284+
...defaultTemplateConfig,
285+
containerName: item.containerName,
286+
serviceName: item.serviceName,
287+
sshUser: item.sshUser,
288+
sshPort: item.sshPort,
289+
repoUrl: item.repoUrl,
290+
repoRef: item.repoRef,
291+
targetDir: item.targetDir,
292+
envGlobalPath: item.envGlobalPath,
293+
envProjectPath: item.envProjectPath,
294+
codexAuthPath: item.codexAuthPath,
295+
codexSharedAuthPath: item.codexAuthPath,
296+
codexHome: item.codexHome,
297+
clonedOnHostname: item.clonedOnHostname
298+
},
299+
item.sshKeyPath,
300+
ipAddress
301+
)
302+
})
303+
304+
const sameConnectionTarget = (left: ProjectItem, right: ProjectItem): boolean =>
305+
left.ipAddress === right.ipAddress &&
306+
left.sshPort === right.sshPort &&
307+
left.sshKeyPath === right.sshKeyPath &&
308+
left.sshUser === right.sshUser
309+
310+
const attemptDirectConnect = <E, R>(
311+
item: ProjectItem,
312+
deps: Pick<OpenResolvedProjectSshDeps<E, R>, "connect" | "log" | "probeReady">
313+
): Effect.Effect<boolean, E, R> =>
314+
deps.probeReady(item).pipe(
315+
Effect.flatMap((ready) =>
316+
ready
317+
? pipe(
318+
deps.log(`Opening SSH: ${item.sshCommand}`),
319+
Effect.zipRight(deps.connect(item)),
320+
Effect.as(true)
321+
)
322+
: Effect.succeed(false)
323+
)
324+
)
325+
193326
export const openResolvedProjectSshEffect = <E, R>(
194327
item: ProjectItem,
195328
deps: OpenResolvedProjectSshDeps<E, R>
196329
) =>
197-
pipe(
198-
deps.log(`Opening SSH: ${item.sshCommand}`),
199-
Effect.zipRight(deps.connectWithUp(item))
200-
)
330+
Effect.gen(function*(_) {
331+
const preferredItem = yield* _(deps.resolvePreferredItem(item))
332+
if (preferredItem !== null) {
333+
const connected = yield* _(attemptDirectConnect(preferredItem, deps))
334+
if (connected) {
335+
return
336+
}
337+
}
338+
339+
const shouldRetryOriginal = preferredItem === null || !sameConnectionTarget(preferredItem, item)
340+
if (shouldRetryOriginal) {
341+
const connected = yield* _(attemptDirectConnect(item, deps))
342+
if (connected) {
343+
return
344+
}
345+
}
346+
347+
yield* _(deps.log(`Opening SSH: ${item.sshCommand}`))
348+
yield* _(deps.connectWithUp(item))
349+
})
201350

202351
export const openResolvedProjectSsh = (
203352
item: ProjectItem
204353
) =>
205354
openResolvedProjectSshEffect(item, {
206355
log: (message) => Effect.log(message),
356+
resolvePreferredItem: (selected) =>
357+
runDockerInspectContainerRuntimeInfo(process.cwd(), selected.containerName).pipe(
358+
Effect.map((runtime) =>
359+
runtime !== null && runtime.ipAddress.length > 0
360+
? withProjectItemIpAddress(selected, runtime.ipAddress)
361+
: null
362+
)
363+
),
364+
probeReady: (selected) => probeProjectSshReady(selected),
365+
connect: (selected) => connectProjectSsh(selected),
207366
connectWithUp: (selected) => connectMenuProjectSshWithUp(selected)
208367
})
209368

@@ -212,7 +371,12 @@ export const openExistingProjectSsh = (
212371
) =>
213372
Effect.gen(function*(_) {
214373
const projects = yield* _(listProjectDetails())
215-
const project = yield* _(selectOpenProject(projects, command.projectDir ?? command.projectRef))
374+
const selector = command.projectDir ?? command.projectRef
375+
const project = yield* _(
376+
resolveOpenProjectEffect(projects, selector, {
377+
inspectRuntime: (containerName) => runDockerInspectContainerRuntimeInfo(process.cwd(), containerName)
378+
})
379+
)
216380
const item = yield* _(resolveApiProjectItem(project))
217381
yield* _(openResolvedProjectSsh(item))
218382
})

0 commit comments

Comments
 (0)