@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/config-apply-sec1.test.ts +163 -1
- package/src/tools.ts +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.2",
|
|
4
4
|
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
50
50
|
"bun-types": "^1.3.13",
|
|
51
|
-
"hono": "^4.12.25"
|
|
51
|
+
"hono": "^4.12.25",
|
|
52
|
+
"zod": "^4.4.3"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { describe, expect, test, beforeEach } from "bun:test";
|
|
18
18
|
import { db, uuidv4 } from "./db.js";
|
|
19
|
-
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
19
|
+
import { upsertNodeWithSec1Guard, finalizePendingMatchingUpdates } from "./tools.js";
|
|
20
20
|
|
|
21
21
|
function insertNode(opts: { node_id?: string; alias: string; network_id: string | null }): string {
|
|
22
22
|
const id = opts.node_id ?? uuidv4();
|
|
@@ -445,6 +445,168 @@ describe("SEC trust-root — REAL driver via upsertNodeWithSec1Guard (catches gu
|
|
|
445
445
|
});
|
|
446
446
|
});
|
|
447
447
|
|
|
448
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
449
|
+
// finalizePendingMatchingUpdates — Option A restart-finalize matcher
|
|
450
|
+
// (通信牛 #290 final CHANGE_REQ 2026-06-28). Real-driver tests: drive
|
|
451
|
+
// the helper that production report_status delegates to + assert on
|
|
452
|
+
// the persisted state. Same single-source-of-truth pattern as
|
|
453
|
+
// upsertNodeWithSec1Guard (prevents inline-mirror test drift).
|
|
454
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
455
|
+
describe("finalizePendingMatchingUpdates — Option A restart-finalize via content match", () => {
|
|
456
|
+
function insertPendingUpdate(opts: {
|
|
457
|
+
update_id?: string;
|
|
458
|
+
node_id: string;
|
|
459
|
+
network_id: string;
|
|
460
|
+
patch: any;
|
|
461
|
+
apply_mode: "hot" | "restart" | "restart_only";
|
|
462
|
+
status?: "pending" | "restarting";
|
|
463
|
+
}): string {
|
|
464
|
+
const id = opts.update_id ?? `cu_${uuidv4()}`;
|
|
465
|
+
db.run(
|
|
466
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, 'test')`,
|
|
467
|
+
[id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()],
|
|
468
|
+
);
|
|
469
|
+
return id;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
test("empty patch (restart_only) → finalizes on ANY snapshot (restart itself = goal)", () => {
|
|
473
|
+
const nid = insertNode({ alias: "n1", network_id: "netA" });
|
|
474
|
+
const uid = insertPendingUpdate({
|
|
475
|
+
node_id: nid, network_id: "netA", patch: {}, apply_mode: "restart_only", status: "restarting",
|
|
476
|
+
});
|
|
477
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "any", flags: {} });
|
|
478
|
+
expect(r.finalizedCount).toBe(1);
|
|
479
|
+
expect(r.finalizedIds).toContain(uid);
|
|
480
|
+
const row = db.get<any>("SELECT status, new_revision FROM node_config_updates WHERE update_id = ?1", uid);
|
|
481
|
+
expect(row.status).toBe("applied");
|
|
482
|
+
expect(row.new_revision).toBe(1);
|
|
483
|
+
const node = db.get<any>("SELECT config_revision FROM nodes WHERE node_id = ?1", nid);
|
|
484
|
+
expect(node.config_revision).toBe(1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("model patch + snapshot.model matches → finalized", () => {
|
|
488
|
+
const nid = insertNode({ alias: "n2", network_id: "netA" });
|
|
489
|
+
const uid = insertPendingUpdate({
|
|
490
|
+
node_id: nid, network_id: "netA",
|
|
491
|
+
patch: { model: "claude-opus-4-new", flags: {} },
|
|
492
|
+
apply_mode: "restart",
|
|
493
|
+
});
|
|
494
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
495
|
+
model: "claude-opus-4-new", flags: { maxTurns: 50 },
|
|
496
|
+
});
|
|
497
|
+
expect(r.finalizedCount).toBe(1);
|
|
498
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("model patch + snapshot.model DIFFERENT → NOT finalized (left pending)", () => {
|
|
502
|
+
const nid = insertNode({ alias: "n3", network_id: "netA" });
|
|
503
|
+
const uid = insertPendingUpdate({
|
|
504
|
+
node_id: nid, network_id: "netA",
|
|
505
|
+
patch: { model: "claude-opus-4-new", flags: {} },
|
|
506
|
+
apply_mode: "restart",
|
|
507
|
+
});
|
|
508
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
509
|
+
model: "claude-sonnet-3-old", flags: {},
|
|
510
|
+
});
|
|
511
|
+
expect(r.finalizedCount).toBe(0);
|
|
512
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("flags patch + every flag matches snapshot → finalized", () => {
|
|
516
|
+
const nid = insertNode({ alias: "n4", network_id: "netA" });
|
|
517
|
+
const uid = insertPendingUpdate({
|
|
518
|
+
node_id: nid, network_id: "netA",
|
|
519
|
+
patch: { flags: { maxTurns: 99, budget: 500 } },
|
|
520
|
+
apply_mode: "hot",
|
|
521
|
+
});
|
|
522
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
523
|
+
flags: { maxTurns: 99, budget: 500, dangerouslySkipPermissions: true },
|
|
524
|
+
});
|
|
525
|
+
expect(r.finalizedCount).toBe(1);
|
|
526
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("flags patch + ONE flag mismatch → NOT finalized (any field absent = no match)", () => {
|
|
530
|
+
const nid = insertNode({ alias: "n5", network_id: "netA" });
|
|
531
|
+
const uid = insertPendingUpdate({
|
|
532
|
+
node_id: nid, network_id: "netA",
|
|
533
|
+
patch: { flags: { maxTurns: 99, budget: 500 } },
|
|
534
|
+
apply_mode: "hot",
|
|
535
|
+
});
|
|
536
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
537
|
+
flags: { maxTurns: 99, budget: 999 }, // budget wrong
|
|
538
|
+
});
|
|
539
|
+
expect(r.finalizedCount).toBe(0);
|
|
540
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("flags patch + flag absent from snapshot → NOT finalized", () => {
|
|
544
|
+
const nid = insertNode({ alias: "n6", network_id: "netA" });
|
|
545
|
+
const uid = insertPendingUpdate({
|
|
546
|
+
node_id: nid, network_id: "netA",
|
|
547
|
+
patch: { flags: { permissionMode: "auto" } },
|
|
548
|
+
apply_mode: "restart",
|
|
549
|
+
});
|
|
550
|
+
const r = finalizePendingMatchingUpdates(nid, {
|
|
551
|
+
flags: { maxTurns: 50 }, // permissionMode missing entirely
|
|
552
|
+
});
|
|
553
|
+
expect(r.finalizedCount).toBe(0);
|
|
554
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("no pending update → no-op (idempotent on routine heartbeats)", () => {
|
|
558
|
+
const nid = insertNode({ alias: "n7", network_id: "netA" });
|
|
559
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: { maxTurns: 50 } });
|
|
560
|
+
expect(r.finalizedCount).toBe(0);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("restarting status finalizes too (not just pending)", () => {
|
|
564
|
+
const nid = insertNode({ alias: "n8", network_id: "netA" });
|
|
565
|
+
const uid = insertPendingUpdate({
|
|
566
|
+
node_id: nid, network_id: "netA",
|
|
567
|
+
patch: { model: "new" },
|
|
568
|
+
apply_mode: "restart",
|
|
569
|
+
status: "restarting",
|
|
570
|
+
});
|
|
571
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "new", flags: {} });
|
|
572
|
+
expect(r.finalizedCount).toBe(1);
|
|
573
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("two nodes with separate pending updates → finalize only matching one", () => {
|
|
577
|
+
const nidA = insertNode({ alias: "match-this", network_id: "netA" });
|
|
578
|
+
const nidB = insertNode({ alias: "leave-alone", network_id: "netA" });
|
|
579
|
+
const uidA = insertPendingUpdate({
|
|
580
|
+
node_id: nidA, network_id: "netA",
|
|
581
|
+
patch: { model: "matched-model" },
|
|
582
|
+
apply_mode: "restart",
|
|
583
|
+
});
|
|
584
|
+
const uidB = insertPendingUpdate({
|
|
585
|
+
node_id: nidB, network_id: "netA",
|
|
586
|
+
patch: { model: "different-model" },
|
|
587
|
+
apply_mode: "restart",
|
|
588
|
+
});
|
|
589
|
+
finalizePendingMatchingUpdates(nidA, { model: "matched-model", flags: {} });
|
|
590
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uidA).status).toBe("applied");
|
|
591
|
+
expect(db.get<any>("SELECT status FROM node_config_updates WHERE update_id = ?1", uidB).status).toBe("pending");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("config_revision bumps to base+1 for single finalize (multi-pending blocked by F-C index)", () => {
|
|
595
|
+
// F-C partial unique index (db.ts) prevents two non-terminal updates
|
|
596
|
+
// for the same node, so the multi-finalize scenario is unreachable
|
|
597
|
+
// at the DB layer. This test pins that ONE pending finalize → revision = base + 1.
|
|
598
|
+
const nid = insertNode({ alias: "n10", network_id: "netA" });
|
|
599
|
+
insertPendingUpdate({
|
|
600
|
+
node_id: nid, network_id: "netA",
|
|
601
|
+
patch: { model: "v1" },
|
|
602
|
+
apply_mode: "restart",
|
|
603
|
+
});
|
|
604
|
+
const r = finalizePendingMatchingUpdates(nid, { model: "v1", flags: {} });
|
|
605
|
+
expect(r.finalizedCount).toBe(1);
|
|
606
|
+
expect(db.get<any>("SELECT config_revision FROM nodes WHERE node_id = ?1", nid).config_revision).toBe(1);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
|
|
448
610
|
describe("F-B polish — reaper anchors on COALESCE(acked_at, created_at), not created_at", () => {
|
|
449
611
|
// 通信龙 catch: reaper threshold = 60s and drain hard-cap = 60s overlap.
|
|
450
612
|
// A healthy-but-slow restart (drain 60s + respawn time) could exceed
|
package/src/tools.ts
CHANGED
|
@@ -1843,6 +1843,101 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
1843
1843
|
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
1844
1844
|
[JSON.stringify(input.config_snapshot), input.node_id],
|
|
1845
1845
|
);
|
|
1846
|
+
// RFC-024 — finalize any pending/restarting update whose target
|
|
1847
|
+
// patch is now reflected in the snapshot. Closes the
|
|
1848
|
+
// restart-required-never-reaches-applied gap that 通信牛 caught:
|
|
1849
|
+
// the old child acks `restarting` + exits 75; the new child boots,
|
|
1850
|
+
// reads the new config, reports status — but never had the
|
|
1851
|
+
// update_id to call ack_config_update(applied) itself. Hub does
|
|
1852
|
+
// it on the new child's behalf by content-matching the patch
|
|
1853
|
+
// against the reported snapshot.
|
|
1854
|
+
finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
|
|
1846
1855
|
}
|
|
1847
1856
|
return { result: existing ? "updated" : "inserted", node_id: input.node_id };
|
|
1848
1857
|
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* RFC-024 restart-finalize (Option A per 通信牛 final review).
|
|
1861
|
+
*
|
|
1862
|
+
* Called from `upsertNodeWithSec1Guard` after writing a fresh
|
|
1863
|
+
* `config_snapshot` for the node. For each pending/restarting update
|
|
1864
|
+
* row, parse the patch and compare every field against the snapshot.
|
|
1865
|
+
* If everything in the patch is now reflected in the live snapshot,
|
|
1866
|
+
* mark the update applied + bump `nodes.config_revision`. The new
|
|
1867
|
+
* child doesn't need to know the update_id — content-matching against
|
|
1868
|
+
* the live state IS the proof that the apply landed.
|
|
1869
|
+
*
|
|
1870
|
+
* Edge cases:
|
|
1871
|
+
* - Empty patch (apply_mode=restart_only from `restart_node` tool) →
|
|
1872
|
+
* ANY snapshot matches (the restart itself was the goal).
|
|
1873
|
+
* - Patch field absent from snapshot → not a match (snapshot
|
|
1874
|
+
* post-dates the apply only when every requested field shows up).
|
|
1875
|
+
* - Multiple concurrent pending rows → impossible by single-flight,
|
|
1876
|
+
* but defensive: finalize OLDEST first so the chain stays linear.
|
|
1877
|
+
*
|
|
1878
|
+
* Pure-ish: takes a snapshot blob (TS shape, see config-snapshot type)
|
|
1879
|
+
* and runs only db.run / db.get. No external IO.
|
|
1880
|
+
*/
|
|
1881
|
+
export function finalizePendingMatchingUpdates(
|
|
1882
|
+
nodeId: string,
|
|
1883
|
+
snapshot: any,
|
|
1884
|
+
): { finalizedCount: number; finalizedIds: string[] } {
|
|
1885
|
+
const pending = db.all<{
|
|
1886
|
+
update_id: string;
|
|
1887
|
+
patch_json: string;
|
|
1888
|
+
apply_mode: string;
|
|
1889
|
+
base_revision: number;
|
|
1890
|
+
}>(
|
|
1891
|
+
"SELECT update_id, patch_json, apply_mode, base_revision FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at ASC",
|
|
1892
|
+
nodeId,
|
|
1893
|
+
);
|
|
1894
|
+
if (pending.length === 0) return { finalizedCount: 0, finalizedIds: [] };
|
|
1895
|
+
|
|
1896
|
+
const snapModel: string | null | undefined = snapshot?.model;
|
|
1897
|
+
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
1898
|
+
|
|
1899
|
+
const finalizedIds: string[] = [];
|
|
1900
|
+
|
|
1901
|
+
for (const row of pending) {
|
|
1902
|
+
let patch: any = {};
|
|
1903
|
+
try { patch = JSON.parse(row.patch_json); } catch { continue; }
|
|
1904
|
+
|
|
1905
|
+
let matches = true;
|
|
1906
|
+
// restart_only / empty patch → any snapshot proves the restart
|
|
1907
|
+
// happened, finalize unconditionally.
|
|
1908
|
+
if (row.apply_mode !== "restart_only") {
|
|
1909
|
+
if (patch.model !== undefined) {
|
|
1910
|
+
if (snapModel !== patch.model) { matches = false; }
|
|
1911
|
+
}
|
|
1912
|
+
if (matches && patch.flags && typeof patch.flags === "object") {
|
|
1913
|
+
for (const [k, v] of Object.entries(patch.flags as Record<string, unknown>)) {
|
|
1914
|
+
// Canonical-key fallback for legacy aliases. The dashboard
|
|
1915
|
+
// schema uses `budget` and `timeout`; node-side config may
|
|
1916
|
+
// also carry the older `maxBudgetUsd` / `claudeTimeoutMs` /
|
|
1917
|
+
// `codexTimeoutMs` keys. agent-node's buildConfigSnapshot
|
|
1918
|
+
// only reports the canonical key, so we just compare on `k`.
|
|
1919
|
+
if (snapFlags[k] !== v) { matches = false; break; }
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
if (!matches) continue;
|
|
1925
|
+
|
|
1926
|
+
// Promote nodes.config_revision atomically with the update row.
|
|
1927
|
+
const nextRev = (db.get<{ config_revision: number }>(
|
|
1928
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1",
|
|
1929
|
+
nodeId,
|
|
1930
|
+
)?.config_revision || 0) + 1;
|
|
1931
|
+
db.run(
|
|
1932
|
+
"UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3 AND status IN ('pending', 'restarting')",
|
|
1933
|
+
[Date.now(), nextRev, row.update_id],
|
|
1934
|
+
);
|
|
1935
|
+
db.run("UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2", [nextRev, nodeId]);
|
|
1936
|
+
finalizedIds.push(row.update_id);
|
|
1937
|
+
console.log(
|
|
1938
|
+
`[commhub] ✓ finalize update ${row.update_id} via report_status content-match: node=${nodeId} new_revision=${nextRev} apply_mode=${row.apply_mode}`,
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
return { finalizedCount: finalizedIds.length, finalizedIds };
|
|
1943
|
+
}
|