Skip to content

Commit e02977e

Browse files
committed
perf(api): speed up controller Docker rebuilds
1 parent 0d7607f commit e02977e

8 files changed

Lines changed: 448 additions & 29 deletions

File tree

.dockerignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
.git
2+
.codex
3+
.github
4+
.knowledge
25
node_modules
36
**/node_modules
47
**/.cache
8+
**/.turbo
9+
coverage
10+
dist
11+
dist-test
12+
dist-web
13+
packages/*/coverage
14+
packages/*/dist
15+
packages/*/dist-test
16+
packages/*/dist-web
17+
**/.vite
18+
**/*.log
519
third_party/skiller-desktop-skills-manager/out

docker-compose.api.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ services:
44
context: .
55
dockerfile: packages/api/Dockerfile
66
args:
7+
DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown}
78
UBUNTU_APT_MIRROR: ${UBUNTU_APT_MIRROR:-}
89
container_name: ${DOCKER_GIT_API_CONTAINER_NAME:-docker-git-api}
910
environment:
1011
DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334}
12+
DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown}
1113
DOCKER_GIT_DOCKER_RUNTIME: ${DOCKER_GIT_DOCKER_RUNTIME:-isolated}
1214
DOCKER_HOST: ${DOCKER_GIT_CONTROLLER_DOCKER_HOST:-unix:///var/run/docker.sock}
1315
DOCKER_GIT_DOCKERD_TCP_HOST: ${DOCKER_GIT_DOCKERD_TCP_HOST:-tcp://0.0.0.0:2375}

packages/api/Dockerfile

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
FROM ubuntu:26.04
1+
FROM ubuntu:26.04 AS controller-base
22

3-
ARG DOCKER_GIT_CONTROLLER_REV=unknown
43
ARG UBUNTU_APT_MIRROR=
5-
LABEL io.prover-coder-ai.docker-git.controller-rev=$DOCKER_GIT_CONTROLLER_REV
64

75
ENV DEBIAN_FRONTEND=noninteractive
8-
ENV DOCKER_GIT_CONTROLLER_REV=$DOCKER_GIT_CONTROLLER_REV
96
ENV BUN_INSTALL=/opt/bun
107
ENV PATH=/opt/bun/bin:$PATH
118
WORKDIR /workspace
@@ -55,16 +52,23 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
5552
&& npm i -g node-gyp \
5653
&& rm -rf /var/lib/apt/lists/*
5754

55+
FROM controller-base AS workspace-deps
56+
5857
COPY package.json bun.lock bunfig.toml tsconfig.base.json tsconfig.json ./
59-
COPY patches ./patches
60-
COPY scripts ./scripts
61-
COPY packages ./packages
62-
COPY .gitmodules ./.gitmodules
63-
COPY third_party ./third_party
58+
RUN mkdir -p packages/api packages/app packages/docker-git-session-sync packages/lib
59+
COPY packages/api/package.json ./packages/api/package.json
60+
COPY packages/app/package.json ./packages/app/package.json
61+
COPY packages/docker-git-session-sync/package.json ./packages/docker-git-session-sync/package.json
62+
COPY packages/lib/package.json ./packages/lib/package.json
6463

6564
RUN set -eu; \
6665
for attempt in 1 2 3 4 5; do \
67-
if bun install --frozen-lockfile --silent; then \
66+
if bun install \
67+
--frozen-lockfile \
68+
--silent \
69+
--filter @effect-template/api \
70+
--filter @effect-template/lib \
71+
--filter @prover-coder-ai/docker-git-session-sync; then \
6872
exit 0; \
6973
fi; \
7074
echo "bun install attempt ${attempt} failed; retrying..." >&2; \
@@ -73,8 +77,24 @@ RUN set -eu; \
7377
done; \
7478
echo "bun install failed after retries" >&2; \
7579
exit 1
80+
81+
FROM workspace-deps AS workspace-static
82+
83+
COPY patches ./patches
84+
COPY scripts ./scripts
85+
COPY packages/docker-git-session-sync ./packages/docker-git-session-sync
86+
COPY packages/lib ./packages/lib
87+
88+
RUN bun run --cwd packages/docker-git-session-sync build
7689
RUN bun run --cwd packages/lib build
77-
RUN bun run --cwd packages/api build
90+
91+
FROM controller-base AS skiller-build
92+
93+
COPY patches ./patches
94+
COPY scripts/skiller-apply-docker-git-patches.mjs ./scripts/skiller-apply-docker-git-patches.mjs
95+
COPY .gitmodules ./.gitmodules
96+
COPY third_party ./third_party
97+
7898
RUN bun scripts/skiller-apply-docker-git-patches.mjs
7999
RUN test -f third_party/skiller-desktop-skills-manager/package.json \
80100
&& cd third_party/skiller-desktop-skills-manager \
@@ -95,6 +115,18 @@ RUN test -f third_party/skiller-desktop-skills-manager/package.json \
95115
&& mkdir -p out/preload \
96116
&& ln -sf index.mjs out/preload/index.js
97117

118+
FROM workspace-static AS controller-final
119+
120+
COPY .gitmodules ./.gitmodules
121+
COPY --from=skiller-build /workspace/third_party/skiller-desktop-skills-manager ./third_party/skiller-desktop-skills-manager
122+
COPY packages/api ./packages/api
123+
124+
RUN ./packages/api/node_modules/.bin/tsc -p packages/api/tsconfig.json
125+
126+
ARG DOCKER_GIT_CONTROLLER_REV=unknown
127+
LABEL io.prover-coder-ai.docker-git.controller-rev=$DOCKER_GIT_CONTROLLER_REV
128+
129+
ENV DOCKER_GIT_CONTROLLER_REV=$DOCKER_GIT_CONTROLLER_REV
98130
ENV DOCKER_GIT_API_PORT=3334
99131
ENV DOCKER_GIT_DOCKER_RUNTIME=isolated
100132
ENV DOCKER_HOST=unix:///var/run/docker.sock
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
export type ControllerComposeUpPlan = {
2+
readonly buildController: boolean
3+
readonly forceRecreateController: boolean
4+
}
5+
6+
export type ControllerImageBuildInput = {
7+
readonly localControllerRevision: string
8+
readonly currentControllerRevision: string | null
9+
readonly currentImageRevision: string | null
10+
readonly forceRecreateController: boolean
11+
}
12+
13+
/**
14+
* Renders the docker compose `up` arguments for the controller bootstrap plan.
15+
*
16+
* @param plan - Immutable build/recreate decision.
17+
* @returns Compose arguments preserving the fixed `up -d` prefix.
18+
*
19+
* @pure true
20+
* @effect n/a
21+
* @invariant `--build` is present iff `plan.buildController`.
22+
* @precondition Plan booleans are already resolved from Docker state.
23+
* @postcondition Returned arguments contain no duplicate compose flags.
24+
* @complexity O(1) time and O(1) space.
25+
* @throws Never
26+
*/
27+
// CHANGE: derive docker compose up flags from explicit bootstrap requirements
28+
// WHY: matching controller images should be started without invalidating Docker build cache
29+
// QUOTE(ТЗ): "хочу сузить время билда докер контейнера"
30+
// REF: user-request-2026-05-22-controller-build-speed
31+
// SOURCE: n/a
32+
// FORMAT THEOREM: forall p: build(p) <=> "--build" in args(p)
33+
// PURITY: CORE
34+
// EFFECT: n/a
35+
// INVARIANT: forceRecreateController controls only --force-recreate
36+
// COMPLEXITY: O(1)
37+
export const resolveControllerComposeUpArgs = (
38+
plan: ControllerComposeUpPlan
39+
): ReadonlyArray<string> => [
40+
"up",
41+
"-d",
42+
...(plan.buildController ? ["--build"] : []),
43+
...(plan.forceRecreateController ? ["--force-recreate"] : [])
44+
]
45+
46+
/**
47+
* Decides whether the controller image must be rebuilt before `docker compose up`.
48+
*
49+
* @param input - Current controller/image revisions and recreate requirement.
50+
* @returns `true` only when neither reusable Docker object proves the local revision.
51+
*
52+
* @pure true
53+
* @effect n/a
54+
* @invariant A matching image revision is sufficient proof to skip build.
55+
* @precondition Revisions are normalized controller revision strings or null.
56+
* @postcondition Forced recreation rebuilds only when no matching image exists.
57+
* @complexity O(1) time and O(1) space.
58+
* @throws Never
59+
*/
60+
// CHANGE: decide whether controller bootstrap needs a Docker image build
61+
// WHY: source revision can be satisfied by either the existing container or an already-built image
62+
// QUOTE(ТЗ): "контейнер собирается минут 5-6"
63+
// REF: user-request-2026-05-22-controller-build-speed
64+
// SOURCE: n/a
65+
// FORMAT THEOREM: image_rev = local_rev -> build_required = false
66+
// PURITY: CORE
67+
// EFFECT: n/a
68+
// INVARIANT: forced recreation without a matching image requires a rebuild
69+
// COMPLEXITY: O(1)
70+
export const shouldBuildControllerImage = (input: ControllerImageBuildInput): boolean => {
71+
if (input.currentImageRevision === input.localControllerRevision) {
72+
return false
73+
}
74+
75+
return input.currentControllerRevision !== input.localControllerRevision || input.forceRecreateController
76+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Effect } from "effect"
2+
3+
import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.js"
4+
import { type ControllerRuntime, runDockerCapture } from "./controller-docker.js"
5+
import { parseControllerRevisionLabelOutput } from "./controller-revision.js"
6+
import type { ControllerBootstrapError } from "./host-errors.js"
7+
8+
const inspectControllerRevisionLabelTemplate = String
9+
.raw`{{ index .Config.Labels "io.prover-coder-ai.docker-git.controller-rev" }}`
10+
11+
/**
12+
* Returns the first non-empty line from Docker CLI output.
13+
*
14+
* @param output - Raw command output.
15+
* @returns The first trimmed non-empty line, or null when none exists.
16+
*
17+
* @pure true
18+
* @effect n/a
19+
* @invariant Result is either null or a string with length > 0.
20+
* @precondition `output` is a finite string.
21+
* @postcondition Whitespace-only lines are ignored.
22+
* @complexity O(n) time and O(n) space where n = |output|.
23+
* @throws Never
24+
*/
25+
// CHANGE: normalize compose image output before image inspection
26+
// WHY: docker compose config --images emits line-oriented output and bootstrap needs one image name proof
27+
// QUOTE(ТЗ): "контейнер собирается минут 5-6"
28+
// REF: user-request-2026-05-22-controller-build-speed
29+
// SOURCE: n/a
30+
// FORMAT THEOREM: exists first non-empty line -> result = trim(first)
31+
// PURITY: CORE
32+
// EFFECT: n/a
33+
// INVARIANT: result is null or non-empty
34+
// COMPLEXITY: O(n)
35+
const firstNonEmptyLine = (output: string): string | null => {
36+
for (const line of output.split(/\r?\n/u)) {
37+
const trimmed = line.trim()
38+
if (trimmed.length > 0) {
39+
return trimmed
40+
}
41+
}
42+
return null
43+
}
44+
45+
/**
46+
* Resolves the Docker image name configured for the active controller compose files.
47+
*
48+
* @returns The first compose image name, or null when compose emits no images.
49+
*
50+
* @pure false
51+
* @effect Docker CLI through ControllerRuntime.
52+
* @invariant Empty compose output is represented as null.
53+
* @precondition Compose files resolve for the current GPU mode.
54+
* @postcondition Returned image name is trimmed and non-empty.
55+
* @complexity O(1) compose invocations.
56+
* @throws Never - failures are represented in the Effect error channel.
57+
*/
58+
// CHANGE: resolve the compose-built controller image before comparing revisions
59+
// WHY: avoiding --build is sound only when the selected image already carries the local revision label
60+
// QUOTE(ТЗ): "хочу сузить время билда докер контейнера"
61+
// REF: user-request-2026-05-22-controller-build-speed
62+
// SOURCE: n/a
63+
// FORMAT THEOREM: compose_image = null -> image_revision = null
64+
// PURITY: SHELL
65+
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
66+
// INVARIANT: no image name is treated as missing revision proof
67+
// COMPLEXITY: O(1) Docker compose invocations
68+
const inspectControllerComposeImageName = (): Effect.Effect<
69+
string | null,
70+
ControllerBootstrapError,
71+
ControllerRuntime
72+
> =>
73+
Effect.gen(function*(_) {
74+
const composeFiles = yield* _(resolveControllerComposeFiles())
75+
const output = yield* _(
76+
runDockerCapture(
77+
[
78+
"compose",
79+
...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath),
80+
"config",
81+
"--images"
82+
],
83+
"Failed to resolve docker-git controller image"
84+
)
85+
)
86+
87+
return firstNonEmptyLine(output)
88+
})
89+
90+
/**
91+
* Reads the revision label from the image resolved by the active compose files.
92+
*
93+
* @returns Current image revision, or null when the image/label is missing.
94+
*
95+
* @pure false
96+
* @effect Docker CLI through ControllerRuntime.
97+
* @invariant Missing image or missing label resolves to null rather than throwing.
98+
* @precondition Docker is reachable through the configured runtime.
99+
* @postcondition Returned revision is normalized by label parsing.
100+
* @complexity O(1) Docker inspections.
101+
* @throws Never - failures are represented in the Effect error channel or recovered to null.
102+
*/
103+
// CHANGE: inspect the compose-built controller image revision label
104+
// WHY: host bootstrap can start an already-current image without forcing Docker to rebuild heavy layers
105+
// QUOTE(ТЗ): "контейнер собирается минут 5-6"
106+
// REF: user-request-2026-05-22-controller-build-speed
107+
// SOURCE: n/a
108+
// FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required
109+
// PURITY: SHELL
110+
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
111+
// INVARIANT: missing image or missing label resolves to null rather than throwing
112+
// COMPLEXITY: O(1) Docker inspections
113+
export const inspectControllerImageRevision = (): Effect.Effect<
114+
string | null,
115+
ControllerBootstrapError,
116+
ControllerRuntime
117+
> =>
118+
inspectControllerComposeImageName().pipe(
119+
Effect.flatMap((imageName) =>
120+
imageName === null
121+
? Effect.succeed<string | null>(null)
122+
: runDockerCapture(
123+
["image", "inspect", "-f", inspectControllerRevisionLabelTemplate, imageName],
124+
`Failed to inspect image revision for ${imageName}`
125+
).pipe(
126+
Effect.map((output) => parseControllerRevisionLabelOutput(output)),
127+
Effect.orElseSucceed((): string | null => null)
128+
)
129+
)
130+
)

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const controllerRevisionEnvKey = "DOCKER_GIT_CONTROLLER_REV"
77

