@moor-sh/mcp 0.18.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "MCP server for moor - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects via the moor HTTP API.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -810,22 +810,27 @@ server.registerTool(
810
810
  },
811
811
  );
812
812
 
813
- // #80 PR #4: moor_update_apply — kick off a transient-respawner update of
813
+ // #80: moor_update_apply — kick off a transient-respawner update of
814
814
  // moor itself. The respawner runs async; this tool returns the audit_id
815
- // immediately. Poll moor_update_status (or, eventually, moor_update_audit)
816
- // for the outcome.
815
+ // immediately. Poll moor_update_audit (history of attempts) or
816
+ // moor_update_status (version change) for the outcome.
817
817
  //
818
- // IMPORTANT: PR #4 has no rollback. A failed compose-up / --wait timeout /
819
- // health check writes a `failed` marker and exits; compose state is left
820
- // as Compose left it. Recovery: manual `docker compose up`, OR wait for the
821
- // 30-min stale-in_progress sweep to reclaim the audit row. Automatic
822
- // rollback (retag + `--pull never`) lands in PR #5.
818
+ // Outcomes (encoded in the audit row's `state` field, surfaced by
819
+ // moor_update_audit):
820
+ // - success : new image is healthy.
821
+ // - failed : pull failed before moor was replaced (no rollback).
822
+ // - rolled_back : up/health failed; rollback to prev image succeeded.
823
+ // - rollback_failed : up/health failed AND rollback also failed —
824
+ // operator must investigate (manual recovery).
825
+ // - in_progress : respawner still running.
826
+ // - crashed : 30-min grace elapsed without a marker
827
+ // (respawner died or moor never read the marker).
823
828
  server.registerTool(
824
829
  "moor_update_apply",
825
830
  {
826
831
  title: "Apply moor update (transient respawner)",
827
832
  description:
828
- "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll. PR #4 happy path ONLY no automatic rollback. A failed up/health writes `failed` and leaves the compose state; recovery may require manual `docker compose up`. Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.",
833
+ "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll via moor_update_audit. Outcomes: success | failed (pull failed pre-replacement) | rolled_back (up/health failed, automatic rollback succeeded) | rollback_failed (rollback also failed manual recovery needed) | crashed (no marker after 30-min grace). Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.",
829
834
  inputSchema: z.object({
830
835
  target_digest: z
831
836
  .string()
@@ -850,7 +855,13 @@ server.registerTool(
850
855
  content: [
851
856
  {
852
857
  type: "text",
853
- text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_status to watch the version change. Expected transitions: in_progress → success (clean apply) or failed (compose/health failure; PR #4 has no rollback so the compose state is left where Compose left it). If the new moor never becomes healthy enough to ingest the marker, the audit row may stay in_progress until the 30-min stale sweep marks it crashed — at which point manual recovery (docker compose up) may be required.`,
858
+ text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_audit to watch the outcome, or moor_update_status to watch the version. Possible terminal states:
859
+ - success (new image healthy)
860
+ - failed (pull failed before moor was replaced)
861
+ - rolled_back (up/health failed; automatic rollback succeeded; drain stays on)
862
+ - rollback_failed (up/health failed AND rollback failed; manual recovery)
863
+ - crashed (no marker after 30-min grace; respawner died)
864
+ Recovery: rolled_back means moor is on the previous image again; the failed update is captured in error_log. rollback_failed or crashed mean an operator should investigate (likely manual docker compose up).`,
854
865
  },
855
866
  ],
856
867
  };
@@ -868,6 +879,57 @@ server.registerTool(
868
879
  },
869
880
  );
870
881
 
882
+ // #80 PR #6: moor_update_audit — recent history of moor_update_apply
883
+ // attempts. Read-only diagnostic; the orchestration lives entirely on
884
+ // the moor side. Tail-truncates error_log / rollback_error per-field
885
+ // so a crashed update with a long error doesn't blow up the token
886
+ // budget; opt in to a larger tail when actively debugging.
887
+ //
888
+ // Rendering helpers (shortDigest / fmtDuration / tailLog / renderAuditRow
889
+ // / renderAuditList) live in ./update-audit-render and are unit-tested
890
+ // directly — this tool is a thin shell around them.
891
+ import { MAX_LOG_TAIL_BYTES, renderAuditList, type UpdateAuditApiRow } from "./update-audit-render";
892
+
893
+ server.registerTool(
894
+ "moor_update_audit",
895
+ {
896
+ title: "Update history (audit log)",
897
+ description:
898
+ "Read-only: recent moor_update_apply attempts and their outcomes. Each row shows audit_id, state (success | failed | rolled_back | rollback_failed | in_progress | crashed), duration, digest deltas, backup path, and any error logs. error_log preserves the ORIGINAL apply failure (never overwritten by rollback step details); rollback_error is set only on rollback_failed. Default tail is 4 KiB per log field; pass tail_bytes=0 to omit log bodies entirely (keeps the metadata line and replaces the body with a sized marker), or up to 16384 to read more.",
899
+ inputSchema: z.object({
900
+ limit: z
901
+ .number()
902
+ .int()
903
+ .min(1)
904
+ .max(200)
905
+ .optional()
906
+ .describe("How many most-recent attempts to return. Default 20, max 200."),
907
+ tail_bytes: z
908
+ .number()
909
+ .int()
910
+ .min(0)
911
+ .max(MAX_LOG_TAIL_BYTES)
912
+ .optional()
913
+ .describe(
914
+ "Max bytes of error_log and rollback_error returned inline per row. Default 4096 (4 KiB). 0 to omit log bodies entirely; 16384 max.",
915
+ ),
916
+ }),
917
+ },
918
+ async ({ limit, tail_bytes }) => {
919
+ const qs = new URLSearchParams();
920
+ if (limit !== undefined) qs.set("limit", String(limit));
921
+ const path = qs.toString()
922
+ ? `/api/server/update/audit?${qs.toString()}`
923
+ : "/api/server/update/audit";
924
+ const res = await apiGet(path);
925
+ if (!res.ok) throw new Error(`update audit failed: ${res.status} ${await res.text()}`);
926
+ const { rows } = (await res.json()) as { rows: UpdateAuditApiRow[] };
927
+ return {
928
+ content: [{ type: "text", text: renderAuditList(rows, { tail_bytes }) }],
929
+ };
930
+ },
931
+ );
932
+
871
933
  server.registerTool(
872
934
  "moor_cleanup_plan",
873
935
  {
@@ -2195,6 +2257,191 @@ server.registerTool(
2195
2257
  },
2196
2258
  );
2197
2259
 
2260
+ // --- Registry credentials ---
2261
+ //
2262
+ // Server-wide credentials used by the pull path to attach
2263
+ // X-Registry-Auth on /images/create for private images. Read shape
2264
+ // is write-only: the raw secret never leaves the API. Tool descriptions
2265
+ // flag that *inputs* (add/update) carry the secret over the tool-call
2266
+ // path and are visible to the MCP client - same security model as
2267
+ // moor_env_set. structuredContent mirrors the API metadata shape so
2268
+ // agents can reason over it without scraping the human text.
2269
+
2270
+ type RegistryCredentialMetadata = {
2271
+ id: number;
2272
+ hostname: string;
2273
+ username: string;
2274
+ secret: { configured: true; kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown" };
2275
+ created_at: string;
2276
+ updated_at: string;
2277
+ };
2278
+
2279
+ function renderCredentialLine(c: RegistryCredentialMetadata): string {
2280
+ return `id=${c.id} ${c.hostname} user=${c.username} kind=${c.secret.kind} updated=${c.updated_at}`;
2281
+ }
2282
+
2283
+ server.registerTool(
2284
+ "moor_registry_credentials_list",
2285
+ {
2286
+ title: "List Registry Credentials",
2287
+ description:
2288
+ "List all stored Docker registry credentials. Returns metadata only - the raw secret value is never returned by any read path. Each row carries `secret: { configured: true, kind }` where kind is derived from known token prefixes (github_classic_pat, github_fine_grained_pat) or 'unknown'.",
2289
+ },
2290
+ async () => {
2291
+ const res = await apiGet("/api/server/registry-credentials");
2292
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2293
+ const data = (await res.json()) as { rows: RegistryCredentialMetadata[] };
2294
+ const text =
2295
+ data.rows.length === 0
2296
+ ? "No registry credentials configured. The pull path falls back to anonymous for every registry."
2297
+ : data.rows.map(renderCredentialLine).join("\n");
2298
+ return {
2299
+ content: [{ type: "text", text }],
2300
+ structuredContent: { rows: data.rows },
2301
+ };
2302
+ },
2303
+ );
2304
+
2305
+ server.registerTool(
2306
+ "moor_registry_credential_get",
2307
+ {
2308
+ title: "Get Registry Credential",
2309
+ description:
2310
+ "Get a single stored registry credential by id. Returns metadata only - the raw secret is never returned. Use this before moor_registry_credential_delete to confirm the hostname you intend to delete.",
2311
+ inputSchema: z.object({
2312
+ id: z
2313
+ .number()
2314
+ .int()
2315
+ .positive()
2316
+ .describe("Credential id (from moor_registry_credentials_list)"),
2317
+ }),
2318
+ },
2319
+ async ({ id }) => {
2320
+ const res = await apiGet(`/api/server/registry-credentials/${id}`);
2321
+ if (res.status === 404) throw new Error(`credential id=${id} not found`);
2322
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2323
+ const row = (await res.json()) as RegistryCredentialMetadata;
2324
+ return {
2325
+ content: [{ type: "text", text: renderCredentialLine(row) }],
2326
+ structuredContent: row as unknown as Record<string, unknown>,
2327
+ };
2328
+ },
2329
+ );
2330
+
2331
+ server.registerTool(
2332
+ "moor_registry_credential_add",
2333
+ {
2334
+ title: "Add Registry Credential",
2335
+ description:
2336
+ "Store a credential for a Docker registry. The pull path will attach X-Registry-Auth to /images/create whenever an image ref matches this hostname. Hostname must be the bare host as it appears in the image ref (e.g. ghcr.io, docker.io, localhost:5000) - no scheme, no path. Note: the secret value passes through the MCP client and tool-call transport, same security model as moor_env_set; rotate via moor_registry_credential_update if it has been exposed.",
2337
+ inputSchema: z.object({
2338
+ hostname: z
2339
+ .string()
2340
+ .describe(
2341
+ "Bare registry host as parsed from an image ref. Examples: ghcr.io, docker.io, localhost:5000, registry.example.com:5000. No scheme, no path.",
2342
+ ),
2343
+ username: z
2344
+ .string()
2345
+ .describe("Registry username. For GHCR with a classic PAT, use your GitHub username."),
2346
+ secret: z
2347
+ .string()
2348
+ .describe(
2349
+ "Registry password or token. For GHCR, a classic PAT with read:packages is the documented path. Visible to the MCP client on input.",
2350
+ ),
2351
+ }),
2352
+ },
2353
+ async ({ hostname, username, secret }) => {
2354
+ const res = await apiPost("/api/server/registry-credentials", { hostname, username, secret });
2355
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2356
+ const row = (await res.json()) as RegistryCredentialMetadata;
2357
+ return {
2358
+ content: [
2359
+ {
2360
+ type: "text",
2361
+ text: `Added credential for ${row.hostname} (id=${row.id}, kind=${row.secret.kind}).`,
2362
+ },
2363
+ ],
2364
+ structuredContent: row as unknown as Record<string, unknown>,
2365
+ };
2366
+ },
2367
+ );
2368
+
2369
+ server.registerTool(
2370
+ "moor_registry_credential_update",
2371
+ {
2372
+ title: "Update Registry Credential",
2373
+ description:
2374
+ "Rotate username and/or secret on an existing credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break the pull path. To change hostnames, delete and re-create. Requires at least one of username or secret. Note: the secret value passes through the MCP client on input - same security model as moor_env_set.",
2375
+ inputSchema: z.object({
2376
+ id: z.number().int().positive().describe("Credential id to update"),
2377
+ username: z.string().optional().describe("New username (optional)"),
2378
+ secret: z
2379
+ .string()
2380
+ .optional()
2381
+ .describe("New secret (optional). Visible to the MCP client on input."),
2382
+ }),
2383
+ },
2384
+ async ({ id, username, secret }) => {
2385
+ if (username === undefined && secret === undefined) {
2386
+ throw new Error("must provide at least one of username or secret to update");
2387
+ }
2388
+ const patch: Record<string, string> = {};
2389
+ if (username !== undefined) patch.username = username;
2390
+ if (secret !== undefined) patch.secret = secret;
2391
+ const res = await apiPut(`/api/server/registry-credentials/${id}`, patch);
2392
+ if (res.status === 404) throw new Error(`credential id=${id} not found`);
2393
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2394
+ const row = (await res.json()) as RegistryCredentialMetadata;
2395
+ const rotated: string[] = [];
2396
+ if (username !== undefined) rotated.push("username");
2397
+ if (secret !== undefined) rotated.push("secret");
2398
+ return {
2399
+ content: [
2400
+ {
2401
+ type: "text",
2402
+ text: `Updated credential id=${id} (${row.hostname}): rotated ${rotated.join(" + ")}. kind=${row.secret.kind}.`,
2403
+ },
2404
+ ],
2405
+ structuredContent: row as unknown as Record<string, unknown>,
2406
+ };
2407
+ },
2408
+ );
2409
+
2410
+ server.registerTool(
2411
+ "moor_registry_credential_delete",
2412
+ {
2413
+ title: "Delete Registry Credential",
2414
+ description:
2415
+ "Delete a stored registry credential. Requires confirm_hostname to match the resolved row's hostname exactly - guards against deleting the wrong row from a stale id. After deletion, pulls for that registry fall back to anonymous. Irreversible.",
2416
+ inputSchema: z.object({
2417
+ id: z.number().int().positive().describe("Credential id to delete"),
2418
+ confirm_hostname: z
2419
+ .string()
2420
+ .describe(
2421
+ "Must equal the credential row's hostname exactly. Resolved via moor_registry_credential_get and compared before deletion.",
2422
+ ),
2423
+ }),
2424
+ },
2425
+ async ({ id, confirm_hostname }) => {
2426
+ const getRes = await apiGet(`/api/server/registry-credentials/${id}`);
2427
+ if (getRes.status === 404) throw new Error(`credential id=${id} not found`);
2428
+ if (!getRes.ok)
2429
+ throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`);
2430
+ const row = (await getRes.json()) as RegistryCredentialMetadata;
2431
+ if (confirm_hostname !== row.hostname) {
2432
+ throw new Error(
2433
+ `confirm_hostname "${confirm_hostname}" does not match resolved hostname "${row.hostname}". Refusing to delete.`,
2434
+ );
2435
+ }
2436
+ const delRes = await apiDelete(`/api/server/registry-credentials/${id}`);
2437
+ if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`);
2438
+ return {
2439
+ content: [{ type: "text", text: `Deleted credential for ${row.hostname} (id=${id}).` }],
2440
+ structuredContent: { deleted: { id, hostname: row.hostname } },
2441
+ };
2442
+ },
2443
+ );
2444
+
2198
2445
  function formatMs(ms: number): string {
2199
2446
  if (ms < 1000) return `${ms}ms`;
2200
2447
  const s = Math.floor(ms / 1000);
@@ -0,0 +1,171 @@
1
+ // Tests for the moor_update_audit renderer. Pure functions — no
2
+ // network, no DB. Lives next to its consumer so the formatting
3
+ // operators actually see has direct test coverage.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ DEFAULT_LOG_TAIL_BYTES,
8
+ fmtDuration,
9
+ MAX_LOG_TAIL_BYTES,
10
+ renderAuditList,
11
+ renderAuditRow,
12
+ shortDigest,
13
+ tailLog,
14
+ type UpdateAuditApiRow,
15
+ } from "./update-audit-render";
16
+
17
+ function row(overrides: Partial<UpdateAuditApiRow> = {}): UpdateAuditApiRow {
18
+ return {
19
+ id: 1,
20
+ state: "success",
21
+ started_at: "2026-05-25 00:00:00",
22
+ duration_ms: 50_000,
23
+ from_digest: `sha256:${"a".repeat(64)}`,
24
+ to_digest: `sha256:${"b".repeat(64)}`,
25
+ prev_image_id: `sha256:${"c".repeat(64)}`,
26
+ backup_path: "/app/data/moor.db.backup-1",
27
+ error_log: null,
28
+ rollback_error: null,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe("shortDigest", () => {
34
+ test("formats sha256:<64 hex> as sha256:<7>…<7>", () => {
35
+ expect(shortDigest(`sha256:${"a".repeat(64)}`)).toBe(
36
+ `sha256:${"a".repeat(7)}…${"a".repeat(7)}`,
37
+ );
38
+ });
39
+ test("strips the repo prefix from repo@sha256:... form", () => {
40
+ expect(shortDigest(`ghcr.io/caiopizzol/moor@sha256:${"b".repeat(64)}`)).toBe(
41
+ `sha256:${"b".repeat(7)}…${"b".repeat(7)}`,
42
+ );
43
+ });
44
+ test("non-digest input passes through unchanged", () => {
45
+ expect(shortDigest("sha256:short")).toBe("sha256:short");
46
+ expect(shortDigest("not-a-digest")).toBe("not-a-digest");
47
+ });
48
+ test("null/undefined → '(none)'", () => {
49
+ expect(shortDigest(null)).toBe("(none)");
50
+ expect(shortDigest(undefined)).toBe("(none)");
51
+ });
52
+ });
53
+
54
+ describe("fmtDuration", () => {
55
+ test("null → em dash", () => expect(fmtDuration(null)).toBe("—"));
56
+ test("sub-second values in ms", () => expect(fmtDuration(420)).toBe("420ms"));
57
+ test("seconds for [1s, 60s)", () => expect(fmtDuration(50_000)).toBe("50s"));
58
+ test("minutes+seconds at and above 60s", () => {
59
+ expect(fmtDuration(60_000)).toBe("1m0s");
60
+ expect(fmtDuration(125_000)).toBe("2m5s");
61
+ });
62
+ });
63
+
64
+ describe("tailLog", () => {
65
+ test("null/undefined → '' (no field rendered by caller)", () => {
66
+ expect(tailLog(null, 100)).toBe("");
67
+ expect(tailLog(undefined, 100)).toBe("");
68
+ });
69
+ test("short string passes through unchanged", () => {
70
+ expect(tailLog("short", 100)).toBe("short");
71
+ });
72
+ test("long string is tail-truncated with a visible marker", () => {
73
+ const big = "x".repeat(10_000);
74
+ const out = tailLog(big, 1000);
75
+ expect(out).toContain("[...9000 earlier bytes truncated]");
76
+ expect(out.endsWith("x".repeat(1000))).toBe(true);
77
+ });
78
+ test("tail_bytes=0 elides the field body entirely with a sized marker", () => {
79
+ const out = tailLog("hello world", 0);
80
+ expect(out).toBe("[...11 bytes elided (tail_bytes=0)]");
81
+ });
82
+ test("tail_bytes=0 on null/undefined → ''", () => {
83
+ expect(tailLog(null, 0)).toBe("");
84
+ expect(tailLog(undefined, 0)).toBe("");
85
+ });
86
+ });
87
+
88
+ describe("renderAuditRow", () => {
89
+ test("success: includes id, state, duration, from/to, backup; no error_log line", () => {
90
+ const out = renderAuditRow(row());
91
+ expect(out).toContain("audit_id=1");
92
+ expect(out).toContain("state=success");
93
+ expect(out).toContain("duration=50s");
94
+ expect(out).toContain("from: sha256:aaaaaaa");
95
+ expect(out).toContain("to: sha256:bbbbbbb");
96
+ expect(out).toContain("prev_image_id: sha256:ccccccc");
97
+ expect(out).toContain("backup: /app/data/moor.db.backup-1");
98
+ expect(out).not.toContain("error_log");
99
+ expect(out).not.toContain("rollback_error");
100
+ });
101
+
102
+ test("rolled_back: error_log shown, rollback_error omitted", () => {
103
+ const out = renderAuditRow(row({ state: "rolled_back", error_log: "health check failed" }));
104
+ expect(out).toContain("state=rolled_back");
105
+ expect(out).toContain("error_log: health check failed");
106
+ expect(out).not.toContain("rollback_error");
107
+ });
108
+
109
+ test("rollback_failed: BOTH error_log and rollback_error rendered", () => {
110
+ const out = renderAuditRow(
111
+ row({
112
+ state: "rollback_failed",
113
+ error_log: "apply up failed",
114
+ rollback_error: "rollback tag failed",
115
+ }),
116
+ );
117
+ expect(out).toContain("state=rollback_failed");
118
+ expect(out).toContain("error_log: apply up failed");
119
+ expect(out).toContain("rollback_error: rollback tag failed");
120
+ });
121
+
122
+ test("tail_bytes truncates long error_log per-field with marker", () => {
123
+ const long = "X".repeat(5000);
124
+ const out = renderAuditRow(row({ error_log: long }), { tail_bytes: 100 });
125
+ expect(out).toContain("[...4900 earlier bytes truncated]");
126
+ });
127
+
128
+ test("tail_bytes=0 elides log body but keeps the field line", () => {
129
+ const out = renderAuditRow(row({ error_log: "anything" }), { tail_bytes: 0 });
130
+ expect(out).toContain("error_log: [...");
131
+ expect(out).toContain("bytes elided (tail_bytes=0)");
132
+ expect(out).not.toContain("anything");
133
+ });
134
+
135
+ test("tail_bytes is clamped to MAX_LOG_TAIL_BYTES", () => {
136
+ // No assertion against the actual byte count; just verify the
137
+ // constant is honored and the function tolerates an over-cap input.
138
+ expect(MAX_LOG_TAIL_BYTES).toBeGreaterThan(DEFAULT_LOG_TAIL_BYTES);
139
+ const long = "Y".repeat(MAX_LOG_TAIL_BYTES + 1000);
140
+ const out = renderAuditRow(row({ error_log: long }), { tail_bytes: 999_999 });
141
+ expect(out).toContain("earlier bytes truncated");
142
+ });
143
+
144
+ test("prev_image_id null → no prev line", () => {
145
+ const out = renderAuditRow(row({ prev_image_id: null }));
146
+ expect(out).not.toContain("prev_image_id");
147
+ });
148
+
149
+ test("backup_path null → no backup line", () => {
150
+ const out = renderAuditRow(row({ backup_path: null }));
151
+ expect(out).not.toContain("backup:");
152
+ });
153
+
154
+ test("non-digest from/to (rare edge) passes through via shortDigest fallback", () => {
155
+ const out = renderAuditRow(row({ from_digest: "weird", to_digest: null }));
156
+ expect(out).toContain("from: weird");
157
+ expect(out).toContain("to: (none)");
158
+ });
159
+ });
160
+
161
+ describe("renderAuditList", () => {
162
+ test("empty → friendly hint", () => {
163
+ expect(renderAuditList([])).toContain("no update attempts recorded yet");
164
+ });
165
+ test("multi-row separated by blank line", () => {
166
+ const out = renderAuditList([row({ id: 2 }), row({ id: 1 })]);
167
+ expect(out.split("\n\n").length).toBe(2);
168
+ expect(out).toContain("audit_id=2");
169
+ expect(out).toContain("audit_id=1");
170
+ });
171
+ });
@@ -0,0 +1,94 @@
1
+ // #80 PR #6: pure renderers for moor_update_audit. Lives next to its
2
+ // consumer (the MCP tool) so the formatting that operators actually
3
+ // see has direct test coverage.
4
+ //
5
+ // Each row renders as a multi-line block with id, state, duration,
6
+ // digest deltas, prev_image_id, backup path, and per-field tailed
7
+ // error_log / rollback_error.
8
+
9
+ /** API row shape returned by GET /api/server/update/audit. Subset of
10
+ * the full DB row — only what the renderer needs. */
11
+ export type UpdateAuditApiRow = {
12
+ id: number;
13
+ state: string;
14
+ started_at: string;
15
+ duration_ms: number | null;
16
+ from_digest: string | null;
17
+ to_digest: string | null;
18
+ prev_image_id: string | null;
19
+ backup_path: string | null;
20
+ error_log: string | null;
21
+ rollback_error: string | null;
22
+ };
23
+
24
+ export const DEFAULT_LOG_TAIL_BYTES = 4096;
25
+ export const MAX_LOG_TAIL_BYTES = 16384;
26
+
27
+ /** Pure: short-form a sha256 digest for compact rendering (first 7 +
28
+ * last 7). Accepts both raw `sha256:<hex>` and `repo@sha256:<hex>`
29
+ * forms; returns the input unchanged on anything else. */
30
+ export function shortDigest(s: string | null | undefined): string {
31
+ if (!s) return "(none)";
32
+ const m = s.match(/^(?:[^@]+@)?sha256:([0-9a-f]{64})$/);
33
+ if (!m) return s;
34
+ const hex = m[1];
35
+ return `sha256:${hex.slice(0, 7)}…${hex.slice(-7)}`;
36
+ }
37
+
38
+ /** Pure: human-readable duration. Mirrors the format moor_runs uses. */
39
+ export function fmtDuration(ms: number | null): string {
40
+ if (ms == null) return "—";
41
+ if (ms < 1000) return `${ms}ms`;
42
+ const s = Math.round(ms / 1000);
43
+ if (s < 60) return `${s}s`;
44
+ const m = Math.floor(s / 60);
45
+ const rem = s % 60;
46
+ return `${m}m${rem}s`;
47
+ }
48
+
49
+ /** Pure: tail-truncate a string to maxBytes with a visible marker.
50
+ * maxBytes=0 means omit the field body entirely (returns the marker
51
+ * alone). Used per-field for error_log + rollback_error so a
52
+ * crashed update with a long log doesn't blow up the token budget. */
53
+ export function tailLog(s: string | null | undefined, maxBytes: number): string {
54
+ if (s == null) return "";
55
+ if (maxBytes <= 0) {
56
+ const enc = new TextEncoder();
57
+ return `[...${enc.encode(s).length} bytes elided (tail_bytes=0)]`;
58
+ }
59
+ const enc = new TextEncoder();
60
+ const bytes = enc.encode(s);
61
+ if (bytes.length <= maxBytes) return s;
62
+ const dropped = bytes.length - maxBytes;
63
+ const tail = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(dropped));
64
+ return `[...${dropped} earlier bytes truncated]\n${tail}`;
65
+ }
66
+
67
+ /** Pure: render one audit row. tail_bytes applies separately to
68
+ * error_log and rollback_error; absent fields are skipped. */
69
+ export function renderAuditRow(row: UpdateAuditApiRow, opts: { tail_bytes?: number } = {}): string {
70
+ const tail = Math.min(opts.tail_bytes ?? DEFAULT_LOG_TAIL_BYTES, MAX_LOG_TAIL_BYTES);
71
+ const lines: string[] = [
72
+ `audit_id=${row.id} state=${row.state} duration=${fmtDuration(row.duration_ms)} started=${row.started_at}`,
73
+ ` from: ${shortDigest(row.from_digest)} → to: ${shortDigest(row.to_digest)}`,
74
+ ];
75
+ if (row.prev_image_id) lines.push(` prev_image_id: ${shortDigest(row.prev_image_id)}`);
76
+ if (row.backup_path) lines.push(` backup: ${row.backup_path}`);
77
+ if (row.error_log) lines.push(` error_log: ${tailLog(row.error_log, tail)}`);
78
+ if (row.rollback_error) {
79
+ lines.push(` rollback_error: ${tailLog(row.rollback_error, tail)}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+
84
+ /** Pure: render a list of rows separated by a blank line. Empty list
85
+ * returns the documented "no updates yet" string. */
86
+ export function renderAuditList(
87
+ rows: UpdateAuditApiRow[],
88
+ opts: { tail_bytes?: number } = {},
89
+ ): string {
90
+ if (rows.length === 0) {
91
+ return "no update attempts recorded yet. Run moor_update_apply to start one.";
92
+ }
93
+ return rows.map((r) => renderAuditRow(r, opts)).join("\n\n");
94
+ }