@songsid/agend 2.1.12-beta.9 → 2.2.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/auth/one-time-code.d.ts +19 -0
- package/dist/auth/one-time-code.js +49 -0
- package/dist/auth/one-time-code.js.map +1 -0
- package/dist/auth-api.d.ts +52 -0
- package/dist/auth-api.js +346 -0
- package/dist/auth-api.js.map +1 -0
- package/dist/backend/claude-code.d.ts +4 -0
- package/dist/backend/claude-code.js +97 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +59 -30
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro-agent.d.ts +66 -0
- package/dist/backend/kiro-agent.js +302 -0
- package/dist/backend/kiro-agent.js.map +1 -0
- package/dist/backend/kiro-identity.d.ts +80 -0
- package/dist/backend/kiro-identity.js +537 -0
- package/dist/backend/kiro-identity.js.map +1 -0
- package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
- package/dist/backend/kiro-v2-store-worker.js +87 -0
- package/dist/backend/kiro-v2-store-worker.js.map +1 -0
- package/dist/backend/kiro-v2-store.d.ts +54 -0
- package/dist/backend/kiro-v2-store.js +131 -0
- package/dist/backend/kiro-v2-store.js.map +1 -0
- package/dist/backend/kiro-v3-identity.d.ts +5 -0
- package/dist/backend/kiro-v3-identity.js +22 -0
- package/dist/backend/kiro-v3-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +83 -7
- package/dist/backend/kiro.js +418 -91
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +45 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +1 -0
- package/dist/channel/adapters/discord.js +29 -10
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +1 -0
- package/dist/channel/adapters/telegram.js +13 -1
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +9 -1
- package/dist/classic-channel-manager.d.ts +18 -10
- package/dist/classic-channel-manager.js +45 -18
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +225 -127
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +5 -3
- package/dist/command-table.js +29 -27
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +46 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/connection-secrets.d.ts +6 -0
- package/dist/connection-secrets.js +4 -0
- package/dist/connection-secrets.js.map +1 -1
- package/dist/daemon.d.ts +89 -4
- package/dist/daemon.js +661 -77
- package/dist/daemon.js.map +1 -1
- package/dist/darwin-memory.d.ts +6 -1
- package/dist/darwin-memory.js +17 -6
- package/dist/darwin-memory.js.map +1 -1
- package/dist/delivery-consumed-watch.d.ts +85 -0
- package/dist/delivery-consumed-watch.js +244 -0
- package/dist/delivery-consumed-watch.js.map +1 -0
- package/dist/delivery-outbox.d.ts +61 -0
- package/dist/delivery-outbox.js +120 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +14 -5
- package/dist/delivery-reconciliation.js +27 -4
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/delivery-reconciliation.test.js +35 -1
- package/dist/delivery-reconciliation.test.js.map +1 -1
- package/dist/event-loop-watch.d.ts +28 -0
- package/dist/event-loop-watch.js +75 -3
- package/dist/event-loop-watch.js.map +1 -1
- package/dist/fleet-context.d.ts +22 -2
- package/dist/fleet-level-config.d.ts +2 -2
- package/dist/fleet-level-config.js +6 -1
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +293 -12
- package/dist/fleet-manager.js +2364 -449
- package/dist/fleet-manager.js.map +1 -1
- package/dist/host-memory.d.ts +4 -0
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-config-impact.js +5 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +10 -5
- package/dist/instance-lifecycle.js +162 -53
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +150 -16
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +2 -0
- package/dist/login-controller.js +17 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/memory-pressure.d.ts +7 -8
- package/dist/memory-pressure.js +76 -15
- package/dist/memory-pressure.js.map +1 -1
- package/dist/monotonic-budget.d.ts +2 -0
- package/dist/monotonic-budget.js +28 -0
- package/dist/monotonic-budget.js.map +1 -0
- package/dist/needs-you-hub.d.ts +114 -0
- package/dist/needs-you-hub.js +370 -0
- package/dist/needs-you-hub.js.map +1 -0
- package/dist/needs-you-live.d.ts +116 -0
- package/dist/needs-you-live.js +228 -0
- package/dist/needs-you-live.js.map +1 -0
- package/dist/needs-you.d.ts +132 -0
- package/dist/needs-you.js +164 -0
- package/dist/needs-you.js.map +1 -0
- package/dist/node-version-guard.d.ts +30 -0
- package/dist/node-version-guard.js +50 -0
- package/dist/node-version-guard.js.map +1 -0
- package/dist/operator-control-paths.d.ts +4 -0
- package/dist/operator-control-paths.js +24 -0
- package/dist/operator-control-paths.js.map +1 -0
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +9 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +5 -5
- package/dist/profile-control.js +4 -20
- package/dist/profile-control.js.map +1 -1
- package/dist/public-web-gateway.d.ts +18 -0
- package/dist/public-web-gateway.js +141 -0
- package/dist/public-web-gateway.js.map +1 -0
- package/dist/public-web-link.d.ts +53 -0
- package/dist/public-web-link.js +175 -0
- package/dist/public-web-link.js.map +1 -0
- package/dist/quickstart-api.d.ts +8 -1
- package/dist/quickstart-api.js +86 -48
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +1 -0
- package/dist/quickstart.js +6 -2
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.d.ts +7 -1
- package/dist/scheduler/db.js +38 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/retry-label.d.ts +11 -0
- package/dist/scheduler/retry-label.js +24 -0
- package/dist/scheduler/retry-label.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +55 -2
- package/dist/scheduler/scheduler.js +220 -8
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/types.d.ts +22 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +5 -0
- package/dist/secret-store.js +20 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-activation.d.ts +110 -0
- package/dist/service-activation.js +302 -0
- package/dist/service-activation.js.map +1 -0
- package/dist/service-installer.d.ts +7 -0
- package/dist/service-installer.js +7 -4
- package/dist/service-installer.js.map +1 -1
- package/dist/service-restart-selection.d.ts +6 -5
- package/dist/service-restart-selection.js +6 -5
- package/dist/service-restart-selection.js.map +1 -1
- package/dist/settings-api.d.ts +10 -2
- package/dist/settings-api.js +175 -72
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.d.ts +20 -0
- package/dist/settings-baseline.js +104 -0
- package/dist/settings-baseline.js.map +1 -0
- package/dist/settings-change.d.ts +21 -0
- package/dist/settings-change.js +133 -0
- package/dist/settings-change.js.map +1 -0
- package/dist/settings-confirmation.d.ts +105 -0
- package/dist/settings-confirmation.js +232 -0
- package/dist/settings-confirmation.js.map +1 -0
- package/dist/settings-control.d.ts +41 -0
- package/dist/settings-control.js +294 -0
- package/dist/settings-control.js.map +1 -0
- package/dist/settings-effect.d.ts +24 -0
- package/dist/settings-effect.js +169 -0
- package/dist/settings-effect.js.map +1 -0
- package/dist/settings-http-confirmation.d.ts +29 -0
- package/dist/settings-http-confirmation.js +123 -0
- package/dist/settings-http-confirmation.js.map +1 -0
- package/dist/settings-instance-patch.d.ts +4 -0
- package/dist/settings-instance-patch.js +26 -0
- package/dist/settings-instance-patch.js.map +1 -0
- package/dist/settings-request-capability.d.ts +16 -0
- package/dist/settings-request-capability.js +32 -0
- package/dist/settings-request-capability.js.map +1 -0
- package/dist/settings-transaction.d.ts +65 -0
- package/dist/settings-transaction.js +223 -0
- package/dist/settings-transaction.js.map +1 -0
- package/dist/setup-auth.d.ts +10 -18
- package/dist/setup-auth.js +14 -38
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +75 -18
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-guide.d.ts +13 -0
- package/dist/setup-guide.js +25 -0
- package/dist/setup-guide.js.map +1 -0
- package/dist/setup-host.d.ts +16 -0
- package/dist/setup-host.js +197 -32
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +3 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/slash-authz.d.ts +17 -3
- package/dist/slash-authz.js +5 -0
- package/dist/slash-authz.js.map +1 -1
- package/dist/spawn-gate.js +3 -3
- package/dist/spawn-gate.js.map +1 -1
- package/dist/statusline-watcher.d.ts +5 -0
- package/dist/statusline-watcher.js +2 -0
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/steer-capability.d.ts +6 -0
- package/dist/steer-capability.js +8 -0
- package/dist/steer-capability.js.map +1 -1
- package/dist/sync-work-attribution.d.ts +14 -0
- package/dist/sync-work-attribution.js +43 -8
- package/dist/sync-work-attribution.js.map +1 -1
- package/dist/tips.js +10 -5
- package/dist/tips.js.map +1 -1
- package/dist/tmux-control.d.ts +35 -5
- package/dist/tmux-control.js +291 -57
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +20 -6
- package/dist/tmux-manager.js +90 -47
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.d.ts +59 -0
- package/dist/tmux-read.js +213 -0
- package/dist/tmux-read.js.map +1 -0
- package/dist/topic-commands.d.ts +21 -10
- package/dist/topic-commands.js +191 -58
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.js +2 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/tunnel/cloudflared-install.d.ts +2 -0
- package/dist/tunnel/cloudflared-install.js +1 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +14 -0
- package/dist/tunnel/purpose-lane.js +43 -0
- package/dist/tunnel/purpose-lane.js.map +1 -0
- package/dist/turn-reply-guard.d.ts +2 -0
- package/dist/turn-reply-guard.js +10 -1
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/ui/app.html +18 -0
- package/dist/ui/chat-render.js +478 -0
- package/dist/ui/chat-store.js +289 -0
- package/dist/ui/chat-strings.js +69 -0
- package/dist/ui/chat-thread.js +209 -0
- package/dist/ui/chat-tour.js +116 -0
- package/dist/ui/fleet-org.js +142 -0
- package/dist/ui/panel-chat.js +418 -0
- package/dist/ui/panel-fleet.js +413 -0
- package/dist/ui/panel-needs.js +202 -0
- package/dist/ui/panel-settings.js +750 -0
- package/dist/ui/preview.js +266 -0
- package/dist/ui/settings-apply.js +239 -0
- package/dist/ui/settings-confirm.js +186 -0
- package/dist/ui/settings-dialogs.js +546 -0
- package/dist/ui/settings-model.js +277 -0
- package/dist/ui/settings-strings.js +652 -0
- package/dist/ui/settings-wizard.js +140 -0
- package/dist/ui/shared/agend-auth.js +93 -0
- package/dist/ui/shared/app-ctx.js +80 -0
- package/dist/ui/shared/app-html.js +9 -0
- package/dist/ui/shared/app-i18n.js +95 -0
- package/dist/ui/shared/app-nav.js +73 -0
- package/dist/ui/shared/app-needs.js +114 -0
- package/dist/ui/shared/app-route.js +73 -0
- package/dist/ui/shared/app-session.js +66 -0
- package/dist/ui/shared/app-shell.js +344 -0
- package/dist/ui/shared/app-store.js +51 -0
- package/dist/ui/shared/app-stream.js +201 -0
- package/dist/ui/shared/app.css +676 -0
- package/dist/ui/shared/app.js +88 -0
- package/dist/ui/shared/fonts/OFL.txt +92 -0
- package/dist/ui/shared/fonts/README.md +21 -0
- package/dist/ui/shared/fonts/inter.woff2 +0 -0
- package/dist/ui/shared/htm.module.js +1 -0
- package/dist/ui/shared/panel-view.js +655 -0
- package/dist/ui/shared/preact-hooks.module.js +2 -0
- package/dist/ui/shared/preact.module.js +2 -0
- package/dist/ui/shared/signin.js +130 -0
- package/dist/ui/shared/theme.js +29 -0
- package/dist/ui/shared/tokens.css +153 -0
- package/dist/ui/shared/ui-confirm.js +43 -0
- package/dist/ui/shared/ui-dialog.js +45 -0
- package/dist/ui/shared/ui-icons.js +61 -0
- package/dist/ui/shared/ui-menu.js +39 -0
- package/dist/ui/shared/ui-states.js +20 -0
- package/dist/ui/shared/ui-toast.js +18 -0
- package/dist/ui/shared/vendor/htm.LICENSE +202 -0
- package/dist/ui/shared/vendor/preact.LICENSE +21 -0
- package/dist/ui/shared/vendor/vendor.json +39 -0
- package/dist/ui/shared/view-strings.js +309 -0
- package/dist/ui/signin.html +51 -0
- package/dist/update-check.d.ts +6 -2
- package/dist/update-check.js +12 -3
- package/dist/update-check.js.map +1 -1
- package/dist/update-install.d.ts +71 -0
- package/dist/update-install.js +162 -0
- package/dist/update-install.js.map +1 -0
- package/dist/upgrade-notices.d.ts +17 -0
- package/dist/upgrade-notices.js +93 -0
- package/dist/upgrade-notices.js.map +1 -0
- package/dist/usage/statusline-usage.d.ts +2 -0
- package/dist/usage/statusline-usage.js +14 -10
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/view-api.d.ts +6 -1
- package/dist/view-api.js +62 -51
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +71 -14
- package/dist/web-api.js +492 -77
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +71 -27
- package/dist/web-auth.js +169 -69
- package/dist/web-auth.js.map +1 -1
- package/dist/web-body.d.ts +3 -0
- package/dist/web-body.js +55 -0
- package/dist/web-body.js.map +1 -0
- package/dist/web-channel-echo.d.ts +9 -0
- package/dist/web-channel-echo.js +55 -0
- package/dist/web-channel-echo.js.map +1 -0
- package/dist/web-chat-history.d.ts +125 -0
- package/dist/web-chat-history.js +184 -0
- package/dist/web-chat-history.js.map +1 -0
- package/dist/web-continuation.d.ts +7 -0
- package/dist/web-continuation.js +22 -0
- package/dist/web-continuation.js.map +1 -0
- package/dist/web-host-guard.d.ts +34 -0
- package/dist/web-host-guard.js +57 -1
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-login.d.ts +72 -0
- package/dist/web-login.js +132 -0
- package/dist/web-login.js.map +1 -0
- package/dist/web-org.d.ts +55 -0
- package/dist/web-org.js +67 -0
- package/dist/web-org.js.map +1 -0
- package/dist/web-preview.d.ts +77 -0
- package/dist/web-preview.js +240 -0
- package/dist/web-preview.js.map +1 -0
- package/dist/web-request-context.d.ts +10 -0
- package/dist/web-request-context.js +13 -0
- package/dist/web-request-context.js.map +1 -0
- package/dist/web-session.d.ts +167 -0
- package/dist/web-session.js +389 -0
- package/dist/web-session.js.map +1 -0
- package/dist/web-shell-routes.d.ts +59 -0
- package/dist/web-shell-routes.js +96 -0
- package/dist/web-shell-routes.js.map +1 -0
- package/dist/web-upload.d.ts +183 -0
- package/dist/web-upload.js +468 -0
- package/dist/web-upload.js.map +1 -0
- package/dist/website-tips-renderer.d.ts +2 -2
- package/dist/website-tips-renderer.js +3 -3
- package/dist/website-tips-renderer.js.map +1 -1
- package/package.json +8 -5
- package/scripts/preinstall-guard.cjs +36 -0
- package/dist/ui/dashboard.html +0 -774
- package/dist/ui/settings.html +0 -2165
- package/dist/ui/view.html +0 -1139
package/dist/fleet-manager.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import { SettingsBaselines } from "./settings-baseline.js";
|
|
2
|
+
import { SettingsConfirmationStore } from "./settings-confirmation.js";
|
|
3
|
+
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
4
|
+
import { SettingsControlServer } from "./settings-control.js";
|
|
5
|
+
import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
7
|
+
import { gatewayRequestContext } from "./web-request-context.js";
|
|
8
|
+
import { createPublicWebGateway } from "./public-web-gateway.js";
|
|
9
|
+
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
10
|
+
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
11
|
+
import { ManagedTunnel } from "./tunnel/manager.js";
|
|
12
|
+
import { withinBudget } from "./monotonic-budget.js";
|
|
1
13
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
2
14
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
3
15
|
import { ProfileControlServer } from "./profile-control.js";
|
|
4
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
16
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
|
|
5
17
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
6
18
|
import { spawnSync } from "node:child_process";
|
|
7
19
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
@@ -34,6 +46,7 @@ import { TmuxManager } from "./tmux-manager.js";
|
|
|
34
46
|
import { AccessManager } from "./channel/access-manager.js";
|
|
35
47
|
import { IpcClient } from "./channel/ipc-bridge.js";
|
|
36
48
|
import { createAdapter } from "./channel/factory.js";
|
|
49
|
+
import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
|
|
37
50
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
38
51
|
import { createBackend } from "./backend/factory.js";
|
|
39
52
|
import { readEffortMetadata } from "./backend/effort-metadata.js";
|
|
@@ -42,6 +55,7 @@ import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
|
42
55
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
43
56
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
44
57
|
import { Scheduler } from "./scheduler/index.js";
|
|
58
|
+
import { escapeTelegramHtml, scheduleClock, scheduleRetryLabel } from "./scheduler/retry-label.js";
|
|
45
59
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
46
60
|
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
|
|
47
61
|
import { DailySummary } from "./daily-summary.js";
|
|
@@ -104,14 +118,16 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
104
118
|
}
|
|
105
119
|
return tasks;
|
|
106
120
|
}
|
|
107
|
-
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
121
|
+
import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
122
|
+
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
|
|
123
|
+
import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
|
|
108
124
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
109
125
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
110
126
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
111
127
|
import { LoginSession } from "./login-manager.js";
|
|
112
128
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
113
129
|
import { decideSlash } from "./slash-authz.js";
|
|
114
|
-
import { commandSpec, decideCommand } from "./command-table.js";
|
|
130
|
+
import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
115
131
|
import { runVisibilityCommand } from "./cross-instance-notice.js";
|
|
116
132
|
import { UPDATE_COMMAND, installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
117
133
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
@@ -126,7 +142,7 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
|
|
|
126
142
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
127
143
|
import { validateFleetConfig } from "./config-validator.js";
|
|
128
144
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
129
|
-
import { presentationState, interactionSummary } from "./interaction-observation.js";
|
|
145
|
+
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
130
146
|
import { readLastInboundAt } from "./daemon.js";
|
|
131
147
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
132
148
|
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
@@ -136,10 +152,18 @@ import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLoc
|
|
|
136
152
|
import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
137
153
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
138
154
|
import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
|
|
155
|
+
import { NeedsYouHub } from "./needs-you-hub.js";
|
|
156
|
+
import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
|
|
139
157
|
import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
|
|
158
|
+
import { buildOrgChart } from "./web-org.js";
|
|
140
159
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
141
|
-
import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
160
|
+
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
161
|
+
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
162
|
+
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
163
|
+
import { tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
164
|
+
import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
142
165
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
166
|
+
import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
|
|
143
167
|
import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
|
|
144
168
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
145
169
|
import { SecretStore } from "./secret-store.js";
|
|
@@ -154,6 +178,7 @@ import { mergeBackendOptions, profileKey, profileName, profileOf } from "./class
|
|
|
154
178
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
155
179
|
import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
|
|
156
180
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
181
|
+
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
157
182
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
158
183
|
import { StormWindow } from "./storm-window.js";
|
|
159
184
|
import { SpawnGate } from "./spawn-gate.js";
|
|
@@ -289,8 +314,42 @@ const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
|
289
314
|
const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
290
315
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
291
316
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
317
|
+
/** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
|
|
318
|
+
const NEEDS_PAUSE_TTL_MS = 10_000;
|
|
292
319
|
const HANG_CALLBACK_PREFIX = "hang:";
|
|
293
320
|
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
321
|
+
/**
|
|
322
|
+
* The prompts the web dashboard also offers: the ones about an instance's own health, which a dashboard
|
|
323
|
+
* user — holding the full-fleet web credential — may answer exactly as a fleet admin on the platform may.
|
|
324
|
+
* Personal or channel-bound prompts stay where they were asked: a /clear confirmation, login, a Classic
|
|
325
|
+
* group's approval, tips, and the per-user /model and /effort menus (which are not nonce entries at all).
|
|
326
|
+
*/
|
|
327
|
+
const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
328
|
+
HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
329
|
+
]);
|
|
330
|
+
/**
|
|
331
|
+
* Where a reply goes on a fleet with no chat platform (web track C4): it is "sent" by being shown in the
|
|
332
|
+
* web chat, which afterReplyRouted does for every reply. Only what routeToolCall's reply path calls exists
|
|
333
|
+
* here; the path checks (assertSendable, the file count) are the reply tool's own, run before these.
|
|
334
|
+
*/
|
|
335
|
+
const WEB_ONLY_REPLY_SINK = {
|
|
336
|
+
type: "web",
|
|
337
|
+
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
338
|
+
sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* Where an instance-health prompt (hang, clean exit, interactive prompt) is posted on a fleet with no chat platform:
|
|
342
|
+
* nowhere but the dashboard (#1307 item 6). Posting "succeeds" with an id of its own, so the prompt is armed and
|
|
343
|
+
* offered on the web exactly as a platform prompt is; there are no platform buttons to edit afterwards, and the
|
|
344
|
+
* outcome reaches the page through prompt_resolved as for any web-answered prompt.
|
|
345
|
+
*/
|
|
346
|
+
const WEB_ONLY_PROMPT_SINK = {
|
|
347
|
+
type: "web",
|
|
348
|
+
notifyAlert: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
349
|
+
editMessage: async () => { },
|
|
350
|
+
editMessageRemoveButtons: async () => { },
|
|
351
|
+
sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
|
|
352
|
+
};
|
|
294
353
|
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
295
354
|
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
296
355
|
export const LOGIN_CALLBACK_PREFIX = "login:";
|
|
@@ -517,6 +576,14 @@ export class FleetManager {
|
|
|
517
576
|
instanceProgress = new Map();
|
|
518
577
|
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
519
578
|
ipcWaitTails = new Map();
|
|
579
|
+
webChannelEchoTails = new Map();
|
|
580
|
+
/**
|
|
581
|
+
* instanceName → the reservation whose echo callback is currently running.
|
|
582
|
+
* sendClassicWebEcho fences every per-entry copy against it: no new copy
|
|
583
|
+
* starts after the ordering budget is gone or the delivery epoch is revoked.
|
|
584
|
+
* Serialized per instance by the echo tail, so one slot is enough.
|
|
585
|
+
*/
|
|
586
|
+
webChannelEchoGuards = new Map();
|
|
520
587
|
/** instanceName → restart currently executing; concurrent callers join it. */
|
|
521
588
|
restartsInFlight = new Map();
|
|
522
589
|
/**
|
|
@@ -556,6 +623,20 @@ export class FleetManager {
|
|
|
556
623
|
pendingModelSelects = new Map();
|
|
557
624
|
/** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
|
|
558
625
|
pendingNonceButtons = new Map();
|
|
626
|
+
/** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
|
|
627
|
+
needsYou = null;
|
|
628
|
+
/** When the fleet first saw an instance crashed, for its "Needs you" age; cleared when it is not. */
|
|
629
|
+
needsCrashedAt = new Map();
|
|
630
|
+
/** Pause reason/time read from the marker, per paused instance; dropped on any attention change of it, and at most
|
|
631
|
+
* NEEDS_PAUSE_TTL_MS old — so a recompute does not re-read the file every time (#1386 §4.3). */
|
|
632
|
+
needsPauseCache = new Map();
|
|
633
|
+
/**
|
|
634
|
+
* Clicks that came from the web dashboard (clickWebPrompt). Only that method adds to it, so nothing an
|
|
635
|
+
* adapter emits — whatever fields its payload carries — can claim a dashboard click's authority.
|
|
636
|
+
*/
|
|
637
|
+
webPromptClicks = new WeakSet();
|
|
638
|
+
/** The web clicks consumeNonceCallback actually claimed (the others were refused or lost a race). */
|
|
639
|
+
webPromptClaims = new WeakSet();
|
|
559
640
|
// Model failover state
|
|
560
641
|
failoverActive = new Map(); // instance → current failover model
|
|
561
642
|
// IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
|
|
@@ -609,6 +690,12 @@ export class FleetManager {
|
|
|
609
690
|
collabInstances = new Set();
|
|
610
691
|
// Health endpoint
|
|
611
692
|
healthServer = null;
|
|
693
|
+
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
694
|
+
previewListener = null;
|
|
695
|
+
previewListening = false;
|
|
696
|
+
/** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
|
|
697
|
+
previewPorts = null;
|
|
698
|
+
previewInputs = "";
|
|
612
699
|
healthPortRetried = false;
|
|
613
700
|
updateCheckTimer = null;
|
|
614
701
|
updateProgressTimer = null;
|
|
@@ -641,12 +728,228 @@ export class FleetManager {
|
|
|
641
728
|
mirrorTimer = null;
|
|
642
729
|
// Web UI: SSE clients + auth token
|
|
643
730
|
sseClients = new Set();
|
|
731
|
+
/** The web chat's recent messages: what `/ui/history` serves and what a reconnecting SSE stream is sent. */
|
|
732
|
+
webChatHistory = new WebChatHistory();
|
|
733
|
+
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
734
|
+
webFiles = new WebFileLedger();
|
|
644
735
|
/**
|
|
645
736
|
* Read from disk on every access rather than cached at startup: `agend
|
|
646
737
|
* web-token rotate` runs in a separate process, and a cached copy would keep
|
|
647
738
|
* authorizing revoked links and cookies until the fleet restarted.
|
|
648
739
|
*/
|
|
740
|
+
settingsGeneration = 0;
|
|
741
|
+
settingsRuntimeRevision = 0;
|
|
742
|
+
settingsConfirmation = null;
|
|
743
|
+
settingsControl = null;
|
|
744
|
+
settingsJobSettlements = new Map();
|
|
745
|
+
classicSettingsOwners = new WeakMap();
|
|
746
|
+
queueSettingsOperation(job, resources, execution, run) {
|
|
747
|
+
const generation = this.settingsGeneration;
|
|
748
|
+
const owned = execution ?? new SettingsExecution({ current: () => !this.shuttingDown && this.settingsGeneration === generation,
|
|
749
|
+
snapshot: () => [this.settingsRuntimeRevision, this.fleetConfig, settingsRevision(join(this.dataDir, ".env"))] });
|
|
750
|
+
const done = Promise.resolve().then(async () => {
|
|
751
|
+
let lease = null, admitted = false;
|
|
752
|
+
try {
|
|
753
|
+
lease = await waitSettingsLease(resources, () => owned.current(), performance.now() + owned.remainingMs, owned.owner);
|
|
754
|
+
owned.assert();
|
|
755
|
+
admitted = true;
|
|
756
|
+
await run(owned);
|
|
757
|
+
if (["applied", "applied_next_use", "reloaded", "restart_required"].includes(job.result) && !owned.completed)
|
|
758
|
+
throw new Error("missing_settings_receipt");
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
job.status = "done";
|
|
762
|
+
job.finishedAt = Date.now();
|
|
763
|
+
job.result = admitted ? "rollback_failed" : "rolled_back";
|
|
764
|
+
job.error = admitted ? "operation did not provide a committed receipt; operator attention required" : "operation was not admitted";
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
lease?.release();
|
|
768
|
+
if (!execution)
|
|
769
|
+
owned.close();
|
|
770
|
+
if ("envKey" in job) {
|
|
771
|
+
if (this.providerSecretInFlight.get(job.envKey) === job.id)
|
|
772
|
+
this.providerSecretInFlight.delete(job.envKey);
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
if (this.connectionSecretInFlight.get(job.connectionId) === job.id)
|
|
776
|
+
this.connectionSecretInFlight.delete(job.connectionId);
|
|
777
|
+
if (this.connectionBindingInFlight.get(job.connectionId) === job.id)
|
|
778
|
+
this.connectionBindingInFlight.delete(job.connectionId);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
this.settingsJobSettlements ??= new Map();
|
|
783
|
+
this.settingsJobSettlements.set(job.id, done);
|
|
784
|
+
while (this.settingsJobSettlements.size > 128) {
|
|
785
|
+
const first = this.settingsJobSettlements.keys().next().value;
|
|
786
|
+
const record = this.providerSecretJobs.get(first) ?? this.connectionSecretJobs.get(first) ?? this.connectionBindingJobs.get(first);
|
|
787
|
+
if (record?.status !== "done")
|
|
788
|
+
break;
|
|
789
|
+
this.settingsJobSettlements.delete(first);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
settingsGate() {
|
|
793
|
+
if (this.settingsConfirmation)
|
|
794
|
+
return this.settingsConfirmation;
|
|
795
|
+
const generation = this.settingsGeneration;
|
|
796
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation;
|
|
797
|
+
const baselines = new SettingsBaselines({ dataDir: this.dataDir, configPath: () => this.configPath,
|
|
798
|
+
config: () => this.fleetConfig, current: () => [this.settingsGeneration, this.settingsRuntimeRevision, this.shuttingDown],
|
|
799
|
+
proof: (kind, target, id, binding, key) => {
|
|
800
|
+
const map = kind === "provider" ? this.providerSecretChallenges : kind === "binding" ? this.connectionBindingChallenges : this.connectionSecretChallenges;
|
|
801
|
+
const proof = map.get(id);
|
|
802
|
+
if (!proof || proof.sessionBinding !== binding || proof.idempotencyKey !== key
|
|
803
|
+
|| ("specId" in proof ? proof.specId : proof.connectionId) !== target
|
|
804
|
+
|| proof.generation !== ("envKey" in proof ? this.providerSecretGeneration(proof.envKey) : this.secureConnectionGeneration(target)))
|
|
805
|
+
return null;
|
|
806
|
+
const remainingMs = proof.deadline === undefined ? proof.expiresAt - Date.now() : proof.deadline - performance.now();
|
|
807
|
+
if (remainingMs <= 0)
|
|
808
|
+
return null;
|
|
809
|
+
const envKey = "envKey" in proof ? proof.envKey : this.secureConnectionChannel(target)?.bot_token_env ?? "";
|
|
810
|
+
return { key: envKey, secret: "secret" in proof ? proof.secret : "", previous: process.env[envKey], remainingMs,
|
|
811
|
+
...("binding" in proof ? { binding: proof.binding } : {}),
|
|
812
|
+
discard: () => { if (map.get(id) === proof)
|
|
813
|
+
map.delete(id); if ("secret" in proof)
|
|
814
|
+
proof.secret = ""; } };
|
|
815
|
+
} });
|
|
816
|
+
const store = new SettingsConfirmationStore({
|
|
817
|
+
audit: (event, fields) => this.logger.info({ event: `settings_confirmation.${event}`, ...fields }, "Settings confirmation audit"),
|
|
818
|
+
notify: view => view.state === "pending" ? this.promptSettingsChange(store, view) : Promise.resolve(),
|
|
819
|
+
});
|
|
820
|
+
const gate = new SettingsHttpConfirmation(store, {
|
|
821
|
+
principal: req => {
|
|
822
|
+
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
823
|
+
if (auth.kind !== "ok" || !current())
|
|
824
|
+
return null;
|
|
825
|
+
const record = auth.session, sessions = this.webSessions, exposure = gatewayRequestContext(req);
|
|
826
|
+
return { id: record.idHash, label: record.label, source: exposure ? "public_link" : "web_session",
|
|
827
|
+
current: () => current() && sessions === this.webSessions && !!this.webToken
|
|
828
|
+
&& sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
|
|
829
|
+
}, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
|
|
830
|
+
job: async (id) => {
|
|
831
|
+
const settlement = this.settingsJobSettlements.get(id);
|
|
832
|
+
if (!settlement)
|
|
833
|
+
return false;
|
|
834
|
+
await settlement;
|
|
835
|
+
const job = this.providerSecretJobs.get(id) ?? this.connectionSecretJobs.get(id) ?? this.connectionBindingJobs.get(id);
|
|
836
|
+
return !!job && ["applied", "applied_next_use", "reloaded"].includes(job.result);
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
this.settingsConfirmation = gate;
|
|
840
|
+
return gate;
|
|
841
|
+
}
|
|
842
|
+
async promptSettingsChange(store, view) {
|
|
843
|
+
const generation = this.settingsGeneration;
|
|
844
|
+
const excluded = new Set(store.affectedConnections(view.id));
|
|
845
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
846
|
+
if (!config.general_topic)
|
|
847
|
+
continue;
|
|
848
|
+
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
849
|
+
const general = this.daemons.get(name);
|
|
850
|
+
if (!adapterId || !adapter || !general || excluded.has(adapterId) || !this.hasFleetAdmins(adapterId))
|
|
851
|
+
continue;
|
|
852
|
+
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
853
|
+
if (!chatId || !topic)
|
|
854
|
+
continue;
|
|
855
|
+
let retired = false, attached = false;
|
|
856
|
+
const postingDeadline = performance.now() + Math.min(5000, view.remaining_ms);
|
|
857
|
+
const current = () => !retired && (attached || performance.now() < postingDeadline) && !this.shuttingDown && this.settingsGeneration === generation
|
|
858
|
+
&& this.fleetConfig?.instances[name]?.general_topic === true
|
|
859
|
+
&& this.daemons.get(name) === general && !this.ipcStoppingInstances.has(name)
|
|
860
|
+
&& this.adapterState.get(adapterId)?.status === "connected"
|
|
861
|
+
&& this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
|
|
862
|
+
&& this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
|
|
863
|
+
&& this.hasFleetAdmins(adapterId);
|
|
864
|
+
if (!current())
|
|
865
|
+
continue;
|
|
866
|
+
try {
|
|
867
|
+
const posted = this.postNonceButtonPromptOrThrow({ prefix: "settings-confirm:", alertType: "clear_confirm",
|
|
868
|
+
instanceName: name, adapterId, adapter, chatId, threadId: topic,
|
|
869
|
+
message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
|
|
870
|
+
choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
|
|
871
|
+
expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
|
|
872
|
+
extra: { pendingChangeId: view.id, confirmationCurrent: current },
|
|
873
|
+
});
|
|
874
|
+
void posted.then(nonce => {
|
|
875
|
+
if (!retired)
|
|
876
|
+
return;
|
|
877
|
+
const entry = this.pendingNonceButtons.get(nonce);
|
|
878
|
+
if (entry?.pendingChangeId !== view.id)
|
|
879
|
+
return;
|
|
880
|
+
this.pendingNonceButtons.delete(nonce);
|
|
881
|
+
clearTimeout(entry.timer);
|
|
882
|
+
void this.retireNonceButtons(entry, entry.messageId ?? "", "Settings confirmation delivery expired.");
|
|
883
|
+
}, () => { });
|
|
884
|
+
const nonce = await withinBudget(posted, postingDeadline);
|
|
885
|
+
attached = true;
|
|
886
|
+
const retire = () => {
|
|
887
|
+
const entry = this.pendingNonceButtons.get(nonce);
|
|
888
|
+
if (!entry || entry.pendingChangeId !== view.id)
|
|
889
|
+
return;
|
|
890
|
+
this.pendingNonceButtons.delete(nonce);
|
|
891
|
+
clearTimeout(entry.timer);
|
|
892
|
+
void this.retireNonceButtons(entry, entry.messageId ?? "", "Settings confirmation is no longer pending.");
|
|
893
|
+
};
|
|
894
|
+
if (!current()) {
|
|
895
|
+
retire();
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (store.attachPrompt(view.id, retire))
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
retired = true;
|
|
903
|
+
this.logger.info({ id: view.id, adapterId }, "Settings confirmation prompt unavailable; trying another General or host CLI");
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
// No platform confirmation was reachable. Host inspection is still required.
|
|
907
|
+
this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
|
|
908
|
+
}
|
|
909
|
+
async handleSettingsChangeCallback(data, adapterId, adapter) {
|
|
910
|
+
const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter);
|
|
911
|
+
if (!result)
|
|
912
|
+
return false;
|
|
913
|
+
if (result === "consumed")
|
|
914
|
+
return true;
|
|
915
|
+
const { entry, action } = result;
|
|
916
|
+
try {
|
|
917
|
+
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
918
|
+
label: `fleet admin ${data.userId}`,
|
|
919
|
+
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
920
|
+
&& !!data.userId && this.isFleetAdmin(data.userId, entry.adapterId),
|
|
921
|
+
});
|
|
922
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, "Settings confirmation rejected or expired.");
|
|
926
|
+
}
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
async startSettingsConfirmationControl() {
|
|
930
|
+
if (this.shuttingDown || this.settingsControl)
|
|
931
|
+
return;
|
|
932
|
+
const generation = this.settingsGeneration, control = new SettingsControlServer(this.dataDir, this.settingsGate().store, () => !this.shuttingDown && this.settingsGeneration === generation);
|
|
933
|
+
this.settingsControl = control;
|
|
934
|
+
try {
|
|
935
|
+
await control.listen();
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
if (this.settingsControl === control)
|
|
939
|
+
this.settingsControl = null;
|
|
940
|
+
throw err;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
649
943
|
get webToken() { return readWebToken(this.dataDir); }
|
|
944
|
+
/**
|
|
945
|
+
* Server-side web sessions (see web-session.ts). Created with the token, not per
|
|
946
|
+
* request: they are persisted, and a restart must find them again.
|
|
947
|
+
*/
|
|
948
|
+
webSessions = null;
|
|
949
|
+
/** Heartbeat of the dashboard's SSE stream; public so a test can shorten it. */
|
|
950
|
+
sseHeartbeatMs = SSE_HEARTBEAT_MS;
|
|
951
|
+
/** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
|
|
952
|
+
webLoginCodes = null;
|
|
650
953
|
/**
|
|
651
954
|
* Set while a Settings apply job is driving the reconcile. The reconcile
|
|
652
955
|
* stays the single doer; it just says out loud what it is doing to whom, so
|
|
@@ -664,7 +967,6 @@ export class FleetManager {
|
|
|
664
967
|
/** Set when the file on disk and the in-memory config disagree on a
|
|
665
968
|
* startup-only key at startup. See checkStartupSignatureConsistency(). */
|
|
666
969
|
fleetSignatureMismatch = null;
|
|
667
|
-
viewToken = null;
|
|
668
970
|
healthServerListening = false;
|
|
669
971
|
constructor(dataDir) {
|
|
670
972
|
this.dataDir = dataDir;
|
|
@@ -674,7 +976,8 @@ export class FleetManager {
|
|
|
674
976
|
FleetManager.sighupHandlerInstalled = true;
|
|
675
977
|
}
|
|
676
978
|
this.stormWindow = new StormWindow();
|
|
677
|
-
this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot)
|
|
979
|
+
this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot),
|
|
980
|
+
onDarwinUnknown: memory => this.logger.info({ pressureRaw: memory?.darwinPressureRaw ?? null }, "macOS kernel memory pressure unknown — no notice or admission restriction") });
|
|
678
981
|
this.spawnGate = new SpawnGate({
|
|
679
982
|
storm: this.stormWindow,
|
|
680
983
|
memoryPressure: this.memoryPressure,
|
|
@@ -706,6 +1009,9 @@ export class FleetManager {
|
|
|
706
1009
|
if (target)
|
|
707
1010
|
this.wakeCoordinator?.noteDelivered(target);
|
|
708
1011
|
}
|
|
1012
|
+
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain" || event.state === "delivered")) {
|
|
1013
|
+
this.needsYou?.poke();
|
|
1014
|
+
}
|
|
709
1015
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
710
1016
|
const row = outbox.get(event.deliveryId);
|
|
711
1017
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
@@ -716,7 +1022,7 @@ export class FleetManager {
|
|
|
716
1022
|
}
|
|
717
1023
|
});
|
|
718
1024
|
outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
|
|
719
|
-
this.replyObligationTimer = setInterval(() => this.sweepReplyObligations(), REPLY_OBLIGATION_SWEEP_MS);
|
|
1025
|
+
this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
|
|
720
1026
|
this.replyObligationTimer.unref?.();
|
|
721
1027
|
// #856: the text the target daemon received differs from what was
|
|
722
1028
|
// admitted. Transport has never been seen to do this; a warning and the
|
|
@@ -728,6 +1034,8 @@ export class FleetManager {
|
|
|
728
1034
|
this.wakeCoordinator.start();
|
|
729
1035
|
outbox.on("expired", (event) => {
|
|
730
1036
|
this.scheduleDeliveryOutboxPump();
|
|
1037
|
+
if (event.count)
|
|
1038
|
+
this.needsYou?.poke(); // #1386: rows that expired failed — they wait on someone now
|
|
731
1039
|
this.wakeCoordinator?.kick();
|
|
732
1040
|
if (event.count)
|
|
733
1041
|
this.notifyFleetError(t("delivery.expired", event.count, event.uncertain ?? 0));
|
|
@@ -834,7 +1142,8 @@ export class FleetManager {
|
|
|
834
1142
|
}
|
|
835
1143
|
this.deliveryPumpTimer = setTimeout(() => {
|
|
836
1144
|
this.deliveryPumpTimer = null;
|
|
837
|
-
|
|
1145
|
+
// The pump's body never awaits: the whole run is one synchronous stretch (#1235 attribution).
|
|
1146
|
+
void measureSyncWork("fleet.deliveryPump", () => this.runDeliveryOutboxPump());
|
|
838
1147
|
}, Math.max(0, delayMs));
|
|
839
1148
|
this.deliveryPumpTimer.unref?.();
|
|
840
1149
|
}
|
|
@@ -1068,9 +1377,7 @@ export class FleetManager {
|
|
|
1068
1377
|
}
|
|
1069
1378
|
const changed = snapshot.level !== this.memoryLogLevel;
|
|
1070
1379
|
if (this.memoryPressure.advisoryOnly()) {
|
|
1071
|
-
//
|
|
1072
|
-
if (changed)
|
|
1073
|
-
this.logger.info({ hostMemory: snapshot }, "Host memory sample (macOS: logged only — no notice, no spawn throttling)");
|
|
1380
|
+
// The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
|
|
1074
1381
|
this.memoryLogLevel = snapshot.level;
|
|
1075
1382
|
return;
|
|
1076
1383
|
}
|
|
@@ -1095,7 +1402,10 @@ export class FleetManager {
|
|
|
1095
1402
|
if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
|
|
1096
1403
|
return;
|
|
1097
1404
|
const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
|
|
1098
|
-
const
|
|
1405
|
+
const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
|
|
1406
|
+
const text = this.memoryPressure.platform === "darwin"
|
|
1407
|
+
? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
|
|
1408
|
+
: t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
|
|
1099
1409
|
if (this.notifyFleetError(text, { throttle: false })) {
|
|
1100
1410
|
this.memoryNoticeAt = now;
|
|
1101
1411
|
this.memoryNoticeLevel = snapshot.level;
|
|
@@ -1218,6 +1528,7 @@ export class FleetManager {
|
|
|
1218
1528
|
}
|
|
1219
1529
|
finishStartup() {
|
|
1220
1530
|
this.startupComplete = true;
|
|
1531
|
+
this.startNeedsYou();
|
|
1221
1532
|
// Resolve whatever a previous run — or a setup host that crashed — left
|
|
1222
1533
|
// behind. A tunnel nobody is tracking is a public entrance nobody is
|
|
1223
1534
|
// watching, and the fleet starting is the moment there is finally a process
|
|
@@ -1381,6 +1692,7 @@ export class FleetManager {
|
|
|
1381
1692
|
const loaded = loadFleetConfig(configPath);
|
|
1382
1693
|
this.assertProviderSecretEnvKeys(loaded);
|
|
1383
1694
|
this.rawFleetConfig = raw;
|
|
1695
|
+
this.settingsRuntimeRevision++;
|
|
1384
1696
|
this.fleetConfig = loaded;
|
|
1385
1697
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
1386
1698
|
this.warnAboutRemovedLoginMode(loaded);
|
|
@@ -1542,6 +1854,11 @@ export class FleetManager {
|
|
|
1542
1854
|
await data.respond(t("instance.not_found", requested));
|
|
1543
1855
|
return;
|
|
1544
1856
|
}
|
|
1857
|
+
// #754 audit: a General speaks for its own bot's instances only.
|
|
1858
|
+
if (this.getInstanceAdapterId(requested) !== adapterId) {
|
|
1859
|
+
await data.respond(t("instance.other_bot", requested));
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1545
1862
|
target = requested;
|
|
1546
1863
|
}
|
|
1547
1864
|
await data.respond(await this.topicCommands.runPauseWake(target, action));
|
|
@@ -2063,6 +2380,9 @@ export class FleetManager {
|
|
|
2063
2380
|
// asks "is anyone still reporting", which only the receiver can date.
|
|
2064
2381
|
receivedAt: now,
|
|
2065
2382
|
});
|
|
2383
|
+
// The dashboard's "working" line and its Stop button follow the edges, not the heartbeat.
|
|
2384
|
+
if (previous?.state !== state)
|
|
2385
|
+
this.emitSseEvent("activity", { instance: name, state: this.getInstanceExecutionState(name) });
|
|
2066
2386
|
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
2067
2387
|
check();
|
|
2068
2388
|
// warm_cap: a fresh transition into idle may free this instance for eviction,
|
|
@@ -2105,6 +2425,16 @@ export class FleetManager {
|
|
|
2105
2425
|
this.cancelButtonIdleRetireTimers.set(name, timer);
|
|
2106
2426
|
}
|
|
2107
2427
|
cacheInstanceProcessStatus(name, status) {
|
|
2428
|
+
// #1386: every transition into crashed is a new occurrence (a new "Needs you" item), and every other status ends
|
|
2429
|
+
// the previous one — at once, not when the coalesced collector next runs: running then crashed in one IPC chunk is
|
|
2430
|
+
// a second crash (#1398 review r2). The collector still recovers a missed running event.
|
|
2431
|
+
if (status === "crashed") {
|
|
2432
|
+
if (this.instanceProcessStatus.get(name) !== "crashed")
|
|
2433
|
+
this.needsCrashedAt.set(name, Date.now());
|
|
2434
|
+
}
|
|
2435
|
+
else
|
|
2436
|
+
this.needsCrashedAt.delete(name);
|
|
2437
|
+
this.needsYou?.poke();
|
|
2108
2438
|
if (status === "running") {
|
|
2109
2439
|
this.instanceProcessStatus.delete(name);
|
|
2110
2440
|
// A prior crash-loop marker is one-shot. Successful respawn is the
|
|
@@ -2122,7 +2452,8 @@ export class FleetManager {
|
|
|
2122
2452
|
this.instanceProcessStatus.set(name, status);
|
|
2123
2453
|
// Never display the last ready prompt as current execution state after its
|
|
2124
2454
|
// owning CLI process has exited.
|
|
2125
|
-
this.instanceStateCache.delete(name)
|
|
2455
|
+
if (this.instanceStateCache.delete(name))
|
|
2456
|
+
this.emitSseEvent("activity", { instance: name, state: null });
|
|
2126
2457
|
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
2127
2458
|
check();
|
|
2128
2459
|
}
|
|
@@ -2251,11 +2582,11 @@ export class FleetManager {
|
|
|
2251
2582
|
finish(false);
|
|
2252
2583
|
});
|
|
2253
2584
|
}
|
|
2254
|
-
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
|
|
2255
|
-
if (this.shuttingDown || !this.
|
|
2585
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false, stillCurrent) {
|
|
2586
|
+
if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2256
2587
|
return false;
|
|
2257
2588
|
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
2258
|
-
if (this.shuttingDown || !this.
|
|
2589
|
+
if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2259
2590
|
return false;
|
|
2260
2591
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
2261
2592
|
if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
|
|
@@ -2267,7 +2598,7 @@ export class FleetManager {
|
|
|
2267
2598
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
2268
2599
|
const wakeStartedAt = Date.now();
|
|
2269
2600
|
await this.explicitWake(instanceName, 30_000);
|
|
2270
|
-
if (!this.
|
|
2601
|
+
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2271
2602
|
return false;
|
|
2272
2603
|
// Waking added one to the warm count — make room by evicting a different
|
|
2273
2604
|
// LRU idle instance (never this one; it's about to work).
|
|
@@ -2275,23 +2606,23 @@ export class FleetManager {
|
|
|
2275
2606
|
// Never satisfy a post-wake gate from a stale pre-pause cache entry.
|
|
2276
2607
|
idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
|
|
2277
2608
|
}
|
|
2278
|
-
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.
|
|
2279
|
-
if (!this.
|
|
2609
|
+
const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent));
|
|
2610
|
+
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent)) {
|
|
2280
2611
|
this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
|
|
2281
2612
|
return false;
|
|
2282
2613
|
}
|
|
2283
2614
|
// A server crash can land while waitForInstanceIdle is pending. Re-check
|
|
2284
2615
|
// immediately before the old timeout path would force text into a boot UI.
|
|
2285
2616
|
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
2286
|
-
if (this.shuttingDown || !this.
|
|
2617
|
+
if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2287
2618
|
return false;
|
|
2288
2619
|
if (!idle) {
|
|
2289
2620
|
this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
|
|
2290
2621
|
}
|
|
2291
|
-
const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
|
|
2622
|
+
const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
|
|
2292
2623
|
if (!sent)
|
|
2293
2624
|
return false;
|
|
2294
|
-
if (this.
|
|
2625
|
+
if (this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent)) {
|
|
2295
2626
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
2296
2627
|
}
|
|
2297
2628
|
return true;
|
|
@@ -2318,8 +2649,8 @@ export class FleetManager {
|
|
|
2318
2649
|
* instance, *including* when the socket happens to be up: otherwise a message
|
|
2319
2650
|
* arriving after the reconnect could overtake one that has been waiting for it.
|
|
2320
2651
|
*/
|
|
2321
|
-
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
2322
|
-
if (!this.
|
|
2652
|
+
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
|
|
2653
|
+
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2323
2654
|
return false;
|
|
2324
2655
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
2325
2656
|
if (!queued) {
|
|
@@ -2330,9 +2661,9 @@ export class FleetManager {
|
|
|
2330
2661
|
const attempt = (queued ?? Promise.resolve())
|
|
2331
2662
|
.catch(() => { })
|
|
2332
2663
|
.then(() => {
|
|
2333
|
-
if (!this.
|
|
2664
|
+
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2334
2665
|
return false;
|
|
2335
|
-
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
|
|
2666
|
+
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch, stillCurrent);
|
|
2336
2667
|
});
|
|
2337
2668
|
// The chain stores a settled-either-way promise so one failed delivery cannot
|
|
2338
2669
|
// wedge every later one, and so `queued` above is safe to await unguarded.
|
|
@@ -2350,11 +2681,11 @@ export class FleetManager {
|
|
|
2350
2681
|
}
|
|
2351
2682
|
}
|
|
2352
2683
|
/** Poll for the instance's IPC to come back, then send. Throws if it does not. */
|
|
2353
|
-
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
|
|
2684
|
+
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
|
|
2354
2685
|
const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
|
|
2355
2686
|
let warned = false;
|
|
2356
2687
|
for (;;) {
|
|
2357
|
-
if (!this.
|
|
2688
|
+
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
2358
2689
|
return false;
|
|
2359
2690
|
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
2360
2691
|
// so a cached reference would stay dead forever.
|
|
@@ -2371,10 +2702,168 @@ export class FleetManager {
|
|
|
2371
2702
|
await new Promise(resolve => setTimeout(resolve, IPC_RECONNECT_POLL_MS));
|
|
2372
2703
|
}
|
|
2373
2704
|
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Reserve the display echo before IPC handoff: a fast reply cannot overtake
|
|
2707
|
+
* it. The web request settles admission without awaiting the platform POST.
|
|
2708
|
+
* Only accepted messages send an echo; failures release replies normally.
|
|
2709
|
+
* Each reservation has a 5s total monotonic ordering budget (queue/admission
|
|
2710
|
+
* included). Expiry drops unstarted copies and releases replies. An adapter
|
|
2711
|
+
* request already in flight may land late, but cannot retain the ordering lane.
|
|
2712
|
+
*/
|
|
2713
|
+
/**
|
|
2714
|
+
* Post an owner web-chat echo to every ClassicBot channel entry opted in
|
|
2715
|
+
* for `instance` (#1320 part B). Per entry: the channel's own adapter, no
|
|
2716
|
+
* thread, mention suppression on. Returns the number of channels posted
|
|
2717
|
+
* to. Failures are per-entry warn-and-continue — a failed echo never fails
|
|
2718
|
+
* the web send. Entries without `web_echo: true` are never touched.
|
|
2719
|
+
*
|
|
2720
|
+
* Every copy is fenced at start time: an entry whose explicit world is
|
|
2721
|
+
* gone is skipped (only legacy adapterId-less entries fall back to the
|
|
2722
|
+
* primary adapter); when running under a reservation, an exhausted
|
|
2723
|
+
* ordering budget or a revoked delivery epoch stops the loop, and each
|
|
2724
|
+
* entry is re-resolved against current registration so a removed opt-in
|
|
2725
|
+
* or a rebound adapter is never posted through a stale route. A copy
|
|
2726
|
+
* already in flight may still land late — the contract permits that.
|
|
2727
|
+
*/
|
|
2728
|
+
async sendClassicWebEcho(instance, text) {
|
|
2729
|
+
const guard = this.webChannelEchoGuards.get(instance);
|
|
2730
|
+
const handled = new Set();
|
|
2731
|
+
let posted = 0;
|
|
2732
|
+
for (;;) {
|
|
2733
|
+
if (guard) {
|
|
2734
|
+
if (performance.now() >= guard.deadlineAt) {
|
|
2735
|
+
this.logger.warn({ instance, posted }, "Classic web echo stopped: ordering budget exhausted");
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
if (!this.isDeliveryEpochCurrent(instance, guard.epoch)) {
|
|
2739
|
+
this.logger.warn({ instance, posted }, "Classic web echo stopped: delivery epoch revoked");
|
|
2740
|
+
break;
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
const entry = (this.classicChannels?.getAll() ?? [])
|
|
2744
|
+
.find(candidate => candidate.instanceName === instance && candidate.webEcho === true
|
|
2745
|
+
&& !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
|
|
2746
|
+
if (!entry)
|
|
2747
|
+
break;
|
|
2748
|
+
handled.add(`${entry.channelId}#${entry.adapterId ?? ""}`);
|
|
2749
|
+
const worldAdapter = entry.adapterId ? this.worlds.get(entry.adapterId)?.adapter : undefined;
|
|
2750
|
+
if (entry.adapterId && !worldAdapter) {
|
|
2751
|
+
this.logger.warn({ instance, channelId: entry.channelId, adapterId: entry.adapterId }, "Classic web echo skipped: adapter world unavailable");
|
|
2752
|
+
continue;
|
|
2753
|
+
}
|
|
2754
|
+
const adapter = worldAdapter ?? this.adapter;
|
|
2755
|
+
try {
|
|
2756
|
+
if (!adapter?.sendText)
|
|
2757
|
+
continue;
|
|
2758
|
+
await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
|
|
2759
|
+
ClassicChannelManager.logMessage(instance, "web-user", text, new Date());
|
|
2760
|
+
posted++;
|
|
2761
|
+
}
|
|
2762
|
+
catch (err) {
|
|
2763
|
+
this.logger.warn({ err, instance, channelId: entry.channelId }, "Classic web echo failed");
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
return posted;
|
|
2767
|
+
}
|
|
2768
|
+
reserveWebChannelEcho(instanceName, sendEcho) {
|
|
2769
|
+
const epoch = this.getDeliveryEpoch(instanceName);
|
|
2770
|
+
const deadlineAt = performance.now() + 5_000;
|
|
2771
|
+
let decide;
|
|
2772
|
+
const admission = new Promise(resolve => { decide = resolve; });
|
|
2773
|
+
const queue = this.webChannelEchoTails.get(instanceName)
|
|
2774
|
+
?? { tail: Promise.resolve(), pending: new Set() };
|
|
2775
|
+
const previous = queue.tail;
|
|
2776
|
+
let resolveDone;
|
|
2777
|
+
const tail = new Promise(resolve => { resolveDone = resolve; });
|
|
2778
|
+
let done = false, expired = false;
|
|
2779
|
+
let timer;
|
|
2780
|
+
let guard;
|
|
2781
|
+
const finish = () => {
|
|
2782
|
+
if (done)
|
|
2783
|
+
return;
|
|
2784
|
+
done = true;
|
|
2785
|
+
clearTimeout(timer);
|
|
2786
|
+
queue.pending.delete(entry);
|
|
2787
|
+
if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
|
|
2788
|
+
this.webChannelEchoGuards.delete(instanceName);
|
|
2789
|
+
}
|
|
2790
|
+
resolveDone();
|
|
2791
|
+
};
|
|
2792
|
+
const entry = { started: false, drop: () => {
|
|
2793
|
+
this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
|
|
2794
|
+
finish();
|
|
2795
|
+
} };
|
|
2796
|
+
const expire = () => {
|
|
2797
|
+
if (done)
|
|
2798
|
+
return;
|
|
2799
|
+
expired = true;
|
|
2800
|
+
this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
|
|
2801
|
+
finish();
|
|
2802
|
+
// Do not post copies that were queued behind an ambiguous platform send.
|
|
2803
|
+
for (const pending of queue.pending)
|
|
2804
|
+
if (!pending.started)
|
|
2805
|
+
pending.drop();
|
|
2806
|
+
};
|
|
2807
|
+
const checkDeadline = () => {
|
|
2808
|
+
if (done)
|
|
2809
|
+
return;
|
|
2810
|
+
const remaining = deadlineAt - performance.now();
|
|
2811
|
+
if (remaining <= 0)
|
|
2812
|
+
expire();
|
|
2813
|
+
else
|
|
2814
|
+
timer = setTimeout(checkDeadline, Math.ceil(remaining));
|
|
2815
|
+
};
|
|
2816
|
+
queue.pending.add(entry);
|
|
2817
|
+
queue.tail = tail;
|
|
2818
|
+
this.webChannelEchoTails.set(instanceName, queue);
|
|
2819
|
+
checkDeadline();
|
|
2820
|
+
void Promise.all([previous, admission]).then(([, accepted]) => {
|
|
2821
|
+
if (done)
|
|
2822
|
+
return;
|
|
2823
|
+
if (!accepted || !this.isDeliveryEpochCurrent(instanceName, epoch)) {
|
|
2824
|
+
finish();
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
// A delayed timer callback must not admit an already expired copy.
|
|
2828
|
+
if (performance.now() >= deadlineAt) {
|
|
2829
|
+
expire();
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
entry.started = true;
|
|
2833
|
+
guard = { epoch, deadlineAt };
|
|
2834
|
+
this.webChannelEchoGuards.set(instanceName, guard);
|
|
2835
|
+
let request;
|
|
2836
|
+
try {
|
|
2837
|
+
request = Promise.resolve(sendEcho());
|
|
2838
|
+
}
|
|
2839
|
+
catch (err) {
|
|
2840
|
+
request = Promise.reject(err);
|
|
2841
|
+
}
|
|
2842
|
+
void request.then(() => {
|
|
2843
|
+
if (expired)
|
|
2844
|
+
this.logger.warn({ instanceName }, "Web channel echo completed late after ordering timeout");
|
|
2845
|
+
finish();
|
|
2846
|
+
}, err => {
|
|
2847
|
+
this.logger.warn({ err, instanceName }, expired
|
|
2848
|
+
? "Web channel echo failed late after ordering timeout" : "Web channel echo failed");
|
|
2849
|
+
finish();
|
|
2850
|
+
});
|
|
2851
|
+
});
|
|
2852
|
+
void tail.then(() => {
|
|
2853
|
+
if (this.webChannelEchoTails.get(instanceName) === queue && queue.tail === tail && queue.pending.size === 0) {
|
|
2854
|
+
this.webChannelEchoTails.delete(instanceName);
|
|
2855
|
+
}
|
|
2856
|
+
});
|
|
2857
|
+
return decide;
|
|
2858
|
+
}
|
|
2374
2859
|
/** Whether this target already has an ordinary non-user delivery in its FIFO. */
|
|
2375
2860
|
hasPendingIdleGatedDelivery(instanceName) {
|
|
2376
2861
|
return this.idleGatedDeliveryTails.has(instanceName);
|
|
2377
2862
|
}
|
|
2863
|
+
/** The running launch's own steer capability (#1405); undefined for a backend without one, or no running Daemon. */
|
|
2864
|
+
instanceLaunchSupportsSteer(instanceName) {
|
|
2865
|
+
return this.daemons.get(instanceName)?.launchSupportsSteer();
|
|
2866
|
+
}
|
|
2378
2867
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
2379
2868
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
2380
2869
|
const deliveryEpoch = this.getDeliveryEpoch(instanceName);
|
|
@@ -2394,22 +2883,22 @@ export class FleetManager {
|
|
|
2394
2883
|
return false;
|
|
2395
2884
|
}
|
|
2396
2885
|
await this.explicitWake(instanceName, 30_000);
|
|
2397
|
-
if (!this.
|
|
2886
|
+
if (!this.deliveryCurrent(instanceName, deliveryEpoch, options.stillCurrent))
|
|
2398
2887
|
return false;
|
|
2399
2888
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
2400
2889
|
}
|
|
2401
|
-
const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
|
|
2890
|
+
const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch, options.stillCurrent);
|
|
2402
2891
|
if (!sent)
|
|
2403
2892
|
return false;
|
|
2404
2893
|
// A cross-instance item arriving before the daemon observes this turn as
|
|
2405
2894
|
// working must not trust the stale idle snapshot from before the send.
|
|
2406
|
-
if (this.
|
|
2895
|
+
if (this.deliveryCurrent(instanceName, deliveryEpoch, options.stillCurrent)) {
|
|
2407
2896
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
2408
2897
|
}
|
|
2409
2898
|
return true;
|
|
2410
2899
|
}
|
|
2411
2900
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
2412
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
|
|
2901
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true, options.stillCurrent));
|
|
2413
2902
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
2414
2903
|
try {
|
|
2415
2904
|
return await delivery;
|
|
@@ -2420,14 +2909,40 @@ export class FleetManager {
|
|
|
2420
2909
|
}
|
|
2421
2910
|
}
|
|
2422
2911
|
}
|
|
2423
|
-
/**
|
|
2912
|
+
/**
|
|
2913
|
+
* The fleet-admin list of exactly this adapter (#754): its `access.allowed_users`, read from fleet.yaml. An adapter
|
|
2914
|
+
* id that matches no configured channel and no running adapter has NO list — it is never answered with the primary
|
|
2915
|
+
* channel's, as `getChannelConfig` would. No id at all means the primary adapter, a single-adapter fleet's only one.
|
|
2916
|
+
*/
|
|
2917
|
+
adminListOf(adapterId) {
|
|
2918
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
2919
|
+
const id = adapterId ?? this.getPrimaryAdapterId();
|
|
2920
|
+
if (!id)
|
|
2921
|
+
return null;
|
|
2922
|
+
const config = channels.find(ch => (ch.id ?? ch.type) === id) ?? this.worlds.get(id)?.channelConfig;
|
|
2923
|
+
return config ? (config.access?.allowed_users ?? []).map(String) : null;
|
|
2924
|
+
}
|
|
2925
|
+
/**
|
|
2926
|
+
* The one fleet-admin gate (#754): is this user an admin of the adapter that OWNS what they are acting on — the
|
|
2927
|
+
* target instance's or General's adapter, never merely the one the request arrived on. An empty list means nobody
|
|
2928
|
+
* (`disabled`, so the reply can say admin commands are off); an unknown adapter means nobody either (`denied`).
|
|
2929
|
+
* Fleet admin is an explicit config entry: a paired or open-mode user is not one.
|
|
2930
|
+
*/
|
|
2931
|
+
adminGate(userId, ownerAdapterId) {
|
|
2932
|
+
const list = this.adminListOf(ownerAdapterId);
|
|
2933
|
+
if (!list)
|
|
2934
|
+
return "denied";
|
|
2935
|
+
if (list.length === 0)
|
|
2936
|
+
return "disabled";
|
|
2937
|
+
return list.includes(String(userId)) ? "ok" : "denied";
|
|
2938
|
+
}
|
|
2939
|
+
/** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. See adminGate. */
|
|
2424
2940
|
isFleetAdmin(userId, adapterId) {
|
|
2425
|
-
|
|
2426
|
-
return allowed.some(entry => String(entry) === String(userId));
|
|
2941
|
+
return this.adminGate(userId, adapterId) === "ok";
|
|
2427
2942
|
}
|
|
2428
2943
|
/** Whether this adapter has any fleet admin at all (an empty allowlist means the admin commands are off). */
|
|
2429
2944
|
hasFleetAdmins(adapterId) {
|
|
2430
|
-
return (this.
|
|
2945
|
+
return (this.adminListOf(adapterId)?.length ?? 0) > 0;
|
|
2431
2946
|
}
|
|
2432
2947
|
/**
|
|
2433
2948
|
* The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
|
|
@@ -2437,9 +2952,7 @@ export class FleetManager {
|
|
|
2437
2952
|
* type a slash command could `/update` the host.
|
|
2438
2953
|
*/
|
|
2439
2954
|
fleetAdminGate(userId, adapterId) {
|
|
2440
|
-
|
|
2441
|
-
return "disabled";
|
|
2442
|
-
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2955
|
+
return this.adminGate(userId, adapterId);
|
|
2443
2956
|
}
|
|
2444
2957
|
runtimeCpuProfiler = null;
|
|
2445
2958
|
cpuProfileControl = null;
|
|
@@ -2478,11 +2991,17 @@ export class FleetManager {
|
|
|
2478
2991
|
* this caller speak here at all) and then the COMMAND TABLE (src/command-table.ts — does this command apply
|
|
2479
2992
|
* in this kind of channel, and which kind of admin does it need). The table can only narrow what the door
|
|
2480
2993
|
* let through. A command nobody registered is answered, not left to time out.
|
|
2994
|
+
*
|
|
2995
|
+
* The door, the table and the start of the command run in one synchronous stretch (#1399 review): no await between
|
|
2996
|
+
* the check and the act, so the channel's instance cannot be rebound to another bot in between.
|
|
2481
2997
|
*/
|
|
2482
2998
|
async dispatchSlash(data, adapterId, adapter) {
|
|
2483
|
-
const
|
|
2484
|
-
if (
|
|
2999
|
+
const door = this.slashDoor(data, adapterId);
|
|
3000
|
+
if ("refusal" in door) {
|
|
3001
|
+
await data.respond(t(door.refusal)).catch(() => { });
|
|
2485
3002
|
return;
|
|
3003
|
+
}
|
|
3004
|
+
const scope = door.scope;
|
|
2486
3005
|
const spec = commandSpec(data.command);
|
|
2487
3006
|
if (!spec) {
|
|
2488
3007
|
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
@@ -2644,9 +3163,18 @@ export class FleetManager {
|
|
|
2644
3163
|
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
2645
3164
|
}
|
|
2646
3165
|
else if (data.command === "dashboard") {
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3166
|
+
if (String(data.options?.action ?? "").trim().toLowerCase() === "revoke") {
|
|
3167
|
+
const result = this.revokeWebSessions();
|
|
3168
|
+
await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
|
|
3169
|
+
}
|
|
3170
|
+
else {
|
|
3171
|
+
const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
|
|
3172
|
+
if (!owner) {
|
|
3173
|
+
await this.sendLocalDashboard(data, adapterId);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
await this.showDashboardMenu(owner, data.respondButtons);
|
|
3177
|
+
}
|
|
2650
3178
|
}
|
|
2651
3179
|
else if (data.command === "restart") {
|
|
2652
3180
|
await this.handleRestartSlash(data, adapterId);
|
|
@@ -2702,10 +3230,10 @@ export class FleetManager {
|
|
|
2702
3230
|
}
|
|
2703
3231
|
}
|
|
2704
3232
|
/**
|
|
2705
|
-
* The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning)
|
|
2706
|
-
*
|
|
3233
|
+
* The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning): the scope
|
|
3234
|
+
* it lets the command into, or the reply that refuses it. Synchronous, so the dispatch acts on what it judged.
|
|
2707
3235
|
*/
|
|
2708
|
-
|
|
3236
|
+
slashDoor(data, adapterId) {
|
|
2709
3237
|
const channelId = data.channelId;
|
|
2710
3238
|
const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
|
|
2711
3239
|
const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
|
|
@@ -2727,21 +3255,28 @@ export class FleetManager {
|
|
|
2727
3255
|
speaker = this.isFleetAdmin(data.userId, adapterId) || (am?.isAllowed(data.userId) ?? false) ? "allowed" : "denied";
|
|
2728
3256
|
}
|
|
2729
3257
|
}
|
|
3258
|
+
const spec = commandSpec(data.command);
|
|
3259
|
+
const rule = spec ? ruleFor(spec, commandScope, "discord") : undefined;
|
|
2730
3260
|
const facts = {
|
|
2731
3261
|
command: data.command,
|
|
2732
3262
|
guildId: data.guildId,
|
|
2733
3263
|
primaryGuildId: String(this.getChannelConfig(adapterId)?.group_id ?? ""),
|
|
2734
3264
|
scope,
|
|
2735
3265
|
speaker,
|
|
3266
|
+
// The table's level, plus the fleet-admin modes of a command whose handler decides by its options: /tips with a
|
|
3267
|
+
// mode saves fleet config or unlocks tips; bare /tips only draws one (#1396 review).
|
|
3268
|
+
fleetAdminCommand: (!!rule && "level" in rule && rule.level === "fleet-admin")
|
|
3269
|
+
|| (data.command === "tips" && typeof data.options?.mode === "string" && data.options.mode.trim() !== ""),
|
|
3270
|
+
otherBotOwns: !!fleetTarget && !!this.getInstanceAdapterId(fleetTarget.name) && this.getInstanceAdapterId(fleetTarget.name) !== adapterId,
|
|
2736
3271
|
};
|
|
2737
3272
|
const decision = decideSlash(facts);
|
|
2738
3273
|
if (decision.allow)
|
|
2739
|
-
return commandScope;
|
|
3274
|
+
return { scope: commandScope };
|
|
2740
3275
|
this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
3276
|
+
return { refusal: decision.reason === "dm" ? "slash.dm_unsupported"
|
|
3277
|
+
: decision.reason === "wrong-guild" ? "slash.wrong_server"
|
|
3278
|
+
: decision.reason === "other-bot" ? "slash.other_bot"
|
|
3279
|
+
: "not_authorized" };
|
|
2745
3280
|
}
|
|
2746
3281
|
/** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
|
|
2747
3282
|
deliveryWorkerMode(target) {
|
|
@@ -2882,8 +3417,39 @@ export class FleetManager {
|
|
|
2882
3417
|
reply_completion_guard: this.classicChannels?.getReplyCompletionGuardByInstance(instanceName, this.fleetConfig?.defaults?.reply_completion_guard) ?? true,
|
|
2883
3418
|
};
|
|
2884
3419
|
}
|
|
3420
|
+
/** Compensation retains this transaction's exact instance owner across its own transitions. */
|
|
3421
|
+
captureClassicSettingsRestoration(instanceName, changedFields, execution) {
|
|
3422
|
+
const generation = this.settingsGeneration;
|
|
3423
|
+
const receipt = { instanceName, epoch: this.lifecycle.epochOf(instanceName), daemon: this.daemons.get(instanceName) };
|
|
3424
|
+
if (execution)
|
|
3425
|
+
this.classicSettingsOwners.set(execution, receipt);
|
|
3426
|
+
return async () => {
|
|
3427
|
+
const cleanup = new SettingsExecution({ current: () => !this.shuttingDown && this.settingsGeneration === generation
|
|
3428
|
+
&& this.lifecycle.epochOf(instanceName) === receipt.epoch && this.daemons.get(instanceName) === receipt.daemon, snapshot: () => null });
|
|
3429
|
+
this.classicSettingsOwners.set(cleanup, receipt);
|
|
3430
|
+
try {
|
|
3431
|
+
cleanup.assert();
|
|
3432
|
+
await this.restartClassicInstanceFromSettings(instanceName, changedFields, cleanup);
|
|
3433
|
+
}
|
|
3434
|
+
finally {
|
|
3435
|
+
cleanup.close();
|
|
3436
|
+
}
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
2885
3439
|
/** Apply a Settings edit to a ClassicBot channel without waiting for the poller. */
|
|
2886
|
-
async restartClassicInstanceFromSettings(instanceName, changedFields = []) {
|
|
3440
|
+
async restartClassicInstanceFromSettings(instanceName, changedFields = [], execution) {
|
|
3441
|
+
return this.lifecycle.runTransition(instanceName, transition => this.restartClassicSettingsOwned(instanceName, changedFields, execution, transition));
|
|
3442
|
+
}
|
|
3443
|
+
async restartClassicSettingsOwned(instanceName, changedFields, execution, transition) {
|
|
3444
|
+
const generation = this.settingsGeneration;
|
|
3445
|
+
const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
|
|
3446
|
+
const check = () => {
|
|
3447
|
+
execution?.assert();
|
|
3448
|
+
if (this.shuttingDown || this.settingsGeneration !== generation || receipt &&
|
|
3449
|
+
(receipt.instanceName !== instanceName || receipt.epoch !== this.lifecycle.epochOf(instanceName) || receipt.daemon !== this.daemons.get(instanceName)))
|
|
3450
|
+
throw new Error("Classic restart superseded");
|
|
3451
|
+
};
|
|
3452
|
+
check();
|
|
2887
3453
|
if (!this.classicChannels)
|
|
2888
3454
|
throw new Error("Classic channel manager not initialized");
|
|
2889
3455
|
const wasRunning = this.daemons.has(instanceName);
|
|
@@ -2902,9 +3468,21 @@ export class FleetManager {
|
|
|
2902
3468
|
this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
|
|
2903
3469
|
return;
|
|
2904
3470
|
}
|
|
2905
|
-
|
|
3471
|
+
const stopping = this.stopInstance(instanceName, transition);
|
|
3472
|
+
// stop() invalidates synchronously in our transition. Keep only that exact acquisition.
|
|
3473
|
+
if (receipt && this.lifecycle.epochOf(instanceName) === receipt.epoch + 1 && this.daemons.get(instanceName) === receipt.daemon)
|
|
3474
|
+
receipt.epoch++;
|
|
3475
|
+
await stopping;
|
|
3476
|
+
if (receipt && this.lifecycle.epochOf(instanceName) === receipt.epoch && !this.daemons.has(instanceName))
|
|
3477
|
+
receipt.daemon = undefined;
|
|
3478
|
+
check();
|
|
3479
|
+
const stoppedEpoch = this.lifecycle.epochOf(instanceName);
|
|
2906
3480
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
2907
|
-
|
|
3481
|
+
check();
|
|
3482
|
+
if (this.lifecycle.epochOf(instanceName) !== stoppedEpoch)
|
|
3483
|
+
throw new Error("Classic restart superseded by stop");
|
|
3484
|
+
await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition, execution);
|
|
3485
|
+
check();
|
|
2908
3486
|
}
|
|
2909
3487
|
/** Reload classicBot.yaml once. Kept callable so the periodic production
|
|
2910
3488
|
* path is covered without relying on fake timers around startAll(). */
|
|
@@ -2980,7 +3558,8 @@ export class FleetManager {
|
|
|
2980
3558
|
*/
|
|
2981
3559
|
resumePaused = false,
|
|
2982
3560
|
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
2983
|
-
transition) {
|
|
3561
|
+
transition, execution) {
|
|
3562
|
+
execution?.assert();
|
|
2984
3563
|
// CLI single-instance cold starts bypass startAll. Start diagnostics before
|
|
2985
3564
|
// any lifecycle/wake work; a shutdown must not revive the stopped sampler.
|
|
2986
3565
|
if (!this.shuttingDown)
|
|
@@ -3039,11 +3618,17 @@ export class FleetManager {
|
|
|
3039
3618
|
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
3040
3619
|
}
|
|
3041
3620
|
}
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3621
|
+
const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
|
|
3622
|
+
const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
|
|
3623
|
+
if (receipt) {
|
|
3624
|
+
await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
|
|
3625
|
+
// Only this transition's actual publication can acquire its replacement.
|
|
3626
|
+
receipt.daemon = daemon;
|
|
3627
|
+
});
|
|
3628
|
+
}
|
|
3629
|
+
else
|
|
3630
|
+
await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
|
|
3631
|
+
execution?.assert();
|
|
3047
3632
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
3048
3633
|
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
3049
3634
|
// a falsely running instance when lifecycle.start() returns early.
|
|
@@ -3052,6 +3637,7 @@ export class FleetManager {
|
|
|
3052
3637
|
// before reaching it, so an await here is not proof the marker was read.
|
|
3053
3638
|
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
3054
3639
|
await this.connectIpcToInstance(name);
|
|
3640
|
+
execution?.assert();
|
|
3055
3641
|
this.requestDiscordUsagePresenceRefresh();
|
|
3056
3642
|
}
|
|
3057
3643
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
@@ -3692,10 +4278,11 @@ export class FleetManager {
|
|
|
3692
4278
|
}
|
|
3693
4279
|
}
|
|
3694
4280
|
/** Load .env file from data dir into process.env */
|
|
3695
|
-
loadEnvFile() {
|
|
4281
|
+
loadEnvFile(owner) {
|
|
3696
4282
|
const envPath = join(this.dataDir, ".env");
|
|
3697
4283
|
if (!existsSync(envPath))
|
|
3698
4284
|
return;
|
|
4285
|
+
assertSettingsLease(settingsFileResource(envPath), owner);
|
|
3699
4286
|
const content = readFileSync(envPath, "utf-8");
|
|
3700
4287
|
for (const line of content.split("\n")) {
|
|
3701
4288
|
const trimmed = line.trim();
|
|
@@ -3720,15 +4307,284 @@ export class FleetManager {
|
|
|
3720
4307
|
// Creates web.token if absent; the value is then read back per request by
|
|
3721
4308
|
// the `webToken` getter, so nothing is cached here.
|
|
3722
4309
|
loadOrCreateWebToken(this.dataDir);
|
|
3723
|
-
this.
|
|
3724
|
-
|
|
3725
|
-
|
|
4310
|
+
this.initializeWebSessions();
|
|
4311
|
+
// A `view.token` file was written here for a read-only credential that nothing
|
|
4312
|
+
// ever accepted. Older installs still have one; it authorizes nothing, so do
|
|
4313
|
+
// not leave a credential-shaped file lying around.
|
|
3726
4314
|
try {
|
|
3727
|
-
|
|
4315
|
+
rmSync(join(this.dataDir, "view.token"), { force: true });
|
|
3728
4316
|
}
|
|
3729
4317
|
catch { /* best effort */ }
|
|
3730
4318
|
this.healthServerListening = false;
|
|
3731
4319
|
}
|
|
4320
|
+
initializeWebSessions() {
|
|
4321
|
+
if (!this.webSessions) {
|
|
4322
|
+
this.webSessions = new WebSessionStore({
|
|
4323
|
+
dataDir: this.dataDir,
|
|
4324
|
+
onWarn: message => this.logger.warn(message),
|
|
4325
|
+
});
|
|
4326
|
+
}
|
|
4327
|
+
if (!this.webLoginCodes) {
|
|
4328
|
+
this.webLoginCodes = new WebLoginCodes({
|
|
4329
|
+
onEvent: event => {
|
|
4330
|
+
if (event === "burned")
|
|
4331
|
+
this.logger.warn("A web login code was used up by wrong attempts");
|
|
4332
|
+
else if (event === "breaker-open")
|
|
4333
|
+
this.logger.warn("Web sign-in paused: too many wrong login codes");
|
|
4334
|
+
},
|
|
4335
|
+
});
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
/**
|
|
4339
|
+
* A single-use login code for the dashboard, for a channel only the operator
|
|
4340
|
+
* can read (`/dashboard`). Null while the panel is closed (no web.token).
|
|
4341
|
+
*/
|
|
4342
|
+
issueDashboardLogin() {
|
|
4343
|
+
const token = this.webToken;
|
|
4344
|
+
if (!token)
|
|
4345
|
+
return null;
|
|
4346
|
+
this.initializeWebSessions();
|
|
4347
|
+
const issued = this.webLoginCodes.issue({ tier: "admin", epoch: tokenEpoch(token) });
|
|
4348
|
+
return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
|
|
4349
|
+
}
|
|
4350
|
+
tunnelPurposeLane = null;
|
|
4351
|
+
publicWebLink = null;
|
|
4352
|
+
getTunnelLane() {
|
|
4353
|
+
return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
|
|
4354
|
+
log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
|
|
4355
|
+
}
|
|
4356
|
+
publicOwnerCurrent(owner) {
|
|
4357
|
+
if (this.shuttingDown || !this.hasFleetAdmins(owner.adapterId) || !this.isFleetAdmin(owner.userId, owner.adapterId))
|
|
4358
|
+
return false;
|
|
4359
|
+
const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === owner.adapterId);
|
|
4360
|
+
if (!configured || String(configured.group_id ?? "") !== owner.chatId)
|
|
4361
|
+
return false;
|
|
4362
|
+
const world = this.worlds.get(owner.adapterId);
|
|
4363
|
+
if (!world || !owner.binding || world.adapter !== owner.binding || this.adapters.get(owner.adapterId) !== owner.binding)
|
|
4364
|
+
return false;
|
|
4365
|
+
return Object.entries(this.fleetConfig?.instances ?? {}).some(([name, cfg]) => cfg.general_topic
|
|
4366
|
+
&& this.getInstanceAdapterId(name) === owner.adapterId && String(this.getGroupIdForInstance(name) ?? "") === owner.chatId
|
|
4367
|
+
&& cfg.topic_id?.toString() === owner.threadId);
|
|
4368
|
+
}
|
|
4369
|
+
/** Exact owning General. No primary-adapter or name fallback participates in authorization. */
|
|
4370
|
+
dashboardOwner(userId, adapterId, address, threadId) {
|
|
4371
|
+
const world = this.worlds.get(adapterId);
|
|
4372
|
+
if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
|
|
4373
|
+
return null;
|
|
4374
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
4375
|
+
if (!cfg.general_topic || this.getInstanceAdapterId(name) !== adapterId)
|
|
4376
|
+
continue;
|
|
4377
|
+
const group = String(this.getGroupIdForInstance(name) ?? "");
|
|
4378
|
+
const topic = cfg.topic_id?.toString();
|
|
4379
|
+
if (!topic)
|
|
4380
|
+
continue;
|
|
4381
|
+
const dc = world.adapter.id === adapterId && world.channelConfig.type === "discord";
|
|
4382
|
+
const matched = dc ? topic === address || (group === address && topic === threadId)
|
|
4383
|
+
: group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
|
|
4384
|
+
if (group && matched)
|
|
4385
|
+
return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
|
|
4386
|
+
}
|
|
4387
|
+
return null;
|
|
4388
|
+
}
|
|
4389
|
+
getPublicWebLink() {
|
|
4390
|
+
this.initializeWebSessions();
|
|
4391
|
+
return this.publicWebLink ??= new PublicWebLink({
|
|
4392
|
+
dataDir: this.dataDir, web: () => this.fleetConfig?.web,
|
|
4393
|
+
permitted: owner => this.publicOwnerCurrent(owner),
|
|
4394
|
+
reserve: id => this.getTunnelLane().reserve("dashboard", id),
|
|
4395
|
+
createGateway: (exposureId, current, open, failed) => createPublicWebGateway({ exposureId, isCurrent: current, isOpen: open, onError: failed,
|
|
4396
|
+
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
4397
|
+
revoke: id => {
|
|
4398
|
+
this.webLoginCodes?.revokeAudience(id);
|
|
4399
|
+
const result = this.webSessions?.revokeExposure(id);
|
|
4400
|
+
if (result && !result.durable)
|
|
4401
|
+
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
4402
|
+
},
|
|
4403
|
+
log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
|
|
4404
|
+
onCleanupUnconfirmed: (result, owner) => {
|
|
4405
|
+
this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
|
|
4406
|
+
const world = this.worlds.get(owner.adapterId);
|
|
4407
|
+
if (world && world.adapter === owner.binding)
|
|
4408
|
+
void world.adapter.sendText(owner.chatId, t("dashboard.public_cleanup"), { threadId: owner.threadId }).catch(() => { });
|
|
4409
|
+
},
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4412
|
+
getPublicWebStatus() {
|
|
4413
|
+
return this.publicWebLink?.status() ?? { state: "closed" };
|
|
4414
|
+
}
|
|
4415
|
+
async confirmPublicWebLogin(info, current) {
|
|
4416
|
+
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
4417
|
+
throw new Error("public owner unavailable");
|
|
4418
|
+
const adapter = info.owner.binding;
|
|
4419
|
+
await adapter.sendText(info.owner.chatId, t("web.public_login_notice", info.label, info.handle.slice(0, 8)), { threadId: info.owner.threadId, allowedMentions: { parse: [] } });
|
|
4420
|
+
if (!current() || !this.publicOwnerCurrent(info.owner))
|
|
4421
|
+
throw new Error("public owner changed");
|
|
4422
|
+
}
|
|
4423
|
+
/** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
|
|
4424
|
+
async dashboardMenu(msg) {
|
|
4425
|
+
const id = msg.adapterId;
|
|
4426
|
+
const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
|
|
4427
|
+
if (!owner) {
|
|
4428
|
+
const adapter = id ? this.adapters.get(id) : undefined;
|
|
4429
|
+
await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
4432
|
+
await this.showDashboardMenu(owner);
|
|
4433
|
+
}
|
|
4434
|
+
async sendLocalDashboard(data, adapterId) {
|
|
4435
|
+
if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId))
|
|
4436
|
+
return;
|
|
4437
|
+
const text = this.topicCommands.getDashboardText();
|
|
4438
|
+
const adapter = this.adapters.get(adapterId);
|
|
4439
|
+
const token = this.webToken;
|
|
4440
|
+
if (!adapter || !token) {
|
|
4441
|
+
await data.respond(text);
|
|
4442
|
+
return;
|
|
4443
|
+
}
|
|
4444
|
+
this.initializeWebSessions();
|
|
4445
|
+
const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token) });
|
|
4446
|
+
const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
|
|
4447
|
+
&& this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
|
|
4448
|
+
const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
|
|
4449
|
+
const deadline = performance.now() + 10_000;
|
|
4450
|
+
let deliveredByDm = false;
|
|
4451
|
+
try {
|
|
4452
|
+
try {
|
|
4453
|
+
if (!adapter.sendDirect)
|
|
4454
|
+
throw new Error("DM unavailable");
|
|
4455
|
+
await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
|
|
4456
|
+
deliveredByDm = true;
|
|
4457
|
+
}
|
|
4458
|
+
catch {
|
|
4459
|
+
if (!current())
|
|
4460
|
+
throw new Error("owner changed");
|
|
4461
|
+
const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
|
|
4462
|
+
if (!id)
|
|
4463
|
+
throw new Error("private delivery unconfirmed");
|
|
4464
|
+
}
|
|
4465
|
+
if (!current())
|
|
4466
|
+
throw new Error("owner changed");
|
|
4467
|
+
if (deliveredByDm)
|
|
4468
|
+
await withinBudget(data.respond(t("dashboard.private_sent")), deadline);
|
|
4469
|
+
}
|
|
4470
|
+
catch {
|
|
4471
|
+
this.webLoginCodes.revokeIfCurrent(login.issuanceId);
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
async showDashboardMenu(owner, respondButtons) {
|
|
4475
|
+
if (!this.publicOwnerCurrent(owner))
|
|
4476
|
+
return;
|
|
4477
|
+
const adapter = owner.binding;
|
|
4478
|
+
const status = this.getPublicWebStatus();
|
|
4479
|
+
const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
|
|
4480
|
+
const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
|
|
4481
|
+
+ (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
|
|
4482
|
+
+ "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0);
|
|
4483
|
+
const choices = [{ action: "local", label: t("dashboard.local") },
|
|
4484
|
+
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
4485
|
+
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
4486
|
+
await this.postNonceButtonPromptOrThrow({
|
|
4487
|
+
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
|
|
4488
|
+
chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
|
|
4489
|
+
message: menuText,
|
|
4490
|
+
choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
|
|
4491
|
+
...(respondButtons ? { deliver: async (c) => {
|
|
4492
|
+
const messageId = await respondButtons(menuText, c);
|
|
4493
|
+
if (!messageId)
|
|
4494
|
+
throw new Error("menu refused");
|
|
4495
|
+
return { chatId: owner.chatId, threadId: owner.threadId, messageId };
|
|
4496
|
+
} } : {}),
|
|
4497
|
+
});
|
|
4498
|
+
}
|
|
4499
|
+
async handleDashboardCallback(data, adapterId, adapter) {
|
|
4500
|
+
const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
|
|
4501
|
+
if (claimed === null)
|
|
4502
|
+
return false;
|
|
4503
|
+
if (claimed === "consumed")
|
|
4504
|
+
return true;
|
|
4505
|
+
const { entry, action } = claimed;
|
|
4506
|
+
const owner = entry.dashboardOwner;
|
|
4507
|
+
if (!owner || !this.publicOwnerCurrent(owner)) {
|
|
4508
|
+
data.ack?.(t("not_authorized"));
|
|
4509
|
+
return true;
|
|
4510
|
+
}
|
|
4511
|
+
if (action === "close") {
|
|
4512
|
+
if (!entry.publicExposureId)
|
|
4513
|
+
return true;
|
|
4514
|
+
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
4515
|
+
if (result?.confirmed === false)
|
|
4516
|
+
data.ack?.(t("dashboard.public_cleanup"));
|
|
4517
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"));
|
|
4518
|
+
return true;
|
|
4519
|
+
}
|
|
4520
|
+
const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
|
|
4521
|
+
const token = this.webToken;
|
|
4522
|
+
if (!token || !current())
|
|
4523
|
+
return false;
|
|
4524
|
+
this.initializeWebSessions();
|
|
4525
|
+
const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
|
|
4526
|
+
const text = t("dashboard.private_link", url, issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), expiresAt ? new Date(expiresAt).toISOString() : t("dashboard.local"));
|
|
4527
|
+
const deadline = performance.now() + 10_000;
|
|
4528
|
+
const direct = owner.binding;
|
|
4529
|
+
try {
|
|
4530
|
+
const deliver = async (c = []) => {
|
|
4531
|
+
try {
|
|
4532
|
+
if (!direct.sendDirect)
|
|
4533
|
+
throw new Error("DM unavailable");
|
|
4534
|
+
return await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
|
|
4535
|
+
}
|
|
4536
|
+
catch {
|
|
4537
|
+
if (direct.type !== "discord" || !data.respondPrivate || !current())
|
|
4538
|
+
throw new Error("private delivery unavailable");
|
|
4539
|
+
const sent = await withinBudget(data.respondPrivate(text, c), deadline);
|
|
4540
|
+
return { ...sent, chatId: owner.chatId, threadId: owner.threadId };
|
|
4541
|
+
}
|
|
4542
|
+
};
|
|
4543
|
+
if (exposureId)
|
|
4544
|
+
await withinBudget(this.postNonceButtonPromptOrThrow({
|
|
4545
|
+
prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter: direct, adapterId,
|
|
4546
|
+
chatId: owner.userId, message: t("dashboard.public_close"), choices: [{ action: "close", label: t("dashboard.public_close") }],
|
|
4547
|
+
expiredText: t("buttons.stale"), timeoutMs: 5 * 60_000, extra: { requesterUserId: owner.userId, publicExposureId: exposureId, dashboardOwner: owner }, deliver,
|
|
4548
|
+
}), deadline);
|
|
4549
|
+
else
|
|
4550
|
+
await deliver();
|
|
4551
|
+
if (!current() || this.webToken !== token)
|
|
4552
|
+
throw new Error("owner changed");
|
|
4553
|
+
return true;
|
|
4554
|
+
}
|
|
4555
|
+
catch {
|
|
4556
|
+
this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
|
|
4557
|
+
this.logger.info("Dashboard private delivery was not confirmed");
|
|
4558
|
+
return false;
|
|
4559
|
+
}
|
|
4560
|
+
};
|
|
4561
|
+
const ok = action === "public"
|
|
4562
|
+
? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent))
|
|
4563
|
+
: await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
|
|
4564
|
+
// Only safe, static words enter General; never link, code or platform error text.
|
|
4565
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t(ok ? "dashboard.private_sent" : "dashboard.private_failed"));
|
|
4566
|
+
return true;
|
|
4567
|
+
}
|
|
4568
|
+
/** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
|
|
4569
|
+
revokeWebSessions() {
|
|
4570
|
+
this.initializeWebSessions();
|
|
4571
|
+
void this.publicWebLink?.close("dashboard revoke");
|
|
4572
|
+
this.webLoginCodes.revoke();
|
|
4573
|
+
const result = this.webSessions.revokeAll();
|
|
4574
|
+
if (result.durable)
|
|
4575
|
+
this.logger.info({ count: result.count }, "Web sessions revoked (all)");
|
|
4576
|
+
else
|
|
4577
|
+
this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
|
|
4578
|
+
return result;
|
|
4579
|
+
}
|
|
4580
|
+
/** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
|
|
4581
|
+
onWebLogin(info) {
|
|
4582
|
+
if (this.fleetConfig?.web?.notify_login === false)
|
|
4583
|
+
return;
|
|
4584
|
+
// The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
|
|
4585
|
+
// sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
|
|
4586
|
+
this.notifyFleetError(t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
|
|
4587
|
+
}
|
|
3732
4588
|
getDashboardAccess() {
|
|
3733
4589
|
return { ready: this.healthServerListening, token: this.webToken };
|
|
3734
4590
|
}
|
|
@@ -3801,7 +4657,8 @@ export class FleetManager {
|
|
|
3801
4657
|
// ambiguous under systemd because this helper shares the old cgroup.
|
|
3802
4658
|
if (this.shuttingDown || !this.isOwnedFullRestartMarker(ownedMarker.startedAt, target))
|
|
3803
4659
|
return;
|
|
3804
|
-
|
|
4660
|
+
// The restart job outlived the helper's wait (systemd still running it): the new fleet settles the marker.
|
|
4661
|
+
if (!result.error && (result.code === 0 || result.code === null || result.code === SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE))
|
|
3805
4662
|
return;
|
|
3806
4663
|
const detail = result.error
|
|
3807
4664
|
? "reload helper failed after launch"
|
|
@@ -3937,7 +4794,14 @@ export class FleetManager {
|
|
|
3937
4794
|
// anything arriving during it is still part of the stop.
|
|
3938
4795
|
this.shuttingDown = false;
|
|
3939
4796
|
this.configPath = configPath;
|
|
3940
|
-
this.
|
|
4797
|
+
const settingsGeneration = this.settingsGeneration;
|
|
4798
|
+
const envLease = await waitSettingsLease([settingsFileResource(join(this.dataDir, ".env"))], () => !this.shuttingDown && this.settingsGeneration === settingsGeneration, performance.now() + 30_000);
|
|
4799
|
+
try {
|
|
4800
|
+
this.loadEnvFile(envLease.owner);
|
|
4801
|
+
}
|
|
4802
|
+
finally {
|
|
4803
|
+
envLease.release();
|
|
4804
|
+
}
|
|
3941
4805
|
this.ensureDeliveryOutbox();
|
|
3942
4806
|
this.rotateFleetLogs();
|
|
3943
4807
|
const fleet = this.loadConfig(configPath);
|
|
@@ -4024,7 +4888,8 @@ export class FleetManager {
|
|
|
4024
4888
|
}
|
|
4025
4889
|
// Poll classicBot.yaml for external changes every 30s
|
|
4026
4890
|
this.classicReloadTimer = setInterval(() => {
|
|
4027
|
-
|
|
4891
|
+
// Attributes the synchronous part, up to the reload's first await (#1235).
|
|
4892
|
+
void measureSyncWork("fleet.classicReload", () => this.reloadClassicConfigFromDisk());
|
|
4028
4893
|
}, 30_000);
|
|
4029
4894
|
const costGuardConfig = {
|
|
4030
4895
|
...DEFAULT_COST_GUARD,
|
|
@@ -4073,6 +4938,8 @@ export class FleetManager {
|
|
|
4073
4938
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
4074
4939
|
this.classicChannels?.rotateLogs();
|
|
4075
4940
|
this.rotateInboxes();
|
|
4941
|
+
// Web uploads no message took before this restart (#1273).
|
|
4942
|
+
this.sweepOrphanedWebUploads();
|
|
4076
4943
|
// Auto-create/adopt a general dispatcher — ONLY for the primary adapter.
|
|
4077
4944
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
4078
4945
|
this.warnUnboundGeneralChannelIds(fleet);
|
|
@@ -4161,7 +5028,7 @@ export class FleetManager {
|
|
|
4161
5028
|
...DEFAULT_SCHEDULER_CONFIG,
|
|
4162
5029
|
...this.fleetConfig?.defaults.scheduler,
|
|
4163
5030
|
};
|
|
4164
|
-
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId) => this.handleScheduleTrigger(schedule, runId), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
|
|
5031
|
+
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId, retry) => this.handleScheduleTrigger(schedule, runId, retry), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), (schedule, retry, reason) => this.scheduleRetryDropped(schedule, retry, reason));
|
|
4165
5032
|
this.scheduler.init();
|
|
4166
5033
|
this.logger.info("Scheduler initialized");
|
|
4167
5034
|
// Tips share the daily-report clock but remain an independent internal
|
|
@@ -4402,6 +5269,8 @@ export class FleetManager {
|
|
|
4402
5269
|
}
|
|
4403
5270
|
await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
|
|
4404
5271
|
}
|
|
5272
|
+
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
5273
|
+
void this.announceWebChatOnce(agendVersion);
|
|
4405
5274
|
}
|
|
4406
5275
|
// Health HTTP endpoint
|
|
4407
5276
|
this.startHealthServer(fleet.health_port ?? 19280);
|
|
@@ -4535,6 +5404,27 @@ export class FleetManager {
|
|
|
4535
5404
|
this.logger.info({ deleted }, "Rotated inbox files");
|
|
4536
5405
|
return deleted;
|
|
4537
5406
|
}
|
|
5407
|
+
/**
|
|
5408
|
+
* Web uploads no message took before a restart (#1273): removed once older than the upload window. Younger ones
|
|
5409
|
+
* are looked at again when the first comes due (an unref'd timer, so it never holds the process).
|
|
5410
|
+
*/
|
|
5411
|
+
sweepOrphanedWebUploads() {
|
|
5412
|
+
// Skip what this process's ledger still holds: those are uploads made since this start, timed by the ledger.
|
|
5413
|
+
// (Compared the way the sweep names files: the inbox directory resolved, the file name as it is.)
|
|
5414
|
+
const owned = new Set([...this.webFiles.ownedPaths()].map(p => { try {
|
|
5415
|
+
return join(realpathSync(dirname(p)), basename(p));
|
|
5416
|
+
}
|
|
5417
|
+
catch {
|
|
5418
|
+
return p;
|
|
5419
|
+
} }));
|
|
5420
|
+
const { deleted, nextDueInMs } = sweepOrphanedUploads(join(getAgendHome(), "workspaces"), Date.now(), undefined, owned);
|
|
5421
|
+
if (deleted > 0)
|
|
5422
|
+
this.logger.info({ deleted }, "Removed web uploads no message took before the restart");
|
|
5423
|
+
if (nextDueInMs !== null) {
|
|
5424
|
+
const t = setTimeout(() => this.sweepOrphanedWebUploads(), Math.max(1_000, nextDueInMs + 1_000));
|
|
5425
|
+
t.unref?.();
|
|
5426
|
+
}
|
|
5427
|
+
}
|
|
4538
5428
|
/** Start the shared channel adapter(s) for topic mode */
|
|
4539
5429
|
async startSharedAdapter(fleet) {
|
|
4540
5430
|
const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
|
|
@@ -4730,7 +5620,27 @@ export class FleetManager {
|
|
|
4730
5620
|
};
|
|
4731
5621
|
}
|
|
4732
5622
|
/** Start the primary adapter (backward-compatible, sets this.adapter) */
|
|
4733
|
-
async startSingleAdapter(fleet, channelConfig, onStarted) {
|
|
5623
|
+
async startSingleAdapter(fleet, channelConfig, onStarted, operation) {
|
|
5624
|
+
const id = channelConfig.id ?? channelConfig.type;
|
|
5625
|
+
const lease = trySettingsLease([`connection:${this.dataDir}:${id}`], operation?.owner);
|
|
5626
|
+
if (!lease)
|
|
5627
|
+
throw new Error("connection operation is still running");
|
|
5628
|
+
try {
|
|
5629
|
+
await this.startSingleAdapterOwned(fleet, channelConfig, onStarted, operation ?? { owner: lease.owner, assert: () => { } });
|
|
5630
|
+
}
|
|
5631
|
+
finally {
|
|
5632
|
+
lease.release();
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
async startSingleAdapterOwned(fleet, channelConfig, onStarted, operation) {
|
|
5636
|
+
const ownerId = channelConfig.id ?? channelConfig.type, generation = this.settingsGeneration;
|
|
5637
|
+
const check = () => {
|
|
5638
|
+
operation?.assert();
|
|
5639
|
+
assertSettingsLease(`connection:${this.dataDir}:${ownerId}`, operation?.owner);
|
|
5640
|
+
if (this.shuttingDown || this.settingsGeneration !== generation)
|
|
5641
|
+
throw new Error("adapter admission superseded");
|
|
5642
|
+
};
|
|
5643
|
+
check();
|
|
4734
5644
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
4735
5645
|
if (!botToken) {
|
|
4736
5646
|
this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
|
|
@@ -4741,39 +5651,46 @@ export class FleetManager {
|
|
|
4741
5651
|
const accessStatePath = join(accessDir, "access.json");
|
|
4742
5652
|
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
4743
5653
|
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
4744
|
-
this.accessManager = accessManager;
|
|
4745
5654
|
const inboxDir = join(this.dataDir, "inbox");
|
|
4746
5655
|
mkdirSync(inboxDir, { recursive: true });
|
|
4747
5656
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
4748
|
-
|
|
5657
|
+
const adapter = await createAdapter(channelConfig, {
|
|
4749
5658
|
id: adapterId,
|
|
4750
5659
|
botToken,
|
|
4751
5660
|
accessManager,
|
|
4752
5661
|
inboxDir,
|
|
4753
5662
|
fleetLabel: fleetLabel(this.fleetConfig),
|
|
4754
5663
|
});
|
|
4755
|
-
|
|
5664
|
+
try {
|
|
5665
|
+
check();
|
|
5666
|
+
}
|
|
5667
|
+
catch (err) {
|
|
5668
|
+
await adapter.stop().catch(() => { });
|
|
5669
|
+
throw err;
|
|
5670
|
+
}
|
|
5671
|
+
this.adapter = adapter;
|
|
5672
|
+
this.accessManager = accessManager;
|
|
4756
5673
|
const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
|
|
4757
5674
|
this.worlds.set(adapterId, world);
|
|
4758
5675
|
this.adapters.set(adapterId, adapter);
|
|
4759
5676
|
this.bindAdapterHealth(adapter, adapterId);
|
|
4760
5677
|
const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
|
|
4761
|
-
|
|
5678
|
+
adapter.on("message", safeHandler(async (msg) => {
|
|
4762
5679
|
if (!isCurrentAdapter())
|
|
4763
5680
|
return;
|
|
4764
5681
|
await this.handleInboundMessage(msg);
|
|
4765
5682
|
}, this.logger, "adapter.message"));
|
|
4766
|
-
|
|
5683
|
+
adapter.on("reaction", safeHandler(async (r) => {
|
|
4767
5684
|
if (!isCurrentAdapter())
|
|
4768
5685
|
return;
|
|
4769
5686
|
await this.handleInboundReaction(r);
|
|
4770
5687
|
}, this.logger, "adapter.reaction"));
|
|
4771
|
-
|
|
4772
|
-
await this.receiveAdapterCallback(data, adapterId,
|
|
5688
|
+
adapter.on("callback_query", safeHandler(async (data) => {
|
|
5689
|
+
await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
|
|
4773
5690
|
}, this.logger, "adapter.callback_query"));
|
|
4774
5691
|
this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
|
|
4775
5692
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
4776
|
-
|
|
5693
|
+
adapter.on("slash_command", safeHandler(async (data) => {
|
|
4777
5694
|
if (!isCurrentAdapter())
|
|
4778
5695
|
return;
|
|
4779
5696
|
await this.dispatchSlash(data, adapterId, adapter);
|
|
@@ -4783,7 +5700,7 @@ export class FleetManager {
|
|
|
4783
5700
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
4784
5701
|
// Non-blocking: /model & status views read the cache; never delays startup.
|
|
4785
5702
|
this.probeCliEnvs();
|
|
4786
|
-
|
|
5703
|
+
adapter.on("started", safeHandler((username, userId) => {
|
|
4787
5704
|
if (!isCurrentAdapter())
|
|
4788
5705
|
return;
|
|
4789
5706
|
this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
|
|
@@ -4799,19 +5716,19 @@ export class FleetManager {
|
|
|
4799
5716
|
this.botUserId = userId;
|
|
4800
5717
|
onStarted?.();
|
|
4801
5718
|
}, this.logger, "adapter.started"));
|
|
4802
|
-
|
|
5719
|
+
adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
|
|
4803
5720
|
this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
|
|
4804
5721
|
}, this.logger, "adapter.polling_conflict"));
|
|
4805
|
-
|
|
5722
|
+
adapter.on("handler_error", safeHandler((err) => {
|
|
4806
5723
|
this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
|
|
4807
5724
|
}, this.logger, "adapter.handler_error"));
|
|
4808
|
-
|
|
5725
|
+
adapter.on("error", (err) => {
|
|
4809
5726
|
if (!isCurrentAdapter())
|
|
4810
5727
|
return;
|
|
4811
5728
|
this.logger.error({ err }, "Primary adapter fatal error");
|
|
4812
|
-
this.restartAdapter(
|
|
5729
|
+
this.restartAdapter(adapter, adapterId).catch(() => { });
|
|
4813
5730
|
});
|
|
4814
|
-
|
|
5731
|
+
adapter.on("new_group_detected", safeHandler(async (data) => {
|
|
4815
5732
|
if (!isCurrentAdapter())
|
|
4816
5733
|
return;
|
|
4817
5734
|
const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
|
|
@@ -4821,11 +5738,20 @@ export class FleetManager {
|
|
|
4821
5738
|
await this.promptClassicApproval({ generalName: generalId, message: adminMsg, groupId: data.groupId, scope: data.source === "telegram" ? "group" : "guild" });
|
|
4822
5739
|
}, this.logger, "adapter.new_group_detected"));
|
|
4823
5740
|
// Start adapter AFTER all event listeners are registered (started event sets botUsername)
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
5741
|
+
try {
|
|
5742
|
+
await adapter.start();
|
|
5743
|
+
check();
|
|
5744
|
+
if (!isCurrentAdapter())
|
|
5745
|
+
throw new Error("adapter ownership changed during login");
|
|
5746
|
+
}
|
|
5747
|
+
catch (err) {
|
|
5748
|
+
await this.disposeSettingsAdapter(adapterId, adapter);
|
|
5749
|
+
throw err;
|
|
5750
|
+
}
|
|
5751
|
+
if (channelConfig.group_id) {
|
|
5752
|
+
adapter.setChatId(String(channelConfig.group_id));
|
|
4827
5753
|
}
|
|
4828
|
-
if (this.discordPresenceTimer &&
|
|
5754
|
+
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
4829
5755
|
this.requestDiscordUsagePresenceRefresh();
|
|
4830
5756
|
}
|
|
4831
5757
|
this.startTopicCleanupPoller();
|
|
@@ -4844,8 +5770,28 @@ export class FleetManager {
|
|
|
4844
5770
|
void this.topicCommands.registerBotCommands(channelConfig).catch(e => this.logger.warn({ err: e, adapterId: channelConfig.id ?? channelConfig.type }, "registerBotCommands failed (non-fatal)"));
|
|
4845
5771
|
}
|
|
4846
5772
|
/** Start an additional (non-primary) adapter */
|
|
4847
|
-
async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted) {
|
|
5773
|
+
async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted, operation) {
|
|
5774
|
+
const id = channelConfig.id ?? channelConfig.type;
|
|
5775
|
+
const lease = trySettingsLease([`connection:${this.dataDir}:${id}`], operation?.owner);
|
|
5776
|
+
if (!lease)
|
|
5777
|
+
throw new Error("connection operation is still running");
|
|
5778
|
+
try {
|
|
5779
|
+
await this.startAdditionalAdapterOwned(channelConfig, registerCommands, onStarted, operation ?? { owner: lease.owner, assert: () => { } });
|
|
5780
|
+
}
|
|
5781
|
+
finally {
|
|
5782
|
+
lease.release();
|
|
5783
|
+
}
|
|
5784
|
+
}
|
|
5785
|
+
async startAdditionalAdapterOwned(channelConfig, registerCommands = true, onStarted, operation) {
|
|
4848
5786
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
5787
|
+
const ownerId = channelConfig.id ?? channelConfig.type, generation = this.settingsGeneration;
|
|
5788
|
+
const check = () => {
|
|
5789
|
+
operation?.assert();
|
|
5790
|
+
assertSettingsLease(`connection:${this.dataDir}:${ownerId}`, operation?.owner);
|
|
5791
|
+
if (this.shuttingDown || this.settingsGeneration !== generation)
|
|
5792
|
+
throw new Error("adapter admission superseded");
|
|
5793
|
+
};
|
|
5794
|
+
check();
|
|
4849
5795
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
4850
5796
|
if (!botToken) {
|
|
4851
5797
|
this.logger.warn({ env: channelConfig.bot_token_env, adapterId }, "Bot token env not set, skipping adapter");
|
|
@@ -4866,6 +5812,13 @@ export class FleetManager {
|
|
|
4866
5812
|
registerCommands,
|
|
4867
5813
|
fleetLabel: fleetLabel(this.fleetConfig),
|
|
4868
5814
|
});
|
|
5815
|
+
try {
|
|
5816
|
+
check();
|
|
5817
|
+
}
|
|
5818
|
+
catch (err) {
|
|
5819
|
+
await adapter.stop().catch(() => { });
|
|
5820
|
+
throw err;
|
|
5821
|
+
}
|
|
4869
5822
|
const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
|
|
4870
5823
|
this.worlds.set(adapterId, world);
|
|
4871
5824
|
this.adapters.set(adapterId, adapter);
|
|
@@ -4922,7 +5875,16 @@ export class FleetManager {
|
|
|
4922
5875
|
// at fleet start, on retry, and when Settings rebinds it or rotates its token: all of them start it here (#1191).
|
|
4923
5876
|
this.registerTelegramCommandsFor(channelConfig);
|
|
4924
5877
|
// Register lifecycle listeners before login; a fast ready/error must not be lost.
|
|
4925
|
-
|
|
5878
|
+
try {
|
|
5879
|
+
await adapter.start();
|
|
5880
|
+
check();
|
|
5881
|
+
if (!isCurrentAdapter())
|
|
5882
|
+
throw new Error("adapter ownership changed during login");
|
|
5883
|
+
}
|
|
5884
|
+
catch (err) {
|
|
5885
|
+
await this.disposeSettingsAdapter(adapterId, adapter);
|
|
5886
|
+
throw err;
|
|
5887
|
+
}
|
|
4926
5888
|
if (channelConfig.group_id) {
|
|
4927
5889
|
adapter.setChatId(String(channelConfig.group_id));
|
|
4928
5890
|
}
|
|
@@ -5153,10 +6115,23 @@ export class FleetManager {
|
|
|
5153
6115
|
}
|
|
5154
6116
|
}
|
|
5155
6117
|
}
|
|
6118
|
+
async disposeSettingsAdapter(id, adapter) {
|
|
6119
|
+
adapter.removeAllListeners();
|
|
6120
|
+
await adapter.stop().catch(() => { });
|
|
6121
|
+
if (this.adapters.get(id) === adapter)
|
|
6122
|
+
this.adapters.delete(id);
|
|
6123
|
+
if (this.worlds.get(id)?.adapter === adapter)
|
|
6124
|
+
this.worlds.delete(id);
|
|
6125
|
+
if (this.adapter === adapter)
|
|
6126
|
+
this.adapter = null;
|
|
6127
|
+
}
|
|
5156
6128
|
/** Restart a channel adapter after fatal error with infinite retry + 60s cap */
|
|
5157
6129
|
async restartAdapter(adapter, id) {
|
|
5158
|
-
if (this.adapterRestarting.has(id))
|
|
6130
|
+
if (this.adapterRestarting.has(id) || this.shuttingDown || this.adapters.get(id) !== adapter)
|
|
5159
6131
|
return;
|
|
6132
|
+
const generation = this.settingsGeneration;
|
|
6133
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation && this.adapters.get(id) === adapter;
|
|
6134
|
+
let lease = null;
|
|
5160
6135
|
this.adapterRestarting.add(id);
|
|
5161
6136
|
// Reflect reality in adapterState throughout. This loop used to leave the state
|
|
5162
6137
|
// untouched, so getAdapterStates() — and therefore /health and the dashboard —
|
|
@@ -5167,10 +6142,15 @@ export class FleetManager {
|
|
|
5167
6142
|
try {
|
|
5168
6143
|
if (adapter.reconnectGateway) {
|
|
5169
6144
|
try {
|
|
6145
|
+
lease = trySettingsLease([`connection:${this.dataDir}:${id}`]);
|
|
6146
|
+
if (!lease || !current())
|
|
6147
|
+
return;
|
|
5170
6148
|
// Discord requires a fresh Client after destroy(). Its adapter owns the
|
|
5171
6149
|
// single-flight, generation fence and bounded IDENTIFY backoff, so all
|
|
5172
6150
|
// watchdog/manual/error triggers must converge here instead of stop/start.
|
|
5173
6151
|
await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
|
|
6152
|
+
if (!current())
|
|
6153
|
+
return;
|
|
5174
6154
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
5175
6155
|
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
5176
6156
|
this.requestDiscordUsagePresenceRefresh();
|
|
@@ -5178,7 +6158,7 @@ export class FleetManager {
|
|
|
5178
6158
|
this.logger.info({ id }, "Adapter gateway rebuilt successfully");
|
|
5179
6159
|
}
|
|
5180
6160
|
catch (err) {
|
|
5181
|
-
if (!this.ipcStoppingInstances.has("__fleet_stopping__")) {
|
|
6161
|
+
if (current() && !this.ipcStoppingInstances.has("__fleet_stopping__")) {
|
|
5182
6162
|
this.adapterState.set(id, {
|
|
5183
6163
|
status: "failed",
|
|
5184
6164
|
retryCount: previous?.retryCount ?? 0,
|
|
@@ -5189,15 +6169,22 @@ export class FleetManager {
|
|
|
5189
6169
|
return;
|
|
5190
6170
|
}
|
|
5191
6171
|
for (let attempt = 1;; attempt++) {
|
|
5192
|
-
if (this.ipcStoppingInstances.has("__fleet_stopping__"))
|
|
6172
|
+
if (!current() || this.ipcStoppingInstances.has("__fleet_stopping__"))
|
|
5193
6173
|
return;
|
|
5194
6174
|
const delay = attempt <= 3 ? 5000 * Math.pow(2, attempt - 1) : 60_000; // 5s, 10s, 20s, then 60s
|
|
5195
6175
|
await new Promise(r => setTimeout(r, delay));
|
|
5196
|
-
if (this.ipcStoppingInstances.has("__fleet_stopping__"))
|
|
6176
|
+
if (!current() || this.ipcStoppingInstances.has("__fleet_stopping__"))
|
|
6177
|
+
return;
|
|
6178
|
+
lease = trySettingsLease([`connection:${this.dataDir}:${id}`]);
|
|
6179
|
+
if (!lease)
|
|
5197
6180
|
return;
|
|
5198
6181
|
try {
|
|
5199
6182
|
await adapter.stop().catch(() => { });
|
|
6183
|
+
if (!current())
|
|
6184
|
+
return;
|
|
5200
6185
|
await adapter.start();
|
|
6186
|
+
if (!current())
|
|
6187
|
+
return;
|
|
5201
6188
|
this.logger.info({ id, attempt }, "Adapter restarted successfully");
|
|
5202
6189
|
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
5203
6190
|
if (this.discordPresenceTimer && adapter.type === "discord") {
|
|
@@ -5206,18 +6193,23 @@ export class FleetManager {
|
|
|
5206
6193
|
return;
|
|
5207
6194
|
}
|
|
5208
6195
|
catch (err) {
|
|
6196
|
+
if (!current())
|
|
6197
|
+
return;
|
|
5209
6198
|
this.adapterState.set(id, {
|
|
5210
6199
|
status: "retrying",
|
|
5211
6200
|
retryCount: attempt,
|
|
5212
6201
|
lastError: err?.message ?? String(err),
|
|
5213
6202
|
});
|
|
5214
6203
|
}
|
|
6204
|
+
lease.release();
|
|
6205
|
+
lease = null;
|
|
5215
6206
|
if (attempt % 10 === 0) {
|
|
5216
6207
|
this.logger.warn({ id, attempt }, "Adapter restart still failing");
|
|
5217
6208
|
}
|
|
5218
6209
|
}
|
|
5219
6210
|
}
|
|
5220
6211
|
finally {
|
|
6212
|
+
lease?.release();
|
|
5221
6213
|
this.adapterRestarting.delete(id);
|
|
5222
6214
|
}
|
|
5223
6215
|
}
|
|
@@ -5442,12 +6434,29 @@ export class FleetManager {
|
|
|
5442
6434
|
/**
|
|
5443
6435
|
* #1346: whether this copy may claim the shared dedup key for
|
|
5444
6436
|
* command-like text. Fleet topics resolve an owning adapter and only its
|
|
5445
|
-
* copy proceeds
|
|
5446
|
-
*
|
|
6437
|
+
* copy proceeds. Classic targets and unknown routing keep their existing
|
|
6438
|
+
* handling. At the receiving Telegram world's own forum root, an explicit
|
|
6439
|
+
* suffix requires a known matching username before dedup; present-thread
|
|
6440
|
+
* copies retain their permissive handling when that identity is unknown.
|
|
5447
6441
|
*/
|
|
5448
6442
|
isOwnerCommandCopy(msg, threadId) {
|
|
5449
|
-
if (!msg.adapterId
|
|
6443
|
+
if (!msg.adapterId)
|
|
5450
6444
|
return true;
|
|
6445
|
+
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
6446
|
+
if (threadId === undefined) {
|
|
6447
|
+
// General at the forum root belongs to the receiving world. Prove an
|
|
6448
|
+
// explicit suffix before shared dedup, even when there is no message id.
|
|
6449
|
+
// Classic/private/foreign-forum copies retain their own dispatch rules.
|
|
6450
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
6451
|
+
const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
|
|
6452
|
+
?? this.worlds.get(msg.adapterId)?.channelConfig;
|
|
6453
|
+
if (msg.source === "telegram" && channel?.type === "telegram"
|
|
6454
|
+
&& channel.group_id != null && String(channel.group_id) === msg.chatId && suffix) {
|
|
6455
|
+
const username = this.worlds.get(msg.adapterId)?.botUsername;
|
|
6456
|
+
return !!username && suffix.toLowerCase() === username.toLowerCase();
|
|
6457
|
+
}
|
|
6458
|
+
return true;
|
|
6459
|
+
}
|
|
5451
6460
|
const target = this.resolveInboundTarget(msg, threadId);
|
|
5452
6461
|
if (!target || target.kind === "classic")
|
|
5453
6462
|
return true;
|
|
@@ -5460,7 +6469,6 @@ export class FleetManager {
|
|
|
5460
6469
|
// Telegram classic branch.
|
|
5461
6470
|
// Hyphenated aliases count too (e.g. /install-cli): Telegram command
|
|
5462
6471
|
// names cannot contain "-", but typed aliases can.
|
|
5463
|
-
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
5464
6472
|
if (suffix && owner) {
|
|
5465
6473
|
const ownerUser = this.worlds.get(owner)?.botUsername;
|
|
5466
6474
|
if (ownerUser && suffix.toLowerCase() !== ownerUser.toLowerCase())
|
|
@@ -5477,6 +6485,32 @@ export class FleetManager {
|
|
|
5477
6485
|
});
|
|
5478
6486
|
}
|
|
5479
6487
|
async handleInboundMessage(msg) {
|
|
6488
|
+
// Platform author identity is the authority. This runs before routing,
|
|
6489
|
+
// collab/commands/access/dedup and does not depend on any echo setting/ACK.
|
|
6490
|
+
const fleetBotIds = new Set();
|
|
6491
|
+
for (const world of this.worlds.values()) {
|
|
6492
|
+
if (world.adapter.type !== msg.source)
|
|
6493
|
+
continue;
|
|
6494
|
+
const id = world.adapter.getBotUserId?.() ?? world.botUserId;
|
|
6495
|
+
if (id)
|
|
6496
|
+
fleetBotIds.add(id);
|
|
6497
|
+
}
|
|
6498
|
+
if (isWebChannelEcho(msg.text ?? "", msg.userId, fleetBotIds))
|
|
6499
|
+
return;
|
|
6500
|
+
const configured = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
6501
|
+
const identitiesPending = configured.some(channel => {
|
|
6502
|
+
if (channel.type !== msg.source)
|
|
6503
|
+
return false;
|
|
6504
|
+
const world = this.worlds.get(channel.id ?? channel.type);
|
|
6505
|
+
return !(world?.adapter.getBotUserId?.() ?? world?.botUserId);
|
|
6506
|
+
});
|
|
6507
|
+
// Startup/rebuild can receive a replay before another configured world's
|
|
6508
|
+
// authenticated ID is ready. Quarantine only bot-flagged prefix candidates;
|
|
6509
|
+
// humans keep flowing, and no unknown author is labelled a fleet account.
|
|
6510
|
+
if (identitiesPending && msg.isBotMessage === true && (msg.text ?? "").startsWith(WEB_ECHO_PREFIX)) {
|
|
6511
|
+
this.logger.debug({ source: msg.source, adapterId: msg.adapterId }, "Web echo candidate quarantined while bot identities are pending");
|
|
6512
|
+
return;
|
|
6513
|
+
}
|
|
5480
6514
|
const threadId = this.inboundRouteThreadId(msg);
|
|
5481
6515
|
this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
|
|
5482
6516
|
// Ownership and per-adapter filtering both run before the dedup claim below.
|
|
@@ -5537,7 +6571,8 @@ export class FleetManager {
|
|
|
5537
6571
|
// per-adapter keys — first-/start-contact onboarding needs every copy
|
|
5538
6572
|
// to flow — so only fleet targets are judged here; the in-handler gates
|
|
5539
6573
|
// (owner entry check, Discord ignore) cover classic instead.
|
|
5540
|
-
if (msg.messageId
|
|
6574
|
+
if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
|
|
6575
|
+
&& /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
|
|
5541
6576
|
this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
|
|
5542
6577
|
return;
|
|
5543
6578
|
}
|
|
@@ -5604,42 +6639,12 @@ export class FleetManager {
|
|
|
5604
6639
|
}
|
|
5605
6640
|
// Handle /start command
|
|
5606
6641
|
if (text === "/start" || text.startsWith("/start ")) {
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
|
|
5614
|
-
return;
|
|
5615
|
-
}
|
|
5616
|
-
}
|
|
5617
|
-
else {
|
|
5618
|
-
if (!this.classicChannels.isGroupAllowed(chatId)) {
|
|
5619
|
-
// Notify admin about new group wanting access
|
|
5620
|
-
const groupTitle = msg.chatTitle || chatId;
|
|
5621
|
-
const adminMsg = t("alert.new_group", groupTitle, chatId, msg.username, msg.userId, msg.source);
|
|
5622
|
-
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
5623
|
-
if (generalId) {
|
|
5624
|
-
await this.promptClassicApproval({
|
|
5625
|
-
// Guarded by isGroupAllowed → this belongs in allowed_groups,
|
|
5626
|
-
// NOT allowed_guilds; writing the latter would change the file
|
|
5627
|
-
// without unblocking the group.
|
|
5628
|
-
generalName: generalId, message: adminMsg, groupId: String(chatId),
|
|
5629
|
-
scope: "group", userId: msg.userId,
|
|
5630
|
-
});
|
|
5631
|
-
}
|
|
5632
|
-
await msgAdapter?.sendText(chatId, t("classic.access_requested"));
|
|
5633
|
-
return;
|
|
5634
|
-
}
|
|
5635
|
-
if (!this.classicChannels.isAdmin(msg.userId)) {
|
|
5636
|
-
await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
|
|
5637
|
-
const generalId = this.findGeneralInstance(msg.adapterId);
|
|
5638
|
-
if (generalId) {
|
|
5639
|
-
this.notifyInstanceTopic(generalId, t("alert.start_not_admin", msg.username, msg.userId, msg.source, chatId));
|
|
5640
|
-
}
|
|
5641
|
-
return;
|
|
5642
|
-
}
|
|
6642
|
+
// All new starts, including explicit backend and later chooser callbacks,
|
|
6643
|
+
// share validateClassicStart. Existing channels are recognized first.
|
|
6644
|
+
const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
|
|
6645
|
+
if (blocker) {
|
|
6646
|
+
await msgAdapter?.sendText(chatId, blocker);
|
|
6647
|
+
return;
|
|
5643
6648
|
}
|
|
5644
6649
|
const channelName = msg.username || chatId;
|
|
5645
6650
|
const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
|
|
@@ -5675,7 +6680,8 @@ export class FleetManager {
|
|
|
5675
6680
|
}
|
|
5676
6681
|
const pauseWake = parsePauseWakeCommand(text);
|
|
5677
6682
|
if (pauseWake) {
|
|
5678
|
-
|
|
6683
|
+
// Channel-admin, as on Discord (#754): a fleet admin of this bot or a ClassicBot admin.
|
|
6684
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
5679
6685
|
await msgAdapter?.sendText(chatId, t("permission.denied"));
|
|
5680
6686
|
return;
|
|
5681
6687
|
}
|
|
@@ -5703,7 +6709,7 @@ export class FleetManager {
|
|
|
5703
6709
|
await msgAdapter?.sendText(chatId, await this.applyModel(modelInstance, modelName));
|
|
5704
6710
|
}
|
|
5705
6711
|
else if (msgAdapter) {
|
|
5706
|
-
const fallback = await this.promptModelMenu(modelInstance, msg.userId, chatId, msgAdapter, chatId);
|
|
6712
|
+
const fallback = await this.promptModelMenu(modelInstance, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
|
|
5707
6713
|
if (fallback)
|
|
5708
6714
|
await msgAdapter.sendText(chatId, fallback);
|
|
5709
6715
|
}
|
|
@@ -5712,7 +6718,8 @@ export class FleetManager {
|
|
|
5712
6718
|
// Handle /compact command (admin only)
|
|
5713
6719
|
const classicCompact = parseCompactCommand(text);
|
|
5714
6720
|
if (classicCompact) {
|
|
5715
|
-
|
|
6721
|
+
// Channel-admin, as on Discord (#754): a fleet admin of this bot or a ClassicBot admin.
|
|
6722
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
5716
6723
|
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
|
|
5717
6724
|
return;
|
|
5718
6725
|
}
|
|
@@ -5804,7 +6811,8 @@ export class FleetManager {
|
|
|
5804
6811
|
}
|
|
5805
6812
|
// Handle /save command (admin only)
|
|
5806
6813
|
if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
|
|
5807
|
-
|
|
6814
|
+
// Channel-admin, as on Discord (#754): a fleet admin of this bot or a ClassicBot admin.
|
|
6815
|
+
if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
|
|
5808
6816
|
await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
|
|
5809
6817
|
return;
|
|
5810
6818
|
}
|
|
@@ -5919,8 +6927,8 @@ export class FleetManager {
|
|
|
5919
6927
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
5920
6928
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
|
|
5921
6929
|
this.emitSseEvent("message", {
|
|
5922
|
-
instance: generalInstance, sender: msg.username,
|
|
5923
|
-
text: (text ?? "").slice(0,
|
|
6930
|
+
instance: generalInstance, sender: msg.username, role: "user",
|
|
6931
|
+
text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
5924
6932
|
});
|
|
5925
6933
|
this.trackInboundMsg(generalInstance, msg);
|
|
5926
6934
|
void this.sendCancelButton(generalInstance);
|
|
@@ -6033,8 +7041,8 @@ export class FleetManager {
|
|
|
6033
7041
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
6034
7042
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
6035
7043
|
this.emitSseEvent("message", {
|
|
6036
|
-
instance: instanceName, sender: msg.username,
|
|
6037
|
-
text: (text ?? "").slice(0,
|
|
7044
|
+
instance: instanceName, sender: msg.username, role: "user",
|
|
7045
|
+
text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
6038
7046
|
});
|
|
6039
7047
|
this.trackInboundMsg(instanceName, msg);
|
|
6040
7048
|
void this.sendCancelButton(instanceName);
|
|
@@ -6086,6 +7094,34 @@ export class FleetManager {
|
|
|
6086
7094
|
* their fleet is organised. Both stay theirs — this only makes sure they are
|
|
6087
7095
|
* not discovered by an agent failing at three in the morning.
|
|
6088
7096
|
*/
|
|
7097
|
+
/**
|
|
7098
|
+
* #1366: the first time a fleet runs 2.2 or later, tell each chat platform's General — once — that its agents can
|
|
7099
|
+
* now be talked to from a browser. Claimed in upgrade-notices.json before the send, released if the send fails.
|
|
7100
|
+
* Platforms with nowhere to post fleet notices (no group, or a Discord fleet with no General channel) are skipped
|
|
7101
|
+
* and not recorded, so they are told once they have one.
|
|
7102
|
+
*/
|
|
7103
|
+
async announceWebChatOnce(version) {
|
|
7104
|
+
if (!hasWebChat(version))
|
|
7105
|
+
return;
|
|
7106
|
+
const path = upgradeNoticesPath(this.dataDir);
|
|
7107
|
+
for (const [adapterId, world] of this.worlds) {
|
|
7108
|
+
if (this.shuttingDown)
|
|
7109
|
+
return;
|
|
7110
|
+
const target = this.fleetNoticeTarget(adapterId);
|
|
7111
|
+
if (!target)
|
|
7112
|
+
continue;
|
|
7113
|
+
if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
|
|
7114
|
+
continue;
|
|
7115
|
+
try {
|
|
7116
|
+
await world.adapter.sendText(target.chatId, t("upgrade.web_chat", WEB_REMOTE_DOCS_URL), target.opts);
|
|
7117
|
+
this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
|
|
7118
|
+
}
|
|
7119
|
+
catch (err) {
|
|
7120
|
+
const released = releaseNotice(path, WEB_CHAT_NOTICE, adapterId);
|
|
7121
|
+
this.logger.warn({ err, adapterId, released }, "Could not announce web chat in General — will try at the next start");
|
|
7122
|
+
}
|
|
7123
|
+
}
|
|
7124
|
+
}
|
|
6089
7125
|
announceToolPermissionsChange() {
|
|
6090
7126
|
try {
|
|
6091
7127
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 19).replace("T", " ");
|
|
@@ -6150,7 +7186,10 @@ export class FleetManager {
|
|
|
6150
7186
|
respond(null, permitted.message);
|
|
6151
7187
|
return;
|
|
6152
7188
|
}
|
|
6153
|
-
|
|
7189
|
+
// A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
|
|
7190
|
+
// else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
|
|
7191
|
+
const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
|
|
7192
|
+
if (this.worlds.size === 0 && !webOnlyReply) {
|
|
6154
7193
|
respond(null, "Channel adapters are not ready — retry shortly");
|
|
6155
7194
|
return;
|
|
6156
7195
|
}
|
|
@@ -6178,7 +7217,8 @@ export class FleetManager {
|
|
|
6178
7217
|
}
|
|
6179
7218
|
const outAdapter = contextWorld?.adapter
|
|
6180
7219
|
?? this.getAdapterForInstance(senderInstanceName ?? instanceName)
|
|
6181
|
-
?? this.adapter
|
|
7220
|
+
?? this.adapter
|
|
7221
|
+
?? (webOnlyReply ? WEB_ONLY_REPLY_SINK : null);
|
|
6182
7222
|
if (!outAdapter) {
|
|
6183
7223
|
respond(null, "No adapter available");
|
|
6184
7224
|
return;
|
|
@@ -6206,6 +7246,28 @@ export class FleetManager {
|
|
|
6206
7246
|
// still in flight and about to succeed. One real send, everyone gets its
|
|
6207
7247
|
// outcome; a genuinely failed send clears the entry so a retry passes.
|
|
6208
7248
|
if (tool === "reply") {
|
|
7249
|
+
// Registered synchronously before a web message is handed to this CLI.
|
|
7250
|
+
// This waits only on display ordering, never on the web delivery itself.
|
|
7251
|
+
const echoTail = this.webChannelEchoTails.get(instanceName)?.tail;
|
|
7252
|
+
if (echoTail) {
|
|
7253
|
+
const replyClient = this.instanceIpcClients.get(instanceName);
|
|
7254
|
+
const bindingName = senderInstanceName ?? instanceName;
|
|
7255
|
+
const binding = this.getAdapterForInstance(bindingName);
|
|
7256
|
+
const bindingGroup = this.getGroupIdForInstance(bindingName);
|
|
7257
|
+
const bindingTopic = this.fleetConfig?.instances[bindingName]?.topic_id;
|
|
7258
|
+
await echoTail;
|
|
7259
|
+
// A replacement daemon must not receive this old tool's response or
|
|
7260
|
+
// publish its reply after the new async ordering boundary.
|
|
7261
|
+
if (this.instanceIpcClients.get(instanceName) !== replyClient)
|
|
7262
|
+
return;
|
|
7263
|
+
if (this.getAdapterForInstance(bindingName) !== binding
|
|
7264
|
+
|| this.getGroupIdForInstance(bindingName) !== bindingGroup
|
|
7265
|
+
|| this.fleetConfig?.instances[bindingName]?.topic_id !== bindingTopic
|
|
7266
|
+
|| (contextAdapterId && this.worlds.get(contextAdapterId)?.adapter !== outAdapter)) {
|
|
7267
|
+
respond(null, "Channel binding changed while waiting for the web echo");
|
|
7268
|
+
return;
|
|
7269
|
+
}
|
|
7270
|
+
}
|
|
6209
7271
|
// Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
|
|
6210
7272
|
const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
|
|
6211
7273
|
if (stickerProblem) {
|
|
@@ -6238,6 +7300,14 @@ export class FleetManager {
|
|
|
6238
7300
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
6239
7301
|
}
|
|
6240
7302
|
}
|
|
7303
|
+
else if (!error && result != null && outAdapter === WEB_ONLY_REPLY_SINK) {
|
|
7304
|
+
// A daemon status line skips the bookkeeping, but on a web-only fleet the web chat is the only
|
|
7305
|
+
// place anyone could read it.
|
|
7306
|
+
this.emitSseEvent("message", {
|
|
7307
|
+
instance: instanceName, sender: senderSessionName ?? instanceName, role: "status",
|
|
7308
|
+
text: String(args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
7309
|
+
});
|
|
7310
|
+
}
|
|
6241
7311
|
};
|
|
6242
7312
|
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
|
|
6243
7313
|
return;
|
|
@@ -6266,6 +7336,15 @@ export class FleetManager {
|
|
|
6266
7336
|
}
|
|
6267
7337
|
}
|
|
6268
7338
|
/** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
|
|
7339
|
+
/** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
|
|
7340
|
+
isWebOnlyFleet() {
|
|
7341
|
+
const config = this.fleetConfig;
|
|
7342
|
+
return config != null && !config.channel && !(config.channels?.length);
|
|
7343
|
+
}
|
|
7344
|
+
/** On a fleet with no chat platform, the dashboard is where an instance-health prompt is posted (#1307 item 6). */
|
|
7345
|
+
webOnlyPromptPlace() {
|
|
7346
|
+
return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
|
|
7347
|
+
}
|
|
6269
7348
|
afterReplyRouted(instanceName, args, senderSessionName) {
|
|
6270
7349
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
6271
7350
|
// more work either. Split the difference: an instance that is clearly
|
|
@@ -6283,10 +7362,20 @@ export class FleetManager {
|
|
|
6283
7362
|
this.reactDone(instanceName);
|
|
6284
7363
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
6285
7364
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
7365
|
+
// Files the agent attached are shown in the web chat too: registered for fetching by id (the path
|
|
7366
|
+
// already passed the reply tool's sendability check, and is resolved once more here), never by path.
|
|
7367
|
+
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
7368
|
+
const attachments = replyFiles
|
|
7369
|
+
.map(path => this.webFiles.registerServed({ path, instance: instanceName }))
|
|
7370
|
+
.filter((f) => f !== null)
|
|
7371
|
+
.map(publicAttachment);
|
|
7372
|
+
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
7373
|
+
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
6286
7374
|
this.emitSseEvent("message", {
|
|
6287
|
-
instance: instanceName, sender: senderSessionName ?? instanceName,
|
|
6288
|
-
text: (args.text ?? "").slice(0,
|
|
7375
|
+
instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
|
|
7376
|
+
text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
|
|
6289
7377
|
ts: new Date().toISOString(),
|
|
7378
|
+
...(attachments.length ? { attachments } : {}),
|
|
6290
7379
|
});
|
|
6291
7380
|
// Log bot reply to classic instance chat-log
|
|
6292
7381
|
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
@@ -6295,22 +7384,57 @@ export class FleetManager {
|
|
|
6295
7384
|
}
|
|
6296
7385
|
}
|
|
6297
7386
|
// ===================== Scheduler =====================
|
|
6298
|
-
async handleScheduleTrigger(schedule, stableRunId = randomUUID()) {
|
|
6299
|
-
const { target, reply_chat_id, reply_thread_id,
|
|
7387
|
+
async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
|
|
7388
|
+
const { target, reply_chat_id, reply_thread_id, label, id, source, silent } = schedule;
|
|
7389
|
+
// #1426: the retry of a deferred occurrence says so in every status it records.
|
|
7390
|
+
const runStatus = (status) => (retry ? `deferred → ${status} (retry)` : status);
|
|
6300
7391
|
const RATE_LIMIT_DEFER_THRESHOLD = 85;
|
|
6301
7392
|
const rl = this.statuslineWatcher.getRateLimits(target);
|
|
6302
|
-
|
|
6303
|
-
|
|
7393
|
+
// A reading whose window has reset describes a window that no longer exists: claude-code rewrites statusline.json
|
|
7394
|
+
// only when it renders, so an idle instance keeps its last percentage long after the reset (#1426).
|
|
7395
|
+
const nowMs = Date.now();
|
|
7396
|
+
const windowCurrent = rl != null && (rl.five_hour_resets_at_ms === null || nowMs < rl.five_hour_resets_at_ms);
|
|
7397
|
+
if (rl && windowCurrent && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
|
|
7398
|
+
const resetsAtMs = rl.five_hour_resets_at_ms;
|
|
7399
|
+
if (retry) {
|
|
7400
|
+
// The window it waited for reset, and a new one is over the threshold again: deferred again — given up.
|
|
7401
|
+
if (retry.resets_at_ms !== null && nowMs >= retry.resets_at_ms) {
|
|
7402
|
+
if (this.scheduler.endRetry(retry))
|
|
7403
|
+
this.scheduleRetryDropped(schedule, retry, "deferred_again");
|
|
7404
|
+
return;
|
|
7405
|
+
}
|
|
7406
|
+
// No reset time to wait for yet (or a new one learnt): look again later, within the deadline.
|
|
7407
|
+
const later = this.scheduler.postponeRetry(retry, resetsAtMs, nowMs);
|
|
7408
|
+
if ("dropped" in later)
|
|
7409
|
+
this.scheduleRetryDropped(schedule, later.retry, later.dropped);
|
|
7410
|
+
else
|
|
7411
|
+
this.logger.info({ target, scheduleId: id, runId: stableRunId, dueAt: new Date(later.due_at_ms).toISOString() }, "Schedule retry still rate limited — looking again later");
|
|
7412
|
+
return;
|
|
7413
|
+
}
|
|
7414
|
+
const pending = this.scheduler.deferForRetry(schedule, stableRunId, { deferredPct: rl.five_hour_pct, resetsAtMs, nowMs });
|
|
7415
|
+
const dueAt = "dropped" in pending ? null : pending.due_at_ms;
|
|
7416
|
+
this.scheduler.recordRun(id, "deferred", `5hr rate limit at ${rl.five_hour_pct}%`
|
|
7417
|
+
+ (dueAt !== null ? `; retry at ${new Date(dueAt).toISOString()}` : ""));
|
|
6304
7418
|
this.eventLog?.insert(target, "schedule_deferred", {
|
|
6305
7419
|
schedule_id: id,
|
|
6306
7420
|
label,
|
|
6307
7421
|
five_hour_pct: rl.five_hour_pct,
|
|
7422
|
+
retry_at: dueAt !== null ? new Date(dueAt).toISOString() : null,
|
|
6308
7423
|
});
|
|
6309
7424
|
this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
|
|
6310
|
-
this.notifyInstanceTopic(target,
|
|
6311
|
-
|
|
7425
|
+
this.notifyInstanceTopic(target, dueAt !== null
|
|
7426
|
+
? t("schedule.deferred_retry", label ?? id, rl.five_hour_pct, scheduleClock(dueAt, schedule.timezone))
|
|
7427
|
+
: t("schedule.deferred", label ?? id, rl.five_hour_pct));
|
|
7428
|
+
this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct, retryAt: dueAt }, "Schedule deferred due to rate limit");
|
|
7429
|
+
if ("dropped" in pending)
|
|
7430
|
+
this.scheduleRetryDropped(schedule, pending.retry, pending.dropped);
|
|
6312
7431
|
return;
|
|
6313
7432
|
}
|
|
7433
|
+
// #1426: the retry runs now — removed first, so a crash from here loses it rather than running it twice.
|
|
7434
|
+
if (retry && !this.scheduler.endRetry(retry))
|
|
7435
|
+
return;
|
|
7436
|
+
// The agent sees it is a retry, and of what (a silent schedule pastes its raw command unchanged).
|
|
7437
|
+
const message = retry && !silent ? `${scheduleRetryLabel(retry, schedule.timezone)}\n${schedule.message}` : schedule.message;
|
|
6314
7438
|
// Silent mode: paste directly to tmux pane — no channel message.
|
|
6315
7439
|
if (silent) {
|
|
6316
7440
|
// The outbox commit is the schedule's durable acceptance point. The run
|
|
@@ -6336,7 +7460,7 @@ export class FleetManager {
|
|
|
6336
7460
|
schedule_run_id: stableRunId,
|
|
6337
7461
|
},
|
|
6338
7462
|
});
|
|
6339
|
-
this.scheduler.recordRun(id, "queued", `durable raw_paste delivery_id=${admitted.delivery.deliveryId}`);
|
|
7463
|
+
this.scheduler.recordRun(id, runStatus("queued"), `durable raw_paste delivery_id=${admitted.delivery.deliveryId}`);
|
|
6340
7464
|
this.logger.info({ target, scheduleId: id, runId: stableRunId, deliveryId: admitted.delivery.deliveryId, duplicate: !admitted.inserted }, "Silent schedule durably admitted as raw_paste");
|
|
6341
7465
|
this.scheduleDeliveryOutboxPump();
|
|
6342
7466
|
return;
|
|
@@ -6344,7 +7468,12 @@ export class FleetManager {
|
|
|
6344
7468
|
const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
|
|
6345
7469
|
const retryCount = schedulerDefaults?.retry_count ?? 3;
|
|
6346
7470
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
7471
|
+
// #1426: a retry is only good until its deadline (the next occurrence or the cap) — through the idle wait and up to
|
|
7472
|
+
// the IPC hand-off itself, not just when it starts.
|
|
7473
|
+
const retryCurrent = retry ? () => Date.now() < retry.deadline_ms : undefined;
|
|
6347
7474
|
const deliver = async () => {
|
|
7475
|
+
if (retryCurrent && !retryCurrent())
|
|
7476
|
+
return "past-deadline";
|
|
6348
7477
|
try {
|
|
6349
7478
|
// A schedule has no live inbound adapter context. Seed the daemon with
|
|
6350
7479
|
// the target instance's configured world so replies use its persona
|
|
@@ -6361,7 +7490,7 @@ export class FleetManager {
|
|
|
6361
7490
|
if (foreignWorld) {
|
|
6362
7491
|
this.logger.warn({ scheduleId: id, target, foreignWorld, targetWorld: adapterId, chatId: reply_chat_id }, "Schedule reply target belongs to another channel world — not seeding chat context; the instance keeps its own last known chat");
|
|
6363
7492
|
}
|
|
6364
|
-
await this.deliverToInstance(target, {
|
|
7493
|
+
const handed = await this.deliverToInstance(target, {
|
|
6365
7494
|
type: "fleet_schedule_trigger",
|
|
6366
7495
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
6367
7496
|
meta: {
|
|
@@ -6372,7 +7501,9 @@ export class FleetManager {
|
|
|
6372
7501
|
user: "scheduler",
|
|
6373
7502
|
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
6374
7503
|
},
|
|
6375
|
-
}, { waitForIdle: true });
|
|
7504
|
+
}, { waitForIdle: true, ...(retryCurrent ? { stillCurrent: retryCurrent } : {}) });
|
|
7505
|
+
if (handed === false && retryCurrent && !retryCurrent())
|
|
7506
|
+
return "past-deadline";
|
|
6376
7507
|
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
6377
7508
|
void this.sendCancelButton(target);
|
|
6378
7509
|
return true;
|
|
@@ -6382,22 +7513,35 @@ export class FleetManager {
|
|
|
6382
7513
|
return false;
|
|
6383
7514
|
}
|
|
6384
7515
|
};
|
|
6385
|
-
|
|
6386
|
-
this.
|
|
7516
|
+
const pastDeadline = () => {
|
|
7517
|
+
this.scheduleRetryDropped(schedule, retry, retry.deadline_kind === "next_occurrence" ? "superseded" : "expired");
|
|
7518
|
+
};
|
|
7519
|
+
const first = await deliver();
|
|
7520
|
+
if (first === "past-deadline") {
|
|
7521
|
+
pastDeadline();
|
|
7522
|
+
return;
|
|
7523
|
+
}
|
|
7524
|
+
if (first) {
|
|
7525
|
+
this.scheduler.recordRun(id, runStatus("delivered"));
|
|
6387
7526
|
if (source !== target)
|
|
6388
7527
|
this.notifySourceTopic(schedule);
|
|
6389
7528
|
return;
|
|
6390
7529
|
}
|
|
6391
7530
|
for (let i = 0; i < retryCount; i++) {
|
|
6392
7531
|
await new Promise((r) => setTimeout(r, retryInterval));
|
|
6393
|
-
|
|
6394
|
-
|
|
7532
|
+
const again = await deliver();
|
|
7533
|
+
if (again === "past-deadline") {
|
|
7534
|
+
pastDeadline();
|
|
7535
|
+
return;
|
|
7536
|
+
}
|
|
7537
|
+
if (again) {
|
|
7538
|
+
this.scheduler.recordRun(id, runStatus("delivered"));
|
|
6395
7539
|
if (source !== target)
|
|
6396
7540
|
this.notifySourceTopic(schedule);
|
|
6397
7541
|
return;
|
|
6398
7542
|
}
|
|
6399
7543
|
}
|
|
6400
|
-
this.scheduler.recordRun(id, "instance_offline", `retry ${retryCount}x failed`);
|
|
7544
|
+
this.scheduler.recordRun(id, runStatus("instance_offline"), `retry ${retryCount}x failed`);
|
|
6401
7545
|
this.notifyScheduleFailure(schedule);
|
|
6402
7546
|
}
|
|
6403
7547
|
/**
|
|
@@ -6477,6 +7621,38 @@ export class FleetManager {
|
|
|
6477
7621
|
threadId: schedule.reply_thread_id ?? undefined,
|
|
6478
7622
|
}).catch((err) => this.logger.error({ err }, "Failed to send schedule failure notification"));
|
|
6479
7623
|
}
|
|
7624
|
+
/**
|
|
7625
|
+
* #1426: a deferred occurrence's retry will not run — superseded by the next occurrence, deferred again, or its wait
|
|
7626
|
+
* capped. Recorded as `deferred → skipped (…)`, and said in the schedule's own chat with its admins @mentioned:
|
|
7627
|
+
* this is the case that used to be a silently lost day.
|
|
7628
|
+
*/
|
|
7629
|
+
scheduleRetryDropped(schedule, retry, reason) {
|
|
7630
|
+
const why = { superseded: "superseded", deferred_again: "deferred again", expired: "expired" }[reason];
|
|
7631
|
+
this.scheduler?.recordRun(schedule.id, `deferred → skipped (${why})`, `run ${retry.run_id} deferred at ${retry.deferred_pct}%`);
|
|
7632
|
+
this.eventLog?.insert(schedule.target, "schedule_retry_dropped", { schedule_id: schedule.id, label: schedule.label, run_id: retry.run_id, reason });
|
|
7633
|
+
this.logger.warn({ scheduleId: schedule.id, target: schedule.target, runId: retry.run_id, reason }, "Deferred schedule occurrence will not run");
|
|
7634
|
+
const reasonText = reason === "superseded"
|
|
7635
|
+
? t("schedule.retry_reason_superseded", scheduleClock(retry.deadline_ms, schedule.timezone))
|
|
7636
|
+
: reason === "deferred_again" ? t("schedule.retry_reason_deferred_again")
|
|
7637
|
+
: t("schedule.retry_reason_expired", Math.round(Scheduler.RETRY_MAX_WAIT_MS / 60_000));
|
|
7638
|
+
const adapter = this.scheduleSourceAdapter(schedule);
|
|
7639
|
+
if (!adapter) {
|
|
7640
|
+
this.notifyInstanceTopic(schedule.target, t("schedule.retry_dropped", "", schedule.label ?? schedule.id, scheduleClock(Date.parse(retry.run_id) || retry.deferred_at_ms, schedule.timezone), retry.deferred_pct, reasonText).trimStart());
|
|
7641
|
+
return;
|
|
7642
|
+
}
|
|
7643
|
+
const admins = this.adminListOf(adapter.id) ?? [];
|
|
7644
|
+
const html = adapter.type === "telegram";
|
|
7645
|
+
const mention = admins.map(adminId => adapter.type === "discord" ? `<@${adminId}>`
|
|
7646
|
+
: html ? `<a href="tg://user?id=${encodeURIComponent(adminId)}">admin</a>` : "").filter(Boolean).join(" ");
|
|
7647
|
+
const due = scheduleClock(Date.parse(retry.run_id) || retry.deferred_at_ms, schedule.timezone);
|
|
7648
|
+
const text = html
|
|
7649
|
+
? t("schedule.retry_dropped", mention, escapeTelegramHtml(schedule.label ?? schedule.id), due, retry.deferred_pct, escapeTelegramHtml(reasonText))
|
|
7650
|
+
: t("schedule.retry_dropped", mention, schedule.label ?? schedule.id, due, retry.deferred_pct, reasonText);
|
|
7651
|
+
adapter.sendText(schedule.reply_chat_id, text.trimStart(), {
|
|
7652
|
+
threadId: schedule.reply_thread_id ?? undefined,
|
|
7653
|
+
...(html ? { format: "html" } : {}),
|
|
7654
|
+
}).catch((err) => this.logger.error({ err }, "Failed to send the deferred-schedule escalation"));
|
|
7655
|
+
}
|
|
6480
7656
|
/**
|
|
6481
7657
|
* The typed IPC messages, all through one door.
|
|
6482
7658
|
*
|
|
@@ -7353,6 +8529,7 @@ export class FleetManager {
|
|
|
7353
8529
|
* non-identity instance leaves are canonicalized to inheritance afterward.
|
|
7354
8530
|
*/
|
|
7355
8531
|
saveFleetConfig(explicitPatches = []) {
|
|
8532
|
+
this.publicWebLink?.refresh();
|
|
7356
8533
|
if (!this.fleetConfig || !this.configPath)
|
|
7357
8534
|
return;
|
|
7358
8535
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -7398,6 +8575,7 @@ export class FleetManager {
|
|
|
7398
8575
|
this.rawFleetDocument.deleteIn(parentPath);
|
|
7399
8576
|
}
|
|
7400
8577
|
}
|
|
8578
|
+
noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
7401
8579
|
const output = String(this.rawFleetDocument);
|
|
7402
8580
|
if (redundantPaths.length > 0)
|
|
7403
8581
|
this.writeFleetConfigBackup(source);
|
|
@@ -7440,9 +8618,18 @@ export class FleetManager {
|
|
|
7440
8618
|
renameSync(tempPath, backupPath);
|
|
7441
8619
|
}
|
|
7442
8620
|
patchFleetDocument(document, path, before, after) {
|
|
7443
|
-
if (Object.is(before, after))
|
|
8621
|
+
if (Object.is(before, after) || JSON.stringify(before) === JSON.stringify(after))
|
|
7444
8622
|
return;
|
|
7445
8623
|
if (Array.isArray(before) && Array.isArray(after)) {
|
|
8624
|
+
// Connection deltas are indexed only while the stable identities still
|
|
8625
|
+
// match. Never apply a stale manager's edit to a reordered world.
|
|
8626
|
+
if (path.length === 1 && path[0] === "channels") {
|
|
8627
|
+
const fresh = document.getIn(path)?.toJSON?.() ?? document.getIn(path);
|
|
8628
|
+
const ids = (items) => items.map(item => item?.id ?? item?.type);
|
|
8629
|
+
if (Array.isArray(fresh) && JSON.stringify(ids(fresh)) !== JSON.stringify(ids(before))) {
|
|
8630
|
+
throw new Error("Connection order changed; reload before saving");
|
|
8631
|
+
}
|
|
8632
|
+
}
|
|
7446
8633
|
const shared = Math.min(before.length, after.length);
|
|
7447
8634
|
for (let i = 0; i < shared; i++) {
|
|
7448
8635
|
this.patchFleetDocument(document, [...path, i], before[i], after[i]);
|
|
@@ -7498,42 +8685,66 @@ export class FleetManager {
|
|
|
7498
8685
|
}
|
|
7499
8686
|
}
|
|
7500
8687
|
}
|
|
7501
|
-
|
|
8688
|
+
getSettingsConfigPath() { return this.configPath; }
|
|
8689
|
+
async removeInstance(name, authorization, execution) {
|
|
8690
|
+
if (execution)
|
|
8691
|
+
return this.lifecycle.runTransition(name, transition => this.removeInstanceOwned(name, authorization, execution, transition));
|
|
8692
|
+
return this.removeInstanceOwned(name, authorization);
|
|
8693
|
+
}
|
|
8694
|
+
async removeInstanceOwned(name, authorization, execution, transition) {
|
|
8695
|
+
execution?.assert();
|
|
7502
8696
|
assertExplicitInstanceRemoval(authorization);
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
}
|
|
7514
|
-
// Clean up team memberships
|
|
7515
|
-
if (this.fleetConfig?.teams) {
|
|
7516
|
-
for (const [teamName, team] of Object.entries(this.fleetConfig.teams)) {
|
|
7517
|
-
const idx = team.members.indexOf(name);
|
|
7518
|
-
if (idx !== -1) {
|
|
7519
|
-
team.members.splice(idx, 1);
|
|
7520
|
-
this.logger.info({ team: teamName, instance: name }, "Removed deleted instance from team");
|
|
8697
|
+
const beforeRemoval = () => {
|
|
8698
|
+
// Drop cached pane context — the map is keyed by instance name and nothing
|
|
8699
|
+
// else evicted deleted entries, so it grew for the life of the process.
|
|
8700
|
+
forgetInstanceContext(name);
|
|
8701
|
+
// Clean up schedules (scheduler is fleet-level, not lifecycle-level)
|
|
8702
|
+
const config = this.fleetConfig?.instances[name];
|
|
8703
|
+
if (this.scheduler && config?.topic_id) {
|
|
8704
|
+
const count = this.scheduler.deleteByInstanceOrThread(name, String(config.topic_id));
|
|
8705
|
+
if (count > 0) {
|
|
8706
|
+
this.logger.info({ name, count }, "Cleaned up schedules for deleted instance");
|
|
7521
8707
|
}
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
8708
|
+
}
|
|
8709
|
+
// Clean up team memberships
|
|
8710
|
+
if (this.fleetConfig?.teams) {
|
|
8711
|
+
for (const [teamName, team] of Object.entries(this.fleetConfig.teams)) {
|
|
8712
|
+
const idx = team.members.indexOf(name);
|
|
8713
|
+
if (idx !== -1) {
|
|
8714
|
+
team.members.splice(idx, 1);
|
|
8715
|
+
this.logger.info({ team: teamName, instance: name }, "Removed deleted instance from team");
|
|
8716
|
+
}
|
|
8717
|
+
if (team.members.length === 0) {
|
|
8718
|
+
delete this.fleetConfig.teams[teamName];
|
|
8719
|
+
this.logger.info({ team: teamName }, "Deleted empty team");
|
|
8720
|
+
}
|
|
7525
8721
|
}
|
|
7526
8722
|
}
|
|
7527
|
-
}
|
|
8723
|
+
};
|
|
8724
|
+
if (execution)
|
|
8725
|
+
execution.mutate(beforeRemoval);
|
|
8726
|
+
else
|
|
8727
|
+
beforeRemoval();
|
|
7528
8728
|
// Capture instance dir BEFORE lifecycle.remove, which deletes the instance
|
|
7529
8729
|
// from fleetConfig. (The backend would be unresolvable afterwards.)
|
|
7530
8730
|
const instanceDirToClean = this.getInstanceDir(name);
|
|
7531
|
-
|
|
8731
|
+
if (execution)
|
|
8732
|
+
await this.lifecycle.remove(name, authorization, execution, transition);
|
|
8733
|
+
else
|
|
8734
|
+
await this.lifecycle.remove(name, authorization);
|
|
8735
|
+
// A later same-name registration owns its files and caches.
|
|
8736
|
+
if (this.fleetConfig?.instances[name] || this.daemons.has(name))
|
|
8737
|
+
return;
|
|
8738
|
+
execution?.assert();
|
|
7532
8739
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
7533
8740
|
// as instances are created and deleted over the lifetime of the fleet.
|
|
7534
8741
|
this.lastActivity.delete(name);
|
|
7535
8742
|
this.lastInboundUser.delete(name);
|
|
7536
8743
|
this.rateLimitWarnedAt.delete(name);
|
|
8744
|
+
// Its web chat goes with it (only after the removal succeeded): a later instance of the same name
|
|
8745
|
+
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
8746
|
+
this.webChatHistory.forget(name);
|
|
8747
|
+
this.webFiles.forget(name);
|
|
7537
8748
|
// Clean up statusline watcher + instance directory
|
|
7538
8749
|
this.statuslineWatcher.unwatch(name);
|
|
7539
8750
|
// instanceDirToClean and effectiveBackend are captured before lifecycle.remove
|
|
@@ -7552,6 +8763,7 @@ export class FleetManager {
|
|
|
7552
8763
|
// short home that would otherwise be inherited by a later same-name instance.
|
|
7553
8764
|
try {
|
|
7554
8765
|
const { CodexBackend } = await import("./backend/codex.js");
|
|
8766
|
+
execution?.assert();
|
|
7555
8767
|
const shortHome = CodexBackend.shortHomeFor(instanceDirToClean);
|
|
7556
8768
|
if (existsSync(shortHome))
|
|
7557
8769
|
rmSync(shortHome, { recursive: true, force: true });
|
|
@@ -7559,6 +8771,7 @@ export class FleetManager {
|
|
|
7559
8771
|
catch (err) {
|
|
7560
8772
|
this.logger.debug({ err, name }, "Codex short home cleanup failed");
|
|
7561
8773
|
}
|
|
8774
|
+
execution?.complete();
|
|
7562
8775
|
}
|
|
7563
8776
|
startStatuslineWatcher(name) {
|
|
7564
8777
|
if (this.lifecycle.isPaused(name))
|
|
@@ -7643,8 +8856,13 @@ export class FleetManager {
|
|
|
7643
8856
|
const listEmojis = adapter.listGuildEmojis.bind(adapter);
|
|
7644
8857
|
let servers;
|
|
7645
8858
|
try {
|
|
8859
|
+
// This authenticated metadata picker is not a start/admission path. Keep
|
|
8860
|
+
// its existing empty-list inventory so existing channels' emoji settings
|
|
8861
|
+
// do not disappear when new-start access changes (#1418).
|
|
8862
|
+
const guildList = this.classicChannels?.getDefaults().allowed_guilds;
|
|
8863
|
+
const unrestrictedInventory = !!this.classicChannels && (!Array.isArray(guildList) || guildList.length === 0);
|
|
7646
8864
|
servers = adapter.listMemberGuilds
|
|
7647
|
-
? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
|
|
8865
|
+
? (await adapter.listMemberGuilds()).filter(g => g.primary || unrestrictedInventory || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
|
|
7648
8866
|
: [{ id: "", name: "", primary: true }];
|
|
7649
8867
|
}
|
|
7650
8868
|
catch (e) {
|
|
@@ -8202,6 +9420,12 @@ export class FleetManager {
|
|
|
8202
9420
|
return reactionForm(resolved.platform, kind === "photo" ? resolved.photo : resolved.attachment);
|
|
8203
9421
|
}
|
|
8204
9422
|
reactMessageStatus(instanceName, chatId, messageId, status, threadId, receivedAt) {
|
|
9423
|
+
// A message the web user sent is no message on any platform: there is nothing to react on, and an id
|
|
9424
|
+
// like web-… would only fail there. Its ticks are the dashboard's (web track C3).
|
|
9425
|
+
if (isWebMessageId(messageId)) {
|
|
9426
|
+
this.reportWebDelivery(instanceName, messageId, status);
|
|
9427
|
+
return;
|
|
9428
|
+
}
|
|
8205
9429
|
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
8206
9430
|
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
8207
9431
|
// (bound bot) and the delivery/confirm reactions (some other bot) come from
|
|
@@ -8224,6 +9448,14 @@ export class FleetManager {
|
|
|
8224
9448
|
const key = `${adapterId}:${chatId}:${threadId ?? ""}:${messageId}`;
|
|
8225
9449
|
this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId, status, status === "received" ? receivedAt : undefined);
|
|
8226
9450
|
}
|
|
9451
|
+
/** One delivery report for a web user's message: recorded with it, and sent to the pages when it moved. */
|
|
9452
|
+
reportWebDelivery(instanceName, messageId, status) {
|
|
9453
|
+
if (status !== "queued" && status !== "processing" && status !== "delivered" && status !== "failed")
|
|
9454
|
+
return;
|
|
9455
|
+
const m = this.webChatHistory.setDelivery(instanceName, messageId, status);
|
|
9456
|
+
if (m)
|
|
9457
|
+
this.emitSseEvent("delivery", { instance: instanceName, messageId, delivery: m.delivery });
|
|
9458
|
+
}
|
|
8227
9459
|
/**
|
|
8228
9460
|
* Apply a terminal delivery verdict. On Telegram the built-in delivered is
|
|
8229
9461
|
* 👀 and failed is 👎; Discord shows ✅/❌ — both configurable (#1005).
|
|
@@ -8736,6 +9968,7 @@ export class FleetManager {
|
|
|
8736
9968
|
// install-backend-menu.test.ts and classic-approve-buttons.test.ts.
|
|
8737
9969
|
const nonce = randomBytes(16).toString("hex");
|
|
8738
9970
|
const entry = {
|
|
9971
|
+
nonce,
|
|
8739
9972
|
prefix: opts.prefix,
|
|
8740
9973
|
instanceName: opts.instanceName,
|
|
8741
9974
|
adapterId: opts.adapterId,
|
|
@@ -8743,6 +9976,7 @@ export class FleetManager {
|
|
|
8743
9976
|
chatId: opts.chatId,
|
|
8744
9977
|
threadId: opts.threadId,
|
|
8745
9978
|
expiredText: opts.expiredText,
|
|
9979
|
+
createdAt: Date.now(),
|
|
8746
9980
|
...opts.extra,
|
|
8747
9981
|
};
|
|
8748
9982
|
entry.timer = setTimeout(() => {
|
|
@@ -8750,6 +9984,7 @@ export class FleetManager {
|
|
|
8750
9984
|
if (pending !== entry)
|
|
8751
9985
|
return;
|
|
8752
9986
|
this.pendingNonceButtons.delete(nonce);
|
|
9987
|
+
this.webPromptGone(entry, entry.expiredText);
|
|
8753
9988
|
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
8754
9989
|
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
|
|
8755
9990
|
}
|
|
@@ -8757,11 +9992,12 @@ export class FleetManager {
|
|
|
8757
9992
|
entry.timer.unref?.();
|
|
8758
9993
|
this.pendingNonceButtons.set(nonce, entry);
|
|
8759
9994
|
try {
|
|
8760
|
-
const
|
|
9995
|
+
const choices = opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label }));
|
|
9996
|
+
const sent = opts.deliver ? await opts.deliver(choices) : await opts.adapter.notifyAlert(opts.chatId, {
|
|
8761
9997
|
type: opts.alertType,
|
|
8762
9998
|
instanceName: opts.instanceName,
|
|
8763
9999
|
message: opts.message,
|
|
8764
|
-
choices
|
|
10000
|
+
choices,
|
|
8765
10001
|
}, opts.threadId ? { threadId: opts.threadId } : undefined);
|
|
8766
10002
|
// Bind the nonce to the provider's canonical delivery address, not the
|
|
8767
10003
|
// logical routing input. Telegram, for example, represents General as
|
|
@@ -8775,6 +10011,16 @@ export class FleetManager {
|
|
|
8775
10011
|
entry.threadId = sent.threadId;
|
|
8776
10012
|
}
|
|
8777
10013
|
entry.messageId = sent.messageId;
|
|
10014
|
+
// Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
|
|
10015
|
+
// and only if nothing claimed or expired it meanwhile.
|
|
10016
|
+
if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
|
|
10017
|
+
entry.web = {
|
|
10018
|
+
text: opts.message,
|
|
10019
|
+
actions: opts.choices.map(c => ({ id: c.action, label: c.label })),
|
|
10020
|
+
expiresAt: Date.now() + (opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS),
|
|
10021
|
+
};
|
|
10022
|
+
this.emitSseEvent("prompt", { instance: entry.instanceName, nonce, ...entry.web });
|
|
10023
|
+
}
|
|
8778
10024
|
return nonce;
|
|
8779
10025
|
}
|
|
8780
10026
|
catch (err) {
|
|
@@ -8827,13 +10073,18 @@ export class FleetManager {
|
|
|
8827
10073
|
// Bind the capability to the exact message/world that created it. Telegram
|
|
8828
10074
|
// keyboards are visible to everyone, so mutating actions require fleet admin;
|
|
8829
10075
|
// a Tip acknowledgement only records that the shared content was read.
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
10076
|
+
// A dashboard click carries the full-fleet web session (checked by the web gate before it got here),
|
|
10077
|
+
// and is good only for a prompt that was offered on the dashboard.
|
|
10078
|
+
const fromWeb = this.webPromptClicks.has(data);
|
|
10079
|
+
const isAuthorized = fromWeb
|
|
10080
|
+
? pending.web !== undefined
|
|
10081
|
+
: data.userId
|
|
10082
|
+
? pending.allowAnyUser
|
|
10083
|
+
? true
|
|
10084
|
+
: pending.authChannelId
|
|
10085
|
+
? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
10086
|
+
: this.isFleetAdmin(data.userId, callbackAdapterId)
|
|
10087
|
+
: false;
|
|
8837
10088
|
const mismatchedFields = [];
|
|
8838
10089
|
if (pending.adapterId !== callbackAdapterId)
|
|
8839
10090
|
mismatchedFields.push("adapterId");
|
|
@@ -8845,6 +10096,8 @@ export class FleetManager {
|
|
|
8845
10096
|
mismatchedFields.push("messageId");
|
|
8846
10097
|
if (!isAuthorized)
|
|
8847
10098
|
mismatchedFields.push("authorization");
|
|
10099
|
+
if (pending.requesterUserId && pending.requesterUserId !== data.userId)
|
|
10100
|
+
mismatchedFields.push("requester");
|
|
8848
10101
|
if (mismatchedFields.length > 0) {
|
|
8849
10102
|
// Deliberately does NOT consume the nonce: the real admin can still click.
|
|
8850
10103
|
this.logger.warn({
|
|
@@ -8862,6 +10115,10 @@ export class FleetManager {
|
|
|
8862
10115
|
this.pendingNonceButtons.delete(match[1]);
|
|
8863
10116
|
if (pending.timer)
|
|
8864
10117
|
clearTimeout(pending.timer);
|
|
10118
|
+
if (fromWeb)
|
|
10119
|
+
this.webPromptClaims.add(data);
|
|
10120
|
+
// The dashboard's copy goes now; the outcome line follows from retireNonceButtons.
|
|
10121
|
+
this.webPromptGone(pending);
|
|
8865
10122
|
return { entry: pending, action: match[2] };
|
|
8866
10123
|
}
|
|
8867
10124
|
/** Collapse a consumed prompt's buttons into a final status line. */
|
|
@@ -8900,12 +10157,18 @@ export class FleetManager {
|
|
|
8900
10157
|
}
|
|
8901
10158
|
/** Routes a click to the handler that owns its prefix; false when none does. */
|
|
8902
10159
|
async dispatchAdapterCallback(data, adapterId, adapter) {
|
|
10160
|
+
if (this.needsYou?.handleCallback(data, adapterId))
|
|
10161
|
+
return true;
|
|
8903
10162
|
if (await this.handleTipDismiss(data, adapterId, adapter))
|
|
8904
10163
|
return true;
|
|
8905
10164
|
if (await this.handleTipUnlock(data, adapterId, adapter))
|
|
8906
10165
|
return true;
|
|
8907
10166
|
if (await this.handleLoginBackendSelect(data, adapterId, adapter))
|
|
8908
10167
|
return true;
|
|
10168
|
+
if (await this.handleSettingsChangeCallback(data, adapterId, adapter))
|
|
10169
|
+
return true;
|
|
10170
|
+
if (await this.handleDashboardCallback(data, adapterId, adapter))
|
|
10171
|
+
return true;
|
|
8909
10172
|
if (await this.handleClassicApproval(data, adapterId, adapter))
|
|
8910
10173
|
return true;
|
|
8911
10174
|
if (this.handleRetiredPromptButton(data, adapterId, adapter))
|
|
@@ -8922,19 +10185,246 @@ export class FleetManager {
|
|
|
8922
10185
|
return true;
|
|
8923
10186
|
if (await this.handleClassicBackendSelection(data))
|
|
8924
10187
|
return true;
|
|
8925
|
-
if (await this.handleModelSelection(data))
|
|
10188
|
+
if (await this.handleModelSelection(data, adapterId))
|
|
8926
10189
|
return true;
|
|
8927
|
-
if (await this.handleEffortSelection(data))
|
|
10190
|
+
if (await this.handleEffortSelection(data, adapterId))
|
|
8928
10191
|
return true;
|
|
8929
10192
|
if (await this.handleHangPrompt(data, adapterId, adapter))
|
|
8930
10193
|
return true;
|
|
8931
10194
|
if (data.callbackData.startsWith("cancel:")) {
|
|
8932
|
-
this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter ?? null, data);
|
|
10195
|
+
this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter ?? null, data, adapterId);
|
|
8933
10196
|
return true;
|
|
8934
10197
|
}
|
|
8935
|
-
return false;
|
|
10198
|
+
return false;
|
|
10199
|
+
}
|
|
10200
|
+
/**
|
|
10201
|
+
* A prompt the dashboard was offered is no longer open (answered on either surface, expired, or its
|
|
10202
|
+
* instance stopped): every page drops its buttons, and shows `outcome` when there is one.
|
|
10203
|
+
*/
|
|
10204
|
+
webPromptGone(entry, outcome) {
|
|
10205
|
+
if (!entry.web || !entry.nonce)
|
|
10206
|
+
return;
|
|
10207
|
+
this.emitSseEvent("prompt_resolved", { instance: entry.instanceName, nonce: entry.nonce, ...(outcome ? { outcome } : {}) });
|
|
10208
|
+
}
|
|
10209
|
+
// ── #1386 "Needs you" ─────────────────────────────────────────────────────────────────────────────────
|
|
10210
|
+
/** The interaction wait an instance is in now, as a comparable key: which daemon/spawn owns it, and its episode. */
|
|
10211
|
+
interactionWaitKey(name) {
|
|
10212
|
+
const snapshot = this.getInstanceInteraction(name);
|
|
10213
|
+
if (!snapshot)
|
|
10214
|
+
return { owner: null, episode: null };
|
|
10215
|
+
const o = snapshot.owner;
|
|
10216
|
+
return { owner: o ? `${o.bootId}:${o.spawnGeneration}:${o.launchAttempt}:${o.launchFenceEpoch}` : null, episode: snapshot.episode };
|
|
10217
|
+
}
|
|
10218
|
+
/** An instance's interaction observation, pause or wake changed (relayed by the lifecycle): recompute now, not at the tick. */
|
|
10219
|
+
onAttentionChanged(name) {
|
|
10220
|
+
this.needsPauseCache.delete(name);
|
|
10221
|
+
this.needsYou?.poke();
|
|
10222
|
+
}
|
|
10223
|
+
/** The list for the web: every world's items, and those of instances with no world (#1386 §5.0). */
|
|
10224
|
+
needsYouItems() {
|
|
10225
|
+
return this.needsYou?.webItems() ?? [];
|
|
10226
|
+
}
|
|
10227
|
+
/**
|
|
10228
|
+
* #1389: the org chart's structure for the web — the instances the dashboard lists (getUiStatus's names), General,
|
|
10229
|
+
* fleet.yaml's teams, each instance's description and thread link. Read on demand; the live state rides the stream.
|
|
10230
|
+
*/
|
|
10231
|
+
orgChart() {
|
|
10232
|
+
const config = this.fleetConfig;
|
|
10233
|
+
const fleetNames = Object.keys(config?.instances ?? {});
|
|
10234
|
+
const classic = new Map((this.classicChannels?.getAll() ?? []).filter(ch => !fleetNames.includes(ch.instanceName)).map(ch => [ch.instanceName, ch]));
|
|
10235
|
+
return buildOrgChart({
|
|
10236
|
+
names: [...fleetNames, ...classic.keys()],
|
|
10237
|
+
instances: Object.fromEntries([
|
|
10238
|
+
...fleetNames.map(name => [name, config?.instances[name]]),
|
|
10239
|
+
...[...classic].map(([name, ch]) => [name, { description: ch.description }]),
|
|
10240
|
+
]),
|
|
10241
|
+
teams: config?.teams,
|
|
10242
|
+
isGeneral: name => !classic.has(name) && isGeneralInstance(config, name),
|
|
10243
|
+
isClassic: name => classic.has(name),
|
|
10244
|
+
place: name => {
|
|
10245
|
+
const world = this.worlds.get(this.getInstanceAdapterId(name) ?? "");
|
|
10246
|
+
return world ? { type: world.type, ...(world.groupId ? { groupId: world.groupId } : {}) } : undefined;
|
|
10247
|
+
},
|
|
10248
|
+
// fleet.yaml's topic only. A ClassicBot room has none here, so no link: its channel may sit in any allowed
|
|
10249
|
+
// guild, not necessarily its world's group.
|
|
10250
|
+
topic: name => {
|
|
10251
|
+
const topic = config?.instances[name]?.topic_id;
|
|
10252
|
+
return topic != null ? String(topic) : undefined;
|
|
10253
|
+
},
|
|
10254
|
+
});
|
|
10255
|
+
}
|
|
10256
|
+
/** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
|
|
10257
|
+
acknowledgeNeedsItem(id, principal) {
|
|
10258
|
+
if (!this.needsYou)
|
|
10259
|
+
return { status: 409, message: t("needs.ack_already") };
|
|
10260
|
+
return this.needsYou.webAcknowledge(id, principal);
|
|
10261
|
+
}
|
|
10262
|
+
needsYouInstances() {
|
|
10263
|
+
const names = new Set(Object.keys(this.fleetConfig?.instances ?? {}));
|
|
10264
|
+
for (const ch of this.classicChannels?.getAll() ?? [])
|
|
10265
|
+
names.add(ch.instanceName);
|
|
10266
|
+
const out = [];
|
|
10267
|
+
for (const name of names) {
|
|
10268
|
+
// Crashed only by the authoritative reconciliation: a daemon that recovered while its "running" IPC event was
|
|
10269
|
+
// missed is running (getInstanceStatus clears the stale cache — before the presentation below reads it). Every
|
|
10270
|
+
// observation that is not crashed forgets the crash time, so a later crash is a new item (#1398 review).
|
|
10271
|
+
const crashed = this.instanceProcessStatus.get(name) === "crashed" && this.getInstanceStatus(name) === "crashed";
|
|
10272
|
+
if (crashed) {
|
|
10273
|
+
if (!this.needsCrashedAt.has(name))
|
|
10274
|
+
this.needsCrashedAt.set(name, Date.now());
|
|
10275
|
+
}
|
|
10276
|
+
else
|
|
10277
|
+
this.needsCrashedAt.delete(name);
|
|
10278
|
+
const p = this.instancePresentation(name);
|
|
10279
|
+
const wait = p.interaction ? this.interactionWaitKey(name) : null;
|
|
10280
|
+
let pauseReason = null, pausedAt = null;
|
|
10281
|
+
if (this.lifecycle.isPaused(name)) {
|
|
10282
|
+
const now = performance.now();
|
|
10283
|
+
let cached = this.needsPauseCache.get(name);
|
|
10284
|
+
if (!cached || now - cached.readAt >= NEEDS_PAUSE_TTL_MS) {
|
|
10285
|
+
const dir = this.getInstanceDir(name);
|
|
10286
|
+
cached = { reason: readPauseReason(dir), pausedAt: readPausedAt(dir), readAt: now };
|
|
10287
|
+
this.needsPauseCache.set(name, cached);
|
|
10288
|
+
}
|
|
10289
|
+
({ reason: pauseReason, pausedAt } = cached);
|
|
10290
|
+
}
|
|
10291
|
+
else {
|
|
10292
|
+
this.needsPauseCache.delete(name);
|
|
10293
|
+
}
|
|
10294
|
+
out.push({
|
|
10295
|
+
name, state: p.state ?? undefined,
|
|
10296
|
+
interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
|
|
10297
|
+
interactionSummary: p.interaction_summary ?? null,
|
|
10298
|
+
pauseReason, pausedAt,
|
|
10299
|
+
crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
|
|
10300
|
+
});
|
|
10301
|
+
}
|
|
10302
|
+
return out;
|
|
10303
|
+
}
|
|
10304
|
+
needsYouPrompts() {
|
|
10305
|
+
const out = [];
|
|
10306
|
+
for (const [nonce, e] of this.pendingNonceButtons) {
|
|
10307
|
+
if (!e.web || !WEB_MIRRORED_PROMPT_PREFIXES.has(e.prefix))
|
|
10308
|
+
continue;
|
|
10309
|
+
out.push({
|
|
10310
|
+
nonce, prefix: e.prefix, instance: e.instanceName, text: e.web.text, actions: e.web.actions,
|
|
10311
|
+
createdAt: e.createdAt ?? Date.now(), adapterId: e.adapterId, chatId: e.chatId,
|
|
10312
|
+
...(e.threadId !== undefined ? { threadId: e.threadId } : {}), ...(e.messageId ? { messageId: e.messageId } : {}),
|
|
10313
|
+
...(e.assistFor ? { assistFor: e.assistFor } : {}),
|
|
10314
|
+
});
|
|
10315
|
+
}
|
|
10316
|
+
return out;
|
|
10317
|
+
}
|
|
10318
|
+
/**
|
|
10319
|
+
* Shutdown: detach the hub first — nothing reaches it from here on, and the next startAll (finishStartup) builds a
|
|
10320
|
+
* fresh one; the old one's late ACKs are fenced by its own stop (#1398 review) — then let it retire its live
|
|
10321
|
+
* messages, bounded so a platform that does not answer cannot hold the shutdown.
|
|
10322
|
+
*/
|
|
10323
|
+
stopNeedsYou() {
|
|
10324
|
+
const hub = this.needsYou;
|
|
10325
|
+
this.needsYou = null;
|
|
10326
|
+
if (!hub)
|
|
10327
|
+
return undefined;
|
|
10328
|
+
return Promise.race([hub.stop(), new Promise(resolve => setTimeout(resolve, 5_000).unref?.())]);
|
|
10329
|
+
}
|
|
10330
|
+
startNeedsYou() {
|
|
10331
|
+
if (this.needsYou)
|
|
10332
|
+
return;
|
|
10333
|
+
const ownerOf = (instance) => {
|
|
10334
|
+
const owner = this.getInstanceAdapterId(instance);
|
|
10335
|
+
return owner !== undefined && this.worlds.has(owner) ? owner : undefined;
|
|
10336
|
+
};
|
|
10337
|
+
this.needsYou = new NeedsYouHub({
|
|
10338
|
+
dataDir: this.dataDir,
|
|
10339
|
+
now: () => Date.now(),
|
|
10340
|
+
mono: () => performance.now(),
|
|
10341
|
+
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
10342
|
+
clearTimer: h => clearTimeout(h),
|
|
10343
|
+
prompts: () => this.needsYouPrompts(),
|
|
10344
|
+
instances: () => this.needsYouInstances(),
|
|
10345
|
+
deliveries: since => (this.deliveryOutbox?.isOpen ? this.deliveryOutbox.needsAttention(since) : []).map(d => ({
|
|
10346
|
+
deliveryId: d.deliveryId, state: d.state, source: d.sourceInstance, target: d.targetInstance, kind: d.kind,
|
|
10347
|
+
finishedAt: Date.parse(d.finishedAt) || 0,
|
|
10348
|
+
})),
|
|
10349
|
+
ownerOf,
|
|
10350
|
+
worlds: () => [...this.worlds.values()].map((w) => ({
|
|
10351
|
+
id: w.id, adapter: w.adapter, place: { type: w.type, ...(w.groupId ? { groupId: w.groupId } : {}) },
|
|
10352
|
+
})),
|
|
10353
|
+
noticeTarget: world => {
|
|
10354
|
+
const target = this.fleetNoticeTarget(world);
|
|
10355
|
+
return target ? { chatId: target.chatId, ...(target.opts.threadId !== undefined ? { threadId: String(target.opts.threadId) } : {}) } : null;
|
|
10356
|
+
},
|
|
10357
|
+
instanceTopic: instance => {
|
|
10358
|
+
const topic = this.fleetConfig?.instances[instance]?.topic_id;
|
|
10359
|
+
if (topic != null)
|
|
10360
|
+
return String(topic);
|
|
10361
|
+
return this.classicChannels?.getChannelIdByInstance(instance) ?? undefined;
|
|
10362
|
+
},
|
|
10363
|
+
isFleetAdmin: (userId, world) => this.isFleetAdmin(userId, world),
|
|
10364
|
+
fleetAdmins: world => (this.getChannelConfig(world)?.access?.allowed_users ?? []).map(String),
|
|
10365
|
+
emitSse: (event, data) => this.emitSseEvent(event, data),
|
|
10366
|
+
acknowledge: (deliveryId, by) => {
|
|
10367
|
+
if (!this.deliveryOutbox?.isOpen)
|
|
10368
|
+
throw new Error("the delivery outbox is not open");
|
|
10369
|
+
return this.deliveryOutbox.acknowledge(deliveryId, by);
|
|
10370
|
+
},
|
|
10371
|
+
settings: () => ({
|
|
10372
|
+
liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
|
|
10373
|
+
dm: this.fleetConfig?.needs_you?.dm === true,
|
|
10374
|
+
}),
|
|
10375
|
+
stopping: () => this.shuttingDown,
|
|
10376
|
+
t: (key, ...args) => t(key, ...args),
|
|
10377
|
+
log: (level, message, extra) => this.logger[level](extra ?? {}, message),
|
|
10378
|
+
});
|
|
10379
|
+
this.needsYou.start();
|
|
10380
|
+
}
|
|
10381
|
+
/** The prompts open on the dashboard right now (a page that loads after one was posted asks for them). */
|
|
10382
|
+
listWebPrompts() {
|
|
10383
|
+
const open = [];
|
|
10384
|
+
for (const [nonce, e] of this.pendingNonceButtons) {
|
|
10385
|
+
if (e.web)
|
|
10386
|
+
open.push({ instance: e.instanceName, nonce, ...e.web });
|
|
10387
|
+
}
|
|
10388
|
+
return open;
|
|
10389
|
+
}
|
|
10390
|
+
/**
|
|
10391
|
+
* A click on a prompt in the web dashboard (web track C4). It is the platform click, made by the
|
|
10392
|
+
* dashboard: the same handler, the same single claim (whoever answers first — here or on Telegram —
|
|
10393
|
+
* wins), and the platform's buttons collapse to the outcome exactly as for a click there.
|
|
10394
|
+
*
|
|
10395
|
+
* The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
|
|
10396
|
+
* on the dashboard, about the instance the page named, and the action one of its own buttons.
|
|
10397
|
+
*/
|
|
10398
|
+
async clickWebPrompt(instance, nonce, action) {
|
|
10399
|
+
if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
|
|
10400
|
+
return { status: 400, error: "Malformed prompt answer" };
|
|
10401
|
+
const entry = this.pendingNonceButtons.get(nonce);
|
|
10402
|
+
// Unknown is the same answer as answered or expired: the page drops the buttons either way.
|
|
10403
|
+
if (!entry || !entry.web)
|
|
10404
|
+
return { status: 409, error: "This prompt is no longer open" };
|
|
10405
|
+
if (entry.instanceName !== instance)
|
|
10406
|
+
return { status: 403, error: "This prompt belongs to another instance" };
|
|
10407
|
+
if (!entry.web.actions.some(a => a.id === action))
|
|
10408
|
+
return { status: 400, error: "Not one of this prompt's answers" };
|
|
10409
|
+
let notice;
|
|
10410
|
+
const data = {
|
|
10411
|
+
callbackData: `${entry.prefix}${nonce}:${action}`,
|
|
10412
|
+
// The exact place the prompt lives, so the platform-side binding checks hold as for a click there.
|
|
10413
|
+
chatId: entry.chatId,
|
|
10414
|
+
threadId: entry.threadId,
|
|
10415
|
+
messageId: entry.messageId ?? "",
|
|
10416
|
+
userId: "web-user",
|
|
10417
|
+
ack: n => { if (notice === undefined && n)
|
|
10418
|
+
notice = n; },
|
|
10419
|
+
};
|
|
10420
|
+
this.webPromptClicks.add(data);
|
|
10421
|
+
await this.dispatchAdapterCallback(data, entry.adapterId, entry.adapter);
|
|
10422
|
+
if (this.webPromptClaims.has(data))
|
|
10423
|
+
return { status: 200 };
|
|
10424
|
+
return { status: 409, error: notice ?? "This prompt is no longer open" };
|
|
8936
10425
|
}
|
|
8937
10426
|
async retireNonceButtons(pending, messageId, text) {
|
|
10427
|
+
this.webPromptGone(pending, text);
|
|
8938
10428
|
try {
|
|
8939
10429
|
if (!pending.adapter.editMessageRemoveButtons)
|
|
8940
10430
|
throw new Error("adapter cannot remove prompt buttons");
|
|
@@ -9045,7 +10535,8 @@ export class FleetManager {
|
|
|
9045
10535
|
message: t("tips.advanced.unlock_prompt"),
|
|
9046
10536
|
choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
|
|
9047
10537
|
expiredText: t("tips.advanced.expired"),
|
|
9048
|
-
|
|
10538
|
+
// No allowAnyUser: unlocking changes a persistent setting, which typed and slash `/tips advanced on` reserve for a
|
|
10539
|
+
// fleet admin (#754 audit) — the default nonce check (fleet admin of the clicking adapter) applies.
|
|
9049
10540
|
timeoutMs: TIP_BUTTON_TIMEOUT_MS,
|
|
9050
10541
|
});
|
|
9051
10542
|
return nonce !== null;
|
|
@@ -9202,9 +10693,69 @@ export class FleetManager {
|
|
|
9202
10693
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
|
|
9203
10694
|
return true;
|
|
9204
10695
|
}
|
|
9205
|
-
|
|
10696
|
+
// The nonce was claimed synchronously. Keep the exact owner across platform
|
|
10697
|
+
// retirement awaits; a replacement with the same name is not this clear's
|
|
10698
|
+
// target, and a role granted at claim time may be revoked while editing.
|
|
10699
|
+
const ipc = this.instanceIpcClients.get(pending.instanceName);
|
|
10700
|
+
const daemon = this.daemons.get(pending.instanceName);
|
|
10701
|
+
const epoch = this.getDeliveryEpoch(pending.instanceName);
|
|
10702
|
+
const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
|
|
10703
|
+
// Object identity survives a resident daemon's respawn/freeze, and stop
|
|
10704
|
+
// invalidates its lifecycle epoch before the queued work replaces objects.
|
|
10705
|
+
// These reads are cached and synchronous; they never probe the pane.
|
|
10706
|
+
const readOwner = () => {
|
|
10707
|
+
try {
|
|
10708
|
+
const owner = daemon?.getInteractionSnapshot?.()?.owner;
|
|
10709
|
+
if (!owner || typeof owner.bootId !== "string" || !owner.bootId
|
|
10710
|
+
|| ![owner.spawnGeneration, owner.launchAttempt, owner.launchFenceEpoch]
|
|
10711
|
+
.every(n => Number.isSafeInteger(n) && n >= 0))
|
|
10712
|
+
return null;
|
|
10713
|
+
return { ...owner };
|
|
10714
|
+
}
|
|
10715
|
+
catch {
|
|
10716
|
+
return null;
|
|
10717
|
+
}
|
|
10718
|
+
};
|
|
10719
|
+
const readLifecycleEpoch = () => {
|
|
10720
|
+
try {
|
|
10721
|
+
const value = this.lifecycle?.epochOf(pending.instanceName);
|
|
10722
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
10723
|
+
}
|
|
10724
|
+
catch {
|
|
10725
|
+
return null;
|
|
10726
|
+
}
|
|
10727
|
+
};
|
|
10728
|
+
const owner = readOwner(), lifecycleEpoch = readLifecycleEpoch();
|
|
10729
|
+
const current = () => {
|
|
10730
|
+
const currentOwner = readOwner();
|
|
10731
|
+
try {
|
|
10732
|
+
return !this.shuttingDown
|
|
10733
|
+
&& !!data.userId && !!pending.authChannelId
|
|
10734
|
+
&& !this.webPromptClicks.has(data) // clear is deliberately not web-mirrored
|
|
10735
|
+
&& this.worlds.get(callbackAdapterId)?.adapter === pending.adapter
|
|
10736
|
+
&& this.commandChannelStillTargets(pending.instanceName, pending.authChannelId, callbackAdapterId, pending.chatId)
|
|
10737
|
+
&& this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
|
|
10738
|
+
&& (this.classicChannels?.getInstanceByChannel(pending.authChannelId, callbackAdapterId) === pending.instanceName
|
|
10739
|
+
? pending.adapter.type !== "telegram" || pending.chatId === pending.authChannelId
|
|
10740
|
+
: String(this.getChannelConfig(callbackAdapterId)?.group_id ?? "") === pending.chatId)
|
|
10741
|
+
&& this.getChannelConfig(callbackAdapterId)?.group_id === groupId
|
|
10742
|
+
&& this.daemons.get(pending.instanceName) === daemon
|
|
10743
|
+
&& this.instanceIpcClients.get(pending.instanceName) === ipc
|
|
10744
|
+
&& owner !== null && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
|
|
10745
|
+
&& lifecycleEpoch !== null && readLifecycleEpoch() === lifecycleEpoch
|
|
10746
|
+
&& this.isDeliveryEpochCurrent(pending.instanceName, epoch);
|
|
10747
|
+
}
|
|
10748
|
+
catch {
|
|
10749
|
+
return false;
|
|
10750
|
+
} // unavailable authority cannot admit a clear
|
|
10751
|
+
};
|
|
10752
|
+
const admitted = current();
|
|
10753
|
+
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, admitted ? t("clear.clearing", pending.instanceName) : t("menu.click_stale"));
|
|
9206
10754
|
try {
|
|
9207
|
-
|
|
10755
|
+
// sendClear sends its first IPC synchronously; no await separates this
|
|
10756
|
+
// final check from that effect. Uncertainty/throw never reaches IPC.
|
|
10757
|
+
const result = admitted && current()
|
|
10758
|
+
? await this.topicCommands.sendClear(pending.instanceName) : t("menu.click_stale");
|
|
9208
10759
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
|
|
9209
10760
|
}
|
|
9210
10761
|
catch (err) {
|
|
@@ -9237,9 +10788,11 @@ export class FleetManager {
|
|
|
9237
10788
|
async retirePendingNoncePrompts(budgetMs = NONCE_RETIRE_BUDGET_MS) {
|
|
9238
10789
|
const entries = [...this.pendingNonceButtons.values()];
|
|
9239
10790
|
this.pendingNonceButtons.clear();
|
|
9240
|
-
for (const entry of entries)
|
|
10791
|
+
for (const entry of entries) {
|
|
9241
10792
|
if (entry.timer)
|
|
9242
10793
|
clearTimeout(entry.timer);
|
|
10794
|
+
this.webPromptGone(entry, entry.expiredText);
|
|
10795
|
+
}
|
|
9243
10796
|
const collapses = entries
|
|
9244
10797
|
.filter(entry => entry.messageId && entry.adapter.editMessageRemoveButtons)
|
|
9245
10798
|
.map(entry => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")));
|
|
@@ -9260,6 +10813,7 @@ export class FleetManager {
|
|
|
9260
10813
|
this.pendingNonceButtons.delete(nonce);
|
|
9261
10814
|
if (entry.timer)
|
|
9262
10815
|
clearTimeout(entry.timer);
|
|
10816
|
+
this.webPromptGone(entry, entry.expiredText);
|
|
9263
10817
|
if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
|
|
9264
10818
|
entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
|
|
9265
10819
|
.catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
|
|
@@ -9271,6 +10825,16 @@ export class FleetManager {
|
|
|
9271
10825
|
* same-world General topic where an administrator can make the choice. */
|
|
9272
10826
|
async notifyNormalExit(instanceName) {
|
|
9273
10827
|
this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
|
|
10828
|
+
const web = this.webOnlyPromptPlace();
|
|
10829
|
+
if (web) {
|
|
10830
|
+
await this.postNonceButtonPrompt({
|
|
10831
|
+
prefix: EXIT_RESTART_CALLBACK_PREFIX, alertType: "exit_restart", instanceName, ...web,
|
|
10832
|
+
message: t("exit.general_notice", instanceName),
|
|
10833
|
+
choices: [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }],
|
|
10834
|
+
expiredText: t("exit.expired", instanceName),
|
|
10835
|
+
});
|
|
10836
|
+
return;
|
|
10837
|
+
}
|
|
9274
10838
|
const worldId = this.getInstanceAdapterId(instanceName);
|
|
9275
10839
|
const generalName = this.findGeneralInstance(worldId);
|
|
9276
10840
|
if (!generalName) {
|
|
@@ -9349,6 +10913,20 @@ export class FleetManager {
|
|
|
9349
10913
|
this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
|
|
9350
10914
|
return;
|
|
9351
10915
|
}
|
|
10916
|
+
const label = this.interactivePromptLabel(kind);
|
|
10917
|
+
const prompt = {
|
|
10918
|
+
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX, alertType: "interactive_prompt", instanceName,
|
|
10919
|
+
message: t("interactive.general_notice", instanceName, label),
|
|
10920
|
+
choices: [{ action: "confirm", label: t("interactive.confirm") }, { action: "cancel", label: t("interactive.cancel") }],
|
|
10921
|
+
expiredText: t("interactive.expired", instanceName),
|
|
10922
|
+
extra: { generalName, promptKind: kind, assistFor: this.interactionWaitKey(instanceName) },
|
|
10923
|
+
};
|
|
10924
|
+
// No chat platform: the dashboard is where it is asked; Confirm still asks General to help.
|
|
10925
|
+
const web = this.webOnlyPromptPlace();
|
|
10926
|
+
if (web) {
|
|
10927
|
+
await this.postNonceButtonPrompt({ ...prompt, ...web });
|
|
10928
|
+
return;
|
|
10929
|
+
}
|
|
9352
10930
|
const adapterId = this.getInstanceAdapterId(generalName);
|
|
9353
10931
|
const adapter = this.getAdapterForInstance(generalName);
|
|
9354
10932
|
const chatId = this.getGroupIdForInstance(generalName);
|
|
@@ -9358,23 +10936,7 @@ export class FleetManager {
|
|
|
9358
10936
|
this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
|
|
9359
10937
|
return;
|
|
9360
10938
|
}
|
|
9361
|
-
|
|
9362
|
-
await this.postNonceButtonPrompt({
|
|
9363
|
-
prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
9364
|
-
alertType: "interactive_prompt",
|
|
9365
|
-
instanceName,
|
|
9366
|
-
adapter,
|
|
9367
|
-
adapterId,
|
|
9368
|
-
chatId,
|
|
9369
|
-
threadId,
|
|
9370
|
-
message: t("interactive.general_notice", instanceName, label),
|
|
9371
|
-
choices: [
|
|
9372
|
-
{ action: "confirm", label: t("interactive.confirm") },
|
|
9373
|
-
{ action: "cancel", label: t("interactive.cancel") },
|
|
9374
|
-
],
|
|
9375
|
-
expiredText: t("interactive.expired", instanceName),
|
|
9376
|
-
extra: { generalName, promptKind: kind },
|
|
9377
|
-
});
|
|
10939
|
+
await this.postNonceButtonPrompt({ ...prompt, adapter, adapterId, chatId, threadId });
|
|
9378
10940
|
}
|
|
9379
10941
|
/**
|
|
9380
10942
|
* Offer a one-tap re-login next to an auth alert.
|
|
@@ -9542,8 +11104,33 @@ export class FleetManager {
|
|
|
9542
11104
|
* concern was real — a second click must not fire a second interrupt key at an
|
|
9543
11105
|
* instance that has already started a new turn.
|
|
9544
11106
|
*/
|
|
9545
|
-
handleCancelClick(instanceName, adapter, data) {
|
|
9546
|
-
|
|
11107
|
+
handleCancelClick(instanceName, adapter, data, adapterId) {
|
|
11108
|
+
// #754 audit: the click names its instance in its own callback data, and Telegram delivers clicks from anyone in
|
|
11109
|
+
// the chat. So it may interrupt only when it comes through the instance's owning adapter (the one that posts its
|
|
11110
|
+
// buttons) from someone that adapter lets speak — and, on a live button, only that instance's own message.
|
|
11111
|
+
const owner = this.getInstanceAdapterId(instanceName);
|
|
11112
|
+
const live = this.cancelButtons.get(data.messageId);
|
|
11113
|
+
// Where the click came from: a live button's own message (and topic), or — when the fleet has no entry for the
|
|
11114
|
+
// message (a button being replaced, or forgotten across a restart) — the instance's destination as it is NOW, so a
|
|
11115
|
+
// button left behind in a channel the instance has since moved from, or a click from an unrelated chat, does not
|
|
11116
|
+
// cancel it (#1396 review).
|
|
11117
|
+
const dest = this.cancelButtonDestination(instanceName);
|
|
11118
|
+
const atDestination = !!owner && (live
|
|
11119
|
+
? live.instanceName === instanceName && live.chatId === data.chatId
|
|
11120
|
+
&& (live.threadId == null || this.clickAtDestination({ chatId: live.chatId, threadId: live.threadId }, data, owner))
|
|
11121
|
+
: !!dest && this.clickAtDestination(dest, data, owner));
|
|
11122
|
+
// Who may press it: in a ClassicBot chat, anyone there — exactly who a typed /cancel there answers, since ClassicBot
|
|
11123
|
+
// traffic is not admitted by the fleet's access policy (#1396 review); elsewhere someone the owning adapter lets speak.
|
|
11124
|
+
const classic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
11125
|
+
const access = owner ? this.worlds.get(owner)?.accessManager ?? (owner === this.getPrimaryAdapterId() ? this.accessManager : null) : null;
|
|
11126
|
+
const speaker = !!data.userId && !!owner
|
|
11127
|
+
&& (classic || this.isFleetAdmin(data.userId, owner) || !!access?.isAllowed(data.userId));
|
|
11128
|
+
if (!owner || adapterId !== owner || !speaker || !atDestination) {
|
|
11129
|
+
this.logger.warn({ instanceName, adapterId, owner, userId: data.userId, live: !!live, atDestination }, "Refused cancel click: not this instance's button where it is now, or not someone its adapter lets speak");
|
|
11130
|
+
data.ack?.(t("buttons.not_allowed"));
|
|
11131
|
+
return;
|
|
11132
|
+
}
|
|
11133
|
+
if (live) {
|
|
9547
11134
|
this.cancelInstance(instanceName);
|
|
9548
11135
|
return;
|
|
9549
11136
|
}
|
|
@@ -9561,6 +11148,28 @@ export class FleetManager {
|
|
|
9561
11148
|
adapter?.editMessage(data.chatId, data.messageId, t("cancel.button_stale", instanceName), data.threadId)
|
|
9562
11149
|
.catch(() => { });
|
|
9563
11150
|
}
|
|
11151
|
+
/** Where an instance's cancel button is posted — and so the only place a click on it can come from. */
|
|
11152
|
+
cancelButtonDestination(instanceName) {
|
|
11153
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
11154
|
+
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
11155
|
+
// Fleet topic instance.
|
|
11156
|
+
if (topicId != null && groupId)
|
|
11157
|
+
return { chatId: String(groupId), threadId: String(topicId) };
|
|
11158
|
+
// Classic instance: channelId from the classic manager; General / flat fallback: the group (no thread).
|
|
11159
|
+
const chatId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? (groupId ? String(groupId) : undefined);
|
|
11160
|
+
return chatId ? { chatId } : null;
|
|
11161
|
+
}
|
|
11162
|
+
/**
|
|
11163
|
+
* A click came from this destination. A Discord click names the guild as its chat and the channel as its thread; a
|
|
11164
|
+
* Telegram click names the chat and topic, with the General topic as thread 1 or none at all.
|
|
11165
|
+
*/
|
|
11166
|
+
clickAtDestination(dest, data, ownerAdapterId) {
|
|
11167
|
+
const telegram = this.getChannelConfig(ownerAdapterId)?.type === "telegram";
|
|
11168
|
+
const thread = (id) => (telegram && (id === undefined || id === "1") ? undefined : id);
|
|
11169
|
+
if (thread(dest.threadId) !== undefined)
|
|
11170
|
+
return data.chatId === dest.chatId && thread(data.threadId) === thread(dest.threadId);
|
|
11171
|
+
return (thread(data.threadId) ?? data.chatId) === dest.chatId;
|
|
11172
|
+
}
|
|
9564
11173
|
hasCancelButton(instanceName) {
|
|
9565
11174
|
for (const e of this.cancelButtons.values()) {
|
|
9566
11175
|
if (e.instanceName === instanceName)
|
|
@@ -9610,20 +11219,7 @@ export class FleetManager {
|
|
|
9610
11219
|
const adapterId = this.getInstanceAdapterId(instanceName);
|
|
9611
11220
|
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
9612
11221
|
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
9613
|
-
|
|
9614
|
-
let threadId;
|
|
9615
|
-
if (topicId != null && groupId) {
|
|
9616
|
-
// Fleet topic instance.
|
|
9617
|
-
chatId = String(groupId);
|
|
9618
|
-
threadId = String(topicId);
|
|
9619
|
-
}
|
|
9620
|
-
else {
|
|
9621
|
-
// Classic instance: channelId from the classic manager.
|
|
9622
|
-
chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
|
|
9623
|
-
// General / flat fallback: post to the group (no thread).
|
|
9624
|
-
if (!chatId && groupId)
|
|
9625
|
-
chatId = String(groupId);
|
|
9626
|
-
}
|
|
11222
|
+
const { chatId, threadId } = this.cancelButtonDestination(instanceName) ?? {};
|
|
9627
11223
|
if (!chatId) {
|
|
9628
11224
|
// A button that cannot be addressed must say so — this exact silence is how
|
|
9629
11225
|
// "the cancel button sometimes never appears" stayed unreported-in-logs.
|
|
@@ -10131,6 +11727,10 @@ export class FleetManager {
|
|
|
10131
11727
|
daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
|
|
10132
11728
|
this.lastInboundMsg.delete(instanceName);
|
|
10133
11729
|
this.clearCancelButton(instanceName);
|
|
11730
|
+
// The queued web messages were just dropped with the rest: their ticks say so, on every page.
|
|
11731
|
+
for (const m of this.webChatHistory.cancelPending(instanceName)) {
|
|
11732
|
+
this.emitSseEvent("delivery", { instance: instanceName, messageId: m.messageId, delivery: m.delivery });
|
|
11733
|
+
}
|
|
10134
11734
|
return true;
|
|
10135
11735
|
}
|
|
10136
11736
|
getDeliveryEpoch(instanceName) {
|
|
@@ -10139,6 +11739,10 @@ export class FleetManager {
|
|
|
10139
11739
|
isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
|
|
10140
11740
|
return deliveryEpoch === this.getDeliveryEpoch(instanceName);
|
|
10141
11741
|
}
|
|
11742
|
+
/** The delivery epoch, and the caller's own fence when it gave one (DeliveryOptions.stillCurrent, #1426). */
|
|
11743
|
+
deliveryCurrent(instanceName, deliveryEpoch, stillCurrent) {
|
|
11744
|
+
return this.isDeliveryEpochCurrent(instanceName, deliveryEpoch) && (stillCurrent?.() ?? true);
|
|
11745
|
+
}
|
|
10142
11746
|
/** Invalidate work queued before a user cancel and wake idle-gate waiters. */
|
|
10143
11747
|
cancelPendingDeliveries(instanceName) {
|
|
10144
11748
|
const next = this.getDeliveryEpoch(instanceName) + 1;
|
|
@@ -10174,6 +11778,7 @@ export class FleetManager {
|
|
|
10174
11778
|
isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
|
|
10175
11779
|
windowBusyMessage: () => this.loginWindow.busyMessage(),
|
|
10176
11780
|
tunnelDataDir: () => this.dataDir,
|
|
11781
|
+
reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
|
|
10177
11782
|
// Throws when the prompt cannot be posted: the controller tells the
|
|
10178
11783
|
// user so instead of leaving "Starting…" as the last word (#1133).
|
|
10179
11784
|
postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
|
|
@@ -10299,7 +11904,7 @@ export class FleetManager {
|
|
|
10299
11904
|
this.notifyInstanceTopic(opts.generalName, opts.message);
|
|
10300
11905
|
return;
|
|
10301
11906
|
}
|
|
10302
|
-
const choices = [{ action: "allow", label: t("classic.approve_group") }];
|
|
11907
|
+
const choices = [{ action: "allow", label: t(opts.scope === "user" ? "classic.approve_user" : "classic.approve_group") }];
|
|
10303
11908
|
if (opts.userId)
|
|
10304
11909
|
choices.push({ action: "allow-admin", label: t("classic.approve_group_admin") });
|
|
10305
11910
|
choices.push({ action: "ignore", label: t("classic.approve_ignore") });
|
|
@@ -10314,7 +11919,7 @@ export class FleetManager {
|
|
|
10314
11919
|
message: opts.message,
|
|
10315
11920
|
choices,
|
|
10316
11921
|
expiredText: t("buttons.stale"),
|
|
10317
|
-
extra: { classicGroupId: opts.groupId, classicUserId: opts.userId, classicScope: opts.scope },
|
|
11922
|
+
extra: { classicGroupId: opts.groupId, classicUserId: opts.userId, classicScope: opts.scope, classicReplyTo: opts.replyTo },
|
|
10318
11923
|
});
|
|
10319
11924
|
}
|
|
10320
11925
|
/**
|
|
@@ -10334,15 +11939,27 @@ export class FleetManager {
|
|
|
10334
11939
|
return;
|
|
10335
11940
|
}
|
|
10336
11941
|
const scope = entry.classicScope ?? "guild";
|
|
10337
|
-
const access = scope === "
|
|
11942
|
+
const access = scope === "user" ? classic.allowUser(groupId)
|
|
11943
|
+
: scope === "group" ? classic.allowGroup(groupId) : classic.allowGuild(groupId);
|
|
10338
11944
|
const admin = adminUserId ? classic.addAdminUser(adminUserId) : null;
|
|
10339
|
-
const lines = [t(access === "added" ? "classic.approve_done_group"
|
|
10340
|
-
: access === "already" ? "classic.approve_done_group_already"
|
|
10341
|
-
: "classic.approve_done_group_open", groupId)];
|
|
11945
|
+
const lines = [t(access === "added" ? "classic.approve_done_group" : "classic.approve_done_group_already", groupId)];
|
|
10342
11946
|
if (admin) {
|
|
10343
11947
|
lines.push(t(admin === "added" ? "classic.approve_done_admin" : "classic.approve_done_admin_already", adminUserId ?? ""));
|
|
10344
11948
|
}
|
|
10345
11949
|
this.notifyInstanceTopic(entry.instanceName, lines.join("\n"));
|
|
11950
|
+
await this.notifyClassicRequester(entry, "classic.request_allowed");
|
|
11951
|
+
}
|
|
11952
|
+
async notifyClassicRequester(entry, key) {
|
|
11953
|
+
const reply = entry.classicReplyTo;
|
|
11954
|
+
// Do not use a replaced bot or guess a new recipient after adapter rebuild.
|
|
11955
|
+
if (!reply || this.worlds.get(reply.adapterId)?.adapter !== reply.adapter)
|
|
11956
|
+
return;
|
|
11957
|
+
try {
|
|
11958
|
+
await reply.adapter.sendText(reply.chatId, t(key));
|
|
11959
|
+
}
|
|
11960
|
+
catch (err) {
|
|
11961
|
+
this.logger.warn({ err, adapterId: reply.adapterId, scope: entry.classicScope }, "Could not notify Classic access requester");
|
|
11962
|
+
}
|
|
10346
11963
|
}
|
|
10347
11964
|
/** Consume a ClassicBot approval button. */
|
|
10348
11965
|
async handleClassicApproval(data, callbackAdapterId, receivingAdapter) {
|
|
@@ -10356,6 +11973,7 @@ export class FleetManager {
|
|
|
10356
11973
|
const userId = entry.classicUserId;
|
|
10357
11974
|
if (action === "ignore") {
|
|
10358
11975
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("classic.approve_ignored", groupId));
|
|
11976
|
+
await this.notifyClassicRequester(entry, "classic.request_ignored");
|
|
10359
11977
|
return true;
|
|
10360
11978
|
}
|
|
10361
11979
|
const grantAdmin = action === "allow-admin" && !!userId;
|
|
@@ -10804,7 +12422,23 @@ export class FleetManager {
|
|
|
10804
12422
|
}
|
|
10805
12423
|
/** Push an SSE event to all connected Web UI clients. */
|
|
10806
12424
|
emitSseEvent(event, data) {
|
|
10807
|
-
|
|
12425
|
+
// #1386: a prompt opened or closed, or an instance's state moved — "Needs you" may have changed.
|
|
12426
|
+
if (event === "prompt" || event === "prompt_resolved" || event === "activity")
|
|
12427
|
+
this.needsYou?.poke();
|
|
12428
|
+
const onError = (err) => this.logger.debug({ err }, "SSE client write failed; evicting");
|
|
12429
|
+
if (event === "message" && data && typeof data === "object") {
|
|
12430
|
+
// A chat message: record it, and send it WITH its id so a reconnecting stream can ask for what it missed.
|
|
12431
|
+
const m = data;
|
|
12432
|
+
const recorded = this.webChatHistory.record({
|
|
12433
|
+
instance: String(m.instance ?? ""), sender: String(m.sender ?? ""), text: String(m.text ?? ""), ts: String(m.ts ?? new Date().toISOString()),
|
|
12434
|
+
attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
|
|
12435
|
+
messageId: typeof m.messageId === "string" ? m.messageId : undefined,
|
|
12436
|
+
role: typeof m.role === "string" ? m.role : undefined,
|
|
12437
|
+
});
|
|
12438
|
+
broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
12439
|
+
return;
|
|
12440
|
+
}
|
|
12441
|
+
broadcastSseEvent(this.sseClients, event, data, onError);
|
|
10808
12442
|
}
|
|
10809
12443
|
listClaimedTasks(assignee) {
|
|
10810
12444
|
try {
|
|
@@ -10815,8 +12449,9 @@ export class FleetManager {
|
|
|
10815
12449
|
}
|
|
10816
12450
|
}
|
|
10817
12451
|
async sendHangNotification(instanceName, unchangedForMs) {
|
|
10818
|
-
const
|
|
10819
|
-
const
|
|
12452
|
+
const web = this.webOnlyPromptPlace(); // no chat platform: asked on the dashboard (#1307 item 6)
|
|
12453
|
+
const adapter = web?.adapter ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
12454
|
+
const adapterId = web?.adapterId ?? this.getInstanceAdapterId(instanceName);
|
|
10820
12455
|
// Same three-way addressing as sendCancelButton: fleet topic → group+thread,
|
|
10821
12456
|
// Classic → its own channel (Classic instances are absent from
|
|
10822
12457
|
// fleetConfig.instances, so the topic path can never address them), else the
|
|
@@ -10826,7 +12461,10 @@ export class FleetManager {
|
|
|
10826
12461
|
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
10827
12462
|
let chatId;
|
|
10828
12463
|
let threadId;
|
|
10829
|
-
if (
|
|
12464
|
+
if (web) {
|
|
12465
|
+
chatId = web.chatId;
|
|
12466
|
+
}
|
|
12467
|
+
else if (topicId != null && groupId) {
|
|
10830
12468
|
chatId = String(groupId);
|
|
10831
12469
|
threadId = String(topicId);
|
|
10832
12470
|
}
|
|
@@ -11774,7 +13412,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11774
13412
|
return done(cachedModel.trim(), "cli-default");
|
|
11775
13413
|
// Say WHY it's unresolved: no fresh probe yet vs. the CLI not exposing a default
|
|
11776
13414
|
// (e.g. claude-code's default is account-side, opencode's is provider-side).
|
|
11777
|
-
return done("default", "unresolved", cliEnv ? "this CLI does not report a default" : "
|
|
13415
|
+
return done("default", "unresolved", cliEnv ? "this CLI does not report a default" : "detected when it starts");
|
|
11778
13416
|
}
|
|
11779
13417
|
/** Human-readable effective model, e.g. `auto (default)`. Used by /ctx. */
|
|
11780
13418
|
modelDisplayForInstance(instanceName) {
|
|
@@ -12061,7 +13699,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12061
13699
|
}));
|
|
12062
13700
|
const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
12063
13701
|
timer.unref?.();
|
|
12064
|
-
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
|
|
13702
|
+
this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
|
|
12065
13703
|
try {
|
|
12066
13704
|
await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
|
|
12067
13705
|
}
|
|
@@ -12073,7 +13711,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12073
13711
|
}
|
|
12074
13712
|
}
|
|
12075
13713
|
/** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
|
|
12076
|
-
async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
13714
|
+
async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId, adapterId) {
|
|
12077
13715
|
const levels = this.effortLevelsFor(instanceName);
|
|
12078
13716
|
if (levels.length === 0) {
|
|
12079
13717
|
return t("effort.unsupported", this.backendNameForInstance(instanceName));
|
|
@@ -12096,7 +13734,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12096
13734
|
}
|
|
12097
13735
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
12098
13736
|
timer.unref?.();
|
|
12099
|
-
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
13737
|
+
this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
12100
13738
|
try {
|
|
12101
13739
|
const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
|
|
12102
13740
|
const pending = this.pendingEffortSelects.get(nonce);
|
|
@@ -12112,7 +13750,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12112
13750
|
}
|
|
12113
13751
|
}
|
|
12114
13752
|
/** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
|
|
12115
|
-
async handleEffortSelection(data) {
|
|
13753
|
+
async handleEffortSelection(data, adapterId) {
|
|
12116
13754
|
if (!data.callbackData.startsWith(EFFORT_SELECT_CALLBACK_PREFIX))
|
|
12117
13755
|
return false;
|
|
12118
13756
|
const match = data.callbackData.match(/^effort-select:([0-9a-f]+):(.+)$/);
|
|
@@ -12121,11 +13759,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12121
13759
|
const pending = this.pendingEffortSelects.get(match[1]);
|
|
12122
13760
|
if (!pending)
|
|
12123
13761
|
return true;
|
|
12124
|
-
|
|
12125
|
-
|
|
13762
|
+
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
13763
|
+
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
12126
13764
|
const cbChannel = data.threadId ?? data.chatId;
|
|
12127
|
-
if (
|
|
13765
|
+
if (!data.userId || data.userId !== pending.userId
|
|
13766
|
+
|| (pending.adapterId !== undefined && pending.adapterId !== adapterId)
|
|
13767
|
+
|| (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
13768
|
+
|| !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
|
|
13769
|
+
data.ack?.(t("buttons.admin_only"));
|
|
12128
13770
|
return true;
|
|
13771
|
+
}
|
|
12129
13772
|
this.pendingEffortSelects.delete(match[1]);
|
|
12130
13773
|
clearTimeout(pending.timer);
|
|
12131
13774
|
const level = match[2];
|
|
@@ -12157,7 +13800,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12157
13800
|
void (async () => {
|
|
12158
13801
|
let result;
|
|
12159
13802
|
try {
|
|
12160
|
-
|
|
13803
|
+
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
13804
|
+
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
13805
|
+
? await this.applyEffort(pending.instanceName, level) : t("menu.click_stale");
|
|
12161
13806
|
}
|
|
12162
13807
|
catch (err) {
|
|
12163
13808
|
this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
|
|
@@ -12214,7 +13859,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12214
13859
|
const choices = this.modelMenuChoices(name, nonce, options, currentModel);
|
|
12215
13860
|
const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
12216
13861
|
timer.unref?.();
|
|
12217
|
-
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
13862
|
+
this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
|
|
12218
13863
|
try {
|
|
12219
13864
|
await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
|
|
12220
13865
|
}
|
|
@@ -12230,7 +13875,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12230
13875
|
* pendingModelSelects coordinator as the DC Select Menu path.
|
|
12231
13876
|
* Returns null on success (menu shown), or a fallback string to send.
|
|
12232
13877
|
*/
|
|
12233
|
-
async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
|
|
13878
|
+
async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId, adapterId) {
|
|
12234
13879
|
const options = await this.getModelOptions(instanceName);
|
|
12235
13880
|
if (options.length === 0) {
|
|
12236
13881
|
return t("model.list_unavailable", instanceName);
|
|
@@ -12250,7 +13895,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12250
13895
|
}
|
|
12251
13896
|
}, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
|
|
12252
13897
|
timer.unref?.();
|
|
12253
|
-
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
13898
|
+
this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
|
|
12254
13899
|
try {
|
|
12255
13900
|
const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
|
|
12256
13901
|
const pending = this.pendingModelSelects.get(nonce);
|
|
@@ -12379,8 +14024,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12379
14024
|
await pending.respond(t("model.usage")).catch(() => { });
|
|
12380
14025
|
}
|
|
12381
14026
|
}
|
|
14027
|
+
/**
|
|
14028
|
+
* A /model or /effort menu click may still act (#1396 review): the instance is still owned by the adapter the click
|
|
14029
|
+
* came through (it may have been rebound since the menu opened) and the clicker is still that channel's admin. Asked
|
|
14030
|
+
* when the click is claimed and again right before the change is applied.
|
|
14031
|
+
*/
|
|
14032
|
+
menuClickStillCurrent(instanceName, userId, channelId, adapterId, sourceChatId) {
|
|
14033
|
+
return this.commandChannelStillTargets(instanceName, channelId, adapterId, sourceChatId)
|
|
14034
|
+
&& this.isModelAdmin(userId, channelId, adapterId);
|
|
14035
|
+
}
|
|
14036
|
+
/** Current source-to-target mapping, including same-adapter topic moves. */
|
|
14037
|
+
commandChannelStillTargets(instanceName, channelId, adapterId, sourceChatId) {
|
|
14038
|
+
if (this.getInstanceAdapterId(instanceName) !== adapterId)
|
|
14039
|
+
return false;
|
|
14040
|
+
const classic = this.classicChannels?.getInstanceByChannel(channelId, adapterId);
|
|
14041
|
+
if (classic !== undefined)
|
|
14042
|
+
return classic === instanceName;
|
|
14043
|
+
// Read the current config rather than relying on a pre-reload route cache.
|
|
14044
|
+
const targets = Object.entries(this.fleetConfig?.instances ?? {}).filter(([name, cfg]) => cfg.topic_id != null && String(cfg.topic_id) === channelId && this.getInstanceAdapterId(name) === adapterId);
|
|
14045
|
+
// A duplicate within one world is ambiguous (the slash table keeps the
|
|
14046
|
+
// last registration). Never authorize an old menu via the first match.
|
|
14047
|
+
if (targets.length > 1)
|
|
14048
|
+
return false;
|
|
14049
|
+
const channel = this.getChannelConfig(adapterId);
|
|
14050
|
+
if (targets.length === 1)
|
|
14051
|
+
return targets[0][0] === instanceName
|
|
14052
|
+
&& (channel?.type !== "telegram" || sourceChatId !== undefined
|
|
14053
|
+
&& channel.group_id != null && String(channel.group_id) === sourceChatId);
|
|
14054
|
+
// Root General menus carry the group address, not the logical topic id.
|
|
14055
|
+
return channel?.type === "telegram" && channel.group_id != null && String(channel.group_id) === channelId
|
|
14056
|
+
&& this.fleetConfig?.instances[instanceName]?.general_topic === true
|
|
14057
|
+
&& this.findGeneralInstance(adapterId) === instanceName;
|
|
14058
|
+
}
|
|
12382
14059
|
/** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
|
|
12383
|
-
async handleModelSelection(data) {
|
|
14060
|
+
async handleModelSelection(data, adapterId) {
|
|
12384
14061
|
if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
|
|
12385
14062
|
return false;
|
|
12386
14063
|
const match = data.callbackData.match(/^model-select:([0-9a-f]+):(.+)$/);
|
|
@@ -12389,12 +14066,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12389
14066
|
const pending = this.pendingModelSelects.get(match[1]);
|
|
12390
14067
|
if (!pending)
|
|
12391
14068
|
return true;
|
|
12392
|
-
//
|
|
12393
|
-
|
|
12394
|
-
return true;
|
|
14069
|
+
// The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
|
|
14070
|
+
// when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
|
|
12395
14071
|
const cbChannel = data.threadId ?? data.chatId;
|
|
12396
|
-
if (
|
|
14072
|
+
if (!data.userId || data.userId !== pending.userId
|
|
14073
|
+
|| (pending.adapterId !== undefined && pending.adapterId !== adapterId)
|
|
14074
|
+
|| (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
|
|
14075
|
+
|| !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
|
|
14076
|
+
data.ack?.(t("buttons.admin_only"));
|
|
12397
14077
|
return true;
|
|
14078
|
+
}
|
|
12398
14079
|
this.pendingModelSelects.delete(match[1]);
|
|
12399
14080
|
clearTimeout(pending.timer);
|
|
12400
14081
|
const model = match[2];
|
|
@@ -12444,7 +14125,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12444
14125
|
void (async () => {
|
|
12445
14126
|
let result;
|
|
12446
14127
|
try {
|
|
12447
|
-
|
|
14128
|
+
// Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
|
|
14129
|
+
result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
|
|
14130
|
+
? await this.applyModel(pending.instanceName, model) : t("menu.click_stale");
|
|
12448
14131
|
}
|
|
12449
14132
|
catch (err) {
|
|
12450
14133
|
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
@@ -12638,30 +14321,58 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12638
14321
|
const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
|
|
12639
14322
|
return adapter?.type === "telegram" ? `${key}.telegram` : key;
|
|
12640
14323
|
}
|
|
14324
|
+
/** Approval is a system notification: General's agent need not be running. */
|
|
14325
|
+
findClassicApprovalGeneral(adapterId) {
|
|
14326
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true);
|
|
14327
|
+
return (adapterId ? generals.find(([name, cfg]) => (cfg.channel_id ?? this.getInstanceAdapterId(name)) === adapterId) : undefined)?.[0]
|
|
14328
|
+
?? generals[0]?.[0];
|
|
14329
|
+
}
|
|
12641
14330
|
/** Return a user-facing blocker without mutating ClassicBot state. */
|
|
12642
14331
|
validateClassicStart(channelId, userId, guildId, adapterId) {
|
|
12643
|
-
|
|
14332
|
+
const classic = this.classicChannels;
|
|
14333
|
+
if (!classic)
|
|
12644
14334
|
return t("classic.manager_unavailable");
|
|
12645
|
-
|
|
12646
|
-
|
|
12647
|
-
|
|
12648
|
-
// Fire-and-forget, exactly as the notifyInstanceTopic it replaces: this
|
|
12649
|
-
// function's return value is the rejection shown to the user, and it
|
|
12650
|
-
// must not wait on posting buttons into the General topic.
|
|
12651
|
-
void this.promptClassicApproval({
|
|
12652
|
-
generalName: generalId,
|
|
12653
|
-
message: t("alert.unauth_guild", guildId, userId),
|
|
12654
|
-
groupId: String(guildId),
|
|
12655
|
-
scope: "guild",
|
|
12656
|
-
userId: userId ? String(userId) : undefined,
|
|
12657
|
-
}).catch(err => this.logger.warn({ err, guildId }, "Classic approval prompt failed"));
|
|
12658
|
-
}
|
|
12659
|
-
return t("classic.not_authorized_guild");
|
|
12660
|
-
}
|
|
12661
|
-
if (this.classicChannels.isClassicChannel(channelId, adapterId))
|
|
14335
|
+
// Admission applies only to NEW channels. Revoking a start grant never
|
|
14336
|
+
// blocks an existing agent's chat or changes its registration.
|
|
14337
|
+
if (classic.isClassicChannel(channelId, adapterId))
|
|
12662
14338
|
return t(this.classicStartKey("classic.already_active", adapterId));
|
|
12663
14339
|
if (this.routing.resolve(channelId))
|
|
12664
14340
|
return t("classic.topic_bound");
|
|
14341
|
+
const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
|
|
14342
|
+
// Discord DMs remain unsupported, including direct calls outside the slash door.
|
|
14343
|
+
if (!guildId && adapter?.type === "discord")
|
|
14344
|
+
return t("slash.dm_unsupported");
|
|
14345
|
+
// C is distinct from fleet admin. Only C bypasses new-channel admission.
|
|
14346
|
+
if (classic.isAdmin(userId))
|
|
14347
|
+
return undefined;
|
|
14348
|
+
const scope = guildId ? "guild" : channelId.startsWith("-") ? "group" : "user";
|
|
14349
|
+
const targetId = guildId ?? (scope === "user" ? userId : channelId);
|
|
14350
|
+
const allowed = scope === "guild" ? classic.isGuildAllowed(targetId)
|
|
14351
|
+
: scope === "group" ? classic.isGroupAllowed(targetId) : classic.isUserAllowed(targetId);
|
|
14352
|
+
if (!allowed) {
|
|
14353
|
+
const generalName = this.findClassicApprovalGeneral(adapterId);
|
|
14354
|
+
if (generalName) {
|
|
14355
|
+
const message = scope === "guild" ? t("alert.unauth_guild", targetId, userId)
|
|
14356
|
+
: scope === "group" ? t("alert.new_group", channelId, targetId, userId, userId, "telegram")
|
|
14357
|
+
: t("alert.unauth_user_private", userId, userId, "telegram");
|
|
14358
|
+
void this.promptClassicApproval({
|
|
14359
|
+
generalName, message, groupId: String(targetId), scope, userId,
|
|
14360
|
+
replyTo: adapter ? { adapterId: adapter.id, adapter, chatId: channelId } : undefined,
|
|
14361
|
+
}).catch(err => this.logger.warn({ err, scope }, "Classic approval prompt failed"));
|
|
14362
|
+
}
|
|
14363
|
+
else {
|
|
14364
|
+
this.logger.warn({ adapterId, scope }, "Classic access request has no running General");
|
|
14365
|
+
}
|
|
14366
|
+
return t("classic.access_requested");
|
|
14367
|
+
}
|
|
14368
|
+
// Preserve the existing group start role. Allow only grants the group;
|
|
14369
|
+
// Allow+admin also gives the requester permission to start there.
|
|
14370
|
+
if (scope === "group") {
|
|
14371
|
+
const generalName = this.findClassicApprovalGeneral(adapterId);
|
|
14372
|
+
if (generalName)
|
|
14373
|
+
this.notifyInstanceTopic(generalName, t("alert.start_not_admin", userId, userId, "telegram", channelId));
|
|
14374
|
+
return t("classic.admin_only_start");
|
|
14375
|
+
}
|
|
12665
14376
|
return undefined;
|
|
12666
14377
|
}
|
|
12667
14378
|
isBackendInstalled(backend) {
|
|
@@ -12688,6 +14399,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12688
14399
|
await data.respond(t("classic.backend_required"));
|
|
12689
14400
|
return;
|
|
12690
14401
|
}
|
|
14402
|
+
const blocker = this.validateClassicStart(data.channelId, data.userId, data.guildId, adapterId);
|
|
14403
|
+
if (blocker) {
|
|
14404
|
+
await data.respond(blocker);
|
|
14405
|
+
return;
|
|
14406
|
+
}
|
|
12691
14407
|
const warning = this.getMissingBackendWarning(requestedBackend);
|
|
12692
14408
|
// Keep the deferred ephemeral response useful even if daemon startup later
|
|
12693
14409
|
// fails because the executable is absent. This is advisory, not a gate.
|
|
@@ -12793,7 +14509,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12793
14509
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
12794
14510
|
}
|
|
12795
14511
|
/** Start a classic channel instance with lightweight config */
|
|
12796
|
-
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
|
|
14512
|
+
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition, execution) {
|
|
14513
|
+
execution?.assert();
|
|
12797
14514
|
if (this.daemons.has(instanceName))
|
|
12798
14515
|
return;
|
|
12799
14516
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
@@ -12831,7 +14548,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12831
14548
|
+ "fix it in classicBot.yaml; it will not start on another login");
|
|
12832
14549
|
}
|
|
12833
14550
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
12834
|
-
|
|
14551
|
+
if (execution)
|
|
14552
|
+
await this.startInstance(instanceName, config, topicMode, "classic", false, transition, execution);
|
|
14553
|
+
else
|
|
14554
|
+
await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
|
|
12835
14555
|
}
|
|
12836
14556
|
/** Handle /start slash command — register classic channel */
|
|
12837
14557
|
async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
|
|
@@ -12893,6 +14613,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12893
14613
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
12894
14614
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
12895
14615
|
this.shuttingDown = true;
|
|
14616
|
+
this.settingsGeneration++;
|
|
14617
|
+
this.settingsConfirmation?.store.close();
|
|
14618
|
+
this.settingsConfirmation = null;
|
|
14619
|
+
const settingsControl = this.settingsControl;
|
|
14620
|
+
this.settingsControl = null;
|
|
14621
|
+
const settingsControlStopped = settingsControl?.close();
|
|
14622
|
+
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
14623
|
+
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
14624
|
+
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
14625
|
+
const needsStopped = this.stopNeedsYou();
|
|
12896
14626
|
const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
|
|
12897
14627
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
12898
14628
|
// Release held delivery promises before awaiting daemon shutdown, then
|
|
@@ -12922,7 +14652,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12922
14652
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
12923
14653
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
12924
14654
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
14655
|
+
await publicStopped;
|
|
12925
14656
|
await profileStopped;
|
|
14657
|
+
await needsStopped;
|
|
14658
|
+
await settingsControlStopped;
|
|
12926
14659
|
await this.cpuProfileControl?.close();
|
|
12927
14660
|
this.cpuProfileControl = null;
|
|
12928
14661
|
await this.shutdownLoginWindows();
|
|
@@ -13049,6 +14782,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13049
14782
|
this.healthServer.close();
|
|
13050
14783
|
this.healthServer = null;
|
|
13051
14784
|
}
|
|
14785
|
+
this.stopPreviewListener();
|
|
14786
|
+
// The store writes lastSeen at most once a minute; what that debounce is still holding — and any
|
|
14787
|
+
// write that failed and is still owed — is paid now, so a restart neither shortens the idle window
|
|
14788
|
+
// nor revives a session that was revoked while the disk was refusing writes.
|
|
14789
|
+
this.webSessions?.flush();
|
|
13052
14790
|
this.eventLog?.close();
|
|
13053
14791
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
13054
14792
|
try {
|
|
@@ -13269,6 +15007,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13269
15007
|
this.routing.rebuild(this.fleetConfig);
|
|
13270
15008
|
this.reregisterClassicChannels();
|
|
13271
15009
|
this.scheduler?.reload();
|
|
15010
|
+
this.reconcilePreviewListener();
|
|
15011
|
+
this.publicWebLink?.refresh();
|
|
13272
15012
|
const newInstances = this.fleetConfig.instances;
|
|
13273
15013
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
13274
15014
|
// Only what a fresh process can adopt, and only relative to the signature
|
|
@@ -13696,7 +15436,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13696
15436
|
const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
|
|
13697
15437
|
const existingId = this.providerSecretChallengesByKey.get(challengeKey);
|
|
13698
15438
|
const existing = existingId ? this.providerSecretChallenges.get(existingId) : undefined;
|
|
13699
|
-
if (existing && existing.expiresAt > Date.now()) {
|
|
15439
|
+
if (existing && (existing.deadline === undefined ? existing.expiresAt > Date.now() : existing.deadline > performance.now())) {
|
|
13700
15440
|
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, spec_id: spec.id, activation: spec.activation };
|
|
13701
15441
|
}
|
|
13702
15442
|
if (existingId)
|
|
@@ -13718,7 +15458,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13718
15458
|
generation: this.providerSecretGeneration(spec.envKey),
|
|
13719
15459
|
operation: "provider-secret.apply",
|
|
13720
15460
|
idempotencyKey: input.idempotencyKey,
|
|
13721
|
-
expiresAt,
|
|
15461
|
+
expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
|
|
13722
15462
|
secret: input.secret,
|
|
13723
15463
|
};
|
|
13724
15464
|
this.providerSecretChallenges.set(challenge.id, challenge);
|
|
@@ -13745,7 +15485,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13745
15485
|
}
|
|
13746
15486
|
const challenge = this.providerSecretChallenges.get(input.verificationId);
|
|
13747
15487
|
const spec = providerSecretSpec(input.specId);
|
|
13748
|
-
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
15488
|
+
if (!challenge || (challenge.deadline === undefined ? challenge.expiresAt <= Date.now() : challenge.deadline <= performance.now())) {
|
|
13749
15489
|
if (challenge)
|
|
13750
15490
|
this.providerSecretChallenges.delete(input.verificationId);
|
|
13751
15491
|
return { error: "verification expired; verify the secret again" };
|
|
@@ -13772,7 +15512,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13772
15512
|
this.providerSecretJobs.set(job.id, job);
|
|
13773
15513
|
this.providerSecretJobSession.set(job.id, input.sessionBinding);
|
|
13774
15514
|
this.providerSecretInFlight.set(spec.envKey, job.id);
|
|
13775
|
-
|
|
15515
|
+
this.queueSettingsOperation(job, [settingsFileResource(join(this.dataDir, ".env"))], input.execution, execution => this.runProviderSecretApply(job, challenge.secret, execution));
|
|
13776
15516
|
return { job, reused: false };
|
|
13777
15517
|
}
|
|
13778
15518
|
startProviderApiKeyApply(input) {
|
|
@@ -13786,7 +15526,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13786
15526
|
getProviderApiKeyApply(jobId, sessionBinding) {
|
|
13787
15527
|
return this.getProviderSecretApply(jobId, sessionBinding);
|
|
13788
15528
|
}
|
|
13789
|
-
async runProviderSecretApply(job, secret) {
|
|
15529
|
+
async runProviderSecretApply(job, secret, execution) {
|
|
13790
15530
|
const spec = providerSecretSpec(job.specId);
|
|
13791
15531
|
const allowed = new Set([
|
|
13792
15532
|
...PROVIDER_SECRET_SPECS.map(item => item.envKey),
|
|
@@ -13801,18 +15541,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13801
15541
|
throw new Error("provider secret is not configured");
|
|
13802
15542
|
// Construct inside the transaction: symlink/permission refusal must
|
|
13803
15543
|
// settle the job as a safe failure, not escape the queued microtask.
|
|
13804
|
-
store = new SecretStore(join(this.dataDir, ".env"), allowed);
|
|
13805
|
-
before = store.write(job.envKey, secret);
|
|
13806
|
-
|
|
13807
|
-
|
|
15544
|
+
store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
|
|
15545
|
+
const write = () => { before = store.write(job.envKey, secret); wrote = true; process.env[job.envKey] = secret; };
|
|
15546
|
+
if (execution)
|
|
15547
|
+
execution.mutate(write);
|
|
15548
|
+
else
|
|
15549
|
+
write();
|
|
13808
15550
|
if (spec.activation === "reload_hook" && spec.reloadHookId) {
|
|
13809
15551
|
await this.runProviderSecretReloadHook(spec.reloadHookId, secret, previousProcessValue);
|
|
15552
|
+
execution?.assert();
|
|
13810
15553
|
job.result = "reloaded";
|
|
13811
15554
|
}
|
|
13812
15555
|
else {
|
|
13813
15556
|
job.result = "applied_next_use";
|
|
13814
15557
|
}
|
|
13815
|
-
this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1);
|
|
15558
|
+
const commit = () => { this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1); };
|
|
15559
|
+
if (execution)
|
|
15560
|
+
execution.commit(commit);
|
|
15561
|
+
else
|
|
15562
|
+
commit();
|
|
13816
15563
|
job.status = "done";
|
|
13817
15564
|
job.finishedAt = Date.now();
|
|
13818
15565
|
}
|
|
@@ -13820,12 +15567,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13820
15567
|
const safe = safeSecretError(err, secret);
|
|
13821
15568
|
this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
|
|
13822
15569
|
try {
|
|
15570
|
+
// Memory and disk have separate receipts: a host-edited file must
|
|
15571
|
+
// remain, but cannot strand our owned process value or hook snapshot.
|
|
15572
|
+
if (wrote && process.env[job.envKey] === secret) {
|
|
15573
|
+
if (previousProcessValue === undefined)
|
|
15574
|
+
delete process.env[job.envKey];
|
|
15575
|
+
else
|
|
15576
|
+
process.env[job.envKey] = previousProcessValue;
|
|
15577
|
+
}
|
|
15578
|
+
if (spec?.reloadHookId && this.providerSecretHotSnapshots.get(spec.reloadHookId) === secret) {
|
|
15579
|
+
if (previousProcessValue === undefined)
|
|
15580
|
+
this.providerSecretHotSnapshots.delete(spec.reloadHookId);
|
|
15581
|
+
else
|
|
15582
|
+
this.providerSecretHotSnapshots.set(spec.reloadHookId, previousProcessValue);
|
|
15583
|
+
}
|
|
13823
15584
|
if (wrote && before && store)
|
|
13824
|
-
store.
|
|
13825
|
-
if (previousProcessValue === undefined)
|
|
13826
|
-
delete process.env[job.envKey];
|
|
13827
|
-
else
|
|
13828
|
-
process.env[job.envKey] = previousProcessValue;
|
|
15585
|
+
store.restoreIfCurrent(before);
|
|
13829
15586
|
// SecretStore.write is itself transactional; when it fails before a
|
|
13830
15587
|
// snapshot is returned there is no new value to roll back. Report the
|
|
13831
15588
|
// truthful no-op rather than claiming rollback_failed.
|
|
@@ -13842,7 +15599,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13842
15599
|
job.finishedAt = Date.now();
|
|
13843
15600
|
}
|
|
13844
15601
|
finally {
|
|
13845
|
-
this.providerSecretInFlight.
|
|
15602
|
+
if (this.providerSecretInFlight.get(job.envKey) === job.id)
|
|
15603
|
+
this.providerSecretInFlight.delete(job.envKey);
|
|
13846
15604
|
secret = "";
|
|
13847
15605
|
}
|
|
13848
15606
|
}
|
|
@@ -13861,10 +15619,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13861
15619
|
await hook(_next, before);
|
|
13862
15620
|
}
|
|
13863
15621
|
catch (err) {
|
|
13864
|
-
if (
|
|
13865
|
-
|
|
13866
|
-
|
|
13867
|
-
|
|
15622
|
+
if (this.providerSecretHotSnapshots.get(hookId) === _next) {
|
|
15623
|
+
if (before === undefined)
|
|
15624
|
+
this.providerSecretHotSnapshots.delete(hookId);
|
|
15625
|
+
else
|
|
15626
|
+
this.providerSecretHotSnapshots.set(hookId, before);
|
|
15627
|
+
}
|
|
13868
15628
|
throw err;
|
|
13869
15629
|
}
|
|
13870
15630
|
}
|
|
@@ -13919,7 +15679,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13919
15679
|
const key = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
|
|
13920
15680
|
const existingId = this.connectionBindingChallengesByKey.get(key);
|
|
13921
15681
|
const existing = existingId ? this.connectionBindingChallenges.get(existingId) : undefined;
|
|
13922
|
-
if (existing && existing.expiresAt > Date.now()) {
|
|
15682
|
+
if (existing && (existing.deadline === undefined ? existing.expiresAt > Date.now() : existing.deadline > performance.now())) {
|
|
13923
15683
|
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, binding: existing.binding, probe: existing.probe };
|
|
13924
15684
|
}
|
|
13925
15685
|
if (existingId)
|
|
@@ -13948,7 +15708,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13948
15708
|
generation: afterGeneration,
|
|
13949
15709
|
operation: "binding.apply",
|
|
13950
15710
|
idempotencyKey: input.idempotencyKey,
|
|
13951
|
-
expiresAt,
|
|
15711
|
+
expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
|
|
13952
15712
|
binding,
|
|
13953
15713
|
probe,
|
|
13954
15714
|
};
|
|
@@ -13971,7 +15731,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13971
15731
|
return { job, reused: true };
|
|
13972
15732
|
}
|
|
13973
15733
|
const challenge = this.connectionBindingChallenges.get(input.verificationId);
|
|
13974
|
-
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
15734
|
+
if (!challenge || (challenge.deadline === undefined ? challenge.expiresAt <= Date.now() : challenge.deadline <= performance.now())) {
|
|
13975
15735
|
this.connectionBindingChallenges.delete(input.verificationId);
|
|
13976
15736
|
return { error: "binding verification expired; verify the binding again" };
|
|
13977
15737
|
}
|
|
@@ -13996,7 +15756,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13996
15756
|
this.connectionBindingJobs.set(job.id, job);
|
|
13997
15757
|
this.connectionBindingJobSession.set(job.id, input.sessionBinding);
|
|
13998
15758
|
this.connectionBindingInFlight.set(input.connectionId, job.id);
|
|
13999
|
-
|
|
15759
|
+
this.queueSettingsOperation(job, [`connection:${this.dataDir}:${job.connectionId}`], input.execution, execution => this.runConnectionBindingApply(job, challenge.binding, execution));
|
|
14000
15760
|
return { job, reused: false };
|
|
14001
15761
|
}
|
|
14002
15762
|
getConnectionBindingApply(jobId, sessionBinding) {
|
|
@@ -14004,9 +15764,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14004
15764
|
return null;
|
|
14005
15765
|
return this.connectionBindingJobs.get(jobId) ?? null;
|
|
14006
15766
|
}
|
|
14007
|
-
async runConnectionBindingApply(job, binding) {
|
|
15767
|
+
async runConnectionBindingApply(job, binding, execution) {
|
|
14008
15768
|
try {
|
|
14009
|
-
await this.rebuildAdapterForBinding(job.connectionId, binding);
|
|
15769
|
+
await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
|
|
14010
15770
|
job.result = "applied";
|
|
14011
15771
|
}
|
|
14012
15772
|
catch (err) {
|
|
@@ -14020,11 +15780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14020
15780
|
finally {
|
|
14021
15781
|
job.status = "done";
|
|
14022
15782
|
job.finishedAt = Date.now();
|
|
14023
|
-
this.connectionBindingInFlight.
|
|
15783
|
+
if (this.connectionBindingInFlight.get(job.connectionId) === job.id)
|
|
15784
|
+
this.connectionBindingInFlight.delete(job.connectionId);
|
|
14024
15785
|
}
|
|
14025
15786
|
}
|
|
14026
15787
|
/** Stop, rebuild and wait for a new adapter before committing YAML binding. */
|
|
14027
|
-
async rebuildAdapterForBinding(connectionId, binding) {
|
|
15788
|
+
async rebuildAdapterForBinding(connectionId, binding, execution) {
|
|
15789
|
+
const generation = this.settingsGeneration;
|
|
15790
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation;
|
|
15791
|
+
const check = () => { execution?.assert(); if (!current())
|
|
15792
|
+
throw new Error("adapter operation superseded"); };
|
|
15793
|
+
check();
|
|
14028
15794
|
const channel = this.secureConnectionChannel(connectionId);
|
|
14029
15795
|
if (!channel || !this.fleetConfig)
|
|
14030
15796
|
throw new Error("connection not found");
|
|
@@ -14034,19 +15800,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14034
15800
|
const oldPrimary = this.adapter;
|
|
14035
15801
|
const oldAccess = this.accessManager;
|
|
14036
15802
|
const oldState = this.adapterState.get(connectionId);
|
|
14037
|
-
const
|
|
15803
|
+
const beforeBinding = structuredClone(this.fleetConfig);
|
|
15804
|
+
const bindingPath = this.fleetConfig.channels
|
|
15805
|
+
? ["channels", String(this.fleetConfig.channels.indexOf(channel))] : ["channel"];
|
|
15806
|
+
const bindingFields = [[...bindingPath, "group_id"],
|
|
15807
|
+
...(binding.general_channel_id !== undefined ? [[...bindingPath, "options", "general_channel_id"]] : [])];
|
|
15808
|
+
let bindingUndo = [];
|
|
14038
15809
|
const primary = this.getPrimaryAdapterId() === connectionId;
|
|
14039
15810
|
if (primary && this.sessionPruneTimer) {
|
|
14040
15811
|
clearInterval(this.sessionPruneTimer);
|
|
14041
15812
|
this.sessionPruneTimer = null;
|
|
14042
15813
|
}
|
|
14043
15814
|
let fresh;
|
|
14044
|
-
let persistedBinding = false;
|
|
14045
15815
|
try {
|
|
14046
15816
|
this.adapterState.set(connectionId, { status: "retrying", retryCount: oldState?.retryCount ?? 0 });
|
|
14047
15817
|
if (oldAdapter) {
|
|
14048
15818
|
oldAdapter.removeAllListeners();
|
|
14049
15819
|
await oldAdapter.stop().catch(() => { });
|
|
15820
|
+
check();
|
|
14050
15821
|
if (this.adapters.get(connectionId) === oldAdapter)
|
|
14051
15822
|
this.adapters.delete(connectionId);
|
|
14052
15823
|
if (this.worlds.get(connectionId)?.adapter === oldAdapter)
|
|
@@ -14058,21 +15829,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14058
15829
|
const started = new Promise(resolve => { startedResolve = resolve; });
|
|
14059
15830
|
const onStarted = () => { startedResolve?.(); };
|
|
14060
15831
|
if (primary)
|
|
14061
|
-
await this.startSingleAdapter(this.fleetConfig, candidate, onStarted);
|
|
15832
|
+
await this.startSingleAdapter(this.fleetConfig, candidate, onStarted, execution);
|
|
14062
15833
|
else
|
|
14063
|
-
await this.startAdditionalAdapter(candidate, true, onStarted);
|
|
15834
|
+
await this.startAdditionalAdapter(candidate, true, onStarted, execution);
|
|
15835
|
+
check();
|
|
14064
15836
|
fresh = this.adapters.get(connectionId);
|
|
14065
15837
|
if (!fresh)
|
|
14066
15838
|
throw new Error("new adapter did not start");
|
|
14067
|
-
const deadline =
|
|
15839
|
+
const deadline = performance.now() + 15_000;
|
|
14068
15840
|
if (!fresh.getHealthSnapshot) {
|
|
14069
15841
|
let timer;
|
|
14070
15842
|
const timeout = new Promise((_, reject) => {
|
|
14071
|
-
timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline -
|
|
15843
|
+
timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline - performance.now()));
|
|
14072
15844
|
timer.unref?.();
|
|
14073
15845
|
});
|
|
14074
15846
|
try {
|
|
14075
15847
|
await Promise.race([started, timeout]);
|
|
15848
|
+
check();
|
|
14076
15849
|
}
|
|
14077
15850
|
finally {
|
|
14078
15851
|
if (timer)
|
|
@@ -14080,11 +15853,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14080
15853
|
}
|
|
14081
15854
|
}
|
|
14082
15855
|
else {
|
|
14083
|
-
while (
|
|
15856
|
+
while (performance.now() < deadline) {
|
|
14084
15857
|
const health = fresh.getHealthSnapshot?.();
|
|
14085
15858
|
if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
|
|
14086
15859
|
break;
|
|
14087
15860
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
15861
|
+
check();
|
|
14088
15862
|
}
|
|
14089
15863
|
const health = fresh.getHealthSnapshot?.();
|
|
14090
15864
|
if (health && health.status !== "connected" && this.adapterState.get(connectionId)?.status !== "connected") {
|
|
@@ -14095,67 +15869,88 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14095
15869
|
fresh.setChatId(String(candidate.group_id));
|
|
14096
15870
|
// Commit only after the replacement adapter is ready. No allowlist,
|
|
14097
15871
|
// topic, instance or schedule fields are touched here.
|
|
14098
|
-
|
|
14099
|
-
|
|
14100
|
-
|
|
14101
|
-
|
|
14102
|
-
|
|
14103
|
-
|
|
14104
|
-
|
|
14105
|
-
|
|
14106
|
-
|
|
14107
|
-
|
|
14108
|
-
|
|
15872
|
+
const commit = () => {
|
|
15873
|
+
channel.group_id = String(binding.group_id);
|
|
15874
|
+
if (binding.general_channel_id !== undefined) {
|
|
15875
|
+
const options = { ...(channel.options ?? {}) };
|
|
15876
|
+
if (binding.general_channel_id === null)
|
|
15877
|
+
delete options.general_channel_id;
|
|
15878
|
+
else
|
|
15879
|
+
options.general_channel_id = String(binding.general_channel_id);
|
|
15880
|
+
if (Object.keys(options).length === 0)
|
|
15881
|
+
delete channel.options;
|
|
15882
|
+
else
|
|
15883
|
+
channel.options = options;
|
|
15884
|
+
}
|
|
15885
|
+
try {
|
|
15886
|
+
this.saveFleetConfig();
|
|
15887
|
+
}
|
|
15888
|
+
finally {
|
|
15889
|
+
bindingUndo = settingsUndo(this.configPath, beforeBinding, this.fleetConfig, bindingFields);
|
|
15890
|
+
}
|
|
15891
|
+
this.routing.rebuild(this.fleetConfig);
|
|
15892
|
+
this.reregisterClassicChannels();
|
|
15893
|
+
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
15894
|
+
};
|
|
15895
|
+
if (execution)
|
|
15896
|
+
execution.commit(commit);
|
|
15897
|
+
else {
|
|
15898
|
+
check();
|
|
15899
|
+
commit();
|
|
14109
15900
|
}
|
|
14110
|
-
this.saveFleetConfig();
|
|
14111
|
-
persistedBinding = true;
|
|
14112
|
-
this.routing.rebuild(this.fleetConfig);
|
|
14113
|
-
this.reregisterClassicChannels();
|
|
14114
|
-
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
14115
15901
|
}
|
|
14116
15902
|
catch (err) {
|
|
15903
|
+
let rollbackError;
|
|
15904
|
+
try {
|
|
15905
|
+
if (bindingUndo.length) {
|
|
15906
|
+
const disk = undoSettingsPaths(this.configPath, loadRawFleetConfig(this.configPath), bindingUndo);
|
|
15907
|
+
const runtime = undoSettingsPaths(this.configPath, this.fleetConfig, bindingUndo);
|
|
15908
|
+
if (disk.conflicts || runtime.conflicts)
|
|
15909
|
+
throw new Error("binding rollback conflict; newer configuration retained");
|
|
15910
|
+
noteSettingsWrite(this.configPath, this.fleetConfig, runtime.value);
|
|
15911
|
+
this.fleetConfig = runtime.value;
|
|
15912
|
+
this.saveFleetConfig(bindingFields.map(path => {
|
|
15913
|
+
let value = disk.value;
|
|
15914
|
+
for (const key of path)
|
|
15915
|
+
value = value?.[key];
|
|
15916
|
+
return { path, value, ...(value === undefined ? { remove: true } : {}) };
|
|
15917
|
+
}));
|
|
15918
|
+
}
|
|
15919
|
+
}
|
|
15920
|
+
catch (restoreErr) {
|
|
15921
|
+
rollbackError = restoreErr;
|
|
15922
|
+
}
|
|
14117
15923
|
if (fresh && fresh !== oldAdapter)
|
|
14118
15924
|
await fresh.stop().catch(() => { });
|
|
14119
|
-
|
|
14120
|
-
|
|
14121
|
-
|
|
14122
|
-
|
|
14123
|
-
|
|
14124
|
-
|
|
14125
|
-
|
|
14126
|
-
|
|
14127
|
-
|
|
14128
|
-
|
|
14129
|
-
|
|
14130
|
-
|
|
14131
|
-
|
|
14132
|
-
|
|
14133
|
-
|
|
14134
|
-
|
|
14135
|
-
|
|
14136
|
-
|
|
14137
|
-
|
|
14138
|
-
|
|
14139
|
-
|
|
15925
|
+
if (this.adapters.get(connectionId) === fresh || this.adapters.get(connectionId) === oldAdapter) {
|
|
15926
|
+
this.adapters.delete(connectionId);
|
|
15927
|
+
if (this.worlds.get(connectionId)?.adapter === fresh || this.worlds.get(connectionId)?.adapter === oldAdapter)
|
|
15928
|
+
this.worlds.delete(connectionId);
|
|
15929
|
+
if (primary && (this.adapter === fresh || this.adapter === oldAdapter))
|
|
15930
|
+
this.adapter = null;
|
|
15931
|
+
}
|
|
15932
|
+
if (current()) {
|
|
15933
|
+
const retained = this.secureConnectionChannel(connectionId);
|
|
15934
|
+
if (retained) {
|
|
15935
|
+
const cleanup = { owner: execution?.owner ?? Symbol("adapter-cleanup"), assert: () => { if (!current())
|
|
15936
|
+
throw new Error("cleanup superseded"); } };
|
|
15937
|
+
try {
|
|
15938
|
+
if (primary)
|
|
15939
|
+
await this.startSingleAdapter(this.fleetConfig, retained, () => { }, cleanup);
|
|
15940
|
+
else
|
|
15941
|
+
await this.startAdditionalAdapter(retained, true, () => { }, cleanup);
|
|
15942
|
+
if (!current())
|
|
15943
|
+
throw new Error("cleanup superseded");
|
|
15944
|
+
this.routing.rebuild(this.fleetConfig);
|
|
15945
|
+
this.reregisterClassicChannels();
|
|
15946
|
+
}
|
|
15947
|
+
catch (restoreErr) {
|
|
15948
|
+
throw new Error(`binding rollback failed: ${safeSecretError(restoreErr)}`);
|
|
15949
|
+
}
|
|
14140
15950
|
}
|
|
14141
15951
|
}
|
|
14142
|
-
|
|
14143
|
-
|
|
14144
|
-
this.adapter = oldPrimary;
|
|
14145
|
-
if (oldWorld)
|
|
14146
|
-
this.worlds.set(connectionId, oldWorld);
|
|
14147
|
-
if (oldAdapter)
|
|
14148
|
-
this.adapters.set(connectionId, oldAdapter);
|
|
14149
|
-
this.accessManager = oldAccess;
|
|
14150
|
-
}
|
|
14151
|
-
// The binding is committed to YAML before routing is rebuilt. If the
|
|
14152
|
-
// post-commit rebuild fails, restore the durable document as well as the
|
|
14153
|
-
// in-memory channel; otherwise a reload would resurrect the failed
|
|
14154
|
-
// binding that the running fleet just rolled back.
|
|
14155
|
-
if (persistedBinding)
|
|
14156
|
-
this.saveFleetConfig();
|
|
14157
|
-
this.routing.rebuild(this.fleetConfig);
|
|
14158
|
-
this.reregisterClassicChannels();
|
|
15952
|
+
if (rollbackError)
|
|
15953
|
+
throw new Error(`binding rollback failed: ${safeSecretError(rollbackError)}`);
|
|
14159
15954
|
throw err;
|
|
14160
15955
|
}
|
|
14161
15956
|
}
|
|
@@ -14170,7 +15965,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14170
15965
|
const challengeKey = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
|
|
14171
15966
|
const existingId = this.connectionSecretChallengesByKey.get(challengeKey);
|
|
14172
15967
|
const existing = existingId ? this.connectionSecretChallenges.get(existingId) : undefined;
|
|
14173
|
-
if (existing && existing.expiresAt > Date.now()) {
|
|
15968
|
+
if (existing && (existing.deadline === undefined ? existing.expiresAt > Date.now() : existing.deadline > performance.now())) {
|
|
14174
15969
|
return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt };
|
|
14175
15970
|
}
|
|
14176
15971
|
if (existingId)
|
|
@@ -14192,7 +15987,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14192
15987
|
generation: this.secureConnectionGeneration(input.connectionId),
|
|
14193
15988
|
operation: "secret.apply",
|
|
14194
15989
|
idempotencyKey: input.idempotencyKey,
|
|
14195
|
-
expiresAt,
|
|
15990
|
+
expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
|
|
14196
15991
|
secret: input.secret,
|
|
14197
15992
|
};
|
|
14198
15993
|
this.connectionSecretChallenges.set(challenge.id, challenge);
|
|
@@ -14222,7 +16017,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14222
16017
|
}
|
|
14223
16018
|
}
|
|
14224
16019
|
const challenge = this.connectionSecretChallenges.get(input.verificationId);
|
|
14225
|
-
if (!challenge || challenge.expiresAt <= Date.now()) {
|
|
16020
|
+
if (!challenge || (challenge.deadline === undefined ? challenge.expiresAt <= Date.now() : challenge.deadline <= performance.now())) {
|
|
14226
16021
|
this.connectionSecretChallenges.delete(input.verificationId);
|
|
14227
16022
|
return { error: "verification expired; verify the secret again" };
|
|
14228
16023
|
}
|
|
@@ -14256,7 +16051,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14256
16051
|
this.connectionSecretJobs.set(job.id, job);
|
|
14257
16052
|
this.connectionSecretJobSession.set(job.id, input.sessionBinding);
|
|
14258
16053
|
this.connectionSecretInFlight.set(input.connectionId, job.id);
|
|
14259
|
-
|
|
16054
|
+
this.queueSettingsOperation(job, [settingsFileResource(join(this.dataDir, ".env")), `connection:${this.dataDir}:${job.connectionId}`], input.execution, execution => this.runConnectionSecretApply(job, challenge.secret, execution));
|
|
14260
16055
|
return { job, reused: false };
|
|
14261
16056
|
}
|
|
14262
16057
|
getConnectionSecretApply(jobId, sessionBinding) {
|
|
@@ -14264,7 +16059,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14264
16059
|
return null;
|
|
14265
16060
|
return this.connectionSecretJobs.get(jobId) ?? null;
|
|
14266
16061
|
}
|
|
14267
|
-
async runConnectionSecretApply(job, secret) {
|
|
16062
|
+
async runConnectionSecretApply(job, secret, execution) {
|
|
14268
16063
|
const channel = this.secureConnectionChannel(job.connectionId);
|
|
14269
16064
|
const envKey = channel?.bot_token_env;
|
|
14270
16065
|
const allowed = new Set((this.fleetConfig?.channels
|
|
@@ -14272,6 +16067,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14272
16067
|
.map(item => item.bot_token_env));
|
|
14273
16068
|
let before = null;
|
|
14274
16069
|
let oldToken;
|
|
16070
|
+
let store;
|
|
16071
|
+
const generation = this.settingsGeneration;
|
|
14275
16072
|
let replaced = false;
|
|
14276
16073
|
try {
|
|
14277
16074
|
if (!channel || !envKey || !allowed.has(envKey))
|
|
@@ -14281,14 +16078,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14281
16078
|
.filter(item => item.bot_token_env === envKey);
|
|
14282
16079
|
if (owners.length !== 1)
|
|
14283
16080
|
throw new Error("secret key is shared by multiple connections");
|
|
14284
|
-
|
|
14285
|
-
|
|
14286
|
-
|
|
14287
|
-
|
|
14288
|
-
|
|
14289
|
-
|
|
14290
|
-
|
|
14291
|
-
|
|
16081
|
+
store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
|
|
16082
|
+
const write = () => {
|
|
16083
|
+
before = store.write(envKey, secret);
|
|
16084
|
+
replaced = true;
|
|
16085
|
+
oldToken = process.env[envKey];
|
|
16086
|
+
process.env[envKey] = secret;
|
|
16087
|
+
this.connectionSecretGenerations.set(job.connectionId, this.secureConnectionGeneration(job.connectionId) + 1);
|
|
16088
|
+
};
|
|
16089
|
+
if (execution)
|
|
16090
|
+
execution.mutate(write);
|
|
16091
|
+
else
|
|
16092
|
+
write();
|
|
16093
|
+
const applied = await this.rebuildAdapterForSecret(job.connectionId, channel, false, execution);
|
|
16094
|
+
execution?.assert();
|
|
16095
|
+
execution?.complete(); // Linearize success inside the runner, before its promise settles.
|
|
14292
16096
|
if (!applied) {
|
|
14293
16097
|
job.result = "restart_required";
|
|
14294
16098
|
job.status = "done";
|
|
@@ -14308,16 +16112,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14308
16112
|
}
|
|
14309
16113
|
else {
|
|
14310
16114
|
try {
|
|
14311
|
-
|
|
14312
|
-
|
|
14313
|
-
|
|
14314
|
-
|
|
14315
|
-
|
|
14316
|
-
|
|
16115
|
+
store.restoreIfCurrent(before);
|
|
16116
|
+
if (process.env[envKey] === secret) {
|
|
16117
|
+
if (oldToken === undefined)
|
|
16118
|
+
delete process.env[envKey];
|
|
16119
|
+
else
|
|
16120
|
+
process.env[envKey] = oldToken;
|
|
16121
|
+
}
|
|
14317
16122
|
// Build a fresh adapter from the restored token. If the old adapter
|
|
14318
16123
|
// was stopped already, this is the only safe way to return to the
|
|
14319
16124
|
// previous runtime without claiming a disk-only rollback succeeded.
|
|
14320
|
-
const
|
|
16125
|
+
const cleanup = { owner: execution?.owner ?? Symbol("secret-cleanup"), assert: () => {
|
|
16126
|
+
if (this.shuttingDown || this.settingsGeneration !== generation)
|
|
16127
|
+
throw new Error("secret cleanup superseded");
|
|
16128
|
+
} };
|
|
16129
|
+
const restored = await this.rebuildAdapterForSecret(job.connectionId, this.secureConnectionChannel(job.connectionId) ?? channel, true, cleanup);
|
|
14321
16130
|
if (!restored)
|
|
14322
16131
|
throw new Error("adapter rollback did not become connected");
|
|
14323
16132
|
job.result = "rolled_back";
|
|
@@ -14332,13 +16141,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14332
16141
|
job.finishedAt = Date.now();
|
|
14333
16142
|
}
|
|
14334
16143
|
finally {
|
|
14335
|
-
this.connectionSecretInFlight.
|
|
16144
|
+
if (this.connectionSecretInFlight.get(job.connectionId) === job.id)
|
|
16145
|
+
this.connectionSecretInFlight.delete(job.connectionId);
|
|
14336
16146
|
// Do not retain the token after the apply (success or rollback).
|
|
14337
16147
|
secret = "";
|
|
14338
16148
|
}
|
|
14339
16149
|
}
|
|
14340
16150
|
/** Stop the old provider client and construct a new one from process.env. */
|
|
14341
|
-
async rebuildAdapterForSecret(connectionId, channel, force = false) {
|
|
16151
|
+
async rebuildAdapterForSecret(connectionId, channel, force = false, execution) {
|
|
16152
|
+
const generation = this.settingsGeneration;
|
|
16153
|
+
const current = () => !this.shuttingDown && this.settingsGeneration === generation;
|
|
16154
|
+
const check = () => { execution?.assert(); if (!current())
|
|
16155
|
+
throw new Error("adapter operation superseded"); };
|
|
16156
|
+
check();
|
|
14342
16157
|
const old = this.adapters.get(connectionId);
|
|
14343
16158
|
if (!old && !force)
|
|
14344
16159
|
return false; // The secret is valid on disk; the next start adopts it.
|
|
@@ -14352,6 +16167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14352
16167
|
if (old) {
|
|
14353
16168
|
old.removeAllListeners();
|
|
14354
16169
|
await old.stop().catch(() => { });
|
|
16170
|
+
check();
|
|
14355
16171
|
if (this.adapters.get(connectionId) === old)
|
|
14356
16172
|
this.adapters.delete(connectionId);
|
|
14357
16173
|
if (this.worlds.get(connectionId)?.adapter === old)
|
|
@@ -14363,13 +16179,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14363
16179
|
const started = new Promise(resolve => { startedResolve = resolve; });
|
|
14364
16180
|
const onStarted = () => { startedResolve?.(); };
|
|
14365
16181
|
if (primary)
|
|
14366
|
-
await this.startSingleAdapter(this.fleetConfig, channel, onStarted);
|
|
16182
|
+
await this.startSingleAdapter(this.fleetConfig, channel, onStarted, execution);
|
|
14367
16183
|
else
|
|
14368
|
-
await this.startAdditionalAdapter(channel, true, onStarted);
|
|
16184
|
+
await this.startAdditionalAdapter(channel, true, onStarted, execution);
|
|
16185
|
+
check();
|
|
14369
16186
|
const fresh = this.adapters.get(connectionId);
|
|
14370
16187
|
if (!fresh)
|
|
14371
16188
|
throw new Error("new adapter did not start");
|
|
14372
|
-
const deadline =
|
|
16189
|
+
const deadline = performance.now() + 15_000;
|
|
14373
16190
|
// Telegram has no gateway health snapshot. Its start() method launches the
|
|
14374
16191
|
// grammY polling loop in the background, so completion of start() is not a
|
|
14375
16192
|
// connected signal. The adapter's `started` event is emitted only after the
|
|
@@ -14377,11 +16194,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14377
16194
|
if (!fresh.getHealthSnapshot) {
|
|
14378
16195
|
let timer;
|
|
14379
16196
|
const timeout = new Promise((_, reject) => {
|
|
14380
|
-
timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline -
|
|
16197
|
+
timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline - performance.now()));
|
|
14381
16198
|
timer.unref?.();
|
|
14382
16199
|
});
|
|
14383
16200
|
try {
|
|
14384
16201
|
await Promise.race([started, timeout]);
|
|
16202
|
+
check();
|
|
14385
16203
|
}
|
|
14386
16204
|
finally {
|
|
14387
16205
|
if (timer)
|
|
@@ -14390,11 +16208,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14390
16208
|
this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
|
|
14391
16209
|
return true;
|
|
14392
16210
|
}
|
|
14393
|
-
while (
|
|
16211
|
+
while (performance.now() < deadline) {
|
|
14394
16212
|
const health = fresh.getHealthSnapshot?.();
|
|
14395
16213
|
if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
|
|
14396
16214
|
return true;
|
|
14397
16215
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
16216
|
+
check();
|
|
14398
16217
|
}
|
|
14399
16218
|
throw new Error("new adapter did not become connected");
|
|
14400
16219
|
}
|
|
@@ -14801,14 +16620,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14801
16620
|
this.rejectedHostsLogged.add(name);
|
|
14802
16621
|
this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
|
|
14803
16622
|
}
|
|
14804
|
-
|
|
14805
|
-
|
|
14806
|
-
|
|
14807
|
-
this.healthPortRetried = false;
|
|
14808
|
-
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
14809
|
-
if (!this.webToken || !this.viewToken)
|
|
14810
|
-
this.initializeWebAuthTokens();
|
|
14811
|
-
this.healthServer = createServer((req, res) => {
|
|
16623
|
+
/** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
|
|
16624
|
+
dispatchWebHttp(req, res, port) {
|
|
16625
|
+
try {
|
|
14812
16626
|
res.setHeader("Content-Type", "application/json");
|
|
14813
16627
|
// No Referer to a tunnel host, an upstream proxy, or any page linked from
|
|
14814
16628
|
// the panel — the dashboard URL is itself a credential-bearing address.
|
|
@@ -14820,7 +16634,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14820
16634
|
// Before any route, /health and /agent included: loopback binding does not
|
|
14821
16635
|
// stop DNS rebinding, and the Host the browser sends is the one thing a
|
|
14822
16636
|
// rebinding page cannot change.
|
|
14823
|
-
if (!isHostAllowed(req.headers.host, allowedHostNames(this.fleetConfig))) {
|
|
16637
|
+
if (!gatewayRequestContext(req) && !isHostAllowed(req.headers.host, allowedHostNames(this.fleetConfig))) {
|
|
14824
16638
|
this.noteRejectedHost(req.headers.host);
|
|
14825
16639
|
res.writeHead(403);
|
|
14826
16640
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
@@ -14835,43 +16649,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14835
16649
|
res.end();
|
|
14836
16650
|
return;
|
|
14837
16651
|
}
|
|
14838
|
-
// Public health probe
|
|
14839
|
-
|
|
14840
|
-
|
|
14841
|
-
|
|
14842
|
-
else if (req.method === "POST" && req.url === "/agent") {
|
|
14843
|
-
// /agent handles its own instance-level auth via X-Agend-Instance-Token
|
|
14844
|
-
}
|
|
14845
|
-
else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
|
|
14846
|
-
// /view routes accept the read-only view.token (or web.token) and do
|
|
14847
|
-
// their own per-method auth in view-api.ts — skip the web-token gate.
|
|
14848
|
-
}
|
|
14849
|
-
else if (isUsagePath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
|
|
14850
|
-
// /api/ai-usage is read-only GET data for the /view Usage panel — open
|
|
14851
|
-
// like the other /view data routes (usage-api.ts rejects non-GET).
|
|
16652
|
+
// Public: the health probe, /agent (instance-token auth of its own), the
|
|
16653
|
+
// sign-in surface, and /view's reads unless web.view_access says otherwise.
|
|
16654
|
+
if (bypassesWebGate(req, requestPath, this.fleetConfig, p => !gatewayRequestContext(req) && (isViewPath(p) || isUsagePath(p)))) {
|
|
16655
|
+
// fall through to the handlers below
|
|
14852
16656
|
}
|
|
14853
16657
|
else {
|
|
14854
16658
|
// All other endpoints require a session cookie or an X-Agend-Token
|
|
14855
|
-
// header; a `?token=` in the URL is
|
|
16659
|
+
// header; a `?token=` in the URL is not a credential.
|
|
14856
16660
|
// /ui/* will also re-check in web-api.ts, which is harmless.
|
|
14857
16661
|
const parsedUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
14858
|
-
const decision = decideWebGate(req, parsedUrl, this.webToken);
|
|
16662
|
+
const decision = decideWebGate(req, parsedUrl, this.webToken, this.webSessions);
|
|
14859
16663
|
if (decision.kind === "reject") {
|
|
16664
|
+
// A browser navigating to a panel with no cookie gets the sign-in page,
|
|
16665
|
+
// not a JSON error: a SameSite=Strict cookie is not sent on a link
|
|
16666
|
+
// followed from a chat app, and the page can tell "no session" from "cookie
|
|
16667
|
+
// not sent" by asking from inside the site. API callers still get JSON.
|
|
16668
|
+
if (decision.reason === "no-credential" && req.method === "GET"
|
|
16669
|
+
&& String(req.headers.accept ?? "").includes("text/html")
|
|
16670
|
+
&& isWebPageNavigation(requestPath)) {
|
|
16671
|
+
serveSigninPage(res, 401);
|
|
16672
|
+
return;
|
|
16673
|
+
}
|
|
14860
16674
|
res.writeHead(decision.status);
|
|
14861
16675
|
res.end(JSON.stringify({ error: decision.message }));
|
|
14862
16676
|
return;
|
|
14863
16677
|
}
|
|
14864
|
-
if (decision.kind === "exchange") {
|
|
14865
|
-
res.setHeader("Set-Cookie", decision.setCookie);
|
|
14866
|
-
res.setHeader("Location", decision.location);
|
|
14867
|
-
// A cached redirect would replay a Set-Cookie for a rotated token.
|
|
14868
|
-
res.setHeader("Cache-Control", "no-store");
|
|
14869
|
-
res.writeHead(302);
|
|
14870
|
-
// Browsers follow the Location; a script that does not gets told why
|
|
14871
|
-
// its URL token stopped being echoed back as data.
|
|
14872
|
-
res.end(JSON.stringify({ redirect: decision.location }));
|
|
14873
|
-
return;
|
|
14874
|
-
}
|
|
14875
16678
|
}
|
|
14876
16679
|
if (req.method === "GET" && req.url === "/health") {
|
|
14877
16680
|
const health = this.getFleetHealth();
|
|
@@ -14971,12 +16774,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14971
16774
|
...this.instancePresentation(inst.name),
|
|
14972
16775
|
};
|
|
14973
16776
|
});
|
|
14974
|
-
|
|
16777
|
+
if (!gatewayRequestContext(req))
|
|
16778
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
14975
16779
|
res.writeHead(200);
|
|
14976
16780
|
res.end(JSON.stringify({
|
|
14977
16781
|
...sysInfo,
|
|
14978
16782
|
version: this.currentVersion,
|
|
14979
16783
|
instances: enriched,
|
|
16784
|
+
publicLink: this.getPublicWebStatus(),
|
|
14980
16785
|
}));
|
|
14981
16786
|
}
|
|
14982
16787
|
catch (err) {
|
|
@@ -15001,7 +16806,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15001
16806
|
sinceIso = new Date(Date.now() - val * unit).toISOString();
|
|
15002
16807
|
}
|
|
15003
16808
|
const rows = this.eventLog?.listActivity({ since: sinceIso, limit: parseInt(limitParam, 10) }) ?? [];
|
|
15004
|
-
|
|
16809
|
+
if (!gatewayRequestContext(req))
|
|
16810
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
15005
16811
|
res.writeHead(200);
|
|
15006
16812
|
res.end(JSON.stringify(rows));
|
|
15007
16813
|
return;
|
|
@@ -15091,17 +16897,52 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15091
16897
|
}
|
|
15092
16898
|
// ── Web UI endpoints (delegated to web-api.ts) ─────
|
|
15093
16899
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
15094
|
-
|
|
15095
|
-
|
|
15096
|
-
|
|
15097
|
-
|
|
15098
|
-
|
|
15099
|
-
|
|
15100
|
-
|
|
16900
|
+
// A handler that throws synchronously answers this request with a 500; it must never reach the process's
|
|
16901
|
+
// uncaughtException handler, which stops the whole fleet (#1252 review: one file name did that).
|
|
16902
|
+
try {
|
|
16903
|
+
if (handleAuthRequest(req, res, url, this))
|
|
16904
|
+
return;
|
|
16905
|
+
if (handleViewRequest(req, res, url, this))
|
|
16906
|
+
return;
|
|
16907
|
+
if (handleUsageRequest(req, res, url, this))
|
|
16908
|
+
return;
|
|
16909
|
+
const settingsNext = (request, response, address) => handleSettingsRequest(request, response, address, this)
|
|
16910
|
+
|| handleWebRequest(request, response, address, this);
|
|
16911
|
+
if (this.settingsGate().handle(req, res, url, settingsNext))
|
|
16912
|
+
return;
|
|
16913
|
+
if (settingsNext(req, res, url))
|
|
16914
|
+
return;
|
|
16915
|
+
}
|
|
16916
|
+
catch (err) {
|
|
16917
|
+
this.logger.error({ err: err?.message, path: url.pathname }, "Web request handler threw");
|
|
16918
|
+
if (!res.headersSent) {
|
|
16919
|
+
res.writeHead(500);
|
|
16920
|
+
res.end(JSON.stringify({ error: "internal error" }));
|
|
16921
|
+
}
|
|
16922
|
+
else
|
|
16923
|
+
res.destroy();
|
|
15101
16924
|
return;
|
|
16925
|
+
}
|
|
15102
16926
|
res.writeHead(404);
|
|
15103
16927
|
res.end(JSON.stringify({ error: "not found" }));
|
|
15104
|
-
}
|
|
16928
|
+
}
|
|
16929
|
+
catch {
|
|
16930
|
+
if (!res.headersSent) {
|
|
16931
|
+
res.writeHead(400);
|
|
16932
|
+
res.end(JSON.stringify({ error: "invalid request" }));
|
|
16933
|
+
}
|
|
16934
|
+
else
|
|
16935
|
+
res.destroy();
|
|
16936
|
+
}
|
|
16937
|
+
}
|
|
16938
|
+
startHealthServer(port) {
|
|
16939
|
+
this.startedAt = Date.now();
|
|
16940
|
+
this.healthServerListening = false;
|
|
16941
|
+
this.healthPortRetried = false;
|
|
16942
|
+
// Defensive for direct/unit callers; normal startup initializes these before adapters.
|
|
16943
|
+
if (!this.webToken || !this.webSessions)
|
|
16944
|
+
this.initializeWebAuthTokens();
|
|
16945
|
+
this.healthServer = createServer((req, res) => this.dispatchWebHttp(req, res, port));
|
|
15105
16946
|
const markListening = (afterTakeover = false) => {
|
|
15106
16947
|
this.healthServerListening = true;
|
|
15107
16948
|
this.logger.info({ port }, afterTakeover
|
|
@@ -15112,6 +16953,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15112
16953
|
// `/dashboard` and `agend web` are the ways to get an authorized link.
|
|
15113
16954
|
this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
|
|
15114
16955
|
this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
|
|
16956
|
+
// #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
|
|
16957
|
+
const bound = this.healthServer?.address();
|
|
16958
|
+
this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
|
|
15115
16959
|
};
|
|
15116
16960
|
this.healthServer.on("error", (err) => {
|
|
15117
16961
|
this.healthServerListening = false;
|
|
@@ -15165,6 +17009,74 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15165
17009
|
});
|
|
15166
17010
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
15167
17011
|
}
|
|
17012
|
+
/**
|
|
17013
|
+
* #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
|
|
17014
|
+
* taken, web.preview: false), cards show Source and Download only; nothing else changes.
|
|
17015
|
+
*/
|
|
17016
|
+
startPreviewListener(requestedPort, boundPort) {
|
|
17017
|
+
this.stopPreviewListener();
|
|
17018
|
+
this.previewPorts = { requested: requestedPort, bound: boundPort };
|
|
17019
|
+
this.previewInputs = this.previewInputsSignature();
|
|
17020
|
+
// web.preview is hot; web.preview_port and web.preview_origin are startup-only (STARTUP_ONLY_FLEET_KEYS): they
|
|
17021
|
+
// come from the configuration this process started on, so a reload that changes them reports "restart required"
|
|
17022
|
+
// and a hot re-enable never half-applies them.
|
|
17023
|
+
const started = (this.startupFleetConfig ?? this.fleetConfig)?.web;
|
|
17024
|
+
const settings = previewSettings({ preview: this.fleetConfig?.web?.preview, preview_port: started?.preview_port, preview_origin: started?.preview_origin }, requestedPort);
|
|
17025
|
+
if (!settings.enabled)
|
|
17026
|
+
return;
|
|
17027
|
+
if (settings.port === null) {
|
|
17028
|
+
this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
|
|
17029
|
+
return;
|
|
17030
|
+
}
|
|
17031
|
+
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig });
|
|
17032
|
+
this.previewListener = listener;
|
|
17033
|
+
listener.server.on("error", (err) => {
|
|
17034
|
+
if (this.previewListener !== listener)
|
|
17035
|
+
return;
|
|
17036
|
+
this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
|
|
17037
|
+
this.previewListening = false;
|
|
17038
|
+
this.previewListener = null;
|
|
17039
|
+
});
|
|
17040
|
+
// An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
|
|
17041
|
+
try {
|
|
17042
|
+
listener.server.listen(settings.port, "127.0.0.1", () => { if (this.previewListener === listener)
|
|
17043
|
+
this.previewListening = true; });
|
|
17044
|
+
}
|
|
17045
|
+
catch (err) {
|
|
17046
|
+
this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
|
|
17047
|
+
this.previewListening = false;
|
|
17048
|
+
this.previewListener = null;
|
|
17049
|
+
}
|
|
17050
|
+
}
|
|
17051
|
+
/** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
|
|
17052
|
+
previewInputsSignature() {
|
|
17053
|
+
const c = this.fleetConfig;
|
|
17054
|
+
return JSON.stringify({ p: c?.web?.preview ?? null, h: c?.hostname ?? null, a: c?.web?.allowed_hosts ?? null });
|
|
17055
|
+
}
|
|
17056
|
+
/**
|
|
17057
|
+
* After a config reload: web.preview true→false stops the listener at once (/ui then offers no origin, boot or
|
|
17058
|
+
* frame-src), false→true starts it, and a change of dashboard names rebuilds it (a new boot id). preview_port and
|
|
17059
|
+
* preview_origin are not adopted here: they are startup-only, and the reload reports "restart required".
|
|
17060
|
+
*/
|
|
17061
|
+
reconcilePreviewListener() {
|
|
17062
|
+
if (!this.previewPorts || this.previewInputsSignature() === this.previewInputs)
|
|
17063
|
+
return;
|
|
17064
|
+
this.logger.info({}, "HTML preview settings changed — rebuilding the preview listener");
|
|
17065
|
+
this.startPreviewListener(this.previewPorts.requested, this.previewPorts.bound);
|
|
17066
|
+
}
|
|
17067
|
+
stopPreviewListener() {
|
|
17068
|
+
this.previewListening = false;
|
|
17069
|
+
this.previewListener?.close();
|
|
17070
|
+
this.previewListener = null;
|
|
17071
|
+
}
|
|
17072
|
+
/** For one /ui load: the preview origin it may frame, and the listener's boot id (see web-preview.ts). */
|
|
17073
|
+
previewForUi(hostHeader, secure) {
|
|
17074
|
+
const listener = this.previewListening ? this.previewListener : null;
|
|
17075
|
+
const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
|
|
17076
|
+
if (!listener && this.fleetConfig?.web?.preview !== false)
|
|
17077
|
+
decided.reason = "Previews are not available on this fleet right now.";
|
|
17078
|
+
return { ...decided, boot: listener ? listener.bootId : null };
|
|
17079
|
+
}
|
|
15168
17080
|
getUiStatus() {
|
|
15169
17081
|
return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
|
|
15170
17082
|
}
|
|
@@ -15221,6 +17133,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15221
17133
|
name,
|
|
15222
17134
|
display_name: display_name || undefined,
|
|
15223
17135
|
status: this.getInstanceStatus(name),
|
|
17136
|
+
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
17137
|
+
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
15224
17138
|
context_pct,
|
|
15225
17139
|
cost,
|
|
15226
17140
|
model,
|
|
@@ -15233,6 +17147,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
15233
17147
|
});
|
|
15234
17148
|
return {
|
|
15235
17149
|
instances,
|
|
17150
|
+
publicLink: this.getPublicWebStatus(),
|
|
15236
17151
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
15237
17152
|
};
|
|
15238
17153
|
}
|