@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21

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.
@@ -0,0 +1,346 @@
1
+ // RFC-026 v4 hub-side runtime — pendingEnvBlobs Map (F1 mint-stream-
2
+ // evict) + sweeper (C4 orphan ntok revoke) + content-match finalize
3
+ // helper (called from report_status when a child first registers).
4
+ //
5
+ // The actual MCP tools (create_node / get_create_request /
6
+ // ack_create_request) are registered in tools.ts and call into this
7
+ // module. Keeping the heavy state + sweeper here makes the tool layer
8
+ // thin + this module independently testable.
9
+
10
+ import { db, uuidv4 } from "./db.js";
11
+
12
+ // ── §4.4 F1 — pendingEnvBlobs Map ─────────────────────────────────
13
+ // In-memory ONLY. Never touches disk / never enters the SQL store.
14
+ // Daemon `get_create_request(request_id)` consumes + evicts. Untaken
15
+ // blobs are GC'd at TTL expiry.
16
+
17
+ export interface PendingEnvBlob {
18
+ request_id: string;
19
+ daemon_node_id: string; // hub uses this for C2 cross-daemon check
20
+ env_blob: Record<string, string>; // KEY → secret value (plaintext, never logged)
21
+ child_token: string; // freshly minted ntok_ for the child
22
+ child_token_id: string; // tokens.token_id for sweeper revoke
23
+ expires_at: number; // ms epoch
24
+ }
25
+
26
+ const TTL_MS = 60_000; // 60s per RFC-026 v4 §4.4
27
+ const SWEEPER_INTERVAL_MS = 30_000; // 30s per RFC-026 v4 §4.4.8
28
+ const REAPER_TTL_MS = 60_000; // 60s timeout for pending/delivered rows
29
+
30
+ const pendingEnvBlobs = new Map<string, PendingEnvBlob>();
31
+ let gcTimer: ReturnType<typeof setInterval> | null = null;
32
+ let sweeperTimer: ReturnType<typeof setInterval> | null = null;
33
+
34
+ export function putPendingEnvBlob(b: Omit<PendingEnvBlob, "expires_at">): void {
35
+ pendingEnvBlobs.set(b.request_id, { ...b, expires_at: Date.now() + TTL_MS });
36
+ }
37
+
38
+ /** Get the pending env_blob for daemon. Returns null if not found OR
39
+ * if caller's daemon_node_id doesn't match (C2 cross-daemon guard).
40
+ * Atomically deletes the entry from the Map (one-shot consume). */
41
+ export function takePendingEnvBlob(
42
+ request_id: string,
43
+ callerDaemonNodeId: string,
44
+ ): PendingEnvBlob | null {
45
+ const b = pendingEnvBlobs.get(request_id);
46
+ if (!b) return null;
47
+ if (b.daemon_node_id !== callerDaemonNodeId) return null;
48
+ if (Date.now() > b.expires_at) {
49
+ pendingEnvBlobs.delete(request_id);
50
+ return null;
51
+ }
52
+ pendingEnvBlobs.delete(request_id); // §4.4 evict immediately
53
+ return b;
54
+ }
55
+
56
+ /** Test/diagnostic helper — does NOT consume. */
57
+ export function peekPendingEnvBlob(request_id: string): PendingEnvBlob | null {
58
+ return pendingEnvBlobs.get(request_id) || null;
59
+ }
60
+
61
+ export function evictExpired(now = Date.now()): number {
62
+ let evicted = 0;
63
+ for (const [k, v] of pendingEnvBlobs) {
64
+ if (now > v.expires_at) {
65
+ pendingEnvBlobs.delete(k);
66
+ evicted++;
67
+ }
68
+ }
69
+ return evicted;
70
+ }
71
+
72
+ export function startPendingEnvGcTimer(): void {
73
+ if (gcTimer) return;
74
+ gcTimer = setInterval(() => {
75
+ const n = evictExpired();
76
+ if (n > 0) console.log(`[create-node] GC: evicted ${n} expired env_blob(s)`);
77
+ }, 5_000);
78
+ (gcTimer as any).unref?.();
79
+ }
80
+
81
+ // ── §4.4.8 C4 — orphan ntok revoke sweeper ────────────────────────
82
+ // Scans `node_create_requests` for non-terminal rows older than
83
+ // REAPER_TTL_MS. Atomically marks request as failed/expired AND
84
+ // revokes the matching api_tokens.row (via request_id link) so the
85
+ // orphan child-ntok cannot be used by anyone who scraped it.
86
+
87
+ export function runOrphanSweepOnce(now = Date.now()): { swept: number; revoked: number } {
88
+ const cutoffAge = REAPER_TTL_MS;
89
+ const cutoffMs = now - cutoffAge;
90
+ // Two cohorts (per RFC §4.4.8 case table):
91
+ // F-1 status='pending' age > TTL → hub crashed before daemon get
92
+ // F-2 status='delivered' age > TTL → daemon got blob but never ack'd
93
+ const stale = db.all<{ request_id: string; status: string; child_token_id: string | null }>(
94
+ `SELECT request_id, status, child_token_id
95
+ FROM node_create_requests
96
+ WHERE status IN ('pending', 'delivered')
97
+ AND created_at < ?1`,
98
+ cutoffMs,
99
+ );
100
+ if (stale.length === 0) return { swept: 0, revoked: 0 };
101
+
102
+ let swept = 0; let revoked = 0;
103
+ // SQLite Bun driver doesn't expose BEGIN/COMMIT cleanly across the
104
+ // wrapper here; fold per-row into a transaction.
105
+ try {
106
+ db.exec("BEGIN");
107
+ for (const row of stale) {
108
+ // Revoke the child-ntok first (sweeper is server-side ground
109
+ // truth per §4.4.8). If child_token_id null somehow, skip
110
+ // revoke (request never made it that far) but still mark
111
+ // failed.
112
+ if (row.child_token_id) {
113
+ // db.run returns the SQLite adapter's RunResult (changes counter
114
+ // if driver-supported). Only count a revoke when the UPDATE
115
+ // actually touched a row — under the bun:sqlite adapter the
116
+ // changes prop is reliable; on adapters returning undefined we
117
+ // fall back to a post-UPDATE SELECT to confirm revoked_at IS
118
+ // NOT NULL (DB is ground truth). Avoids over-counting when
119
+ // child_token_id is stale or already revoked. (通信龙 nit 2)
120
+ const r = db.run(
121
+ `UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`,
122
+ [row.child_token_id],
123
+ );
124
+ const changes = (r as any)?.changes;
125
+ if (typeof changes === "number") {
126
+ if (changes > 0) revoked++;
127
+ } else {
128
+ // Adapter didn't report; verify via SELECT
129
+ const after = db.get<{ revoked_at: string | null }>(
130
+ `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`, row.child_token_id,
131
+ );
132
+ if (after?.revoked_at) revoked++;
133
+ }
134
+ }
135
+ const newStatus = row.status === "pending" ? "failed" : "expired";
136
+ const err = row.status === "pending" ? "sweeper_revoked_before_delivery" : "sweeper_revoked_after_delivery_no_ack";
137
+ db.run(
138
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
139
+ [newStatus, err, now, row.request_id],
140
+ );
141
+ // Also drop any lingering env_blob (defense; should already be
142
+ // gone for delivered, but pending could leak if GC hasn't run).
143
+ pendingEnvBlobs.delete(row.request_id);
144
+ swept++;
145
+ }
146
+ db.exec("COMMIT");
147
+ } catch (e: any) {
148
+ try { db.exec("ROLLBACK"); } catch { /* swallow */ }
149
+ console.warn(`[create-node] sweeper transaction failed: ${e?.message || e}`);
150
+ return { swept: 0, revoked: 0 };
151
+ }
152
+ if (swept > 0) {
153
+ console.log(`[commhub] ✓ create-node sweeper: swept ${swept} stale request(s), revoked ${revoked} child-ntok(s)`);
154
+ auditCreateNode({
155
+ action: "create_node_sweeper_revoked",
156
+ detail: { swept, revoked, sweeper_run_at_ms: now },
157
+ });
158
+ }
159
+ return { swept, revoked };
160
+ }
161
+
162
+ export function startSweeperTimer(): void {
163
+ if (sweeperTimer) return;
164
+ // Boot-time sweep: catch any rows left over from a prior process
165
+ // crash (case F-1: hub crashed after mint, before daemon get).
166
+ try { runOrphanSweepOnce(); } catch (e: any) {
167
+ console.warn(`[create-node] boot sweeper failed: ${e?.message || e}`);
168
+ }
169
+ sweeperTimer = setInterval(() => {
170
+ try { runOrphanSweepOnce(); }
171
+ catch (e: any) { console.warn(`[create-node] periodic sweeper failed: ${e?.message || e}`); }
172
+ }, SWEEPER_INTERVAL_MS);
173
+ (sweeperTimer as any).unref?.();
174
+ }
175
+
176
+ export function stopBackgroundTimersForTest(): void {
177
+ if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
178
+ if (sweeperTimer) { clearInterval(sweeperTimer); sweeperTimer = null; }
179
+ pendingEnvBlobs.clear();
180
+ }
181
+
182
+ // ── content-match finalize on child first register ────────────────
183
+ // Called from tools.ts report_status handler when a node registers (or
184
+ // re-registers). If there's a pending node_create_requests row whose
185
+ // `child_name` matches the incoming alias AND the daemon_node_id is
186
+ // in caller's network, mark it succeeded and stamp the real
187
+ // child_node_id. Mirrors RFC-024 finalizePendingMatchingUpdates.
188
+
189
+ export function finalizeCreateOnFirstRegister(
190
+ incoming: { node_id: string; alias: string; network_id: string | null },
191
+ ): { finalized: number; matchedIds: string[] } {
192
+ if (!incoming.node_id || !incoming.alias) return { finalized: 0, matchedIds: [] };
193
+ const net = incoming.network_id ?? "default";
194
+ const pending = db.all<{ request_id: string; daemon_node_id: string }>(
195
+ `SELECT request_id, daemon_node_id FROM node_create_requests
196
+ WHERE child_name = ?1 AND network_id = ?2 AND status IN ('delivered', 'pending')
197
+ ORDER BY created_at ASC`,
198
+ incoming.alias, net,
199
+ );
200
+ if (pending.length === 0) return { finalized: 0, matchedIds: [] };
201
+
202
+ const matchedIds: string[] = [];
203
+ for (const row of pending) {
204
+ const r = db.run(
205
+ `UPDATE node_create_requests
206
+ SET status = 'succeeded', child_node_id = ?1, acked_at = ?2
207
+ WHERE request_id = ?3 AND status IN ('delivered', 'pending')`,
208
+ [incoming.node_id, Date.now(), row.request_id],
209
+ );
210
+ matchedIds.push(row.request_id);
211
+ console.log(`[commhub] ✓ create-node finalize: request=${row.request_id} child=${incoming.alias} (${incoming.node_id}) daemon=${row.daemon_node_id}`);
212
+ auditCreateNode({
213
+ action: "create_node_succeeded",
214
+ network_id: net,
215
+ target_id: row.request_id,
216
+ detail: { child_node_id: incoming.node_id, child_alias: incoming.alias, daemon_node_id: row.daemon_node_id },
217
+ });
218
+ }
219
+ return { finalized: matchedIds.length, matchedIds };
220
+ }
221
+
222
+ export function newRequestId(): string {
223
+ return `cr_${uuidv4()}`;
224
+ }
225
+
226
+ // §4.1.4 C2 — resolve the caller daemon's node row by **token-bound
227
+ // identity** (PR #299 BLOCKER #1). Module-level so unit tests can
228
+ // import + call directly (per 通信龙 nit 1 — don't inline-mirror SQL
229
+ // in tests; that's exactly the helper-drift anti-pattern the tools.ts
230
+ // comment warns against).
231
+ export type DaemonResolveResult =
232
+ | { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
233
+ | { ok: false; error: string };
234
+
235
+ export function resolveCallerDaemonTokenBound(opts: {
236
+ callerTokenIsNetwork: boolean;
237
+ callerTokenId: string | null | undefined;
238
+ enforceNetworkId: string | null | undefined;
239
+ }): DaemonResolveResult {
240
+ if (!opts.callerTokenIsNetwork || !opts.callerTokenId) {
241
+ return { ok: false, error: "caller_not_a_daemon" };
242
+ }
243
+ if (!opts.enforceNetworkId) {
244
+ return { ok: false, error: "caller_not_a_daemon" };
245
+ }
246
+ const tokRow = db.get<{ name: string; network_id: string | null }>(
247
+ `SELECT name, network_id FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
248
+ opts.callerTokenId,
249
+ );
250
+ if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
251
+ return { ok: false, error: "caller_not_a_daemon" };
252
+ }
253
+ if (tokRow.network_id !== opts.enforceNetworkId) {
254
+ return { ok: false, error: "caller_not_a_daemon" };
255
+ }
256
+ const tokenAlias = tokRow.name.slice(5);
257
+ const nodeRow = db.get<{ node_id: string; alias: string; network_id: string }>(
258
+ `SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1`,
259
+ tokenAlias, tokRow.network_id,
260
+ );
261
+ if (!nodeRow) {
262
+ return { ok: false, error: "caller_not_a_daemon" };
263
+ }
264
+ return {
265
+ ok: true,
266
+ daemonNodeId: nodeRow.node_id,
267
+ daemonAlias: nodeRow.alias,
268
+ networkId: nodeRow.network_id,
269
+ };
270
+ }
271
+
272
+ // RFC-026 §4.5 — append a row to audit_log for every create_node
273
+ // lifecycle event. Best-effort: never throw out (calling tool should
274
+ // continue even if audit insert fails for any reason).
275
+ /** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode
276
+ * but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE +
277
+ * audit INSERT can ROLLBACK if the audit row would have been lost.
278
+ * Required for the "no audit-but state changed" window that §4.5 / D8
279
+ * explicitly closes (PR1 SF-2 review catch — auditCreateNode's
280
+ * swallow-and-warn defeats atomicity).
281
+ *
282
+ * Callers in a non-transaction context should keep using auditCreateNode
283
+ * (best-effort, never throws). */
284
+ export function auditCreateNodeStrict(input: {
285
+ action: Parameters<typeof auditCreateNode>[0]["action"];
286
+ user_id?: string | null;
287
+ username?: string | null;
288
+ network_id?: string | null;
289
+ target_id?: string | null;
290
+ detail: Record<string, unknown>;
291
+ }): void {
292
+ db.run(
293
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
294
+ VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
295
+ [
296
+ input.user_id || null,
297
+ input.username || null,
298
+ input.action,
299
+ input.target_id || null,
300
+ JSON.stringify(input.detail),
301
+ input.network_id || null,
302
+ ],
303
+ );
304
+ }
305
+
306
+ export function auditCreateNode(input: {
307
+ action:
308
+ | "create_node_dispatched"
309
+ | "create_node_rejected"
310
+ | "create_node_succeeded"
311
+ | "create_node_sweeper_revoked"
312
+ | "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
313
+ // RFC-027 §4.5 — stop/delete lifecycle audit action enum extension.
314
+ // Re-using auditCreateNode is fine: the action column carries the
315
+ // discriminator; target_type stays 'node_create_request' which is a
316
+ // misnomer for stop/delete rows but updating the schema would force a
317
+ // wider migration (see P1.1 issue).
318
+ | "stop_node_dispatched"
319
+ | "stop_node_completed"
320
+ | "delete_node_dispatched"
321
+ | "delete_node_completed"
322
+ | "forced_stop_with_in_flight"
323
+ | "backup_purged";
324
+ user_id?: string | null;
325
+ username?: string | null;
326
+ network_id?: string | null;
327
+ target_id?: string | null; // request_id
328
+ detail: Record<string, unknown>;
329
+ }): void {
330
+ try {
331
+ db.run(
332
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
333
+ VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
334
+ [
335
+ input.user_id || null,
336
+ input.username || null,
337
+ input.action,
338
+ input.target_id || null,
339
+ JSON.stringify(input.detail),
340
+ input.network_id || null,
341
+ ],
342
+ );
343
+ } catch (e: any) {
344
+ console.warn(`[commhub] audit_log insert (create_node) failed: ${e?.message || e}`);
345
+ }
346
+ }
package/src/db.ts CHANGED
@@ -301,6 +301,72 @@ try {
301
301
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
302
302
  }
303
303
 
304
+ // RFC-026 §9 / #338 PR2 — daemon self-declare fields promoted from
305
+ // config_snapshot to first-class indexable columns. The hub
306
+ // `list_host_supervisors` MCP tool reads these directly without parsing
307
+ // the snapshot JSON on every list call.
308
+ //
309
+ // Both fields are JSON arrays of strings:
310
+ // runtimes_supported e.g. ["claude-agent-sdk","codex-sdk","grok-build-acp"]
311
+ // allowed_secret_keys e.g. ["ANTHROPIC_API_KEY","OPENAI_API_KEY"]
312
+ //
313
+ // Stored as TEXT (JSON-encoded) — SQLite has no native array type and
314
+ // the wrapper code is small. Both nullable for pre-PR2 daemons; the
315
+ // list endpoint falls back to [] when null.
316
+ try {
317
+ db.exec(`ALTER TABLE nodes ADD COLUMN runtimes_supported TEXT`);
318
+ } catch (e: any) {
319
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
320
+ }
321
+ try {
322
+ db.exec(`ALTER TABLE nodes ADD COLUMN allowed_secret_keys TEXT`);
323
+ } catch (e: any) {
324
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
325
+ }
326
+
327
+ // RFC-027 §2.3 — state machine for stop/delete (D5). 'active' default
328
+ // matches pre-RFC behavior for every existing row. Transitions:
329
+ // active → stopping → stopped → (back to active via restart_node)
330
+ // active → deleting → (row gone)
331
+ // stopped → deleting → (row gone)
332
+ // Used as the inbox-enqueue gate per §2.3 race-free invariant:
333
+ // pushEvent / INSERT INTO inbox must refuse a non-active target so the
334
+ // SIGTERM-in-flight window never gets a new task.
335
+ try {
336
+ db.exec(`ALTER TABLE nodes ADD COLUMN lifecycle_state TEXT DEFAULT 'active'`);
337
+ } catch (e: any) {
338
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
339
+ }
340
+
341
+ // RFC-027 §2 — stop/delete request envelope. Mirrors
342
+ // node_create_requests structure so the daemon-side pull/ack pattern
343
+ // is symmetric with create_node. action='stop'|'delete' picks which
344
+ // branch (delete adds backup_path + revokes ntok on ack).
345
+ db.exec(`
346
+ CREATE TABLE IF NOT EXISTS node_stop_requests (
347
+ request_id TEXT PRIMARY KEY,
348
+ network_id TEXT NOT NULL,
349
+ daemon_node_id TEXT NOT NULL,
350
+ child_node_id TEXT NOT NULL,
351
+ child_alias TEXT NOT NULL,
352
+ action TEXT NOT NULL CHECK (action IN ('stop', 'delete')),
353
+ delete_config INTEGER NOT NULL DEFAULT 1,
354
+ grace_seconds INTEGER NOT NULL DEFAULT 10,
355
+ force INTEGER NOT NULL DEFAULT 0,
356
+ in_flight_at_dispatch INTEGER NOT NULL DEFAULT 0,
357
+ created_by_token TEXT NOT NULL,
358
+ status TEXT NOT NULL DEFAULT 'pending',
359
+ error TEXT,
360
+ backup_path TEXT,
361
+ exit_signal TEXT,
362
+ created_at INTEGER NOT NULL,
363
+ delivered_at INTEGER,
364
+ acked_at INTEGER
365
+ )
366
+ `);
367
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`);
368
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`);
369
+
304
370
  // `node_config_updates` — pending + history. One row per dashboard write.
305
371
  // At most one row per node may be in a non-terminal state at a time
306
372
  // (single-flight enforced at the tool layer; this table just stores).
@@ -376,6 +442,136 @@ db.exec(`
376
442
  CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
377
443
  `);
378
444
 
445
+ // RFC-026 v4 §4.4.8 C4 — api_tokens metadata so sweeper can revoke
446
+ // orphan child-ntoks without ever touching plaintext tokens. ALTER in
447
+ // try/catch (idempotent for already-migrated DBs).
448
+ // request_id — links a child token to the create_node request that
449
+ // minted it; sweeper joins on this
450
+ // revoked_at — explicit revoke; resolveToken treats non-NULL as
451
+ // invalid even before expiry
452
+ for (const ddl of [
453
+ "ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
454
+ "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
455
+ "ALTER TABLE api_tokens ADD COLUMN role TEXT",
456
+ ]) {
457
+ try { db.exec(ddl); }
458
+ catch (e: any) {
459
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
460
+ }
461
+ }
462
+ try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
463
+ catch (e: any) { /* index may already exist */ void e; }
464
+
465
+ // RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
466
+ // env_blob NEVER lives in this table (F1 mint-stream-evict — secret
467
+ // blob is in-memory Map keyed by request_id, drained on daemon get,
468
+ // TTL 60s GC).
469
+ //
470
+ // env_keys is a JSON array of secret KEY NAMES only (for audit). The
471
+ // actual values flow through pendingEnvBlobs Map and never touch
472
+ // disk.
473
+ db.exec(`
474
+ CREATE TABLE IF NOT EXISTS node_create_requests (
475
+ request_id TEXT PRIMARY KEY,
476
+ daemon_node_id TEXT NOT NULL,
477
+ child_name TEXT NOT NULL,
478
+ network_id TEXT NOT NULL,
479
+ runtime TEXT NOT NULL,
480
+ model TEXT NOT NULL,
481
+ flags_json TEXT NOT NULL,
482
+ env_keys TEXT NOT NULL,
483
+ status TEXT NOT NULL,
484
+ error TEXT,
485
+ child_token_id TEXT,
486
+ created_at INTEGER NOT NULL,
487
+ created_by_token TEXT NOT NULL,
488
+ delivered_at INTEGER,
489
+ acked_at INTEGER,
490
+ child_node_id TEXT
491
+ );
492
+
493
+ CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
494
+ CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
495
+ CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
496
+ `);
497
+ try {
498
+ // Single-flight per (daemon, child_name) — prevents racing dashboard
499
+ // create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
500
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncr_inflight ON node_create_requests(daemon_node_id, child_name) WHERE status IN ('pending', 'delivered')`);
501
+ } catch (e: any) {
502
+ console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
503
+ }
504
+
505
+ // RFC-028 P1 v4 §2.2 — providers + provider_models + network_secrets +
506
+ // probe_results. All 4 tables idempotent CREATE TABLE IF NOT EXISTS. F2
507
+ // lazy gate: tables created on hub boot regardless; vault master key
508
+ // (ANET_HUB_SECRET_VAULT_KEY env) only required when these tables get
509
+ // their first non-empty row (lazy in vault.ts). Empty tables = hub
510
+ // boots fine without env (critical for existing prod hub升级 不 brick).
511
+ db.exec(`
512
+ CREATE TABLE IF NOT EXISTS providers (
513
+ provider_id TEXT PRIMARY KEY,
514
+ network_id TEXT NOT NULL,
515
+ name TEXT NOT NULL,
516
+ vendor TEXT NOT NULL,
517
+ base_url TEXT NOT NULL,
518
+ secret_key_ref TEXT NOT NULL,
519
+ created_at INTEGER NOT NULL,
520
+ created_by TEXT NOT NULL,
521
+ enabled INTEGER NOT NULL DEFAULT 1,
522
+ UNIQUE(network_id, name)
523
+ );
524
+ CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
525
+ `);
526
+ db.exec(`
527
+ CREATE TABLE IF NOT EXISTS provider_models (
528
+ model_id TEXT PRIMARY KEY,
529
+ provider_id TEXT NOT NULL,
530
+ model_name TEXT NOT NULL,
531
+ display_name TEXT,
532
+ context_window INTEGER,
533
+ supports_vision INTEGER NOT NULL DEFAULT 0,
534
+ enabled INTEGER NOT NULL DEFAULT 1,
535
+ created_at INTEGER NOT NULL,
536
+ UNIQUE(provider_id, model_name)
537
+ );
538
+ CREATE INDEX IF NOT EXISTS idx_models_provider ON provider_models(provider_id);
539
+ `);
540
+ // network_secrets — encrypted-at-rest vault. ciphertext + iv + tag are
541
+ // AES-GCM output; plaintext NEVER touches this table. master key from
542
+ // ANET_HUB_SECRET_VAULT_KEY env (lazy gate in vault.ts).
543
+ db.exec(`
544
+ CREATE TABLE IF NOT EXISTS network_secrets (
545
+ network_id TEXT NOT NULL,
546
+ key TEXT NOT NULL,
547
+ ciphertext BLOB NOT NULL,
548
+ iv BLOB NOT NULL,
549
+ tag BLOB NOT NULL,
550
+ created_at INTEGER NOT NULL,
551
+ updated_at INTEGER NOT NULL,
552
+ PRIMARY KEY (network_id, key)
553
+ );
554
+ `);
555
+ // probe_results — connectivity matrix history. v3 R3 lock: error_label
556
+ // is HUB-DERIVED ONLY from (status, raw_status_code) — daemon CANNOT
557
+ // submit arbitrary error string. status enum mirrors ProbeAckPayload.
558
+ db.exec(`
559
+ CREATE TABLE IF NOT EXISTS probe_results (
560
+ probe_id TEXT PRIMARY KEY,
561
+ provider_id TEXT NOT NULL,
562
+ model_name TEXT NOT NULL,
563
+ daemon_node_id TEXT NOT NULL,
564
+ network_id TEXT NOT NULL,
565
+ status TEXT NOT NULL,
566
+ latency_ms INTEGER,
567
+ error_label TEXT,
568
+ probed_at INTEGER NOT NULL,
569
+ probed_by_user TEXT,
570
+ raw_status_code INTEGER
571
+ );
572
+ CREATE INDEX IF NOT EXISTS idx_probe_matrix ON probe_results(network_id, provider_id, model_name, daemon_node_id, probed_at DESC);
573
+ `);
574
+
379
575
  // ── V3: audit_log table ──
380
576
  db.exec(`
381
577
  CREATE TABLE IF NOT EXISTS audit_log (