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/actions/free-docker-disk/action.yml b/.github/actions/free-docker-disk/action.yml
index b466eef5..a8773238 100644
--- a/.github/actions/free-docker-disk/action.yml
+++ b/.github/actions/free-docker-disk/action.yml
@@ -7,10 +7,51 @@ runs:
- name: Free disk for Docker builds
shell: bash
run: |
- set -euxo pipefail
+ set -euo pipefail
- df -h
- docker system df || true
+ 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}"
+ force_cleanup_normalized="${force_cleanup,,}"
+ 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_normalized" in
+ 1|true|yes|on)
+ force_cleanup_enabled=1
+ ;;
+ *)
+ force_cleanup_enabled=0
+ ;;
+ esac
+
+ 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
+ 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 \
@@ -21,5 +62,7 @@ runs:
docker system prune -af --volumes || true
- df -h
- docker system df || true
+ if command -v df >/dev/null 2>&1; then
+ df -h || true
+ fi
+ timeout 20s docker system df || true
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 3011af06..7b324603 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -153,6 +153,9 @@ jobs:
name: E2E (Browser command)
runs-on: ubuntu-latest
timeout-minutes: 40
+ env:
+ DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0"
+ DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -170,6 +173,9 @@ jobs:
name: E2E (OpenCode)
runs-on: ubuntu-latest
timeout-minutes: 40
+ env:
+ DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0"
+ DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -187,6 +193,9 @@ jobs:
name: E2E (Clone cache)
runs-on: ubuntu-latest
timeout-minutes: 40
+ env:
+ DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0"
+ DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -204,6 +213,9 @@ jobs:
name: E2E (Login context)
runs-on: ubuntu-latest
timeout-minutes: 40
+ env:
+ DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0"
+ DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -221,6 +233,9 @@ jobs:
name: E2E (Runtime volumes + SSH)
runs-on: ubuntu-latest
timeout-minutes: 60
+ env:
+ DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0"
+ DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -238,6 +253,9 @@ jobs:
name: E2E (Clone auto-open SSH)
runs-on: ubuntu-latest
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/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/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/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/packages/app/src/docker-git/browser-frontend.ts b/packages/app/src/docker-git/browser-frontend.ts
index 86845886..4adfa803 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(explicitApiBaseUrl)
+ }
+
+ 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-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..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
@@ -21,7 +22,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
@@ -140,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
new file mode 100644
index 00000000..ea2c1414
--- /dev/null
+++ b/packages/app/src/docker-git/controller-image-revision.ts
@@ -0,0 +1,188 @@
+import { Effect } from "effect"
+
+import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.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.
+ *
+ * @param output - Raw command output.
+ * @returns Trimmed non-empty output lines.
+ *
+ * @pure true
+ * @effect n/a
+ * @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 a deterministic image proof
+// QUOTE(ТЗ): "контейнер собирается минут 5-6"
+// REF: user-request-2026-05-22-controller-build-speed
+// SOURCE: n/a
+// FORMAT THEOREM: result = map(trim, lines(output)) filtered by non-empty
+// PURITY: CORE
+// EFFECT: n/a
+// INVARIANT: every result entry is non-empty
+// COMPLEXITY: O(n)
+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 The single image, or null for empty/ambiguous output.
+ *
+ * @pure true
+ * @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
+ */
+// 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| = 1 -> images[0], otherwise null
+// PURITY: CORE
+// EFFECT: n/a
+// INVARIANT: multiple compose images never collapse to the first image
+// COMPLEXITY: O(n) where n = |output|
+const resolveSingleControllerImageName = (output: string): string | null => {
+ const imageNames = nonEmptyLines(output)
+ const imageName = imageNames[0]
+ 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 zero or multiple images.
+ *
+ * @pure false
+ * @effect Docker CLI through ControllerRuntime.
+ * @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.
+ * @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_images| = 1 -> image name, otherwise null
+// PURITY: SHELL
+// EFFECT: Effect
+// INVARIANT: ambiguous image lists are not treated as reusable images
+// 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 resolveSingleControllerImageName(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/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 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
+// 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, daemon/socket errors stay in the error channel
+// COMPLEXITY: O(1) Docker inspections
+export const inspectControllerImageRevision = (): Effect.Effect<
+ string | null,
+ ControllerBootstrapError,
+ ControllerRuntime
+> =>
+ inspectControllerComposeImageName().pipe(
+ Effect.flatMap((imageName) =>
+ imageName === null
+ ? Effect.succeed(null)
+ : runDockerCaptureWithFailureOutput(
+ ["image", "inspect", "-f", inspectControllerRevisionLabelTemplate, imageName],
+ `Failed to inspect image revision for ${imageName}`
+ ).pipe(
+ Effect.map((output) => parseControllerRevisionLabelOutput(output)),
+ Effect.catchTag("ControllerBootstrapError", (error) =>
+ isMissingControllerImageInspectionError(error)
+ ? Effect.succeed(null)
+ : Effect.fail(error))
+ )
+ )
+ )
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..95ffb2fb 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))
@@ -306,5 +317,12 @@ export const restartController = (): Effect.Effect(
})
)
+// 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/src/docker-git/menu-create-advance.ts b/packages/app/src/docker-git/menu-create-advance.ts
index 0c57d94c..a1e6d4bc 100644
--- a/packages/app/src/docker-git/menu-create-advance.ts
+++ b/packages/app/src/docker-git/menu-create-advance.ts
@@ -263,7 +263,7 @@ const resolveNextCreateFlowStep = (
): number =>
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..bbeb9dfc 100644
--- a/packages/app/src/docker-git/menu-create-inputs.ts
+++ b/packages/app/src/docker-git/menu-create-inputs.ts
@@ -3,6 +3,30 @@ 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
+ */
+// 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] === "/") {
@@ -11,6 +35,30 @@ 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
+ */
+// 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] === "/") {
@@ -19,24 +67,64 @@ 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
+ */
+// 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)
.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 +143,30 @@ 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
+ */
+// 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
@@ -63,9 +175,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 +207,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/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/controller-image-revision.test.ts b/packages/app/tests/docker-git/controller-image-revision.test.ts
new file mode 100644
index 00000000..8c66bc1e
--- /dev/null
+++ b/packages/app/tests/docker-git/controller-image-revision.test.ts
@@ -0,0 +1,346 @@
+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, 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
+ readonly stderr: string
+ readonly stdout: string
+}
+
+const emptyCommandResult: TestCommandResult = {
+ exitCode: 0,
+ 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)
+
+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)))
+ })
+ )
+
+/**
+ * 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.
+ *
+ * @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) =>
+ 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", () => {
+ 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 }
+ )
+ }))
+
+ 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"
+ ))
+})
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..29049498
--- /dev/null
+++ b/packages/app/tests/docker-git/controller-revision.test.ts
@@ -0,0 +1,278 @@
+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"
+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()
+})
+
+/**
+ * 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" }]
+ ])
+
+ 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.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()) {
+ 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.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) => 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 })
+ })
+ })
+}
+
+/**
+ * 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 9889c2de..1af7e393 100644
--- a/packages/app/tests/docker-git/controller.test.ts
+++ b/packages/app/tests/docker-git/controller.test.ts
@@ -1,16 +1,53 @@
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 {
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("")
+/**
+ * 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(() => {
@@ -92,6 +129,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 +144,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 +202,21 @@ 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")
}))
})
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..6366fd59
--- /dev/null
+++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts
@@ -0,0 +1,147 @@
+import { Match } from "effect"
+import * as fc from "fast-check"
+import { describe, expect, it } from "vitest"
+
+import { advanceCreateFlow, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js"
+import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js"
+import { featureCreateRepoUrl } 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 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 }
+ )
+ })
+})
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..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 = (
@@ -150,6 +152,24 @@ describe("menu-create-shared", () => {
expect(view.values.outDir).toBe("/home/dev/.docker-git/org/repo")
})
+ it("preserves an absolute root projectsRoot in browser mode", () => {
+ 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("moves between remaining settings rows and clears the input buffer", () => {
const view = createFeatureRepoSettingsView(cwd)
const editingView = { ...view, buffer: "stale" }
@@ -185,18 +205,22 @@ describe("menu-create-shared", () => {
)
})
- it("advances by one settings index after applying the current setting", () => {
- const next = expectCreateContinueView(advanceCreateFlow(
- cwd,
- {
- ...createFeatureRepoSettingsView(cwd),
- buffer: "45%"
- }
- ))
+ it("advances to the next remaining settings row after applying the current setting", () => {
+ 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(2)
- expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("gpu")
+ 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", () => {
diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh
index b669888e..b294514f 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,55 @@ 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
+}
+
+# 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
+
+ return 0
+}
+
dg_pick_free_port() {
local first_port="$1"
local last_port="$2"
@@ -430,31 +480,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."