@botcord/daemon 0.1.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/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GatewayChannelConfig,
|
|
3
|
+
GatewayConfig,
|
|
4
|
+
GatewayRoute,
|
|
5
|
+
RouteMatch,
|
|
6
|
+
TrustLevel as GatewayTrustLevel,
|
|
7
|
+
} from "./gateway/index.js";
|
|
8
|
+
import type { DaemonConfig, RouteRule } from "./config.js";
|
|
9
|
+
import { resolveAgentIds } from "./config.js";
|
|
10
|
+
import { agentWorkspaceDir } from "./agent-workspace.js";
|
|
11
|
+
import { log as daemonLog } from "./log.js";
|
|
12
|
+
|
|
13
|
+
/** Options accepted by {@link toGatewayConfig}. */
|
|
14
|
+
export interface ToGatewayConfigOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Explicit list of agent ids to bind channels to. When provided, overrides
|
|
17
|
+
* anything derivable from the daemon config itself. P1 passes discovered
|
|
18
|
+
* credentials in via this field so `toGatewayConfig` stays pure.
|
|
19
|
+
*/
|
|
20
|
+
agentIds?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Per-agent runtime/cwd cached from credentials (see
|
|
23
|
+
* `docs/agent-runtime-property-plan.md`). When present for an agent id,
|
|
24
|
+
* `toGatewayConfig` synthesizes a terminal route pinning that agent's
|
|
25
|
+
* turns to its runtime. Explicit `cfg.routes` entries still win because
|
|
26
|
+
* synthesized routes are appended after them.
|
|
27
|
+
*/
|
|
28
|
+
agentRuntimes?: Record<string, { runtime?: string; cwd?: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Historical channel id used when the daemon bound a single agent. Kept as a
|
|
33
|
+
* named export for any downstream reader that still references it; no new
|
|
34
|
+
* code paths in the daemon emit this id — channels are now keyed by agentId.
|
|
35
|
+
*
|
|
36
|
+
* @deprecated Channel ids are now the agentId itself.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
|
|
39
|
+
|
|
40
|
+
/** Channel `type` tag used by `createBotCordChannel`. */
|
|
41
|
+
export const BOTCORD_CHANNEL_TYPE = "botcord";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map daemon's historical narrower TrustLevel ("owner" | "untrusted") onto
|
|
45
|
+
* gateway's ("owner" | "trusted" | "public"). Matches the adapter-level
|
|
46
|
+
* mapping in `adapters/runtimes.ts`: "untrusted" collapses to "public".
|
|
47
|
+
* Accepts `undefined` → `undefined` so callers can pass through.
|
|
48
|
+
*/
|
|
49
|
+
function mapTrustLevel(
|
|
50
|
+
level: "owner" | "untrusted" | undefined,
|
|
51
|
+
): GatewayTrustLevel | undefined {
|
|
52
|
+
if (level === undefined) return undefined;
|
|
53
|
+
return level === "owner" ? "owner" : "public";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Translate a single daemon route rule into a gateway route. Gateway matches
|
|
58
|
+
* on the channel-agnostic fields; the daemon surface keeps the legacy
|
|
59
|
+
* `roomId`/`roomPrefix` aliases for backward compatibility. When both a
|
|
60
|
+
* legacy alias and its canonical field are present, the canonical field
|
|
61
|
+
* wins and a warning is logged.
|
|
62
|
+
*/
|
|
63
|
+
function mapRoute(r: RouteRule): GatewayRoute {
|
|
64
|
+
const match: RouteMatch = {};
|
|
65
|
+
if (r.match.channel) match.channel = r.match.channel;
|
|
66
|
+
if (r.match.accountId) match.accountId = r.match.accountId;
|
|
67
|
+
|
|
68
|
+
if (r.match.conversationId && r.match.roomId && r.match.conversationId !== r.match.roomId) {
|
|
69
|
+
daemonLog.warn("daemon.config.route.conflict", {
|
|
70
|
+
field: "conversationId",
|
|
71
|
+
roomId: r.match.roomId,
|
|
72
|
+
conversationId: r.match.conversationId,
|
|
73
|
+
resolution: "conversationId wins",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const conversationId = r.match.conversationId ?? r.match.roomId;
|
|
77
|
+
if (conversationId) match.conversationId = conversationId;
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
r.match.conversationPrefix &&
|
|
81
|
+
r.match.roomPrefix &&
|
|
82
|
+
r.match.conversationPrefix !== r.match.roomPrefix
|
|
83
|
+
) {
|
|
84
|
+
daemonLog.warn("daemon.config.route.conflict", {
|
|
85
|
+
field: "conversationPrefix",
|
|
86
|
+
roomPrefix: r.match.roomPrefix,
|
|
87
|
+
conversationPrefix: r.match.conversationPrefix,
|
|
88
|
+
resolution: "conversationPrefix wins",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const conversationPrefix = r.match.conversationPrefix ?? r.match.roomPrefix;
|
|
92
|
+
if (conversationPrefix) match.conversationPrefix = conversationPrefix;
|
|
93
|
+
|
|
94
|
+
if (r.match.conversationKind) match.conversationKind = r.match.conversationKind;
|
|
95
|
+
if (r.match.senderId) match.senderId = r.match.senderId;
|
|
96
|
+
if (typeof r.match.mentioned === "boolean") match.mentioned = r.match.mentioned;
|
|
97
|
+
|
|
98
|
+
const rawTrust = (r as { trustLevel?: "owner" | "untrusted" }).trustLevel;
|
|
99
|
+
return {
|
|
100
|
+
match,
|
|
101
|
+
runtime: r.adapter,
|
|
102
|
+
cwd: r.cwd,
|
|
103
|
+
extraArgs: r.extraArgs,
|
|
104
|
+
trustLevel: mapTrustLevel(rawTrust),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert the daemon's on-disk config into a gateway runtime config. Only
|
|
110
|
+
* used in-process at daemon boot; the daemon config file itself is the
|
|
111
|
+
* user-facing contract.
|
|
112
|
+
*
|
|
113
|
+
* When `opts.agentIds` is provided (discovery or explicit override), the
|
|
114
|
+
* mapper trusts that list verbatim. Otherwise it falls back to the legacy
|
|
115
|
+
* `resolveAgentIds(cfg)` path so callers that haven't been updated for P1
|
|
116
|
+
* keep working.
|
|
117
|
+
*/
|
|
118
|
+
export function toGatewayConfig(
|
|
119
|
+
cfg: DaemonConfig,
|
|
120
|
+
opts: ToGatewayConfigOptions = {},
|
|
121
|
+
): GatewayConfig {
|
|
122
|
+
// One channel per configured agent. Channel id = agentId so session keys
|
|
123
|
+
// (`runtime:channel:accountId:kind:convId`) and activity records carry
|
|
124
|
+
// the agent identity end-to-end. Pre-multi-agent single-agent installs
|
|
125
|
+
// previously used the fixed id "botcord-main"; existing on-disk session
|
|
126
|
+
// entries keyed by that id are silently dropped on the first message
|
|
127
|
+
// after upgrade — a one-time reset, not a bug.
|
|
128
|
+
const agentIds = opts.agentIds ?? resolveAgentIds(cfg);
|
|
129
|
+
const channels: GatewayChannelConfig[] = agentIds.map((agentId) => ({
|
|
130
|
+
id: agentId,
|
|
131
|
+
type: BOTCORD_CHANNEL_TYPE,
|
|
132
|
+
accountId: agentId,
|
|
133
|
+
agentId,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
|
|
137
|
+
// defensively so future config extensions can propagate without a shape bump.
|
|
138
|
+
const rawDefaultTrust = (cfg.defaultRoute as { trustLevel?: "owner" | "untrusted" })
|
|
139
|
+
.trustLevel;
|
|
140
|
+
const defaultRoute: GatewayRoute = {
|
|
141
|
+
runtime: cfg.defaultRoute.adapter,
|
|
142
|
+
cwd: cfg.defaultRoute.cwd,
|
|
143
|
+
extraArgs: cfg.defaultRoute.extraArgs,
|
|
144
|
+
// queueMode: omitted — dispatcher's kind-based default wins
|
|
145
|
+
// (direct → cancel-previous, group → serial).
|
|
146
|
+
trustLevel: mapTrustLevel(rawDefaultTrust),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const routes: GatewayRoute[] = (cfg.routes ?? []).map(mapRoute);
|
|
150
|
+
|
|
151
|
+
// Synthesize a per-agent route for every bound agent and hand it to the
|
|
152
|
+
// gateway via the managed-routes bucket (plan §10.1). User-authored
|
|
153
|
+
// `cfg.routes[]` stay untouched so an explicit operator override still
|
|
154
|
+
// wins on conflict — the gateway matches `routes[] → managedRoutes →
|
|
155
|
+
// defaultRoute` in that order.
|
|
156
|
+
const managedMap = buildManagedRoutes(
|
|
157
|
+
agentIds,
|
|
158
|
+
opts.agentRuntimes ?? {},
|
|
159
|
+
defaultRoute,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
channels,
|
|
164
|
+
defaultRoute,
|
|
165
|
+
routes,
|
|
166
|
+
managedRoutes: Array.from(managedMap.values()),
|
|
167
|
+
streamBlocks: cfg.streamBlocks,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build the daemon's managed per-agent routes. Emits exactly one route per
|
|
173
|
+
* `agentId`, keyed by `accountId`. `runtime` comes from the agent's cached
|
|
174
|
+
* metadata when present (credentials file), otherwise falls back to
|
|
175
|
+
* `defaultRoute.runtime`. `cwd` prefers the cached value but falls back to
|
|
176
|
+
* the agent's workspace directory (see plan §10) so every agent runs inside
|
|
177
|
+
* its own dedicated tree by default.
|
|
178
|
+
*
|
|
179
|
+
* Iteration order of `agentIds` is preserved in the resulting Map for test
|
|
180
|
+
* determinism; the gateway router does not depend on map order.
|
|
181
|
+
*
|
|
182
|
+
* Exported so `reload_config` and `provisionAgent` hot-add can share the
|
|
183
|
+
* same synthesis logic (plan §10.5).
|
|
184
|
+
*/
|
|
185
|
+
export function buildManagedRoutes(
|
|
186
|
+
agentIds: string[],
|
|
187
|
+
agentRuntimes: Record<string, { runtime?: string; cwd?: string }>,
|
|
188
|
+
defaultRoute: GatewayRoute,
|
|
189
|
+
): Map<string, GatewayRoute> {
|
|
190
|
+
const out = new Map<string, GatewayRoute>();
|
|
191
|
+
for (const agentId of agentIds) {
|
|
192
|
+
const meta = agentRuntimes[agentId] ?? {};
|
|
193
|
+
out.set(agentId, {
|
|
194
|
+
match: { accountId: agentId },
|
|
195
|
+
runtime: meta.runtime ?? defaultRoute.runtime,
|
|
196
|
+
cwd: meta.cwd || agentWorkspaceDir(agentId),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
|
|
2
|
+
import {
|
|
3
|
+
Gateway,
|
|
4
|
+
createBotCordChannel,
|
|
5
|
+
sanitizeUntrustedContent,
|
|
6
|
+
type ChannelAdapter,
|
|
7
|
+
type GatewayChannelConfig,
|
|
8
|
+
type GatewayInboundMessage,
|
|
9
|
+
type GatewayLogger,
|
|
10
|
+
type GatewayRuntimeSnapshot,
|
|
11
|
+
} from "./gateway/index.js";
|
|
12
|
+
import { ActivityTracker } from "./activity-tracker.js";
|
|
13
|
+
import type { DaemonConfig } from "./config.js";
|
|
14
|
+
import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
|
|
15
|
+
import { resolveBootAgents, type BootAgentsResult } from "./agent-discovery.js";
|
|
16
|
+
import { ensureAgentWorkspace } from "./agent-workspace.js";
|
|
17
|
+
import { ControlChannel } from "./control-channel.js";
|
|
18
|
+
import { toGatewayConfig } from "./daemon-config-map.js";
|
|
19
|
+
import { log as daemonLog } from "./log.js";
|
|
20
|
+
import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
|
|
21
|
+
import { SnapshotWriter } from "./snapshot-writer.js";
|
|
22
|
+
import { createDaemonSystemContextBuilder } from "./system-context.js";
|
|
23
|
+
import { createRoomStaticContextBuilder } from "./room-context.js";
|
|
24
|
+
import { createRoomContextFetcher } from "./room-context-fetcher.js";
|
|
25
|
+
import { composeBotCordUserTurn } from "./turn-text.js";
|
|
26
|
+
import { UserAuthManager } from "./user-auth.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
|
|
30
|
+
* long-running CLI turns behave the same way under the gateway core.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default cadence for writing `gateway.snapshot()` to disk. Override via
|
|
36
|
+
* `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_SNAPSHOT_INTERVAL_MS = 5_000;
|
|
39
|
+
|
|
40
|
+
function resolveSnapshotIntervalMs(): number {
|
|
41
|
+
const raw = process.env.BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS;
|
|
42
|
+
if (!raw) return DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
43
|
+
const n = Number(raw);
|
|
44
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_SNAPSHOT_INTERVAL_MS;
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Sender classification lives in `./sender-classify.ts` so it can be shared
|
|
49
|
+
// with the user-turn composer without a daemon.ts ↔ turn-text.ts cycle.
|
|
50
|
+
import { classifyActivitySender } from "./sender-classify.js";
|
|
51
|
+
export { classifyActivitySender };
|
|
52
|
+
|
|
53
|
+
/** Minimal activity-tracker surface the inbound observer uses. */
|
|
54
|
+
interface ActivityRecorderTarget {
|
|
55
|
+
record: (entry: {
|
|
56
|
+
agentId: string;
|
|
57
|
+
roomId: string;
|
|
58
|
+
roomName?: string;
|
|
59
|
+
topic: string | null;
|
|
60
|
+
lastInboundPreview: string;
|
|
61
|
+
lastSenderKind: "agent" | "human" | "owner";
|
|
62
|
+
lastSender: string;
|
|
63
|
+
}) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the `onInbound` observer wired to the given activity tracker.
|
|
68
|
+
* Exported for tests.
|
|
69
|
+
*
|
|
70
|
+
* The recorded `agentId` is taken from the inbound message's `accountId`
|
|
71
|
+
* — so a multi-agent daemon files activity under whichever configured
|
|
72
|
+
* agent actually received the message. An optional `fallbackAgentId`
|
|
73
|
+
* covers pathological inputs where `accountId` is empty (should never
|
|
74
|
+
* happen from the gateway, but defensive).
|
|
75
|
+
*/
|
|
76
|
+
export function createActivityRecorder(opts: {
|
|
77
|
+
activityTracker: ActivityRecorderTarget;
|
|
78
|
+
fallbackAgentId?: string;
|
|
79
|
+
}): (msg: GatewayInboundMessage) => void {
|
|
80
|
+
return (msg: GatewayInboundMessage): void => {
|
|
81
|
+
const { kind, label } = classifyActivitySender(msg);
|
|
82
|
+
const rawText = typeof msg.text === "string" ? msg.text : "";
|
|
83
|
+
// Owner text passes through verbatim; everything else gets the same
|
|
84
|
+
// sanitization the legacy dispatcher applied before recording a preview.
|
|
85
|
+
const preview = kind === "owner" ? rawText : sanitizeUntrustedContent(rawText);
|
|
86
|
+
const agentId = msg.accountId || opts.fallbackAgentId || "";
|
|
87
|
+
opts.activityTracker.record({
|
|
88
|
+
agentId,
|
|
89
|
+
roomId: msg.conversation.id,
|
|
90
|
+
roomName: msg.conversation.title,
|
|
91
|
+
topic: msg.conversation.threadId ?? null,
|
|
92
|
+
lastInboundPreview: preview,
|
|
93
|
+
lastSenderKind: kind,
|
|
94
|
+
lastSender: label,
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Minimal send-capable surface used by {@link pushRuntimeSnapshot}.
|
|
101
|
+
* Exists so the helper is trivially mockable from unit tests without needing
|
|
102
|
+
* a full `ControlChannel` + user-auth harness.
|
|
103
|
+
*/
|
|
104
|
+
export interface RuntimeSnapshotSink {
|
|
105
|
+
send: (frame: {
|
|
106
|
+
id: string;
|
|
107
|
+
type: string;
|
|
108
|
+
params?: Record<string, unknown>;
|
|
109
|
+
ts?: number;
|
|
110
|
+
}) => boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
|
|
115
|
+
* P0: first-connect push only — reconnect-push and diffing are P1. A send
|
|
116
|
+
* failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
|
|
117
|
+
* or wait for the next daemon restart). Exported for unit tests.
|
|
118
|
+
*/
|
|
119
|
+
export function pushRuntimeSnapshot(sink: RuntimeSnapshotSink): boolean {
|
|
120
|
+
const snap = collectRuntimeSnapshot();
|
|
121
|
+
const ok = sink.send({
|
|
122
|
+
id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
123
|
+
type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
|
|
124
|
+
params: snap as unknown as Record<string, unknown>,
|
|
125
|
+
ts: Date.now(),
|
|
126
|
+
});
|
|
127
|
+
if (!ok) {
|
|
128
|
+
daemonLog.warn("runtime-snapshot: control-channel send returned false", {
|
|
129
|
+
runtimes: snap.runtimes.length,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return ok;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
|
|
136
|
+
export interface DaemonRuntimeOptions {
|
|
137
|
+
config: DaemonConfig;
|
|
138
|
+
/** Informational only; surfaced in startup logs. */
|
|
139
|
+
configPath: string;
|
|
140
|
+
/** Override the JSON session store location; defaults to `~/.botcord/daemon/sessions.json`. */
|
|
141
|
+
sessionStorePath?: string;
|
|
142
|
+
/** Override the snapshot file path; defaults to `~/.botcord/daemon/snapshot.json`. */
|
|
143
|
+
snapshotPath?: string;
|
|
144
|
+
/** Override snapshot write cadence in ms; defaults to 5s or `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`. */
|
|
145
|
+
snapshotIntervalMs?: number;
|
|
146
|
+
log?: GatewayLogger;
|
|
147
|
+
/** Override Hub base URL; defaults to the one stored in credentials. */
|
|
148
|
+
hubBaseUrl?: string;
|
|
149
|
+
/** Override credentials JSON path; defaults to `~/.botcord/credentials/<agentId>.json`. */
|
|
150
|
+
credentialsPath?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Inject a pre-resolved boot-agent list (e.g. from tests). When omitted,
|
|
153
|
+
* `startDaemon` resolves boot agents from `config.agents`/`config.agentId`
|
|
154
|
+
* or falls back to credential discovery.
|
|
155
|
+
*/
|
|
156
|
+
bootAgents?: BootAgentsResult;
|
|
157
|
+
/**
|
|
158
|
+
* Inject a pre-built user-auth manager. Typically only set by tests; in
|
|
159
|
+
* production the daemon calls `UserAuthManager.load()` internally, and
|
|
160
|
+
* only starts the control channel when a user-auth record exists.
|
|
161
|
+
*/
|
|
162
|
+
userAuth?: UserAuthManager | null;
|
|
163
|
+
/** Skip the control channel even when user-auth is available. Test hook. */
|
|
164
|
+
disableControlChannel?: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Handle returned by {@link startDaemon}. */
|
|
168
|
+
export interface DaemonHandle {
|
|
169
|
+
/** Graceful shutdown — idempotent. */
|
|
170
|
+
stop: (reason?: string) => Promise<void>;
|
|
171
|
+
/** Channel + turn status snapshot, straight from `Gateway.snapshot()`. */
|
|
172
|
+
snapshot: () => GatewayRuntimeSnapshot;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Adapt daemon's file-based `log` module into the gateway logger contract.
|
|
177
|
+
* Writes go to `~/.botcord/logs/daemon.log` + stderr, preserving the format
|
|
178
|
+
* existing `logs -f` watchers rely on. Debug lines stay gated by
|
|
179
|
+
* `BOTCORD_DAEMON_DEBUG`, mirroring pre-migration behavior.
|
|
180
|
+
*/
|
|
181
|
+
function buildDaemonLogger(): GatewayLogger {
|
|
182
|
+
return {
|
|
183
|
+
info: (msg, meta) => daemonLog.info(msg, meta),
|
|
184
|
+
warn: (msg, meta) => daemonLog.warn(msg, meta),
|
|
185
|
+
error: (msg, meta) => daemonLog.error(msg, meta),
|
|
186
|
+
debug: (msg, meta) => daemonLog.debug(msg, meta),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Boot the gateway using a daemon-shaped config. This is the P0.5 compat
|
|
192
|
+
* entry point: the on-disk config at `~/.botcord/daemon/config.json` keeps
|
|
193
|
+
* its existing shape, and `Gateway` handles channels/dispatch/sessions
|
|
194
|
+
* under the hood.
|
|
195
|
+
*
|
|
196
|
+
* Only the BotCord channel is supported today; `channels[]` in the
|
|
197
|
+
* translated gateway config has exactly one entry (`botcord-main`).
|
|
198
|
+
*/
|
|
199
|
+
export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHandle> {
|
|
200
|
+
const logger = opts.log ?? buildDaemonLogger();
|
|
201
|
+
|
|
202
|
+
// Resolve boot agents: explicit `agents` config wins; otherwise scan the
|
|
203
|
+
// credentials directory. A zero-agent result is valid in P1 — the daemon
|
|
204
|
+
// still starts with zero channels so operators can drop credentials in
|
|
205
|
+
// and restart without re-running `init`.
|
|
206
|
+
const boot = opts.bootAgents ?? resolveBootAgents(opts.config);
|
|
207
|
+
for (const w of boot.warnings) {
|
|
208
|
+
logger.warn("daemon.discovery.warning", { message: w });
|
|
209
|
+
}
|
|
210
|
+
const agentIds = boot.agents.map((a) => a.agentId);
|
|
211
|
+
const { credentialPathByAgentId, agentRuntimes } = backfillBootAgents(
|
|
212
|
+
boot.agents,
|
|
213
|
+
{ logger },
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
|
|
217
|
+
|
|
218
|
+
// ActivityTracker lives at the daemon layer (not the gateway core). We
|
|
219
|
+
// expose it to the gateway via (a) the `buildSystemContext` hook so the
|
|
220
|
+
// cross-room digest reflects current activity, and (b) the `onInbound`
|
|
221
|
+
// observer so incoming messages get recorded before the turn runs —
|
|
222
|
+
// mirroring the pre-P0.5 dispatcher's "record-before-adapter-run" ordering.
|
|
223
|
+
const activityTracker = new ActivityTracker();
|
|
224
|
+
|
|
225
|
+
// Shared room-context fetcher — one BotCordClient per accountId, created
|
|
226
|
+
// lazily and reused across turns so JWT refreshes amortize. The builder
|
|
227
|
+
// wrapping it adds a TTL cache on top so group rooms don't hit Hub every
|
|
228
|
+
// turn.
|
|
229
|
+
const roomContextFetcher = createRoomContextFetcher({
|
|
230
|
+
credentialPathByAgentId,
|
|
231
|
+
...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
|
|
232
|
+
...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
|
|
233
|
+
log: logger,
|
|
234
|
+
});
|
|
235
|
+
const roomContextBuilder = createRoomStaticContextBuilder({
|
|
236
|
+
fetchRoomInfo: roomContextFetcher,
|
|
237
|
+
log: logger,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Cache one system-context builder per configured agentId. The gateway
|
|
241
|
+
// calls this with each inbound message and we pick the right builder by
|
|
242
|
+
// `message.accountId` — so per-agent working memory + activity digests
|
|
243
|
+
// stay scoped when a single daemon hosts multiple agents.
|
|
244
|
+
type PerAgentBuilder = (
|
|
245
|
+
msg: GatewayInboundMessage,
|
|
246
|
+
) => Promise<string | undefined> | string | undefined;
|
|
247
|
+
const scBuilders = new Map<string, PerAgentBuilder>();
|
|
248
|
+
for (const aid of agentIds) {
|
|
249
|
+
scBuilders.set(
|
|
250
|
+
aid,
|
|
251
|
+
createDaemonSystemContextBuilder({
|
|
252
|
+
agentId: aid,
|
|
253
|
+
activityTracker,
|
|
254
|
+
roomContextBuilder,
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const buildSystemContext = (
|
|
259
|
+
message: GatewayInboundMessage,
|
|
260
|
+
): Promise<string | undefined> | string | undefined => {
|
|
261
|
+
const b = scBuilders.get(message.accountId);
|
|
262
|
+
if (b) return b(message);
|
|
263
|
+
// Unknown accountId (shouldn't happen in practice): fall back to the
|
|
264
|
+
// first configured agent so we still emit *something* rather than
|
|
265
|
+
// silently dropping the context block. When no agents are bound the
|
|
266
|
+
// daemon has no context to surface — return undefined.
|
|
267
|
+
const first = agentIds[0];
|
|
268
|
+
if (!first) return undefined;
|
|
269
|
+
const fallback = scBuilders.get(first);
|
|
270
|
+
return fallback ? fallback(message) : undefined;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Observer runs after ack + before runtime.run. Keeping the side effect
|
|
274
|
+
// outside the system-context builder (option A) means the builder stays
|
|
275
|
+
// pure — a cleaner contract the gateway can also expose to non-daemon
|
|
276
|
+
// callers in the future.
|
|
277
|
+
const onInbound = createActivityRecorder({
|
|
278
|
+
activityTracker,
|
|
279
|
+
...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const gateway = new Gateway({
|
|
283
|
+
config: gwConfig,
|
|
284
|
+
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
285
|
+
createChannel: (chCfg: GatewayChannelConfig): ChannelAdapter => {
|
|
286
|
+
const agentId =
|
|
287
|
+
typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
|
|
288
|
+
return createBotCordChannel({
|
|
289
|
+
id: chCfg.id,
|
|
290
|
+
accountId: chCfg.accountId,
|
|
291
|
+
agentId,
|
|
292
|
+
credentialsPath:
|
|
293
|
+
credentialPathByAgentId.get(agentId) ?? opts.credentialsPath,
|
|
294
|
+
hubBaseUrl: opts.hubBaseUrl,
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
log: logger,
|
|
298
|
+
turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
|
|
299
|
+
buildSystemContext,
|
|
300
|
+
onInbound,
|
|
301
|
+
composeUserTurn: composeBotCordUserTurn,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
logger.info("daemon starting", {
|
|
305
|
+
agents: agentIds,
|
|
306
|
+
source: boot.source,
|
|
307
|
+
credentialsDir: boot.credentialsDir,
|
|
308
|
+
configPath: opts.configPath,
|
|
309
|
+
sessionsPath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
310
|
+
channels: gwConfig.channels.map((c) => c.id),
|
|
311
|
+
routeCount: gwConfig.routes?.length ?? 0,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
if (agentIds.length === 0) {
|
|
315
|
+
logger.warn("daemon starting with no channels", {
|
|
316
|
+
source: boot.source,
|
|
317
|
+
credentialsDir: boot.credentialsDir,
|
|
318
|
+
hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await gateway.start();
|
|
323
|
+
logger.info("daemon started", { agents: agentIds });
|
|
324
|
+
|
|
325
|
+
// Control channel is optional — daemon still runs (data-plane only)
|
|
326
|
+
// when user-auth hasn't been set up yet. Operators can `login` later
|
|
327
|
+
// without restarting, but for P0 we require a restart to pick it up.
|
|
328
|
+
let controlChannel: ControlChannel | null = null;
|
|
329
|
+
const userAuth =
|
|
330
|
+
opts.userAuth === undefined
|
|
331
|
+
? tryLoadUserAuth(logger)
|
|
332
|
+
: opts.userAuth;
|
|
333
|
+
if (userAuth?.current && !opts.disableControlChannel) {
|
|
334
|
+
logger.info("control-channel: enabling", {
|
|
335
|
+
userId: userAuth.current.userId,
|
|
336
|
+
hubUrl: userAuth.current.hubUrl,
|
|
337
|
+
});
|
|
338
|
+
const provisioner = createProvisioner({ gateway });
|
|
339
|
+
controlChannel = new ControlChannel({
|
|
340
|
+
auth: userAuth,
|
|
341
|
+
handle: provisioner,
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
await controlChannel.start();
|
|
345
|
+
// Plan §8.5 P0 — push one runtime snapshot immediately after connect
|
|
346
|
+
// so Hub's `daemon_instances.runtimes_json` is populated for the
|
|
347
|
+
// dashboard even before any user action. No periodic refresh in P0.
|
|
348
|
+
const pushed = pushRuntimeSnapshot(controlChannel);
|
|
349
|
+
logger.info("control-channel: initial runtime_snapshot push", {
|
|
350
|
+
ok: pushed,
|
|
351
|
+
});
|
|
352
|
+
} catch (err) {
|
|
353
|
+
logger.warn("control-channel failed to start; continuing without it", {
|
|
354
|
+
error: err instanceof Error ? err.message : String(err),
|
|
355
|
+
});
|
|
356
|
+
// start() schedules its own reconnect; we swallow the initial
|
|
357
|
+
// failure so the daemon boots either way.
|
|
358
|
+
}
|
|
359
|
+
} else if (!userAuth?.current) {
|
|
360
|
+
logger.info("control-channel skipped: no user-auth record", {
|
|
361
|
+
hint: "run `botcord-daemon start` to enable Hub control plane (device-code login)",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const snapshotWriter = new SnapshotWriter({
|
|
366
|
+
path: opts.snapshotPath ?? SNAPSHOT_PATH,
|
|
367
|
+
intervalMs: opts.snapshotIntervalMs ?? resolveSnapshotIntervalMs(),
|
|
368
|
+
snapshot: () => gateway.snapshot(),
|
|
369
|
+
log: logger,
|
|
370
|
+
});
|
|
371
|
+
snapshotWriter.start();
|
|
372
|
+
|
|
373
|
+
let stopping: Promise<void> | null = null;
|
|
374
|
+
const stop = (reason?: string): Promise<void> => {
|
|
375
|
+
if (stopping) return stopping;
|
|
376
|
+
logger.info("daemon stopping", { reason: reason ?? null });
|
|
377
|
+
snapshotWriter.stop();
|
|
378
|
+
// Write one final snapshot so `status` doesn't briefly see stale data,
|
|
379
|
+
// then delete the file on the way out.
|
|
380
|
+
snapshotWriter.writeFinal();
|
|
381
|
+
const controlStopP = controlChannel
|
|
382
|
+
? controlChannel.stop().catch(() => undefined)
|
|
383
|
+
: Promise.resolve();
|
|
384
|
+
stopping = Promise.all([controlStopP, gateway.stop(reason)]).then(
|
|
385
|
+
() => undefined,
|
|
386
|
+
).finally(() => {
|
|
387
|
+
snapshotWriter.remove();
|
|
388
|
+
logger.info("daemon stopped", { reason: reason ?? null });
|
|
389
|
+
});
|
|
390
|
+
return stopping;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
stop,
|
|
395
|
+
snapshot: () => gateway.snapshot(),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Result of {@link backfillBootAgents}: the maps the boot flow needs to
|
|
401
|
+
* plumb into `toGatewayConfig` + the channel factory.
|
|
402
|
+
*/
|
|
403
|
+
export interface BootBackfillResult {
|
|
404
|
+
credentialPathByAgentId: Map<string, string>;
|
|
405
|
+
agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Walk the boot-agent list and (a) populate the credential-path + runtime
|
|
410
|
+
* caches used downstream, and (b) idempotently create each agent's on-disk
|
|
411
|
+
* workspace tree (plan §9). One agent's failing workspace must not block
|
|
412
|
+
* the others — errors are warned and swallowed per agent. Exported for
|
|
413
|
+
* unit tests; `startDaemon` calls this inline.
|
|
414
|
+
*/
|
|
415
|
+
export function backfillBootAgents(
|
|
416
|
+
agents: BootAgentsResult["agents"],
|
|
417
|
+
opts: {
|
|
418
|
+
logger: GatewayLogger;
|
|
419
|
+
ensure?: typeof ensureAgentWorkspace;
|
|
420
|
+
},
|
|
421
|
+
): BootBackfillResult {
|
|
422
|
+
const ensure = opts.ensure ?? ensureAgentWorkspace;
|
|
423
|
+
const credentialPathByAgentId = new Map<string, string>();
|
|
424
|
+
const agentRuntimes: Record<string, { runtime?: string; cwd?: string }> = {};
|
|
425
|
+
const failed: string[] = [];
|
|
426
|
+
for (const a of agents) {
|
|
427
|
+
if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
|
|
428
|
+
if (a.runtime || a.cwd) {
|
|
429
|
+
agentRuntimes[a.agentId] = {
|
|
430
|
+
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
431
|
+
...(a.cwd ? { cwd: a.cwd } : {}),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
// Seed files are written only when missing (see `ensureAgentWorkspace`),
|
|
435
|
+
// so a legacy agent whose workspace dir doesn't exist yet gets one on
|
|
436
|
+
// the next boot — with zero risk of overwriting the user's edits.
|
|
437
|
+
try {
|
|
438
|
+
ensure(a.agentId, {
|
|
439
|
+
...(a.displayName ? { displayName: a.displayName } : {}),
|
|
440
|
+
...(a.runtime ? { runtime: a.runtime } : {}),
|
|
441
|
+
...(a.keyId ? { keyId: a.keyId } : {}),
|
|
442
|
+
...(a.savedAt ? { savedAt: a.savedAt } : {}),
|
|
443
|
+
// `bio` is not surfaced on BootAgent — identity.md renders a
|
|
444
|
+
// placeholder the user can fill in.
|
|
445
|
+
});
|
|
446
|
+
} catch (err) {
|
|
447
|
+
failed.push(a.agentId);
|
|
448
|
+
opts.logger.warn("ensureAgentWorkspace failed at boot; continuing", {
|
|
449
|
+
agentId: a.agentId,
|
|
450
|
+
error: err instanceof Error ? err.message : String(err),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (failed.length > 0) {
|
|
455
|
+
opts.logger.warn("ensureAgentWorkspace: boot backfill incomplete", {
|
|
456
|
+
count: failed.length,
|
|
457
|
+
agentIds: failed,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return { credentialPathByAgentId, agentRuntimes };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Load the user-auth record if present, swallowing "file missing" as the
|
|
465
|
+
* expected not-logged-in state. A parse / permission error is logged and
|
|
466
|
+
* treated as "no record" so a broken user-auth.json can't block the
|
|
467
|
+
* data-plane from coming up.
|
|
468
|
+
*/
|
|
469
|
+
function tryLoadUserAuth(logger: GatewayLogger): UserAuthManager | null {
|
|
470
|
+
try {
|
|
471
|
+
return UserAuthManager.load();
|
|
472
|
+
} catch (err) {
|
|
473
|
+
logger.warn("failed to load user-auth", {
|
|
474
|
+
error: err instanceof Error ? err.message : String(err),
|
|
475
|
+
});
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|