@songsid/agend 2.3.1 → 2.3.2-beta.2
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/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +16 -10
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +7 -1
- package/dist/authority/session-authorizations.js +6 -4
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +19 -6
- package/dist/authority/web-authority.js +22 -8
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +33 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -5
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/config-validator.js +40 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +34 -3
- package/dist/daemon.js +227 -46
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +12 -1
- package/dist/delivery-outbox.js +67 -15
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +9 -0
- package/dist/doctor.js.map +1 -1
- package/dist/event-log.d.ts +5 -0
- package/dist/event-log.js +13 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +140 -0
- package/dist/fleet-manager.js +414 -29
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +1 -0
- package/dist/fleet-process-handlers.js +9 -1
- package/dist/fleet-process-handlers.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +113 -55
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +114 -56
- package/dist/guest-seen-people.d.ts +38 -0
- package/dist/guest-seen-people.js +78 -0
- package/dist/guest-seen-people.js.map +1 -0
- package/dist/instance-config-impact.js +4 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -0
- package/dist/instance-lifecycle.js +16 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +38 -10
- package/dist/locale.js.map +1 -1
- package/dist/mac-auto-restore.d.ts +117 -0
- package/dist/mac-auto-restore.js +294 -0
- package/dist/mac-auto-restore.js.map +1 -0
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +2 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +9 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.d.ts +26 -0
- package/dist/settings-api.js +107 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +37 -3
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +5 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/start-attempt.d.ts +9 -5
- package/dist/start-attempt.js +27 -8
- package/dist/start-attempt.js.map +1 -1
- package/dist/start-failure-chat.d.ts +4 -0
- package/dist/start-failure-chat.js +23 -6
- package/dist/start-failure-chat.js.map +1 -1
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +8 -0
- package/dist/start-failure-notice.js.map +1 -1
- package/dist/start-failure.d.ts +25 -0
- package/dist/start-failure.js +75 -2
- package/dist/start-failure.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -0
- package/dist/turn-reply-guard.js +111 -12
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/ui/backend-signin.js +5 -2
- package/dist/ui/chat-ambient.js +1 -1
- package/dist/ui/chat-effects.js +4 -4
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/first-run.js +2 -2
- package/dist/ui/fleet-canvas-view.js +114 -0
- package/dist/ui/fleet-canvas.js +15 -77
- package/dist/ui/fleet-office-editor.js +80 -5
- package/dist/ui/fleet-office-layout.js +197 -0
- package/dist/ui/fleet-office-model.js +6 -3
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-pack.d.ts +39 -0
- package/dist/ui/fleet-office-pack.js +240 -29
- package/dist/ui/fleet-office-skin.js +140 -29
- package/dist/ui/fleet-office-store.js +84 -5
- package/dist/ui/fleet-office-view.js +154 -0
- package/dist/ui/fleet-office-zones.js +95 -33
- package/dist/ui/fleet-office.js +779 -140
- package/dist/ui/fleet-usage.js +7 -6
- package/dist/ui/panel-chat.js +1 -1
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +2 -2
- package/dist/ui/panel-settings.js +578 -163
- package/dist/ui/search-dialog.js +26 -7
- package/dist/ui/settings-appearance.js +82 -29
- package/dist/ui/settings-apply.js +42 -7
- package/dist/ui/settings-dialogs.js +68 -22
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +244 -78
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +2 -0
- package/dist/ui/settings-registry.js +142 -0
- package/dist/ui/settings-strings.js +102 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +14 -0
- package/dist/ui/shared/app-nav.js +10 -5
- package/dist/ui/shared/app-prefs.js +80 -2
- package/dist/ui/shared/app-route.js +39 -5
- package/dist/ui/shared/app-shell.js +59 -4
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +254 -17
- package/dist/ui/shared/app.js +3 -0
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/identity-color.js +4 -4
- package/dist/ui/shared/signin.js +2 -2
- package/dist/ui/shared/ui-dialog.js +6 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/web-authorize.js +13 -12
- package/dist/web-api.d.ts +8 -2
- package/dist/web-api.js +160 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +7 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-experimental.d.ts +1 -1
- package/dist/web-experimental.js +1 -1
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-mod-agent.js +2 -2
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-office-skins.d.ts +108 -0
- package/dist/web-office-skins.js +298 -0
- package/dist/web-office-skins.js.map +1 -0
- package/dist/web-office-store.d.ts +8 -2
- package/dist/web-office-store.js +37 -7
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +6 -0
- package/dist/web-settings-patch.js +61 -4
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -2
- package/dist/web-shell-routes.js +17 -7
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/working-recovery.d.ts +104 -0
- package/dist/working-recovery.js +263 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +3 -1
- package/dist/working-state-journal.js +15 -2
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
|
|
1
2
|
import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
|
|
2
3
|
import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
|
|
3
4
|
import { SettingsBaselines } from "./settings-baseline.js";
|
|
@@ -158,6 +159,7 @@ import { agendVersion } from "./agent-whats-new.js";
|
|
|
158
159
|
import { OwnerCssStore } from "./web-owner-css-store.js";
|
|
159
160
|
import { ThemeStore } from "./web-theme-store.js";
|
|
160
161
|
import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
|
|
162
|
+
import { OfficeSkinStore } from "./web-office-skins.js";
|
|
161
163
|
import { WebAppearance } from "./web-appearance.js";
|
|
162
164
|
import { WebEffectLibrary } from "./web-effect-library.js";
|
|
163
165
|
import { WebEffectTrials } from "./web-effect-trials.js";
|
|
@@ -605,6 +607,89 @@ export class FleetManager {
|
|
|
605
607
|
managerBootId = randomUUID();
|
|
606
608
|
deliveryOutbox = null;
|
|
607
609
|
deliveryOutboxRecovered = false;
|
|
610
|
+
workingRecovery = null;
|
|
611
|
+
workingRecoveryTimer = null;
|
|
612
|
+
workingRecoveryCause = "unknown_interruption";
|
|
613
|
+
recoveryEnabled(name) {
|
|
614
|
+
const own = this.fleetConfig?.instances[name]?.resume_interrupted_work;
|
|
615
|
+
return own ?? this.fleetConfig?.defaults?.resume_interrupted_work ?? true;
|
|
616
|
+
}
|
|
617
|
+
initializeWorkingRecovery(outbox) {
|
|
618
|
+
const store = outbox.workingRecovery;
|
|
619
|
+
this.workingRecoveryCause = store.beginRun(this.managerBootId, process.pid, Date.now());
|
|
620
|
+
this.workingRecovery = new WorkingRecoveryCoordinator({
|
|
621
|
+
store, now: () => performance.now(), active: () => this.startupComplete && !this.shuttingDown && outbox.isOpen,
|
|
622
|
+
pressure: () => {
|
|
623
|
+
const p = this.memoryPressure.snapshot();
|
|
624
|
+
return p.sampledAt === null || Date.now() - p.sampledAt > 60_000 ? { ...p, level: "unknown" } : p;
|
|
625
|
+
}, storm: () => this.stormWindow.isActive(),
|
|
626
|
+
enabled: name => this.recoveryEnabled(name),
|
|
627
|
+
live: name => {
|
|
628
|
+
const daemon = this.daemons.get(name);
|
|
629
|
+
if (this.sessionOomStopped(name))
|
|
630
|
+
return { owner: "", configuration: "", sessionId: null, idle: false,
|
|
631
|
+
paused: false, stopped: true, legacyPending: false, legacyCovered: false, nativeWork: false, cancelled: false, background: null };
|
|
632
|
+
return daemon?.getWorkingRecoveryState?.() ?? null;
|
|
633
|
+
},
|
|
634
|
+
card: id => this.scheduler?.db.board.findCard(id) ?? (this.scheduler ? undefined : null),
|
|
635
|
+
laneFree: name => !this.isInstanceRestarting(name) && !this.activeDurableTargets.has(name)
|
|
636
|
+
&& outbox.countForTarget(name, ["queued", "retry_wait", "delivering", "submission_started", "reconciliation_pending"]) === 0,
|
|
637
|
+
admit: (episode, tasks, advisory) => outbox.admitWorkingRecovery(episode, buildWorkingRecoveryPrompt(tasks, this.workingRecoveryCause, advisory), advisory),
|
|
638
|
+
summary: (group, rows) => {
|
|
639
|
+
const general = this.findGeneralInstance();
|
|
640
|
+
if (!general || !this.daemons.has(general))
|
|
641
|
+
return;
|
|
642
|
+
// Our own informational notice must not supersede General's recovery
|
|
643
|
+
// as unrelated pending work before it crosses the input boundary.
|
|
644
|
+
const generalEpisode = store.get(general);
|
|
645
|
+
if (generalEpisode?.outcome === "admitted" && generalEpisode.deliveryId) {
|
|
646
|
+
const state = outbox.get(generalEpisode.deliveryId)?.state;
|
|
647
|
+
if (!state || !["delivered", "uncertain", "failed", "cancelled"].includes(state))
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const deferredAdvisories = rows.filter(row => row.outcome === "deferred"
|
|
651
|
+
&& (this.daemons.get(row.instance)?.getWorkingRecoveryState?.().background ?? null) === null)
|
|
652
|
+
.map(row => `${row.instance} (${row.reason})`);
|
|
653
|
+
store.summary(group, () => outbox.admitSystemNotice(general, `working-recovery-summary:${group}`, `[system:working-recovery-summary] Observed cause: unknown interruption${this.workingRecoveryCause === "unknown_interruption" ? "" : `; prior requested action: ${this.workingRecoveryCause}`}. ${rows.length} interrupted instances; continuation queued ${rows.filter(r => r.outcome === "admitted").length}, excluded ${rows.filter(r => r.outcome === "excluded").length}, still deferred ${rows.filter(r => r.outcome === "deferred").length}. ${rows.filter(r => r.reason === "advisory_background_unknown").length ? `Advisory, background unknown: ${rows.filter(r => r.reason === "advisory_background_unknown").map(r => r.instance).join(", ")}. ` : ""}${deferredAdvisories.length ? `Deferred advisory candidates (background unknown; all other gates still apply): ${deferredAdvisories.join(", ")}. ` : ""}Details are in the Log. Deferred work remains eligible; no action or confirmation is requested.`));
|
|
654
|
+
},
|
|
655
|
+
log: (episode, outcome, reason) => {
|
|
656
|
+
const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
|
|
657
|
+
this.logger.info({ name: episode.instance, episode: episode.key, outcome, reason,
|
|
658
|
+
observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery decision");
|
|
659
|
+
this.eventLog?.insert(episode.instance, "working_state", {
|
|
660
|
+
message: `Interrupted work: ${outcome} (${reason}); observed cause unknown_interruption${requestedAction ? `; prior requested action ${requestedAction}` : ""}.`,
|
|
661
|
+
reason, outcome, observedCause: "unknown_interruption", requestedAction
|
|
662
|
+
});
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
if (this.workingRecoveryTimer)
|
|
666
|
+
clearInterval(this.workingRecoveryTimer);
|
|
667
|
+
this.workingRecoveryTimer = setInterval(() => {
|
|
668
|
+
if (!outbox.isOpen || this.shuttingDown)
|
|
669
|
+
return;
|
|
670
|
+
try {
|
|
671
|
+
this.workingRecovery?.tick();
|
|
672
|
+
this.scheduleDeliveryOutboxPump();
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
this.logger.warn("Working recovery deferred: prerequisites or durable store unavailable");
|
|
676
|
+
}
|
|
677
|
+
}, 1_000);
|
|
678
|
+
this.workingRecoveryTimer.unref?.();
|
|
679
|
+
}
|
|
680
|
+
recoveryDeliveryEligible(row, writing = false) {
|
|
681
|
+
if (row.kind !== WORKING_RECOVERY_KIND)
|
|
682
|
+
return true;
|
|
683
|
+
const outbox = this.deliveryOutbox, episode = outbox?.workingRecovery.get(row.targetInstance);
|
|
684
|
+
if (!episode || episode.deliveryId !== row.deliveryId
|
|
685
|
+
|| outbox.hasOtherPendingWork(row.targetInstance, row.deliveryId)
|
|
686
|
+
|| this.workingRecovery?.verdict(episode).outcome === "excluded") {
|
|
687
|
+
outbox?.withdrawWorkingRecovery(row.deliveryId, "recovery superseded or no longer authorized before input");
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
return (writing ? this.workingRecovery?.accepts(row.targetInstance, episode.key)
|
|
691
|
+
: this.workingRecovery?.canDispatch(row.targetInstance, episode.key)) === true;
|
|
692
|
+
}
|
|
608
693
|
/** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
|
|
609
694
|
wakeCoordinator = null;
|
|
610
695
|
/**
|
|
@@ -1054,7 +1139,12 @@ export class FleetManager {
|
|
|
1054
1139
|
}
|
|
1055
1140
|
officeStoreInstance;
|
|
1056
1141
|
/** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
|
|
1057
|
-
get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
|
|
1142
|
+
get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir, id => this.officeSkins.pack(id))); }
|
|
1143
|
+
officeSkinsInstance;
|
|
1144
|
+
/** #2513: the Office view's uploaded skin packs (<AGEND_HOME>/web-office-skins/). */
|
|
1145
|
+
get officeSkins() { return (this.officeSkinsInstance ??= new OfficeSkinStore(this.dataDir)); }
|
|
1146
|
+
/** How often a person may upload or delete a skin pack; memory only, as the offices' limiter. */
|
|
1147
|
+
officeSkinWrites = new OfficeWriteLimiter(undefined, { perWindow: 10, windowMs: 60_000 });
|
|
1058
1148
|
/** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
|
|
1059
1149
|
officeWrites = new OfficeWriteLimiter();
|
|
1060
1150
|
/** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
|
|
@@ -1846,6 +1936,7 @@ export class FleetManager {
|
|
|
1846
1936
|
}
|
|
1847
1937
|
this.deliveryOutbox = outbox;
|
|
1848
1938
|
this.deliveryOutboxRecovered = true;
|
|
1939
|
+
this.initializeWorkingRecovery(outbox);
|
|
1849
1940
|
outbox.on("working_references", (names) => {
|
|
1850
1941
|
for (const name of names)
|
|
1851
1942
|
this.observeWorkingState(name, "obligations");
|
|
@@ -2124,6 +2215,10 @@ export class FleetManager {
|
|
|
2124
2215
|
lastActivityAt: this.lastActivity.get(name) ?? null, references, totalReferences, unavailable });
|
|
2125
2216
|
if (!entry)
|
|
2126
2217
|
return;
|
|
2218
|
+
if (reason === "cancelled")
|
|
2219
|
+
outbox.workingRecovery.cancel(name, observation.configuration, observation.sessionId);
|
|
2220
|
+
if (entry.prior)
|
|
2221
|
+
outbox.workingRecovery.discover(name, entry.prior);
|
|
2127
2222
|
const state = entry.current.observation.executionState ?? "unknown";
|
|
2128
2223
|
const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
|
|
2129
2224
|
owed: entry.current.references.filter(r => r.kind === "owed").length,
|
|
@@ -2217,7 +2312,7 @@ export class FleetManager {
|
|
|
2217
2312
|
// Phase 2c: a lane a queue worker owns is never claimed here.
|
|
2218
2313
|
target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
|
|
2219
2314
|
? null
|
|
2220
|
-
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
|
|
2315
|
+
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets, row => this.recoveryDeliveryEligible(row));
|
|
2221
2316
|
if (!claimed)
|
|
2222
2317
|
break;
|
|
2223
2318
|
this.activeDurableTargets.add(claimed.targetInstance);
|
|
@@ -2279,7 +2374,7 @@ export class FleetManager {
|
|
|
2279
2374
|
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
2280
2375
|
blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
|
|
2281
2376
|
daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
|
|
2282
|
-
claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
|
|
2377
|
+
claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set(), row => this.recoveryDeliveryEligible(row)),
|
|
2283
2378
|
tryAcquireBudget: () => {
|
|
2284
2379
|
if (this.activeDurableTargets.size >= 8)
|
|
2285
2380
|
return false;
|
|
@@ -2320,7 +2415,24 @@ export class FleetManager {
|
|
|
2320
2415
|
// including time spent waiting for a handoff/idle gate to return.
|
|
2321
2416
|
try {
|
|
2322
2417
|
let sent;
|
|
2323
|
-
if (claimed.kind ===
|
|
2418
|
+
if (claimed.kind === WORKING_RECOVERY_KIND) {
|
|
2419
|
+
const daemon = this.daemons.get(target), key = claimed.sourceKey.replace(/^working-recovery:/, "");
|
|
2420
|
+
if (!daemon || typeof payload.content !== "string")
|
|
2421
|
+
throw new Error("Working recovery owner unavailable");
|
|
2422
|
+
const release = this.workingRecovery?.reserveDispatch(target, key);
|
|
2423
|
+
if (!release) {
|
|
2424
|
+
outbox.retryBeforeBegin(claimed.deliveryId, daemon.bootId, claimed.attemptNo, "working recovery pace or prerequisites pending", 5_000);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
try {
|
|
2428
|
+
await daemon.deliverWorkingRecovery(payload.content, claimed, () => this.daemons.get(target) === daemon && this.recoveryDeliveryEligible(claimed, true));
|
|
2429
|
+
}
|
|
2430
|
+
finally {
|
|
2431
|
+
release();
|
|
2432
|
+
}
|
|
2433
|
+
sent = true;
|
|
2434
|
+
}
|
|
2435
|
+
else if (claimed.kind === "raw_paste") {
|
|
2324
2436
|
if (payload.type !== "raw_paste" || typeof payload.content !== "string") {
|
|
2325
2437
|
throw new Error("Durable raw_paste row has an invalid payload");
|
|
2326
2438
|
}
|
|
@@ -3004,6 +3116,7 @@ export class FleetManager {
|
|
|
3004
3116
|
}
|
|
3005
3117
|
/** SIGUSR1: full process reload. Names who asked (#1120). */
|
|
3006
3118
|
onFullRestartSignal() {
|
|
3119
|
+
this.recordWorkingRestartIntent("manual_restart");
|
|
3007
3120
|
this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
|
|
3008
3121
|
this.gracefulShutdownForReload()
|
|
3009
3122
|
.then(() => {
|
|
@@ -4866,7 +4979,7 @@ export class FleetManager {
|
|
|
4866
4979
|
return new WakeCoordinator({
|
|
4867
4980
|
mode: target => this.deliveryWorkerMode(target),
|
|
4868
4981
|
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
4869
|
-
listPending: () => this.deliveryOutbox?.listPending() ?? [],
|
|
4982
|
+
listPending: () => this.deliveryOutbox?.listPending().filter(row => row.kind !== WORKING_RECOVERY_KIND) ?? [],
|
|
4870
4983
|
isPaused: target => !this.sessionOomStopped(target) && this.lifecycle.isPaused(target),
|
|
4871
4984
|
pauseReason: target => readPauseReason(this.getInstanceDir(target)),
|
|
4872
4985
|
isRestarting: target => this.isInstanceRestarting(target),
|
|
@@ -6722,7 +6835,8 @@ export class FleetManager {
|
|
|
6722
6835
|
const [adapterId, userId] = k.split("\u0000");
|
|
6723
6836
|
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
6724
6837
|
}
|
|
6725
|
-
|
|
6838
|
+
// #2348: every granted account is listed too, signed in or not.
|
|
6839
|
+
for (const key of [...Object.keys(cfg?.narrow ?? {}), ...Object.keys(cfg?.grants ?? {})]) {
|
|
6726
6840
|
const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
|
|
6727
6841
|
if (at > 0 && !accounts.has(k))
|
|
6728
6842
|
accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
|
|
@@ -6731,10 +6845,153 @@ export class FleetManager {
|
|
|
6731
6845
|
const owner = { adapterId: a.adapterId, userId: a.userId };
|
|
6732
6846
|
const badge = this.guestBadge(owner);
|
|
6733
6847
|
// `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
|
|
6848
|
+
const grant = own(cfg?.grants, `${a.adapterId}:${a.userId}`);
|
|
6849
|
+
const sent = this.guestCodeSent.get(`${a.adapterId}:${a.userId}`);
|
|
6734
6850
|
return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
|
|
6735
|
-
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null
|
|
6851
|
+
devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null,
|
|
6852
|
+
// #2348: the grant as fleet.yaml has it (null: none), and the last code an admin sent from Settings.
|
|
6853
|
+
grant: grant ? { ...(grant.name ? { name: grant.name } : {}), instances: [...grant.instances] } : null,
|
|
6854
|
+
...(sent ? { code: { at: sent.at, outcome: sent.outcome.kind, ...(sent.outcome.kind === "sent" ? { expiresAt: sent.at + sent.outcome.expiresInMs } : {}),
|
|
6855
|
+
...(sent.outcome.kind === "dm_failed" ? { reason: sent.outcome.reason, platform: sent.outcome.platform, ...(sent.outcome.startUrl ? { startUrl: sent.outcome.startUrl } : {}) } : {}) } } : {}) };
|
|
6736
6856
|
}).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
|
|
6737
|
-
|
|
6857
|
+
// #2348: `explicit` — grants are the scope (default deny); false: the derived scope of #1920, until grants are saved.
|
|
6858
|
+
return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, explicit: cfg?.grants !== undefined, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests,
|
|
6859
|
+
audit: this.guestAudit() };
|
|
6860
|
+
}
|
|
6861
|
+
/**
|
|
6862
|
+
* #2499 review: the guest audit an admin reads — the newest grant changes and admin-sent codes from the event log
|
|
6863
|
+
* (`fleet` rows: no agent's Log shows them). Through guestOverview only: Settings' admin gate, never over the public
|
|
6864
|
+
* link, never a guest's. The person's name as the card shows it; a session handle stays on the server (`by` → whether
|
|
6865
|
+
* a person saved it here or it was confirmed in General).
|
|
6866
|
+
*/
|
|
6867
|
+
guestAudit(limit = FleetManager.GUEST_AUDIT_SHOWN) {
|
|
6868
|
+
if (!this.eventLog)
|
|
6869
|
+
return [];
|
|
6870
|
+
// One read, newest first by row id: a total order (created_at is to the second), so the cap never drops a newer row.
|
|
6871
|
+
let rows = [];
|
|
6872
|
+
try {
|
|
6873
|
+
rows = this.eventLog.latestOfTypes("fleet", ["guest_grant_changed", "guest_code_sent"], limit);
|
|
6874
|
+
}
|
|
6875
|
+
catch {
|
|
6876
|
+
return [];
|
|
6877
|
+
}
|
|
6878
|
+
const names = (list) => (Array.isArray(list) ? list.filter((n) => typeof n === "string") : []);
|
|
6879
|
+
return rows.flatMap((r) => {
|
|
6880
|
+
const p = r.payload ?? {}, account = typeof p.account === "string" ? p.account : "";
|
|
6881
|
+
const at = account.indexOf(":");
|
|
6882
|
+
if (at <= 0)
|
|
6883
|
+
return [];
|
|
6884
|
+
const name = this.webPerson({ adapterId: account.slice(0, at), userId: account.slice(at + 1) }).name;
|
|
6885
|
+
const when = Date.parse(r.created_at.includes("T") ? r.created_at : `${r.created_at.replace(" ", "T")}Z`);
|
|
6886
|
+
return r.event_type === "guest_grant_changed"
|
|
6887
|
+
? [{ kind: "grant", at: when, account, name, via: p.via === "confirmed" ? "confirmed" : "direct", added: names(p.added), removed: names(p.removed),
|
|
6888
|
+
endedSessions: typeof p.endedSessions === "number" ? p.endedSessions : 0 }]
|
|
6889
|
+
: [{ kind: "code", at: when, account, name, outcome: typeof p.outcome === "string" ? p.outcome : "unknown" }];
|
|
6890
|
+
});
|
|
6891
|
+
}
|
|
6892
|
+
static GUEST_AUDIT_SHOWN = 20;
|
|
6893
|
+
/**
|
|
6894
|
+
* #2348: Settings → Guests' picker — the people AgEnD has seen in chat (seen_people, from human receipts: never a bot,
|
|
6895
|
+
* never the web), without fleet admins (they sign in as admins) or accounts of a connection the fleet no longer has.
|
|
6896
|
+
* Admin only (settings-api.ts), never over the public link.
|
|
6897
|
+
*/
|
|
6898
|
+
guestPeople() {
|
|
6899
|
+
const seen = this.deliveryOutbox?.isOpen ? this.deliveryOutbox.seenPeople() : [];
|
|
6900
|
+
const people = seen.filter((p) => this.worlds.has(p.adapterId) && !this.isFleetAdmin(p.userId, p.adapterId)).map((p) => {
|
|
6901
|
+
const owner = { adapterId: p.adapterId, userId: p.userId };
|
|
6902
|
+
return { adapterId: p.adapterId, userId: p.userId, platform: this.worlds.get(p.adapterId)?.adapter.type ?? p.adapterId, username: p.username,
|
|
6903
|
+
name: this.webPerson(owner).name, lastSeen: p.lastSeen, instances: p.instances.map((i) => i.name) };
|
|
6904
|
+
});
|
|
6905
|
+
return { people };
|
|
6906
|
+
}
|
|
6907
|
+
/**
|
|
6908
|
+
* #2348: why this account may not be given a grant, or null. Only someone AgEnD has seen in chat, on a connection the
|
|
6909
|
+
* fleet runs, who is not a fleet admin of it (an admin is never a guest).
|
|
6910
|
+
*/
|
|
6911
|
+
guestGrantRefusal(adapterId, userId) {
|
|
6912
|
+
if (!this.worlds.has(adapterId) && !this.fleetConfig?.channels?.some((c) => (c.id ?? c.type) === adapterId))
|
|
6913
|
+
return "no such connection in this fleet";
|
|
6914
|
+
if (this.isFleetAdmin(userId, adapterId))
|
|
6915
|
+
return "a fleet admin signs in as an admin, not as a guest";
|
|
6916
|
+
if (!this.deliveryOutbox?.isOpen || !this.deliveryOutbox.isSeenPerson(adapterId, userId))
|
|
6917
|
+
return "only someone AgEnD has seen in chat can be given guest access";
|
|
6918
|
+
return null;
|
|
6919
|
+
}
|
|
6920
|
+
/**
|
|
6921
|
+
* #2348 delta §5: a grant changed with a save — one event-log entry per person, shown in Settings → Guests' Recent
|
|
6922
|
+
* changes (guestOverview: admin-only, never over the public link or to a guest): who saved it (an admin's
|
|
6923
|
+
* session handle, the header token, or null when replayed for a confirmation), whether it applied at once or was
|
|
6924
|
+
* confirmed in General, the agents added and taken away, and the sessions a removal ended. Never message content.
|
|
6925
|
+
*/
|
|
6926
|
+
noteGuestGrantChange(change) {
|
|
6927
|
+
this.logger.info({ account: change.account, via: change.via, added: change.added.length, removed: change.removed.length }, "Guest access changed in Settings");
|
|
6928
|
+
try {
|
|
6929
|
+
this.eventLog?.insert("fleet", "guest_grant_changed", { ...change });
|
|
6930
|
+
}
|
|
6931
|
+
catch { /* the log line stands */ }
|
|
6932
|
+
}
|
|
6933
|
+
/** #2348: an admin's Send sign-in code, per admin (the caller): at most this many in ADMIN_CODE_WINDOW_MS. */
|
|
6934
|
+
static ADMIN_CODE_MAX = 10;
|
|
6935
|
+
static ADMIN_CODE_WINDOW_MS = 10 * 60_000;
|
|
6936
|
+
adminCodeSends = new Map();
|
|
6937
|
+
/** #2348: the last admin-sent code per account, for the Settings card ("Code sent 18:42 · expires 18:52", or why not). */
|
|
6938
|
+
guestCodeSent = new Map();
|
|
6939
|
+
/**
|
|
6940
|
+
* #2348: Settings → Guests' "Send sign-in code" — a guest code to an account an admin granted access, by DM only (no
|
|
6941
|
+
* chat to answer in, nothing posted anywhere). The same issuance as the person's own /web (one code per account, the
|
|
6942
|
+
* code store's caps and breaker, the public link's audience when it is open) and the same once-a-minute limit per
|
|
6943
|
+
* account, plus a per-admin cap. Refused without guest sign-in, for a fleet admin, or with nothing granted. Audited.
|
|
6944
|
+
*/
|
|
6945
|
+
async sendGuestCodeByAdmin(owner, by,
|
|
6946
|
+
/** #2365 review: the initiating admin is still signed in and still an admin (settings-api re-checks the session). */
|
|
6947
|
+
current = () => true) {
|
|
6948
|
+
const guests = this.fleetConfig?.web?.guests;
|
|
6949
|
+
if (guests?.enabled !== true)
|
|
6950
|
+
return { status: 409, body: { ok: false, outcome: "guests_off", error: "Guest sign-in is off" } };
|
|
6951
|
+
if (this.isFleetAdmin(owner.userId, owner.adapterId))
|
|
6952
|
+
return { status: 409, body: { ok: false, outcome: "admin", error: "A fleet admin signs in as an admin" } };
|
|
6953
|
+
if (!this.guestScope(owner).size)
|
|
6954
|
+
return { status: 409, body: { ok: false, outcome: "nothing", error: "Nothing is granted to this person yet: save their access first" } };
|
|
6955
|
+
const token = this.webToken;
|
|
6956
|
+
const adapter = this.adapters.get(owner.adapterId);
|
|
6957
|
+
if (this.shuttingDown || !token || !adapter)
|
|
6958
|
+
return { status: 503, body: { ok: false, outcome: "starting", error: "AgEnD is starting or this connection is not running" } };
|
|
6959
|
+
const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
|
|
6960
|
+
const now = performance.now();
|
|
6961
|
+
if (this.guestCodeInFlight.has(key) || (asked !== undefined && now - asked < 60_000)) {
|
|
6962
|
+
return { status: 429, body: { ok: false, outcome: "wait", retryAfterMs: asked !== undefined ? Math.max(0, 60_000 - (now - asked)) : 60_000 } };
|
|
6963
|
+
}
|
|
6964
|
+
const mine = (this.adminCodeSends.get(by) ?? []).filter((at) => now - at < FleetManager.ADMIN_CODE_WINDOW_MS);
|
|
6965
|
+
if (mine.length >= FleetManager.ADMIN_CODE_MAX)
|
|
6966
|
+
return { status: 429, body: { ok: false, outcome: "admin_limit", retryAfterMs: FleetManager.ADMIN_CODE_WINDOW_MS - (now - mine[0]) } };
|
|
6967
|
+
this.adminCodeSends.set(by, [...mine, now]);
|
|
6968
|
+
while (this.adminCodeSends.size > 256)
|
|
6969
|
+
this.adminCodeSends.delete(this.adminCodeSends.keys().next().value);
|
|
6970
|
+
let outcome = { kind: "busy" };
|
|
6971
|
+
this.guestCodeInFlight.add(key);
|
|
6972
|
+
try {
|
|
6973
|
+
await this.issueGuestCode({ replyHere: async () => undefined }, owner, key, adapter, token, (o) => { outcome = o; }, current);
|
|
6974
|
+
}
|
|
6975
|
+
finally {
|
|
6976
|
+
this.guestCodeInFlight.delete(key);
|
|
6977
|
+
}
|
|
6978
|
+
const done = outcome;
|
|
6979
|
+
// #2365 review: the admin's session ended (or stopped being an admin's) while the code was on its way: nothing was
|
|
6980
|
+
// sent, or what was made is withdrawn — the answer says so, as to anyone no longer signed in.
|
|
6981
|
+
if (done.kind === "revoked" && !current())
|
|
6982
|
+
return { status: 401, body: { ok: false, outcome: "revoked", error: "no longer authorized" } };
|
|
6983
|
+
this.guestCodeSent.set(`${owner.adapterId}:${owner.userId}`, { at: Date.now(), outcome: done });
|
|
6984
|
+
while (this.guestCodeSent.size > 512)
|
|
6985
|
+
this.guestCodeSent.delete(this.guestCodeSent.keys().next().value);
|
|
6986
|
+
// Audited: who asked, for whom, what happened (never the code).
|
|
6987
|
+
this.logger.info({ by, adapterId: owner.adapterId, outcome: done.kind }, "Guest sign-in code sent by an admin from Settings");
|
|
6988
|
+
try {
|
|
6989
|
+
this.eventLog?.insert("fleet", "guest_code_sent", { by, account: `${owner.adapterId}:${owner.userId}`, outcome: done.kind, instances: this.guestScope(owner).size });
|
|
6990
|
+
}
|
|
6991
|
+
catch { /* the log line stands */ }
|
|
6992
|
+
const status = done.kind === "sent" ? 200 : done.kind === "dm_failed" ? 502 : 409;
|
|
6993
|
+
return { status, body: { ok: done.kind === "sent", outcome: done.kind, ...(done.kind === "sent" ? { expiresAt: Date.now() + done.expiresInMs, publicLink: done.publicLink } : {}),
|
|
6994
|
+
...(done.kind === "dm_failed" ? { platform: done.platform, reason: done.reason, ...(done.startUrl ? { startUrl: done.startUrl } : {}) } : {}) } };
|
|
6738
6995
|
}
|
|
6739
6996
|
/** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
|
|
6740
6997
|
guestBadge(owner) {
|
|
@@ -6803,24 +7060,35 @@ export class FleetManager {
|
|
|
6803
7060
|
return true;
|
|
6804
7061
|
}
|
|
6805
7062
|
/** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
|
|
6806
|
-
async issueGuestCode(ask, owner, key, adapter, token
|
|
7063
|
+
async issueGuestCode(ask, owner, key, adapter, token,
|
|
7064
|
+
/** #2348: how it ended, for a caller with no chat to answer in (Settings → Guests' Send sign-in code). */
|
|
7065
|
+
report = () => { },
|
|
7066
|
+
/**
|
|
7067
|
+
* #2365 review: the one who asked may still ask — for an admin's Send sign-in code, that admin's session as it is
|
|
7068
|
+
* now. Asked with the revocation checks, after every wait: false ends the issuance, and withdraws its code if one was made.
|
|
7069
|
+
*/
|
|
7070
|
+
stillAllowed = () => true) {
|
|
6807
7071
|
const say = (text) => ask.replyHere(text).catch(() => undefined);
|
|
6808
7072
|
const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
|
|
6809
|
-
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
|
|
7073
|
+
const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && stillAllowed();
|
|
6810
7074
|
// §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
|
|
6811
7075
|
await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
|
|
6812
|
-
if (!unrevoked())
|
|
6813
|
-
|
|
7076
|
+
if (!unrevoked()) {
|
|
7077
|
+
report({ kind: "revoked" });
|
|
7078
|
+
return;
|
|
7079
|
+
} // revoked while its rooms were read: no code
|
|
6814
7080
|
const scope = this.guestScope(owner);
|
|
6815
7081
|
if (!scope.size) {
|
|
6816
7082
|
const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
|
|
6817
7083
|
await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
|
|
7084
|
+
report({ kind: "nothing" });
|
|
6818
7085
|
return;
|
|
6819
7086
|
}
|
|
6820
7087
|
this.initializeWebSessions();
|
|
6821
7088
|
const codes = this.webGuestLoginCodes;
|
|
6822
7089
|
if (codes.pausedForMs() > 0) {
|
|
6823
7090
|
await say(t("web.guest_paused"));
|
|
7091
|
+
report({ kind: "paused" });
|
|
6824
7092
|
return;
|
|
6825
7093
|
}
|
|
6826
7094
|
// §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
|
|
@@ -6831,6 +7099,7 @@ export class FleetManager {
|
|
|
6831
7099
|
recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
|
|
6832
7100
|
if (!login) {
|
|
6833
7101
|
await say(t("web.guest_busy"));
|
|
7102
|
+
report({ kind: "busy" });
|
|
6834
7103
|
return;
|
|
6835
7104
|
}
|
|
6836
7105
|
this.guestCodeAskedAt.delete(key);
|
|
@@ -6865,13 +7134,17 @@ export class FleetManager {
|
|
|
6865
7134
|
throw new Error("guest no longer admitted");
|
|
6866
7135
|
await say(t("web.guest_sent"));
|
|
6867
7136
|
}
|
|
7137
|
+
report({ kind: "sent", expiresInMs: LOGIN_CODE_TTL_MS, publicLink: !!open });
|
|
6868
7138
|
}
|
|
6869
7139
|
catch (err) {
|
|
6870
7140
|
codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
|
|
6871
7141
|
this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
|
|
6872
|
-
if (!unrevoked())
|
|
6873
|
-
|
|
7142
|
+
if (!unrevoked()) {
|
|
7143
|
+
report({ kind: "revoked" });
|
|
7144
|
+
return;
|
|
7145
|
+
} // revoked meanwhile: the revoke's own answer said so
|
|
6874
7146
|
const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
|
|
7147
|
+
report({ kind: "dm_failed", platform: adapter.type, reason: String(err?.message ?? err).slice(0, 200), ...(start ? { startUrl: start } : {}) });
|
|
6875
7148
|
// #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
|
|
6876
7149
|
// the DM — once, within the window.
|
|
6877
7150
|
if (adapter.type === "telegram")
|
|
@@ -7418,6 +7691,7 @@ export class FleetManager {
|
|
|
7418
7691
|
const startupStartedAt = Date.now();
|
|
7419
7692
|
FleetManager.signalTarget = this;
|
|
7420
7693
|
this.startupComplete = false;
|
|
7694
|
+
this.workingRecovery?.reset();
|
|
7421
7695
|
this.startupDashboard = null;
|
|
7422
7696
|
// Cleared here, not at the end of doStopAll: a stop has an async tail, and
|
|
7423
7697
|
// anything arriving during it is still part of the stop.
|
|
@@ -7996,12 +8270,17 @@ export class FleetManager {
|
|
|
7996
8270
|
// health server — so `systemctl start` returned success while the fleet was
|
|
7997
8271
|
// still deaf: no path existed for a user message to arrive.
|
|
7998
8272
|
sdNotify("READY=1");
|
|
8273
|
+
this.workingRecovery?.ready();
|
|
7999
8274
|
// #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
|
|
8000
8275
|
try {
|
|
8001
8276
|
const { ownPackage, recordStarted } = await import("./start-failure.js");
|
|
8002
8277
|
const version = ownPackage().version;
|
|
8003
8278
|
if (version)
|
|
8004
8279
|
recordStarted(this.dataDir, version, process.pid, Date.now());
|
|
8280
|
+
// #2481: a start completed — the failed tree an automatic restore kept for a report can go.
|
|
8281
|
+
const prefix = ownPackage().prefix;
|
|
8282
|
+
if (prefix)
|
|
8283
|
+
void (await import("./mac-auto-restore.js")).pruneFailedTrees(prefix).catch(() => { });
|
|
8005
8284
|
}
|
|
8006
8285
|
catch (err) {
|
|
8007
8286
|
this.logger.debug({ err }, "fleet-started record not written");
|
|
@@ -10666,6 +10945,7 @@ export class FleetManager {
|
|
|
10666
10945
|
// overwritten with coordinates the target's adapter cannot address.
|
|
10667
10946
|
...(foreignWorld ? {} : { chat_id: reply_chat_id, thread_id: reply_thread_id }),
|
|
10668
10947
|
user: "scheduler",
|
|
10948
|
+
source: "schedule",
|
|
10669
10949
|
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
10670
10950
|
},
|
|
10671
10951
|
}, { waitForIdle: true, ...(retryCurrent ? { stillCurrent: retryCurrent } : {}) });
|
|
@@ -14760,7 +15040,7 @@ export class FleetManager {
|
|
|
14760
15040
|
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
14761
15041
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14762
15042
|
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
14763
|
-
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
|
|
15043
|
+
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
|
|
14764
15044
|
});
|
|
14765
15045
|
}
|
|
14766
15046
|
sessionAuthorizations = null;
|
|
@@ -14785,13 +15065,13 @@ export class FleetManager {
|
|
|
14785
15065
|
session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14786
15066
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14787
15067
|
authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
14788
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
15068
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
14789
15069
|
});
|
|
14790
15070
|
if (check.refusal)
|
|
14791
15071
|
return { ok: false, refusal: check.refusal };
|
|
14792
15072
|
const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
|
|
14793
15073
|
store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
|
|
14794
|
-
return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
|
|
15074
|
+
return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs, peer) };
|
|
14795
15075
|
}
|
|
14796
15076
|
authorityStateStore = null;
|
|
14797
15077
|
/**
|
|
@@ -14800,13 +15080,24 @@ export class FleetManager {
|
|
|
14800
15080
|
*/
|
|
14801
15081
|
webAuthorityState() {
|
|
14802
15082
|
if (!this.dataDir)
|
|
14803
|
-
return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
|
|
15083
|
+
return { enabled: false, loopbackOnly: true, namedLink: false, quickLink: false, epoch: 0 };
|
|
14804
15084
|
this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
|
|
14805
15085
|
const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
|
|
14806
|
-
// #2453 (§0.2):
|
|
14807
|
-
// good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
|
|
14808
|
-
|
|
14809
|
-
|
|
15086
|
+
// #2453 (§0.2) / §0.3: a link's option seen going off — the authority of every session over that link ends now, for
|
|
15087
|
+
// good: their authorizations go (a later "on" can never bring one back without its account's Confirm). A record from
|
|
15088
|
+
// before §0.3 has no peer: over the gateway, only a named-link session could have been authorized then.
|
|
15089
|
+
if (state.namedLinkClosed || state.quickLinkClosed) {
|
|
15090
|
+
const ended = (a) => {
|
|
15091
|
+
const session = this.webSessions?.findByHandle(a.handle);
|
|
15092
|
+
if (!session)
|
|
15093
|
+
return true;
|
|
15094
|
+
if (session.surface === "local")
|
|
15095
|
+
return false;
|
|
15096
|
+
const peer = a.peer ?? "named_link";
|
|
15097
|
+
return (!!state.namedLinkClosed && peer === "named_link") || (!!state.quickLinkClosed && peer === "quick_link");
|
|
15098
|
+
};
|
|
15099
|
+
this.sessionAuthorizationStore()?.prune(a => !ended(a));
|
|
15100
|
+
}
|
|
14810
15101
|
return state;
|
|
14811
15102
|
}
|
|
14812
15103
|
/**
|
|
@@ -14839,7 +15130,8 @@ export class FleetManager {
|
|
|
14839
15130
|
.filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
|
|
14840
15131
|
.map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
|
|
14841
15132
|
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
|
|
14842
|
-
|
|
15133
|
+
// Over a link: the temporary one (§0.3), else the named one (a record from before §0.3 has no peer).
|
|
15134
|
+
...(sessions.get(a.handle).surface === "gateway" ? (a.peer === "quick_link" ? { quick: true } : { named: true }) : {}) })) : [];
|
|
14843
15135
|
const host = this.fleetConfig?.web?.public_link?.hostname;
|
|
14844
15136
|
return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
|
|
14845
15137
|
}
|
|
@@ -14854,9 +15146,17 @@ export class FleetManager {
|
|
|
14854
15146
|
const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
|
|
14855
15147
|
// The host's clock, with its zone: the person may read the DM on a phone in another one.
|
|
14856
15148
|
const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
|
|
15149
|
+
const quickHost = () => { try {
|
|
15150
|
+
const u = this.publicWebLink?.openSignin()?.url;
|
|
15151
|
+
return u ? new URL(u).host : "?";
|
|
15152
|
+
}
|
|
15153
|
+
catch {
|
|
15154
|
+
return "?";
|
|
15155
|
+
} };
|
|
14857
15156
|
const where = (peer) => peer === "named_link"
|
|
14858
15157
|
? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
|
|
14859
|
-
:
|
|
15158
|
+
: peer === "quick_link" ? t("web_auth.peer_quick_link", quickHost()) // §0.3: the internet
|
|
15159
|
+
: t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
|
|
14860
15160
|
return this.authorizationRequests = new SessionAuthorizationRequests({
|
|
14861
15161
|
now: () => performance.now(), wallNow: () => Date.now(),
|
|
14862
15162
|
maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
|
|
@@ -14890,8 +15190,8 @@ export class FleetManager {
|
|
|
14890
15190
|
const record = this.webSessions?.findByHandle(handle);
|
|
14891
15191
|
if (!record)
|
|
14892
15192
|
return { ok: false, refusal: "session_gone" };
|
|
14893
|
-
// §0.2: a
|
|
14894
|
-
const r = this.authorizeWebSession(record, peer, peer === "named_link");
|
|
15193
|
+
// §0.2 / §0.3: a session over a link is judged as the gateway request it is (its exposure still the current one).
|
|
15194
|
+
const r = this.authorizeWebSession(record, peer, peer === "named_link" || peer === "quick_link");
|
|
14895
15195
|
// What the card says: the end of the authorization, or the session's own end when that comes first.
|
|
14896
15196
|
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
14897
15197
|
},
|
|
@@ -14937,7 +15237,7 @@ export class FleetManager {
|
|
|
14937
15237
|
session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
14938
15238
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
14939
15239
|
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
14940
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
15240
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
|
|
14941
15241
|
});
|
|
14942
15242
|
return r.refusal;
|
|
14943
15243
|
}
|
|
@@ -17764,7 +18064,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
17764
18064
|
const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
|
|
17765
18065
|
const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
|
|
17766
18066
|
const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
|
|
17767
|
-
|
|
18067
|
+
// #2551: a native reply to one of THIS bot's messages is addressed to it, as a mention is (a Discord reply carries
|
|
18068
|
+
// its ping in the reference, not the text). One hop only: a reply to a message of ours that itself answered a bot
|
|
18069
|
+
// (or the sender) is a chain of bots answering each other — a mention still carries it on. A reply to our answer
|
|
18070
|
+
// to a person is the ordinary handoff, and is addressed.
|
|
18071
|
+
const repliesToUs = !!adapterBotUserId && msg.replyToAuthorId === adapterBotUserId && msg.replyToChain !== true;
|
|
18072
|
+
const isMentioned = (mentionTag && text.includes(mentionTag)) || repliesToUs;
|
|
17768
18073
|
if (!isMentioned) {
|
|
17769
18074
|
// Bare attachment (no @mention) — already saved above; just acknowledge.
|
|
17770
18075
|
if (saved) {
|
|
@@ -20033,6 +20338,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20033
20338
|
}
|
|
20034
20339
|
tmuxExecHelperTimer = null;
|
|
20035
20340
|
stopAllInFlight = null;
|
|
20341
|
+
/** Signal-handler continuation, after successful graceful shutdown only. */
|
|
20342
|
+
noteRequestedShutdown() {
|
|
20343
|
+
const progress = readUpdateProgress(this.dataDir)?.progress;
|
|
20344
|
+
const update = progress && updateProgressOperation(progress) === "update"
|
|
20345
|
+
&& ["installed", "stopping", "starting"].includes(progress.stage);
|
|
20346
|
+
this.recordWorkingRestartIntent(update ? "update" : "manual_restart");
|
|
20347
|
+
}
|
|
20348
|
+
recordWorkingRestartIntent(cause) {
|
|
20349
|
+
this.workingRecoveryCause = cause;
|
|
20350
|
+
try {
|
|
20351
|
+
this.deliveryOutbox?.workingRecovery.requested(this.managerBootId, cause);
|
|
20352
|
+
}
|
|
20353
|
+
catch {
|
|
20354
|
+
this.logger.warn("Could not record requested restart; next recovery will report an unknown interruption");
|
|
20355
|
+
}
|
|
20356
|
+
}
|
|
20036
20357
|
/** Stops release memory. Reassess between batches without waiting for recovery. */
|
|
20037
20358
|
async stopInstancesInBatches(entries, stop) {
|
|
20038
20359
|
let index = 0;
|
|
@@ -20050,15 +20371,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20050
20371
|
}
|
|
20051
20372
|
}
|
|
20052
20373
|
async doStopAll() {
|
|
20374
|
+
// The scheduler closes before individual daemons stop. Preserve the owned
|
|
20375
|
+
// task/reply boundary while those stores are still available, not a later
|
|
20376
|
+
// shutdown snapshot whose unavailable sources cannot authorize recovery.
|
|
20377
|
+
for (const name of this.daemons.keys())
|
|
20378
|
+
this.observeWorkingState(name, "before_stop");
|
|
20053
20379
|
this.startupMemoryDeadline?.stop();
|
|
20054
20380
|
this.startupMemoryDeadline = null;
|
|
20055
20381
|
this.startupComplete = false;
|
|
20382
|
+
this.workingRecovery?.reset();
|
|
20056
20383
|
this.startupDashboard = null;
|
|
20057
20384
|
this.reloadPending = false;
|
|
20058
20385
|
// Before anything is stopped: everything that dies from here on dies
|
|
20059
20386
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
20060
20387
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
20061
20388
|
this.shuttingDown = true;
|
|
20389
|
+
this.workingRecovery?.reset();
|
|
20390
|
+
if (this.workingRecoveryTimer)
|
|
20391
|
+
clearInterval(this.workingRecoveryTimer);
|
|
20392
|
+
this.workingRecoveryTimer = null;
|
|
20062
20393
|
if (this.updateProgressTimer) {
|
|
20063
20394
|
clearInterval(this.updateProgressTimer);
|
|
20064
20395
|
this.updateProgressTimer = null;
|
|
@@ -20738,6 +21069,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20738
21069
|
fleetSignatureMismatchKeys() {
|
|
20739
21070
|
return this.fleetSignatureMismatch;
|
|
20740
21071
|
}
|
|
21072
|
+
/**
|
|
21073
|
+
* #2347 restart flow: saved changes that take effect only after AgEnD restarts — the startup-only keys fleet.yaml
|
|
21074
|
+
* now differs on, and the newest apply job whose fleet row can carry the restart (`job_id`, null when none can: a
|
|
21075
|
+
* hand edit, or a job past its retention). Null when nothing is pending, and when a restart could not clear it
|
|
21076
|
+
* (the signature mismatch the panel reports instead). The same test requestSettingsSelfRestart makes.
|
|
21077
|
+
*/
|
|
21078
|
+
settingsRestartPending() {
|
|
21079
|
+
if (this.appliedFleetLevel === null || this.fleetSignatureMismatch)
|
|
21080
|
+
return null;
|
|
21081
|
+
const next = this.nextFleetConfig();
|
|
21082
|
+
if (this.appliedFleetLevel === this.fleetLevelSignature(next))
|
|
21083
|
+
return null;
|
|
21084
|
+
let job = null;
|
|
21085
|
+
// A job whose restart was asked for already has its row in flight ("running"), so it is never offered again.
|
|
21086
|
+
for (const candidate of this.applyJobs.all()) {
|
|
21087
|
+
const row = candidate.targets.find(item => item.target === APPLY_FLEET_TARGET);
|
|
21088
|
+
if (row?.status === "restart-required" && (!job || candidate.startedAt >= job.startedAt))
|
|
21089
|
+
job = candidate;
|
|
21090
|
+
}
|
|
21091
|
+
return { keys: fleetLevelDifferences(this.startupFleetConfig, next), job_id: job ? job.id : null };
|
|
21092
|
+
}
|
|
20741
21093
|
/**
|
|
20742
21094
|
* Restart AgEnD itself on behalf of a Settings apply.
|
|
20743
21095
|
*
|
|
@@ -21988,6 +22340,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21988
22340
|
this.logger.info("No instances to restart");
|
|
21989
22341
|
return;
|
|
21990
22342
|
}
|
|
22343
|
+
this.recordWorkingRestartIntent("manual_restart");
|
|
22344
|
+
this.workingRecovery?.reset();
|
|
21991
22345
|
this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
|
|
21992
22346
|
const groupId = this.fleetConfig?.channel?.group_id;
|
|
21993
22347
|
const generalName = this.findGeneralInstance();
|
|
@@ -22088,7 +22442,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22088
22442
|
this.startStatuslineWatcher(name);
|
|
22089
22443
|
}
|
|
22090
22444
|
}
|
|
22445
|
+
this.workingRecovery?.ready();
|
|
22091
22446
|
this.logger.info("Graceful restart complete");
|
|
22447
|
+
// The in-process interval keeps its label, but a later unrelated process
|
|
22448
|
+
// crash must not inherit this completed request as its shutdown intent.
|
|
22449
|
+
try {
|
|
22450
|
+
this.deliveryOutbox?.workingRecovery.clearRequested(this.managerBootId);
|
|
22451
|
+
}
|
|
22452
|
+
catch {
|
|
22453
|
+
this.logger.warn("Could not clear completed in-process restart intent");
|
|
22454
|
+
}
|
|
22092
22455
|
const configuredNames = this.configuredStartupInstanceNames(fleet, topicMode);
|
|
22093
22456
|
const total = configuredNames.length;
|
|
22094
22457
|
const started = configuredNames.filter(name => this.daemons.has(name)).length;
|
|
@@ -22143,6 +22506,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22143
22506
|
}
|
|
22144
22507
|
const ipc = this.instanceIpcClients.get(name);
|
|
22145
22508
|
if (ipc?.connected) {
|
|
22509
|
+
daemon?.noteLegacyRestartReminder?.();
|
|
22146
22510
|
ipc.send({
|
|
22147
22511
|
type: "fleet_inbound",
|
|
22148
22512
|
content: msg,
|
|
@@ -22290,6 +22654,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22290
22654
|
const guests = this.fleetConfig?.web?.guests;
|
|
22291
22655
|
if (!owner || guests?.enabled !== true)
|
|
22292
22656
|
return NO_GUEST_SCOPE;
|
|
22657
|
+
// #2348: explicit grants, once the key exists, are the whole scope — no narrowing on top (a grant is already exact).
|
|
22658
|
+
if (guests.grants !== undefined) {
|
|
22659
|
+
const granted = this.guestReach(owner);
|
|
22660
|
+
return granted.size ? granted : NO_GUEST_SCOPE;
|
|
22661
|
+
}
|
|
22293
22662
|
const scope = new Set(this.guestReach(owner));
|
|
22294
22663
|
const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
|
|
22295
22664
|
if (narrow) {
|
|
@@ -22310,6 +22679,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22310
22679
|
const scope = new Set();
|
|
22311
22680
|
if (guests?.enabled !== true)
|
|
22312
22681
|
return scope;
|
|
22682
|
+
// #2348: with explicit grants, what an admin granted this account — of the instances that exist now (fleet.yaml's, or
|
|
22683
|
+
// a ClassicBot room's) — and nothing else: no connection access, no room membership, no open_instances.
|
|
22684
|
+
if (guests.grants !== undefined) {
|
|
22685
|
+
const key = `${owner.adapterId}:${owner.userId}`;
|
|
22686
|
+
const grant = Object.hasOwn(guests.grants, key) ? guests.grants[key] : undefined;
|
|
22687
|
+
if (!grant || !Array.isArray(grant.instances))
|
|
22688
|
+
return scope;
|
|
22689
|
+
const rooms = new Set((this.classicChannels?.getAll() ?? []).map((r) => r.instanceName));
|
|
22690
|
+
for (const name of grant.instances) {
|
|
22691
|
+
if ((this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, name)) || rooms.has(name))
|
|
22692
|
+
scope.add(name);
|
|
22693
|
+
}
|
|
22694
|
+
return scope;
|
|
22695
|
+
}
|
|
22313
22696
|
const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
|
|
22314
22697
|
const world = this.worlds.get(owner.adapterId);
|
|
22315
22698
|
if (world && world.accessManager.isAllowed(owner.userId)) {
|
|
@@ -22351,7 +22734,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22351
22734
|
const owner = record.owner;
|
|
22352
22735
|
if (this.guestScope(owner).size)
|
|
22353
22736
|
return "ok";
|
|
22354
|
-
|
|
22737
|
+
// #2348: with explicit grants no room's membership can give anything: nothing granted is gone, not pending. Nor does
|
|
22738
|
+
// a connection's start matter: grants are config (and ClassicBot rooms), read without the adapter.
|
|
22739
|
+
if (owner && this.fleetConfig?.web?.guests?.enabled === true && this.fleetConfig.web.guests.grants === undefined) {
|
|
22355
22740
|
// Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
|
|
22356
22741
|
// request until its configured world exists. Removed connections and initialized denied access stay final.
|
|
22357
22742
|
const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
|