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.
Files changed (107) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +25 -14
  4. package/docs/configuration.md +139 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  9. package/docs/goals/rehearsal-bench/decisions.md +41 -3
  10. package/docs/goals/rehearsal-bench/roadmap.md +3 -1
  11. package/docs/goals/release-gate/decisions.md +86 -0
  12. package/docs/goals/release-gate/roadmap.md +36 -3
  13. package/docs/live-testing.md +22 -0
  14. package/docs/pending-verification.md +178 -0
  15. package/docs/permissions.md +1 -1
  16. package/docs/profile-guide.md +28 -8
  17. package/examples/extensions/archive/backends/local.ts +6 -3
  18. package/examples/extensions/archive/queue.ts +51 -10
  19. package/examples/extensions/feed-watch/config.ts +42 -2
  20. package/examples/extensions/feed-watch/digest.ts +4 -7
  21. package/examples/extensions/feed-watch/items.ts +18 -7
  22. package/examples/extensions/feed-watch/match.ts +125 -9
  23. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  24. package/examples/extensions/feed-watch/watch.ts +39 -5
  25. package/examples/extensions/gws/index.ts +126 -8
  26. package/examples/extensions/longview/hook.ts +72 -7
  27. package/examples/extensions/longview/index.ts +2 -0
  28. package/examples/extensions/morning/README.md +26 -15
  29. package/examples/extensions/morning/index.ts +29 -6
  30. package/examples/extensions/morning/lib/hosts.ts +16 -0
  31. package/examples/extensions/morning/lib/morning.ts +78 -0
  32. package/examples/extensions/morning/lib/upload.ts +584 -0
  33. package/examples/extensions/morning/skill/SKILL.md +34 -4
  34. package/examples/extensions/napkin/index.ts +12 -3
  35. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  36. package/examples/extensions/overview/index.ts +20 -0
  37. package/examples/extensions/overview/skill/SKILL.md +9 -1
  38. package/examples/extensions/pinchtab/index.ts +36 -7
  39. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  40. package/examples/profiles/_template/AGENTS.md +6 -1
  41. package/examples/profiles/football-reporter/AGENTS.md +16 -4
  42. package/examples/profiles/football-reporter/README.md +1 -1
  43. package/examples/profiles/football-reporter/config.yaml +42 -6
  44. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  45. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  46. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  47. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  48. package/examples/profiles/football-reporter/standard.json +45 -8
  49. package/package.json +11 -6
  50. package/resources/skills/tasks/SKILL.md +17 -1
  51. package/resources/templates/AGENTS.md +4 -3
  52. package/resources/templates/mercury.example.yaml +13 -4
  53. package/src/adapters/whatsapp-ingress.ts +15 -3
  54. package/src/agent/container-entry.ts +299 -29
  55. package/src/agent/container-env.ts +26 -8
  56. package/src/agent/container-runner.ts +497 -76
  57. package/src/agent/image-contract.ts +77 -0
  58. package/src/agent/image-manifest.ts +180 -0
  59. package/src/agent/image-refresh.ts +318 -0
  60. package/src/cli/mercury.ts +225 -28
  61. package/src/cli/mrctl-http.ts +5 -0
  62. package/src/cli/mrctl.ts +57 -5
  63. package/src/cli/service-unit.ts +109 -0
  64. package/src/config-file.ts +22 -1
  65. package/src/config.ts +105 -14
  66. package/src/core/api.ts +11 -2
  67. package/src/core/commands.ts +11 -4
  68. package/src/core/connection-health.ts +377 -0
  69. package/src/core/direct-send.ts +235 -14
  70. package/src/core/exec.ts +10 -0
  71. package/src/core/history-window.ts +142 -0
  72. package/src/core/model-command.ts +130 -0
  73. package/src/core/operator-alerts.ts +326 -23
  74. package/src/core/permissions.ts +28 -0
  75. package/src/core/profiles.ts +32 -16
  76. package/src/core/reply-context.ts +31 -0
  77. package/src/core/routes/config-builtin.ts +11 -0
  78. package/src/core/routes/console.ts +98 -16
  79. package/src/core/routes/dashboard.ts +93 -10
  80. package/src/core/routes/model.ts +26 -3
  81. package/src/core/routes/send.ts +1 -1
  82. package/src/core/routes/tasks.ts +74 -0
  83. package/src/core/runtime.ts +302 -21
  84. package/src/core/system-messages.ts +33 -0
  85. package/src/core/task-scheduler.ts +155 -7
  86. package/src/extensions/image-builder.ts +1 -1
  87. package/src/extensions/installer.ts +72 -17
  88. package/src/extensions/load-project.ts +74 -0
  89. package/src/extensions/loader.ts +50 -9
  90. package/src/host-version.ts +32 -0
  91. package/src/main.ts +42 -21
  92. package/src/preflight/checks/credential.ts +300 -0
  93. package/src/preflight/checks/docker.ts +149 -0
  94. package/src/preflight/checks/extensions.ts +90 -0
  95. package/src/preflight/checks/host-deps.ts +247 -0
  96. package/src/preflight/checks/image-contract.ts +228 -0
  97. package/src/preflight/checks/roundtrip.ts +424 -0
  98. package/src/preflight/checks/sandbox.ts +159 -0
  99. package/src/preflight/deps.ts +107 -0
  100. package/src/preflight/probe-container.ts +156 -0
  101. package/src/preflight/report.ts +177 -0
  102. package/src/preflight/run.ts +223 -0
  103. package/src/server.ts +55 -14
  104. package/src/storage/db.ts +41 -2
  105. package/src/storage/models-json.ts +110 -0
  106. package/src/text/reporter-lint.ts +167 -23
  107. 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
+ }