|
| 1 | +import { Effect } from "effect" |
| 2 | + |
| 3 | +import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.js" |
| 4 | +import { type ControllerRuntime, runDockerCapture } from "./controller-docker.js" |
| 5 | +import { parseControllerRevisionLabelOutput } from "./controller-revision.js" |
| 6 | +import type { ControllerBootstrapError } from "./host-errors.js" |
| 7 | + |
| 8 | +const inspectControllerRevisionLabelTemplate = String |
| 9 | + .raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}` |
| 10 | + |
| 11 | +/** |
| 12 | + * Returns the first non-empty line from Docker CLI output. |
| 13 | + * |
| 14 | + * @param output - Raw command output. |
| 15 | + * @returns The first trimmed non-empty line, or null when none exists. |
| 16 | + * |
| 17 | + * @pure true |
| 18 | + * @effect n/a |
| 19 | + * @invariant Result is either null or a string with length > 0. |
| 20 | + * @precondition `output` is a finite string. |
| 21 | + * @postcondition Whitespace-only lines are ignored. |
| 22 | + * @complexity O(n) time and O(n) space where n = |output|. |
| 23 | + * @throws Never |
| 24 | + */ |
| 25 | +// CHANGE: normalize compose image output before image inspection |
| 26 | +// WHY: docker compose config --images emits line-oriented output and bootstrap needs one image name proof |
| 27 | +// QUOTE(ТЗ): "контейнер собирается минут 5-6" |
| 28 | +// REF: user-request-2026-05-22-controller-build-speed |
| 29 | +// SOURCE: n/a |
| 30 | +// FORMAT THEOREM: exists first non-empty line -> result = trim(first) |
| 31 | +// PURITY: CORE |
| 32 | +// EFFECT: n/a |
| 33 | +// INVARIANT: result is null or non-empty |
| 34 | +// COMPLEXITY: O(n) |
| 35 | +const firstNonEmptyLine = (output: string): string | null => { |
| 36 | + for (const line of output.split(/\r?\n/u)) { |
| 37 | + const trimmed = line.trim() |
| 38 | + if (trimmed.length > 0) { |
| 39 | + return trimmed |
| 40 | + } |
| 41 | + } |
| 42 | + return null |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Resolves the Docker image name configured for the active controller compose files. |
| 47 | + * |
| 48 | + * @returns The first compose image name, or null when compose emits no images. |
| 49 | + * |
| 50 | + * @pure false |
| 51 | + * @effect Docker CLI through ControllerRuntime. |
| 52 | + * @invariant Empty compose output is represented as null. |
| 53 | + * @precondition Compose files resolve for the current GPU mode. |
| 54 | + * @postcondition Returned image name is trimmed and non-empty. |
| 55 | + * @complexity O(1) compose invocations. |
| 56 | + * @throws Never - failures are represented in the Effect error channel. |
| 57 | + */ |
| 58 | +// CHANGE: resolve the compose-built controller image before comparing revisions |
| 59 | +// WHY: avoiding --build is sound only when the selected image already carries the local revision label |
| 60 | +// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" |
| 61 | +// REF: user-request-2026-05-22-controller-build-speed |
| 62 | +// SOURCE: n/a |
| 63 | +// FORMAT THEOREM: compose_image = null -> image_revision = null |
| 64 | +// PURITY: SHELL |
| 65 | +// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime> |
| 66 | +// INVARIANT: no image name is treated as missing revision proof |
| 67 | +// COMPLEXITY: O(1) Docker compose invocations |
| 68 | +const inspectControllerComposeImageName = (): Effect.Effect< |
| 69 | + string | null, |
| 70 | + ControllerBootstrapError, |
| 71 | + ControllerRuntime |
| 72 | +> => |
| 73 | + Effect.gen(function*(_) { |
| 74 | + const composeFiles = yield* _(resolveControllerComposeFiles()) |
| 75 | + const output = yield* _( |
| 76 | + runDockerCapture( |
| 77 | + [ |
| 78 | + "compose", |
| 79 | + ...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath), |
| 80 | + "config", |
| 81 | + "--images" |
| 82 | + ], |
| 83 | + "Failed to resolve docker-git controller image" |
| 84 | + ) |
| 85 | + ) |
| 86 | + |
| 87 | + return firstNonEmptyLine(output) |
| 88 | + }) |
| 89 | + |
| 90 | +/** |
| 91 | + * Reads the revision label from the image resolved by the active compose files. |
| 92 | + * |
| 93 | + * @returns Current image revision, or null when the image/label is missing. |
| 94 | + * |
| 95 | + * @pure false |
| 96 | + * @effect Docker CLI through ControllerRuntime. |
| 97 | + * @invariant Missing image or missing label resolves to null rather than throwing. |
| 98 | + * @precondition Docker is reachable through the configured runtime. |
| 99 | + * @postcondition Returned revision is normalized by label parsing. |
| 100 | + * @complexity O(1) Docker inspections. |
| 101 | + * @throws Never - failures are represented in the Effect error channel or recovered to null. |
| 102 | + */ |
| 103 | +// CHANGE: inspect the compose-built controller image revision label |
| 104 | +// WHY: host bootstrap can start an already-current image without forcing Docker to rebuild heavy layers |
| 105 | +// QUOTE(ТЗ): "контейнер собирается минут 5-6" |
| 106 | +// REF: user-request-2026-05-22-controller-build-speed |
| 107 | +// SOURCE: n/a |
| 108 | +// FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required |
| 109 | +// PURITY: SHELL |
| 110 | +// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime> |
| 111 | +// INVARIANT: missing image or missing label resolves to null rather than throwing |
| 112 | +// COMPLEXITY: O(1) Docker inspections |
| 113 | +export const inspectControllerImageRevision = (): Effect.Effect< |
| 114 | + string | null, |
| 115 | + ControllerBootstrapError, |
| 116 | + ControllerRuntime |
| 117 | +> => |
| 118 | + inspectControllerComposeImageName().pipe( |
| 119 | + Effect.flatMap((imageName) => |
| 120 | + imageName === null |
| 121 | + ? Effect.succeed<string | null>(null) |
| 122 | + : runDockerCapture( |
| 123 | + ["image", "inspect", "-f", inspectControllerRevisionLabelTemplate, imageName], |
| 124 | + `Failed to inspect image revision for ${imageName}` |
| 125 | + ).pipe( |
| 126 | + Effect.map((output) => parseControllerRevisionLabelOutput(output)), |
| 127 | + Effect.orElseSucceed((): string | null => null) |
| 128 | + ) |
| 129 | + ) |
| 130 | + ) |
0 commit comments