Skip to content

Commit 40b718a

Browse files
committed
fix(app): preserve controller image inspection errors
1 parent e635acd commit 40b718a

2 files changed

Lines changed: 74 additions & 77 deletions

File tree

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

Lines changed: 15 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,6 @@ import type { ControllerBootstrapError } from "./host-errors.js"
88
const inspectControllerRevisionLabelTemplate = String
99
.raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}`
1010

11-
/**
12-
* Builds a typed controller bootstrap error.
13-
*
14-
* @param message - Human-readable bootstrap failure message.
15-
* @returns Controller bootstrap error value.
16-
*
17-
* @pure true
18-
* @effect n/a
19-
* @invariant Returned error tag is always `ControllerBootstrapError`.
20-
* @precondition `message` is a finite string.
21-
* @postcondition The returned error preserves the provided message.
22-
* @complexity O(1) time and O(1) space.
23-
* @throws Never
24-
*/
25-
// CHANGE: represent deterministic image-resolution failures as typed bootstrap errors
26-
// WHY: ambiguous compose image output must fail through the Effect error channel
27-
// QUOTE(ТЗ): "хочу сузить время билда докер контейнера"
28-
// REF: user-request-2026-05-22-controller-build-speed
29-
// SOURCE: n/a
30-
// FORMAT THEOREM: error(message).message = message
31-
// PURITY: CORE
32-
// EFFECT: n/a
33-
// INVARIANT: error tag is stable
34-
// COMPLEXITY: O(1)
35-
const controllerBootstrapError = (message: string): ControllerBootstrapError => ({
36-
_tag: "ControllerBootstrapError",
37-
message
38-
})
39-
4011
/**
4112
* Returns all non-empty lines from Docker CLI output.
4213
*
@@ -72,56 +43,40 @@ const nonEmptyLines = (output: string): ReadonlyArray<string> => {
7243
* Resolves compose image output into exactly one controller image name.
7344
*
7445
* @param output - Raw `docker compose config --images` output.
75-
* @returns Effect with the single image, null for empty output, or a typed bootstrap error for ambiguity.
46+
* @returns The single image, or null for empty/ambiguous output.
7647
*
7748
* @pure true
78-
* @effect Effect.succeed | Effect.fail
79-
* @invariant More than one non-empty line is rejected as ambiguous.
49+
* @effect n/a
50+
* @invariant More than one non-empty line never collapses to the first image.
8051
* @precondition `output` is finite Docker CLI output.
8152
* @postcondition Success with a string implies exactly one non-empty image line existed.
8253
* @complexity O(n) time and O(n) space where n = |output|.
83-
* @throws Never - ambiguity is represented in the Effect error channel.
54+
* @throws Never
8455
*/
8556
// CHANGE: require deterministic controller image resolution from compose output
8657
// WHY: revision reuse is sound only when the inspected image is uniquely the controller image
8758
// QUOTE(ТЗ): "хочу сузить время билда докер контейнера"
8859
// REF: user-request-2026-05-22-controller-build-speed
8960
// SOURCE: n/a
90-
// FORMAT THEOREM: |images| = 0 -> null, |images| = 1 -> images[0], |images| > 1 -> error
61+
// FORMAT THEOREM: |images| = 1 -> images[0], otherwise null
9162
// PURITY: CORE
92-
// EFFECT: Effect<string | null, ControllerBootstrapError>
63+
// EFFECT: n/a
9364
// INVARIANT: multiple compose images never collapse to the first image
9465
// COMPLEXITY: O(n) where n = |output|
95-
const resolveSingleControllerImageName = (
96-
output: string
97-
): Effect.Effect<string | null, ControllerBootstrapError> => {
66+
const resolveSingleControllerImageName = (output: string): string | null => {
9867
const imageNames = nonEmptyLines(output)
99-
if (imageNames.length === 0) {
100-
return Effect.succeed(null)
101-
}
10268
const imageName = imageNames[0]
103-
if (imageNames.length === 1 && imageName !== undefined) {
104-
return Effect.succeed(imageName)
105-
}
106-
return Effect.fail(
107-
controllerBootstrapError(
108-
[
109-
"Expected exactly one docker-git controller image from docker compose config --images.",
110-
"Resolved images:",
111-
...imageNames.map((name) => `- ${name}`)
112-
].join("\n")
113-
)
114-
)
69+
return imageNames.length === 1 && imageName !== undefined ? imageName : null
11570
}
11671

11772
/**
11873
* Resolves the Docker image name configured for the active controller compose files.
11974
*
120-
* @returns The single compose image name, or null when compose emits no images.
75+
* @returns The single compose image name, or null when compose emits zero or multiple images.
12176
*
12277
* @pure false
12378
* @effect Docker CLI through ControllerRuntime.
124-
* @invariant Multiple compose images fail rather than selecting the first line.
79+
* @invariant Multiple compose images return null rather than selecting the first line.
12580
* @precondition Compose files resolve for the current GPU mode.
12681
* @postcondition Returned image name is trimmed and non-empty.
12782
* @complexity O(1) compose invocations.
@@ -132,10 +87,10 @@ const resolveSingleControllerImageName = (
13287
// QUOTE(ТЗ): "хочу сузить время билда докер контейнера"
13388
// REF: user-request-2026-05-22-controller-build-speed
13489
// SOURCE: n/a
135-
// FORMAT THEOREM: |compose_images| <= 1 or bootstrap fails
90+
// FORMAT THEOREM: |compose_images| = 1 -> image name, otherwise null
13691
// PURITY: SHELL
13792
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
138-
// INVARIANT: ambiguous image lists are typed bootstrap errors
93+
// INVARIANT: ambiguous image lists are not treated as reusable images
13994
// COMPLEXITY: O(1) Docker compose invocations
14095
const inspectControllerComposeImageName = (): Effect.Effect<
14196
string | null,
@@ -156,7 +111,7 @@ const inspectControllerComposeImageName = (): Effect.Effect<
156111
)
157112
)
158113

159-
return yield* _(resolveSingleControllerImageName(output))
114+
return resolveSingleControllerImageName(output)
160115
})
161116

162117
/**
@@ -166,7 +121,7 @@ const inspectControllerComposeImageName = (): Effect.Effect<
166121
*
167122
* @pure false
168123
* @effect Docker CLI through ControllerRuntime.
169-
* @invariant Missing or ambiguous compose image output resolves to null rather than throwing.
124+
* @invariant Missing image or missing label resolves to null rather than throwing.
170125
* @precondition Docker is reachable through the configured runtime.
171126
* @postcondition Returned revision is normalized by label parsing.
172127
* @complexity O(1) Docker inspections.
@@ -180,15 +135,14 @@ const inspectControllerComposeImageName = (): Effect.Effect<
180135
// FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required
181136
// PURITY: SHELL
182137
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
183-
// INVARIANT: missing or unresolvable image metadata resolves to null rather than throwing
138+
// INVARIANT: missing image or missing label resolves to null rather than throwing
184139
// COMPLEXITY: O(1) Docker inspections
185140
export const inspectControllerImageRevision = (): Effect.Effect<
186141
string | null,
187142
ControllerBootstrapError,
188143
ControllerRuntime
189144
> =>
190145
inspectControllerComposeImageName().pipe(
191-
Effect.orElseSucceed((): string | null => null),
192146
Effect.flatMap((imageName) =>
193147
imageName === null
194148
? Effect.succeed<string | null>(null)

packages/app/tests/docker-git/controller-image-revision.test.ts

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Effect, Layer } from "effect"
77
import * as Inspectable from "effect/Inspectable"
88
import * as Sink from "effect/Sink"
99
import * as Stream from "effect/Stream"
10+
import * as fc from "fast-check"
1011

1112
import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js"
1213

@@ -21,6 +22,15 @@ const emptyCommandResult: TestCommandResult = {
2122
stderr: "",
2223
stdout: ""
2324
}
25+
const composeImageLineArbitrary = fc
26+
.string({ minLength: 1 })
27+
.filter((value) => value.trim().length > 0 && !value.includes("\n") && !value.includes("\r"))
28+
const nonReusableComposeImagesOutputArbitrary = fc.oneof(
29+
fc.constantFrom("", "\n", " \n\t\n"),
30+
fc.array(composeImageLineArbitrary, { maxLength: 8, minLength: 2 }).map((lines) =>
31+
lines.map((line) => ` ${line} `).join("\n")
32+
)
33+
)
2434

2535
const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value)
2636

@@ -97,23 +107,56 @@ const commandExecutorLayer = (handler: TestCommandHandler) =>
97107
})
98108
)
99109

100-
describe("controller image revision", () => {
101-
it.effect("falls back to null when compose image resolution is ambiguous", () =>
102-
Effect.gen(function*(_) {
103-
const revision = yield* _(
104-
inspectControllerImageRevision().pipe(
105-
Effect.provide(
106-
commandExecutorLayer((command) =>
107-
command.command === "docker" && command.args.includes("--images")
108-
? { exitCode: 0, stderr: "", stdout: "app-api:latest\nanother-image:latest\n" }
109-
: emptyCommandResult
110-
)
111-
),
112-
Effect.provide(FileSystem.layerNoop({})),
113-
Effect.provide(Path.layer)
114-
)
110+
/**
111+
* Runs image revision inspection with controlled `docker compose config --images` output.
112+
*
113+
* @param composeImagesOutput - Stdout emitted by the fake `--images` command.
114+
* @returns Effect producing the inspected image revision.
115+
* @pure false
116+
* @effect CommandExecutor, FileSystem, Path
117+
* @invariant Docker commands are served by the in-memory command executor.
118+
* @precondition `composeImagesOutput` is finite text.
119+
* @postcondition The real Docker daemon is never invoked.
120+
* @complexity O(n) time and space where n = |composeImagesOutput|.
121+
* @throws Never - all command failures are represented in the Effect error channel.
122+
*/
123+
// CHANGE: centralize the mocked compose image inspection path for property tests
124+
// WHY: the fallback invariant depends only on normalized compose stdout cardinality
125+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
126+
// REF: CodeRabbit PR #344 review 4349246446
127+
// SOURCE: n/a
128+
// FORMAT THEOREM: output -> inspectControllerImageRevision(output)
129+
// PURITY: SHELL
130+
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
131+
// INVARIANT: Docker command output is supplied by the test harness
132+
// COMPLEXITY: O(n)
133+
const inspectRevisionWithComposeImagesOutput = (composeImagesOutput: string) =>
134+
inspectControllerImageRevision().pipe(
135+
Effect.provide(
136+
commandExecutorLayer((command) =>
137+
command.command === "docker" && command.args.includes("--images")
138+
? { exitCode: 0, stderr: "", stdout: composeImagesOutput }
139+
: emptyCommandResult
115140
)
141+
),
142+
Effect.provide(FileSystem.layerNoop({})),
143+
Effect.provide(Path.layer)
144+
)
116145

117-
expect(revision).toBeNull()
146+
describe("controller image revision", () => {
147+
it.effect("falls back to null for non-reusable compose image output cardinalities", () =>
148+
Effect.tryPromise({
149+
catch: (cause) => cause,
150+
try: () =>
151+
fc.assert(
152+
fc.asyncProperty(nonReusableComposeImagesOutputArbitrary, (composeImagesOutput) =>
153+
Effect.runPromise(
154+
Effect.gen(function*(_) {
155+
const revision = yield* _(inspectRevisionWithComposeImagesOutput(composeImagesOutput))
156+
expect(revision).toBeNull()
157+
})
158+
)),
159+
{ numRuns: 50 }
160+
)
118161
}))
119162
})

0 commit comments

Comments
 (0)