mercury-agent 0.18.2 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/container/Dockerfile +25 -0
- package/container/build.sh +23 -3
- package/docs/behavior-layers.md +25 -14
- package/docs/configuration.md +139 -11
- package/docs/container-lifecycle.md +151 -1
- package/docs/context-architecture.md +6 -2
- package/docs/extensions.md +9 -1
- package/docs/goals/football-reporter-profile/decisions.md +79 -0
- package/docs/goals/rehearsal-bench/decisions.md +41 -3
- package/docs/goals/rehearsal-bench/roadmap.md +3 -1
- package/docs/goals/release-gate/decisions.md +86 -0
- package/docs/goals/release-gate/roadmap.md +36 -3
- package/docs/live-testing.md +22 -0
- package/docs/pending-verification.md +178 -0
- package/docs/permissions.md +1 -1
- package/docs/profile-guide.md +28 -8
- package/examples/extensions/archive/backends/local.ts +6 -3
- package/examples/extensions/archive/queue.ts +51 -10
- package/examples/extensions/feed-watch/config.ts +42 -2
- package/examples/extensions/feed-watch/digest.ts +4 -7
- package/examples/extensions/feed-watch/items.ts +18 -7
- package/examples/extensions/feed-watch/match.ts +125 -9
- package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
- package/examples/extensions/feed-watch/watch.ts +39 -5
- package/examples/extensions/gws/index.ts +126 -8
- package/examples/extensions/longview/hook.ts +72 -7
- package/examples/extensions/longview/index.ts +2 -0
- package/examples/extensions/morning/README.md +26 -15
- package/examples/extensions/morning/index.ts +29 -6
- package/examples/extensions/morning/lib/hosts.ts +16 -0
- package/examples/extensions/morning/lib/morning.ts +78 -0
- package/examples/extensions/morning/lib/upload.ts +584 -0
- package/examples/extensions/morning/skill/SKILL.md +34 -4
- package/examples/extensions/napkin/index.ts +12 -3
- package/examples/extensions/napkin/pi-spawn.ts +5 -1
- package/examples/extensions/overview/index.ts +20 -0
- package/examples/extensions/overview/skill/SKILL.md +9 -1
- package/examples/extensions/pinchtab/index.ts +36 -7
- package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
- package/examples/profiles/_template/AGENTS.md +6 -1
- package/examples/profiles/football-reporter/AGENTS.md +16 -4
- package/examples/profiles/football-reporter/README.md +1 -1
- package/examples/profiles/football-reporter/config.yaml +42 -6
- package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
- package/examples/profiles/football-reporter/standard.json +45 -8
- package/package.json +11 -6
- package/resources/skills/tasks/SKILL.md +17 -1
- package/resources/templates/AGENTS.md +4 -3
- package/resources/templates/mercury.example.yaml +13 -4
- package/src/adapters/whatsapp-ingress.ts +15 -3
- package/src/agent/container-entry.ts +299 -29
- package/src/agent/container-env.ts +26 -8
- package/src/agent/container-runner.ts +497 -76
- package/src/agent/image-contract.ts +77 -0
- package/src/agent/image-manifest.ts +180 -0
- package/src/agent/image-refresh.ts +318 -0
- package/src/cli/mercury.ts +225 -28
- package/src/cli/mrctl-http.ts +5 -0
- package/src/cli/mrctl.ts +57 -5
- package/src/cli/service-unit.ts +109 -0
- package/src/config-file.ts +22 -1
- package/src/config.ts +105 -14
- package/src/core/api.ts +11 -2
- package/src/core/commands.ts +11 -4
- package/src/core/connection-health.ts +377 -0
- package/src/core/direct-send.ts +235 -14
- package/src/core/exec.ts +10 -0
- package/src/core/history-window.ts +142 -0
- package/src/core/model-command.ts +130 -0
- package/src/core/operator-alerts.ts +326 -23
- package/src/core/permissions.ts +28 -0
- package/src/core/profiles.ts +32 -16
- package/src/core/reply-context.ts +31 -0
- package/src/core/routes/config-builtin.ts +11 -0
- package/src/core/routes/console.ts +98 -16
- package/src/core/routes/dashboard.ts +93 -10
- package/src/core/routes/model.ts +26 -3
- package/src/core/routes/send.ts +1 -1
- package/src/core/routes/tasks.ts +74 -0
- package/src/core/runtime.ts +302 -21
- package/src/core/system-messages.ts +33 -0
- package/src/core/task-scheduler.ts +155 -7
- package/src/extensions/image-builder.ts +1 -1
- package/src/extensions/installer.ts +72 -17
- package/src/extensions/load-project.ts +74 -0
- package/src/extensions/loader.ts +50 -9
- package/src/host-version.ts +32 -0
- package/src/main.ts +42 -21
- package/src/preflight/checks/credential.ts +300 -0
- package/src/preflight/checks/docker.ts +149 -0
- package/src/preflight/checks/extensions.ts +90 -0
- package/src/preflight/checks/host-deps.ts +247 -0
- package/src/preflight/checks/image-contract.ts +228 -0
- package/src/preflight/checks/roundtrip.ts +424 -0
- package/src/preflight/checks/sandbox.ts +159 -0
- package/src/preflight/deps.ts +107 -0
- package/src/preflight/probe-container.ts +156 -0
- package/src/preflight/report.ts +177 -0
- package/src/preflight/run.ts +223 -0
- package/src/server.ts +55 -14
- package/src/storage/db.ts +41 -2
- package/src/storage/models-json.ts +110 -0
- package/src/text/reporter-lint.ts +167 -23
- package/src/types.ts +22 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-vs-image contract: what the *host's* copy of Mercury believes the
|
|
3
|
+
* agent image must contain.
|
|
4
|
+
*
|
|
5
|
+
* On 2026-09-01 the first paying client's bot was born with `/health` green
|
|
6
|
+
* while every container it would spawn was doomed — the host ran mercury
|
|
7
|
+
* 0.17.0 against an August image with no `/app/src/text/`. Nothing compared
|
|
8
|
+
* the two. This list is that comparison's left-hand side, and
|
|
9
|
+
* `mercury preflight`'s `image.contract` check stats every entry inside the
|
|
10
|
+
* image the service would actually spawn.
|
|
11
|
+
*
|
|
12
|
+
* **The generator is `container/Dockerfile`.** These are its `COPY`
|
|
13
|
+
* destinations (excluding `--from=` stage copies and the `/tmp/` staging copy,
|
|
14
|
+
* which do not survive into `/app`). `tests/preflight-image-contract.test.ts`
|
|
15
|
+
* parses the Dockerfile and asserts set equality, so a `COPY` added or removed
|
|
16
|
+
* without an edit here turns the suite red rather than silently narrowing the
|
|
17
|
+
* contract.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The image's recorded mercury version: the manifest `container/Dockerfile`
|
|
22
|
+
* generates at build time (`src/agent/image-manifest.ts`; `version` comes from
|
|
23
|
+
* the `MERCURY_VERSION` build arg). A `RUN` output, not a `COPY`, so the check
|
|
24
|
+
* reads it separately from the contract list below.
|
|
25
|
+
*/
|
|
26
|
+
export const IMAGE_VERSION_FILE = "/app/image-manifest.json";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Paths that must exist inside the agent image. A trailing `/` marks a
|
|
30
|
+
* directory (`resources/` is copied wholesale); everything else is a file.
|
|
31
|
+
*/
|
|
32
|
+
export const IMAGE_CONTRACT_PATHS: readonly string[] = [
|
|
33
|
+
"/app/package.json",
|
|
34
|
+
"/app/src/agent/container-entry.ts",
|
|
35
|
+
"/app/src/agent/model-capabilities-core.ts",
|
|
36
|
+
"/app/src/agent/pi-failure-class.ts",
|
|
37
|
+
"/app/src/agent/pi-jsonl-parser.ts",
|
|
38
|
+
"/app/src/agent/preferences-prompt.ts",
|
|
39
|
+
"/app/src/cli/mrctl.ts",
|
|
40
|
+
"/app/src/cli/mrctl-http.ts",
|
|
41
|
+
"/app/src/extensions/reserved.ts",
|
|
42
|
+
"/app/src/extensions/permission-guard.ts",
|
|
43
|
+
"/app/src/text/sanitize-text.ts",
|
|
44
|
+
"/app/src/types.ts",
|
|
45
|
+
"/app/resources/",
|
|
46
|
+
"/app/container/Dockerfile",
|
|
47
|
+
"/app/src/agent/image-manifest.ts",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a Dockerfile's contract-bearing `COPY` destinations.
|
|
52
|
+
*
|
|
53
|
+
* Shared by the check's pin test and anything else that needs the generator's
|
|
54
|
+
* own answer. Skips `COPY --from=<stage>` (build-stage tooling: bun, node, go
|
|
55
|
+
* — not Mercury source) and any destination under `/tmp/` (staging that a
|
|
56
|
+
* later `RUN` deletes). Continuation lines (`\` at end) are joined first, so a
|
|
57
|
+
* wrapped `COPY` is read as one instruction — under CRLF too: the escape is a
|
|
58
|
+
* backslash then an optional CR then the newline, not `\r`.
|
|
59
|
+
*/
|
|
60
|
+
export function parseDockerfileCopyDestinations(dockerfile: string): string[] {
|
|
61
|
+
const joined = dockerfile.replace(/\\\r?\n\s*/g, " ");
|
|
62
|
+
const destinations: string[] = [];
|
|
63
|
+
for (const rawLine of joined.split("\n")) {
|
|
64
|
+
const line = rawLine.trim();
|
|
65
|
+
if (!/^COPY\s/i.test(line)) continue;
|
|
66
|
+
// Drop flags (`--from=…`, `--chown=…`). A stage copy is not part of the
|
|
67
|
+
// Mercury contract — it is the toolchain the base image is built from.
|
|
68
|
+
const tokens = line.split(/\s+/).slice(1);
|
|
69
|
+
if (tokens.some((t) => /^--from=/i.test(t))) continue;
|
|
70
|
+
const operands = tokens.filter((t) => !t.startsWith("--"));
|
|
71
|
+
const dest = operands[operands.length - 1];
|
|
72
|
+
if (!dest) continue;
|
|
73
|
+
if (dest.startsWith("/tmp/")) continue;
|
|
74
|
+
destinations.push(dest);
|
|
75
|
+
}
|
|
76
|
+
return destinations;
|
|
77
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent image's self-description.
|
|
3
|
+
*
|
|
4
|
+
* Two questions have no answer today, which is how a host on 0.18.2 ran a
|
|
5
|
+
* container built from 0.18.0-beta.0 for a day with `/health` green
|
|
6
|
+
* throughout: *what mercury version is this image* and *which files from the
|
|
7
|
+
* repo does it carry*. This module answers the second and carries the first,
|
|
8
|
+
* and it is run **inside the image at build time** against the Dockerfile that
|
|
9
|
+
* is building it — not against a copy in the repo — so the manifest cannot
|
|
10
|
+
* describe a build that did not happen.
|
|
11
|
+
*
|
|
12
|
+
* The host side (release-gate R1.1(c)) parses its own `container/Dockerfile`
|
|
13
|
+
* with this same function and compares: same version, same COPY sources. A
|
|
14
|
+
* difference means the image and the host disagree about what the container
|
|
15
|
+
* is, which is exactly the 0.17.0 shape.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
/** One build-context path a `COPY` instruction puts into the image. */
|
|
21
|
+
export interface CopyEntry {
|
|
22
|
+
/** Path in the build context, verbatim as the Dockerfile writes it. */
|
|
23
|
+
source: string;
|
|
24
|
+
/** Path inside the image. */
|
|
25
|
+
dest: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ImageManifest {
|
|
29
|
+
/** Mercury version this image was built from, passed in as a build arg. */
|
|
30
|
+
version: string;
|
|
31
|
+
/** SHA-256 of the Dockerfile that built it, for an exact-match check. */
|
|
32
|
+
dockerfileSha256: string;
|
|
33
|
+
/** Sorted, de-duplicated build-context sources. The drift check reads this. */
|
|
34
|
+
copySources: string[];
|
|
35
|
+
/** Every source with where it landed, for diagnosing a mismatch. */
|
|
36
|
+
entries: CopyEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Join Dockerfile line continuations so a wrapped instruction parses as one.
|
|
41
|
+
* Comment lines are dropped first: a `#` line inside a continuation is a
|
|
42
|
+
* comment in Dockerfile syntax, not part of the instruction.
|
|
43
|
+
*/
|
|
44
|
+
function logicalLines(dockerfile: string): string[] {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
let pending = "";
|
|
47
|
+
for (const raw of dockerfile.split(/\r?\n/)) {
|
|
48
|
+
const line = raw.trimEnd();
|
|
49
|
+
if (/^\s*#/.test(line)) continue;
|
|
50
|
+
if (line.endsWith("\\")) {
|
|
51
|
+
pending += `${line.slice(0, -1)} `;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out.push(pending + line);
|
|
55
|
+
pending = "";
|
|
56
|
+
}
|
|
57
|
+
if (pending) out.push(pending);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every `COPY` that takes a path from the build context.
|
|
63
|
+
*
|
|
64
|
+
* `COPY --from=<stage>` is deliberately excluded: those pull from another
|
|
65
|
+
* image (bun, node, go), not from this repo, so they are not files a host can
|
|
66
|
+
* check against its own checkout — and including them would make the drift
|
|
67
|
+
* check fail whenever an upstream base image changes, which is not drift.
|
|
68
|
+
*/
|
|
69
|
+
export function parseCopyInstructions(dockerfile: string): CopyEntry[] {
|
|
70
|
+
const entries: CopyEntry[] = [];
|
|
71
|
+
for (const line of logicalLines(dockerfile)) {
|
|
72
|
+
const match = /^\s*COPY\s+(.+)$/i.exec(line);
|
|
73
|
+
if (!match) continue;
|
|
74
|
+
const rest = match[1].trim();
|
|
75
|
+
// The JSON-array form quotes its arguments and would parse here as a
|
|
76
|
+
// source literally named `["src",`. Refuse rather than silently record
|
|
77
|
+
// nonsense: a manifest that is quietly wrong is worse than no manifest,
|
|
78
|
+
// and this whole module exists because something looked fine and was not.
|
|
79
|
+
if (rest.startsWith("[")) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`image-manifest: COPY in exec (JSON array) form is not supported, and ` +
|
|
82
|
+
`parsing it silently would produce a wrong manifest: ${line.trim()}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
let fromAnotherStage = false;
|
|
86
|
+
const args: string[] = [];
|
|
87
|
+
for (const token of rest.split(/\s+/)) {
|
|
88
|
+
if (token.startsWith("--")) {
|
|
89
|
+
if (token.startsWith("--from=")) fromAnotherStage = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
args.push(token);
|
|
93
|
+
}
|
|
94
|
+
if (fromAnotherStage) continue;
|
|
95
|
+
if (args.length < 2) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`image-manifest: COPY needs at least a source and a destination: ${line.trim()}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const dest = args[args.length - 1] as string;
|
|
101
|
+
for (const source of args.slice(0, -1)) {
|
|
102
|
+
entries.push({ source, dest });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build the manifest for a Dockerfile's text and a mercury version. */
|
|
109
|
+
export function buildImageManifest(
|
|
110
|
+
dockerfile: string,
|
|
111
|
+
version: string,
|
|
112
|
+
): ImageManifest {
|
|
113
|
+
const entries = parseCopyInstructions(dockerfile);
|
|
114
|
+
const copySources = [...new Set(entries.map((e) => e.source))].sort();
|
|
115
|
+
return {
|
|
116
|
+
version,
|
|
117
|
+
// Hashed with line endings normalised. The image is built on Linux and the
|
|
118
|
+
// host comparing against it may be a Windows checkout with CRLF, which is
|
|
119
|
+
// the same Dockerfile; without this the check would report drift on every
|
|
120
|
+
// Windows box and be switched off within a day.
|
|
121
|
+
dockerfileSha256: createHash("sha256")
|
|
122
|
+
.update(dockerfile.replace(/\r\n/g, "\n"))
|
|
123
|
+
.digest("hex"),
|
|
124
|
+
copySources,
|
|
125
|
+
entries,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compare a host's own Dockerfile against the manifest an image reports.
|
|
131
|
+
* Returns the differences, empty when the two agree.
|
|
132
|
+
*/
|
|
133
|
+
export function diffImageManifest(
|
|
134
|
+
hostDockerfile: string,
|
|
135
|
+
hostVersion: string,
|
|
136
|
+
image: ImageManifest,
|
|
137
|
+
): string[] {
|
|
138
|
+
const host = buildImageManifest(hostDockerfile, hostVersion);
|
|
139
|
+
const problems: string[] = [];
|
|
140
|
+
if (host.version !== image.version) {
|
|
141
|
+
problems.push(
|
|
142
|
+
`version: host is ${host.version}, image reports ${image.version}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
for (const source of host.copySources) {
|
|
146
|
+
if (!image.copySources.includes(source)) {
|
|
147
|
+
problems.push(`missing from image: ${source}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (const source of image.copySources) {
|
|
151
|
+
if (!host.copySources.includes(source)) {
|
|
152
|
+
problems.push(`in image but not in this host's Dockerfile: ${source}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Reported last and only when the lists already agree: an identical COPY
|
|
156
|
+
// list with a different hash means the image was built from a Dockerfile
|
|
157
|
+
// that differs somewhere else (a RUN, a base image, an ENV), which is real
|
|
158
|
+
// drift but a much weaker signal than a missing file.
|
|
159
|
+
if (
|
|
160
|
+
problems.length === 0 &&
|
|
161
|
+
host.dockerfileSha256 !== image.dockerfileSha256
|
|
162
|
+
) {
|
|
163
|
+
problems.push(
|
|
164
|
+
`Dockerfile differs outside its COPY list (host ${host.dockerfileSha256.slice(0, 12)}, image ${image.dockerfileSha256.slice(0, 12)})`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return problems;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Build-time entry point: `bun run image-manifest.ts <dockerfile> <version>`.
|
|
171
|
+
// Writes the manifest to stdout; the Dockerfile redirects it into the image.
|
|
172
|
+
if (import.meta.main) {
|
|
173
|
+
const [dockerfilePath, version] = process.argv.slice(2);
|
|
174
|
+
if (!dockerfilePath || !version) {
|
|
175
|
+
console.error("usage: image-manifest.ts <dockerfile-path> <version>");
|
|
176
|
+
process.exit(2);
|
|
177
|
+
}
|
|
178
|
+
const text = await Bun.file(dockerfilePath).text();
|
|
179
|
+
console.log(JSON.stringify(buildImageManifest(text, version), null, 2));
|
|
180
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a floating agent-image tag has to be fetched again.
|
|
3
|
+
*
|
|
4
|
+
* `ensureImage()` used to return the moment `docker image inspect` succeeded.
|
|
5
|
+
* The default image is a **floating** tag
|
|
6
|
+
* (`ghcr.io/…/mercury-agent:latest`, `DEFAULT_AGENT_CONTAINER_IMAGE`), so once
|
|
7
|
+
* a host had pulled it that tag was never fetched again for the life of the
|
|
8
|
+
* machine: a box upgraded through npm kept running last month's container
|
|
9
|
+
* against this month's host code, with `/health` at 200 and nothing in the
|
|
10
|
+
* log. That is the "green /health, dead bot" mechanism, and it bit the owner
|
|
11
|
+
* on 2026-09-05.
|
|
12
|
+
*
|
|
13
|
+
* The rule here is the narrow half of release-gate R0.4: **a floating tag is
|
|
14
|
+
* re-pulled whenever the host's Mercury version is not the one that pulled
|
|
15
|
+
* it.** Pinning the default to `:${pkg.version}` is the other half and waits
|
|
16
|
+
* on R0.3 — Mercury publishes no per-version tag today, so pinning first would
|
|
17
|
+
* make every fresh install fail on a tag nobody pushed.
|
|
18
|
+
*
|
|
19
|
+
* The decision is pure and the flow (`ensureAgentImage`) drives its pull and
|
|
20
|
+
* inspect through injected deps, so both can be tested without a daemon. Only
|
|
21
|
+
* the pull record's read and write live in `container-runner.ts`, which is
|
|
22
|
+
* where the data dir is.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** What the last successful pull recorded, read back from `<dataDir>`. */
|
|
26
|
+
export interface ImagePullRecord {
|
|
27
|
+
/** The Mercury version whose boot pulled `image`. */
|
|
28
|
+
version: string;
|
|
29
|
+
/** The image reference that was pulled. */
|
|
30
|
+
image: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ImageRefreshDecision =
|
|
34
|
+
| { refresh: false; why: "digest-pinned" | "no-registry-host" | "current" }
|
|
35
|
+
| {
|
|
36
|
+
refresh: true;
|
|
37
|
+
why: "never-recorded" | "version-changed" | "ref-changed";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** File under `<dataDir>` holding the last {@link ImagePullRecord}. */
|
|
41
|
+
export const IMAGE_PULL_RECORD_FILE = "image-pull.json";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether this reference can be re-fetched from a registry at all.
|
|
45
|
+
*
|
|
46
|
+
* Two references must never be pulled:
|
|
47
|
+
*
|
|
48
|
+
* - **Digest-pinned** (`…@sha256:…`) — it cannot drift, so a pull is pure cost.
|
|
49
|
+
* - **No registry host** — in this project a bare name is a *local* build:
|
|
50
|
+
* `mercury build` tags `mercury-agent:latest` (`LOCAL_BUILD_IMAGE_TAG`) and
|
|
51
|
+
* the derived extension image is `mercury-agent-ext:<hash>`. Neither exists
|
|
52
|
+
* in any registry, so a pull would fail on every boot and the warning would
|
|
53
|
+
* be noise about an image that is exactly where the operator put it.
|
|
54
|
+
*
|
|
55
|
+
* Docker's own rule for "is the first path component a registry host": it
|
|
56
|
+
* contains a `.` or a `:`, or it is literally `localhost`. A bare
|
|
57
|
+
* Docker-Hub-hosted name (`someuser/mercury-agent:latest`) therefore is not
|
|
58
|
+
* refreshed — the trade taken deliberately, because a false pull attempt on
|
|
59
|
+
* every local build is a worse daily cost than a missed refresh on a shape
|
|
60
|
+
* nothing in this project uses. The boot log still names the ref and its image
|
|
61
|
+
* id, so drift on such a tag is visible rather than silent.
|
|
62
|
+
*/
|
|
63
|
+
export function isPullableRef(image: string): boolean {
|
|
64
|
+
const ref = image.trim();
|
|
65
|
+
if (!ref) return false;
|
|
66
|
+
if (ref.includes("@")) return false;
|
|
67
|
+
const firstSlash = ref.indexOf("/");
|
|
68
|
+
if (firstSlash === -1) return false;
|
|
69
|
+
const host = ref.slice(0, firstSlash);
|
|
70
|
+
return host === "localhost" || host.includes(".") || host.includes(":");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Should the image already present locally be pulled again?
|
|
75
|
+
*
|
|
76
|
+
* `record === null` means no boot ever recorded a pull — which is the state of
|
|
77
|
+
* every host that upgraded before this code existed, including the one that
|
|
78
|
+
* carried a month-old container on 2026-09-05. Absence refreshes: it costs one
|
|
79
|
+
* pull, once, and it is the only way those hosts ever converge.
|
|
80
|
+
*/
|
|
81
|
+
export function decideImageRefresh(input: {
|
|
82
|
+
image: string;
|
|
83
|
+
hostVersion: string;
|
|
84
|
+
record: ImagePullRecord | null;
|
|
85
|
+
}): ImageRefreshDecision {
|
|
86
|
+
const { image, hostVersion, record } = input;
|
|
87
|
+
if (image.includes("@")) return { refresh: false, why: "digest-pinned" };
|
|
88
|
+
if (!isPullableRef(image)) return { refresh: false, why: "no-registry-host" };
|
|
89
|
+
if (!record) return { refresh: true, why: "never-recorded" };
|
|
90
|
+
if (record.image !== image) return { refresh: true, why: "ref-changed" };
|
|
91
|
+
if (record.version !== hostVersion) {
|
|
92
|
+
return { refresh: true, why: "version-changed" };
|
|
93
|
+
}
|
|
94
|
+
return { refresh: false, why: "current" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Facts about the image the service will actually spawn. */
|
|
98
|
+
export interface ImageFacts {
|
|
99
|
+
/** Content id (`sha256:…`) — the thing that changes when a tag moves. */
|
|
100
|
+
id: string | null;
|
|
101
|
+
/** First `RepoDigests` entry, absent for an image never pulled. */
|
|
102
|
+
digest: string | null;
|
|
103
|
+
/** `org.opencontainers.image.version`, or null until release-gate R0.1. */
|
|
104
|
+
version: string | null;
|
|
105
|
+
created: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `docker image inspect --format` template that yields one JSON object per
|
|
110
|
+
* image. Written as literal text plus `json` actions rather than a `dict`
|
|
111
|
+
* call, which older daemons' template engines do not provide.
|
|
112
|
+
*/
|
|
113
|
+
export const IMAGE_FACTS_FORMAT =
|
|
114
|
+
'{"id":{{json .Id}},"repoDigests":{{json .RepoDigests}},' +
|
|
115
|
+
'"created":{{json .Created}},"labels":{{json .Config.Labels}}}';
|
|
116
|
+
|
|
117
|
+
/** Labels a Mercury image may record its version under, most specific first. */
|
|
118
|
+
const VERSION_LABELS = [
|
|
119
|
+
"org.opencontainers.image.version",
|
|
120
|
+
"com.mercury.version",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse {@link IMAGE_FACTS_FORMAT} output. Never throws: an unreadable answer
|
|
125
|
+
* costs the boot log its detail, not the boot.
|
|
126
|
+
*/
|
|
127
|
+
export function parseImageFacts(stdout: string): ImageFacts {
|
|
128
|
+
const empty: ImageFacts = {
|
|
129
|
+
id: null,
|
|
130
|
+
digest: null,
|
|
131
|
+
version: null,
|
|
132
|
+
created: null,
|
|
133
|
+
};
|
|
134
|
+
const line = stdout
|
|
135
|
+
.split("\n")
|
|
136
|
+
.map((l) => l.trim())
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.pop();
|
|
139
|
+
if (!line) return empty;
|
|
140
|
+
let parsed: {
|
|
141
|
+
id?: unknown;
|
|
142
|
+
repoDigests?: unknown;
|
|
143
|
+
created?: unknown;
|
|
144
|
+
labels?: unknown;
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(line);
|
|
148
|
+
} catch {
|
|
149
|
+
return empty;
|
|
150
|
+
}
|
|
151
|
+
const digests = Array.isArray(parsed.repoDigests)
|
|
152
|
+
? parsed.repoDigests.filter((d): d is string => typeof d === "string")
|
|
153
|
+
: [];
|
|
154
|
+
const labels =
|
|
155
|
+
parsed.labels && typeof parsed.labels === "object"
|
|
156
|
+
? (parsed.labels as Record<string, unknown>)
|
|
157
|
+
: {};
|
|
158
|
+
let version: string | null = null;
|
|
159
|
+
for (const key of VERSION_LABELS) {
|
|
160
|
+
const value = labels[key];
|
|
161
|
+
if (typeof value === "string" && value) {
|
|
162
|
+
version = value;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
id: typeof parsed.id === "string" ? parsed.id : null,
|
|
168
|
+
digest: digests[0] ?? null,
|
|
169
|
+
version,
|
|
170
|
+
created: typeof parsed.created === "string" ? parsed.created : null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The docker call `ensureAgentImage` makes, injected so it can be driven. */
|
|
175
|
+
export interface ImageExecResult {
|
|
176
|
+
code: number;
|
|
177
|
+
stdout: string;
|
|
178
|
+
stderr: string;
|
|
179
|
+
timedOut: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface EnsureAgentImageDeps {
|
|
183
|
+
exec(args: string[], timeoutMs: number): Promise<ImageExecResult>;
|
|
184
|
+
/** The host's Mercury version, or null when the manifest is unreadable. */
|
|
185
|
+
hostVersion: string | null;
|
|
186
|
+
readRecord(): ImagePullRecord | null;
|
|
187
|
+
writeRecord(record: ImagePullRecord): void;
|
|
188
|
+
log: {
|
|
189
|
+
info(message: string, meta?: Record<string, unknown>): void;
|
|
190
|
+
warn(message: string, meta?: Record<string, unknown>): void;
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** What the boot line reports, returned so a caller (or a test) can read it. */
|
|
195
|
+
export interface EnsureAgentImageResult {
|
|
196
|
+
image: string;
|
|
197
|
+
facts: ImageFacts | null;
|
|
198
|
+
previousId: string | null;
|
|
199
|
+
refresh: "pulled" | "refreshed" | "skipped" | "failed";
|
|
200
|
+
reason: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const INSPECT_TIMEOUT_MS = 10_000;
|
|
204
|
+
const PULL_TIMEOUT_MS = 300_000;
|
|
205
|
+
|
|
206
|
+
async function inspect(
|
|
207
|
+
image: string,
|
|
208
|
+
deps: EnsureAgentImageDeps,
|
|
209
|
+
): Promise<ImageFacts | null> {
|
|
210
|
+
const result = await deps.exec(
|
|
211
|
+
["image", "inspect", "--format", IMAGE_FACTS_FORMAT, image],
|
|
212
|
+
INSPECT_TIMEOUT_MS,
|
|
213
|
+
);
|
|
214
|
+
if (result.code !== 0) return null;
|
|
215
|
+
return parseImageFacts(result.stdout);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Make `image` present and current, then say what was resolved.
|
|
220
|
+
*
|
|
221
|
+
* Throws only when the image is absent *and* cannot be pulled — that host has
|
|
222
|
+
* nothing to run. A **refresh** that fails warns and returns: the box already
|
|
223
|
+
* has a working image, and refusing to boot because a registry was unreachable
|
|
224
|
+
* would turn a visibility fix into an outage.
|
|
225
|
+
*
|
|
226
|
+
* The record is written only after a pull that returned 0. Recording a pull
|
|
227
|
+
* that did not happen is how a tag stays frozen while the bookkeeping says it
|
|
228
|
+
* is fresh.
|
|
229
|
+
*/
|
|
230
|
+
export async function ensureAgentImage(
|
|
231
|
+
image: string,
|
|
232
|
+
deps: EnsureAgentImageDeps,
|
|
233
|
+
): Promise<EnsureAgentImageResult> {
|
|
234
|
+
const { hostVersion } = deps;
|
|
235
|
+
let facts = await inspect(image, deps);
|
|
236
|
+
const previousId = facts?.id ?? null;
|
|
237
|
+
let refresh: EnsureAgentImageResult["refresh"] = "skipped";
|
|
238
|
+
let reason: string;
|
|
239
|
+
|
|
240
|
+
if (!facts) {
|
|
241
|
+
reason = "not-present";
|
|
242
|
+
// Pull output is captured, not streamed: the HTTP server is already
|
|
243
|
+
// answering (warming) while this runs, and a synchronous inherit-stdio
|
|
244
|
+
// pull used to freeze it for up to five minutes.
|
|
245
|
+
deps.log.info("Agent image not found locally, pulling (can take minutes)", {
|
|
246
|
+
image,
|
|
247
|
+
});
|
|
248
|
+
const pull = await deps.exec(["pull", image], PULL_TIMEOUT_MS);
|
|
249
|
+
if (pull.code !== 0) {
|
|
250
|
+
const detail = pull.timedOut
|
|
251
|
+
? `timed out after ${PULL_TIMEOUT_MS}ms`
|
|
252
|
+
: pull.stderr.trim().slice(-2000);
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Failed to pull agent image: ${image}${detail ? ` (${detail})` : ""}\n` +
|
|
255
|
+
`Build it locally with: mercury build\n` +
|
|
256
|
+
`Or pull manually: docker pull ${image}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
deps.log.info("Agent image pulled successfully", { image });
|
|
260
|
+
refresh = "pulled";
|
|
261
|
+
facts = await inspect(image, deps);
|
|
262
|
+
if (hostVersion) deps.writeRecord({ version: hostVersion, image });
|
|
263
|
+
} else if (!hostVersion) {
|
|
264
|
+
// No readable host version means there is no comparison to make. Named in
|
|
265
|
+
// the boot line rather than folded into "current" — a silent skip here
|
|
266
|
+
// would be the same shape as the bug this exists to fix.
|
|
267
|
+
reason = "host-version-unreadable";
|
|
268
|
+
} else {
|
|
269
|
+
const decision = decideImageRefresh({
|
|
270
|
+
image,
|
|
271
|
+
hostVersion,
|
|
272
|
+
record: deps.readRecord(),
|
|
273
|
+
});
|
|
274
|
+
reason = decision.why;
|
|
275
|
+
if (decision.refresh) {
|
|
276
|
+
deps.log.info("Agent image tag may be stale, re-pulling", {
|
|
277
|
+
image,
|
|
278
|
+
reason: decision.why,
|
|
279
|
+
hostVersion,
|
|
280
|
+
});
|
|
281
|
+
const pull = await deps.exec(["pull", image], PULL_TIMEOUT_MS);
|
|
282
|
+
if (pull.code === 0) {
|
|
283
|
+
refresh = "refreshed";
|
|
284
|
+
facts = (await inspect(image, deps)) ?? facts;
|
|
285
|
+
deps.writeRecord({ version: hostVersion, image });
|
|
286
|
+
} else {
|
|
287
|
+
refresh = "failed";
|
|
288
|
+
deps.log.warn(
|
|
289
|
+
`Could not refresh the agent image ${image} - continuing on the copy this host already has, which may predate mercury ${hostVersion}. Pull it by hand (docker pull ${image}) or check the registry credential.`,
|
|
290
|
+
{
|
|
291
|
+
image,
|
|
292
|
+
timedOut: pull.timedOut,
|
|
293
|
+
detail: pull.stderr.trim().slice(-500),
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// The boot line R0.4 asks for: the reference actually resolved, its id and
|
|
301
|
+
// digest, and the version label when the image carries one (it does not
|
|
302
|
+
// until R0.1 stamps it - null here is that gap, stated rather than hidden).
|
|
303
|
+
deps.log.info("Agent image resolved", {
|
|
304
|
+
image,
|
|
305
|
+
imageId: facts?.id ?? null,
|
|
306
|
+
digest: facts?.digest ?? null,
|
|
307
|
+
imageVersion: facts?.version ?? null,
|
|
308
|
+
created: facts?.created ?? null,
|
|
309
|
+
hostVersion,
|
|
310
|
+
refresh,
|
|
311
|
+
reason,
|
|
312
|
+
...(previousId && facts?.id && previousId !== facts.id
|
|
313
|
+
? { previousImageId: previousId }
|
|
314
|
+
: {}),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return { image, facts, previousId, refresh, reason };
|
|
318
|
+
}
|