@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100
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 +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +8 -7
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/agent-notice.ts +48 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +554 -38
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +830 -0
- package/src/config-apply-validate.test.ts +352 -0
- package/src/config-apply-validate.ts +249 -0
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +275 -0
- package/src/create-node-validate.ts +288 -0
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +349 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +1268 -42
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2128
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-context-cost-http.test.ts +119 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +72 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +344 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-status-participants-http.test.ts +220 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1761 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +281 -0
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +641 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5038 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +413 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +313 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +211 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +5426 -275
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-skillhub-"));
|
|
9
|
+
let server: any;
|
|
10
|
+
let base = "";
|
|
11
|
+
let ownerToken = "";
|
|
12
|
+
let nodeToken = "";
|
|
13
|
+
let foreignToken = "";
|
|
14
|
+
let networkId = "";
|
|
15
|
+
|
|
16
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
17
|
+
const res = await fetch(`${base}/mcp`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
20
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
21
|
+
});
|
|
22
|
+
expect(res.status).toBe(200);
|
|
23
|
+
const raw = await res.text();
|
|
24
|
+
const dataLines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
25
|
+
const payload = dataLines.length
|
|
26
|
+
? JSON.parse(dataLines.at(-1)!.slice(5).trim())
|
|
27
|
+
: JSON.parse(raw);
|
|
28
|
+
return JSON.parse(payload.result.content[0].text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
32
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
33
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
34
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
37
|
+
const owner = register(`skill_owner_${Date.now()}`, "SkillHubOwner123!", undefined, "seed");
|
|
38
|
+
expect(owner.ok).toBe(true);
|
|
39
|
+
ownerToken = owner.token!; networkId = owner.network_id!;
|
|
40
|
+
const ownerId = db.get<{ user_id: string }>("SELECT owner_id AS user_id FROM networks WHERE network_id = ?1", networkId)!.user_id;
|
|
41
|
+
const node = createNetworkTokenForNode(ownerId, networkId, "skill-writer");
|
|
42
|
+
expect(node.ok).toBe(true); nodeToken = node.token!;
|
|
43
|
+
const foreign = register(`skill_foreign_${Date.now()}`, "SkillHubForeign123!", undefined, "seed");
|
|
44
|
+
expect(foreign.ok).toBe(true); foreignToken = foreign.token!;
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
afterAll(() => { try { server?.stop?.(true); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
51
|
+
|
|
52
|
+
describe("SkillHub real Hub + SQLite", () => {
|
|
53
|
+
let skillId = "";
|
|
54
|
+
test("node submit is token-attributed, pending and immutable/idempotent", async () => {
|
|
55
|
+
const input = { network_id: networkId, slug: "incident-handoff", name: "Incident handoff", version: "1.0.0", description: "handoff", content: "# Incident handoff\n\nVerify facts." };
|
|
56
|
+
const first = await tool(nodeToken, "submit_skill", { ...input, source_alias: "forged-admin" });
|
|
57
|
+
expect(first.ok).toBe(true); expect(first.status).toBe("pending"); expect(first.source_alias).toBe("skill-writer"); skillId = first.skill_id;
|
|
58
|
+
const retry = await tool(nodeToken, "submit_skill", input);
|
|
59
|
+
expect(retry.idempotent).toBe(true); expect(retry.skill_id).toBe(skillId);
|
|
60
|
+
const conflict = await tool(nodeToken, "submit_skill", { ...input, content: "changed" });
|
|
61
|
+
expect(conflict.error).toBe("skill_version_conflict");
|
|
62
|
+
const raceBase = { ...input, slug: "concurrent-version", version: "2.0.0" };
|
|
63
|
+
const raced = await Promise.all([
|
|
64
|
+
tool(nodeToken, "submit_skill", { ...raceBase, content: "winner-a" }),
|
|
65
|
+
tool(nodeToken, "submit_skill", { ...raceBase, content: "winner-b" }),
|
|
66
|
+
]);
|
|
67
|
+
expect(raced.filter(x => x.ok).length).toBe(1);
|
|
68
|
+
expect(raced.filter(x => x.error === "skill_version_conflict").length).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("pending is reviewer-only; owner publishes; published becomes visible", async () => {
|
|
72
|
+
const nodeList = await tool(nodeToken, "list_skills", { network_id: networkId, include_pending: true });
|
|
73
|
+
expect(nodeList.skills).toHaveLength(0);
|
|
74
|
+
expect((await tool(nodeToken, "get_skill", { network_id: networkId, skill_id: skillId })).error).toBe("skill_not_found");
|
|
75
|
+
const ownerList = await tool(ownerToken, "list_skills", { network_id: networkId, include_pending: true });
|
|
76
|
+
expect(ownerList.reviewer).toBe(true); expect(ownerList.skills.map((x: any) => x.skill_id)).toContain(skillId);
|
|
77
|
+
expect((await tool(ownerToken, "get_skill", { network_id: networkId, skill_id: skillId })).skill.content).toContain("Verify facts");
|
|
78
|
+
const reviewed = await tool(ownerToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "published" });
|
|
79
|
+
expect(reviewed.status).toBe("published");
|
|
80
|
+
const published = await tool(nodeToken, "list_skills", { network_id: networkId });
|
|
81
|
+
expect(published.skills.map((x: any) => x.skill_id)).toContain(skillId);
|
|
82
|
+
expect((await tool(nodeToken, "get_skill", { network_id: networkId, skill_id: skillId })).skill.skill_id).toBe(skillId);
|
|
83
|
+
const detail = (await tool(nodeToken, "get_skill", { network_id: networkId, skill_id: skillId })).skill;
|
|
84
|
+
expect(detail.created_by_user).toBeUndefined(); expect(detail.reviewed_by_user).toBeUndefined(); expect(detail.content_hash).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("query matches regardless of letter case (SQLite LIKE semantics, kept on PostgreSQL)", async () => {
|
|
88
|
+
for (const query of ["incident", "INCIDENT", "Handoff"]) {
|
|
89
|
+
const found = await tool(nodeToken, "list_skills", { network_id: networkId, query });
|
|
90
|
+
expect(found.skills.map((x: any) => x.skill_id)).toContain(skillId);
|
|
91
|
+
}
|
|
92
|
+
expect((await tool(nodeToken, "list_skills", { network_id: networkId, query: "no-such-skill" })).skills).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("% and _ in the query are literal characters, not wildcards (#2141)", async () => {
|
|
96
|
+
const ids = async (query: string) => (await tool(nodeToken, "list_skills", { network_id: networkId, query })).skills.map((x: any) => x.skill_id);
|
|
97
|
+
expect(await ids("_")).not.toContain(skillId); // was: matched everything
|
|
98
|
+
expect(await ids("%")).not.toContain(skillId);
|
|
99
|
+
expect(await ids("incident_handoff")).not.toContain(skillId); // `_` must not stand for the "-" in the slug
|
|
100
|
+
expect(await ids("incident-handoff")).toContain(skillId);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("foreign user cannot read or review another network", async () => {
|
|
104
|
+
expect((await tool(foreignToken, "list_skills", { network_id: networkId })).ok).toBe(false);
|
|
105
|
+
const review = await tool(foreignToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "rejected" });
|
|
106
|
+
expect(review.ok).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { SQLiteAdapter } from "./db-adapter";
|
|
8
|
+
|
|
9
|
+
// #2151 — a cleanly stopped Hub must leave a complete .db: the -wal is absent
|
|
10
|
+
// or empty, and a copy of the .db file alone still has the data.
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-wal-close-"));
|
|
12
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
const walBytes = (db: string) => (existsSync(`${db}-wal`) ? statSync(`${db}-wal`).size : 0);
|
|
15
|
+
|
|
16
|
+
function copyOnlyDb(db: string): Database {
|
|
17
|
+
const copy = join(dirname(db), `copy-of-${db.split("/").pop()}`);
|
|
18
|
+
copyFileSync(db, copy); // deliberately without -wal / -shm
|
|
19
|
+
return new Database(copy, { readonly: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("#2151 SQLite WAL is checkpointed on close", () => {
|
|
23
|
+
test("SQLiteAdapter.close() after >20 distinct statements leaves a complete .db", () => {
|
|
24
|
+
const path = join(dir, "adapter.db");
|
|
25
|
+
const raw = new Database(path);
|
|
26
|
+
raw.exec("PRAGMA journal_mode=WAL");
|
|
27
|
+
const db = new SQLiteAdapter(raw);
|
|
28
|
+
db.exec("CREATE TABLE t (x TEXT)");
|
|
29
|
+
db.run("INSERT INTO t VALUES (?1)", ["kept"]);
|
|
30
|
+
// bun:sqlite stops being able to close after more than 20 distinct query() strings.
|
|
31
|
+
for (let i = 0; i < 30; i++) db.get(`SELECT ${i} AS v`);
|
|
32
|
+
db.close();
|
|
33
|
+
expect(walBytes(path)).toBe(0);
|
|
34
|
+
const copy = copyOnlyDb(path);
|
|
35
|
+
expect(copy.query("SELECT x FROM t").all()).toEqual([{ x: "kept" }]);
|
|
36
|
+
copy.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a real Hub that served traffic and got SIGTERM leaves a complete .db", async () => {
|
|
40
|
+
const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
|
|
41
|
+
const home = join(dir, "home");
|
|
42
|
+
const path = join(dir, "hub.db");
|
|
43
|
+
const port = String(27000 + Math.floor(Math.random() * 2000));
|
|
44
|
+
const base = `http://127.0.0.1:${port}`;
|
|
45
|
+
const hub = Bun.spawn(["bun", bin, "--port", port, "--host", "127.0.0.1", "--db", path], {
|
|
46
|
+
env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
let up = false;
|
|
50
|
+
for (let i = 0; i < 150 && !up; i++) {
|
|
51
|
+
up = await fetch(`${base}/health`).then((r) => r.ok, () => false);
|
|
52
|
+
if (!up) await Bun.sleep(100);
|
|
53
|
+
}
|
|
54
|
+
expect(up).toBe(true);
|
|
55
|
+
const post = (p: string, body: unknown, token?: string) => fetch(`${base}${p}`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
}).then((r) => r.json() as Promise<any>);
|
|
60
|
+
const reg = await post("/api/auth/register", { username: "walcheck", password: "StrongPassw0rd" });
|
|
61
|
+
expect(reg.ok).toBe(true);
|
|
62
|
+
const login = await post("/api/auth/login", { username: "walcheck", password: "StrongPassw0rd" });
|
|
63
|
+
const net = await post("/api/networks", { name: "wal-net" }, login.token);
|
|
64
|
+
expect(net.ok).toBe(true);
|
|
65
|
+
const ntok = await post("/api/auth/node-token", { network_id: net.network_id, node_name: "wal-agent" }, login.token);
|
|
66
|
+
expect(ntok.ok).toBe(true);
|
|
67
|
+
expect(walBytes(path)).toBeGreaterThan(0); // the writes really are in the WAL before the stop
|
|
68
|
+
} finally {
|
|
69
|
+
hub.kill("SIGTERM");
|
|
70
|
+
await hub.exited;
|
|
71
|
+
}
|
|
72
|
+
expect(walBytes(path)).toBe(0);
|
|
73
|
+
const copy = copyOnlyDb(path);
|
|
74
|
+
expect(copy.query("SELECT COUNT(*) AS n FROM users WHERE username = 'walcheck'").get()).toEqual({ n: 1 });
|
|
75
|
+
expect(copy.query("SELECT COUNT(*) AS n FROM networks WHERE network_name = 'wal-net'").get()).toEqual({ n: 1 });
|
|
76
|
+
copy.close();
|
|
77
|
+
}, 40_000);
|
|
78
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Round-2/4 review ③ — stale session sweeper tests.
|
|
2
|
+
//
|
|
3
|
+
// Drives the real sweepStaleSessions against isolated in-process
|
|
4
|
+
// SQLite (COMMHUB_DB=/tmp/...). Pins behaviour the read-path
|
|
5
|
+
// regression (per-request UPDATE) would re-introduce if anyone
|
|
6
|
+
// undoes the fix:
|
|
7
|
+
//
|
|
8
|
+
// 1. fresh sessions stay non-offline regardless of how many
|
|
9
|
+
// times the sweeper runs;
|
|
10
|
+
// 2. stale sessions get marked offline exactly once (idempotent);
|
|
11
|
+
// 3. global sweep crosses tenant boundaries — a stale agent in
|
|
12
|
+
// network B is marked offline even when no one in net B is
|
|
13
|
+
// polling /api/status;
|
|
14
|
+
// 4. env-var cutoff is respected and bad values fall back silently.
|
|
15
|
+
//
|
|
16
|
+
// Run with:
|
|
17
|
+
// COMMHUB_DB=/tmp/stale-test.db bun test src/stale-sweeper.test.ts
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { sweepStaleSessions, staleCutoffMinutes } from "./stale-sweeper.js";
|
|
22
|
+
|
|
23
|
+
function insertSession(opts: {
|
|
24
|
+
alias: string;
|
|
25
|
+
status?: string;
|
|
26
|
+
minutesAgo: number;
|
|
27
|
+
network_id?: string;
|
|
28
|
+
}): void {
|
|
29
|
+
const id = `r_${opts.alias}`;
|
|
30
|
+
db.run(
|
|
31
|
+
`INSERT OR REPLACE INTO sessions
|
|
32
|
+
(resume_id, alias, status, network_id, updated_at, registered_at)
|
|
33
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now', ?5), datetime('now', ?5))`,
|
|
34
|
+
[id, opts.alias, opts.status ?? "idle", opts.network_id ?? "default", `-${opts.minutesAgo} minutes`]
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getStatus(alias: string): string | null {
|
|
39
|
+
const row = db.get<{ status: string }>(
|
|
40
|
+
"SELECT status FROM sessions WHERE alias = ?1",
|
|
41
|
+
alias
|
|
42
|
+
);
|
|
43
|
+
return row?.status ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
db.run("DELETE FROM sessions");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("sweepStaleSessions — fresh agents not touched", () => {
|
|
51
|
+
test("agent updated < cutoff stays at its current status", () => {
|
|
52
|
+
insertSession({ alias: "live-1", status: "idle", minutesAgo: 1 });
|
|
53
|
+
insertSession({ alias: "live-2", status: "working", minutesAgo: 5 });
|
|
54
|
+
|
|
55
|
+
sweepStaleSessions(10);
|
|
56
|
+
|
|
57
|
+
expect(getStatus("live-1")).toBe("idle");
|
|
58
|
+
expect(getStatus("live-2")).toBe("working");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("multiple sweeps on quiet hub are pure no-ops (no write fired)", () => {
|
|
62
|
+
insertSession({ alias: "live", status: "idle", minutesAgo: 1 });
|
|
63
|
+
|
|
64
|
+
const r1 = sweepStaleSessions(10);
|
|
65
|
+
const r2 = sweepStaleSessions(10);
|
|
66
|
+
const r3 = sweepStaleSessions(10);
|
|
67
|
+
|
|
68
|
+
expect(r1.markedOffline).toBe(0);
|
|
69
|
+
expect(r2.markedOffline).toBe(0);
|
|
70
|
+
expect(r3.markedOffline).toBe(0);
|
|
71
|
+
expect(getStatus("live")).toBe("idle");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("sweepStaleSessions — stale agents marked offline", () => {
|
|
76
|
+
test("agent updated > cutoff gets marked offline exactly once", () => {
|
|
77
|
+
insertSession({ alias: "stale", status: "idle", minutesAgo: 30 });
|
|
78
|
+
|
|
79
|
+
const r1 = sweepStaleSessions(10);
|
|
80
|
+
expect(r1.markedOffline).toBe(1);
|
|
81
|
+
expect(getStatus("stale")).toBe("offline");
|
|
82
|
+
|
|
83
|
+
// Second sweep does nothing because the row is already offline
|
|
84
|
+
// (the predicate is `status != 'offline'`).
|
|
85
|
+
const r2 = sweepStaleSessions(10);
|
|
86
|
+
expect(r2.markedOffline).toBe(0);
|
|
87
|
+
expect(getStatus("stale")).toBe("offline");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("already-offline stale row not re-marked (write avoided)", () => {
|
|
91
|
+
insertSession({ alias: "old-offline", status: "offline", minutesAgo: 60 });
|
|
92
|
+
|
|
93
|
+
const r = sweepStaleSessions(10);
|
|
94
|
+
|
|
95
|
+
expect(r.markedOffline).toBe(0);
|
|
96
|
+
expect(getStatus("old-offline")).toBe("offline");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("sweepStaleSessions — global across tenants (the read-path fix)", () => {
|
|
101
|
+
test("stale row in network B is marked offline even with no net-B polling", () => {
|
|
102
|
+
// Pre-fix: GET /api/status from net-A would only UPDATE rows in
|
|
103
|
+
// net-A's scope. A stale agent in net-B would stay non-offline
|
|
104
|
+
// until someone polled net-B. After the fix, the global sweeper
|
|
105
|
+
// is tenant-blind on the maintenance write.
|
|
106
|
+
insertSession({ alias: "stale-a", status: "idle", minutesAgo: 30, network_id: "net-A" });
|
|
107
|
+
insertSession({ alias: "stale-b", status: "idle", minutesAgo: 30, network_id: "net-B" });
|
|
108
|
+
insertSession({ alias: "stale-c", status: "idle", minutesAgo: 30, network_id: null as any });
|
|
109
|
+
|
|
110
|
+
const r = sweepStaleSessions(10);
|
|
111
|
+
|
|
112
|
+
expect(r.markedOffline).toBe(3);
|
|
113
|
+
expect(getStatus("stale-a")).toBe("offline");
|
|
114
|
+
expect(getStatus("stale-b")).toBe("offline");
|
|
115
|
+
expect(getStatus("stale-c")).toBe("offline");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("sweepStaleSessions — cutoff boundary + env", () => {
|
|
120
|
+
test("exactly-at-cutoff agent treated as stale (< strict, not <=)", () => {
|
|
121
|
+
// Just past the cutoff window.
|
|
122
|
+
insertSession({ alias: "just-stale", status: "idle", minutesAgo: 11 });
|
|
123
|
+
sweepStaleSessions(10);
|
|
124
|
+
expect(getStatus("just-stale")).toBe("offline");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("just-under-cutoff agent treated as live", () => {
|
|
128
|
+
insertSession({ alias: "just-live", status: "working", minutesAgo: 9 });
|
|
129
|
+
sweepStaleSessions(10);
|
|
130
|
+
expect(getStatus("just-live")).toBe("working");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("env COMMHUB_STALE_CUTOFF_MINUTES overrides default", () => {
|
|
134
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "3";
|
|
135
|
+
expect(staleCutoffMinutes()).toBe(3);
|
|
136
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("env invalid value falls back to default 10 (no operator footgun)", () => {
|
|
140
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "not-a-number";
|
|
141
|
+
expect(staleCutoffMinutes()).toBe(10);
|
|
142
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
143
|
+
|
|
144
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "0";
|
|
145
|
+
expect(staleCutoffMinutes()).toBe(10);
|
|
146
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
147
|
+
|
|
148
|
+
process.env.COMMHUB_STALE_CUTOFF_MINUTES = "-5";
|
|
149
|
+
expect(staleCutoffMinutes()).toBe(10);
|
|
150
|
+
delete process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("sweepStaleSessions — read-path regression guard (pin the fix)", () => {
|
|
155
|
+
// This is the load-bearing pin: if anyone adds back a per-request
|
|
156
|
+
// UPDATE in /api/status / /api/servers / /api/server-detail-* /
|
|
157
|
+
// get_all_status, this test plus the absence of those UPDATEs in
|
|
158
|
+
// the source should make the regression visible. Since this is a
|
|
159
|
+
// unit-level test of the sweeper itself, the regression-prevention
|
|
160
|
+
// role is mostly social — the comments in this file + the comments
|
|
161
|
+
// in index.ts and tools.ts at the former UPDATE sites tell future
|
|
162
|
+
// editors "do not put the UPDATE back; use the sweeper."
|
|
163
|
+
test("after sweep, sessions row has the offline status persisted (single source of truth)", () => {
|
|
164
|
+
insertSession({ alias: "x", status: "working", minutesAgo: 30 });
|
|
165
|
+
const before = sweepStaleSessions(10);
|
|
166
|
+
expect(before.markedOffline).toBe(1);
|
|
167
|
+
|
|
168
|
+
// Re-read directly — no extra UPDATE needed by a caller.
|
|
169
|
+
const status = getStatus("x");
|
|
170
|
+
expect(status).toBe("offline");
|
|
171
|
+
|
|
172
|
+
// 100 simulated read-path lookups don't fire any maintenance writes.
|
|
173
|
+
for (let i = 0; i < 100; i++) {
|
|
174
|
+
const row = db.get<{ status: string }>(
|
|
175
|
+
"SELECT status FROM sessions WHERE alias = ?1",
|
|
176
|
+
"x"
|
|
177
|
+
);
|
|
178
|
+
expect(row?.status).toBe("offline");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Round-2/4 review ③ — read-path write amplification fix.
|
|
2
|
+
//
|
|
3
|
+
// Before this module: GET /api/status, GET /api/servers, GET
|
|
4
|
+
// /api/server-health/:host, and the MCP `get_all_status` tool each
|
|
5
|
+
// fired:
|
|
6
|
+
//
|
|
7
|
+
// UPDATE sessions SET status = 'offline'
|
|
8
|
+
// WHERE updated_at < ?1 AND status != 'offline'
|
|
9
|
+
//
|
|
10
|
+
// on every call. With the dashboard polling /api/status every few
|
|
11
|
+
// seconds × N tabs × M users, that's a high-frequency WRITE on a
|
|
12
|
+
// heavily-read table just to maintain a derived field. Wait events
|
|
13
|
+
// stack up (WAL fsync, page locks) and the writes are 99% no-ops.
|
|
14
|
+
//
|
|
15
|
+
// Fix: move the stale-marking off the read path into a single
|
|
16
|
+
// background timer. Reads now SELECT only; the timer runs every 60s
|
|
17
|
+
// and applies the same UPDATE GLOBALLY (no per-network scope — stale
|
|
18
|
+
// is stale regardless of which tenant is asking). Worst-case delay
|
|
19
|
+
// between an agent crashing and going `offline` in the dashboard:
|
|
20
|
+
//
|
|
21
|
+
// 10min stale cutoff + 60s sweep = up to ~11min
|
|
22
|
+
//
|
|
23
|
+
// (unchanged from before the fix for any reader that wasn't actively
|
|
24
|
+
// polling).
|
|
25
|
+
//
|
|
26
|
+
// Same pattern as `src/retention.ts` for shutdown (clearInterval in
|
|
27
|
+
// the graceful shutdown handler).
|
|
28
|
+
|
|
29
|
+
import { db } from "./db.js";
|
|
30
|
+
|
|
31
|
+
export type StaleSweepResult = {
|
|
32
|
+
startedAt: string;
|
|
33
|
+
durationMs: number;
|
|
34
|
+
markedOffline: number;
|
|
35
|
+
staleCutoffMinutes: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Stale window: an agent that hasn't reported in this many minutes is
|
|
39
|
+
// considered offline. Matches the pre-fix per-request cutoff so the
|
|
40
|
+
// observable behaviour from the dashboard's perspective is unchanged.
|
|
41
|
+
const DEFAULT_STALE_MINUTES = 10;
|
|
42
|
+
const DEFAULT_SWEEP_MS = 60 * 1000;
|
|
43
|
+
|
|
44
|
+
export function staleCutoffMinutes(): number {
|
|
45
|
+
const raw = process.env.COMMHUB_STALE_CUTOFF_MINUTES;
|
|
46
|
+
if (raw === undefined || raw === "") return DEFAULT_STALE_MINUTES;
|
|
47
|
+
const n = Number(raw);
|
|
48
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_STALE_MINUTES;
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sqliteCutoff(minutes: number): string {
|
|
53
|
+
return new Date(Date.now() - minutes * 60 * 1000)
|
|
54
|
+
.toISOString()
|
|
55
|
+
.replace("T", " ")
|
|
56
|
+
.slice(0, 19);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Single GLOBAL UPDATE — no per-network filtering. The pre-fix per-
|
|
60
|
+
// request stale marker WAS scoped (each REST call only saw its own
|
|
61
|
+
// network's stale rows), but the semantics of "this row hasn't been
|
|
62
|
+
// heard from in 10min" is global. A network that no one's looking at
|
|
63
|
+
// still wants its agents marked offline so an admin scanning all
|
|
64
|
+
// networks gets consistent state.
|
|
65
|
+
export function sweepStaleSessions(cutoffMinutes = staleCutoffMinutes()): StaleSweepResult {
|
|
66
|
+
const startedAt = new Date().toISOString();
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
let markedOffline = 0;
|
|
69
|
+
try {
|
|
70
|
+
const cutoff = sqliteCutoff(cutoffMinutes);
|
|
71
|
+
const res = db.run(
|
|
72
|
+
"UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'",
|
|
73
|
+
[cutoff],
|
|
74
|
+
);
|
|
75
|
+
markedOffline = res.changes ?? 0;
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
console.log(`[commhub stale-sweep] failed: ${e?.message ?? e}`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
startedAt,
|
|
81
|
+
durationMs: Date.now() - t0,
|
|
82
|
+
markedOffline,
|
|
83
|
+
staleCutoffMinutes: cutoffMinutes,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function startStaleSessionSweeper(
|
|
88
|
+
intervalMs?: number,
|
|
89
|
+
): ReturnType<typeof setInterval> {
|
|
90
|
+
const env = process.env.COMMHUB_STALE_SWEEP_SECONDS;
|
|
91
|
+
const envMs = env && Number.isFinite(Number(env)) && Number(env) > 0
|
|
92
|
+
? Number(env) * 1000
|
|
93
|
+
: undefined;
|
|
94
|
+
const ms = intervalMs ?? envMs ?? DEFAULT_SWEEP_MS;
|
|
95
|
+
const sweep = () => {
|
|
96
|
+
try {
|
|
97
|
+
const r = sweepStaleSessions();
|
|
98
|
+
// Log only when something actually changed — quiet hubs stay quiet.
|
|
99
|
+
if (r.markedOffline > 0) {
|
|
100
|
+
console.log(
|
|
101
|
+
`[commhub stale-sweep] marked ${r.markedOffline} session(s) offline in ${r.durationMs}ms ` +
|
|
102
|
+
`(cutoff=${r.staleCutoffMinutes}min)`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch (e: any) {
|
|
106
|
+
console.log(`[commhub stale-sweep] top-level failed: ${e?.message ?? e}`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// Fire once at boot via setImmediate so the very-first /api/status
|
|
110
|
+
// after server start gets fresh stale state without blocking
|
|
111
|
+
// startup.
|
|
112
|
+
setImmediate(sweep);
|
|
113
|
+
return setInterval(sweep, ms);
|
|
114
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
const NET = "net_start_node"; const USER = "u_start_node";
|
|
7
|
+
const DAEMON = "node_start_daemon"; const DAEMON_ALIAS = "start-daemon";
|
|
8
|
+
const CHILD = "node_start_child"; const CHILD_ALIAS = "start-child";
|
|
9
|
+
|
|
10
|
+
function cleanup() {
|
|
11
|
+
for (const table of ["node_start_requests", "node_create_requests", "audit_log", "nodes", "api_tokens", "network_members", "networks"]) {
|
|
12
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
13
|
+
}
|
|
14
|
+
try { db.run("DELETE FROM users WHERE user_id=?1", USER); } catch {}
|
|
15
|
+
}
|
|
16
|
+
beforeEach(() => { cleanup();
|
|
17
|
+
db.run(`INSERT INTO users(user_id,username,password_hash,role,created_at) VALUES(?1,?1,'x','user',datetime('now'))`, USER);
|
|
18
|
+
db.run(`INSERT INTO networks(network_id,network_name,owner_id,created_at) VALUES(?1,?1,?2,datetime('now'))`, [NET, USER]);
|
|
19
|
+
db.run(`INSERT INTO network_members(user_id,network_id,role,joined_at) VALUES(?1,?2,'owner',datetime('now'))`, [USER, NET]);
|
|
20
|
+
db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot,hostname,created_at,updated_at,lifecycle_state) VALUES(?1,?2,?2,?3,'{}','h',datetime('now'),datetime('now'),'active')`, [DAEMON, DAEMON_ALIAS, NET]);
|
|
21
|
+
db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot,hostname,created_at,updated_at,lifecycle_state) VALUES(?1,?2,?2,?3,'{}','h',datetime('now'),datetime('now'),'stopped')`, [CHILD, CHILD_ALIAS, NET]);
|
|
22
|
+
db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('tok_start_daemon',?1,?2,'network',?3,'h')`, [USER, NET, `node:${DAEMON_ALIAS}`]);
|
|
23
|
+
db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id) VALUES('cr_start_child',?1,?2,?3,'codex-sdk','x','{}','[]','succeeded',1,'t',?4)`, [DAEMON, CHILD_ALIAS, NET, CHILD]);
|
|
24
|
+
});
|
|
25
|
+
afterAll(cleanup);
|
|
26
|
+
|
|
27
|
+
function handlers(user: string | null, daemon = false) {
|
|
28
|
+
const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = {};
|
|
29
|
+
const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; return orig(n,d,schema,h); };
|
|
30
|
+
registerTools(s, undefined, daemon ? NET : null, user, null, daemon, daemon ? "tok_start_daemon" : null); return out;
|
|
31
|
+
}
|
|
32
|
+
async function call(h: any, args: any) { return JSON.parse((await h(args)).content[0].text); }
|
|
33
|
+
|
|
34
|
+
describe("start_node Hub -> daemon lifecycle", () => {
|
|
35
|
+
test("dispatch, authenticated pull, ack transitions stopped -> starting -> active", async () => {
|
|
36
|
+
const u = handlers(USER); const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
37
|
+
expect(dispatch.ok).toBe(true); expect(dispatch.lifecycle_state).toBe("starting");
|
|
38
|
+
const d = handlers(null, true); const pulled = await call(d.get_start_request, { request_id: dispatch.request_id });
|
|
39
|
+
expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS });
|
|
40
|
+
const ack = await call(d.ack_start_request, { request_id: dispatch.request_id, status: "started", child_pid: 4321 });
|
|
41
|
+
expect(ack.ok).toBe(true);
|
|
42
|
+
expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("active");
|
|
43
|
+
expect(db.all<any>(`SELECT action FROM audit_log WHERE target_id=?1 ORDER BY id`, dispatch.request_id).map((x:any)=>x.action)).toEqual(["start_node_dispatched", "start_node_completed"]);
|
|
44
|
+
});
|
|
45
|
+
test("active child and caller-supplied wrong daemon fail closed", async () => {
|
|
46
|
+
const u = handlers(USER);
|
|
47
|
+
db.run(`UPDATE nodes SET lifecycle_state='active' WHERE node_id=?1`, CHILD);
|
|
48
|
+
expect((await call(u.start_node, { child_node_id: CHILD, network_id: NET })).error).toBe("node_not_stopped");
|
|
49
|
+
db.run(`UPDATE nodes SET lifecycle_state='stopped' WHERE node_id=?1`, CHILD);
|
|
50
|
+
expect((await call(u.start_node, { child_node_id: CHILD, daemon_node_id: "node_wrong", network_id: NET })).error).toBe("daemon_child_mismatch");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// #1448 finding-2 — stale-starting reaper(对齐 update_node_config/restart_node 的 60s
|
|
55
|
+
// single-flight stale-supersede)。start 只受理 stopped;卡在 starting 的节点(门铃丢 /
|
|
56
|
+
// daemon 在 UPDATE starting 后死)改前永远 start 不了(gate 返 node_not_stopped)、也无
|
|
57
|
+
// reaper 拉回 → 永久卡死。改后:in-flight start 请求晾过 60s ⇒ 标 timeout 超越 + 放行
|
|
58
|
+
// 重派;未过阈值 ⇒ 拒 node_already_starting。
|
|
59
|
+
describe("start_node — stale-starting reaper (#1448 finding-2)", () => {
|
|
60
|
+
function seedStartReq(reqId: string, status: string, ageMs: number) {
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,created_at)
|
|
63
|
+
VALUES(?1,?2,?3,?4,?5,'t',?6,?7)`,
|
|
64
|
+
[reqId, NET, DAEMON, CHILD, CHILD_ALIAS, status, Date.now() - ageMs],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("starting + FRESH in-flight start (< 60s) → refused node_already_starting (not superseded)", async () => {
|
|
69
|
+
db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
|
|
70
|
+
seedStartReq("str_fresh", "delivered", 5_000); // 5s old — within threshold
|
|
71
|
+
const u = handlers(USER);
|
|
72
|
+
const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
73
|
+
expect(r.ok).toBe(false);
|
|
74
|
+
expect(r.error).toBe("node_already_starting");
|
|
75
|
+
expect(r.existing_request_id).toBe("str_fresh");
|
|
76
|
+
expect(typeof r.age_ms).toBe("number");
|
|
77
|
+
// old request untouched, no new dispatch
|
|
78
|
+
expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id='str_fresh'`)?.status).toBe("delivered");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// witnessed-red:改前 gate 直接 `!== 'stopped' → node_not_stopped`,一个 stale 的
|
|
82
|
+
// 'starting' 节点返回 node_not_stopped、永远卡死。改后应超越重派(ok:true)。
|
|
83
|
+
test("starting + STALE in-flight start (> 60s) → supersede old + re-dispatch (converges)", async () => {
|
|
84
|
+
db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
|
|
85
|
+
seedStartReq("str_stale", "delivered", 61_000); // 61s old — past 60s threshold
|
|
86
|
+
const u = handlers(USER);
|
|
87
|
+
const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
88
|
+
expect(r.ok).toBe(true);
|
|
89
|
+
expect(r.lifecycle_state).toBe("starting");
|
|
90
|
+
expect(typeof r.request_id).toBe("string");
|
|
91
|
+
expect(r.request_id).not.toBe("str_stale"); // fresh request
|
|
92
|
+
// old stale request superseded → terminal, unblocks the child-inflight unique index
|
|
93
|
+
expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id='str_stale'`)?.status).toBe("timeout");
|
|
94
|
+
// exactly one non-terminal start request now (the new one)
|
|
95
|
+
const live = db.all<any>(`SELECT request_id FROM node_start_requests WHERE child_node_id=?1 AND status IN ('pending','delivered')`, CHILD);
|
|
96
|
+
expect(live.map((x:any)=>x.request_id)).toEqual([r.request_id]);
|
|
97
|
+
// audit records the real prior state
|
|
98
|
+
const aud = db.get<any>(`SELECT detail FROM audit_log WHERE target_id=?1`, r.request_id);
|
|
99
|
+
expect(JSON.parse(aud.detail).lifecycle_state_before).toBe("starting");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("starting + NO in-flight row (orphaned state) → self-heals by re-dispatching", async () => {
|
|
103
|
+
db.run(`UPDATE nodes SET lifecycle_state='starting' WHERE node_id=?1`, CHILD);
|
|
104
|
+
const u = handlers(USER);
|
|
105
|
+
const r = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
|
|
106
|
+
expect(r.ok).toBe(true);
|
|
107
|
+
expect(r.lifecycle_state).toBe("starting");
|
|
108
|
+
});
|
|
109
|
+
});
|