@astrofoundry/pi-astro 0.34.1 → 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/inference.md +4 -5
- 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 +78 -13
- package/extensions/astro-discord/tools-report.ts +123 -40
- package/extensions/specialist-gate/approvals.test.ts +4 -1
- package/extensions/specialist-gate/index.ts +1 -1
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +16 -3
- package/skills/edge/SKILL.md +1 -1
- package/skills/inference/SKILL.md +3 -27
- package/skills/proxmox/SKILL.md +2 -2
- package/skills/security/SKILL.md +2 -2
- package/specialists/AGENTS.md +8 -7
- package/specialists/README.md +24 -18
- package/specialists/arcane/run.ts +223 -10
- package/specialists/edge/run.ts +3 -3
- package/specialists/entry/remote/spc-arcane-install.sh +2 -2
- package/specialists/entry/remote/spc-arcane-op.py +3 -0
- package/specialists/entry/specialist-install.sh +266 -8
- package/specialists/entry/specialist-install.test.ts +50 -1
- package/specialists/inference/run.ts +12 -94
- package/specialists/lib/arcane-host.test.ts +24 -1
- package/specialists/lib/arcane-host.ts +6 -2
- package/specialists/lib/deployment.ts +1 -1
- package/specialists/lib/stack-update.test.ts +101 -0
- package/specialists/lib/stack-update.ts +115 -0
- package/specialists/provision/provision.sh +2 -2
- package/specialists/wrappers.test.ts +7 -16
- package/specialists/entry/remote/spc-inference-hermes-entry.sh +0 -27
- package/specialists/entry/remote/spc-inference-pulsar-entry.sh +0 -14
|
@@ -14,7 +14,23 @@ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./comman
|
|
|
14
14
|
import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, type ReportName, type ReportSchedule, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, reportSchedule, saveConfig } from "./config.ts";
|
|
15
15
|
import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
|
|
16
16
|
import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type ArcaneReport,
|
|
19
|
+
buildStacks,
|
|
20
|
+
failureMessage,
|
|
21
|
+
layoutMessages as arcaneLayout,
|
|
22
|
+
majorLines,
|
|
23
|
+
managerSummary,
|
|
24
|
+
messageComponents as arcaneComponents,
|
|
25
|
+
parseArcaneButton,
|
|
26
|
+
readPostedArcane,
|
|
27
|
+
releaseNotesFromJson,
|
|
28
|
+
type ServiceLine,
|
|
29
|
+
serviceUsage,
|
|
30
|
+
stackOutcome,
|
|
31
|
+
type StackState,
|
|
32
|
+
writePostedArcane,
|
|
33
|
+
} from "./daily-report.ts";
|
|
18
34
|
import { GatewayClient } from "./gateway.ts";
|
|
19
35
|
import { type CommandOption, IS_COMPONENTS_V2, KeyedQueue, MEDIA, MEDIA_COMMANDS, type MediaKind, type Outcome, addOutcome, applyOutcome, commandArgs, hasActiveButton, listMessage, mediaAccess, mediaPointer, parseAddButton, parseAddReport, parseMediaList, parseQueueState, queueLine } from "./media.ts";
|
|
20
36
|
import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
|
|
@@ -35,6 +51,7 @@ import {
|
|
|
35
51
|
notesFor,
|
|
36
52
|
parseToolsButton,
|
|
37
53
|
parseVerdict,
|
|
54
|
+
reviewTask,
|
|
38
55
|
readPackageVersion,
|
|
39
56
|
readPostedReport,
|
|
40
57
|
REVIEW_SOURCES,
|
|
@@ -135,8 +152,12 @@ const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url
|
|
|
135
152
|
/** Upper bound for one tool update from an Update button; `brew upgrade` of everything can take a while. */
|
|
136
153
|
const UPDATE_TIMEOUT_MS = 30 * 60_000;
|
|
137
154
|
const REVIEWER = "astro.release-reviewer";
|
|
138
|
-
/** Key of a running Arcane manager upgrade among the tool runs,
|
|
139
|
-
const
|
|
155
|
+
/** Key of a running Arcane stack update or manager upgrade among the tool runs; one at a time, because all of them push to the same GitOps checkout. */
|
|
156
|
+
const ARCANE_RUN = "arcane-update";
|
|
157
|
+
/** Upper bound for one `stack-update`: its own worst case (sync, redeploy, and health wait, again for a rollback, plus the checks) is about 125 minutes. */
|
|
158
|
+
const STACK_UPDATE_TIMEOUT_MS = 135 * 60_000;
|
|
159
|
+
/** Interaction tokens last 15 minutes; a later note goes to the channel as a reply to the report. */
|
|
160
|
+
const FOLLOW_UP_WINDOW_MS = 14 * 60_000;
|
|
140
161
|
|
|
141
162
|
interface ActiveTask {
|
|
142
163
|
specialist: string;
|
|
@@ -307,6 +328,7 @@ class Bridge {
|
|
|
307
328
|
this.gateway = gateway;
|
|
308
329
|
void this.registerMediaCommands();
|
|
309
330
|
await this.closeInterruptedUpdates();
|
|
331
|
+
await this.closeInterruptedArcane();
|
|
310
332
|
// Checked every minute: each report decides whether today's is due, and waiting restarts check whether their service is idle.
|
|
311
333
|
this.reportTimer = setInterval(() => {
|
|
312
334
|
void this.maybeReports();
|
|
@@ -423,32 +445,40 @@ class Bridge {
|
|
|
423
445
|
}
|
|
424
446
|
}
|
|
425
447
|
|
|
426
|
-
/**
|
|
448
|
+
/**
|
|
449
|
+
* The Arcane Daily Report through the specialist wrapper, without a model:
|
|
450
|
+
* one section per stack with updates, a release-notes review for every
|
|
451
|
+
* major-version update, and an Update button per stack that is not held.
|
|
452
|
+
*/
|
|
427
453
|
private async arcaneReport(schedule: ReportSchedule, date: string): Promise<void> {
|
|
428
454
|
if (!loadSpecialists().config) {
|
|
429
455
|
log("arcane report: specialists.json missing; no report");
|
|
430
456
|
return;
|
|
431
457
|
}
|
|
432
458
|
log(`arcane report ${date}: running arcane update-report`);
|
|
433
|
-
let
|
|
434
|
-
let offer: string | null = null;
|
|
459
|
+
let report: ArcaneReport | null = null;
|
|
435
460
|
try {
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
461
|
+
const built = buildStacks(JSON.parse(await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000)));
|
|
462
|
+
if (!built) throw new Error("update-report returned an unexpected answer");
|
|
463
|
+
for (const { stack, line } of majorLines(built.stacks)) if (!this.stopping) line.review = await this.reviewMajor(stack, line);
|
|
464
|
+
report = { date, channelId: schedule.channelId, timeZone: schedule.timeZone, ...built, messages: [] };
|
|
439
465
|
} catch (err) {
|
|
440
466
|
log(`arcane report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
|
|
441
|
-
message = { content: failureMessage() };
|
|
442
467
|
}
|
|
443
468
|
if (this.stopping) return;
|
|
444
469
|
writeReportState("arcane", { date, status: "sending" });
|
|
445
|
-
|
|
446
|
-
content:
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
470
|
+
if (!report) {
|
|
471
|
+
const posted = await this.rest.createMessage(schedule.channelId, { content: failureMessage() });
|
|
472
|
+
writeReportState("arcane", { date, status: "failed", messageId: posted.id });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
for (const page of arcaneLayout(report)) {
|
|
476
|
+
const posted = await this.rest.createComponentsMessage(schedule.channelId, arcaneComponents(report, page));
|
|
477
|
+
report.messages.push({ id: posted.id, ...page });
|
|
478
|
+
}
|
|
479
|
+
writePostedArcane(report);
|
|
480
|
+
writeReportState("arcane", { date, status: "sent", messageId: report.messages[0]?.id });
|
|
481
|
+
log(`arcane report ${date}: ${report.stacks.length} stack(s) in ${report.messages.length} message(s)`);
|
|
452
482
|
}
|
|
453
483
|
|
|
454
484
|
/** Runs a program with an argument list as this user, never through a shell; resolves even on failure. */
|
|
@@ -497,10 +527,28 @@ class Bridge {
|
|
|
497
527
|
log(`tools report ${date}: ${report.lines.length} update(s) in ${report.messages.length} message(s)`);
|
|
498
528
|
}
|
|
499
529
|
|
|
500
|
-
/** The release-notes verdict of one line
|
|
530
|
+
/** The release-notes verdict of one tools line. */
|
|
501
531
|
private async reviewLine(line: ToolLine): Promise<Review> {
|
|
502
532
|
const notes = await notesFor(line, fetch);
|
|
503
533
|
if (notes.notes === null) return { verdict: null, reason: notes.reason };
|
|
534
|
+
return this.review(`${line.label} ${line.installed} -> ${line.latest}`, reviewPrompt(line, notes.notes, notes.omitted));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The release-notes verdict of a stack service's major-version update, from the wrapper's `release-notes`. */
|
|
538
|
+
private async reviewMajor(stack: string, line: ServiceLine): Promise<Review> {
|
|
539
|
+
let notes: ReturnType<typeof releaseNotesFromJson>;
|
|
540
|
+
try {
|
|
541
|
+
notes = releaseNotesFromJson(JSON.parse(await this.runEntry("arcane", "daily-report", ["release-notes", `${stack}/${line.service}`], 5 * 60_000)));
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return { verdict: null, reason: `release notes unavailable: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 120)}` };
|
|
544
|
+
}
|
|
545
|
+
if (notes.notes === null) return { verdict: null, reason: notes.reason };
|
|
546
|
+
const subject = { label: `${stack}/${line.service}`, installed: line.running ?? "an older build", latest: line.latest ?? "a newer build", usage: serviceUsage(stack, line) };
|
|
547
|
+
return this.review(`${subject.label} ${subject.installed} -> ${subject.latest}`, reviewTask(subject, notes.notes, notes.omitted));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The tool-less reviewer agent's verdict on one task; the release notes reach it only as task text. */
|
|
551
|
+
private async review(subject: string, task: string): Promise<Review> {
|
|
504
552
|
const agent = this.specialists().agents.find((a) => a.name === REVIEWER);
|
|
505
553
|
if (!agent) return { verdict: null, reason: `${REVIEWER} is not installed` };
|
|
506
554
|
const { depth } = currentDepth();
|
|
@@ -508,7 +556,7 @@ class Bridge {
|
|
|
508
556
|
const result = await runAgent({
|
|
509
557
|
agent,
|
|
510
558
|
skills: [],
|
|
511
|
-
task
|
|
559
|
+
task,
|
|
512
560
|
cwd: process.cwd(),
|
|
513
561
|
depth,
|
|
514
562
|
remaining: 0,
|
|
@@ -516,7 +564,7 @@ class Bridge {
|
|
|
516
564
|
env: { [ACTIVATION_ENV]: "0" },
|
|
517
565
|
});
|
|
518
566
|
const verdict = isFailed(result) ? null : parseVerdict(finalOutput(result.messages));
|
|
519
|
-
log(`
|
|
567
|
+
log(`release review: ${subject}: ${verdict?.verdict ?? "no answer"}`);
|
|
520
568
|
return verdict ?? { verdict: null, reason: "the review gave no usable answer; read the notes before updating" };
|
|
521
569
|
} catch (err) {
|
|
522
570
|
return { verdict: null, reason: `the review failed: ${err instanceof Error ? err.message.slice(0, 120) : String(err)}` };
|
|
@@ -541,6 +589,19 @@ class Bridge {
|
|
|
541
589
|
for (const m of report.messages) await this.rest.editComponents(report.channelId, m.id, messageComponents(report, m)).catch(() => undefined);
|
|
542
590
|
}
|
|
543
591
|
|
|
592
|
+
/** Stacks a crash or restart left "updating" become Retry (the manager: did not complete); the wrapper may have stopped halfway. */
|
|
593
|
+
private async closeInterruptedArcane(): Promise<void> {
|
|
594
|
+
const report = readPostedArcane();
|
|
595
|
+
const stuck = report?.stacks.filter((s) => s.state === "running") ?? [];
|
|
596
|
+
if (!report || stuck.length === 0) return;
|
|
597
|
+
for (const s of stuck) {
|
|
598
|
+
s.state = "failed";
|
|
599
|
+
s.detail = "interrupted by a bridge restart; check the stack and the GitOps repository with the arcane specialist before pressing Retry";
|
|
600
|
+
}
|
|
601
|
+
writePostedArcane(report);
|
|
602
|
+
for (const m of report.messages.filter((x) => stuck.some((s) => x.stacks.includes(s.stack)))) await this.rest.editComponents(report.channelId, m.id, arcaneComponents(report, m)).catch(() => undefined);
|
|
603
|
+
}
|
|
604
|
+
|
|
544
605
|
private async redrawLine(report: ToolsReport, key: string | null): Promise<void> {
|
|
545
606
|
const m = report.messages.find((x) => (key === null ? x.all : x.keys.includes(key)));
|
|
546
607
|
if (m) await this.rest.editComponents(report.channelId, m.id, messageComponents(report, m)).catch((err) => log(`tools report: redraw failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
@@ -659,67 +720,135 @@ class Bridge {
|
|
|
659
720
|
if (services.length > 0) writeRestarts(addRestarts(readRestarts(), [...new Set(services)], label, new Date()));
|
|
660
721
|
}
|
|
661
722
|
|
|
662
|
-
/**
|
|
663
|
-
|
|
664
|
-
|
|
723
|
+
/**
|
|
724
|
+
* The Arcane Daily Report's Update and Upgrade Arcane buttons: owners only,
|
|
725
|
+
* stacks the newest report still offers, one Arcane update at a time. The
|
|
726
|
+
* run is marked before the first await, so a second click cannot start
|
|
727
|
+
* another one.
|
|
728
|
+
*/
|
|
729
|
+
private async onArcaneButton(interaction: Interaction, customId: string): Promise<void> {
|
|
730
|
+
const button = parseArcaneButton(customId);
|
|
665
731
|
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
|
|
666
732
|
this.refreshConfig();
|
|
667
|
-
if (!
|
|
733
|
+
if (!button) {
|
|
668
734
|
await this.rest.ephemeralReply(interaction.id, interaction.token, "This button is no longer active.");
|
|
669
735
|
return;
|
|
670
736
|
}
|
|
671
737
|
if (!isOwner(this.config, userId)) {
|
|
672
|
-
await this.rest.ephemeralReply(interaction.id, interaction.token, "Only owners upgrade Arcane.");
|
|
738
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, button.kind === "manager" ? "Only owners upgrade Arcane." : "Only owners update stacks.");
|
|
673
739
|
return;
|
|
674
740
|
}
|
|
675
741
|
if (this.stopping) {
|
|
676
742
|
await this.rest.ephemeralReply(interaction.id, interaction.token, "The bridge is restarting; try again in a minute.");
|
|
677
743
|
return;
|
|
678
744
|
}
|
|
679
|
-
const
|
|
680
|
-
|
|
745
|
+
const stack = button.kind === "manager" ? "arcane" : button.stack;
|
|
746
|
+
const report = readPostedArcane();
|
|
747
|
+
const page = report?.messages.find((m) => m.id === interaction.message?.id && m.stacks.includes(stack));
|
|
748
|
+
const entry = page ? report?.stacks.find((s) => s.stack === stack && s.actionable && (button.kind === "stack" || s.manager === button.version)) : undefined;
|
|
749
|
+
if (!report || !entry) {
|
|
681
750
|
await this.rest.ephemeralReply(interaction.id, interaction.token, "This offer is outdated; use the newest Arcane Daily Report.");
|
|
682
751
|
return;
|
|
683
752
|
}
|
|
684
|
-
if (
|
|
685
|
-
await this.rest.ephemeralReply(interaction.id, interaction.token, "
|
|
753
|
+
if (entry.state !== "offered" && !(entry.state === "failed" && button.kind === "stack")) {
|
|
754
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "This update already ran; its line in the report says how it ended.");
|
|
686
755
|
return;
|
|
687
756
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
757
|
+
const running = this.toolRuns.get(ARCANE_RUN);
|
|
758
|
+
if (running) {
|
|
759
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, `An Arcane update is already running (${running.label}); try again when it finishes.`);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
this.toolRuns.set(ARCANE_RUN, { channelId: report.channelId, label: button.kind === "manager" ? `the Arcane manager to v${button.version}` : `the ${stack} stack` });
|
|
763
|
+
this.saveOpenWork();
|
|
764
|
+
const click = { token: interaction.token, at: Date.now(), userId };
|
|
765
|
+
try {
|
|
766
|
+
await this.rest.deferUpdate(interaction.id, interaction.token);
|
|
767
|
+
if (button.kind === "manager") await this.runManagerUpgrade(report, button.version, click);
|
|
768
|
+
else await this.runStackUpdate(report, stack, click);
|
|
769
|
+
} catch (err) {
|
|
770
|
+
log(`arcane: ${stack} update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
771
|
+
} finally {
|
|
772
|
+
this.toolRuns.delete(ARCANE_RUN);
|
|
773
|
+
this.saveOpenWork();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Sets one stack's state in the posted report and redraws its message
|
|
779
|
+
* through the bot's own route, which outlives the interaction token. A run
|
|
780
|
+
* from an older report changes only that report's message.
|
|
781
|
+
*/
|
|
782
|
+
private async markArcane(report: ArcaneReport, stack: string, state: StackState, detail?: string): Promise<void> {
|
|
783
|
+
const current = readPostedArcane();
|
|
784
|
+
const target = current && current.date === report.date ? current : report;
|
|
785
|
+
const entry = target.stacks.find((s) => s.stack === stack);
|
|
786
|
+
if (entry) {
|
|
787
|
+
entry.state = state;
|
|
788
|
+
entry.detail = detail;
|
|
789
|
+
}
|
|
790
|
+
if (target === current) writePostedArcane(target);
|
|
791
|
+
const page = target.messages.find((m) => m.stacks.includes(stack));
|
|
792
|
+
if (page) await this.rest.editComponents(target.channelId, page.id, arcaneComponents(target, page)).catch((err) => log(`arcane report: redraw failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** A note only the clicking owner sees while the interaction token lives; after that, a reply to the report in the channel. */
|
|
796
|
+
private async arcaneNote(report: ArcaneReport, stack: string, click: { token: string; at: number }, content: string): Promise<void> {
|
|
797
|
+
if (Date.now() - click.at < FOLLOW_UP_WINDOW_MS) {
|
|
798
|
+
try {
|
|
799
|
+
await this.rest.followUp(this.config.applicationId, click.token, { content, flags: EPHEMERAL });
|
|
800
|
+
return;
|
|
801
|
+
} catch (err) {
|
|
802
|
+
log(`arcane: ephemeral note failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const page = report.messages.find((m) => m.stacks.includes(stack)) ?? report.messages[0];
|
|
806
|
+
await this.rest.createMessage(report.channelId, { content, ...(page ? { replyTo: page.id } : {}) }).catch(() => undefined);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** One stack's `stack-update`, which edits, commits, redeploys, checks, and rolls back on its own; the button shows how it ended. */
|
|
810
|
+
private async runStackUpdate(report: ArcaneReport, stack: string, click: { token: string; at: number; userId: string }): Promise<void> {
|
|
811
|
+
await this.markArcane(report, stack, "running");
|
|
812
|
+
log(`arcane: updating stack ${stack} for ${click.userId}`);
|
|
813
|
+
let outcome: { state: StackState; detail: string };
|
|
814
|
+
try {
|
|
815
|
+
// The owner saw the major-version lines and their review in the report; a major that appeared since is refused.
|
|
816
|
+
const shown = report.stacks.find((s) => s.stack === stack)?.lines.some((l) => l.major) ?? false;
|
|
817
|
+
const args = ["stack-update", stack, ...(shown ? ["--confirm"] : [])];
|
|
818
|
+
outcome = stackOutcome(JSON.parse(await this.runEntry("arcane", `discord-${click.userId}`, args, STACK_UPDATE_TIMEOUT_MS)) as Record<string, unknown>);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
outcome = { state: "failed", detail: `failed: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300)}` };
|
|
821
|
+
}
|
|
822
|
+
await this.markArcane(report, stack, outcome.state, outcome.detail);
|
|
823
|
+
log(`arcane: ${stack} ${outcome.detail}`);
|
|
824
|
+
if (outcome.state !== "updated") await this.arcaneNote(report, stack, click, `${stack}: ${outcome.detail}`);
|
|
691
825
|
}
|
|
692
826
|
|
|
693
827
|
/**
|
|
694
828
|
* The upgrade runs through the wrapper (arcane/AGENTS.md "Manager upgrades")
|
|
695
829
|
* and can take longer than an interaction token lives, so the report and the
|
|
696
|
-
* reply go through the bot's own message routes.
|
|
697
|
-
* bridge restart lands in the middle.
|
|
830
|
+
* reply go through the bot's own message routes.
|
|
698
831
|
*/
|
|
699
|
-
private async runManagerUpgrade(
|
|
700
|
-
await this.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
log(`arcane: manager upgrade to ${version} for ${userId}`);
|
|
704
|
-
let ok = false;
|
|
832
|
+
private async runManagerUpgrade(report: ArcaneReport, version: string, click: { userId: string }): Promise<void> {
|
|
833
|
+
await this.markArcane(report, "arcane", "running");
|
|
834
|
+
log(`arcane: manager upgrade to ${version} for ${click.userId}`);
|
|
835
|
+
let state: StackState = "failed";
|
|
705
836
|
let summary: string;
|
|
706
837
|
try {
|
|
707
|
-
const out = JSON.parse(await this.runEntry("arcane", `discord-${userId}`, ["manager-upgrade", version], 80 * 60_000)) as Record<string, unknown>;
|
|
708
|
-
|
|
838
|
+
const out = JSON.parse(await this.runEntry("arcane", `discord-${click.userId}`, ["manager-upgrade", version], 80 * 60_000)) as Record<string, unknown>;
|
|
839
|
+
state = out.status === "upgraded" ? "updated" : out.status === "rolled back" ? "rolled back" : "failed";
|
|
709
840
|
summary = managerSummary(out);
|
|
710
|
-
if (
|
|
841
|
+
if (state === "updated") {
|
|
711
842
|
const cli = await this.execTool("/usr/bin/sudo", ["-n", INSTALLER, "arcane-cli", version], { timeoutMs: 10 * 60_000 });
|
|
712
843
|
summary += cli.code === 0 ? ` arcane-cli ${version} is installed for the specialists.` : ` arcane-cli ${version} was not installed (${cli.stderr.trim().split("\n").pop()?.slice(0, 200) ?? `exit ${cli.code}`}); the Cortex Tools Report offers it.`;
|
|
713
844
|
}
|
|
714
845
|
} catch (err) {
|
|
715
846
|
summary = `❌ The Arcane upgrade to v${version} did not finish: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300)}. Check the manager with the arcane specialist before retrying.`;
|
|
716
|
-
} finally {
|
|
717
|
-
this.toolRuns.delete(MANAGER_RUN);
|
|
718
|
-
this.saveOpenWork();
|
|
719
847
|
}
|
|
720
|
-
await this.
|
|
721
|
-
|
|
722
|
-
|
|
848
|
+
await this.markArcane(report, "arcane", state);
|
|
849
|
+
const page = report.messages.find((m) => m.stacks.includes("arcane")) ?? report.messages[0];
|
|
850
|
+
await this.rest.createMessage(report.channelId, { content: summary, ...(page ? { replyTo: page.id } : {}) }).catch(() => undefined);
|
|
851
|
+
log(`arcane: manager upgrade to ${version} ${state === "updated" ? "done" : "did not complete"}`);
|
|
723
852
|
}
|
|
724
853
|
|
|
725
854
|
private async brewNodeVersion(): Promise<string> {
|
|
@@ -1109,8 +1238,8 @@ class Bridge {
|
|
|
1109
1238
|
await this.onToolsButton(interaction, customId);
|
|
1110
1239
|
return;
|
|
1111
1240
|
}
|
|
1112
|
-
if (customId.startsWith("arcane:
|
|
1113
|
-
await this.
|
|
1241
|
+
if (customId.startsWith("arcane:")) {
|
|
1242
|
+
await this.onArcaneButton(interaction, customId);
|
|
1114
1243
|
return;
|
|
1115
1244
|
}
|
|
1116
1245
|
const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(customId);
|
|
@@ -7,8 +7,6 @@ export interface ReportState {
|
|
|
7
7
|
date: string;
|
|
8
8
|
status: "sending" | "sent" | "failed";
|
|
9
9
|
messageId?: string;
|
|
10
|
-
/** Arcane report only: the manager release its Upgrade button offers, `2.17.0`. */
|
|
11
|
-
managerOffer?: string;
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
/** Local date and minutes past midnight in a time zone. */
|
|
@@ -7,6 +7,11 @@ import { addRestarts, astrogateActivity, herdrActivity, needsPrompt, readRestart
|
|
|
7
7
|
import {
|
|
8
8
|
allId,
|
|
9
9
|
arcaneServerVersion,
|
|
10
|
+
bwRelease,
|
|
11
|
+
gcloudRelease,
|
|
12
|
+
installedComponent,
|
|
13
|
+
jqRelease,
|
|
14
|
+
pythonBuild,
|
|
10
15
|
brewHasOutdated,
|
|
11
16
|
buildLines,
|
|
12
17
|
type Collected,
|
|
@@ -80,10 +85,23 @@ const COLLECTED: Collected = {
|
|
|
80
85
|
piLatest: "1.3.0",
|
|
81
86
|
piAstroInstalled: "0.33.0",
|
|
82
87
|
piAstroLatest: "0.33.0",
|
|
83
|
-
installer: {
|
|
88
|
+
installer: {
|
|
89
|
+
node: { current: "24.22.0", previous: null },
|
|
90
|
+
wrappers: "0.32.0",
|
|
91
|
+
arcaneCli: "2.16.1",
|
|
92
|
+
trendarr: "0.6.0",
|
|
93
|
+
python: { current: "3.14.8+20261009", previous: null },
|
|
94
|
+
gcloud: { current: "589.0.0", previous: null },
|
|
95
|
+
bw: { current: "2026.10.0", previous: null },
|
|
96
|
+
jq: { current: null, previous: null },
|
|
97
|
+
},
|
|
84
98
|
arcaneServer: "2.17.0",
|
|
85
99
|
trendarrLatest: "0.6.0",
|
|
86
100
|
nodeLts: "24.22.0",
|
|
101
|
+
pythonLatest: "3.14.8+20261013",
|
|
102
|
+
gcloudLatest: "590.0.0",
|
|
103
|
+
bwLatest: "2026.11.0",
|
|
104
|
+
jqLatest: "1.8.2",
|
|
87
105
|
warnings: [],
|
|
88
106
|
};
|
|
89
107
|
|
|
@@ -113,6 +131,34 @@ describe("versions", () => {
|
|
|
113
131
|
});
|
|
114
132
|
});
|
|
115
133
|
|
|
134
|
+
describe("root-owned tool releases", () => {
|
|
135
|
+
it("reads the newest Python build of the installed minor, Bitwarden CLI, jq, and gcloud versions", () => {
|
|
136
|
+
const asset = (v: string) => ({ name: `cpython-${v}+20261013-aarch64-apple-darwin-install_only_stripped.tar.gz` });
|
|
137
|
+
const python = { tag_name: "20261013", assets: [asset("3.14.8"), asset("3.15.0"), asset("3.14.10"), { name: "cpython-3.14.9+20261013-x86_64-apple-darwin-install_only_stripped.tar.gz" }, { name: "SHA256SUMS" }] };
|
|
138
|
+
expect(pythonBuild(python, "3.14")).toBe("3.14.10+20261013");
|
|
139
|
+
expect(pythonBuild(python, "3.13")).toBeNull();
|
|
140
|
+
expect(pythonBuild({ tag_name: "latest", assets: [] }, "3.14")).toBeNull();
|
|
141
|
+
expect(bwRelease([{ tag_name: "desktop-v2026.11.0" }, { tag_name: "cli-v2026.11.1", prerelease: true }, { tag_name: "cli-v2026.10.0" }, { tag_name: "cli-v2026.9.1" }])).toBe("2026.10.0");
|
|
142
|
+
expect(bwRelease({})).toBeNull();
|
|
143
|
+
expect(jqRelease({ tag_name: "jq-1.8.2" })).toBe("1.8.2");
|
|
144
|
+
expect(jqRelease({ tag_name: "1.8.2" })).toBeNull();
|
|
145
|
+
expect(gcloudRelease({ version: "589.0.0", revision: 20261009074343 })).toBe("589.0.0");
|
|
146
|
+
expect(gcloudRelease({ version: "latest" })).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("reads each component from the installer status", () => {
|
|
150
|
+
const status = { node: { current: "24.22.0", previous: null }, wrappers: "0.34.1", arcaneCli: "2.16.1", trendarr: null, python: { current: "3.14.8+20261013" }, jq: { current: null } };
|
|
151
|
+
expect(installedComponent(status, "node")).toBe("24.22.0");
|
|
152
|
+
expect(installedComponent(status, "wrappers")).toBe("0.34.1");
|
|
153
|
+
expect(installedComponent(status, "arcane-cli")).toBe("2.16.1");
|
|
154
|
+
expect(installedComponent(status, "trendarr")).toBeNull();
|
|
155
|
+
expect(installedComponent(status, "python")).toBe("3.14.8+20261013");
|
|
156
|
+
expect(installedComponent(status, "jq")).toBeNull();
|
|
157
|
+
expect(installedComponent(status, "gcloud")).toBeNull();
|
|
158
|
+
expect(installedComponent(null, "bw")).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
116
162
|
describe("report lines", () => {
|
|
117
163
|
it("lists our tools first, then requested formulae and casks; dependencies and pinned packages stay out", () => {
|
|
118
164
|
const lines = buildLines(COLLECTED);
|
|
@@ -120,17 +166,20 @@ describe("report lines", () => {
|
|
|
120
166
|
"pi 0.87.1->1.3.0",
|
|
121
167
|
"spc:wrappers 0.32.0->0.33.0",
|
|
122
168
|
"spc:arcane-cli 2.16.1->2.17.0",
|
|
169
|
+
"spc:python 3.14.8+20261009->3.14.8+20261013",
|
|
170
|
+
"spc:gcloud 589.0.0->590.0.0",
|
|
171
|
+
"spc:bw 2026.10.0->2026.11.0",
|
|
123
172
|
"brew:bitwarden-cli 2026.8.0->2026.9.0",
|
|
124
173
|
"brew:gh 2.97.0->2.101.0",
|
|
125
174
|
"brew:herdr 0.9.0->0.9.1",
|
|
126
175
|
"brew:pnpm 11.22.0->12.6.0",
|
|
127
176
|
"cask:codex 0.152.1->0.156.1",
|
|
128
177
|
"cask:temurin@17 17.0.20,8->17.0.20.1,1",
|
|
129
|
-
"cask:gcloud-cli 585.0.0->586.0.0",
|
|
130
178
|
]);
|
|
131
179
|
expect(lines[0].major).toBe(true);
|
|
132
|
-
expect(lines.find((l) => l.key === "
|
|
133
|
-
expect(lines.find((l) => l.key === "
|
|
180
|
+
expect(lines.find((l) => l.key === "spc:gcloud")?.major).toBe(false);
|
|
181
|
+
expect(lines.find((l) => l.key === "spc:python")?.action).toEqual({ kind: "installer", component: "python" });
|
|
182
|
+
expect(lines.find((l) => l.key === "cask:codex")?.action).toEqual({ kind: "cask", token: "codex" });
|
|
134
183
|
expect(lines.every((l) => l.state === "offered")).toBe(true);
|
|
135
184
|
expect(brewHasOutdated(OUTDATED)).toBe(true);
|
|
136
185
|
expect(brewHasOutdated({ formulae: [OUTDATED.formulae[6]], casks: [] })).toBe(false);
|
|
@@ -166,12 +215,26 @@ describe("collection", () => {
|
|
|
166
215
|
const dir = mkdtempSync(join(tmpdir(), "tools-pkg-"));
|
|
167
216
|
try {
|
|
168
217
|
writeFileSync(join(dir, "package.json"), JSON.stringify({ version: "0.33.0" }));
|
|
169
|
-
const
|
|
218
|
+
const bodies: Record<string, unknown> = {
|
|
219
|
+
"https://nodejs.org/dist/index.json": [{ version: "v24.22.0", lts: "Krypton", files: ["osx-arm64-tar"] }],
|
|
220
|
+
"https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest": { tag_name: "20261013", assets: [{ name: "cpython-3.14.8+20261013-aarch64-apple-darwin-install_only_stripped.tar.gz" }] },
|
|
221
|
+
"https://dl.google.com/dl/cloudsdk/channels/rapid/components-2.json": { version: "590.0.0" },
|
|
222
|
+
"https://api.github.com/repos/bitwarden/clients/releases?per_page=30": [{ tag_name: "cli-v2026.11.0" }],
|
|
223
|
+
};
|
|
224
|
+
const fetched: string[] = [];
|
|
225
|
+
const fetchImpl = vi.fn(async (url: string | URL) => {
|
|
226
|
+
fetched.push(String(url));
|
|
227
|
+
const body = bodies[String(url)];
|
|
228
|
+
return body === undefined ? new Response("{}", { status: 404 }) : new Response(JSON.stringify(body));
|
|
229
|
+
}) as unknown as typeof fetch;
|
|
170
230
|
const c = await collectTools({ exec, fetchImpl, piAstroPackage: join(dir, "package.json"), arcaneServer: async () => "2.16.1" });
|
|
171
231
|
expect(calls[0]).toBe("brew update");
|
|
172
232
|
expect(c.warnings.join(" | ")).toMatch(/brew update failed, versions may be stale: Error: network down/);
|
|
173
233
|
expect(c.warnings.join(" | ")).toMatch(/npm view @astrofoundry\/trendarr failed/);
|
|
174
234
|
expect(c).toMatchObject({ piInstalled: "0.87.1", piLatest: "1.3.0", piAstroInstalled: "0.33.0", nodeLts: "24.22.0", arcaneServer: "2.16.1", trendarrLatest: null });
|
|
235
|
+
expect(c).toMatchObject({ pythonLatest: "3.14.8+20261013", gcloudLatest: "590.0.0", bwLatest: "2026.11.0", jqLatest: null });
|
|
236
|
+
// jq is not installed by the installer yet, so its releases are not even asked for.
|
|
237
|
+
expect(fetched.some((u) => u.includes("jqlang"))).toBe(false);
|
|
175
238
|
} finally {
|
|
176
239
|
rmSync(dir, { recursive: true, force: true });
|
|
177
240
|
}
|
|
@@ -237,8 +300,8 @@ describe("buttons and commands", () => {
|
|
|
237
300
|
|
|
238
301
|
it("runs one fixed command per kind, reads the version back, and knows what to roll back and restart", () => {
|
|
239
302
|
expect(updateCommand({ kind: "brew", name: "monadeo/tap/astrogate" }, "0.0.11")).toEqual({ file: "/opt/homebrew/bin/brew", args: ["upgrade", "monadeo/tap/astrogate"] });
|
|
240
|
-
expect(updateCommand({ kind: "cask", token: "
|
|
241
|
-
expect(updateCommand({ kind: "
|
|
303
|
+
expect(updateCommand({ kind: "cask", token: "codex" }, "0.156.1").args).toEqual(["upgrade", "--cask", "codex"]);
|
|
304
|
+
expect(updateCommand({ kind: "installer", component: "python" }, "3.14.8+20261013").args).toEqual(["-n", "/usr/local/sbin/specialist-install", "python", "3.14.8+20261013"]);
|
|
242
305
|
expect(updateCommand({ kind: "pi" }, "1.3.0")).toEqual({ file: "/opt/homebrew/bin/pi", args: ["update", "--self"] });
|
|
243
306
|
expect(updateCommand({ kind: "pi-astro" }, "0.33.0").args).toEqual(["update", "npm:@astrofoundry/pi-astro"]);
|
|
244
307
|
expect(updateCommand({ kind: "installer", component: "arcane-cli" }, "2.17.0")).toEqual({ file: "/usr/bin/sudo", args: ["-n", "/usr/local/sbin/specialist-install", "arcane-cli", "2.17.0"] });
|
|
@@ -249,6 +312,8 @@ describe("buttons and commands", () => {
|
|
|
249
312
|
expect(verifiedVersion({ kind: "brew", name: "node" }, "node 26.7.0 26.9.0\n", "26.9.0")).toBe(true);
|
|
250
313
|
expect(verifiedVersion({ kind: "brew", name: "gh" }, "gh 2.97.0\n", "2.101.0")).toBe(false);
|
|
251
314
|
expect(verifiedVersion({ kind: "installer", component: "node" }, JSON.stringify({ node: { current: "24.23.0" } }), "24.23.0")).toBe(true);
|
|
315
|
+
expect(verifiedVersion({ kind: "installer", component: "gcloud" }, JSON.stringify({ gcloud: { current: "590.0.0", previous: "589.0.0" } }), "590.0.0")).toBe(true);
|
|
316
|
+
expect(verifiedVersion({ kind: "installer", component: "arcane-cli" }, JSON.stringify({ arcaneCli: "2.17.0" }), "2.17.0")).toBe(true);
|
|
252
317
|
expect(verifiedVersion({ kind: "pi" }, "not json", "1.3.0")).toBe(false);
|
|
253
318
|
expect(servicesFor({ kind: "pi" })).toEqual(["bridge"]);
|
|
254
319
|
expect(servicesFor({ kind: "brew", name: "node" })).toEqual(["astrogate", "bridge"]);
|
|
@@ -262,17 +327,17 @@ describe("release-notes review", () => {
|
|
|
262
327
|
const release = (tag: string, body: string, prerelease = false) => ({ tag_name: tag, name: tag, body, published_at: "2026-10-15T00:00:00Z", html_url: `https://github.com/x/releases/${tag}`, draft: false, prerelease });
|
|
263
328
|
|
|
264
329
|
it("reads only the tool's own releases in a shared repository, between the two versions", async () => {
|
|
265
|
-
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.
|
|
266
|
-
const line = buildLines(COLLECTED).find((l) => l.key === "
|
|
330
|
+
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([release("cli-v2026.11.0", "bw: new list flag"), release("desktop-v2026.11.0", "desktop only"), release("cli-v2026.10.0", "old")]))) as unknown as typeof fetch;
|
|
331
|
+
const line = buildLines(COLLECTED).find((l) => l.key === "spc:bw") as ToolLine;
|
|
267
332
|
const notes = await notesFor(line, fetchImpl);
|
|
268
|
-
expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.
|
|
333
|
+
expect(notes.notes?.map((n) => n.tag)).toEqual(["v2026.11.0"]);
|
|
269
334
|
const prompt = reviewPrompt(line, notes.notes ?? [], []);
|
|
270
|
-
expect(prompt).toMatch(/How
|
|
271
|
-
expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.
|
|
335
|
+
expect(prompt).toMatch(/How we use it: specialist-cli provision runs bw/);
|
|
336
|
+
expect(prompt).toMatch(/<<<RELEASE NOTES\n### v2026\.11\.0 \(2026-10-15\)\nbw: new list flag\nRELEASE NOTES>>>/);
|
|
272
337
|
});
|
|
273
338
|
|
|
274
339
|
it("says why there is nothing to review", async () => {
|
|
275
|
-
const gcloud = buildLines(COLLECTED).find((l) => l.key === "
|
|
340
|
+
const gcloud = buildLines(COLLECTED).find((l) => l.key === "spc:gcloud") as ToolLine;
|
|
276
341
|
expect(await notesFor(gcloud, fetch)).toEqual({ notes: null, reason: "no release notes to review here; see https://cloud.google.com/sdk/docs/release-notes" });
|
|
277
342
|
const trendarr: ToolLine = { key: "spc:trendarr", label: "trendarr", installed: "0.6.0", latest: "0.7.0", major: false, action: { kind: "installer", component: "trendarr" }, state: "offered" };
|
|
278
343
|
const notFound = vi.fn(async () => new Response("{}", { status: 404 })) as unknown as typeof fetch;
|