@sleep2agi/commhub-server 0.9.0-preview.28 → 0.9.0-preview.29
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/bin/commhub.ts +4 -0
- package/package.json +4 -4
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-networks-http.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +1 -6
- package/src/auth.ts +88 -14
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +279 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +10 -17
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +1 -1
- package/src/file-download-authz.test.ts +21 -1
- package/src/file-download-date-bucket.test.ts +1 -1
- package/src/file-network-scope-dev-open.test.ts +1 -1
- package/src/file-network-scope.test.ts +1 -1
- package/src/health-redaction.test.ts +12 -0
- package/src/index.ts +6 -2
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +20 -0
- package/src/node-attrs.test.ts +1 -1
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/rest-explicit-columns-http.test.ts +149 -0
- package/src/rest-identity.test.ts +120 -0
- package/src/rest-identity.ts +93 -0
- package/src/rest-projections.ts +67 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +460 -0
- package/src/scheduled-tasks.ts +538 -0
- package/src/send-reply-agent-warning.test.ts +38 -38
- package/src/server.ts +315 -60
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +329 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +82 -0
- package/src/task-diagnostic.ts +89 -0
- package/src/task-idempotency-mcp.test.ts +137 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +160 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +635 -107
- package/src/uploads-http.test.ts +1 -3
- package/src/uploads.ts +14 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-task-diagnostic-"));
|
|
7
|
+
process.env.COMMHUB_DB = join(PRIVATE_DB_DIR, "hub.db");
|
|
8
|
+
|
|
9
|
+
let db: typeof import("./db.js").db;
|
|
10
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
11
|
+
let base = "";
|
|
12
|
+
let tokenA = "";
|
|
13
|
+
let tokenB = "";
|
|
14
|
+
let networkA = "";
|
|
15
|
+
let networkB = "";
|
|
16
|
+
|
|
17
|
+
const ALIAS = "diagnostic-worker";
|
|
18
|
+
|
|
19
|
+
async function detail(token: string, taskId: string): Promise<{ status: number; body: any }> {
|
|
20
|
+
const response = await fetch(`${base}/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
21
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
22
|
+
});
|
|
23
|
+
return { status: response.status, body: await response.json() };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function insertTask(taskId: string, patch: {
|
|
27
|
+
status?: string;
|
|
28
|
+
runtimeSubmittedAt?: string | null;
|
|
29
|
+
consumedAt?: string | null;
|
|
30
|
+
} = {}) {
|
|
31
|
+
db.run(
|
|
32
|
+
`INSERT INTO tasks
|
|
33
|
+
(task_id, from_name, to_node_id, to_name, status, content, priority,
|
|
34
|
+
network_id, runtime_submitted_at, consumed_at)
|
|
35
|
+
VALUES (?1, 'diagnostic-sender', 'node-diagnostic-a', ?2, ?3,
|
|
36
|
+
'diagnose me', 'normal', ?4, ?5, ?6)`,
|
|
37
|
+
[taskId, ALIAS, patch.status ?? "delivered", networkA, patch.runtimeSubmittedAt ?? null, patch.consumedAt ?? null],
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function openSse(token: string, networkId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
|
42
|
+
const response = await fetch(`${base}/events/${encodeURIComponent(ALIAS)}?network_id=${encodeURIComponent(networkId)}`, {
|
|
43
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
44
|
+
});
|
|
45
|
+
expect(response.status).toBe(200);
|
|
46
|
+
const reader = response.body!.getReader();
|
|
47
|
+
const first = await reader.read();
|
|
48
|
+
expect(new TextDecoder().decode(first.value)).toContain('"type":"connected"');
|
|
49
|
+
return reader;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
({ db } = await import("./db.js"));
|
|
54
|
+
const { register } = await import("./auth.js");
|
|
55
|
+
const authA = register(`diagnostic_a_${Date.now()}`, "Diagnostic-A-Strong-1!", undefined, "diagnostic-a");
|
|
56
|
+
const authB = register(`diagnostic_b_${Date.now()}`, "Diagnostic-B-Strong-1!", undefined, "diagnostic-b");
|
|
57
|
+
expect(authA.ok).toBe(true);
|
|
58
|
+
expect(authB.ok).toBe(true);
|
|
59
|
+
tokenA = authA.token!;
|
|
60
|
+
tokenB = authB.token!;
|
|
61
|
+
networkA = authA.network_id!;
|
|
62
|
+
networkB = authB.network_id!;
|
|
63
|
+
|
|
64
|
+
for (const [networkId, suffix] of [[networkA, "a"], [networkB, "b"]] as const) {
|
|
65
|
+
db.run(
|
|
66
|
+
`INSERT INTO nodes
|
|
67
|
+
(node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
68
|
+
VALUES (?1, ?2, ?2, ?3, 'diagnostic-host', datetime('now'), datetime('now'), 'active')`,
|
|
69
|
+
[`node-diagnostic-${suffix}`, ALIAS, networkId],
|
|
70
|
+
);
|
|
71
|
+
db.run(
|
|
72
|
+
`INSERT INTO sessions
|
|
73
|
+
(resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
74
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
75
|
+
[`resume-diagnostic-${suffix}`, ALIAS, `node-diagnostic-${suffix}`, networkId],
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const mod = await import("./server.js");
|
|
80
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
81
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterAll(() => {
|
|
85
|
+
try { server?.stop(true); } catch {}
|
|
86
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("#166 scoped single-task diagnostics", () => {
|
|
90
|
+
test("does not count another network's same-alias SSE connection", async () => {
|
|
91
|
+
insertTask("diag-cross-network");
|
|
92
|
+
const foreignReader = await openSse(tokenB, networkB);
|
|
93
|
+
try {
|
|
94
|
+
const own = await detail(tokenA, "diag-cross-network");
|
|
95
|
+
expect(own.status).toBe(200);
|
|
96
|
+
expect(own.body.diagnostic).toMatchObject({
|
|
97
|
+
code: "target_no_live_sse",
|
|
98
|
+
evidence: { live_sse_connections: 0 },
|
|
99
|
+
});
|
|
100
|
+
const foreign = await detail(tokenB, "diag-cross-network");
|
|
101
|
+
expect(foreign.status).toBe(404);
|
|
102
|
+
expect(foreign.body).toMatchObject({ ok: false, error: "task_not_found" });
|
|
103
|
+
} finally {
|
|
104
|
+
await foreignReader.cancel();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("reports a live same-network SSE without claiming runtime consumption", async () => {
|
|
109
|
+
insertTask("diag-live-sse");
|
|
110
|
+
const reader = await openSse(tokenA, networkA);
|
|
111
|
+
try {
|
|
112
|
+
const result = await detail(tokenA, "diag-live-sse");
|
|
113
|
+
expect(result.body.diagnostic).toMatchObject({
|
|
114
|
+
code: "delivered_waiting_for_agent",
|
|
115
|
+
evidence: {
|
|
116
|
+
target_session_status: "idle",
|
|
117
|
+
live_sse_connections: 1,
|
|
118
|
+
runtime_submitted: false,
|
|
119
|
+
runtime_consumed: false,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
await reader.cancel();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("uses authoritative runtime evidence before current connectivity", async () => {
|
|
128
|
+
insertTask("diag-submitted", { runtimeSubmittedAt: "2026-08-10T00:00:00Z" });
|
|
129
|
+
insertTask("diag-consumed", {
|
|
130
|
+
runtimeSubmittedAt: "2026-08-10T00:00:00Z",
|
|
131
|
+
consumedAt: "2026-08-10T00:00:01Z",
|
|
132
|
+
});
|
|
133
|
+
db.run("UPDATE sessions SET status = 'offline' WHERE node_id = 'node-diagnostic-a' AND network_id = ?1", [networkA]);
|
|
134
|
+
expect((await detail(tokenA, "diag-submitted")).body.diagnostic.code).toBe("runtime_submitted_waiting_for_signal");
|
|
135
|
+
expect((await detail(tokenA, "diag-consumed")).body.diagnostic.code).toBe("runtime_consumed_nonterminal");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("distinguishes offline and missing target sessions", async () => {
|
|
139
|
+
insertTask("diag-offline");
|
|
140
|
+
expect((await detail(tokenA, "diag-offline")).body.diagnostic.code).toBe("target_session_offline");
|
|
141
|
+
db.run("DELETE FROM sessions WHERE node_id = 'node-diagnostic-a' AND network_id = ?1", [networkA]);
|
|
142
|
+
insertTask("diag-missing");
|
|
143
|
+
expect((await detail(tokenA, "diag-missing")).body.diagnostic.code).toBe("target_session_missing");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { diagnoseTask, type TaskDiagnosticInput } from "./task-diagnostic";
|
|
3
|
+
|
|
4
|
+
const base = (patch: Partial<TaskDiagnosticInput> = {}): TaskDiagnosticInput => ({
|
|
5
|
+
status: "delivered",
|
|
6
|
+
runtimeSubmittedAt: null,
|
|
7
|
+
consumedAt: null,
|
|
8
|
+
targetSessionStatus: "idle",
|
|
9
|
+
targetSessionExists: true,
|
|
10
|
+
liveSseConnections: 1,
|
|
11
|
+
...patch,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("#166 evidence-only task diagnostics", () => {
|
|
15
|
+
test("terminal state wins over stale transport and runtime evidence", () => {
|
|
16
|
+
expect(diagnoseTask(base({
|
|
17
|
+
status: "replied",
|
|
18
|
+
runtimeSubmittedAt: "2026-08-10T00:00:00Z",
|
|
19
|
+
consumedAt: "2026-08-10T00:00:01Z",
|
|
20
|
+
targetSessionExists: false,
|
|
21
|
+
targetSessionStatus: null,
|
|
22
|
+
liveSseConnections: 0,
|
|
23
|
+
}))).toMatchObject({ code: "terminal", action_hint: "none" });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("runtime evidence outranks current target connectivity", () => {
|
|
27
|
+
expect(diagnoseTask(base({ consumedAt: "2026-08-10T00:00:01Z", liveSseConnections: 0 }))).toMatchObject({
|
|
28
|
+
code: "runtime_consumed_nonterminal",
|
|
29
|
+
action_hint: "inspect_runtime",
|
|
30
|
+
});
|
|
31
|
+
expect(diagnoseTask(base({ runtimeSubmittedAt: "2026-08-10T00:00:00Z", targetSessionStatus: "offline" }))).toMatchObject({
|
|
32
|
+
code: "runtime_submitted_waiting_for_signal",
|
|
33
|
+
action_hint: "inspect_runtime",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("distinguishes missing, offline, and no-SSE targets", () => {
|
|
38
|
+
expect(diagnoseTask(base({ targetSessionExists: false, targetSessionStatus: null }))).toMatchObject({
|
|
39
|
+
code: "target_session_missing",
|
|
40
|
+
action_hint: "check_target_registration",
|
|
41
|
+
});
|
|
42
|
+
expect(diagnoseTask(base({ targetSessionStatus: "offline" }))).toMatchObject({
|
|
43
|
+
code: "target_session_offline",
|
|
44
|
+
action_hint: "restore_target",
|
|
45
|
+
});
|
|
46
|
+
expect(diagnoseTask(base({ liveSseConnections: 0 }))).toMatchObject({
|
|
47
|
+
code: "target_no_live_sse",
|
|
48
|
+
action_hint: "restore_sse",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("does not infer consumption from lifecycle status alone", () => {
|
|
53
|
+
const diagnostic = diagnoseTask(base({ status: "running" }));
|
|
54
|
+
expect(diagnostic).toMatchObject({
|
|
55
|
+
code: "lifecycle_progress_without_runtime_evidence",
|
|
56
|
+
action_hint: "inspect_runtime",
|
|
57
|
+
evidence: { runtime_submitted: false, runtime_consumed: false },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reports delivered waiting only when registration and SSE are present", () => {
|
|
62
|
+
expect(diagnoseTask(base())).toEqual({
|
|
63
|
+
schema_version: 1,
|
|
64
|
+
code: "delivered_waiting_for_agent",
|
|
65
|
+
action_hint: "inspect_agent_queue",
|
|
66
|
+
evidence: {
|
|
67
|
+
task_status: "delivered",
|
|
68
|
+
target_session_status: "idle",
|
|
69
|
+
live_sse_connections: 1,
|
|
70
|
+
runtime_submitted: false,
|
|
71
|
+
runtime_consumed: false,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("normalizes invalid SSE counts without widening the evidence", () => {
|
|
77
|
+
expect(diagnoseTask(base({ liveSseConnections: Number.NaN }))).toMatchObject({
|
|
78
|
+
code: "target_no_live_sse",
|
|
79
|
+
evidence: { live_sse_connections: 0 },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const TERMINAL_STATUSES = new Set(["replied", "failed", "cancelled", "expired"]);
|
|
2
|
+
|
|
3
|
+
export type TaskDiagnosticCode =
|
|
4
|
+
| "terminal"
|
|
5
|
+
| "runtime_consumed_nonterminal"
|
|
6
|
+
| "runtime_submitted_waiting_for_signal"
|
|
7
|
+
| "target_session_missing"
|
|
8
|
+
| "target_session_offline"
|
|
9
|
+
| "target_no_live_sse"
|
|
10
|
+
| "lifecycle_progress_without_runtime_evidence"
|
|
11
|
+
| "delivered_waiting_for_agent";
|
|
12
|
+
|
|
13
|
+
export type TaskDiagnosticAction =
|
|
14
|
+
| "none"
|
|
15
|
+
| "inspect_runtime"
|
|
16
|
+
| "check_target_registration"
|
|
17
|
+
| "restore_target"
|
|
18
|
+
| "restore_sse"
|
|
19
|
+
| "inspect_agent_queue";
|
|
20
|
+
|
|
21
|
+
export interface TaskDiagnosticInput {
|
|
22
|
+
status: string;
|
|
23
|
+
runtimeSubmittedAt: string | null;
|
|
24
|
+
consumedAt: string | null;
|
|
25
|
+
targetSessionStatus: string | null;
|
|
26
|
+
targetSessionExists: boolean;
|
|
27
|
+
liveSseConnections: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TaskDiagnostic {
|
|
31
|
+
schema_version: 1;
|
|
32
|
+
code: TaskDiagnosticCode;
|
|
33
|
+
action_hint: TaskDiagnosticAction;
|
|
34
|
+
evidence: {
|
|
35
|
+
task_status: string;
|
|
36
|
+
target_session_status: string | null;
|
|
37
|
+
live_sse_connections: number;
|
|
38
|
+
runtime_submitted: boolean;
|
|
39
|
+
runtime_consumed: boolean;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Classify only facts the Hub can prove. This deliberately does not claim
|
|
45
|
+
* that a model session has (or lacks) MCP tools: that capability lives in an
|
|
46
|
+
* external runtime and is not observable from the Hub wire.
|
|
47
|
+
*/
|
|
48
|
+
export function diagnoseTask(input: TaskDiagnosticInput): TaskDiagnostic {
|
|
49
|
+
const liveSseConnections = Number.isSafeInteger(input.liveSseConnections) && input.liveSseConnections > 0
|
|
50
|
+
? input.liveSseConnections
|
|
51
|
+
: 0;
|
|
52
|
+
const evidence: TaskDiagnostic["evidence"] = {
|
|
53
|
+
task_status: input.status,
|
|
54
|
+
target_session_status: input.targetSessionStatus,
|
|
55
|
+
live_sse_connections: liveSseConnections,
|
|
56
|
+
runtime_submitted: Boolean(input.runtimeSubmittedAt),
|
|
57
|
+
runtime_consumed: Boolean(input.consumedAt),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let code: TaskDiagnosticCode;
|
|
61
|
+
let action_hint: TaskDiagnosticAction;
|
|
62
|
+
if (TERMINAL_STATUSES.has(input.status)) {
|
|
63
|
+
code = "terminal";
|
|
64
|
+
action_hint = "none";
|
|
65
|
+
} else if (input.consumedAt) {
|
|
66
|
+
code = "runtime_consumed_nonterminal";
|
|
67
|
+
action_hint = "inspect_runtime";
|
|
68
|
+
} else if (input.runtimeSubmittedAt) {
|
|
69
|
+
code = "runtime_submitted_waiting_for_signal";
|
|
70
|
+
action_hint = "inspect_runtime";
|
|
71
|
+
} else if (!input.targetSessionExists) {
|
|
72
|
+
code = "target_session_missing";
|
|
73
|
+
action_hint = "check_target_registration";
|
|
74
|
+
} else if (input.targetSessionStatus === "offline") {
|
|
75
|
+
code = "target_session_offline";
|
|
76
|
+
action_hint = "restore_target";
|
|
77
|
+
} else if (liveSseConnections === 0) {
|
|
78
|
+
code = "target_no_live_sse";
|
|
79
|
+
action_hint = "restore_sse";
|
|
80
|
+
} else if (input.status === "acked" || input.status === "running") {
|
|
81
|
+
code = "lifecycle_progress_without_runtime_evidence";
|
|
82
|
+
action_hint = "inspect_runtime";
|
|
83
|
+
} else {
|
|
84
|
+
code = "delivered_waiting_for_agent";
|
|
85
|
+
action_hint = "inspect_agent_queue";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { schema_version: 1, code, action_hint, evidence };
|
|
89
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
import { sharedSendDedup } from "./send_dedup.js";
|
|
6
|
+
|
|
7
|
+
const NET = "net_chat_idem";
|
|
8
|
+
const USER = "user_chat_idem";
|
|
9
|
+
const TARGET = "node_chat_idem";
|
|
10
|
+
|
|
11
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
12
|
+
|
|
13
|
+
function cleanup() {
|
|
14
|
+
for (const table of ["tasks", "inbox", "task_events"]) {
|
|
15
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
16
|
+
}
|
|
17
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
18
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
19
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
20
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
21
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
22
|
+
sharedSendDedup.clear();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function seed() {
|
|
26
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [USER]);
|
|
27
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
|
|
28
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
|
|
29
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, 'host', datetime('now'), datetime('now'), 'active')", [`id_${TARGET}`, TARGET, NET]);
|
|
30
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))", [`resume_${TARGET}`, TARGET, `id_${TARGET}`, NET]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sendTaskHandler(auth?: { callerAlias: string; nodeToken: boolean }): ToolHandler {
|
|
34
|
+
const server = new McpServer({ name: "idempotency-test", version: "0" }) as any;
|
|
35
|
+
let handler: ToolHandler | undefined;
|
|
36
|
+
const original = server.tool.bind(server);
|
|
37
|
+
server.tool = (name: string, description: string, schema: any, candidate: ToolHandler) => {
|
|
38
|
+
if (name === "send_task") handler = candidate;
|
|
39
|
+
return original(name, description, schema, candidate);
|
|
40
|
+
};
|
|
41
|
+
registerTools(
|
|
42
|
+
server,
|
|
43
|
+
undefined,
|
|
44
|
+
NET,
|
|
45
|
+
USER,
|
|
46
|
+
auth?.callerAlias ?? USER,
|
|
47
|
+
auth?.nodeToken ?? false,
|
|
48
|
+
auth?.nodeToken ? "token_node_chat_idem" : null,
|
|
49
|
+
);
|
|
50
|
+
if (!handler) throw new Error("send_task handler missing");
|
|
51
|
+
return handler;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function call(handler: ToolHandler, args: any) {
|
|
55
|
+
const result = await handler(args);
|
|
56
|
+
return JSON.parse(result.content[0].text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
60
|
+
afterAll(cleanup);
|
|
61
|
+
|
|
62
|
+
describe("send_task durable idempotency", () => {
|
|
63
|
+
test("Hub stamps authenticated user origin and overrides client spoofing", async () => {
|
|
64
|
+
const handler = sendTaskHandler();
|
|
65
|
+
const sent = await call(handler, {
|
|
66
|
+
alias: TARGET,
|
|
67
|
+
task: "authenticated dashboard payload",
|
|
68
|
+
priority: "normal",
|
|
69
|
+
meta: {
|
|
70
|
+
source: "dashboard-chat",
|
|
71
|
+
client_request_id: "dreq_0123456789abcdef0123456789abcdef",
|
|
72
|
+
auth_origin: "node",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
expect(sent.ok).toBe(true);
|
|
76
|
+
const task = db.get<{ meta_json: string }>("SELECT meta_json FROM tasks WHERE task_id = ?1", [sent.message_id]);
|
|
77
|
+
const inbox = db.get<{ meta_json: string }>("SELECT meta_json FROM inbox WHERE id = ?1", [sent.message_id]);
|
|
78
|
+
expect(JSON.parse(task!.meta_json).auth_origin).toBe("user");
|
|
79
|
+
expect(JSON.parse(inbox!.meta_json).auth_origin).toBe("user");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("network-token caller cannot forge Dashboard user origin through MCP", async () => {
|
|
83
|
+
const handler = sendTaskHandler({ callerAlias: "node-origin-sender", nodeToken: true });
|
|
84
|
+
const sent = await call(handler, {
|
|
85
|
+
alias: TARGET,
|
|
86
|
+
task: "node attempts user-origin spoof",
|
|
87
|
+
priority: "normal",
|
|
88
|
+
meta: {
|
|
89
|
+
source: "dashboard-chat",
|
|
90
|
+
client_request_id: "dreq_fedcba9876543210fedcba9876543210",
|
|
91
|
+
auth_origin: "user",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
expect(sent.ok).toBe(true);
|
|
95
|
+
const inbox = db.get<{ meta_json: string }>("SELECT meta_json FROM inbox WHERE id = ?1", [sent.message_id]);
|
|
96
|
+
expect(JSON.parse(inbox!.meta_json).auth_origin).toBe("node");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("lost-response retry returns the original task and does not enqueue twice", async () => {
|
|
100
|
+
const handler = sendTaskHandler();
|
|
101
|
+
const args = {
|
|
102
|
+
alias: TARGET,
|
|
103
|
+
task: "chat idempotent payload",
|
|
104
|
+
priority: "normal",
|
|
105
|
+
meta: { client_request_id: "dreq_0123456789abcdef" },
|
|
106
|
+
};
|
|
107
|
+
const first = await call(handler, args);
|
|
108
|
+
const replay = await call(handler, args);
|
|
109
|
+
|
|
110
|
+
expect(first.ok).toBe(true);
|
|
111
|
+
expect(replay).toEqual({
|
|
112
|
+
ok: true,
|
|
113
|
+
message_id: first.message_id,
|
|
114
|
+
task_id: first.message_id,
|
|
115
|
+
task_status: "delivered",
|
|
116
|
+
idempotent_replay: true,
|
|
117
|
+
});
|
|
118
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
119
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("request-id reuse with changed content fails closed without a second row", async () => {
|
|
123
|
+
const handler = sendTaskHandler();
|
|
124
|
+
const base = {
|
|
125
|
+
alias: TARGET,
|
|
126
|
+
priority: "normal",
|
|
127
|
+
meta: { client_request_id: "dreq_0123456789abcdef" },
|
|
128
|
+
};
|
|
129
|
+
expect((await call(handler, { ...base, task: "first payload" })).ok).toBe(true);
|
|
130
|
+
expect(await call(handler, { ...base, task: "changed payload" })).toEqual({
|
|
131
|
+
ok: false,
|
|
132
|
+
error: "idempotency_conflict",
|
|
133
|
+
message: "client_request_id was already used with a different task payload",
|
|
134
|
+
});
|
|
135
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
clientRequestIdFromMeta,
|
|
4
|
+
idempotentTaskId,
|
|
5
|
+
idempotentTaskMatches,
|
|
6
|
+
type StoredIdempotentTask,
|
|
7
|
+
} from "./task-idempotency.js";
|
|
8
|
+
|
|
9
|
+
describe("dashboard task idempotency", () => {
|
|
10
|
+
test("accepts only bounded dashboard request ids", () => {
|
|
11
|
+
expect(clientRequestIdFromMeta({ client_request_id: "dreq_0123456789abcdef" })).toBe("dreq_0123456789abcdef");
|
|
12
|
+
expect(clientRequestIdFromMeta({ client_request_id: "short" })).toBeNull();
|
|
13
|
+
expect(clientRequestIdFromMeta({ client_request_id: "dreq_bad space 0123456789" })).toBeNull();
|
|
14
|
+
expect(clientRequestIdFromMeta(null)).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("same caller scope and request id derive the same task id", () => {
|
|
18
|
+
const a = idempotentTaskId("net_a", "admin", "dreq_0123456789abcdef");
|
|
19
|
+
expect(idempotentTaskId("net_a", "admin", "dreq_0123456789abcdef")).toBe(a);
|
|
20
|
+
expect(idempotentTaskId("net_b", "admin", "dreq_0123456789abcdef")).not.toBe(a);
|
|
21
|
+
expect(idempotentTaskId("net_a", "other", "dreq_0123456789abcdef")).not.toBe(a);
|
|
22
|
+
expect(a).toMatch(/^idem_[0-9a-f]{40}$/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("replay accepts semantically identical metadata regardless of key order", () => {
|
|
26
|
+
const row: StoredIdempotentTask = {
|
|
27
|
+
task_id: "idem_x", from_name: "admin", to_name: "worker", priority: "normal",
|
|
28
|
+
content: "hello", network_id: "net_a", status: "delivered",
|
|
29
|
+
meta_json: JSON.stringify({ client_request_id: "dreq_0123456789abcdef", attachments: [{ name: "a", file_id: "f1" }] }),
|
|
30
|
+
};
|
|
31
|
+
expect(idempotentTaskMatches(row, {
|
|
32
|
+
fromName: "admin", toName: "worker", priority: "normal", content: "hello", networkId: "net_a",
|
|
33
|
+
metaJson: JSON.stringify({ attachments: [{ file_id: "f1", name: "a" }], client_request_id: "dreq_0123456789abcdef" }),
|
|
34
|
+
})).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("rolling upgrade ignores the new server-only auth stamp for replay equality", () => {
|
|
38
|
+
const row: StoredIdempotentTask = {
|
|
39
|
+
task_id: "idem_x", from_name: "admin", to_name: "worker", priority: "normal",
|
|
40
|
+
content: "hello", network_id: "net_a", status: "delivered",
|
|
41
|
+
meta_json: JSON.stringify({ client_request_id: "dreq_0123456789abcdef", source: "dashboard-chat" }),
|
|
42
|
+
};
|
|
43
|
+
expect(idempotentTaskMatches(row, {
|
|
44
|
+
fromName: "admin", toName: "worker", priority: "normal", content: "hello", networkId: "net_a",
|
|
45
|
+
metaJson: JSON.stringify({ client_request_id: "dreq_0123456789abcdef", source: "dashboard-chat", auth_origin: "user" }),
|
|
46
|
+
})).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("same request id with changed payload is a conflict", () => {
|
|
50
|
+
const row: StoredIdempotentTask = {
|
|
51
|
+
task_id: "idem_x", from_name: "admin", to_name: "worker", priority: "normal",
|
|
52
|
+
content: "hello", network_id: "net_a", status: "delivered",
|
|
53
|
+
meta_json: JSON.stringify({ client_request_id: "dreq_0123456789abcdef" }),
|
|
54
|
+
};
|
|
55
|
+
expect(idempotentTaskMatches(row, {
|
|
56
|
+
fromName: "admin", toName: "worker", priority: "normal", content: "changed", networkId: "net_a",
|
|
57
|
+
metaJson: row.meta_json,
|
|
58
|
+
})).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
|
|
3
|
+
const CLIENT_REQUEST_ID_RE = /^dreq_[A-Za-z0-9_-]{16,96}$/;
|
|
4
|
+
|
|
5
|
+
export interface StoredIdempotentTask {
|
|
6
|
+
task_id: string;
|
|
7
|
+
from_name: string;
|
|
8
|
+
to_name: string;
|
|
9
|
+
priority: string;
|
|
10
|
+
content: string;
|
|
11
|
+
network_id: string | null;
|
|
12
|
+
meta_json: string | null;
|
|
13
|
+
status: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExpectedIdempotentTask {
|
|
17
|
+
fromName: string;
|
|
18
|
+
toName: string;
|
|
19
|
+
priority: string;
|
|
20
|
+
content: string;
|
|
21
|
+
networkId: string | null;
|
|
22
|
+
metaJson: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clientRequestIdFromMeta(meta: unknown): string | null {
|
|
26
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
|
|
27
|
+
const value = (meta as Record<string, unknown>).client_request_id;
|
|
28
|
+
return typeof value === "string" && CLIENT_REQUEST_ID_RE.test(value) ? value : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function idempotentTaskId(networkId: string | null, fromName: string, requestId: string): string {
|
|
32
|
+
const digest = createHash("sha256")
|
|
33
|
+
.update(networkId ?? "<legacy-global>")
|
|
34
|
+
.update("\0")
|
|
35
|
+
.update(fromName)
|
|
36
|
+
.update("\0")
|
|
37
|
+
.update(requestId)
|
|
38
|
+
.digest("hex");
|
|
39
|
+
return `idem_${digest.slice(0, 40)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function canonicalJson(value: unknown): string {
|
|
43
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
44
|
+
if (value && typeof value === "object") {
|
|
45
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
46
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
47
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
|
|
48
|
+
.join(",")}}`;
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify(value) ?? "null";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parsedMeta(value: string | null): unknown {
|
|
54
|
+
if (!value) return null;
|
|
55
|
+
try { return JSON.parse(value); } catch { return value; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function logicalRequestMeta(value: string | null): unknown {
|
|
59
|
+
const parsed = parsedMeta(value);
|
|
60
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
|
61
|
+
// auth_origin is a Hub-derived audit fact, not part of the client request.
|
|
62
|
+
// Ignore it for replay equality so a request first accepted by the old Hub
|
|
63
|
+
// (without the stamp) remains idempotent across a rolling upgrade.
|
|
64
|
+
const { auth_origin: _authOrigin, ...logical } = parsed as Record<string, unknown>;
|
|
65
|
+
return logical;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function idempotentTaskMatches(row: StoredIdempotentTask, expected: ExpectedIdempotentTask): boolean {
|
|
69
|
+
return row.from_name === expected.fromName
|
|
70
|
+
&& row.to_name === expected.toName
|
|
71
|
+
&& row.priority === expected.priority
|
|
72
|
+
&& row.content === expected.content
|
|
73
|
+
&& (row.network_id ?? null) === (expected.networkId ?? null)
|
|
74
|
+
&& canonicalJson(logicalRequestMeta(row.meta_json)) === canonicalJson(logicalRequestMeta(expected.metaJson));
|
|
75
|
+
}
|