@astrofoundry/pi-astro 0.35.0 → 0.36.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.
@@ -1,96 +1,322 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseImageRef } from "../../specialists/lib/registry.ts";
4
+ import { configDir } from "./config.ts";
5
+ import { escapeDiscord } from "./media.ts";
1
6
  import type { MessageComponent } from "./rest.ts";
7
+ import type { ReleaseNote } from "../../specialists/lib/releases.ts";
8
+ import type { Review, Verdict } from "./tools-report.ts";
2
9
 
3
10
  /** Every scheduled report starts with this, so it stands apart from conversations in the same channel. */
4
11
  export const REPORT_PREFIX = "Arcane Daily Report:";
5
- const MESSAGE_LIMIT = 1900;
6
-
7
- /** A release tag as reported, or "unresolved" for anything that is not one (`older build`, `new build`, `?`). Never a digest. */
8
- export function displayVersion(value: unknown): string {
9
- return typeof value === "string" && /^v?[0-9][A-Za-z0-9.+_-]{0,100}$/.test(value) ? value : "unresolved";
10
- }
11
12
 
13
+ /** One row of the wrapper's `update-report` JSON, as far as the report reads it. */
12
14
  interface ReportRow {
13
15
  stack: string;
14
16
  service: string;
15
- running: unknown;
16
- latest: unknown;
17
+ ref: string;
18
+ running: string;
19
+ latest: string;
17
20
  status: string;
18
- action?: string;
19
- note?: string;
21
+ action: string;
22
+ note: string;
20
23
  }
21
24
 