88
const controllerRevisionInputs: ReadonlyArray<string> = [
99
"docker-compose.yml",
10+
"docker-compose.api.yml",
1011
"docker-compose.gpu.yml",
1112
"package.json",
1213
"bun.lock",
@@ -16,11 +17,23 @@ const controllerRevisionInputs: ReadonlyArray<string> = [
1617
"patches",
1718
"scripts",
1819
"packages/api",
19-
"packages/lib"
20+
"packages/docker-git-session-sync",
21+
"packages/lib",
22+
"third_party/skiller-desktop-skills-manager"
2023
]
2124

22-
const skippedDirectoryNames = new Set([".git", "node_modules", "dist", "dist-test", ".turbo"])
23-
const skippedFileNames = new Set([".DS_Store"])
25+
const skippedDirectoryNames = new Set([
26+
".git",
27+
".turbo",
28+
".vite",
29+
"coverage",
30+
"dist",
31+
"dist-test",
32+
"dist-web",
33+
"node_modules",
34+
"out"
35+
])
36+
const skippedFileNames = new Set([".DS_Store", ".git"])
2437

2538
const appendChunk = (chunks: Array<string>, value: string): void => {
2639
chunks.push(value)
@@ -102,6 +115,35 @@ export const parseControllerRevisionEnvOutput = (output: string): string | null
102115
return null
103116
}
104117

118+
// CHANGE: parse the controller image revision label from Docker inspect output
119+
// WHY: bootstrap can skip rebuilding when an existing image already proves the required revision
120+
// QUOTE(ТЗ): "хочу сузить время билда докер контейнера"
121+
// REF: user-request-2026-05-22-controller-build-speed
122+
// SOURCE: n/a
123+
// FORMAT THEOREM: forall output: blank(output) or missing_label(output) -> null
124+
// PURITY: CORE
125+
// EFFECT: n/a
126+
// INVARIANT: non-empty label text is preserved after trimming
127+
// COMPLEXITY: O(n) where n = |output|
128+
/**
129+
* Parses the docker-git controller revision label emitted by `docker image inspect`.
130+
*
131+
* @param output - Raw Go-template output from Docker.
132+
* @returns Trimmed revision string, or null when the label is absent.
133+
*
134+
* @pure true
135+
* @effect n/a
136+
* @invariant Blank and Docker `<no value>` outputs are treated as missing labels.
137+
* @precondition `output` is a finite string.
138+
* @postcondition Non-empty revision text is returned without surrounding whitespace.
139+
* @complexity O(n) time and O(n) space where n = |output|.
140+
* @throws Never
141+
*/
142+
export const parseControllerRevisionLabelOutput = (output: string): string | null => {
143+
const revision = output.trim()
144+
return revision.length === 0 || revision === "<no value>" ? null : revision
145+
}
146+
105147
export const shouldForceRecreateController = (
106148
controllerExists: boolean,
107149
localRevision: string,

0 commit comments

Comments
 (0)