Skip to content

Commit 789a128

Browse files
committed
fix(app): keep docker inspect failures visible
1 parent 40b718a commit 789a128

4 files changed

Lines changed: 502 additions & 40 deletions

File tree

packages/app/src/docker-git/controller-docker.ts

Lines changed: 216 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
23
import type * as FileSystem from "@effect/platform/FileSystem"
34
import type * as Path from "@effect/platform/Path"
45
import { Effect } from "effect"
56

67
import { composeFilesForMode, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js"
78
import {
8-
runCommandCapture,
9+
runCommandCaptureWithFailureOutput,
910
runCommandExitCode,
1011
runCommandExitCodeStreaming,
1112
runCommandWithCapturedOutput
@@ -146,43 +147,235 @@ const formatDockerInvocationFailure = (
146147
`Exit code: ${exitCode}`
147148
].join("\n")
148149

150+
// CHANGE: include captured Docker output in command failure diagnostics
151+
// WHY: callers need typed errors that can distinguish missing images from Docker access failures
152+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
153+
// REF: CodeRabbit PR #344 review 4349265315
154+
// SOURCE: n/a
155+
// FORMAT THEOREM: output = "" -> base_message; output != "" -> base_message + output
156+
// PURITY: CORE
157+
// EFFECT: n/a
158+
// INVARIANT: the original headline, invocation and exit code are always preserved
159+
// COMPLEXITY: O(n) where n = |output|
160+
/**
161+
* Formats Docker command failure diagnostics with optional captured output.
162+
*
163+
* @param headline - Human-readable failure headline.
164+
* @param invocation - Resolved Docker command invocation.
165+
* @param exitCode - Process exit code.
166+
* @param output - Combined stdout/stderr captured from the process.
167+
* @returns Stable multi-line diagnostic message.
168+
*
169+
* @pure true
170+
* @effect n/a
171+
* @invariant Empty output does not add an output section.
172+
* @precondition `headline` is non-empty and `exitCode` is the process exit code.
173+
* @postcondition The returned message preserves the command and exit code.
174+
* @complexity O(n) time and O(n) space where n = |output|.
175+
* @throws Never
176+
*/
177+
const formatDockerInvocationFailureWithOutput = (
178+
headline: string,
179+
invocation: DockerInvocation,
180+
exitCode: number,
181+
output: string
182+
): string =>
183+
[
184+
formatDockerInvocationFailure(headline, invocation, exitCode),
185+
output.trim().length > 0 ? `Output:\n${output.trim()}` : ""
186+
].filter((part) => part.length > 0).join("\n")
187+
188+
// CHANGE: share Docker command resolution between exit-code and capture paths
189+
// WHY: all controller Docker operations must use the same direct/sudo resolution and argument composition
190+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
191+
// REF: CodeRabbit PR #344 review 4349265315
192+
// SOURCE: n/a
193+
// FORMAT THEOREM: resolve(args) = build(resolveDockerCommand(), args)
194+
// PURITY: SHELL
195+
// EFFECT: Effect<DockerInvocation, ControllerBootstrapError, ControllerRuntime>
196+
// INVARIANT: returned invocation always has a concrete command and immutable args
197+
// COMPLEXITY: O(|args|)
198+
/**
199+
* Resolves the Docker executable and composes it with operation arguments.
200+
*
201+
* @param args - Docker CLI arguments after the executable.
202+
* @returns Effect containing the concrete command invocation.
203+
*
204+
* @pure false
205+
* @effect CommandExecutor through Docker probing.
206+
* @invariant Invocation command defaults to `docker` only when the resolved command list is empty.
207+
* @precondition `args` is a finite argument vector.
208+
* @postcondition Sudo/direct Docker probing errors remain typed `ControllerBootstrapError` failures.
209+
* @complexity O(n) time and O(n) space where n = |args|.
210+
* @throws Never - all failures are represented in the Effect error channel.
211+
*/
212+
const resolveDockerInvocation = (
213+
args: ReadonlyArray<string>
214+
): Effect.Effect<DockerInvocation, ControllerBootstrapError, ControllerRuntime> =>
215+
resolveDockerCommand().pipe(
216+
Effect.map((dockerCommand) => buildDockerInvocation(dockerCommand, args))
217+
)
218+
149219
const runDockerExitCodeCommand = (
150220
args: ReadonlyArray<string>
151221
): Effect.Effect<number, ControllerBootstrapError, ControllerRuntime> =>
152-
Effect.gen(function*(_) {
153-
const dockerCommand = yield* _(resolveDockerCommand())
154-
const invocation = buildDockerInvocation(dockerCommand, args)
155-
return yield* _(runExitCode(invocation.command, invocation.args))
156-
})
222+
resolveDockerInvocation(args).pipe(
223+
Effect.flatMap((invocation) => runExitCode(invocation.command, invocation.args))
224+
)
157225

158-
export const runDockerCapture = (
226+
// CHANGE: preserve typed Docker capture errors while normalizing platform failures
227+
// WHY: callers must see daemon/socket diagnostics instead of nullable fallback for infrastructure failures
228+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
229+
// REF: CodeRabbit PR #344 review 4349265315
230+
// SOURCE: n/a
231+
// FORMAT THEOREM: ControllerBootstrapError -> same; PlatformError -> ControllerBootstrapError(label, details)
232+
// PURITY: CORE
233+
// EFFECT: n/a
234+
// INVARIANT: existing ControllerBootstrapError messages are preserved exactly
235+
// COMPLEXITY: O(|error|)
236+
/**
237+
* Builds a mapper from command runner errors into controller bootstrap errors.
238+
*
239+
* @param label - Operation label used for platform error diagnostics.
240+
* @returns A total error mapper for Docker capture effects.
241+
*
242+
* @pure true
243+
* @effect n/a
244+
* @invariant Existing `ControllerBootstrapError` values are returned unchanged.
245+
* @precondition `label` is finite human-readable text.
246+
* @postcondition Non-controller platform errors include the operation label and original details.
247+
* @complexity O(n) where n = |error string|.
248+
* @throws Never
249+
*/
250+
const mapDockerCaptureError =
251+
(label: string) => (error: ControllerBootstrapError | PlatformError): ControllerBootstrapError =>
252+
error._tag === "ControllerBootstrapError"
253+
? error
254+
: controllerBootstrapError(`${label} failed.\nDetails: ${String(error)}`)
255+
256+
// CHANGE: choose whether a Docker capture failure includes process output
257+
// WHY: regular callers keep stable messages, while image inspection needs output for missing-image classification
258+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
259+
// REF: CodeRabbit PR #344 review 4349265315
260+
// SOURCE: n/a
261+
// FORMAT THEOREM: includeOutput -> failure_with_output; !includeOutput -> base_failure
262+
// PURITY: CORE
263+
// EFFECT: n/a
264+
// INVARIANT: both modes preserve headline, command and exit code
265+
// COMPLEXITY: O(|output|)
266+
/**
267+
* Formats a Docker capture failure according to the selected diagnostic mode.
268+
*
269+
* @param label - Operation label.
270+
* @param invocation - Resolved Docker invocation.
271+
* @param exitCode - Process exit code.
272+
* @param output - Combined stdout/stderr from the process.
273+
* @param includeOutput - Whether the message should include captured process output.
274+
* @returns Stable Docker failure message.
275+
*
276+
* @pure true
277+
* @effect n/a
278+
* @invariant Base diagnostics always include command and exit code.
279+
* @precondition `exitCode` is the observed process exit code.
280+
* @postcondition Captured output appears only when `includeOutput` is true and output is non-empty.
281+
* @complexity O(n) where n = |output|.
282+
* @throws Never
283+
*/
284+
const formatDockerCaptureFailure = (
285+
label: string,
286+
invocation: DockerInvocation,
287+
exitCode: number,
288+
output: string,
289+
includeOutput: boolean
290+
): string =>
291+
includeOutput
292+
? formatDockerInvocationFailureWithOutput(`${label} failed.`, invocation, exitCode, output)
293+
: formatDockerInvocationFailure(`${label} failed.`, invocation, exitCode)
294+
295+
// CHANGE: centralize Docker capture execution for regular and diagnostic modes
296+
// WHY: selective recovery must not duplicate Docker probing, invocation building, or platform error mapping
297+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
298+
// REF: CodeRabbit PR #344 review 4349265315
299+
// SOURCE: n/a
300+
// FORMAT THEOREM: docker_exit=0 -> stdout; docker_exit!=0 -> ControllerBootstrapError(mode)
301+
// PURITY: SHELL
302+
// EFFECT: Effect<string, ControllerBootstrapError, ControllerRuntime>
303+
// INVARIANT: no Docker capture failure is converted to success
304+
// COMPLEXITY: O(command_output)
305+
/**
306+
* Runs a Docker command and maps non-zero exits through the selected output mode.
307+
*
308+
* @param args - Docker CLI arguments after the executable.
309+
* @param label - Operation label used in diagnostics.
310+
* @param includeOutput - Whether non-zero exit diagnostics include captured stdout/stderr.
311+
* @returns Effect containing stdout on success.
312+
*
313+
* @pure false
314+
* @effect CommandExecutor, FileSystem, Path through ControllerRuntime.
315+
* @invariant Docker probing and command execution failures stay in the typed error channel.
316+
* @precondition `args` is finite and `label` is non-empty.
317+
* @postcondition Success implies Docker exited with code 0.
318+
* @complexity O(n) time and O(n) space where n is captured output size.
319+
* @throws Never - all failures are represented in the Effect error channel.
320+
*/
321+
const runDockerCaptureWithOutputMode = (
159322
args: ReadonlyArray<string>,
160-
label: string
323+
label: string,
324+
includeOutput: boolean
161325
): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
162-
Effect.gen(function*(_) {
163-
const dockerCommand = yield* _(resolveDockerCommand())
164-
const invocation = buildDockerInvocation(dockerCommand, args)
165-
const output = yield* _(
166-
runCommandCapture(
326+
resolveDockerInvocation(args).pipe(
327+
Effect.flatMap((invocation) =>
328+
runCommandCaptureWithFailureOutput(
167329
{
168330
cwd: process.cwd(),
169331
command: invocation.command,
170332
args: invocation.args
171333
},
172334
[0],
173-
(exitCode) => controllerBootstrapError(formatDockerInvocationFailure(`${label} failed.`, invocation, exitCode))
335+
(exitCode, output) =>
336+
controllerBootstrapError(formatDockerCaptureFailure(label, invocation, exitCode, output, includeOutput))
174337
)
175-
)
176-
177-
return output
178-
}).pipe(
179-
Effect.mapError((error): ControllerBootstrapError =>
180-
error._tag === "ControllerBootstrapError"
181-
? error
182-
: controllerBootstrapError(`${label} failed.\nDetails: ${String(error)}`)
183-
)
338+
),
339+
Effect.mapError(mapDockerCaptureError(label))
184340
)
185341

342+
export const runDockerCapture = (
343+
args: ReadonlyArray<string>,
344+
label: string
345+
): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
346+
runDockerCaptureWithOutputMode(args, label, false)
347+
348+
// CHANGE: preserve Docker stderr/stdout diagnostics for selective error recovery
349+
// WHY: image revision inspection must fallback only for absent images while surfacing daemon/socket failures
350+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
351+
// REF: CodeRabbit PR #344 review 4349265315
352+
// SOURCE: n/a
353+
// FORMAT THEOREM: docker_exit ∉ ok -> ControllerBootstrapError(message includes output)
354+
// PURITY: SHELL
355+
// EFFECT: Effect<string, ControllerBootstrapError, ControllerRuntime>
356+
// INVARIANT: Docker access resolution errors remain ControllerBootstrapError failures
357+
// COMPLEXITY: O(command_output)
358+
/**
359+
* Runs a Docker command and includes captured stdout/stderr in the typed failure message.
360+
*
361+
* @param args - Docker CLI arguments after the resolved docker executable.
362+
* @param label - Human-readable operation label used in failure diagnostics.
363+
* @returns Effect containing stdout when Docker exits successfully.
364+
*
365+
* @pure false
366+
* @effect CommandExecutor, FileSystem, Path through ControllerRuntime.
367+
* @invariant Non-zero Docker exits are failures and preserve the combined command output.
368+
* @precondition `args` is a finite Docker argument vector and `label` is non-empty.
369+
* @postcondition Docker daemon/socket discovery errors are not converted to success.
370+
* @complexity O(n) time and O(n) space where n is captured command output.
371+
* @throws Never - all failures are represented in the Effect error channel.
372+
*/
373+
export const runDockerCaptureWithFailureOutput = (
374+
args: ReadonlyArray<string>,
375+
label: string
376+
): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
377+
runDockerCaptureWithOutputMode(args, label, true)
378+
186379
export const runCompose = (
187380
args: ReadonlyArray<string>
188381
): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>

packages/app/src/docker-git/controller-image-revision.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,40 @@
11
import { Effect } from "effect"
22

33
import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.js"
4-
import { type ControllerRuntime, runDockerCapture } from "./controller-docker.js"
4+
import { type ControllerRuntime, runDockerCapture, runDockerCaptureWithFailureOutput } from "./controller-docker.js"
55
import { parseControllerRevisionLabelOutput } from "./controller-revision.js"
66
import type { ControllerBootstrapError } from "./host-errors.js"
77

88
const inspectControllerRevisionLabelTemplate = String
99
.raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}`
10+
const missingImageInspectionPatterns: ReadonlyArray<RegExp> = [/No such image/iu, /No such object/iu]
11+
12+
/**
13+
* Detects the Docker inspect failure that means the reusable controller image is absent.
14+
*
15+
* @param error - Typed Docker bootstrap error from image inspection.
16+
* @returns True only for Docker's missing-image diagnostics.
17+
*
18+
* @pure true
19+
* @effect n/a
20+
* @invariant Daemon/socket/permission failures are not classified as missing images.
21+
* @precondition `error.message` is the captured Docker inspect diagnostic.
22+
* @postcondition True implies the caller may safely fallback to rebuilding the image.
23+
* @complexity O(n * m) where n = pattern count and m = |message|.
24+
* @throws Never
25+
*/
26+
// CHANGE: classify image-not-found separately from Docker infrastructure failures
27+
// WHY: controller bootstrap can rebuild absent images, but daemon/socket failures must stay visible
28+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
29+
// REF: CodeRabbit PR #344 review 4349265315
30+
// SOURCE: n/a
31+
// FORMAT THEOREM: missing_image(error) -> fallback_null; infrastructure_error(error) -> typed_failure
32+
// PURITY: CORE
33+
// EFFECT: n/a
34+
// INVARIANT: permission and daemon diagnostics do not satisfy the predicate
35+
// COMPLEXITY: O(n * m)
36+
const isMissingControllerImageInspectionError = (error: ControllerBootstrapError): boolean =>
37+
missingImageInspectionPatterns.some((pattern) => pattern.test(error.message))
1038

1139
/**
1240
* Returns all non-empty lines from Docker CLI output.
@@ -121,11 +149,11 @@ const inspectControllerComposeImageName = (): Effect.Effect<
121149
*
122150
* @pure false
123151
* @effect Docker CLI through ControllerRuntime.
124-
* @invariant Missing image or missing label resolves to null rather than throwing.
152+
* @invariant Missing image/label resolves to null; Docker infrastructure failures remain typed failures.
125153
* @precondition Docker is reachable through the configured runtime.
126154
* @postcondition Returned revision is normalized by label parsing.
127155
* @complexity O(1) Docker inspections.
128-
* @throws Never - failures are represented in the Effect error channel or recovered to null.
156+
* @throws Never - failures are represented in the Effect error channel or selectively recovered to null.
129157
*/
130158
// CHANGE: inspect the compose-built controller image revision label
131159
// WHY: host bootstrap can start an already-current image without forcing Docker to rebuild heavy layers
@@ -135,7 +163,7 @@ const inspectControllerComposeImageName = (): Effect.Effect<
135163
// FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required
136164
// PURITY: SHELL
137165
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
138-
// INVARIANT: missing image or missing label resolves to null rather than throwing
166+
// INVARIANT: missing image or missing label resolves to null, daemon/socket errors stay in the error channel
139167
// COMPLEXITY: O(1) Docker inspections
140168
export const inspectControllerImageRevision = (): Effect.Effect<
141169
string | null,
@@ -146,12 +174,16 @@ export const inspectControllerImageRevision = (): Effect.Effect<
146174
Effect.flatMap((imageName) =>
147175
imageName === null
148176
? Effect.succeed<string | null>(null)
149-
: runDockerCapture(
177+
: runDockerCaptureWithFailureOutput(
150178
["image", "inspect", "-f", inspectControllerRevisionLabelTemplate, imageName],
151179
`Failed to inspect image revision for ${imageName}`
152180
).pipe(
153181
Effect.map((output) => parseControllerRevisionLabelOutput(output)),
154-
Effect.orElseSucceed((): string | null => null)
182+
Effect.catchTag("ControllerBootstrapError", (error) =>
183+
isMissingControllerImageInspectionError(error)
184+
? Effect.succeed<string | null>(null)
185+
: Effect.fail(error)
186+
)
155187
)
156188
)
157189
)

0 commit comments

Comments
 (0)