@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.
- package/README.md +3 -3
- package/agents/arcane.md +3 -1
- package/agents/release-reviewer.md +2 -2
- package/extensions/astro-discord/daily-report.test.ts +153 -77
- package/extensions/astro-discord/daily-report.ts +322 -53
- package/extensions/astro-discord/index.ts +180 -51
- package/extensions/astro-discord/reports.ts +0 -2
- package/extensions/astro-discord/tools-report.test.ts +1 -1
- package/extensions/astro-discord/tools-report.ts +15 -3
- package/extensions/specialist-gate/approvals.test.ts +3 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +16 -3
- package/specialists/AGENTS.md +3 -2
- package/specialists/README.md +5 -5
- package/specialists/arcane/run.ts +223 -10
- package/specialists/lib/stack-update.test.ts +101 -0
- package/specialists/lib/stack-update.ts +115 -0
- package/specialists/wrappers.test.ts +1 -0
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
+
ref: string;
|
|
18
|
+
running: string;
|
|
19
|
+
latest: string;
|
|
17
20
|
status: string;
|
|
18
|
-
action
|
|
19
|
-
note
|
|
21
|
+
action: string;
|
|
22
|
+
note: string;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
interface
|
|
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
|
|
30
|
-
const r = value as Partial<
|
|
31
|
-
return !!r && typeof r.
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
/**
|
|
53
|
-
export function
|
|
54
|
-
if (!
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
83
|
-
export function
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
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
|
|
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
|
+
}
|