@songsid/agend 2.3.2-beta.5 → 2.3.2-beta.6
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/channel/adapters/discord.d.ts +11 -0
- package/dist/channel/adapters/discord.js +50 -8
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/config-validator.js +25 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/fleet-manager.d.ts +21 -5
- package/dist/fleet-manager.js +154 -133
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +14 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +14 -1
- package/dist/locale.js +8 -0
- package/dist/locale.js.map +1 -1
- package/dist/notice-delivery.d.ts +7 -6
- package/dist/notice-delivery.js +16 -18
- package/dist/notice-delivery.js.map +1 -1
- package/dist/office-share-page.d.ts +13 -0
- package/dist/office-share-page.js +82 -0
- package/dist/office-share-page.js.map +1 -0
- package/dist/office-share-service.d.ts +89 -0
- package/dist/office-share-service.js +172 -0
- package/dist/office-share-service.js.map +1 -0
- package/dist/office-share.d.ts +228 -0
- package/dist/office-share.js +489 -0
- package/dist/office-share.js.map +1 -0
- package/dist/public-web-gateway.js +6 -0
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/quickstart.js +0 -2
- package/dist/quickstart.js.map +1 -1
- package/dist/settings-api.js +1 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-host.js +1 -2
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +0 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/types.d.ts +12 -3
- package/dist/ui/fleet-office-pack.js +7 -2
- package/dist/ui/fleet-office-skin.js +5 -4
- package/dist/ui/fleet-office-zones.js +17 -6
- package/dist/ui/fleet-office.js +20 -10
- package/dist/ui/office-share-dialog.js +145 -0
- package/dist/ui/office-share-page.js +116 -0
- package/dist/ui/panel-settings.js +12 -8
- package/dist/ui/settings-registry.js +2 -2
- package/dist/ui/settings-strings.js +6 -6
- package/dist/ui/shared/app.css +28 -0
- package/dist/web-api.d.ts +14 -0
- package/dist/web-api.js +91 -0
- package/dist/web-api.js.map +1 -1
- package/dist/web-office-skins.d.ts +2 -1
- package/dist/web-office-skins.js +2 -1
- package/dist/web-office-skins.js.map +1 -1
- package/dist/web-settings-patch.js +1 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/working-recovery.d.ts +2 -2
- package/dist/working-recovery.js +43 -8
- package/dist/working-recovery.js.map +1 -1
- package/dist/working-state-journal.d.ts +2 -0
- package/dist/working-state-journal.js +16 -1
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
|
|
2
|
-
import { loginNoticeMode,
|
|
2
|
+
import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
|
|
3
3
|
import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
|
|
4
4
|
import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
|
|
5
5
|
import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
|
|
@@ -22,6 +22,9 @@ import { PublicLinkAutoOpen } from "./public-link-auto-open.js";
|
|
|
22
22
|
import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from "./public-link-owner.js";
|
|
23
23
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
24
24
|
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
25
|
+
import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
|
|
26
|
+
import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
|
|
27
|
+
import { ALL_OFFICES } from "./office-share.js";
|
|
25
28
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
26
29
|
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
27
30
|
import { withinBudget } from "./monotonic-budget.js";
|
|
@@ -627,6 +630,7 @@ export class FleetManager {
|
|
|
627
630
|
workingRecovery = null;
|
|
628
631
|
workingRecoveryTimer = null;
|
|
629
632
|
workingRecoveryCause = "unknown_interruption";
|
|
633
|
+
workingRecoveryGroup = this.managerBootId;
|
|
630
634
|
recoveryEnabled(name) {
|
|
631
635
|
const own = this.fleetConfig?.instances[name]?.resume_interrupted_work;
|
|
632
636
|
return own ?? this.fleetConfig?.defaults?.resume_interrupted_work ?? true;
|
|
@@ -667,7 +671,16 @@ export class FleetManager {
|
|
|
667
671
|
const deferredAdvisories = rows.filter(row => row.outcome === "deferred"
|
|
668
672
|
&& (this.daemons.get(row.instance)?.getWorkingRecoveryState?.().background ?? null) === null)
|
|
669
673
|
.map(row => `${row.instance} (${row.reason})`);
|
|
670
|
-
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.`));
|
|
674
|
+
const posted = 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.`));
|
|
675
|
+
if (posted) {
|
|
676
|
+
const counts = { interrupted: rows.length, queued: rows.filter(row => row.outcome === "admitted").length,
|
|
677
|
+
excluded: rows.filter(row => row.outcome === "excluded").length, deferred: rows.filter(row => row.outcome === "deferred").length };
|
|
678
|
+
const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
|
|
679
|
+
this.logger.info({ group, ...counts, observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery summary queued for General");
|
|
680
|
+
this.eventLog?.insert(general, "working_state", { reason: "recovery_summary", group, ...counts,
|
|
681
|
+
observedCause: "unknown_interruption", requestedAction,
|
|
682
|
+
message: `Interrupted work summary queued for General: ${counts.interrupted} interrupted; ${counts.queued} queued, ${counts.excluded} excluded, ${counts.deferred} deferred.` });
|
|
683
|
+
}
|
|
671
684
|
},
|
|
672
685
|
log: (episode, outcome, reason) => {
|
|
673
686
|
const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
|
|
@@ -1166,6 +1179,51 @@ export class FleetManager {
|
|
|
1166
1179
|
officeSkinWrites = new OfficeWriteLimiter(undefined, { perWindow: 10, windowMs: 60_000 });
|
|
1167
1180
|
/** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
|
|
1168
1181
|
officeWrites = new OfficeWriteLimiter();
|
|
1182
|
+
officeSharesInstance;
|
|
1183
|
+
/**
|
|
1184
|
+
* #2636: Office share links — the live Office view for people outside the fleet, read-only, on a quick tunnel of
|
|
1185
|
+
* their own (a manager and lease of its own: never the fleet's one tunnel lane nor the public link's preview tunnel).
|
|
1186
|
+
*/
|
|
1187
|
+
get officeShares() {
|
|
1188
|
+
return (this.officeSharesInstance ??= new OfficeShareService({
|
|
1189
|
+
source: {
|
|
1190
|
+
status: () => this.getUiStatus(),
|
|
1191
|
+
org: () => this.orgChart(),
|
|
1192
|
+
offices: () => this.officeStore.list(),
|
|
1193
|
+
acts: () => this.officeActs.live(),
|
|
1194
|
+
traffic: () => this.trafficSnapshot(),
|
|
1195
|
+
needs: () => this.needsYouItems().map(i => i.instance),
|
|
1196
|
+
hidden: name => crossInstanceVisibility(this.fleetConfig, name) === "hidden",
|
|
1197
|
+
skins: this.officeSkins,
|
|
1198
|
+
},
|
|
1199
|
+
manager: new ManagedTunnel({ dataDir: join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR), log: () => this.logger.warn("Office share tunnel cleanup requires attention") }),
|
|
1200
|
+
ensure: signal => ensureCloudflared({ dataDir: this.dataDir, pinnedOnly: true, signal }),
|
|
1201
|
+
provider: binary => new CloudflaredProvider({ binaryName: binary, protocol: this.fleetConfig?.web?.public_link?.protocol ?? "http2" }),
|
|
1202
|
+
config: () => this.fleetConfig?.web?.office_share,
|
|
1203
|
+
officeExists: id => this.officeStore.list().offices.some(o => o.id === id),
|
|
1204
|
+
ownerCurrent: owner => this.officeShareOwnerCurrent(owner),
|
|
1205
|
+
changed: () => this.emitSseEvent("office_shares", {}),
|
|
1206
|
+
log: (event, detail) => this.logger.info({ event, ...(detail ? { detail } : {}) }, "Office share link"),
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* #2636: a share made or ended — said in General, as a sign-in is (who, which office, for how long; never its URL).
|
|
1211
|
+
* `share` null with `count`: "Revoke all".
|
|
1212
|
+
*/
|
|
1213
|
+
officeShareNotice(event, who, share, count) {
|
|
1214
|
+
const office = !share ? "" : share.office === ALL_OFFICES ? t("office_share.all") : this.officeStore.list().offices.find(o => o.id === share.office)?.name ?? share.office;
|
|
1215
|
+
const minutes = share ? Math.round((share.expiresAt - share.createdAt) / 60_000) : 0;
|
|
1216
|
+
const text = event === "created" ? t("office_share.created_notice", who, office, minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`)
|
|
1217
|
+
: share ? t("office_share.revoked_notice", who, office) : t("office_share.revoked_all_notice", who, count);
|
|
1218
|
+
this.notifyFleetError(text, { throttle: false });
|
|
1219
|
+
}
|
|
1220
|
+
/** A share's owner: a person (`person:<adapter>:<user>`) still a fleet admin there; the host's own admin session stays. */
|
|
1221
|
+
officeShareOwnerCurrent(owner) {
|
|
1222
|
+
const m = /^person:([^:]+):(.+)$/.exec(owner);
|
|
1223
|
+
if (!m)
|
|
1224
|
+
return owner === "admin-session";
|
|
1225
|
+
return this.hasFleetAdmins(m[1]) && this.isFleetAdmin(m[2], m[1]);
|
|
1226
|
+
}
|
|
1169
1227
|
officeActsInstance;
|
|
1170
1228
|
/** #2578: the agents' acts in the Office view (office_view / office_set), memory only; every change is `office_acts`. */
|
|
1171
1229
|
get officeActs() {
|
|
@@ -2352,7 +2410,7 @@ export class FleetManager {
|
|
|
2352
2410
|
if (reason === "cancelled")
|
|
2353
2411
|
outbox.workingRecovery.cancel(name, observation.configuration, observation.sessionId);
|
|
2354
2412
|
if (entry.prior)
|
|
2355
|
-
outbox.workingRecovery.discover(name, entry.prior);
|
|
2413
|
+
outbox.workingRecovery.discover(name, entry.prior, this.workingRecoveryGroup);
|
|
2356
2414
|
const state = entry.current.observation.executionState ?? "unknown";
|
|
2357
2415
|
const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
|
|
2358
2416
|
owed: entry.current.references.filter(r => r.kind === "owed").length,
|
|
@@ -2876,8 +2934,9 @@ export class FleetManager {
|
|
|
2876
2934
|
// the next tunnel and says so, it does not block the fleet.
|
|
2877
2935
|
this.announceToolPermissionsChange();
|
|
2878
2936
|
this.tightenInstanceDirectories();
|
|
2879
|
-
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way
|
|
2880
|
-
|
|
2937
|
+
// #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way (and
|
|
2938
|
+
// #2636's Office share tunnel's).
|
|
2939
|
+
for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR), join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR)]) {
|
|
2881
2940
|
void reapStaleTunnel(leaseDir)
|
|
2882
2941
|
.then(outcome => {
|
|
2883
2942
|
if (outcome.kind === "manual")
|
|
@@ -5531,36 +5590,40 @@ export class FleetManager {
|
|
|
5531
5590
|
* config file races. Stagger applies between outer lifecycle admissions.
|
|
5532
5591
|
* TODO: per-instance startup timeout (existing issue, not introduced here)
|
|
5533
5592
|
*/
|
|
5534
|
-
async startInstancesWithConcurrency(entries, topicMode, onReady) {
|
|
5593
|
+
async startInstancesWithConcurrency(entries, topicMode, onReady, classic = []) {
|
|
5535
5594
|
// Persisted pauses are intentionally preserved across fleet restarts. Filter
|
|
5536
5595
|
// them before grouping/staggering: startInstance() retains its own guard as
|
|
5537
5596
|
// a final backstop, but putting a no-op entry in this queue still consumes a
|
|
5538
5597
|
// full stagger slot for every distinct working directory.
|
|
5539
5598
|
const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
|
|
5599
|
+
// ClassicBot rooms queue with the fleet instances, at the same pressure pacing (#2642 follow-up): they used to start
|
|
5600
|
+
// only after the whole fleet, the chat connections, topics and icons. A name the fleet also configures is the fleet's.
|
|
5601
|
+
const fleetNames = new Set(entries.map(([name]) => name));
|
|
5602
|
+
const runnableClassic = classic.filter(ch => !this.lifecycle.isPaused(ch.instanceName) && !fleetNames.has(ch.instanceName));
|
|
5540
5603
|
const pausedCount = entries.length - runnableEntries.length;
|
|
5541
5604
|
if (pausedCount > 0) {
|
|
5542
5605
|
this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
|
|
5543
5606
|
}
|
|
5544
|
-
if (runnableEntries.length === 0)
|
|
5607
|
+
if (runnableEntries.length === 0 && runnableClassic.length === 0)
|
|
5545
5608
|
return;
|
|
5546
5609
|
if (this.fleetConfig?.defaults?.startup?.concurrency == null) {
|
|
5547
5610
|
this.logger.info({
|
|
5548
5611
|
concurrency: this.spawnConcurrency(),
|
|
5549
5612
|
freeMemMB: Math.round(freemem() / (1024 * 1024)),
|
|
5550
|
-
totalInstances: runnableEntries.length,
|
|
5613
|
+
totalInstances: runnableEntries.length + runnableClassic.length,
|
|
5551
5614
|
}, "Adaptive startup concurrency");
|
|
5552
5615
|
}
|
|
5553
5616
|
const startedAt = performance.now();
|
|
5554
5617
|
let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
|
|
5555
|
-
|
|
5618
|
+
const admit = (name, workingDirectory, start) => this.spawnGate.run({
|
|
5556
5619
|
instanceName: name,
|
|
5557
|
-
workingDirectory
|
|
5620
|
+
workingDirectory,
|
|
5558
5621
|
reason: "startup",
|
|
5559
5622
|
stage: "lifecycle",
|
|
5560
5623
|
}, async () => {
|
|
5561
5624
|
peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
|
|
5562
5625
|
try {
|
|
5563
|
-
if (await
|
|
5626
|
+
if (await start()) {
|
|
5564
5627
|
readyInstances++;
|
|
5565
5628
|
onReady?.(name);
|
|
5566
5629
|
}
|
|
@@ -5568,9 +5631,13 @@ export class FleetManager {
|
|
|
5568
5631
|
finally {
|
|
5569
5632
|
active--;
|
|
5570
5633
|
}
|
|
5571
|
-
})
|
|
5634
|
+
});
|
|
5635
|
+
await Promise.all([
|
|
5636
|
+
...runnableEntries.map(([name, config]) => admit(name, config.working_directory, () => this.startInstanceUnattended(name, config, topicMode, "instance"))),
|
|
5637
|
+
...runnableClassic.map(ch => admit(ch.instanceName, join(getAgendHome(), "workspaces", ch.instanceName), () => this.startClassicInstanceUnattended(ch, "classic instance"))),
|
|
5638
|
+
]);
|
|
5572
5639
|
this.logger.info({
|
|
5573
|
-
totalInstances: runnableEntries.length, readyInstances,
|
|
5640
|
+
totalInstances: runnableEntries.length + runnableClassic.length, classicInstances: runnableClassic.length, readyInstances,
|
|
5574
5641
|
elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
|
|
5575
5642
|
peakConcurrentStarts,
|
|
5576
5643
|
}, "Fleet startup batch completed");
|
|
@@ -6581,33 +6648,11 @@ export class FleetManager {
|
|
|
6581
6648
|
getPublicWebStatus() {
|
|
6582
6649
|
return this.publicWebLink?.status() ?? { state: "closed" };
|
|
6583
6650
|
}
|
|
6584
|
-
/** Notices use the first explicit admin of the owning connection; never a guest/paired account. */
|
|
6585
|
-
noticeAdmin(adapterId) {
|
|
6586
|
-
return this.adminListOf(adapterId)?.find(id => id.length > 0);
|
|
6587
|
-
}
|
|
6588
|
-
/** Best-effort private delivery. The caller retains its existing topic fallback and authority fences. */
|
|
6589
|
-
async tryNoticeDm(adapter, userId, text) {
|
|
6590
|
-
if (!userId || !adapter?.sendDirect)
|
|
6591
|
-
return false;
|
|
6592
|
-
try {
|
|
6593
|
-
await withinBudget(adapter.sendDirect(userId, text, { disablePreview: true }), performance.now() + 2_000);
|
|
6594
|
-
return true;
|
|
6595
|
-
}
|
|
6596
|
-
catch {
|
|
6597
|
-
this.logger.info({ adapterId: adapter.id }, "Fleet notice DM failed; falling back to the topic");
|
|
6598
|
-
return false;
|
|
6599
|
-
}
|
|
6600
|
-
}
|
|
6601
6651
|
async confirmPublicWebLogin(info, current) {
|
|
6602
6652
|
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
6603
6653
|
throw new Error("public owner unavailable");
|
|
6604
6654
|
const adapter = info.owner.binding;
|
|
6605
|
-
|
|
6606
|
-
if (loginNoticeMode(this.fleetConfig?.web) !== "dm" || !await this.tryNoticeDm(adapter, info.owner.userId, text)) {
|
|
6607
|
-
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
6608
|
-
throw new Error("public owner changed");
|
|
6609
|
-
await adapter.sendText(info.owner.chatId, text, { threadId: info.owner.threadId, allowedMentions: { parse: [] } });
|
|
6610
|
-
}
|
|
6655
|
+
await adapter.sendText(info.owner.chatId, t("web.public_login_notice", info.label, info.handle.slice(0, 8)), { threadId: info.owner.threadId, allowedMentions: { parse: [] } });
|
|
6611
6656
|
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
6612
6657
|
throw new Error("public owner changed");
|
|
6613
6658
|
}
|
|
@@ -6625,12 +6670,7 @@ export class FleetManager {
|
|
|
6625
6670
|
if (!open)
|
|
6626
6671
|
throw new Error("guest public sign-in unavailable");
|
|
6627
6672
|
const adapter = open.owner.binding;
|
|
6628
|
-
|
|
6629
|
-
if (loginNoticeMode(this.fleetConfig?.web) !== "dm" || !await this.tryNoticeDm(adapter, open.owner.userId, text)) {
|
|
6630
|
-
if (!admitted())
|
|
6631
|
-
throw new Error("guest public sign-in changed");
|
|
6632
|
-
await adapter.sendText(open.owner.chatId, text, { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
|
|
6633
|
-
}
|
|
6673
|
+
await adapter.sendText(open.owner.chatId, t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8)), { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
|
|
6634
6674
|
if (!admitted())
|
|
6635
6675
|
throw new Error("guest public sign-in changed");
|
|
6636
6676
|
}
|
|
@@ -7468,26 +7508,18 @@ export class FleetManager {
|
|
|
7468
7508
|
}
|
|
7469
7509
|
/** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
|
|
7470
7510
|
onWebLogin(info) {
|
|
7471
|
-
//
|
|
7472
|
-
|
|
7473
|
-
if (
|
|
7474
|
-
|
|
7475
|
-
const text = guest
|
|
7476
|
-
? t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8))
|
|
7477
|
-
: t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8));
|
|
7478
|
-
if (loginNoticeMode(this.fleetConfig?.web) === "dm") {
|
|
7479
|
-
const adapterId = info.owner?.adapterId;
|
|
7480
|
-
const adapter = adapterId ? this.adapters.get(adapterId) : undefined;
|
|
7481
|
-
// A local guest's own account must never receive the admin's security notice.
|
|
7482
|
-
const userId = guest ? (adapterId ? this.noticeAdmin(adapterId) : undefined)
|
|
7483
|
-
: info.owner && this.isFleetAdmin(info.owner.userId, adapterId) ? info.owner.userId : undefined;
|
|
7484
|
-
void this.tryNoticeDm(adapter, userId, text).then(sent => { if (!sent)
|
|
7485
|
-
this.notifyFleetError(text); })
|
|
7486
|
-
.catch(() => this.logger.warn("Web sign-in notice could not be delivered"));
|
|
7511
|
+
// #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
|
|
7512
|
+
// and how many agents they now reach — admins stay aware of everyone who is not one of them.
|
|
7513
|
+
if (info.tier === "guest" && info.owner) {
|
|
7514
|
+
this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
|
|
7487
7515
|
return;
|
|
7488
7516
|
}
|
|
7489
|
-
//
|
|
7490
|
-
this.
|
|
7517
|
+
// Never by DM (hmsg-3b8ac2af: Telegram cannot DM a user who has not started the bot); a legacy "dm" reads as on.
|
|
7518
|
+
if (!loginNoticeMode(this.fleetConfig?.web))
|
|
7519
|
+
return;
|
|
7520
|
+
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
7521
|
+
// sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
|
|
7522
|
+
this.notifyFleetError(t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
|
|
7491
7523
|
}
|
|
7492
7524
|
getDashboardAccess() {
|
|
7493
7525
|
return { ready: this.healthServerListening, token: this.webToken };
|
|
@@ -8265,9 +8297,11 @@ export class FleetManager {
|
|
|
8265
8297
|
this.rotateAllInstanceLogs();
|
|
8266
8298
|
}, 24 * 60 * 60_000);
|
|
8267
8299
|
this.logRotateTimer.unref?.();
|
|
8268
|
-
// Phase 2: Start remaining instances with staggered concurrency
|
|
8269
|
-
|
|
8270
|
-
|
|
8300
|
+
// Phase 2: Start remaining instances, and the ClassicBot rooms with them, with staggered concurrency. A room needs no
|
|
8301
|
+
// chat connection to spawn: its open-list is set above (reregisterClassicChannels) and inbound queues until it is up.
|
|
8302
|
+
const classicAtBoot = topicMode && (fleet.channel || fleet.channels?.length) ? this.classicChannels?.getAll() ?? [] : [];
|
|
8303
|
+
if (others.length > 0 || classicAtBoot.length > 0) {
|
|
8304
|
+
await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
|
|
8271
8305
|
}
|
|
8272
8306
|
if (topicMode && (fleet.channel || fleet.channels?.length)) {
|
|
8273
8307
|
await adapterStartup;
|
|
@@ -8309,22 +8343,7 @@ export class FleetManager {
|
|
|
8309
8343
|
// IPC is already wired by startInstancesWithConcurrency → startInstance →
|
|
8310
8344
|
// connectIpcToInstance. The previous 3s sleep + connectToInstances loop
|
|
8311
8345
|
// was redundant.
|
|
8312
|
-
//
|
|
8313
|
-
if (this.classicChannels) {
|
|
8314
|
-
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
8315
|
-
const channels = this.classicChannels.getAll()
|
|
8316
|
-
.filter(ch => !this.lifecycle.isPaused(ch.instanceName));
|
|
8317
|
-
const concurrency = 3;
|
|
8318
|
-
let idx = 0;
|
|
8319
|
-
while (idx < channels.length) {
|
|
8320
|
-
const batch = channels.slice(idx, idx + concurrency);
|
|
8321
|
-
await Promise.allSettled(batch.map(async (ch) => {
|
|
8322
|
-
if (await this.startClassicInstanceUnattended(ch, "classic instance"))
|
|
8323
|
-
startupProgress.markReady();
|
|
8324
|
-
}));
|
|
8325
|
-
idx += concurrency;
|
|
8326
|
-
}
|
|
8327
|
-
}
|
|
8346
|
+
// The ClassicBot rooms started in Phase 2, with the fleet.
|
|
8328
8347
|
for (const name of Object.keys(fleet.instances)) {
|
|
8329
8348
|
this.startStatuslineWatcher(name);
|
|
8330
8349
|
}
|
|
@@ -8591,24 +8610,42 @@ export class FleetManager {
|
|
|
8591
8610
|
if (channelConfigs.length === 0)
|
|
8592
8611
|
return;
|
|
8593
8612
|
// Start ALL adapters in parallel — any single failure doesn't block others.
|
|
8594
|
-
const
|
|
8613
|
+
const settled = channelConfigs.map(() => false);
|
|
8614
|
+
// Each connection is recorded (connected, or retrying with its retry scheduled) as its own login settles.
|
|
8615
|
+
const recorded = channelConfigs.map((cfg, i) => (i === 0
|
|
8595
8616
|
? this.startSingleAdapter(fleet, cfg)
|
|
8596
|
-
: this.startAdditionalAdapter(cfg)))
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
}
|
|
8617
|
+
: this.startAdditionalAdapter(cfg)).then(() => ({ status: "fulfilled" }), (reason) => ({ status: "rejected", reason }))
|
|
8618
|
+
.then(result => { settled[i] = true; this.recordAdapterStartup(cfg, result); })
|
|
8619
|
+
.catch(err => this.logger.error({ err }, "Recording a chat connection's startup failed")));
|
|
8620
|
+
// #2642: bounded. Startup continues past a connection still logging in after this long (ClassicBot instances,
|
|
8621
|
+
// READY); that connection is recorded, or retried, when its login settles, and joins the fleet as it would anyway.
|
|
8622
|
+
let timer;
|
|
8623
|
+
const inTime = await Promise.race([
|
|
8624
|
+
Promise.all(recorded).then(() => true),
|
|
8625
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(false), FleetManager.ADAPTER_STARTUP_WAIT_MS); timer.unref?.(); }),
|
|
8626
|
+
]).finally(() => clearTimeout(timer));
|
|
8627
|
+
if (!inTime) {
|
|
8628
|
+
this.logger.warn({
|
|
8629
|
+
pending: channelConfigs.filter((_, i) => !settled[i]).map(cfg => cfg.id ?? cfg.type),
|
|
8630
|
+
waitedMs: FleetManager.ADAPTER_STARTUP_WAIT_MS,
|
|
8631
|
+
}, "Chat connection still connecting — fleet startup continues without waiting for it");
|
|
8632
|
+
}
|
|
8633
|
+
}
|
|
8634
|
+
/** How long fleet startup waits for its chat connections before it continues without the slow ones (#2642). */
|
|
8635
|
+
static ADAPTER_STARTUP_WAIT_MS = 60_000;
|
|
8636
|
+
/** Track a connection's startup outcome; a failure gets the background retry. */
|
|
8637
|
+
recordAdapterStartup(cfg, result) {
|
|
8638
|
+
const adapterId = cfg.id ?? cfg.type;
|
|
8639
|
+
if (result.status === "fulfilled") {
|
|
8640
|
+
this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
|
|
8641
|
+
return;
|
|
8611
8642
|
}
|
|
8643
|
+
const err = result.reason;
|
|
8644
|
+
this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
|
|
8645
|
+
this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
|
|
8646
|
+
this.scheduleAdapterRetry(adapterId);
|
|
8647
|
+
// Notify admin via whichever adapter is already up
|
|
8648
|
+
this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
|
|
8612
8649
|
}
|
|
8613
8650
|
/** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
|
|
8614
8651
|
scheduleAdapterRetry(adapterId) {
|
|
@@ -12249,10 +12286,11 @@ export class FleetManager {
|
|
|
12249
12286
|
const { office, ...dashboard } = patch.value;
|
|
12250
12287
|
return [{ ...patch, value: dashboard }, { path: [...path.slice(0, -1), "office_access"], value: office, remove: office === null }];
|
|
12251
12288
|
}
|
|
12289
|
+
// #2581's DM choice was withdrawn: an old string is just "on", posted in the topic, and its route is dropped.
|
|
12252
12290
|
if (patch.path.join(".") === "web.notify_login" && patch.value === "dm")
|
|
12253
|
-
return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "login"],
|
|
12291
|
+
return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "login"], remove: true }];
|
|
12254
12292
|
if (patch.path.join(".") === "defaults.tips" && patch.value === "dm")
|
|
12255
|
-
return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "tips"],
|
|
12293
|
+
return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "tips"], remove: true }];
|
|
12256
12294
|
return [patch];
|
|
12257
12295
|
});
|
|
12258
12296
|
// Re-read immediately before patching so an unrelated concurrent/manual
|
|
@@ -12408,12 +12446,13 @@ export class FleetManager {
|
|
|
12408
12446
|
}
|
|
12409
12447
|
/** One-time upgrade migration; invalid YAML is never rewritten. */
|
|
12410
12448
|
migrateNoticeDeliveryStorage() {
|
|
12411
|
-
if (this.fleetConfig && this.configPath && (this.fleetConfig.web?.notify_login === "dm" || this.fleetConfig.defaults.tips === "dm"
|
|
12449
|
+
if (this.fleetConfig && this.configPath && (this.fleetConfig.web?.notify_login === "dm" || this.fleetConfig.defaults.tips === "dm"
|
|
12450
|
+
|| this.fleetConfig.web?.notice_delivery !== undefined)
|
|
12412
12451
|
&& validateFleetConfig(this.rawFleetConfig).valid) {
|
|
12413
12452
|
try {
|
|
12414
12453
|
this.writeFleetConfigBackup(readFileSync(this.configPath, "utf8"));
|
|
12415
12454
|
this.saveFleetConfig();
|
|
12416
|
-
this.logger.info("
|
|
12455
|
+
this.logger.info("Notices no longer go by DM: stored them as topic booleans and dropped the old DM routing");
|
|
12417
12456
|
}
|
|
12418
12457
|
catch (err) {
|
|
12419
12458
|
this.logger.warn({ err }, "Could not migrate DM notice routing; keeping the original file");
|
|
@@ -15073,14 +15112,14 @@ export class FleetManager {
|
|
|
15073
15112
|
}
|
|
15074
15113
|
}
|
|
15075
15114
|
/** Post one fresh nonce-armed tip in a known General channel. */
|
|
15076
|
-
async promptTip(generalName, adapter, chatId, threadId
|
|
15115
|
+
async promptTip(generalName, adapter, chatId, threadId) {
|
|
15077
15116
|
const state = this.readTipState();
|
|
15078
15117
|
if (!state)
|
|
15079
15118
|
return "unavailable";
|
|
15080
15119
|
if (!state.advancedUnlocked
|
|
15081
15120
|
&& visibleTipLevels(true).has("advanced")
|
|
15082
15121
|
&& canUnlockAdvancedTips(state.dismissed)) {
|
|
15083
|
-
return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId
|
|
15122
|
+
return await this.promptAdvancedTipUnlock(generalName, adapter, chatId, threadId)
|
|
15084
15123
|
? "posted"
|
|
15085
15124
|
: "unavailable";
|
|
15086
15125
|
}
|
|
@@ -15096,7 +15135,6 @@ export class FleetManager {
|
|
|
15096
15135
|
chatId,
|
|
15097
15136
|
threadId,
|
|
15098
15137
|
message: this.formatTip(tip),
|
|
15099
|
-
...(directUserId ? { deliver: (choices) => withinBudget(adapter.sendDirect(directUserId, this.formatTip(tip), { choices, disablePreview: true }), performance.now() + 5_000) } : {}),
|
|
15100
15138
|
choices: [
|
|
15101
15139
|
{ action: "dismiss", label: t("tips.dismiss") },
|
|
15102
15140
|
{ action: "confused", label: t("tips.confused") },
|
|
@@ -15108,7 +15146,7 @@ export class FleetManager {
|
|
|
15108
15146
|
});
|
|
15109
15147
|
return nonce ? "posted" : "unavailable";
|
|
15110
15148
|
}
|
|
15111
|
-
async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId
|
|
15149
|
+
async promptAdvancedTipUnlock(generalName, adapter, chatId, threadId) {
|
|
15112
15150
|
const nonce = await this.postNonceButtonPrompt({
|
|
15113
15151
|
prefix: TIP_UNLOCK_CALLBACK_PREFIX,
|
|
15114
15152
|
alertType: "tip",
|
|
@@ -15118,7 +15156,6 @@ export class FleetManager {
|
|
|
15118
15156
|
chatId,
|
|
15119
15157
|
threadId,
|
|
15120
15158
|
message: t("tips.advanced.unlock_prompt"),
|
|
15121
|
-
...(directUserId ? { deliver: (choices) => withinBudget(adapter.sendDirect(directUserId, t("tips.advanced.unlock_prompt"), { choices, disablePreview: true }), performance.now() + 5_000) } : {}),
|
|
15122
15159
|
choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
|
|
15123
15160
|
expiredText: t("tips.advanced.expired"),
|
|
15124
15161
|
// No allowAnyUser: unlocking changes a persistent setting, which typed and slash `/tips advanced on` reserve for a
|
|
@@ -15138,15 +15175,6 @@ export class FleetManager {
|
|
|
15138
15175
|
const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
|
|
15139
15176
|
if (!adapter || !chatId)
|
|
15140
15177
|
return;
|
|
15141
|
-
if (this.fleetConfig && tipNoticeMode(this.fleetConfig) === "dm" && adapter.sendDirect) {
|
|
15142
|
-
const userId = this.noticeAdmin(adapter.id);
|
|
15143
|
-
if (userId) {
|
|
15144
|
-
const result = await this.promptTip(generalName, adapter, userId, undefined, userId);
|
|
15145
|
-
if (result !== "unavailable")
|
|
15146
|
-
return;
|
|
15147
|
-
// The failed prompt disarmed its nonce; a new topic prompt is safe.
|
|
15148
|
-
}
|
|
15149
|
-
}
|
|
15150
15178
|
const result = await this.promptTip(generalName, adapter, chatId, topicId != null ? String(topicId) : undefined);
|
|
15151
15179
|
if (result === "unavailable") {
|
|
15152
15180
|
this.logger.warn({ generalName }, "Daily tip could not be posted");
|
|
@@ -20723,8 +20751,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20723
20751
|
/** Signal-handler continuation, after successful graceful shutdown only. */
|
|
20724
20752
|
noteRequestedShutdown() {
|
|
20725
20753
|
const progress = readUpdateProgress(this.dataDir)?.progress;
|
|
20726
|
-
|
|
20727
|
-
|
|
20754
|
+
// Host CLI updates carry a generic marker with no chat progress target.
|
|
20755
|
+
const update = progress ? updateProgressOperation(progress) === "update"
|
|
20756
|
+
&& ["installed", "stopping", "starting"].includes(progress.stage) : isUpdateInProgress(this.dataDir);
|
|
20728
20757
|
this.recordWorkingRestartIntent(update ? "update" : "manual_restart");
|
|
20729
20758
|
}
|
|
20730
20759
|
recordWorkingRestartIntent(cause) {
|
|
@@ -20784,6 +20813,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20784
20813
|
const settingsControlStopped = settingsControl?.close();
|
|
20785
20814
|
this.publicLinkAutoOpen?.stop();
|
|
20786
20815
|
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
20816
|
+
const sharesStopped = this.officeSharesInstance?.close(); // #2636: every Office share ends, and its tunnel
|
|
20787
20817
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
20788
20818
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
20789
20819
|
const needsStopped = this.stopNeedsYou();
|
|
@@ -20822,6 +20852,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20822
20852
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
20823
20853
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
20824
20854
|
await publicStopped;
|
|
20855
|
+
await sharesStopped?.catch(() => { });
|
|
20825
20856
|
await profileStopped;
|
|
20826
20857
|
await needsStopped;
|
|
20827
20858
|
await settingsControlStopped;
|
|
@@ -22724,6 +22755,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22724
22755
|
this.logger.info("No instances to restart");
|
|
22725
22756
|
return;
|
|
22726
22757
|
}
|
|
22758
|
+
this.workingRecoveryGroup = randomUUID();
|
|
22727
22759
|
this.recordWorkingRestartIntent("manual_restart");
|
|
22728
22760
|
this.workingRecovery?.reset();
|
|
22729
22761
|
this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
|
|
@@ -22799,29 +22831,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
22799
22831
|
}
|
|
22800
22832
|
// General is ready again; now its topic can own the live progress message.
|
|
22801
22833
|
await restartProgress.start(progressTarget);
|
|
22802
|
-
|
|
22803
|
-
|
|
22834
|
+
// The ClassicBot rooms (killed during orphan cleanup) restart with the fleet instances, in the same queue; their
|
|
22835
|
+
// open-lists are refreshed first so a room's inbound is never dropped while it starts.
|
|
22836
|
+
const restartClassic = topicMode ? this.classicChannels?.getAll() ?? [] : [];
|
|
22837
|
+
if (restartClassic.length > 0)
|
|
22838
|
+
this.reregisterClassicChannels();
|
|
22839
|
+
if (restartOthers.length > 0 || restartClassic.length > 0) {
|
|
22840
|
+
await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady(), restartClassic);
|
|
22804
22841
|
}
|
|
22805
22842
|
if (topicMode) {
|
|
22806
22843
|
this.routing.rebuild(this.fleetConfig);
|
|
22807
22844
|
this.reregisterClassicChannels();
|
|
22808
22845
|
// startInstance already calls connectIpcToInstance, no need for connectToInstances here
|
|
22809
|
-
// Restart classic channel instances (killed during orphan cleanup)
|
|
22810
|
-
if (this.classicChannels) {
|
|
22811
|
-
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
22812
|
-
const channels = this.classicChannels.getAll()
|
|
22813
|
-
.filter(ch => !this.lifecycle.isPaused(ch.instanceName));
|
|
22814
|
-
const concurrency = 3;
|
|
22815
|
-
let idx = 0;
|
|
22816
|
-
while (idx < channels.length) {
|
|
22817
|
-
const batch = channels.slice(idx, idx + concurrency);
|
|
22818
|
-
await Promise.allSettled(batch.map(async (ch) => {
|
|
22819
|
-
if (await this.startClassicInstanceUnattended(ch, "classic instance"))
|
|
22820
|
-
restartProgress.markReady();
|
|
22821
|
-
}));
|
|
22822
|
-
idx += concurrency;
|
|
22823
|
-
}
|
|
22824
|
-
}
|
|
22825
22846
|
for (const name of Object.keys(fleet.instances)) {
|
|
22826
22847
|
this.startStatuslineWatcher(name);
|
|
22827
22848
|
}
|