From feeb85a776901b442487586d215364c881e8907b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 14:26:54 +0000 Subject: [PATCH 01/12] perf(api): speed up controller Docker rebuilds --- .dockerignore | 14 ++ .github/workflows/check.yml | 12 ++ docker-compose.api.yml | 3 + docker-compose.yml | 1 + packages/api/Dockerfile | 114 +++++++++++---- .../docker-git/controller-bootstrap-plan.ts | 76 ++++++++++ .../app/src/docker-git/controller-compose.ts | 46 +++++-- .../app/src/docker-git/controller-docker.ts | 8 +- .../docker-git/controller-image-revision.ts | 130 ++++++++++++++++++ .../app/src/docker-git/controller-revision.ts | 48 ++++++- packages/app/src/docker-git/controller.ts | 41 ++++-- .../app/tests/docker-git/controller.test.ts | 127 ++++++++++++++++- 12 files changed, 559 insertions(+), 61 deletions(-) create mode 100644 packages/app/src/docker-git/controller-bootstrap-plan.ts create mode 100644 packages/app/src/docker-git/controller-image-revision.ts diff --git a/.dockerignore b/.dockerignore index 92722160..80259aa2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,19 @@ .git +.codex +.github +.knowledge node_modules **/node_modules **/.cache +**/.turbo +coverage +dist +dist-test +dist-web +packages/*/coverage +packages/*/dist +packages/*/dist-test +packages/*/dist-web +**/.vite +**/*.log third_party/skiller-desktop-skills-manager/out diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3011af06..c91ca1c1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -153,6 +153,8 @@ jobs: name: E2E (Browser command) runs-on: ubuntu-latest timeout-minutes: 40 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" steps: - uses: actions/checkout@v6 with: @@ -170,6 +172,8 @@ jobs: name: E2E (OpenCode) runs-on: ubuntu-latest timeout-minutes: 40 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" steps: - uses: actions/checkout@v6 with: @@ -187,6 +191,8 @@ jobs: name: E2E (Clone cache) runs-on: ubuntu-latest timeout-minutes: 40 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" steps: - uses: actions/checkout@v6 with: @@ -204,6 +210,8 @@ jobs: name: E2E (Login context) runs-on: ubuntu-latest timeout-minutes: 40 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" steps: - uses: actions/checkout@v6 with: @@ -221,6 +229,8 @@ jobs: name: E2E (Runtime volumes + SSH) runs-on: ubuntu-latest timeout-minutes: 60 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" steps: - uses: actions/checkout@v6 with: @@ -238,6 +248,8 @@ jobs: name: E2E (Clone auto-open SSH) runs-on: ubuntu-latest timeout-minutes: 40 + env: + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" steps: - uses: actions/checkout@v6 with: diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 11b42eeb..78d29a25 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -4,10 +4,13 @@ services: context: . dockerfile: packages/api/Dockerfile args: + DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown} + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: ${DOCKER_GIT_CONTROLLER_BUILD_SKILLER:-1} UBUNTU_APT_MIRROR: ${UBUNTU_APT_MIRROR:-} container_name: ${DOCKER_GIT_API_CONTAINER_NAME:-docker-git-api} environment: DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334} + DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown} DOCKER_GIT_DOCKER_RUNTIME: ${DOCKER_GIT_DOCKER_RUNTIME:-isolated} DOCKER_HOST: ${DOCKER_GIT_CONTROLLER_DOCKER_HOST:-unix:///var/run/docker.sock} DOCKER_GIT_DOCKERD_TCP_HOST: ${DOCKER_GIT_DOCKERD_TCP_HOST:-tcp://0.0.0.0:2375} diff --git a/docker-compose.yml b/docker-compose.yml index f33c0434..78d29a25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: dockerfile: packages/api/Dockerfile args: DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown} + DOCKER_GIT_CONTROLLER_BUILD_SKILLER: ${DOCKER_GIT_CONTROLLER_BUILD_SKILLER:-1} UBUNTU_APT_MIRROR: ${UBUNTU_APT_MIRROR:-} container_name: ${DOCKER_GIT_API_CONTAINER_NAME:-docker-git-api} environment: diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index e983fdae..3383cff0 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -1,11 +1,8 @@ -FROM ubuntu:26.04 +FROM ubuntu:26.04 AS controller-base -ARG DOCKER_GIT_CONTROLLER_REV=unknown ARG UBUNTU_APT_MIRROR= -LABEL io.prover-coder-ai.docker-git.controller-rev=$DOCKER_GIT_CONTROLLER_REV ENV DEBIAN_FRONTEND=noninteractive -ENV DOCKER_GIT_CONTROLLER_REV=$DOCKER_GIT_CONTROLLER_REV ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:$PATH WORKDIR /workspace @@ -55,16 +52,23 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ && npm i -g node-gyp \ && rm -rf /var/lib/apt/lists/* +FROM controller-base AS workspace-deps + COPY package.json bun.lock bunfig.toml tsconfig.base.json tsconfig.json ./ -COPY patches ./patches -COPY scripts ./scripts -COPY packages ./packages -COPY .gitmodules ./.gitmodules -COPY third_party ./third_party +RUN mkdir -p packages/api packages/app packages/docker-git-session-sync packages/lib +COPY packages/api/package.json ./packages/api/package.json +COPY packages/app/package.json ./packages/app/package.json +COPY packages/docker-git-session-sync/package.json ./packages/docker-git-session-sync/package.json +COPY packages/lib/package.json ./packages/lib/package.json RUN set -eu; \ for attempt in 1 2 3 4 5; do \ - if bun install --frozen-lockfile --silent; then \ + if bun install \ + --frozen-lockfile \ + --silent \ + --filter @effect-template/api \ + --filter @effect-template/lib \ + --filter @prover-coder-ai/docker-git-session-sync; then \ exit 0; \ fi; \ echo "bun install attempt ${attempt} failed; retrying..." >&2; \ @@ -73,28 +77,78 @@ RUN set -eu; \ done; \ echo "bun install failed after retries" >&2; \ exit 1 + +FROM workspace-deps AS workspace-static + +COPY patches ./patches +COPY scripts ./scripts +COPY packages/docker-git-session-sync ./packages/docker-git-session-sync +COPY packages/lib ./packages/lib + +RUN bun run --cwd packages/docker-git-session-sync build RUN bun run --cwd packages/lib build -RUN bun run --cwd packages/api build -RUN bun scripts/skiller-apply-docker-git-patches.mjs -RUN test -f third_party/skiller-desktop-skills-manager/package.json \ - && cd third_party/skiller-desktop-skills-manager \ - && for attempt in 1 2 3 4 5; do \ - if bun install --frozen-lockfile --silent; then \ - break; \ - fi; \ - if [ "$attempt" = "5" ]; then \ - echo "skiller bun install failed after retries" >&2; \ - exit 1; \ - fi; \ - echo "skiller bun install attempt ${attempt} failed; retrying..." >&2; \ - rm -rf /root/.bun/install/cache node_modules; \ - sleep $((attempt * 2)); \ - done \ - && bun run build \ - && touch out/.docker-git-browser-folder-picker.patch \ - && mkdir -p out/preload \ - && ln -sf index.mjs out/preload/index.js +FROM controller-base AS skiller-build + +ARG DOCKER_GIT_CONTROLLER_BUILD_SKILLER=1 + +COPY patches ./patches +COPY scripts/skiller-apply-docker-git-patches.mjs ./scripts/skiller-apply-docker-git-patches.mjs +COPY .gitmodules ./.gitmodules +COPY third_party ./third_party + +RUN if [ "$DOCKER_GIT_CONTROLLER_BUILD_SKILLER" = "1" ]; then \ + bun scripts/skiller-apply-docker-git-patches.mjs; \ + else \ + echo "Skipping Skiller build for controller image."; \ + fi +RUN if [ "$DOCKER_GIT_CONTROLLER_BUILD_SKILLER" = "1" ]; then \ + test -f third_party/skiller-desktop-skills-manager/package.json \ + && cd third_party/skiller-desktop-skills-manager \ + && for attempt in 1 2 3 4 5; do \ + if bun install --frozen-lockfile --silent; then \ + break; \ + fi; \ + if [ "$attempt" = "5" ]; then \ + echo "skiller bun install failed after retries" >&2; \ + exit 1; \ + fi; \ + echo "skiller bun install attempt ${attempt} failed; retrying..." >&2; \ + rm -rf /root/.bun/install/cache node_modules; \ + sleep $((attempt * 2)); \ + done \ + && bun run build \ + && touch out/.docker-git-browser-folder-picker.patch \ + && mkdir -p out/preload \ + && ln -sf index.mjs out/preload/index.js; \ + else \ + cd third_party/skiller-desktop-skills-manager \ + && mkdir -p node_modules/electron/dist out/main out/renderer out/preload \ + && printf '%s\n' '#!/usr/bin/env sh' 'echo "Skiller is not bundled in this controller image." >&2' 'exit 1' \ + > node_modules/electron/dist/electron \ + && chmod +x node_modules/electron/dist/electron \ + && printf '%s\n' 'throw new Error("Skiller is not bundled in this controller image.")' > out/main/index.js \ + && printf '%s\n' 'Skiller unavailable' > out/renderer/index.html \ + && printf '%s\n' 'export {}' > out/preload/index.mjs \ + && touch out/.docker-git-browser-folder-picker.patch \ + && ln -sf index.mjs out/preload/index.js; \ + fi + +FROM workspace-static AS controller-final + +COPY .gitmodules ./.gitmodules +COPY --from=skiller-build /workspace/third_party/skiller-desktop-skills-manager ./third_party/skiller-desktop-skills-manager +COPY packages/api ./packages/api + +RUN ./packages/api/node_modules/.bin/tsc -p packages/api/tsconfig.json + +ARG DOCKER_GIT_CONTROLLER_REV=unknown +ARG DOCKER_GIT_CONTROLLER_BUILD_SKILLER=1 +LABEL io.prover-coder-ai.docker-git.controller-rev=$DOCKER_GIT_CONTROLLER_REV +LABEL io.prover-coder-ai.docker-git.controller-build-skiller=$DOCKER_GIT_CONTROLLER_BUILD_SKILLER + +ENV DOCKER_GIT_CONTROLLER_REV=$DOCKER_GIT_CONTROLLER_REV +ENV DOCKER_GIT_CONTROLLER_BUILD_SKILLER=$DOCKER_GIT_CONTROLLER_BUILD_SKILLER ENV DOCKER_GIT_API_PORT=3334 ENV DOCKER_GIT_DOCKER_RUNTIME=isolated ENV DOCKER_HOST=unix:///var/run/docker.sock diff --git a/packages/app/src/docker-git/controller-bootstrap-plan.ts b/packages/app/src/docker-git/controller-bootstrap-plan.ts new file mode 100644 index 00000000..7e1f4bf6 --- /dev/null +++ b/packages/app/src/docker-git/controller-bootstrap-plan.ts @@ -0,0 +1,76 @@ +export type ControllerComposeUpPlan = { + readonly buildController: boolean + readonly forceRecreateController: boolean +} + +export type ControllerImageBuildInput = { + readonly localControllerRevision: string + readonly currentControllerRevision: string | null + readonly currentImageRevision: string | null + readonly forceRecreateController: boolean +} + +/** + * Renders the docker compose `up` arguments for the controller bootstrap plan. + * + * @param plan - Immutable build/recreate decision. + * @returns Compose arguments preserving the fixed `up -d` prefix. + * + * @pure true + * @effect n/a + * @invariant `--build` is present iff `plan.buildController`. + * @precondition Plan booleans are already resolved from Docker state. + * @postcondition Returned arguments contain no duplicate compose flags. + * @complexity O(1) time and O(1) space. + * @throws Never + */ +// CHANGE: derive docker compose up flags from explicit bootstrap requirements +// WHY: matching controller images should be started without invalidating Docker build cache +// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: forall p: build(p) <=> "--build" in args(p) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: forceRecreateController controls only --force-recreate +// COMPLEXITY: O(1) +export const resolveControllerComposeUpArgs = ( + plan: ControllerComposeUpPlan +): ReadonlyArray => [ + "up", + "-d", + ...(plan.buildController ? ["--build"] : []), + ...(plan.forceRecreateController ? ["--force-recreate"] : []) +] + +/** + * Decides whether the controller image must be rebuilt before `docker compose up`. + * + * @param input - Current controller/image revisions and recreate requirement. + * @returns `true` only when neither reusable Docker object proves the local revision. + * + * @pure true + * @effect n/a + * @invariant A matching image revision is sufficient proof to skip build. + * @precondition Revisions are normalized controller revision strings or null. + * @postcondition Forced recreation rebuilds only when no matching image exists. + * @complexity O(1) time and O(1) space. + * @throws Never + */ +// CHANGE: decide whether controller bootstrap needs a Docker image build +// WHY: source revision can be satisfied by either the existing container or an already-built image +// QUOTE(ТЗ): "контейнер собирается минут 5-6" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: image_rev = local_rev -> build_required = false +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: forced recreation without a matching image requires a rebuild +// COMPLEXITY: O(1) +export const shouldBuildControllerImage = (input: ControllerImageBuildInput): boolean => { + if (input.currentImageRevision === input.localControllerRevision) { + return false + } + + return input.currentControllerRevision !== input.localControllerRevision || input.forceRecreateController +} diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index 48341bc1..44fdd23a 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -8,8 +8,10 @@ import { findExistingUpwards } from "./frontend-lib/usecases/path-helpers.js" import type { ControllerBootstrapError } from "./host-errors.js" export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU" +export const controllerBuildSkillerEnvKey = "DOCKER_GIT_CONTROLLER_BUILD_SKILLER" export type ControllerGpuMode = "none" | "all" +export type ControllerBuildSkillerMode = "0" | "1" export type ControllerComposeFiles = { readonly composePath: string @@ -29,10 +31,19 @@ export const parseControllerGpuMode = (raw?: string): ControllerGpuMode | null = return trimmed === "all" ? "all" : null } +export const parseControllerBuildSkillerMode = (raw?: string): ControllerBuildSkillerMode | null => { + const trimmed = raw?.trim() ?? "" + if (trimmed.length === 0 || trimmed === "1" || trimmed === "true") { + return "1" + } + return trimmed === "0" || trimmed === "false" ? "0" : null +} + export const controllerRevisionForMode = ( sourceRevision: string, - gpuMode: ControllerGpuMode -): string => `${sourceRevision}-${gpuMode}` + gpuMode: ControllerGpuMode, + buildSkillerMode: ControllerBuildSkillerMode = "1" +): string => `${sourceRevision}-${gpuMode}-skiller${buildSkillerMode}` const loadControllerGpuMode = (): Effect.Effect => { const raw = process.env[controllerGpuModeEnvKey] @@ -47,6 +58,19 @@ const loadControllerGpuMode = (): Effect.Effect => { + const raw = process.env[controllerBuildSkillerEnvKey] + const parsed = parseControllerBuildSkillerMode(raw) + if (parsed !== null) { + return Effect.succeed(parsed) + } + return Effect.fail( + controllerBootstrapError( + `${controllerBuildSkillerEnvKey} must be unset or one of: 0, 1, false, true. Received: ${raw ?? ""}` + ) + ) +} + const composeFilePath = (): Effect.Effect => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) @@ -99,6 +123,7 @@ const composeFilesForGpuMode = ( type ComposePathAndGpuMode = { readonly composePath: string readonly gpuMode: ControllerGpuMode + readonly buildSkillerMode: ControllerBuildSkillerMode } const withComposePathAndGpuMode = ( @@ -112,7 +137,11 @@ const withComposePathAndGpuMode = ( Effect.mapError(mapComposePathError), Effect.flatMap((composePath) => loadControllerGpuMode().pipe( - Effect.flatMap((gpuMode) => effect({ composePath, gpuMode })) + Effect.flatMap((gpuMode) => + loadControllerBuildSkillerMode().pipe( + Effect.flatMap((buildSkillerMode) => effect({ buildSkillerMode, composePath, gpuMode })) + ) + ) ) ) ) @@ -125,11 +154,12 @@ export const resolveControllerComposeFiles = (): Effect.Effect< const computeControllerRevision = ( composePath: string, - gpuMode: ControllerGpuMode + gpuMode: ControllerGpuMode, + buildSkillerMode: ControllerBuildSkillerMode ): Effect.Effect => computeLocalControllerRevision(composePath).pipe( Effect.mapError(mapControllerRevisionError), - Effect.map((revision) => controllerRevisionForMode(revision, gpuMode)) + Effect.map((revision) => controllerRevisionForMode(revision, gpuMode, buildSkillerMode)) ) const persistControllerRevision = (revision: string): Effect.Effect => @@ -142,6 +172,6 @@ export const prepareControllerRevision = (): Effect.Effect< ControllerBootstrapError, FileSystem.FileSystem | Path.Path > => - withComposePathAndGpuMode(({ composePath, gpuMode }) => computeControllerRevision(composePath, gpuMode)).pipe( - Effect.tap((revision) => persistControllerRevision(revision)) - ) + withComposePathAndGpuMode(({ buildSkillerMode, composePath, gpuMode }) => + computeControllerRevision(composePath, gpuMode, buildSkillerMode) + ).pipe(Effect.tap((revision) => persistControllerRevision(revision))) diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 491dfdae..eedc63fa 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -21,7 +21,13 @@ import { import { parseControllerRevisionEnvOutput } from "./controller-revision.js" import type { ControllerBootstrapError } from "./host-errors.js" -export { controllerGpuModeEnvKey, controllerRevisionForMode, parseControllerGpuMode } from "./controller-compose.js" +export { + controllerBuildSkillerEnvKey, + controllerGpuModeEnvKey, + controllerRevisionForMode, + parseControllerBuildSkillerMode, + parseControllerGpuMode +} from "./controller-compose.js" export type ControllerRuntime = | CommandExecutor.CommandExecutor diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts new file mode 100644 index 00000000..84436bab --- /dev/null +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -0,0 +1,130 @@ +import { Effect } from "effect" + +import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.js" +import { type ControllerRuntime, runDockerCapture } from "./controller-docker.js" +import { parseControllerRevisionLabelOutput } from "./controller-revision.js" +import type { ControllerBootstrapError } from "./host-errors.js" + +const inspectControllerRevisionLabelTemplate = String + .raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}` + +/** + * Returns the first non-empty line from Docker CLI output. + * + * @param output - Raw command output. + * @returns The first trimmed non-empty line, or null when none exists. + * + * @pure true + * @effect n/a + * @invariant Result is either null or a string with length > 0. + * @precondition `output` is a finite string. + * @postcondition Whitespace-only lines are ignored. + * @complexity O(n) time and O(n) space where n = |output|. + * @throws Never + */ +// CHANGE: normalize compose image output before image inspection +// WHY: docker compose config --images emits line-oriented output and bootstrap needs one image name proof +// QUOTE(ТЗ): "контейнер собирается минут 5-6" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: exists first non-empty line -> result = trim(first) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: result is null or non-empty +// COMPLEXITY: O(n) +const firstNonEmptyLine = (output: string): string | null => { + for (const line of output.split(/\r?\n/u)) { + const trimmed = line.trim() + if (trimmed.length > 0) { + return trimmed + } + } + return null +} + +/** + * Resolves the Docker image name configured for the active controller compose files. + * + * @returns The first compose image name, or null when compose emits no images. + * + * @pure false + * @effect Docker CLI through ControllerRuntime. + * @invariant Empty compose output is represented as null. + * @precondition Compose files resolve for the current GPU mode. + * @postcondition Returned image name is trimmed and non-empty. + * @complexity O(1) compose invocations. + * @throws Never - failures are represented in the Effect error channel. + */ +// CHANGE: resolve the compose-built controller image before comparing revisions +// WHY: avoiding --build is sound only when the selected image already carries the local revision label +// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: compose_image = null -> image_revision = null +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: no image name is treated as missing revision proof +// COMPLEXITY: O(1) Docker compose invocations +const inspectControllerComposeImageName = (): Effect.Effect< + string | null, + ControllerBootstrapError, + ControllerRuntime +> => + Effect.gen(function*(_) { + const composeFiles = yield* _(resolveControllerComposeFiles()) + const output = yield* _( + runDockerCapture( + [ + "compose", + ...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath), + "config", + "--images" + ], + "Failed to resolve docker-git controller image" + ) + ) + + return firstNonEmptyLine(output) + }) + +/** + * Reads the revision label from the image resolved by the active compose files. + * + * @returns Current image revision, or null when the image/label is missing. + * + * @pure false + * @effect Docker CLI through ControllerRuntime. + * @invariant Missing image or missing label resolves to null rather than throwing. + * @precondition Docker is reachable through the configured runtime. + * @postcondition Returned revision is normalized by label parsing. + * @complexity O(1) Docker inspections. + * @throws Never - failures are represented in the Effect error channel or recovered to null. + */ +// CHANGE: inspect the compose-built controller image revision label +// WHY: host bootstrap can start an already-current image without forcing Docker to rebuild heavy layers +// QUOTE(ТЗ): "контейнер собирается минут 5-6" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: missing image or missing label resolves to null rather than throwing +// COMPLEXITY: O(1) Docker inspections +export const inspectControllerImageRevision = (): Effect.Effect< + string | null, + ControllerBootstrapError, + ControllerRuntime +> => + inspectControllerComposeImageName().pipe( + Effect.flatMap((imageName) => + imageName === null + ? Effect.succeed(null) + : runDockerCapture( + ["image", "inspect", "-f", inspectControllerRevisionLabelTemplate, imageName], + `Failed to inspect image revision for ${imageName}` + ).pipe( + Effect.map((output) => parseControllerRevisionLabelOutput(output)), + Effect.orElseSucceed((): string | null => null) + ) + ) + ) diff --git a/packages/app/src/docker-git/controller-revision.ts b/packages/app/src/docker-git/controller-revision.ts index 57d7f147..0a566965 100644 --- a/packages/app/src/docker-git/controller-revision.ts +++ b/packages/app/src/docker-git/controller-revision.ts @@ -7,6 +7,7 @@ export const controllerRevisionEnvKey = "DOCKER_GIT_CONTROLLER_REV" const controllerRevisionInputs: ReadonlyArray = [ "docker-compose.yml", + "docker-compose.api.yml", "docker-compose.gpu.yml", "package.json", "bun.lock", @@ -16,11 +17,23 @@ const controllerRevisionInputs: ReadonlyArray = [ "patches", "scripts", "packages/api", - "packages/lib" + "packages/docker-git-session-sync", + "packages/lib", + "third_party/skiller-desktop-skills-manager" ] -const skippedDirectoryNames = new Set([".git", "node_modules", "dist", "dist-test", ".turbo"]) -const skippedFileNames = new Set([".DS_Store"]) +const skippedDirectoryNames = new Set([ + ".git", + ".turbo", + ".vite", + "coverage", + "dist", + "dist-test", + "dist-web", + "node_modules", + "out" +]) +const skippedFileNames = new Set([".DS_Store", ".git"]) const appendChunk = (chunks: Array, value: string): void => { chunks.push(value) @@ -102,6 +115,35 @@ export const parseControllerRevisionEnvOutput = (output: string): string | null return null } +// CHANGE: parse the controller image revision label from Docker inspect output +// WHY: bootstrap can skip rebuilding when an existing image already proves the required revision +// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: forall output: blank(output) or missing_label(output) -> null +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: non-empty label text is preserved after trimming +// COMPLEXITY: O(n) where n = |output| +/** + * Parses the docker-git controller revision label emitted by `docker image inspect`. + * + * @param output - Raw Go-template output from Docker. + * @returns Trimmed revision string, or null when the label is absent. + * + * @pure true + * @effect n/a + * @invariant Blank and Docker `` outputs are treated as missing labels. + * @precondition `output` is a finite string. + * @postcondition Non-empty revision text is returned without surrounding whitespace. + * @complexity O(n) time and O(n) space where n = |output|. + * @throws Never + */ +export const parseControllerRevisionLabelOutput = (output: string): string | null => { + const revision = output.trim() + return revision.length === 0 || revision === "" ? null : revision +} + export const shouldForceRecreateController = ( controllerExists: boolean, localRevision: string, diff --git a/packages/app/src/docker-git/controller.ts b/packages/app/src/docker-git/controller.ts index c8812745..68cf0bd5 100644 --- a/packages/app/src/docker-git/controller.ts +++ b/packages/app/src/docker-git/controller.ts @@ -1,5 +1,6 @@ import { Duration, Effect, pipe, Schedule } from "effect" +import { resolveControllerComposeUpArgs, shouldBuildControllerImage } from "./controller-bootstrap-plan.js" import { controllerContainerName, controllerExists, @@ -13,6 +14,7 @@ import { runCompose } from "./controller-docker.js" import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js" +import { inspectControllerImageRevision } from "./controller-image-revision.js" import { buildApiBaseUrlCandidates, type DockerNetworkIps, @@ -142,6 +144,8 @@ type ControllerBootstrapContext = { readonly explicitApiBaseUrl: string | undefined readonly localControllerRevision: string readonly currentControllerRevision: string | null + readonly currentImageRevision: string | null + readonly buildController: boolean readonly forceRecreateController: boolean readonly currentContainerNetworks: DockerNetworkIps readonly initialControllerNetworks: DockerNetworkIps @@ -158,16 +162,25 @@ const loadControllerBootstrapContext = (): Effect.Effect< const localControllerRevision = yield* _(prepareLocalControllerRevision()) const currentControllerExists = yield* _(controllerExists()) const currentControllerRevision = yield* _(inspectControllerRevision()) + const currentImageRevision = yield* _(inspectControllerImageRevision()) const currentContainerNetworks = yield* _(resolveCurrentContainerNetworks()) const initialControllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName)) const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits() + const forceRecreateController = forceRecreateForResourceLimits || + shouldForceRecreateController(currentControllerExists, localControllerRevision, currentControllerRevision) return { explicitApiBaseUrl, localControllerRevision, currentControllerRevision, - forceRecreateController: forceRecreateForResourceLimits || - shouldForceRecreateController(currentControllerExists, localControllerRevision, currentControllerRevision), + currentImageRevision, + buildController: shouldBuildControllerImage({ + currentControllerRevision, + currentImageRevision, + forceRecreateController, + localControllerRevision + }), + forceRecreateController, currentContainerNetworks, initialControllerNetworks } @@ -206,29 +219,27 @@ const reuseReachableControllerIfPossible = ( }) ) -const logControllerRecreate = ( - localControllerRevision: string, - currentControllerRevision: string | null +const logControllerStart = ( + context: ControllerBootstrapContext ): Effect.Effect => Effect.log( - `Rebuilding docker-git controller: local revision ${localControllerRevision}, container revision ${ - currentControllerRevision ?? "unknown" - }` + [ + context.buildController ? "Rebuilding docker-git controller" : "Recreating docker-git controller", + `local revision ${context.localControllerRevision}`, + `container revision ${context.currentControllerRevision ?? "unknown"}`, + `image revision ${context.currentImageRevision ?? "unknown"}` + ].join(": ") ) const startAndRememberController = ( context: ControllerBootstrapContext ): Effect.Effect => Effect.gen(function*(_) { - if (context.forceRecreateController) { - yield* _(logControllerRecreate(context.localControllerRevision, context.currentControllerRevision)) + if (context.forceRecreateController || context.buildController) { + yield* _(logControllerStart(context)) } - yield* _( - runCompose( - context.forceRecreateController ? ["up", "-d", "--build", "--force-recreate"] : ["up", "-d", "--build"] - ) - ) + yield* _(runCompose(resolveControllerComposeUpArgs(context))) yield* _(ensureControllerReachabilityNetworks(context.currentContainerNetworks)) const controllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName)) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 9889c2de..99067ecd 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -1,15 +1,40 @@ +import { NodeContext } from "@effect/platform-node" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" +import * as fc from "fast-check" -import { controllerRevisionForMode, parseControllerGpuMode } from "../../src/docker-git/controller-docker.js" import { + resolveControllerComposeUpArgs, + shouldBuildControllerImage +} from "../../src/docker-git/controller-bootstrap-plan.js" +import { + controllerRevisionForMode, + parseControllerBuildSkillerMode, + parseControllerGpuMode +} from "../../src/docker-git/controller-docker.js" +import { + computeRevisionFromInputs, parseControllerRevisionEnvOutput, + parseControllerRevisionLabelOutput, shouldForceRecreateController } from "../../src/docker-git/controller-revision.js" import { buildApiBaseUrlCandidates, isRemoteDockerHost } from "../../src/docker-git/controller.js" const joinIp = (...octets: ReadonlyArray): string => octets.join(".") const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("") +const ignoredControllerRevisionEntries: ReadonlyArray = [ + ".git", + ".turbo", + ".vite", + "coverage", + "dist", + "dist-test", + "dist-web", + "node_modules", + "out" +] describe("controller reachability", () => { it.effect("builds direct API candidates without Docker inspection", () => @@ -92,6 +117,13 @@ describe("controller reachability", () => { expect(parseControllerRevisionEnvOutput("PATH=/usr/bin\nNODE_ENV=production\n")).toBeNull() })) + it.effect("parses controller revision from image label output", () => + Effect.sync(() => { + expect(parseControllerRevisionLabelOutput(" abc123def4567890 \n")).toBe("abc123def4567890") + expect(parseControllerRevisionLabelOutput("")).toBeNull() + expect(parseControllerRevisionLabelOutput(" \n")).toBeNull() + })) + it.effect("forces controller recreate when the running revision differs", () => Effect.sync(() => { expect(shouldForceRecreateController(false, "local-a", null)).toBe(false) @@ -100,6 +132,55 @@ describe("controller reachability", () => { expect(shouldForceRecreateController(true, "local-a", null)).toBe(true) })) + it.effect("skips controller image build when a matching image or reusable container exists", () => + Effect.sync(() => { + expect( + shouldBuildControllerImage({ + currentControllerRevision: "old", + currentImageRevision: "local-a", + forceRecreateController: true, + localControllerRevision: "local-a" + }) + ).toBe(false) + expect( + shouldBuildControllerImage({ + currentControllerRevision: "local-a", + currentImageRevision: "old", + forceRecreateController: false, + localControllerRevision: "local-a" + }) + ).toBe(false) + expect( + shouldBuildControllerImage({ + currentControllerRevision: "local-a", + currentImageRevision: "old", + forceRecreateController: true, + localControllerRevision: "local-a" + }) + ).toBe(true) + expect( + shouldBuildControllerImage({ + currentControllerRevision: null, + currentImageRevision: null, + forceRecreateController: false, + localControllerRevision: "local-a" + }) + ).toBe(true) + })) + + it.effect("keeps compose up flags equivalent to the bootstrap plan", () => + Effect.sync(() => { + fc.assert( + fc.property(fc.boolean(), fc.boolean(), (buildController, forceRecreateController) => { + const args = resolveControllerComposeUpArgs({ buildController, forceRecreateController }) + + expect(args.slice(0, 2)).toEqual(["up", "-d"]) + expect(args.includes("--build")).toBe(buildController) + expect(args.includes("--force-recreate")).toBe(forceRecreateController) + }) + ) + })) + it.effect("parses controller GPU mode from environment values", () => Effect.sync(() => { expect(parseControllerGpuMode()).toBe("none") @@ -109,9 +190,47 @@ describe("controller reachability", () => { expect(parseControllerGpuMode("gpu")).toBeNull() })) - it.effect("includes controller GPU mode in the revision", () => + it.effect("parses controller Skiller build mode from environment values", () => + Effect.sync(() => { + expect(parseControllerBuildSkillerMode()).toBe("1") + expect(parseControllerBuildSkillerMode("")).toBe("1") + expect(parseControllerBuildSkillerMode("1")).toBe("1") + expect(parseControllerBuildSkillerMode("true")).toBe("1") + expect(parseControllerBuildSkillerMode("0")).toBe("0") + expect(parseControllerBuildSkillerMode("false")).toBe("0") + expect(parseControllerBuildSkillerMode("skip")).toBeNull() + })) + + it.effect("includes controller GPU and Skiller build modes in the revision", () => Effect.sync(() => { - expect(controllerRevisionForMode("abc123def4567890", "none")).toBe("abc123def4567890-none") - expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-all") + expect(controllerRevisionForMode("abc123def4567890", "none")).toBe("abc123def4567890-none-skiller1") + expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-all-skiller1") + expect(controllerRevisionForMode("abc123def4567890", "none", "0")).toBe("abc123def4567890-none-skiller0") })) + + it.effect("ignores generated paths when computing controller revisions", () => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const rootDir = yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-revision-" })) + const sourceDir = path.join(rootDir, "src") + yield* _(fs.makeDirectory(sourceDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), "export const value = 1\n")) + + const before = yield* _(computeRevisionFromInputs(rootDir, ["src"])) + + for (const entry of ignoredControllerRevisionEntries) { + if (entry === ".git") { + yield* _(fs.writeFileString(path.join(sourceDir, entry), "gitdir: ignored\n")) + continue + } + yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), "ignored\n")) + } + + const after = yield* _(computeRevisionFromInputs(rootDir, ["src"])) + expect(after).toBe(before) + }).pipe(Effect.provide(NodeContext.layer)) + )) }) From 9cf8a46efd4f4dc58d18314f93d7dde1fdbe93ba Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 19:52:12 +0000 Subject: [PATCH 02/12] perf(ci): speed up docker e2e setup --- .github/actions/free-docker-disk/action.yml | 34 +++++++++++-- .github/workflows/check.yml | 6 +++ packages/app/package.json | 2 + scripts/e2e/_lib.sh | 55 +++++++++++++++++++++ scripts/e2e/browser-command.sh | 6 ++- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/.github/actions/free-docker-disk/action.yml b/.github/actions/free-docker-disk/action.yml index b466eef5..3527f74d 100644 --- a/.github/actions/free-docker-disk/action.yml +++ b/.github/actions/free-docker-disk/action.yml @@ -7,10 +7,38 @@ runs: - name: Free disk for Docker builds shell: bash run: | - set -euxo pipefail + set -euo pipefail df -h - docker system df || true + + threshold_gib="${DOCKER_GIT_FREE_DOCKER_DISK_MIN_AVAILABLE_GIB:-40}" + force_cleanup="${DOCKER_GIT_FORCE_FREE_DOCKER_DISK:-0}" + force_cleanup_enabled=0 + + if [[ ! "$threshold_gib" =~ ^[0-9]+$ ]]; then + echo "Invalid DOCKER_GIT_FREE_DOCKER_DISK_MIN_AVAILABLE_GIB: $threshold_gib" >&2 + exit 1 + fi + + case "$force_cleanup" in + 1|true|TRUE|yes|YES|on|ON) + force_cleanup_enabled=1 + ;; + *) + force_cleanup_enabled=0 + ;; + esac + + available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')" + threshold_kib="$((threshold_gib * 1024 * 1024))" + + if [[ "$force_cleanup_enabled" != "1" && "$available_kib" -ge "$threshold_kib" ]]; then + echo "Skipping Docker disk cleanup: / has at least ${threshold_gib}GiB available." + exit 0 + fi + + echo "Running Docker disk cleanup: available=${available_kib}KiB threshold=${threshold_kib}KiB force=${force_cleanup}." + timeout 20s docker system df || true sudo rm -rf \ /opt/ghc \ @@ -22,4 +50,4 @@ runs: docker system prune -af --volumes || true df -h - docker system df || true + timeout 20s docker system df || true diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c91ca1c1..7b324603 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -155,6 +155,7 @@ jobs: timeout-minutes: 40 env: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - uses: actions/checkout@v6 with: @@ -174,6 +175,7 @@ jobs: timeout-minutes: 40 env: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - uses: actions/checkout@v6 with: @@ -193,6 +195,7 @@ jobs: timeout-minutes: 40 env: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - uses: actions/checkout@v6 with: @@ -212,6 +215,7 @@ jobs: timeout-minutes: 40 env: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - uses: actions/checkout@v6 with: @@ -231,6 +235,7 @@ jobs: timeout-minutes: 60 env: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - uses: actions/checkout@v6 with: @@ -250,6 +255,7 @@ jobs: timeout-minutes: 40 env: DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0" + DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1" steps: - uses: actions/checkout@v6 with: diff --git a/packages/app/package.json b/packages/app/package.json index 625ac0ea..17d6db04 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,8 @@ "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", + "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "build:docker-git:reuse-install": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", "clone": "bun run build:docker-git && bun dist/src/docker-git/main.js clone", "open": "bun run build:docker-git && bun dist/src/docker-git/main.js open", diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index b669888e..9af91cc5 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -24,6 +24,7 @@ exec sudo -n env \ "DOCKER_GIT_API_CONTAINER_NAME=${DOCKER_GIT_API_CONTAINER_NAME:-}" \ "DOCKER_GIT_API_PORT=${DOCKER_GIT_API_PORT:-}" \ "DOCKER_GIT_CONTROLLER_DOCKER_HOST=${DOCKER_GIT_CONTROLLER_DOCKER_HOST:-}" \ + "DOCKER_GIT_CONTROLLER_BUILD_SKILLER=${DOCKER_GIT_CONTROLLER_BUILD_SKILLER:-}" \ "DOCKER_GIT_CONTROLLER_REV=${DOCKER_GIT_CONTROLLER_REV:-}" \ "DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE=${DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE:-}" \ "DOCKER_GIT_DOCKERD_TCP_HOST=${DOCKER_GIT_DOCKERD_TCP_HOST:-}" \ @@ -134,6 +135,35 @@ dg_ensure_node_gyp() { export PATH="$node_gyp_bin:$PATH" } +dg_is_truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) + return 0 + ;; + *) + return 1 + ;; + esac +} + +dg_log_duration() { + local label="$1" + local started_at="$2" + local finished_at + finished_at="$(date +%s)" + + echo "e2e: ${label} completed in $((finished_at - started_at))s" >&2 +} + +dg_workspace_install_ready() { + local repo_root="$1" + + [[ -d "$repo_root/node_modules" ]] \ + && [[ -x "$repo_root/packages/app/node_modules/.bin/vite" ]] \ + && [[ -x "$repo_root/packages/lib/node_modules/.bin/tsc" ]] \ + && [[ -x "$repo_root/packages/docker-git-session-sync/node_modules/.bin/vite" ]] +} + dg_pick_free_port() { local first_port="$1" local last_port="$2" @@ -430,31 +460,56 @@ dg_project_ssh_to_container() { dg_prepare_bun_workspace() { local repo_root="$1" local bin_dir="$2" + local started_at dg_ensure_bun dg_ensure_node_gyp "$bin_dir" + if dg_is_truthy "${DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL:-0}" && dg_workspace_install_ready "$repo_root"; then + echo "e2e: reusing existing Bun workspace install" >&2 + return 0 + fi + + started_at="$(date +%s)" ( cd "$repo_root" bun install --no-save --silent ) + dg_log_duration "Bun workspace install" "$started_at" } dg_build_docker_git_cli() { local repo_root="$1" + local started_at + + started_at="$(date +%s)" + + if dg_is_truthy "${DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL:-0}" && dg_workspace_install_ready "$repo_root"; then + ( + cd "$repo_root" + bun run --cwd packages/app build:docker-git:reuse-install + ) + dg_log_duration "docker-git CLI build" "$started_at" + return 0 + fi ( cd "$repo_root" bun run --cwd packages/app build:docker-git ) + dg_log_duration "docker-git CLI build" "$started_at" } dg_prepare_docker_git_cli() { local repo_root="$1" local bin_dir="$2" + local started_at + + started_at="$(date +%s)" dg_prepare_bun_workspace "$repo_root" "$bin_dir" dg_build_docker_git_cli "$repo_root" + dg_log_duration "prepare docker-git CLI" "$started_at" } dg_run_docker_git() { diff --git a/scripts/e2e/browser-command.sh b/scripts/e2e/browser-command.sh index 424e3b87..5670f8b7 100755 --- a/scripts/e2e/browser-command.sh +++ b/scripts/e2e/browser-command.sh @@ -140,7 +140,11 @@ dg_ensure_docker "$E2E_BIN" dg_prepare_docker_git_cli "$REPO_ROOT" "$E2E_BIN" cd "$REPO_ROOT" -setsid bash -lc 'bun run docker-git -- browser' >"$BROWSER_LOG" 2>&1 & +if dg_is_truthy "${DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL:-0}"; then + setsid bash -lc 'bun packages/app/dist/src/docker-git/main.js browser' >"$BROWSER_LOG" 2>&1 & +else + setsid bash -lc 'bun run docker-git -- browser' >"$BROWSER_LOG" 2>&1 & +fi BROWSER_PID="$!" wait_for_log_line "Ensuring docker-git API controller is current." From 6cda27334da469a48a6082b58583a4deb8e076af Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 21:07:34 +0000 Subject: [PATCH 03/12] fix(app): address remaining coderabbit review --- .github/actions/free-docker-disk/action.yml | 20 +++- .../api/src/services/terminal-sessions.ts | 4 +- packages/app/src/docker-git/controller.ts | 9 +- .../app/src/docker-git/menu-create-advance.ts | 8 +- .../app/src/docker-git/menu-create-inputs.ts | 96 ++++++++++++++- .../app/src/web/app-ready-terminal-tabs.tsx | 2 +- .../app/src/web/panel-project-details.tsx | 7 +- .../src/web/terminal-panel-cleanup-runtime.ts | 4 +- .../app/tests/docker-git/controller.test.ts | 112 ++++++++++++++---- .../docker-git/menu-create-shared.test.ts | 18 ++- scripts/e2e/_lib.sh | 28 ++++- 11 files changed, 261 insertions(+), 47 deletions(-) diff --git a/.github/actions/free-docker-disk/action.yml b/.github/actions/free-docker-disk/action.yml index 3527f74d..8728d332 100644 --- a/.github/actions/free-docker-disk/action.yml +++ b/.github/actions/free-docker-disk/action.yml @@ -9,7 +9,11 @@ runs: run: | set -euo pipefail - df -h + if command -v df >/dev/null 2>&1; then + df -h || true + else + echo "df is not available; Docker disk cleanup will run without a free-space precheck." >&2 + fi threshold_gib="${DOCKER_GIT_FREE_DOCKER_DISK_MIN_AVAILABLE_GIB:-40}" force_cleanup="${DOCKER_GIT_FORCE_FREE_DOCKER_DISK:-0}" @@ -29,7 +33,15 @@ runs: ;; esac - available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')" + if command -v df >/dev/null 2>&1; then + available_kib="$(df -Pk / 2>/dev/null | awk 'NR == 2 { print $4 }' || true)" + else + available_kib="" + fi + if [[ ! "$available_kib" =~ ^[0-9]+$ ]]; then + echo "Could not parse available disk space from df output; Docker disk cleanup will run." >&2 + available_kib=0 + fi threshold_kib="$((threshold_gib * 1024 * 1024))" if [[ "$force_cleanup_enabled" != "1" && "$available_kib" -ge "$threshold_kib" ]]; then @@ -49,5 +61,7 @@ runs: docker system prune -af --volumes || true - df -h + if command -v df >/dev/null 2>&1; then + df -h || true + fi timeout 20s docker system df || true diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 55b71a75..8e2d162d 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -766,11 +766,11 @@ const writePtyInput = (pty: PtyBridge | null, data: string): void => { const shellQuote = (value: string): string => `'${value.replace(/'/gu, "'\\''")}'` // CHANGE: Predicate for when tmux should forward right-click pane events. -// WHY: Mouse-aware apps and copy/view mode still need pane mouse events, while tmux menus must stay disabled. +// WHY: Mouse-aware apps receive pane events; copy/view mode keeps tmux handling unless mouse tracking is active. // QUOTE(TZ): issue #340 right-click must not open the default tmux menu in browser terminals. // REF: PR #342 tmux right-click handling. // SOURCE: n/a -// FORMAT THEOREM: mouse-aware-or-copy-mode => predicate evaluates truthy in tmux. +// FORMAT THEOREM: mouse_any_flag or non-copy/view pane mode => predicate evaluates truthy in tmux. // PURITY: CORE // EFFECT: none // INVARIANT: The predicate contains only tmux format language and no shell interpolation. diff --git a/packages/app/src/docker-git/controller.ts b/packages/app/src/docker-git/controller.ts index 68cf0bd5..95ffb2fb 100644 --- a/packages/app/src/docker-git/controller.ts +++ b/packages/app/src/docker-git/controller.ts @@ -317,5 +317,12 @@ export const restartController = (): Effect.Effect currentStep === "repoUrl" ? firstCreateSettingsStepIndex - : currentStepIndex + 1 + : currentStepIndex const shouldCompleteCreateFlow = ( nextSteps: ReadonlyArray, @@ -278,14 +278,14 @@ const shouldCompleteCreateFlow = ( * @complexity O(k + s) where s = number of remaining create steps */ // CHANGE: advance normal create-flow settings after committing the active prompt -// WHY: applying a non-repo step must move forward instead of reselecting the same index -// QUOTE(ТЗ): "after applying a non-repoUrl step it advances to currentStepIndex + 1" +// WHY: applying a non-repo step removes it from nextSteps, so the same index selects the next remaining row +// QUOTE(ТЗ): "после cpuLimit нельзя пропустить ramLimit" // REF: issue-339 // SOURCE: n/a // FORMAT THEOREM: remaining = empty or nextStep past end -> Complete, otherwise Continue(next valid setting) // PURITY: CORE // EFFECT: n/a -// INVARIANT: applying the final settings index completes instead of clamping back to it +// INVARIANT: the next view never skips the remaining setting at the applied index // COMPLEXITY: O(k + s) where s = number of remaining create steps export const advanceCreateFlow = ( contextOrCwd: string | CreateFlowContext, diff --git a/packages/app/src/docker-git/menu-create-inputs.ts b/packages/app/src/docker-git/menu-create-inputs.ts index e3b3263f..7ab44895 100644 --- a/packages/app/src/docker-git/menu-create-inputs.ts +++ b/packages/app/src/docker-git/menu-create-inputs.ts @@ -3,6 +3,20 @@ import { defaultProjectsRoot } from "./frontend-lib/usecases/menu-helpers.js" import type { CreateFlowContext } from "./menu-create-flow-types.js" import type { CreateInputs } from "./menu-types.js" +/** + * Removes leading path separators from a path segment. + * + * @param value - Path segment to normalize. + * @returns The segment without leading slash characters. + * + * @pure true + * @effect n/a + * @invariant Result length is less than or equal to input length. + * @precondition `value` is a finite string. + * @postcondition The result does not start with `/` unless it is empty. + * @complexity O(n) time and O(n) space where n = |value|. + * @throws Never + */ const trimLeftSlash = (value: string): string => { let start = 0 while (start < value.length && value[start] === "/") { @@ -11,6 +25,20 @@ const trimLeftSlash = (value: string): string => { return value.slice(start) } +/** + * Removes trailing path separators from a path segment. + * + * @param value - Path segment to normalize. + * @returns The segment without trailing slash characters. + * + * @pure true + * @effect n/a + * @invariant Result length is less than or equal to input length. + * @precondition `value` is a finite string. + * @postcondition The result does not end with `/` unless it is empty. + * @complexity O(n) time and O(n) space where n = |value|. + * @throws Never + */ const trimRightSlash = (value: string): string => { let end = value.length while (end > 0 && value[end - 1] === "/") { @@ -19,24 +47,54 @@ const trimRightSlash = (value: string): string => { return value.slice(0, end) } +/** + * Joins normalized POSIX-style path parts while preserving a root `/`. + * + * @param parts - Ordered path parts, starting with the base directory. + * @returns A slash-separated path with empty non-root segments removed. + * + * @pure true + * @effect n/a + * @invariant A leading `/` base remains absolute in the result. + * @precondition Each part is a finite string. + * @postcondition Non-root parts do not introduce duplicate separators. + * @complexity O(p + n) time and O(p + n) space where p = |parts| and n = total input length. + * @throws Never + */ const joinPath = (...parts: ReadonlyArray): string => { const cleaned = parts .filter((part) => part.length > 0) .map((part, index) => { if (index === 0) { - return trimRightSlash(part) + const trimmed = trimRightSlash(part) + return trimmed.length === 0 && part.startsWith("/") ? "/" : trimmed } return trimRightSlash(trimLeftSlash(part)) }) + .filter((part, index) => index === 0 || part.length > 0) + + if (cleaned.length === 0) { + return "" + } + if (cleaned[0] === "/") { + return cleaned.length === 1 ? "/" : `/${cleaned.slice(1).join("/")}` + } return cleaned.join("/") } /** * Normalizes legacy cwd input into the create-flow context record. * + * @param context - Legacy cwd string or already-normalized context record. + * @returns A context record with at least `cwd` defined. + * * @pure true + * @effect n/a * @invariant string input maps to { cwd: input } - * @complexity O(1) + * @precondition `context` is a finite cwd string or CreateFlowContext. + * @postcondition Object context input is preserved by reference. + * @complexity O(1) time and O(1) space. + * @throws Never */ // CHANGE: normalize create-flow context boundaries into one record shape // WHY: pure helpers can share cwd and optional projectsRoot resolution @@ -55,6 +113,20 @@ export const normalizeCreateFlowContext = ( ? { cwd: context } : context +/** + * Resolves the configured projects root or derives it from cwd. + * + * @param context - Create-flow context with cwd and optional projectsRoot. + * @returns Explicit non-blank projectsRoot, otherwise the cwd-derived default. + * + * @pure true + * @effect n/a + * @invariant Non-blank `projectsRoot` takes precedence over cwd defaults. + * @precondition `context.cwd` is a finite string. + * @postcondition Returned root is a finite string. + * @complexity O(n) time and O(n) space where n = |context.projectsRoot ?? context.cwd|. + * @throws Never + */ const resolveProjectsRoot = (context: CreateFlowContext): string => context.projectsRoot?.trim().length ? context.projectsRoot @@ -63,9 +135,17 @@ const resolveProjectsRoot = (context: CreateFlowContext): string => /** * Resolves the default output directory for a repo input. * + * @param context - Create-flow context used to select the projects root. + * @param repoUrl - Repository input accepted by `resolveRepoInput`. + * @returns Default output directory under the resolved projects root. + * * @pure true + * @effect n/a * @invariant output is rooted under the resolved projects root - * @complexity O(n) where n = |repoUrl| + * @precondition `repoUrl` is a finite string. + * @postcondition The result contains the repository path parts in order. + * @complexity O(n) time and O(n) space where n = |repoUrl|. + * @throws Never */ // CHANGE: derive create-flow output directory from repo identity and context root // WHY: repo URL, branch suffix, and browser-provided projectsRoot must resolve consistently @@ -87,9 +167,17 @@ export const resolveDefaultOutDir = (context: CreateFlowContext, repoUrl: string /** * Resolves partial create-flow values into total create command inputs. * + * @param contextOrCwd - Legacy cwd string or create-flow context. + * @param values - Partial create inputs collected by the flow. + * @returns Total create inputs with deterministic defaults. + * * @pure true + * @effect n/a * @invariant every CreateInputs field is defined in the result - * @complexity O(n) where n = |repoUrl| + * @precondition `values` is a finite partial record. + * @postcondition Explicit false boolean fields remain false in the result. + * @complexity O(n) time and O(n) space where n = |repoUrl|. + * @throws Never */ // CHANGE: totalize create-flow partial values with deterministic defaults // WHY: completion must hand the shell a complete create command input record diff --git a/packages/app/src/web/app-ready-terminal-tabs.tsx b/packages/app/src/web/app-ready-terminal-tabs.tsx index a252c65a..31ec8bcb 100644 --- a/packages/app/src/web/app-ready-terminal-tabs.tsx +++ b/packages/app/src/web/app-ready-terminal-tabs.tsx @@ -37,7 +37,7 @@ const activeTerminalProject = ( sessions: ReadonlyArray, activeSessionId: string | null ): { readonly projectId: string; readonly projectKey?: string | undefined } | null => { - const active = sessions.find((session) => terminalSessionId(session) === activeSessionId) ?? sessions.at(-1) + const active = sessions.find((session) => terminalSessionId(session) === activeSessionId) ?? sessions[0] return active?.browserProjectId === undefined ? null : { projectId: active.browserProjectId, projectKey: active.browserProjectKey } diff --git a/packages/app/src/web/panel-project-details.tsx b/packages/app/src/web/panel-project-details.tsx index 45e4dea8..8684d972 100644 --- a/packages/app/src/web/panel-project-details.tsx +++ b/packages/app/src/web/panel-project-details.tsx @@ -124,6 +124,11 @@ const renderDetailsPanel = ( ) } +const selectedProjectKeyForLiveSessions = ( + project: SelectPanelProps["project"], + selectedProjectSummary: SelectPanelProps["selectedProjectSummary"] +): string | null => selectedProjectSummary?.projectKey ?? project?.projectKey ?? null + export const SelectPanel = ( { currentMenu, @@ -136,7 +141,7 @@ export const SelectPanel = ( selectedProjectSummary }: SelectPanelProps ): JSX.Element | null => { - const selectedProjectKey = selectedProjectSummary?.projectKey ?? null + const selectedProjectKey = selectedProjectKeyForLiveSessions(project, selectedProjectSummary) if (currentMenu !== "Select") { return null diff --git a/packages/app/src/web/terminal-panel-cleanup-runtime.ts b/packages/app/src/web/terminal-panel-cleanup-runtime.ts index 75ed285f..345c6ebc 100644 --- a/packages/app/src/web/terminal-panel-cleanup-runtime.ts +++ b/packages/app/src/web/terminal-panel-cleanup-runtime.ts @@ -24,7 +24,9 @@ export const cleanupTerminalResources = ( args.lifecycle.disposed = true clearReconnectTimer(args) for (const disposable of args.lifecycle.inlineImageDisposables) { - disposable.dispose() + runOptionalTerminalOperation(() => { + disposable.dispose() + }) } args.lifecycle.inlineImageDisposables = [] revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 99067ecd..91f71609 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -1,8 +1,7 @@ -import { NodeContext } from "@effect/platform-node" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" +import { Effect, Option } from "effect" import * as fc from "fast-check" import { @@ -36,6 +35,72 @@ const ignoredControllerRevisionEntries: ReadonlyArray = [ "out" ] +type MemoryFileEntry = + | { readonly _tag: "Directory" } + | { readonly _tag: "File"; readonly contents: string } + +const normalizeMemoryPath = (value: string): string => { + const normalized = value.replaceAll(/\/+/gu, "/").replace(/\/$/u, "") + return normalized.length === 0 ? "/" : normalized +} + +const memoryFileInfo = (entry: MemoryFileEntry): FileSystem.File.Info => ({ + atime: Option.none(), + birthtime: Option.none(), + blksize: Option.none(), + blocks: Option.none(), + dev: 0, + gid: Option.none(), + ino: Option.none(), + mode: 0, + mtime: Option.none(), + nlink: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(entry._tag === "File" ? entry.contents.length : 0), + type: entry._tag === "Directory" ? "Directory" : "File", + uid: Option.none() +}) + +const createMemoryFileSystemLayer = () => { + let entries = new Map([ + ["/memory", { _tag: "Directory" }] + ]) + + return FileSystem.layerNoop({ + exists: (path) => Effect.sync(() => entries.has(normalizeMemoryPath(path))), + makeDirectory: (path) => + Effect.sync(() => { + entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "Directory" }) + }), + readDirectory: (path) => + Effect.sync(() => { + const directory = normalizeMemoryPath(path) + const prefix = directory === "/" ? "/" : `${directory}/` + const names = new Set() + for (const candidate of entries.keys()) { + if (candidate === directory || !candidate.startsWith(prefix)) { + continue + } + const name = candidate.slice(prefix.length).split("/")[0] + if (name !== undefined && name.length > 0) { + names.add(name) + } + } + return [...names] + }), + readFileString: (path) => + Effect.sync(() => { + const entry = entries.get(normalizeMemoryPath(path)) + return entry?._tag === "File" ? entry.contents : "" + }), + stat: (path) => Effect.sync(() => memoryFileInfo(entries.get(normalizeMemoryPath(path)) ?? { _tag: "Directory" })), + writeFileString: (path, contents) => + Effect.sync(() => { + entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "File", contents }) + }) + }) +} + describe("controller reachability", () => { it.effect("builds direct API candidates without Docker inspection", () => Effect.sync(() => { @@ -209,28 +274,29 @@ describe("controller reachability", () => { })) it.effect("ignores generated paths when computing controller revisions", () => - Effect.scoped( - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const rootDir = yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-revision-" })) - const sourceDir = path.join(rootDir, "src") - yield* _(fs.makeDirectory(sourceDir, { recursive: true })) - yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), "export const value = 1\n")) - - const before = yield* _(computeRevisionFromInputs(rootDir, ["src"])) - - for (const entry of ignoredControllerRevisionEntries) { - if (entry === ".git") { - yield* _(fs.writeFileString(path.join(sourceDir, entry), "gitdir: ignored\n")) - continue - } - yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) - yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), "ignored\n")) + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const rootDir = "/memory" + const sourceDir = path.join(rootDir, "src") + yield* _(fs.makeDirectory(sourceDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), "export const value = 1\n")) + + const before = yield* _(computeRevisionFromInputs(rootDir, ["src"])) + + for (const entry of ignoredControllerRevisionEntries) { + if (entry === ".git") { + yield* _(fs.writeFileString(path.join(sourceDir, entry), "gitdir: ignored\n")) + continue } + yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), "ignored\n")) + } - const after = yield* _(computeRevisionFromInputs(rootDir, ["src"])) - expect(after).toBe(before) - }).pipe(Effect.provide(NodeContext.layer)) + const after = yield* _(computeRevisionFromInputs(rootDir, ["src"])) + expect(after).toBe(before) + }).pipe( + Effect.provide(createMemoryFileSystemLayer()), + Effect.provide(Path.layer) )) }) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index f030dad8..6dd9111f 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -150,6 +150,18 @@ describe("menu-create-shared", () => { expect(view.values.outDir).toBe("/home/dev/.docker-git/org/repo") }) + it("preserves an absolute root projectsRoot in browser mode", () => { + const view = expectCreateContinueView(advanceCreateFlow( + { + cwd: "/repo/packages/api", + projectsRoot: "/" + }, + createInitialFlowView(featureCreateRepoUrl) + )) + + expect(view.values.outDir).toBe("/org/repo") + }) + it("moves between remaining settings rows and clears the input buffer", () => { const view = createFeatureRepoSettingsView(cwd) const editingView = { ...view, buffer: "stale" } @@ -185,7 +197,7 @@ describe("menu-create-shared", () => { ) }) - it("advances by one settings index after applying the current setting", () => { + it("advances to the next remaining settings row after applying the current setting", () => { const next = expectCreateContinueView(advanceCreateFlow( cwd, { @@ -195,8 +207,8 @@ describe("menu-create-shared", () => { )) expect(next.values.cpuLimit).toBe("45%") - expect(next.step).toBe(2) - expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("gpu") + expect(next.step).toBe(1) + expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("ramLimit") }) it("maps create-mode steps to the matching display row when opening browser Settings", () => { diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index 9af91cc5..b294514f 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -155,13 +155,33 @@ dg_log_duration() { echo "e2e: ${label} completed in $((finished_at - started_at))s" >&2 } +# The reuse fast path assumes Bun installed the current workspace layout: +# root node_modules plus Vite/TypeScript bins for packages/app, packages/lib, +# and packages/docker-git-session-sync. If package names, locations, or the +# package manager change, this check should fail closed and print the missing +# path so CI falls back to a fresh install instead of silently using stale deps. dg_workspace_install_ready() { local repo_root="$1" + local required_bins=( + "$repo_root/packages/app/node_modules/.bin/vite" + "$repo_root/packages/lib/node_modules/.bin/tsc" + "$repo_root/packages/docker-git-session-sync/node_modules/.bin/vite" + ) + local bin + + if [[ ! -d "$repo_root/node_modules" ]]; then + echo "e2e: workspace install check failed: missing directory $repo_root/node_modules" >&2 + return 1 + fi + + for bin in "${required_bins[@]}"; do + if [[ ! -x "$bin" ]]; then + echo "e2e: workspace install check failed: missing executable $bin" >&2 + return 1 + fi + done - [[ -d "$repo_root/node_modules" ]] \ - && [[ -x "$repo_root/packages/app/node_modules/.bin/vite" ]] \ - && [[ -x "$repo_root/packages/lib/node_modules/.bin/tsc" ]] \ - && [[ -x "$repo_root/packages/docker-git-session-sync/node_modules/.bin/vite" ]] + return 0 } dg_pick_free_port() { From af96a46ec1d5382f80d1c0f2cee4fa2b4f51f20c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 21:29:11 +0000 Subject: [PATCH 04/12] test(app): cover controller revision ignores with properties --- .../app/tests/docker-git/controller.test.ts | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 91f71609..3831706a 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -34,6 +34,11 @@ const ignoredControllerRevisionEntries: ReadonlyArray = [ "node_modules", "out" ] +const ignoredControllerRevisionEntrySubsetArbitrary = fc.uniqueArray( + fc.constantFrom(...ignoredControllerRevisionEntries), + { maxLength: ignoredControllerRevisionEntries.length, minLength: 1 } +) +const revisionFileContentsArbitrary = fc.string({ maxLength: 256 }) type MemoryFileEntry = | { readonly _tag: "Directory" } @@ -101,6 +106,12 @@ const createMemoryFileSystemLayer = () => { }) } +const assertControllerRevisionProperty = (property: fc.IAsyncProperty) => + Effect.tryPromise({ + catch: (cause) => cause, + try: () => fc.assert(property, { numRuns: 25 }) + }) + describe("controller reachability", () => { it.effect("builds direct API candidates without Docker inspection", () => Effect.sync(() => { @@ -274,29 +285,39 @@ describe("controller reachability", () => { })) it.effect("ignores generated paths when computing controller revisions", () => - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const rootDir = "/memory" - const sourceDir = path.join(rootDir, "src") - yield* _(fs.makeDirectory(sourceDir, { recursive: true })) - yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), "export const value = 1\n")) + assertControllerRevisionProperty( + fc.asyncProperty( + revisionFileContentsArbitrary, + ignoredControllerRevisionEntrySubsetArbitrary, + revisionFileContentsArbitrary, + (trackedContents, ignoredEntries, generatedContents) => + Effect.runPromise( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const rootDir = "/memory" + const sourceDir = path.join(rootDir, "src") + yield* _(fs.makeDirectory(sourceDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), trackedContents)) - const before = yield* _(computeRevisionFromInputs(rootDir, ["src"])) + const before = yield* _(computeRevisionFromInputs(rootDir, ["src"])) - for (const entry of ignoredControllerRevisionEntries) { - if (entry === ".git") { - yield* _(fs.writeFileString(path.join(sourceDir, entry), "gitdir: ignored\n")) - continue - } - yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) - yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), "ignored\n")) - } + for (const entry of ignoredEntries) { + if (entry === ".git") { + yield* _(fs.writeFileString(path.join(sourceDir, entry), generatedContents)) + continue + } + yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), generatedContents)) + } - const after = yield* _(computeRevisionFromInputs(rootDir, ["src"])) - expect(after).toBe(before) - }).pipe( - Effect.provide(createMemoryFileSystemLayer()), - Effect.provide(Path.layer) + const after = yield* _(computeRevisionFromInputs(rootDir, ["src"])) + expect(after).toBe(before) + }).pipe( + Effect.provide(createMemoryFileSystemLayer()), + Effect.provide(Path.layer) + ) + ) + ) )) }) From 461c21a7df991e77e6cc30e78639938b44cb89ce Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 21:42:27 +0000 Subject: [PATCH 05/12] test(app): strengthen controller revision properties --- .../docker-git/controller-revision.test.ts | 205 ++++++++++++++++++ .../app/tests/docker-git/controller.test.ts | 159 +++----------- 2 files changed, 234 insertions(+), 130 deletions(-) create mode 100644 packages/app/tests/docker-git/controller-revision.test.ts diff --git a/packages/app/tests/docker-git/controller-revision.test.ts b/packages/app/tests/docker-git/controller-revision.test.ts new file mode 100644 index 00000000..0db164d4 --- /dev/null +++ b/packages/app/tests/docker-git/controller-revision.test.ts @@ -0,0 +1,205 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Option } from "effect" +import * as fc from "fast-check" + +import { computeRevisionFromInputs } from "../../src/docker-git/controller-revision.js" + +const ignoredControllerRevisionEntries: ReadonlyArray = [ + ".git", + ".turbo", + ".vite", + "coverage", + "dist", + "dist-test", + "dist-web", + "node_modules", + "out" +] +const ignoredControllerRevisionEntrySubsetArbitrary = fc.uniqueArray( + fc.constantFrom(...ignoredControllerRevisionEntries), + { maxLength: ignoredControllerRevisionEntries.length, minLength: 1 } +) +const revisionFileContentsArbitrary = fc.string({ maxLength: 256 }) +const changedTrackedFileContentsArbitrary = fc + .tuple(revisionFileContentsArbitrary, revisionFileContentsArbitrary) + .filter(([left, right]) => left !== right) +const memoryRootDir = "/memory" +const memoryRevisionInput = "src" +const memoryTrackedFileName = "tracked.ts" + +type MemoryFileEntry = + | { readonly _tag: "Directory" } + | { readonly _tag: "File"; readonly contents: string } + +const normalizeMemoryPath = (value: string): string => { + const normalized = value.replaceAll(/\/+/gu, "/").replace(/\/$/u, "") + return normalized.length === 0 ? "/" : normalized +} + +const memoryFileInfo = (entry: MemoryFileEntry): FileSystem.File.Info => ({ + atime: Option.none(), + birthtime: Option.none(), + blksize: Option.none(), + blocks: Option.none(), + dev: 0, + gid: Option.none(), + ino: Option.none(), + mode: 0, + mtime: Option.none(), + nlink: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(entry._tag === "File" ? entry.contents.length : 0), + type: entry._tag === "Directory" ? "Directory" : "File", + uid: Option.none() +}) + +const createMemoryFileSystemLayer = () => { + let entries = new Map([ + ["/memory", { _tag: "Directory" }] + ]) + + return FileSystem.layerNoop({ + exists: (path) => Effect.sync(() => entries.has(normalizeMemoryPath(path))), + makeDirectory: (path) => + Effect.sync(() => { + entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "Directory" }) + }), + readDirectory: (path) => + Effect.sync(() => { + const directory = normalizeMemoryPath(path) + const prefix = directory === "/" ? "/" : `${directory}/` + const names = new Set() + for (const candidate of entries.keys()) { + if (candidate === directory || !candidate.startsWith(prefix)) { + continue + } + const name = candidate.slice(prefix.length).split("/")[0] + if (name !== undefined && name.length > 0) { + names.add(name) + } + } + return [...names] + }), + readFileString: (path) => + Effect.sync(() => { + const entry = entries.get(normalizeMemoryPath(path)) + return entry?._tag === "File" ? entry.contents : "" + }), + stat: (path) => Effect.sync(() => memoryFileInfo(entries.get(normalizeMemoryPath(path)) ?? { _tag: "Directory" })), + writeFileString: (path, contents) => + Effect.sync(() => { + entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "File", contents }) + }) + }) +} + +/** + * Runs an asynchronous fast-check property inside Effect-based tests. + * + * @param property - Async property whose cases return Promises from Effect programs. + * @returns Effect that fails if fast-check finds a counterexample. + * @pure false + * @effect Effect.tryPromise, fc.assert + * @invariant A returned success proves every sampled property case passed. + * @precondition The property is finite and does not share mutable memory filesystem state across cases. + * @postcondition Counterexamples are surfaced as typed Effect failures. + * @complexity O(r * c) time where r is numRuns and c is property case cost. + * @throws Never + */ +const assertControllerRevisionProperty = (property: fc.IAsyncProperty) => + Effect.tryPromise({ + catch: (cause) => cause, + try: () => fc.assert(property, { numRuns: 50 }) + }) + +/** + * Writes the tracked memory source tree shared by controller revision properties. + * + * @param trackedContents - Contents written to the tracked source file. + * @returns Effect producing the root and source directory paths. + * @pure false + * @effect FileSystem.FileSystem, Path.Path + * @invariant The same tracked file path is created for every property case. + * @precondition `trackedContents` is a finite string. + * @postcondition `src/tracked.ts` exists in the fresh memory filesystem. + * @complexity O(n) time and space where n = trackedContents.length. + * @throws Never + */ +const writeTrackedMemoryRevisionSource = (trackedContents: string) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const sourceDir = path.join(memoryRootDir, memoryRevisionInput) + yield* _(fs.makeDirectory(sourceDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, memoryTrackedFileName), trackedContents)) + return { rootDir: memoryRootDir, sourceDir } + }) + +/** + * Computes a controller revision for a memory-backed source tree with one tracked file. + * + * @param trackedContents - Contents written to `src/tracked.ts`. + * @returns Effect producing the revision for the generated in-memory tree. + * @pure false + * @effect FileSystem.FileSystem, Path.Path, WebCrypto digest through computeRevisionFromInputs. + * @invariant Equal tracked contents produce equal revisions for the fixed tree. + * @precondition `trackedContents` is a finite string. + * @postcondition The in-memory filesystem layer is fresh for the call. + * @complexity O(n) time and space where n = trackedContents.length. + * @throws Never + */ +const computeMemoryRevisionForTrackedContents = (trackedContents: string) => + Effect.gen(function*(_) { + const { rootDir } = yield* _(writeTrackedMemoryRevisionSource(trackedContents)) + return yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput])) + }).pipe( + Effect.provide(createMemoryFileSystemLayer()), + Effect.provide(Path.layer) + ) + +describe("controller revisions", () => { + it.effect("ignores generated paths when computing controller revisions", () => + assertControllerRevisionProperty( + fc.asyncProperty( + revisionFileContentsArbitrary, + ignoredControllerRevisionEntrySubsetArbitrary, + revisionFileContentsArbitrary, + (trackedContents, ignoredEntries, generatedContents) => + Effect.runPromise( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const { rootDir, sourceDir } = yield* _(writeTrackedMemoryRevisionSource(trackedContents)) + + const before = yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput])) + + for (const entry of ignoredEntries) { + yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) + yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), generatedContents)) + } + + const after = yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput])) + expect(after).toBe(before) + }).pipe( + Effect.provide(createMemoryFileSystemLayer()), + Effect.provide(Path.layer) + ) + ) + ) + )) + + it.effect("changes controller revisions when tracked source changes", () => + assertControllerRevisionProperty( + fc.asyncProperty(changedTrackedFileContentsArbitrary, ([initialContents, changedContents]) => + Effect.runPromise( + Effect.gen(function*(_) { + const initialRevision = yield* _(computeMemoryRevisionForTrackedContents(initialContents)) + const changedRevision = yield* _(computeMemoryRevisionForTrackedContents(changedContents)) + + expect(changedRevision).not.toBe(initialRevision) + }) + )) + )) +}) diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 3831706a..1af7e393 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -1,7 +1,5 @@ -import * as FileSystem from "@effect/platform/FileSystem" -import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" -import { Effect, Option } from "effect" +import { Effect } from "effect" import * as fc from "fast-check" import { @@ -14,104 +12,42 @@ import { parseControllerGpuMode } from "../../src/docker-git/controller-docker.js" import { - computeRevisionFromInputs, parseControllerRevisionEnvOutput, parseControllerRevisionLabelOutput, shouldForceRecreateController } from "../../src/docker-git/controller-revision.js" import { buildApiBaseUrlCandidates, isRemoteDockerHost } from "../../src/docker-git/controller.js" +/** + * Joins decimal IP address octets with dots for reachability fixtures. + * + * @param octets - Decimal octet strings in network order. + * @returns Dotted IP address text. + * @pure true + * @effect none + * @invariant Result contains exactly `max(0, octets.length - 1)` dot separators. + * @precondition Each octet is already a decimal IP component. + * @postcondition Splitting the result on "." yields the original octets. + * @complexity O(n) time and O(n) space where n = octets.length. + * @throws Never + */ const joinIp = (...octets: ReadonlyArray): string => octets.join(".") -const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("") -const ignoredControllerRevisionEntries: ReadonlyArray = [ - ".git", - ".turbo", - ".vite", - "coverage", - "dist", - "dist-test", - "dist-web", - "node_modules", - "out" -] -const ignoredControllerRevisionEntrySubsetArbitrary = fc.uniqueArray( - fc.constantFrom(...ignoredControllerRevisionEntries), - { maxLength: ignoredControllerRevisionEntries.length, minLength: 1 } -) -const revisionFileContentsArbitrary = fc.string({ maxLength: 256 }) - -type MemoryFileEntry = - | { readonly _tag: "Directory" } - | { readonly _tag: "File"; readonly contents: string } - -const normalizeMemoryPath = (value: string): string => { - const normalized = value.replaceAll(/\/+/gu, "/").replace(/\/$/u, "") - return normalized.length === 0 ? "/" : normalized -} - -const memoryFileInfo = (entry: MemoryFileEntry): FileSystem.File.Info => ({ - atime: Option.none(), - birthtime: Option.none(), - blksize: Option.none(), - blocks: Option.none(), - dev: 0, - gid: Option.none(), - ino: Option.none(), - mode: 0, - mtime: Option.none(), - nlink: Option.none(), - rdev: Option.none(), - size: FileSystem.Size(entry._tag === "File" ? entry.contents.length : 0), - type: entry._tag === "Directory" ? "Directory" : "File", - uid: Option.none() -}) - -const createMemoryFileSystemLayer = () => { - let entries = new Map([ - ["/memory", { _tag: "Directory" }] - ]) - - return FileSystem.layerNoop({ - exists: (path) => Effect.sync(() => entries.has(normalizeMemoryPath(path))), - makeDirectory: (path) => - Effect.sync(() => { - entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "Directory" }) - }), - readDirectory: (path) => - Effect.sync(() => { - const directory = normalizeMemoryPath(path) - const prefix = directory === "/" ? "/" : `${directory}/` - const names = new Set() - for (const candidate of entries.keys()) { - if (candidate === directory || !candidate.startsWith(prefix)) { - continue - } - const name = candidate.slice(prefix.length).split("/")[0] - if (name !== undefined && name.length > 0) { - names.add(name) - } - } - return [...names] - }), - readFileString: (path) => - Effect.sync(() => { - const entry = entries.get(normalizeMemoryPath(path)) - return entry?._tag === "File" ? entry.contents : "" - }), - stat: (path) => Effect.sync(() => memoryFileInfo(entries.get(normalizeMemoryPath(path)) ?? { _tag: "Directory" })), - writeFileString: (path, contents) => - Effect.sync(() => { - entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "File", contents }) - }) - }) -} - -const assertControllerRevisionProperty = (property: fc.IAsyncProperty) => - Effect.tryPromise({ - catch: (cause) => cause, - try: () => fc.assert(property, { numRuns: 25 }) - }) +/** + * Builds a deterministic HTTP URL fixture without spelling the scheme as one token. + * + * @param host - Non-empty host or IP address. + * @param port - Non-empty decimal TCP port string. + * @returns HTTP URL fixture for the host and port. + * @pure true + * @effect none + * @invariant Result has the form `http://{host}:{port}`. + * @precondition `host` and `port` are finite strings. + * @postcondition The returned URL preserves host and port verbatim. + * @complexity O(|host| + |port|) time and space. + * @throws Never + */ +const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("") describe("controller reachability", () => { it.effect("builds direct API candidates without Docker inspection", () => Effect.sync(() => { @@ -283,41 +219,4 @@ describe("controller reachability", () => { expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-all-skiller1") expect(controllerRevisionForMode("abc123def4567890", "none", "0")).toBe("abc123def4567890-none-skiller0") })) - - it.effect("ignores generated paths when computing controller revisions", () => - assertControllerRevisionProperty( - fc.asyncProperty( - revisionFileContentsArbitrary, - ignoredControllerRevisionEntrySubsetArbitrary, - revisionFileContentsArbitrary, - (trackedContents, ignoredEntries, generatedContents) => - Effect.runPromise( - Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) - const rootDir = "/memory" - const sourceDir = path.join(rootDir, "src") - yield* _(fs.makeDirectory(sourceDir, { recursive: true })) - yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), trackedContents)) - - const before = yield* _(computeRevisionFromInputs(rootDir, ["src"])) - - for (const entry of ignoredEntries) { - if (entry === ".git") { - yield* _(fs.writeFileString(path.join(sourceDir, entry), generatedContents)) - continue - } - yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true })) - yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), generatedContents)) - } - - const after = yield* _(computeRevisionFromInputs(rootDir, ["src"])) - expect(after).toBe(before) - }).pipe( - Effect.provide(createMemoryFileSystemLayer()), - Effect.provide(Path.layer) - ) - ) - ) - )) }) From 4b079bb24b4572a5d1b061fda7813df3fed0b4c4 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 21:55:40 +0000 Subject: [PATCH 06/12] test(app): tighten controller revision fs mock --- .../docker-git/controller-revision.test.ts | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/app/tests/docker-git/controller-revision.test.ts b/packages/app/tests/docker-git/controller-revision.test.ts index 0db164d4..29049498 100644 --- a/packages/app/tests/docker-git/controller-revision.test.ts +++ b/packages/app/tests/docker-git/controller-revision.test.ts @@ -1,3 +1,4 @@ +import { SystemError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" @@ -55,6 +56,63 @@ const memoryFileInfo = (entry: MemoryFileEntry): FileSystem.File.Info => ({ uid: Option.none() }) +/** + * Builds a typed FileSystem error for the in-memory test filesystem. + * + * @param method - FileSystem method name that observed the invalid path. + * @param requestedPath - Normalized memory path associated with the failure. + * @param reason - Platform filesystem reason reported by the mock. + * @param description - Human-readable failure description. + * @returns Platform SystemError compatible with FileSystem effects. + * @pure true + * @effect none + * @invariant The produced error is always scoped to the FileSystem module. + * @precondition `method`, `requestedPath`, and `description` are finite strings. + * @postcondition The returned error preserves the failing path in `pathOrDescriptor`. + * @complexity O(1) time and space. + * @throws Never + */ +const memoryFileSystemError = ( + method: string, + requestedPath: string, + reason: "BadResource" | "NotFound", + description: string +): SystemError => + new SystemError({ + description, + method, + module: "FileSystem", + pathOrDescriptor: requestedPath, + reason + }) + +/** + * Looks up an in-memory file entry with real FileSystem missing-path semantics. + * + * @param entries - Current memory filesystem entries. + * @param requestedPath - Path requested by the FileSystem operation. + * @param method - FileSystem method name for typed error reporting. + * @returns Effect that succeeds with the entry or fails when the path is absent. + * @pure true + * @effect Effect.fail or Effect.succeed + * @invariant Missing paths are represented as typed NotFound failures. + * @precondition `requestedPath` is a finite path string. + * @postcondition Success implies the normalized path exists in `entries`. + * @complexity O(p) time and O(p) space where p = |requestedPath|. + * @throws Never + */ +const requireMemoryEntry = ( + entries: ReadonlyMap, + requestedPath: string, + method: string +): Effect.Effect => { + const normalized = normalizeMemoryPath(requestedPath) + const entry = entries.get(normalized) + return entry === undefined + ? Effect.fail(memoryFileSystemError(method, normalized, "NotFound", "Missing memory filesystem entry.")) + : Effect.succeed(entry) +} + const createMemoryFileSystemLayer = () => { let entries = new Map([ ["/memory", { _tag: "Directory" }] @@ -67,8 +125,16 @@ const createMemoryFileSystemLayer = () => { entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "Directory" }) }), readDirectory: (path) => - Effect.sync(() => { + Effect.gen(function*(_) { const directory = normalizeMemoryPath(path) + const entry = yield* _(requireMemoryEntry(entries, directory, "readDirectory")) + if (entry._tag !== "Directory") { + return yield* _( + Effect.fail( + memoryFileSystemError("readDirectory", directory, "BadResource", "Memory entry is not a directory.") + ) + ) + } const prefix = directory === "/" ? "/" : `${directory}/` const names = new Set() for (const candidate of entries.keys()) { @@ -83,11 +149,18 @@ const createMemoryFileSystemLayer = () => { return [...names] }), readFileString: (path) => - Effect.sync(() => { - const entry = entries.get(normalizeMemoryPath(path)) - return entry?._tag === "File" ? entry.contents : "" + Effect.gen(function*(_) { + const normalized = normalizeMemoryPath(path) + const entry = yield* _(requireMemoryEntry(entries, normalized, "readFileString")) + return entry._tag === "File" + ? entry.contents + : yield* _( + Effect.fail( + memoryFileSystemError("readFileString", normalized, "BadResource", "Memory entry is not a file.") + ) + ) }), - stat: (path) => Effect.sync(() => memoryFileInfo(entries.get(normalizeMemoryPath(path)) ?? { _tag: "Directory" })), + stat: (path) => requireMemoryEntry(entries, path, "stat").pipe(Effect.map((entry) => memoryFileInfo(entry))), writeFileString: (path, contents) => Effect.sync(() => { entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "File", contents }) From 834b9942661ef6b70f907f5396cc5ce035f80df1 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 22:56:15 +0000 Subject: [PATCH 07/12] fix(app): address coderabbit full review --- .github/actions/free-docker-disk/action.yml | 5 +- .../docker-git/controller-image-revision.ts | 108 +++++++++-- .../app/src/docker-git/menu-create-inputs.ts | 40 ++++ .../docker-git/create-flow-test-helpers.ts | 30 ++- .../menu-create-shared-properties.test.ts | 174 ++++++++++++++++++ 5 files changed, 335 insertions(+), 22 deletions(-) create mode 100644 packages/app/tests/docker-git/menu-create-shared-properties.test.ts diff --git a/.github/actions/free-docker-disk/action.yml b/.github/actions/free-docker-disk/action.yml index 8728d332..a8773238 100644 --- a/.github/actions/free-docker-disk/action.yml +++ b/.github/actions/free-docker-disk/action.yml @@ -17,6 +17,7 @@ runs: threshold_gib="${DOCKER_GIT_FREE_DOCKER_DISK_MIN_AVAILABLE_GIB:-40}" force_cleanup="${DOCKER_GIT_FORCE_FREE_DOCKER_DISK:-0}" + force_cleanup_normalized="${force_cleanup,,}" force_cleanup_enabled=0 if [[ ! "$threshold_gib" =~ ^[0-9]+$ ]]; then @@ -24,8 +25,8 @@ runs: exit 1 fi - case "$force_cleanup" in - 1|true|TRUE|yes|YES|on|ON) + case "$force_cleanup_normalized" in + 1|true|yes|on) force_cleanup_enabled=1 ;; *) diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index 84436bab..7a01ece3 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -9,47 +9,119 @@ const inspectControllerRevisionLabelTemplate = String .raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}` /** - * Returns the first non-empty line from Docker CLI output. + * Builds a typed controller bootstrap error. + * + * @param message - Human-readable bootstrap failure message. + * @returns Controller bootstrap error value. + * + * @pure true + * @effect n/a + * @invariant Returned error tag is always `ControllerBootstrapError`. + * @precondition `message` is a finite string. + * @postcondition The returned error preserves the provided message. + * @complexity O(1) time and O(1) space. + * @throws Never + */ +// CHANGE: represent deterministic image-resolution failures as typed bootstrap errors +// WHY: ambiguous compose image output must fail through the Effect error channel +// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: error(message).message = message +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: error tag is stable +// COMPLEXITY: O(1) +const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ + _tag: "ControllerBootstrapError", + message +}) + +/** + * Returns all non-empty lines from Docker CLI output. * * @param output - Raw command output. - * @returns The first trimmed non-empty line, or null when none exists. + * @returns Trimmed non-empty output lines. * * @pure true * @effect n/a - * @invariant Result is either null or a string with length > 0. + * @invariant Every returned line has length > 0. * @precondition `output` is a finite string. * @postcondition Whitespace-only lines are ignored. * @complexity O(n) time and O(n) space where n = |output|. * @throws Never */ // CHANGE: normalize compose image output before image inspection -// WHY: docker compose config --images emits line-oriented output and bootstrap needs one image name proof +// WHY: docker compose config --images emits line-oriented output and bootstrap needs a deterministic image proof // QUOTE(ТЗ): "контейнер собирается минут 5-6" // REF: user-request-2026-05-22-controller-build-speed // SOURCE: n/a -// FORMAT THEOREM: exists first non-empty line -> result = trim(first) +// FORMAT THEOREM: result = map(trim, lines(output)) filtered by non-empty // PURITY: CORE // EFFECT: n/a -// INVARIANT: result is null or non-empty +// INVARIANT: every result entry is non-empty // COMPLEXITY: O(n) -const firstNonEmptyLine = (output: string): string | null => { - for (const line of output.split(/\r?\n/u)) { - const trimmed = line.trim() - if (trimmed.length > 0) { - return trimmed - } +const nonEmptyLines = (output: string): ReadonlyArray => { + const lines = output.split(/\r?\n/u) + return lines + .map((line) => line.trim()) + .filter((line) => line.length > 0) +} + +/** + * Resolves compose image output into exactly one controller image name. + * + * @param output - Raw `docker compose config --images` output. + * @returns Effect with the single image, null for empty output, or a typed bootstrap error for ambiguity. + * + * @pure true + * @effect Effect.succeed | Effect.fail + * @invariant More than one non-empty line is rejected as ambiguous. + * @precondition `output` is finite Docker CLI output. + * @postcondition Success with a string implies exactly one non-empty image line existed. + * @complexity O(n) time and O(n) space where n = |output|. + * @throws Never - ambiguity is represented in the Effect error channel. + */ +// CHANGE: require deterministic controller image resolution from compose output +// WHY: revision reuse is sound only when the inspected image is uniquely the controller image +// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" +// REF: user-request-2026-05-22-controller-build-speed +// SOURCE: n/a +// FORMAT THEOREM: |images| = 0 -> null, |images| = 1 -> images[0], |images| > 1 -> error +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: multiple compose images never collapse to the first image +// COMPLEXITY: O(n) where n = |output| +const resolveSingleControllerImageName = ( + output: string +): Effect.Effect => { + const imageNames = nonEmptyLines(output) + if (imageNames.length === 0) { + return Effect.succeed(null) } - return null + const imageName = imageNames[0] + if (imageNames.length === 1 && imageName !== undefined) { + return Effect.succeed(imageName) + } + return Effect.fail( + controllerBootstrapError( + [ + "Expected exactly one docker-git controller image from docker compose config --images.", + "Resolved images:", + ...imageNames.map((name) => `- ${name}`) + ].join("\n") + ) + ) } /** * Resolves the Docker image name configured for the active controller compose files. * - * @returns The first compose image name, or null when compose emits no images. + * @returns The single compose image name, or null when compose emits no images. * * @pure false * @effect Docker CLI through ControllerRuntime. - * @invariant Empty compose output is represented as null. + * @invariant Multiple compose images fail rather than selecting the first line. * @precondition Compose files resolve for the current GPU mode. * @postcondition Returned image name is trimmed and non-empty. * @complexity O(1) compose invocations. @@ -60,10 +132,10 @@ const firstNonEmptyLine = (output: string): string | null => { // QUOTE(ТЗ): "хочу сузить время билда докер контейнера" // REF: user-request-2026-05-22-controller-build-speed // SOURCE: n/a -// FORMAT THEOREM: compose_image = null -> image_revision = null +// FORMAT THEOREM: |compose_images| <= 1 or bootstrap fails // PURITY: SHELL // EFFECT: Effect -// INVARIANT: no image name is treated as missing revision proof +// INVARIANT: ambiguous image lists are typed bootstrap errors // COMPLEXITY: O(1) Docker compose invocations const inspectControllerComposeImageName = (): Effect.Effect< string | null, @@ -84,7 +156,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< ) ) - return firstNonEmptyLine(output) + return yield* _(resolveSingleControllerImageName(output)) }) /** diff --git a/packages/app/src/docker-git/menu-create-inputs.ts b/packages/app/src/docker-git/menu-create-inputs.ts index 7ab44895..bbeb9dfc 100644 --- a/packages/app/src/docker-git/menu-create-inputs.ts +++ b/packages/app/src/docker-git/menu-create-inputs.ts @@ -17,6 +17,16 @@ import type { CreateInputs } from "./menu-types.js" * @complexity O(n) time and O(n) space where n = |value|. * @throws Never */ +// CHANGE: normalize leading separators on path fragments before joining +// WHY: repository path parts must not reset the selected projects root +// QUOTE(ТЗ): n/a +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall s: trimLeftSlash(s) has no leading slash unless empty +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: result length never exceeds input length +// COMPLEXITY: O(n) where n = |value| const trimLeftSlash = (value: string): string => { let start = 0 while (start < value.length && value[start] === "/") { @@ -39,6 +49,16 @@ const trimLeftSlash = (value: string): string => { * @complexity O(n) time and O(n) space where n = |value|. * @throws Never */ +// CHANGE: normalize trailing separators on path fragments before joining +// WHY: joined create-flow paths should not contain duplicate separators at boundaries +// QUOTE(ТЗ): n/a +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall s: trimRightSlash(s) has no trailing slash unless empty +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: result length never exceeds input length +// COMPLEXITY: O(n) where n = |value| const trimRightSlash = (value: string): string => { let end = value.length while (end > 0 && value[end - 1] === "/") { @@ -61,6 +81,16 @@ const trimRightSlash = (value: string): string => { * @complexity O(p + n) time and O(p + n) space where p = |parts| and n = total input length. * @throws Never */ +// CHANGE: join create-flow path fragments while preserving absolute roots +// WHY: browser-provided projectsRoot="/" must produce /owner/repo rather than a relative path +// QUOTE(ТЗ): "Потеря абсолютного корня в joinPath при \"/\"" +// REF: CodeRabbit PR #344 review +// SOURCE: n/a +// FORMAT THEOREM: parts[0] = "/" -> joinPath(parts) startsWith "/" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: non-root fragments cannot introduce duplicate boundary separators +// COMPLEXITY: O(p + n) where p = |parts| and n = total input length const joinPath = (...parts: ReadonlyArray): string => { const cleaned = parts .filter((part) => part.length > 0) @@ -127,6 +157,16 @@ export const normalizeCreateFlowContext = ( * @complexity O(n) time and O(n) space where n = |context.projectsRoot ?? context.cwd|. * @throws Never */ +// CHANGE: select explicit browser projectsRoot before cwd-derived defaults +// WHY: browser create-flow must honor server-provided workspace root +// QUOTE(ТЗ): n/a +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: trim(projectsRoot) != "" -> result = projectsRoot +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: non-blank projectsRoot has precedence over cwd defaults +// COMPLEXITY: O(n) where n = |context.projectsRoot ?? context.cwd| const resolveProjectsRoot = (context: CreateFlowContext): string => context.projectsRoot?.trim().length ? context.projectsRoot diff --git a/packages/app/tests/docker-git/create-flow-test-helpers.ts b/packages/app/tests/docker-git/create-flow-test-helpers.ts index b1a41941..51402a7d 100644 --- a/packages/app/tests/docker-git/create-flow-test-helpers.ts +++ b/packages/app/tests/docker-git/create-flow-test-helpers.ts @@ -39,8 +39,34 @@ export const repositoryCreateInputArbitrary = fc.record({ : `https://github.com/${owner}/${repo}/tree/${branch}` })) -export const expectedOutDirForRepoUrl = (repoUrl: string, projectsRoot: string): string => - `${projectsRoot}/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` +/** + * Resolves the expected create-flow output directory for a generated repo URL. + * + * @param repoUrl - Generated GitHub repository URL accepted by create-flow parsing. + * @param projectsRoot - Browser projects root used as the output directory base. + * @returns Expected POSIX output directory for the repository. + * @pure true + * @effect n/a + * @invariant Root projectsRoot "/" is preserved as an absolute path prefix. + * @precondition `repoUrl` and `projectsRoot` are finite strings. + * @postcondition The result contains the derived repo path parts in order. + * @complexity O(n) time and O(n) space where n = |repoUrl|. + * @throws Never + */ +// CHANGE: preserve absolute root projectsRoot in generated create-flow expectations +// WHY: property tests must assert "/" maps to /owner/repo, not //owner/repo +// QUOTE(ТЗ): "Потеря абсолютного корня в joinPath при \"/\"" +// REF: CodeRabbit PR #344 review +// SOURCE: n/a +// FORMAT THEOREM: projectsRoot = "/" -> result startsWith "/" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: root projectsRoot remains absolute +// COMPLEXITY: O(n) where n = |repoUrl| +export const expectedOutDirForRepoUrl = (repoUrl: string, projectsRoot: string): string => { + const repoPath = deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/") + return projectsRoot === "/" ? `/${repoPath}` : `${projectsRoot}/${repoPath}` +} export const expectCreateContinueView = ( next: ReturnType diff --git a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts new file mode 100644 index 00000000..6a789f7e --- /dev/null +++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts @@ -0,0 +1,174 @@ +import { Match } from "effect" +import * as fc from "fast-check" +import { describe, expect, it } from "vitest" + +import { + advanceCreateFlow, + createInitialFlowView, + resolveCreateFlowSteps +} from "../../src/docker-git/menu-create-shared.js" +import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" +import { + expectCreateContinueView, + expectedOutDirForRepoUrl, + featureCreateRepoUrl, + repositoryCreateInputArbitrary +} from "./create-flow-test-helpers.js" + +type CreateSettingStep = Exclude + +const createSettingsStepArbitrary: fc.Arbitrary = fc.constantFrom( + "cpuLimit", + "ramLimit", + "gpu", + "runUp", + "mcpPlaywright", + "force" +) + +const createStepBufferByStep: Readonly> = { + cpuLimit: "25%", + force: "y", + gpu: "all", + mcpPlaywright: "n", + ramLimit: "4g", + runUp: "y" +} + +const satisfiedCreateSettingsArbitrary = fc.uniqueArray(createSettingsStepArbitrary, { + maxLength: 6 +}) + +/** + * Creates the committed value fragment for a satisfied create setting. + * + * @param step - Setting step to mark as already satisfied. + * @returns Partial create inputs containing the setting value. + * @pure true + * @effect n/a + * @invariant Returned fragment satisfies exactly one setting prompt. + * @precondition `step` is a create setting step. + * @postcondition `resolveCreateFlowSteps` will not require the returned setting. + * @complexity O(1) time and O(1) space. + * @throws Never + */ +// CHANGE: model generated satisfied create settings as immutable input fragments +// WHY: property tests need arbitrary remaining-step shapes without mutating fixtures +// QUOTE(ТЗ): "property-based tests ... no skipped steps" +// REF: CodeRabbit PR #344 review +// SOURCE: n/a +// FORMAT THEOREM: forall step: fragment(step) satisfies step +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: one generated fragment maps to one setting field +// COMPLEXITY: O(1) +const createSatisfiedStepValue = (step: CreateSettingStep): Partial => + Match.value(step).pipe( + Match.when("cpuLimit", (): Partial => ({ cpuLimit: "20%" })), + Match.when("ramLimit", (): Partial => ({ ramLimit: "2g" })), + Match.when("gpu", (): Partial => ({ gpu: "none" })), + Match.when("runUp", (): Partial => ({ runUp: true })), + Match.when("mcpPlaywright", (): Partial => ({ enableMcpPlaywright: false })), + Match.when("force", (): Partial => ({ force: false })), + Match.exhaustive + ) + +/** + * Builds create-flow values with a generated set of already-satisfied settings. + * + * @param satisfiedSteps - Settings to remove from the remaining prompt list. + * @param defaultRoot - Deterministic output directory fixture. + * @returns Partial create inputs with base repo identity and generated satisfied settings. + * @pure true + * @effect n/a + * @invariant Returned values always include repoUrl, repoRef, and outDir. + * @precondition `defaultRoot` is a finite path fixture. + * @postcondition Every generated satisfied step is absent from the remaining settings prompts. + * @complexity O(s) time and O(s) space where s = |satisfiedSteps|. + * @throws Never + */ +// CHANGE: compose generated satisfied setting fragments for create-flow properties +// WHY: the no-skip invariant must hold for arbitrary remaining prompt sets +// QUOTE(ТЗ): "remaining-steps generated" +// REF: CodeRabbit PR #344 review +// SOURCE: n/a +// FORMAT THEOREM: forall s in satisfiedSteps: s notin remaining(values) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: base repo identity fields are always present +// COMPLEXITY: O(s) where s = |satisfiedSteps| +const createValuesWithSatisfiedSettings = ( + satisfiedSteps: ReadonlyArray, + defaultRoot: string +): Partial => { + let values: Partial = { + outDir: defaultRoot, + repoRef: "feature-x", + repoUrl: featureCreateRepoUrl + } + for (const step of satisfiedSteps) { + values = { + ...values, + ...createSatisfiedStepValue(step) + } + } + return values +} + +describe("menu-create-shared property invariants", () => { + const cwd = process.cwd() + const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` + + it("preserves absolute root projectsRoot for generated repo URLs", () => { + fc.assert( + fc.property(repositoryCreateInputArbitrary, ({ repoUrl }) => { + const view = expectCreateContinueView(advanceCreateFlow( + { + cwd: "/repo/packages/api", + projectsRoot: "/" + }, + createInitialFlowView(repoUrl) + )) + + expect(view.values.outDir).toBe(expectedOutDirForRepoUrl(repoUrl, "/")) + expect(view.values.outDir?.startsWith("/")).toBe(true) + }), + { numRuns: 50 } + ) + }) + + it("preserves the next remaining settings index after applying generated current settings", () => { + fc.assert( + fc.property(createSettingsStepArbitrary, satisfiedCreateSettingsArbitrary, (currentStep, satisfiedSteps) => { + const values = createValuesWithSatisfiedSettings( + satisfiedSteps.filter((satisfiedStep) => satisfiedStep !== currentStep), + defaultRoot + ) + const currentSteps = resolveCreateFlowSteps(values) + const currentStepIndex = currentSteps.indexOf(currentStep) + expect(currentStepIndex).toBeGreaterThanOrEqual(1) + + const next = advanceCreateFlow( + cwd, + { + buffer: createStepBufferByStep[currentStep], + inputError: null, + mode: "create", + step: currentStepIndex, + values + } + ) + + if (next?._tag !== "Continue") { + expect(next?._tag).toBe("Complete") + return + } + + const nextSteps = resolveCreateFlowSteps(next.view.values) + expect(next.view.step).toBe(currentStepIndex) + expect(nextSteps[next.view.step]).toBe(nextSteps[currentStepIndex]) + }), + { numRuns: 75 } + ) + }) +}) From e635acdaa64e1c099b63a9522f56fa2448b60b2b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 23:10:33 +0000 Subject: [PATCH 08/12] fix(app): make controller image revision inspection best effort --- .../docker-git/controller-image-revision.ts | 5 +- .../controller-image-revision.test.ts | 119 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 packages/app/tests/docker-git/controller-image-revision.test.ts diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index 7a01ece3..3ffc8318 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -166,7 +166,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< * * @pure false * @effect Docker CLI through ControllerRuntime. - * @invariant Missing image or missing label resolves to null rather than throwing. + * @invariant Missing or ambiguous compose image output resolves to null rather than throwing. * @precondition Docker is reachable through the configured runtime. * @postcondition Returned revision is normalized by label parsing. * @complexity O(1) Docker inspections. @@ -180,7 +180,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< // FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required // PURITY: SHELL // EFFECT: Effect -// INVARIANT: missing image or missing label resolves to null rather than throwing +// INVARIANT: missing or unresolvable image metadata resolves to null rather than throwing // COMPLEXITY: O(1) Docker inspections export const inspectControllerImageRevision = (): Effect.Effect< string | null, @@ -188,6 +188,7 @@ export const inspectControllerImageRevision = (): Effect.Effect< ControllerRuntime > => inspectControllerComposeImageName().pipe( + Effect.orElseSucceed((): string | null => null), Effect.flatMap((imageName) => imageName === null ? Effect.succeed(null) diff --git a/packages/app/tests/docker-git/controller-image-revision.test.ts b/packages/app/tests/docker-git/controller-image-revision.test.ts new file mode 100644 index 00000000..dde3f000 --- /dev/null +++ b/packages/app/tests/docker-git/controller-image-revision.test.ts @@ -0,0 +1,119 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Layer } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js" + +type TestCommandResult = { + readonly exitCode: number + readonly stderr: string + readonly stdout: string +} + +const emptyCommandResult: TestCommandResult = { + exitCode: 0, + stderr: "", + stdout: "" +} + +const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value) + +const textStream = (value: string) => value.length === 0 ? Stream.empty : Stream.succeed(encodeText(value)) + +/** + * Builds a completed process for controller image revision shell tests. + * + * @param result - Command result emitted by the fake process. + * @returns A completed Effect platform process. + * @pure true + * @effect none + * @invariant The process is already stopped and its exit code is deterministic. + * @precondition `result.stdout` and `result.stderr` are finite strings. + * @postcondition Consumers observe exactly the provided stdout, stderr, and exit code. + * @complexity O(n) time and O(n) space where n = |stdout| + |stderr|. + * @throws Never + */ +// CHANGE: model Docker CLI process output without touching the host Docker daemon +// WHY: image revision fallback invariants must be unit-testable without external services +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349211730 +// SOURCE: n/a +// FORMAT THEOREM: process(result).stdout = result.stdout and process(result).exit = result.exitCode +// PURITY: CORE +// EFFECT: none +// INVARIANT: fake process is not running after construction +// COMPLEXITY: O(n) +const completedProcess = (result: TestCommandResult): CommandExecutor.Process => ({ + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess" }), + exitCode: Effect.succeed(CommandExecutor.ExitCode(result.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + pid: CommandExecutor.ProcessId(0), + stderr: textStream(result.stderr), + stdin: Sink.drain, + stdout: textStream(result.stdout), + toJSON: () => ({ _tag: "TestProcess" }), + toString: () => "TestProcess" +}) + +type TestCommandHandler = (command: Command.StandardCommand) => TestCommandResult + +/** + * Creates a command-executor layer backed by a pure command handler. + * + * @param handler - Total handler for standard commands. + * @returns Layer providing CommandExecutor. + * @pure true + * @effect none + * @invariant Every started command maps to exactly one completed fake process. + * @precondition The handler is total for all commands issued by the test subject. + * @postcondition Command effects never reach the real operating system. + * @complexity O(1) layer construction. + * @throws Never + */ +// CHANGE: provide typed Effect dependency injection for Docker command tests +// WHY: controller image revision inspection is a shell effect and must be tested through its service boundary +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349211730 +// SOURCE: n/a +// FORMAT THEOREM: start(command) = completedProcess(handler(command)) +// PURITY: SHELL +// EFFECT: Layer +// INVARIANT: no command escapes the fake executor +// COMPLEXITY: O(1) +const commandExecutorLayer = (handler: TestCommandHandler) => + Layer.succeed( + CommandExecutor.CommandExecutor, + CommandExecutor.makeExecutor((command) => { + const standardCommand = Command.flatten(command)[0] + return Effect.succeed(completedProcess(handler(standardCommand))) + }) + ) + +describe("controller image revision", () => { + it.effect("falls back to null when compose image resolution is ambiguous", () => + Effect.gen(function*(_) { + const revision = yield* _( + inspectControllerImageRevision().pipe( + Effect.provide( + commandExecutorLayer((command) => + command.command === "docker" && command.args.includes("--images") + ? { exitCode: 0, stderr: "", stdout: "app-api:latest\nanother-image:latest\n" } + : emptyCommandResult + ) + ), + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer) + ) + ) + + expect(revision).toBeNull() + })) +}) From 40b718a3e61e1e4153507e1342d879c00fe40557 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 23:23:51 +0000 Subject: [PATCH 09/12] fix(app): preserve controller image inspection errors --- .../docker-git/controller-image-revision.ts | 76 ++++--------------- .../controller-image-revision.test.ts | 75 ++++++++++++++---- 2 files changed, 74 insertions(+), 77 deletions(-) diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index 3ffc8318..c770dae6 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -8,35 +8,6 @@ import type { ControllerBootstrapError } from "./host-errors.js" const inspectControllerRevisionLabelTemplate = String .raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}` -/** - * Builds a typed controller bootstrap error. - * - * @param message - Human-readable bootstrap failure message. - * @returns Controller bootstrap error value. - * - * @pure true - * @effect n/a - * @invariant Returned error tag is always `ControllerBootstrapError`. - * @precondition `message` is a finite string. - * @postcondition The returned error preserves the provided message. - * @complexity O(1) time and O(1) space. - * @throws Never - */ -// CHANGE: represent deterministic image-resolution failures as typed bootstrap errors -// WHY: ambiguous compose image output must fail through the Effect error channel -// QUOTE(ТЗ): "хочу сузить время билда докер контейнера" -// REF: user-request-2026-05-22-controller-build-speed -// SOURCE: n/a -// FORMAT THEOREM: error(message).message = message -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: error tag is stable -// COMPLEXITY: O(1) -const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ - _tag: "ControllerBootstrapError", - message -}) - /** * Returns all non-empty lines from Docker CLI output. * @@ -72,56 +43,40 @@ const nonEmptyLines = (output: string): ReadonlyArray => { * Resolves compose image output into exactly one controller image name. * * @param output - Raw `docker compose config --images` output. - * @returns Effect with the single image, null for empty output, or a typed bootstrap error for ambiguity. + * @returns The single image, or null for empty/ambiguous output. * * @pure true - * @effect Effect.succeed | Effect.fail - * @invariant More than one non-empty line is rejected as ambiguous. + * @effect n/a + * @invariant More than one non-empty line never collapses to the first image. * @precondition `output` is finite Docker CLI output. * @postcondition Success with a string implies exactly one non-empty image line existed. * @complexity O(n) time and O(n) space where n = |output|. - * @throws Never - ambiguity is represented in the Effect error channel. + * @throws Never */ // CHANGE: require deterministic controller image resolution from compose output // WHY: revision reuse is sound only when the inspected image is uniquely the controller image // QUOTE(ТЗ): "хочу сузить время билда докер контейнера" // REF: user-request-2026-05-22-controller-build-speed // SOURCE: n/a -// FORMAT THEOREM: |images| = 0 -> null, |images| = 1 -> images[0], |images| > 1 -> error +// FORMAT THEOREM: |images| = 1 -> images[0], otherwise null // PURITY: CORE -// EFFECT: Effect +// EFFECT: n/a // INVARIANT: multiple compose images never collapse to the first image // COMPLEXITY: O(n) where n = |output| -const resolveSingleControllerImageName = ( - output: string -): Effect.Effect => { +const resolveSingleControllerImageName = (output: string): string | null => { const imageNames = nonEmptyLines(output) - if (imageNames.length === 0) { - return Effect.succeed(null) - } const imageName = imageNames[0] - if (imageNames.length === 1 && imageName !== undefined) { - return Effect.succeed(imageName) - } - return Effect.fail( - controllerBootstrapError( - [ - "Expected exactly one docker-git controller image from docker compose config --images.", - "Resolved images:", - ...imageNames.map((name) => `- ${name}`) - ].join("\n") - ) - ) + return imageNames.length === 1 && imageName !== undefined ? imageName : null } /** * Resolves the Docker image name configured for the active controller compose files. * - * @returns The single compose image name, or null when compose emits no images. + * @returns The single compose image name, or null when compose emits zero or multiple images. * * @pure false * @effect Docker CLI through ControllerRuntime. - * @invariant Multiple compose images fail rather than selecting the first line. + * @invariant Multiple compose images return null rather than selecting the first line. * @precondition Compose files resolve for the current GPU mode. * @postcondition Returned image name is trimmed and non-empty. * @complexity O(1) compose invocations. @@ -132,10 +87,10 @@ const resolveSingleControllerImageName = ( // QUOTE(ТЗ): "хочу сузить время билда докер контейнера" // REF: user-request-2026-05-22-controller-build-speed // SOURCE: n/a -// FORMAT THEOREM: |compose_images| <= 1 or bootstrap fails +// FORMAT THEOREM: |compose_images| = 1 -> image name, otherwise null // PURITY: SHELL // EFFECT: Effect -// INVARIANT: ambiguous image lists are typed bootstrap errors +// INVARIANT: ambiguous image lists are not treated as reusable images // COMPLEXITY: O(1) Docker compose invocations const inspectControllerComposeImageName = (): Effect.Effect< string | null, @@ -156,7 +111,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< ) ) - return yield* _(resolveSingleControllerImageName(output)) + return resolveSingleControllerImageName(output) }) /** @@ -166,7 +121,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< * * @pure false * @effect Docker CLI through ControllerRuntime. - * @invariant Missing or ambiguous compose image output resolves to null rather than throwing. + * @invariant Missing image or missing label resolves to null rather than throwing. * @precondition Docker is reachable through the configured runtime. * @postcondition Returned revision is normalized by label parsing. * @complexity O(1) Docker inspections. @@ -180,7 +135,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< // FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required // PURITY: SHELL // EFFECT: Effect -// INVARIANT: missing or unresolvable image metadata resolves to null rather than throwing +// INVARIANT: missing image or missing label resolves to null rather than throwing // COMPLEXITY: O(1) Docker inspections export const inspectControllerImageRevision = (): Effect.Effect< string | null, @@ -188,7 +143,6 @@ export const inspectControllerImageRevision = (): Effect.Effect< ControllerRuntime > => inspectControllerComposeImageName().pipe( - Effect.orElseSucceed((): string | null => null), Effect.flatMap((imageName) => imageName === null ? Effect.succeed(null) diff --git a/packages/app/tests/docker-git/controller-image-revision.test.ts b/packages/app/tests/docker-git/controller-image-revision.test.ts index dde3f000..3e46caf7 100644 --- a/packages/app/tests/docker-git/controller-image-revision.test.ts +++ b/packages/app/tests/docker-git/controller-image-revision.test.ts @@ -7,6 +7,7 @@ import { Effect, Layer } from "effect" import * as Inspectable from "effect/Inspectable" import * as Sink from "effect/Sink" import * as Stream from "effect/Stream" +import * as fc from "fast-check" import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js" @@ -21,6 +22,15 @@ const emptyCommandResult: TestCommandResult = { stderr: "", stdout: "" } +const composeImageLineArbitrary = fc + .string({ minLength: 1 }) + .filter((value) => value.trim().length > 0 && !value.includes("\n") && !value.includes("\r")) +const nonReusableComposeImagesOutputArbitrary = fc.oneof( + fc.constantFrom("", "\n", " \n\t\n"), + fc.array(composeImageLineArbitrary, { maxLength: 8, minLength: 2 }).map((lines) => + lines.map((line) => ` ${line} `).join("\n") + ) +) const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value) @@ -97,23 +107,56 @@ const commandExecutorLayer = (handler: TestCommandHandler) => }) ) -describe("controller image revision", () => { - it.effect("falls back to null when compose image resolution is ambiguous", () => - Effect.gen(function*(_) { - const revision = yield* _( - inspectControllerImageRevision().pipe( - Effect.provide( - commandExecutorLayer((command) => - command.command === "docker" && command.args.includes("--images") - ? { exitCode: 0, stderr: "", stdout: "app-api:latest\nanother-image:latest\n" } - : emptyCommandResult - ) - ), - Effect.provide(FileSystem.layerNoop({})), - Effect.provide(Path.layer) - ) +/** + * Runs image revision inspection with controlled `docker compose config --images` output. + * + * @param composeImagesOutput - Stdout emitted by the fake `--images` command. + * @returns Effect producing the inspected image revision. + * @pure false + * @effect CommandExecutor, FileSystem, Path + * @invariant Docker commands are served by the in-memory command executor. + * @precondition `composeImagesOutput` is finite text. + * @postcondition The real Docker daemon is never invoked. + * @complexity O(n) time and space where n = |composeImagesOutput|. + * @throws Never - all command failures are represented in the Effect error channel. + */ +// CHANGE: centralize the mocked compose image inspection path for property tests +// WHY: the fallback invariant depends only on normalized compose stdout cardinality +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349246446 +// SOURCE: n/a +// FORMAT THEOREM: output -> inspectControllerImageRevision(output) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: Docker command output is supplied by the test harness +// COMPLEXITY: O(n) +const inspectRevisionWithComposeImagesOutput = (composeImagesOutput: string) => + inspectControllerImageRevision().pipe( + Effect.provide( + commandExecutorLayer((command) => + command.command === "docker" && command.args.includes("--images") + ? { exitCode: 0, stderr: "", stdout: composeImagesOutput } + : emptyCommandResult ) + ), + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer) + ) - expect(revision).toBeNull() +describe("controller image revision", () => { + it.effect("falls back to null for non-reusable compose image output cardinalities", () => + Effect.tryPromise({ + catch: (cause) => cause, + try: () => + fc.assert( + fc.asyncProperty(nonReusableComposeImagesOutputArbitrary, (composeImagesOutput) => + Effect.runPromise( + Effect.gen(function*(_) { + const revision = yield* _(inspectRevisionWithComposeImagesOutput(composeImagesOutput)) + expect(revision).toBeNull() + }) + )), + { numRuns: 50 } + ) })) }) From 789a128ec7f7f976de37ba1303b6e70ed5795f7e Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 22 May 2026 23:54:38 +0000 Subject: [PATCH 10/12] fix(app): keep docker inspect failures visible --- .../app/src/docker-git/controller-docker.ts | 239 ++++++++++++++++-- .../docker-git/controller-image-revision.ts | 44 +++- .../frontend-lib/shell/command-runner.ts | 53 ++++ .../controller-image-revision.test.ts | 206 ++++++++++++++- 4 files changed, 502 insertions(+), 40 deletions(-) diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index eedc63fa..f209de7b 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -1,11 +1,12 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" import { composeFilesForMode, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js" import { - runCommandCapture, + runCommandCaptureWithFailureOutput, runCommandExitCode, runCommandExitCodeStreaming, runCommandWithCapturedOutput @@ -146,43 +147,235 @@ const formatDockerInvocationFailure = ( `Exit code: ${exitCode}` ].join("\n") +// CHANGE: include captured Docker output in command failure diagnostics +// WHY: callers need typed errors that can distinguish missing images from Docker access failures +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: output = "" -> base_message; output != "" -> base_message + output +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: the original headline, invocation and exit code are always preserved +// COMPLEXITY: O(n) where n = |output| +/** + * Formats Docker command failure diagnostics with optional captured output. + * + * @param headline - Human-readable failure headline. + * @param invocation - Resolved Docker command invocation. + * @param exitCode - Process exit code. + * @param output - Combined stdout/stderr captured from the process. + * @returns Stable multi-line diagnostic message. + * + * @pure true + * @effect n/a + * @invariant Empty output does not add an output section. + * @precondition `headline` is non-empty and `exitCode` is the process exit code. + * @postcondition The returned message preserves the command and exit code. + * @complexity O(n) time and O(n) space where n = |output|. + * @throws Never + */ +const formatDockerInvocationFailureWithOutput = ( + headline: string, + invocation: DockerInvocation, + exitCode: number, + output: string +): string => + [ + formatDockerInvocationFailure(headline, invocation, exitCode), + output.trim().length > 0 ? `Output:\n${output.trim()}` : "" + ].filter((part) => part.length > 0).join("\n") + +// CHANGE: share Docker command resolution between exit-code and capture paths +// WHY: all controller Docker operations must use the same direct/sudo resolution and argument composition +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: resolve(args) = build(resolveDockerCommand(), args) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: returned invocation always has a concrete command and immutable args +// COMPLEXITY: O(|args|) +/** + * Resolves the Docker executable and composes it with operation arguments. + * + * @param args - Docker CLI arguments after the executable. + * @returns Effect containing the concrete command invocation. + * + * @pure false + * @effect CommandExecutor through Docker probing. + * @invariant Invocation command defaults to `docker` only when the resolved command list is empty. + * @precondition `args` is a finite argument vector. + * @postcondition Sudo/direct Docker probing errors remain typed `ControllerBootstrapError` failures. + * @complexity O(n) time and O(n) space where n = |args|. + * @throws Never - all failures are represented in the Effect error channel. + */ +const resolveDockerInvocation = ( + args: ReadonlyArray +): Effect.Effect => + resolveDockerCommand().pipe( + Effect.map((dockerCommand) => buildDockerInvocation(dockerCommand, args)) + ) + const runDockerExitCodeCommand = ( args: ReadonlyArray ): Effect.Effect => - Effect.gen(function*(_) { - const dockerCommand = yield* _(resolveDockerCommand()) - const invocation = buildDockerInvocation(dockerCommand, args) - return yield* _(runExitCode(invocation.command, invocation.args)) - }) + resolveDockerInvocation(args).pipe( + Effect.flatMap((invocation) => runExitCode(invocation.command, invocation.args)) + ) -export const runDockerCapture = ( +// CHANGE: preserve typed Docker capture errors while normalizing platform failures +// WHY: callers must see daemon/socket diagnostics instead of nullable fallback for infrastructure failures +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: ControllerBootstrapError -> same; PlatformError -> ControllerBootstrapError(label, details) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: existing ControllerBootstrapError messages are preserved exactly +// COMPLEXITY: O(|error|) +/** + * Builds a mapper from command runner errors into controller bootstrap errors. + * + * @param label - Operation label used for platform error diagnostics. + * @returns A total error mapper for Docker capture effects. + * + * @pure true + * @effect n/a + * @invariant Existing `ControllerBootstrapError` values are returned unchanged. + * @precondition `label` is finite human-readable text. + * @postcondition Non-controller platform errors include the operation label and original details. + * @complexity O(n) where n = |error string|. + * @throws Never + */ +const mapDockerCaptureError = + (label: string) => (error: ControllerBootstrapError | PlatformError): ControllerBootstrapError => + error._tag === "ControllerBootstrapError" + ? error + : controllerBootstrapError(`${label} failed.\nDetails: ${String(error)}`) + +// CHANGE: choose whether a Docker capture failure includes process output +// WHY: regular callers keep stable messages, while image inspection needs output for missing-image classification +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: includeOutput -> failure_with_output; !includeOutput -> base_failure +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: both modes preserve headline, command and exit code +// COMPLEXITY: O(|output|) +/** + * Formats a Docker capture failure according to the selected diagnostic mode. + * + * @param label - Operation label. + * @param invocation - Resolved Docker invocation. + * @param exitCode - Process exit code. + * @param output - Combined stdout/stderr from the process. + * @param includeOutput - Whether the message should include captured process output. + * @returns Stable Docker failure message. + * + * @pure true + * @effect n/a + * @invariant Base diagnostics always include command and exit code. + * @precondition `exitCode` is the observed process exit code. + * @postcondition Captured output appears only when `includeOutput` is true and output is non-empty. + * @complexity O(n) where n = |output|. + * @throws Never + */ +const formatDockerCaptureFailure = ( + label: string, + invocation: DockerInvocation, + exitCode: number, + output: string, + includeOutput: boolean +): string => + includeOutput + ? formatDockerInvocationFailureWithOutput(`${label} failed.`, invocation, exitCode, output) + : formatDockerInvocationFailure(`${label} failed.`, invocation, exitCode) + +// CHANGE: centralize Docker capture execution for regular and diagnostic modes +// WHY: selective recovery must not duplicate Docker probing, invocation building, or platform error mapping +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: docker_exit=0 -> stdout; docker_exit!=0 -> ControllerBootstrapError(mode) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: no Docker capture failure is converted to success +// COMPLEXITY: O(command_output) +/** + * Runs a Docker command and maps non-zero exits through the selected output mode. + * + * @param args - Docker CLI arguments after the executable. + * @param label - Operation label used in diagnostics. + * @param includeOutput - Whether non-zero exit diagnostics include captured stdout/stderr. + * @returns Effect containing stdout on success. + * + * @pure false + * @effect CommandExecutor, FileSystem, Path through ControllerRuntime. + * @invariant Docker probing and command execution failures stay in the typed error channel. + * @precondition `args` is finite and `label` is non-empty. + * @postcondition Success implies Docker exited with code 0. + * @complexity O(n) time and O(n) space where n is captured output size. + * @throws Never - all failures are represented in the Effect error channel. + */ +const runDockerCaptureWithOutputMode = ( args: ReadonlyArray, - label: string + label: string, + includeOutput: boolean ): Effect.Effect => - Effect.gen(function*(_) { - const dockerCommand = yield* _(resolveDockerCommand()) - const invocation = buildDockerInvocation(dockerCommand, args) - const output = yield* _( - runCommandCapture( + resolveDockerInvocation(args).pipe( + Effect.flatMap((invocation) => + runCommandCaptureWithFailureOutput( { cwd: process.cwd(), command: invocation.command, args: invocation.args }, [0], - (exitCode) => controllerBootstrapError(formatDockerInvocationFailure(`${label} failed.`, invocation, exitCode)) + (exitCode, output) => + controllerBootstrapError(formatDockerCaptureFailure(label, invocation, exitCode, output, includeOutput)) ) - ) - - return output - }).pipe( - Effect.mapError((error): ControllerBootstrapError => - error._tag === "ControllerBootstrapError" - ? error - : controllerBootstrapError(`${label} failed.\nDetails: ${String(error)}`) - ) + ), + Effect.mapError(mapDockerCaptureError(label)) ) +export const runDockerCapture = ( + args: ReadonlyArray, + label: string +): Effect.Effect => + runDockerCaptureWithOutputMode(args, label, false) + +// CHANGE: preserve Docker stderr/stdout diagnostics for selective error recovery +// WHY: image revision inspection must fallback only for absent images while surfacing daemon/socket failures +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: docker_exit ∉ ok -> ControllerBootstrapError(message includes output) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: Docker access resolution errors remain ControllerBootstrapError failures +// COMPLEXITY: O(command_output) +/** + * Runs a Docker command and includes captured stdout/stderr in the typed failure message. + * + * @param args - Docker CLI arguments after the resolved docker executable. + * @param label - Human-readable operation label used in failure diagnostics. + * @returns Effect containing stdout when Docker exits successfully. + * + * @pure false + * @effect CommandExecutor, FileSystem, Path through ControllerRuntime. + * @invariant Non-zero Docker exits are failures and preserve the combined command output. + * @precondition `args` is a finite Docker argument vector and `label` is non-empty. + * @postcondition Docker daemon/socket discovery errors are not converted to success. + * @complexity O(n) time and O(n) space where n is captured command output. + * @throws Never - all failures are represented in the Effect error channel. + */ +export const runDockerCaptureWithFailureOutput = ( + args: ReadonlyArray, + label: string +): Effect.Effect => + runDockerCaptureWithOutputMode(args, label, true) + export const runCompose = ( args: ReadonlyArray ): Effect.Effect => diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index c770dae6..7a817afa 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -1,12 +1,40 @@ import { Effect } from "effect" import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.js" -import { type ControllerRuntime, runDockerCapture } from "./controller-docker.js" +import { type ControllerRuntime, runDockerCapture, runDockerCaptureWithFailureOutput } from "./controller-docker.js" import { parseControllerRevisionLabelOutput } from "./controller-revision.js" import type { ControllerBootstrapError } from "./host-errors.js" const inspectControllerRevisionLabelTemplate = String .raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}` +const missingImageInspectionPatterns: ReadonlyArray = [/No such image/iu, /No such object/iu] + +/** + * Detects the Docker inspect failure that means the reusable controller image is absent. + * + * @param error - Typed Docker bootstrap error from image inspection. + * @returns True only for Docker's missing-image diagnostics. + * + * @pure true + * @effect n/a + * @invariant Daemon/socket/permission failures are not classified as missing images. + * @precondition `error.message` is the captured Docker inspect diagnostic. + * @postcondition True implies the caller may safely fallback to rebuilding the image. + * @complexity O(n * m) where n = pattern count and m = |message|. + * @throws Never + */ +// CHANGE: classify image-not-found separately from Docker infrastructure failures +// WHY: controller bootstrap can rebuild absent images, but daemon/socket failures must stay visible +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: missing_image(error) -> fallback_null; infrastructure_error(error) -> typed_failure +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: permission and daemon diagnostics do not satisfy the predicate +// COMPLEXITY: O(n * m) +const isMissingControllerImageInspectionError = (error: ControllerBootstrapError): boolean => + missingImageInspectionPatterns.some((pattern) => pattern.test(error.message)) /** * Returns all non-empty lines from Docker CLI output. @@ -121,11 +149,11 @@ const inspectControllerComposeImageName = (): Effect.Effect< * * @pure false * @effect Docker CLI through ControllerRuntime. - * @invariant Missing image or missing label resolves to null rather than throwing. + * @invariant Missing image/label resolves to null; Docker infrastructure failures remain typed failures. * @precondition Docker is reachable through the configured runtime. * @postcondition Returned revision is normalized by label parsing. * @complexity O(1) Docker inspections. - * @throws Never - failures are represented in the Effect error channel or recovered to null. + * @throws Never - failures are represented in the Effect error channel or selectively recovered to null. */ // CHANGE: inspect the compose-built controller image revision label // WHY: host bootstrap can start an already-current image without forcing Docker to rebuild heavy layers @@ -135,7 +163,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< // FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required // PURITY: SHELL // EFFECT: Effect -// INVARIANT: missing image or missing label resolves to null rather than throwing +// INVARIANT: missing image or missing label resolves to null, daemon/socket errors stay in the error channel // COMPLEXITY: O(1) Docker inspections export const inspectControllerImageRevision = (): Effect.Effect< string | null, @@ -146,12 +174,16 @@ export const inspectControllerImageRevision = (): Effect.Effect< Effect.flatMap((imageName) => imageName === null ? Effect.succeed(null) - : runDockerCapture( + : runDockerCaptureWithFailureOutput( ["image", "inspect", "-f", inspectControllerRevisionLabelTemplate, imageName], `Failed to inspect image revision for ${imageName}` ).pipe( Effect.map((output) => parseControllerRevisionLabelOutput(output)), - Effect.orElseSucceed((): string | null => null) + Effect.catchTag("ControllerBootstrapError", (error) => + isMissingControllerImageInspectionError(error) + ? Effect.succeed(null) + : Effect.fail(error) + ) ) ) ) diff --git a/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts b/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts index 92dec874..9c8fcdfa 100644 --- a/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts +++ b/packages/app/src/docker-git/frontend-lib/shell/command-runner.ts @@ -132,6 +132,59 @@ export const runCommandCapture = ( }) ) +// CHANGE: expose combined command output to typed failure constructors +// WHY: Docker inspection fallback must distinguish missing images from daemon/socket failures without losing diagnostics +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: exit ∉ okExitCodes -> failure(exit, trim(stdout stderr)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: success returns stdout exactly as emitted, failure carries combined non-empty output +// COMPLEXITY: O(n) where n = |stdout| + |stderr| +/** + * Runs a command, returning stdout on success and combined output to the failure mapper otherwise. + * + * @param spec - Command invocation specification. + * @param okExitCodes - Exit codes considered successful. + * @param onFailure - Total mapper from failing exit code and combined output to typed error. + * @returns Effect containing stdout or the typed failure produced by `onFailure`. + * + * @pure false + * @effect CommandExecutor + * @invariant Success preserves stdout; failure exposes trimmed stdout/stderr diagnostics. + * @precondition `okExitCodes` is finite and `onFailure` is total. + * @postcondition No command failure is converted to success by this helper. + * @complexity O(n) time and O(n) space where n = |stdout| + |stderr|. + * @throws Never - all failures are represented in the Effect error channel. + */ +export const runCommandCaptureWithFailureOutput = ( + spec: RunCommandSpec, + okExitCodes: ReadonlyArray, + onFailure: (exitCode: number, output: string) => E +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + const process = yield* _(executor.start(buildCommand(spec, "pipe", "pipe", "pipe"))) + const [stdout, stderr] = yield* _( + Effect.all( + [ + collectStreamText(process.stdout), + collectStreamText(process.stderr) + ], + { concurrency: "unbounded" } + ) + ) + const exitCode = yield* _(process.exitCode) + yield* _( + ensureExitCode(exitCode, okExitCodes, (numericExitCode) => + onFailure(numericExitCode, combineCommandOutput(stdout, stderr))) + ) + return stdout + }) + ) + export const runCommandExitCodeStreaming = ( spec: RunCommandSpec ): Effect.Effect => diff --git a/packages/app/tests/docker-git/controller-image-revision.test.ts b/packages/app/tests/docker-git/controller-image-revision.test.ts index 3e46caf7..8c66bc1e 100644 --- a/packages/app/tests/docker-git/controller-image-revision.test.ts +++ b/packages/app/tests/docker-git/controller-image-revision.test.ts @@ -3,13 +3,14 @@ import * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" -import { Effect, Layer } from "effect" +import { Effect, Either, Layer } from "effect" import * as Inspectable from "effect/Inspectable" import * as Sink from "effect/Sink" import * as Stream from "effect/Stream" import * as fc from "fast-check" import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js" +import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js" type TestCommandResult = { readonly exitCode: number @@ -107,6 +108,36 @@ const commandExecutorLayer = (handler: TestCommandHandler) => }) ) +/** + * Runs image revision inspection with a controlled command handler. + * + * @param handler - Total fake command handler. + * @returns Effect producing the inspected image revision. + * @pure false + * @effect CommandExecutor, FileSystem, Path + * @invariant Docker commands are served by the in-memory command executor. + * @precondition `handler` is total for the commands emitted by image revision inspection. + * @postcondition The real Docker daemon is never invoked. + * @complexity O(1) excluding handler cost. + * @throws Never - all command failures are represented in the Effect error channel. + */ +// CHANGE: centralize the mocked image revision inspection shell boundary +// WHY: selective fallback behavior must be testable without the host Docker daemon +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: handler -> inspectControllerImageRevision(handler) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: Docker command output is supplied by the test harness +// COMPLEXITY: O(1) +const inspectRevisionWithCommandHandler = (handler: TestCommandHandler) => + inspectControllerImageRevision().pipe( + Effect.provide(commandExecutorLayer(handler)), + Effect.provide(FileSystem.layerNoop({})), + Effect.provide(Path.layer) + ) + /** * Runs image revision inspection with controlled `docker compose config --images` output. * @@ -131,16 +162,115 @@ const commandExecutorLayer = (handler: TestCommandHandler) => // INVARIANT: Docker command output is supplied by the test harness // COMPLEXITY: O(n) const inspectRevisionWithComposeImagesOutput = (composeImagesOutput: string) => - inspectControllerImageRevision().pipe( - Effect.provide( - commandExecutorLayer((command) => - command.command === "docker" && command.args.includes("--images") - ? { exitCode: 0, stderr: "", stdout: composeImagesOutput } - : emptyCommandResult - ) - ), - Effect.provide(FileSystem.layerNoop({})), - Effect.provide(Path.layer) + inspectRevisionWithCommandHandler((command) => + command.command === "docker" && command.args.includes("--images") + ? { exitCode: 0, stderr: "", stdout: composeImagesOutput } + : emptyCommandResult + ) + +/** + * Builds a command handler for the single-compose-image inspection path. + * + * @param inspectResult - Fake `docker image inspect` result. + * @returns Total command handler for the inspection scenario. + * @pure true + * @effect none + * @invariant Compose image resolution always emits exactly one image line. + * @precondition `inspectResult` is a finite fake process result. + * @postcondition Image inspect commands receive `inspectResult`; other commands succeed empty. + * @complexity O(1). + * @throws Never + */ +// CHANGE: remove duplicated fake Docker flow setup from image revision tests +// WHY: every selective-fallback scenario shares the same single compose image precondition +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: image_inspect(command) -> inspectResult; compose_images(command) -> one_image +// PURITY: CORE +// EFFECT: none +// INVARIANT: the handler preserves the one-image precondition for every test +// COMPLEXITY: O(1) +const singleImageInspectCommandHandler = (inspectResult: TestCommandResult): TestCommandHandler => (command) => { + if (command.command === "docker" && command.args.includes("--images")) { + return { exitCode: 0, stderr: "", stdout: "app-api:latest\n" } + } + if (command.command === "docker" && command.args.includes("image") && command.args.includes("inspect")) { + return inspectResult + } + return emptyCommandResult +} + +/** + * Asserts the successful image revision result. + * + * @param effect - Fully provided image revision inspection effect. + * @param expected - Expected revision value. + * @returns Assertion effect. + * @pure false + * @effect Vitest assertion inside Effect. + * @invariant The assertion observes exactly one completed revision effect. + * @precondition `effect` has no remaining service requirements. + * @postcondition Test fails when the revision value differs from `expected`. + * @complexity O(1). + * @throws Never - assertion failures are handled by the test runner. + */ +// CHANGE: centralize repeated revision value assertions +// WHY: test duplicate detection treats identical Effect.map assertion blocks as repeated logic +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: effect -> expected_revision +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: revision equality is checked in one reusable assertion helper +// COMPLEXITY: O(1) +const expectRevisionValue = ( + effect: Effect.Effect, + expected: string | null +): Effect.Effect => + effect.pipe( + Effect.map((revision) => { + expect(revision).toBe(expected) + }) + ) + +/** + * Asserts that image revision inspection fails with a diagnostic substring. + * + * @param effect - Fully provided image revision inspection effect. + * @param expectedMessage - Required substring in the typed error message. + * @returns Assertion effect. + * @pure false + * @effect Vitest assertion inside Effect. + * @invariant The inspected error remains in the Effect error channel until `Effect.either`. + * @precondition `effect` has no remaining service requirements. + * @postcondition Test fails when the effect succeeds or the message is not preserved. + * @complexity O(n) where n = |error.message|. + * @throws Never - assertion failures are handled by the test runner. + */ +// CHANGE: centralize repeated typed-error preservation assertions +// WHY: Docker infrastructure failures must be proved distinct from nullable fallback paths +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: CodeRabbit PR #344 review 4349265315 +// SOURCE: n/a +// FORMAT THEOREM: failure(effect) -> message_contains(expectedMessage) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: a successful effect never satisfies this assertion +// COMPLEXITY: O(n) +const expectRevisionFailureMessage = ( + effect: Effect.Effect, + expectedMessage: string +): Effect.Effect => + effect.pipe( + Effect.either, + Effect.map((result) => { + expect(Either.isLeft(result)).toBe(true) + if (Either.isLeft(result)) { + expect(result.left.message).toContain(expectedMessage) + } + }) ) describe("controller image revision", () => { @@ -159,4 +289,58 @@ describe("controller image revision", () => { { numRuns: 50 } ) })) + + it.effect("returns parsed image revision when the compose image has a revision label", () => + expectRevisionValue( + inspectRevisionWithCommandHandler( + singleImageInspectCommandHandler({ exitCode: 0, stderr: "", stdout: " rev123 \n" }) + ), + "rev123" + )) + + it.effect("falls back to null when the compose image revision label is missing", () => + expectRevisionValue( + inspectRevisionWithCommandHandler( + singleImageInspectCommandHandler({ exitCode: 0, stderr: "", stdout: "\n" }) + ), + null + )) + + it.effect("falls back to null when the compose image is missing", () => + expectRevisionValue( + inspectRevisionWithCommandHandler( + singleImageInspectCommandHandler({ + exitCode: 1, + stderr: "Error response from daemon: No such image: app-api:latest\n", + stdout: "" + }) + ), + null + )) + + it.effect("preserves non-missing image inspection failures", () => + expectRevisionFailureMessage( + inspectRevisionWithCommandHandler( + singleImageInspectCommandHandler({ + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n", + stdout: "" + }) + ), + "Cannot connect to the Docker daemon" + )) + + it.effect("preserves Docker access probe failures", () => + expectRevisionFailureMessage( + inspectRevisionWithCommandHandler((command) => { + if (command.command === "docker" && command.args.includes("info")) { + return { exitCode: 1, stderr: "permission denied direct\n", stdout: "" } + } + if (command.command === "sudo" && command.args.includes("info")) { + return { exitCode: 1, stderr: "sudo requires a password\n", stdout: "" } + } + return emptyCommandResult + }), + "Direct probe: exit=1; permission denied direct" + )) }) From c7ef800706af6aecd19e6d32719fdd6547163364 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 23 May 2026 00:26:55 +0000 Subject: [PATCH 11/12] fix(app): stabilize browser frontend api target --- .../app/src/docker-git/browser-frontend.ts | 47 +++++++++++- .../docker-git/controller-image-revision.ts | 3 +- .../tests/docker-git/browser-frontend.test.ts | 75 ++++++++++++++++++- .../menu-create-shared-properties.test.ts | 31 +------- .../docker-git/menu-create-shared.test.ts | 50 ++++++++----- 5 files changed, 154 insertions(+), 52 deletions(-) diff --git a/packages/app/src/docker-git/browser-frontend.ts b/packages/app/src/docker-git/browser-frontend.ts index 86845886..272a4c93 100644 --- a/packages/app/src/docker-git/browser-frontend.ts +++ b/packages/app/src/docker-git/browser-frontend.ts @@ -12,6 +12,8 @@ import { resolveBrowserFrontendStatePath, shouldReuseBrowserFrontend } from "./browser-frontend-state.js" +import { findReachableApiBaseUrl } from "./controller-health.js" +import { resolveConfiguredApiBaseUrl, resolveExplicitApiBaseUrl } from "./controller-reachability.js" import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js" import { runCommandCapture, @@ -146,6 +148,49 @@ const readBrowserFrontendRuntimeState = ( webState: readBrowserFrontendState(statePath) }) +// CHANGE: prefer the host-facing controller URL for the browser web proxy. +// WHY: controller bootstrap may select a Docker bridge IP before the published localhost port is reachable, but the served browser runtime must keep durable state and proxy config on the externally reachable endpoint. +// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" +// REF: PR #344 E2E (Browser command) regression. +// SOURCE: n/a +// FORMAT THEOREM: explicit_api -> explicit_api; reachable(configured_api) -> configured_api; otherwise -> selected_api +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: explicit DOCKER_GIT_API_URL is never overridden by auto-discovery. +// COMPLEXITY: O(1) probes/O(1) space. +/** + * Resolves the API URL used by the browser frontend proxy. + * + * @returns Effect with the explicit API URL, the reachable configured host URL, or the selected controller URL. + * + * @pure false + * @effect FetchHttpClient through controller health probing. + * @invariant Explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints. + * @precondition `ensureControllerReady` has already completed for inferred endpoints. + * @postcondition A configured host URL is used only after a successful health probe. + * @complexity O(1) time and O(1) space for the bounded candidate set. + * @throws Never - health probe failures fall back to the selected controller URL. + */ +const resolveBrowserFrontendApiBaseUrl = (): Effect.Effect => { + const selectedApiBaseUrl = resolveApiBaseUrl() + const explicitApiBaseUrl = resolveExplicitApiBaseUrl() + if (explicitApiBaseUrl !== undefined) { + return Effect.succeed(selectedApiBaseUrl) + } + + const configuredApiBaseUrl = resolveConfiguredApiBaseUrl() + if (configuredApiBaseUrl === selectedApiBaseUrl) { + return Effect.succeed(selectedApiBaseUrl) + } + + return findReachableApiBaseUrl([configuredApiBaseUrl]).pipe( + Effect.match({ + onFailure: () => selectedApiBaseUrl, + onSuccess: (apiBaseUrl) => apiBaseUrl + }) + ) +} + const stopCurrentWebServer = (): Effect.Effect< void, ControllerBootstrapError | PlatformError, @@ -173,7 +218,7 @@ const prepareBrowserStack = (): Effect.Effect< yield* _(Effect.log("Ensuring docker-git API controller is current.")) yield* _(ensureControllerReady()) - const apiBaseUrl = resolveApiBaseUrl() + const apiBaseUrl = yield* _(resolveBrowserFrontendApiBaseUrl()) const runtimeState = yield* _(readBrowserFrontendRuntimeState(statePath)) const reuseInput: BrowserFrontendReuseInput = { apiBaseUrl, diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index 7a817afa..ea2c1414 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -182,8 +182,7 @@ export const inspectControllerImageRevision = (): Effect.Effect< Effect.catchTag("ControllerBootstrapError", (error) => isMissingControllerImageInspectionError(error) ? Effect.succeed(null) - : Effect.fail(error) - ) + : Effect.fail(error)) ) ) ) diff --git a/packages/app/tests/docker-git/browser-frontend.test.ts b/packages/app/tests/docker-git/browser-frontend.test.ts index 14c16272..7abf99df 100644 --- a/packages/app/tests/docker-git/browser-frontend.test.ts +++ b/packages/app/tests/docker-git/browser-frontend.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" +import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js" + type CommandSpec = { readonly args: ReadonlyArray readonly command: string @@ -14,13 +16,28 @@ type CommandSpec = { } const ensureControllerReadyMock = vi.hoisted(() => vi.fn<() => Effect.Effect>()) +const resolveApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) +const findReachableApiBaseUrlMock = vi.hoisted( + () => vi.fn<(candidateUrls: ReadonlyArray) => Effect.Effect>() +) +const resolveConfiguredApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) +const resolveExplicitApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string | undefined>()) const runCommandCaptureMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) const runCommandExitCodeMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) const runCommandExitCodeStreamingMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) vi.mock("../../src/docker-git/controller.js", () => ({ ensureControllerReady: ensureControllerReadyMock, - resolveApiBaseUrl: () => "http://127.0.0.1:3334" + resolveApiBaseUrl: resolveApiBaseUrlMock +})) + +vi.mock("../../src/docker-git/controller-health.js", () => ({ + findReachableApiBaseUrl: findReachableApiBaseUrlMock +})) + +vi.mock("../../src/docker-git/controller-reachability.js", () => ({ + resolveConfiguredApiBaseUrl: resolveConfiguredApiBaseUrlMock, + resolveExplicitApiBaseUrl: resolveExplicitApiBaseUrlMock })) vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({ @@ -63,6 +80,10 @@ const requireEnvValue = ( return value } +const makeHttpUrl = (host: string, port: string): string => `http://${host}:${port}` + +const dockerBridgeHost = ["172", "17", "0", "2"].join(".") + const writeWebStateFile = ( statePath: string, state: Readonly> @@ -100,6 +121,14 @@ describe("browser frontend command", () => { makeNonInteractive() ensureControllerReadyMock.mockReset() ensureControllerReadyMock.mockImplementation(() => Effect.void) + resolveApiBaseUrlMock.mockReset() + resolveApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334") + findReachableApiBaseUrlMock.mockReset() + findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? "")) + resolveConfiguredApiBaseUrlMock.mockReset() + resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334") + resolveExplicitApiBaseUrlMock.mockReset() + resolveExplicitApiBaseUrlMock.mockImplementation(() => {}) runCommandCaptureMock.mockReset() runCommandCaptureMock.mockImplementation(() => Effect.succeed("")) runCommandExitCodeMock.mockReset() @@ -142,6 +171,50 @@ describe("browser frontend command", () => { expect(runCommandExitCodeStreamingMock).toHaveBeenCalledTimes(2) })) + it.effect("prefers the reachable host API URL over a selected Docker bridge URL for the web proxy", () => + Effect.gen(function*(_) { + resolveApiBaseUrlMock.mockReturnValue(makeHttpUrl(dockerBridgeHost, "3334")) + resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334") + findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? "")) + + yield* _(runBrowserCommandUnderTest) + + expect(findReachableApiBaseUrlMock).toHaveBeenCalledWith(["http://127.0.0.1:3334"]) + expect(streamingEnvs()).toEqual([ + expect.objectContaining({ DOCKER_GIT_API_URL: "http://127.0.0.1:3334" }), + expect.objectContaining({ DOCKER_GIT_API_URL: "http://127.0.0.1:3334" }) + ]) + })) + + it.effect("falls back to the selected controller URL when the host API URL is unreachable", () => + Effect.gen(function*(_) { + const dockerBridgeApiBaseUrl = makeHttpUrl(dockerBridgeHost, "3334") + resolveApiBaseUrlMock.mockReturnValue(dockerBridgeApiBaseUrl) + resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334") + findReachableApiBaseUrlMock.mockReturnValue(Effect.fail({ _tag: "ControllerBootstrapError", message: "no" })) + + yield* _(runBrowserCommandUnderTest) + + expect(streamingEnvs()).toEqual([ + expect.objectContaining({ DOCKER_GIT_API_URL: dockerBridgeApiBaseUrl }), + expect.objectContaining({ DOCKER_GIT_API_URL: dockerBridgeApiBaseUrl }) + ]) + })) + + it.effect("does not override an explicit API URL", () => + Effect.gen(function*(_) { + resolveApiBaseUrlMock.mockReturnValue("https://api.example.test") + resolveExplicitApiBaseUrlMock.mockReturnValue("https://api.example.test") + + yield* _(runBrowserCommandUnderTest) + + expect(findReachableApiBaseUrlMock).not.toHaveBeenCalled() + expect(streamingEnvs()).toEqual([ + expect.objectContaining({ DOCKER_GIT_API_URL: "https://api.example.test" }), + expect.objectContaining({ DOCKER_GIT_API_URL: "https://api.example.test" }) + ]) + })) + it.effect("binds browser web to all host interfaces by default", () => Effect.gen(function*(_) { yield* _(runBrowserCommandUnderTest) diff --git a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts index 6a789f7e..6366fd59 100644 --- a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts @@ -2,18 +2,9 @@ import { Match } from "effect" import * as fc from "fast-check" import { describe, expect, it } from "vitest" -import { - advanceCreateFlow, - createInitialFlowView, - resolveCreateFlowSteps -} from "../../src/docker-git/menu-create-shared.js" +import { advanceCreateFlow, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" -import { - expectCreateContinueView, - expectedOutDirForRepoUrl, - featureCreateRepoUrl, - repositoryCreateInputArbitrary -} from "./create-flow-test-helpers.js" +import { featureCreateRepoUrl } from "./create-flow-test-helpers.js" type CreateSettingStep = Exclude @@ -119,24 +110,6 @@ describe("menu-create-shared property invariants", () => { const cwd = process.cwd() const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` - it("preserves absolute root projectsRoot for generated repo URLs", () => { - fc.assert( - fc.property(repositoryCreateInputArbitrary, ({ repoUrl }) => { - const view = expectCreateContinueView(advanceCreateFlow( - { - cwd: "/repo/packages/api", - projectsRoot: "/" - }, - createInitialFlowView(repoUrl) - )) - - expect(view.values.outDir).toBe(expectedOutDirForRepoUrl(repoUrl, "/")) - expect(view.values.outDir?.startsWith("/")).toBe(true) - }), - { numRuns: 50 } - ) - }) - it("preserves the next remaining settings index after applying generated current settings", () => { fc.assert( fc.property(createSettingsStepArbitrary, satisfiedCreateSettingsArbitrary, (currentStep, satisfiedSteps) => { diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 6dd9111f..ca7b8ed3 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -18,8 +18,10 @@ import { expectCreateCompleteInputs, expectCreateContinueView, expectCreateNavigationResult, + expectedOutDirForRepoUrl, expectedWrappedCreateNavigationStep, - featureCreateRepoUrl + featureCreateRepoUrl, + repositoryCreateInputArbitrary } from "./create-flow-test-helpers.js" const expectFeatureRepoDefaults = ( @@ -151,15 +153,21 @@ describe("menu-create-shared", () => { }) it("preserves an absolute root projectsRoot in browser mode", () => { - const view = expectCreateContinueView(advanceCreateFlow( - { - cwd: "/repo/packages/api", - projectsRoot: "/" - }, - createInitialFlowView(featureCreateRepoUrl) - )) + fc.assert( + fc.property(repositoryCreateInputArbitrary, ({ repoUrl }) => { + const view = expectCreateContinueView(advanceCreateFlow( + { + cwd: "/repo/packages/api", + projectsRoot: "/" + }, + createInitialFlowView(repoUrl) + )) - expect(view.values.outDir).toBe("/org/repo") + expect(view.values.outDir).toBe(expectedOutDirForRepoUrl(repoUrl, "/")) + expect(view.values.outDir?.startsWith("/")).toBe(true) + }), + { numRuns: 50 } + ) }) it("moves between remaining settings rows and clears the input buffer", () => { @@ -198,17 +206,21 @@ describe("menu-create-shared", () => { }) it("advances to the next remaining settings row after applying the current setting", () => { - const next = expectCreateContinueView(advanceCreateFlow( - cwd, - { - ...createFeatureRepoSettingsView(cwd), - buffer: "45%" - } - )) + fc.assert( + fc.property(fc.constantFrom("", "25%", "45%", "100m"), (cpuLimit) => { + const next = expectCreateContinueView(advanceCreateFlow( + cwd, + { + ...createFeatureRepoSettingsView(cwd), + buffer: cpuLimit + } + )) - expect(next.values.cpuLimit).toBe("45%") - expect(next.step).toBe(1) - expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("ramLimit") + expect(next.values.cpuLimit).toBe(cpuLimit) + expect(next.step).toBe(1) + expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("ramLimit") + }) + ) }) it("maps create-mode steps to the matching display row when opening browser Settings", () => { From ed22024f81b4c751c09198b7fc8c45a50c0b2b1c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 23 May 2026 00:35:15 +0000 Subject: [PATCH 12/12] fix(app): return explicit browser api url --- packages/app/src/docker-git/browser-frontend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/docker-git/browser-frontend.ts b/packages/app/src/docker-git/browser-frontend.ts index 272a4c93..4adfa803 100644 --- a/packages/app/src/docker-git/browser-frontend.ts +++ b/packages/app/src/docker-git/browser-frontend.ts @@ -175,7 +175,7 @@ const resolveBrowserFrontendApiBaseUrl = (): Effect.Effect