22
- interface UpdateReport {
25
+ interface UpdateReportJson {
23
26
  checkedAt: string;
24
27
  images: number;
25
28
  privateImages: unknown[];
26
29
  rows: ReportRow[];
30
+ holds?: Record<string, string>;
31
+ holdsError?: string;
27
32
  }
28
33
 
29
- function isReport(value: unknown): value is UpdateReport {
30
- const r = value as Partial<UpdateReport> | null;
31
- return !!r && typeof r.checkedAt === "string" && typeof r.images === "number" && Array.isArray(r.privateImages) && Array.isArray(r.rows);
34
+ function isRow(value: unknown): value is ReportRow {
35
+ const r = value as Partial<ReportRow> | null;
36
+ return !!r && typeof r.stack === "string" && typeof r.service === "string" && typeof r.status === "string";
32
37
  }
33
38
 
34
- function clock(iso: string, timeZone: string): string {
35
- return new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" }).format(new Date(iso));
39
+ function isReport(value: unknown): value is UpdateReportJson {
40
+ const r = value as Partial<UpdateReportJson> | null;
41
+ return !!r && typeof r.checkedAt === "string" && typeof r.images === "number" && Array.isArray(r.privateImages) && Array.isArray(r.rows) && r.rows.every(isRow);
36
42
  }
37
43
 
38
- function name(row: ReportRow): string {
39
- return row.service === row.stack ? row.stack : `${row.stack}/${row.service}`;
44
+ /** A release tag as reported (`1.2.3`, `v2.17.0`, `2.6.5.5623-ls164`); `new build`, `older build`, `?` and digests are not. */
45
+ export function releaseTag(value: unknown): string | null {
46
+ return typeof value === "string" && /^v?[0-9][A-Za-z0-9.+_-]{0,100}$/.test(value) ? value : null;
40
47
  }
41
48
 
42
- /** One line per service: current to target version, with how the update applies when it is not a plain pull. */
43
- function line(row: ReportRow): string | null {
44
- const major = /newer major (\S+) available/.exec(row.note ?? "");
45
- if (row.status === "up-to-date") return major ? `• ${name(row)}: newer major ${major[1]} available (tag change)` : null;
46
- if (row.status === "unknown") return `• ${name(row)}: unresolved (check failed)`;
47
- const how = row.status === "rebuild" ? " (rebuild)" : row.action === "edit tag" ? " (tag change)" : row.action === "edit digest" ? " (digest change)" : "";
48
- const extra = major ? `; newer major ${major[1]} available` : /major version change/.test(row.note ?? "") ? "; major version change" : "";
49
- return `• ${name(row)}: ${displayVersion(row.running)} → ${displayVersion(row.latest)}${how}${extra}`;
49
+ function numbers(tag: string): number[] {
50
+ return (/^v?(\d+(?:\.\d+)*)/.exec(tag)?.[1] ?? "").split(".").filter(Boolean).map(Number);
50
51
  }
51
52
 
52
- /** The Discord message for one `update-report` result; a list too long for one message moves to an attachment. */
53
- export function formatDailyReport(report: unknown, timeZone: string): { content: string; attachment?: string } {
54
- if (!isReport(report)) return { content: failureMessage() };
55
- const lines = report.rows.map(line).filter((l): l is string => l !== null);
56
- const updates = report.rows.filter((r) => r.status === "update" || r.status === "release" || r.status === "rebuild").length;
57
- const scope = `${report.images} public images${report.privateImages.length > 0 ? `, ${report.privateImages.length} private excluded` : ""}, checked ${clock(report.checkedAt, timeZone)}`;
58
- const head = updates === 0 && lines.length === 0 ? `${REPORT_PREFIX} no updates (${scope}).` : `${REPORT_PREFIX} ${updates} update${updates === 1 ? "" : "s"} (${scope}).`;
59
- const body = [head, ...lines, "Checks only; nothing was updated."].join("\n");
60
- if (body.length <= MESSAGE_LIMIT) return { content: body };
61
- return { content: `${head}\nThe full list is attached.\nChecks only; nothing was updated.`, attachment: [head, ...lines].join("\n") };
53
+ /** True when the second tag's first number is higher: an update across a major version. */
54
+ export function crossesMajor(running: string | null, latest: string | null): boolean {
55
+ if (!running || !latest) return false;
56
+ const [a] = numbers(running);
57
+ const [b] = numbers(latest);
58
+ return a !== undefined && b !== undefined && b > a;
62
59
  }
63
60
 
64
- /** Sent when the check itself fails: no list, so nothing reads as up to date. */
65
- export function failureMessage(): string {
66
- return `${REPORT_PREFIX} the version check failed today, so there is no update list. The bridge log on Cortex has the reason.`;
61
+ const PENDING = new Set(["update", "release", "rebuild"]);
62
+
63
+ export interface ServiceLine {
64
+ service: string;
65
+ status: string;
66
+ action: string;
67
+ running: string | null;
68
+ latest: string | null;
69
+ /** The tag the Compose file names (`latest`, `18`, `2.9.13`); what "new build of" refers to. */
70
+ tag: string;
71
+ major: boolean;
72
+ note: string;
73
+ review?: Review;
67
74
  }
68
75
 
69
- /** custom_id of the report's Upgrade Arcane button: `arcane:manager:<x.y.z>`. */
70
- export const MANAGER_BUTTON = /^arcane:manager:(\d{1,4}\.\d{1,4}\.\d{1,4})$/;
76
+ export type StackState = "offered" | "running" | "updated" | "rolled back" | "failed" | "current";
71
77
 
72
- /** The newer Arcane manager release a report offers (`2.17.0`), from the row of stack and service `arcane`, or null. */
73
- export function managerOffer(report: unknown): string | null {
74
- if (!isReport(report)) return null;
75
- const row = report.rows.find((r) => r.stack === "arcane" && r.service === "arcane" && (r.status === "update" || r.status === "release"));
76
- const m = row ? /^v?(\d{1,4}\.\d{1,4}\.\d{1,4})$/.exec(displayVersion(row.latest)) : null;
78
+ export interface StackEntry {
79
+ stack: string;
80
+ lines: ServiceLine[];
81
+ /** Why the stack is held (`update-holds.json` in the GitOps repository); a held stack gets no button. */
82
+ held?: string;
83
+ /** The arcane stack only: the manager release its button upgrades to, `2.17.0`. */
84
+ manager?: string;
85
+ /** Whether the stack has an update its button applies. */
86
+ actionable: boolean;
87
+ state: StackState;
88
+ /** What the last update did, or why it failed. */
89
+ detail?: string;
90
+ }
91
+
92
+ /** One posted message of a report and the stacks it shows, so a click redraws exactly that message. */
93
+ export interface PostedPage {
94
+ id: string;
95
+ stacks: string[];
96
+ head: boolean;
97
+ }
98
+
99
+ export interface ArcaneReport {
100
+ date: string;
101
+ checkedAt: string;
102
+ channelId: string;
103
+ timeZone: string;
104
+ images: number;
105
+ privateCount: number;
106
+ holdsError?: string;
107
+ stacks: StackEntry[];
108
+ messages: PostedPage[];
109
+ }
110
+
111
+ function tagOf(ref: string): string {
112
+ if (ref.length === 0) return "?";
113
+ try {
114
+ return parseImageRef(ref).tag ?? "digest pin";
115
+ } catch {
116
+ return "?";
117
+ }
118
+ }
119
+
120
+ function managerVersion(line: ServiceLine): string | null {
121
+ const m = line.latest ? /^v?(\d{1,4}\.\d{1,4}\.\d{1,4})$/.exec(line.latest) : null;
77
122
  return m ? m[1] : null;
78
123
  }
79
124
 
80
- export type ManagerButtonState = "offer" | "running" | "upgraded" | "failed";
125
+ export interface ReportStacks {
126
+ stacks: StackEntry[];
127
+ images: number;
128
+ privateCount: number;
129
+ checkedAt: string;
130
+ holdsError?: string;
131
+ }
132
+
133
+ /**
134
+ * The report's stacks from one `update-report` answer: services with a
135
+ * pending update, a failed check, or a newer major tag, grouped by stack.
136
+ * The arcane stack is the manager, offered through manager-upgrade.
137
+ */
138
+ export function buildStacks(report: unknown): ReportStacks | null {
139
+ if (!isReport(report)) return null;
140
+ const holds = report.holds ?? {};
141
+ const shown = report.rows.filter((r) => PENDING.has(r.status) || r.status === "unknown" || /newer major \S+ available/.test(r.note ?? ""));
142
+ const names = [...new Set(shown.map((r) => r.stack))].sort((a, b) => a.localeCompare(b));
143
+ const stacks = names.map((stack): StackEntry => {
144
+ const lines = shown
145
+ .filter((r) => r.stack === stack)
146
+ .map((r): ServiceLine => {
147
+ const running = releaseTag(r.running);
148
+ const latest = releaseTag(r.latest);
149
+ return { service: r.service, status: r.status, action: r.action ?? "", running, latest, tag: tagOf(r.ref ?? ""), major: PENDING.has(r.status) && crossesMajor(running, latest), note: r.note ?? "" };
150
+ });
151
+ const pending = lines.some((l) => PENDING.has(l.status));
152
+ const held = holds[stack];
153
+ if (stack === "arcane") {
154
+ const offer = lines.map((l) => (PENDING.has(l.status) ? managerVersion(l) : null)).find((v) => v !== null) ?? null;
155
+ return { stack, lines, actionable: offer !== null && !held, ...(offer ? { manager: offer } : {}), ...(held ? { held } : {}), state: "offered" };
156
+ }
157
+ return { stack, lines, actionable: pending && !held, ...(held ? { held } : {}), state: "offered" };
158
+ });
159
+ return { stacks, images: report.images, privateCount: report.privateImages.length, checkedAt: report.checkedAt, ...(report.holdsError ? { holdsError: report.holdsError } : {}) };
160
+ }
161
+
162
+ /** Services whose update crosses a major version; each gets a release-notes review before the report posts. */
163
+ export function majorLines(stacks: readonly StackEntry[]): { stack: string; line: ServiceLine }[] {
164
+ return stacks.flatMap((s) => s.lines.filter((l) => l.major).map((line) => ({ stack: s.stack, line })));
165
+ }
166
+
167
+ const VERDICT_MARK: Record<Verdict, string> = { none: "🟢 nothing relevant", check: "🟡 check", breaking: "🔴 breaking" };
168
+
169
+ /** One service in plain words: what runs, what is offered, and what kind of change it is. */
170
+ export function serviceText(l: ServiceLine): string {
171
+ const name = escapeDiscord(l.service);
172
+ const tag = `\`${escapeDiscord(l.tag)}\``;
173
+ let text: string;
174
+ if (l.status === "unknown") {
175
+ text = `${name}: check failed${l.note ? ` (${escapeDiscord(l.note.slice(0, 160))})` : ""}`;
176
+ } else if (!PENDING.has(l.status)) {
177
+ const newer = /newer major (\S+) available/.exec(l.note)?.[1] ?? "?";
178
+ text = `${name}: ${escapeDiscord(l.running ?? l.tag)}, newer major ${escapeDiscord(newer)} available (a tag change by hand; Update does not apply it)`;
179
+ } else if (l.status === "rebuild") {
180
+ text = `${name}: rebuild of ${tag}`;
181
+ } else if (l.latest === null) {
182
+ text = `${name}: ${l.running ? `${escapeDiscord(l.running)} → ` : ""}new build of ${tag}`;
183
+ } else if (l.running !== null && l.running === l.latest) {
184
+ text = `${name}: ${escapeDiscord(l.latest)}, new build of ${tag}`;
185
+ } else {
186
+ const from = l.running ?? "older build";
187
+ const sameRelease = l.running !== null && numbers(l.running).join(".") === numbers(l.latest).join(".");
188
+ const kind = sameRelease ? `new build of ${tag}` : l.action === "edit tag" ? "new version (tag change)" : l.action === "edit digest" ? "new version (digest pin)" : "new version";
189
+ text = `${name}: ${escapeDiscord(from)} → ${escapeDiscord(l.latest)} · ${kind}`;
190
+ }
191
+ if (l.major) text += " · **major version**";
192
+ const newer = PENDING.has(l.status) ? /newer major (\S+) available/.exec(l.note)?.[1] : undefined;
193
+ if (newer) text += ` · newer major ${escapeDiscord(newer)} available`;
194
+ const review = l.review ? `\n-# ${l.review.verdict ? `${VERDICT_MARK[l.review.verdict]}: ` : "notes: "}${escapeDiscord(l.review.reason)}` : "";
195
+ return `• ${text}${review}`;
196
+ }
197
+
198
+ function stackText(s: StackEntry): string {
199
+ const held = s.held ? `\n-# ⏸ held: ${escapeDiscord(s.held)}` : "";
200
+ const detail = s.detail ? `\n-# ${escapeDiscord(s.detail)}` : "";
201
+ return `**${escapeDiscord(s.stack)}**\n${s.lines.map(serviceText).join("\n")}${held}${detail}`.slice(0, 1800);
202
+ }
203
+
204
+ const PRIMARY = 1;
205
+ const SECONDARY = 2;
206
+ const SUCCESS = 3;
207
+ const DANGER = 4;
208
+ const STACK = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,80}$/;
209
+
210
+ /** custom_id of a stack's Update button. */
211
+ export function stackId(stack: string): string {
212
+ return `arcane:stack:${stack}`;
213
+ }
214
+
215
+ /** custom_id of the arcane stack's button: `arcane:manager:<x.y.z>`. */
216
+ export const MANAGER_BUTTON = /^arcane:manager:(\d{1,4}\.\d{1,4}\.\d{1,4})$/;
217
+
218
+ export type ArcaneButton = { kind: "stack"; stack: string } | { kind: "manager"; version: string };
219
+
220
+ export function parseArcaneButton(customId: string): ArcaneButton | null {
221
+ const stack = /^arcane:stack:(.+)$/.exec(customId);
222
+ if (stack) return STACK.test(stack[1]) && stack[1] !== "arcane" ? { kind: "stack", stack: stack[1] } : null;
223
+ const manager = MANAGER_BUTTON.exec(customId);
224
+ return manager ? { kind: "manager", version: manager[1] } : null;
225
+ }
226
+
227
+ function stackButton(s: StackEntry): MessageComponent {
228
+ const id = s.manager ? `arcane:manager:${s.manager}` : stackId(s.stack);
229
+ if (s.state === "running") return { type: 2, style: SECONDARY, label: s.manager ? `Upgrading Arcane to v${s.manager}…` : "Updating…", custom_id: id, disabled: true };
230
+ if (s.state === "updated") return { type: 2, style: SUCCESS, label: s.manager ? `Arcane v${s.manager} is running` : "Updated", custom_id: id, disabled: true };
231
+ if (s.state === "current") return { type: 2, style: SECONDARY, label: "Already current", custom_id: id, disabled: true };
232
+ if (s.state === "rolled back") return { type: 2, style: DANGER, label: "Rolled back", custom_id: id, disabled: true };
233
+ if (s.state === "failed") return s.manager ? { type: 2, style: DANGER, label: "Upgrade did not complete", custom_id: id, disabled: true } : { type: 2, style: DANGER, label: "Retry", custom_id: id };
234
+ return { type: 2, style: PRIMARY, label: s.manager ? `Upgrade Arcane to v${s.manager}` : "Update", custom_id: id };
235
+ }
236
+
237
+ /** A stack with an update its button applies is a Section with the button beside it; any other stack is text only. */
238
+ export function stackComponent(s: StackEntry): MessageComponent {
239
+ const text: MessageComponent = { type: 10, content: stackText(s) };
240
+ return s.actionable ? { type: 9, components: [text], accessory: stackButton(s) } : text;
241
+ }
242
+
243
+ function clock(iso: string, timeZone: string): string {
244
+ return new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" }).format(new Date(iso));
245
+ }
246
+
247
+ type ReportView = Omit<ArcaneReport, "messages" | "date" | "channelId">;
248
+
249
+ export function headText(report: ReportView): MessageComponent {
250
+ const withUpdates = report.stacks.filter((s) => s.lines.some((l) => PENDING.has(l.status))).length;
251
+ const scope = `${report.images} images${report.privateCount > 0 ? `, ${report.privateCount} private excluded` : ""}, checked ${clock(report.checkedAt, report.timeZone)}`;
252
+ const head =
253
+ report.stacks.length === 0
254
+ ? `**${REPORT_PREFIX}** no updates (${scope}).`
255
+ : `**${REPORT_PREFIX}** ${withUpdates} stack${withUpdates === 1 ? "" : "s"} with updates (${scope}).`;
256
+ const warning = report.holdsError ? `\n-# ⚠️ ${escapeDiscord(report.holdsError)}; Update still refuses held stacks` : "";
257
+ return { type: 10, content: `${head}${warning}` };
258
+ }
259
+
260
+ const MESSAGE_COMPONENTS = 40;
261
+ /** Discord documents no total text limit for these messages; this keeps each one near a normal message's size. */
262
+ const MESSAGE_TEXT = 3500;
263
+
264
+ /** The components of one posted message, redrawn from the report's current state. */
265
+ export function messageComponents(report: ReportView, page: Omit<PostedPage, "id">): MessageComponent[] {
266
+ const out: MessageComponent[] = [];
267
+ if (page.head) out.push(headText(report));
268
+ for (const name of page.stacks) {
269
+ const s = report.stacks.find((x) => x.stack === name);
270
+ if (s) out.push(stackComponent(s));
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /** Splits the report into messages within 40 components and the text budget: header first. */
276
+ export function layoutMessages(report: ReportView): Omit<PostedPage, "id">[] {
277
+ const pages: Omit<PostedPage, "id">[] = [];
278
+ let page: Omit<PostedPage, "id"> = { stacks: [], head: true };
279
+ let used = 1;
280
+ let text = (headText(report).content ?? "").length;
281
+ for (const s of report.stacks) {
282
+ const size = s.actionable ? 3 : 1;
283
+ const length = stackText(s).length;
284
+ if (used + size > MESSAGE_COMPONENTS || text + length > MESSAGE_TEXT) {
285
+ pages.push(page);
286
+ page = { stacks: [], head: false };
287
+ used = 0;
288
+ text = 0;
289
+ }
290
+ page.stacks.push(s.stack);
291
+ used += size;
292
+ text += length;
293
+ }
294
+ pages.push(page);
295
+ return pages;
296
+ }
297
+
298
+ /** Sent when the check itself fails: no list, so nothing reads as up to date. */
299
+ export function failureMessage(): string {
300
+ return `${REPORT_PREFIX} the version check failed today, so there is no update list. The bridge log on Cortex has the reason.`;
301
+ }
81
302
 
82
- /** The one button under the report; only the offer is clickable. */
83
- export function managerButton(version: string, state: ManagerButtonState): MessageComponent[] {
84
- const label = { offer: `Upgrade Arcane to v${version}`, running: `Upgrading Arcane to v${version}…`, upgraded: `Arcane v${version} is running`, failed: `Upgrade to v${version} did not complete` }[state];
85
- const style = { offer: 1, running: 2, upgraded: 3, failed: 4 }[state];
86
- return [{ type: 1, components: [{ type: 2, style, label, custom_id: `arcane:manager:${version}`, ...(state === "offer" ? {} : { disabled: true }) }] }];
303
+ /** The stack's state after `stack-update`, from its JSON answer, and one line for the report. */
304
+ export function stackOutcome(out: Record<string, unknown>): { state: StackState; detail: string } {
305
+ const reason = typeof out.reason === "string" ? out.reason.slice(0, 300) : "";
306
+ const services = Array.isArray(out.services) ? (out.services as { service?: unknown; to?: unknown }[]) : [];
307
+ const moved = services.map((s) => `${String(s.service)} ${String(s.to)}`).join(", ");
308
+ if (out.status === "updated") return { state: "updated", detail: `updated: ${moved || "every service"}` };
309
+ if (out.status === "nothing to do") return { state: "current", detail: "nothing to update any more" };
310
+ if (out.status === "rolled back") return { state: "rolled back", detail: `rolled back: ${reason || "a check failed"}; the previous Compose file runs again` };
311
+ if (out.status === "failed") return { state: "failed", detail: `failed: ${reason || "no reason given"}` };
312
+ return { state: "failed", detail: "the update returned an unexpected answer; check the stack with the arcane specialist" };
87
313
  }
88
314
 
89
315
  function short(value: unknown): string {
90
316
  return typeof value === "string" ? value.replace(/^sha256:/, "").slice(0, 12) : "?";
91
317
  }
92
318
 
93
- /** The reply after an upgrade, from the wrapper's `manager-upgrade` answer. */
319
+ /** The reply after a manager upgrade, from the wrapper's `manager-upgrade` answer. */
94
320
  export function managerSummary(out: Record<string, unknown>): string {
95
321
  const from = typeof out.from === "string" ? `v${out.from}` : "the previous version";
96
322
  const to = typeof out.to === "string" ? `v${out.to}` : "the new version";
@@ -105,3 +331,46 @@ export function managerSummary(out: Record<string, unknown>): string {
105
331
  if (out.status === "stopped") return `❌ The Arcane upgrade to ${to} stopped before the restart: ${reason}. The pin was reverted; ${from} keeps running.`;
106
332
  return `❌ The Arcane upgrade returned an unexpected answer; check the manager with the arcane specialist before retrying.`;
107
333
  }
334
+
335
+ type StackNotes = { notes: Pick<ReleaseNote, "tag" | "published" | "notes">[]; omitted: string[] } | { notes: null; reason: string };
336
+
337
+ function isNote(value: unknown): value is Pick<ReleaseNote, "tag" | "published" | "notes"> {
338
+ const n = value as Partial<ReleaseNote> | null;
339
+ return !!n && typeof n.tag === "string" && typeof n.published === "string" && typeof n.notes === "string";
340
+ }
341
+
342
+ /** The releases from the wrapper's `release-notes <stack>/<service>` answer, or why there are none to review. */
343
+ export function releaseNotesFromJson(value: unknown): StackNotes {
344
+ const r = value as { releases?: unknown; omitted?: unknown; reason?: unknown } | null;
345
+ if (!r || typeof r !== "object" || !Array.isArray(r.releases) || !r.releases.every(isNote)) return { notes: null, reason: "release-notes returned an unexpected answer" };
346
+ if (r.releases.length === 0) return { notes: null, reason: typeof r.reason === "string" && r.reason.length > 0 ? r.reason.slice(0, 160) : "no release notes found" };
347
+ const omitted = Array.isArray(r.omitted) ? r.omitted.filter((o): o is string => typeof o === "string") : [];
348
+ return { notes: r.releases, omitted };
349
+ }
350
+
351
+ /** What the reviewer is told about a service of a stack, so it can tell what a release would break. */
352
+ export function serviceUsage(stack: string, line: ServiceLine): string {
353
+ return `${line.service} is a service of the Docker Compose stack ${stack} on the Arcane VM, deployed through GitOps. The update replaces its image (tag ${line.tag}) from ${line.running ?? "an older build"} to ${line.latest ?? "a newer build"} and changes nothing else in the Compose file: no environment variables, volumes, ports, or companion services. Judge whether the release needs a configuration, data, or companion change before or after the swap.`;
354
+ }
355
+
356
+ // ---------------------------------------------------------------- posted report state
357
+
358
+ function postedPath(): string {
359
+ return join(configDir(), "arcane-report-posted.json");
360
+ }
361
+
362
+ export function readPostedArcane(): ArcaneReport | null {
363
+ try {
364
+ const r = JSON.parse(readFileSync(postedPath(), "utf-8")) as Partial<ArcaneReport>;
365
+ return typeof r.date === "string" && Array.isArray(r.stacks) && Array.isArray(r.messages) ? (r as ArcaneReport) : null;
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+
371
+ export function writePostedArcane(report: ArcaneReport): void {
372
+ const path = postedPath();
373
+ const tmp = `${path}.tmp`;
374
+ writeFileSync(tmp, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
375
+ renameSync(tmp, path);
376
+ }