@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- 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-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +622 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message.test.ts +286 -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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -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/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/push.ts +163 -7
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -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 +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -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 +437 -0
- package/src/server.ts +3591 -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/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -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 +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -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 +471 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -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 +200 -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 +2952 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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,115 @@
|
|
|
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
|
+
// 🔴 #1083 新增。这里保持 toEqual(整形断言)而**不**退化成 toMatchObject ——
|
|
73
|
+
// 隔壁那条测试的名字就是 "without widening the evidence",
|
|
74
|
+
// 这个严格性是故意的:它防的正是「有人往 evidence 里悄悄加字段」。
|
|
75
|
+
// 加宽了就把新字段写进来,不是把断言放松。
|
|
76
|
+
target_ever_reported_runtime_evidence: null,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 🔴 #1083:【时序】与【能力】必须落在不同的 code 上。
|
|
82
|
+
// 实测背景:一个**确实回了长信**的任务与一个**毫无回音**的任务,
|
|
83
|
+
// 在旧实现下 diagnostic 逐字相同(都是 lifecycle_progress_without_runtime_evidence
|
|
84
|
+
// + inspect_runtime)⇒ 鉴别力 = 0。
|
|
85
|
+
test("a target that never reported runtime evidence gets its own code, not inspect_runtime", () => {
|
|
86
|
+
const d = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: false }));
|
|
87
|
+
expect(d.code).toBe("runtime_evidence_channel_absent");
|
|
88
|
+
expect(d.action_hint).toBe("wire_runtime_evidence");
|
|
89
|
+
expect(d.evidence.target_ever_reported_runtime_evidence).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a target that HAS reported evidence before keeps the timing-flavoured code", () => {
|
|
93
|
+
const d = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: true }));
|
|
94
|
+
expect(d.code).toBe("lifecycle_progress_without_runtime_evidence");
|
|
95
|
+
expect(d.action_hint).toBe("inspect_runtime");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// 「Hub 没算这一项」不等于「没报过」—— 不知道就不改判。
|
|
99
|
+
test("an unknown capability (null / omitted) does not change the old verdict", () => {
|
|
100
|
+
const omitted = diagnoseTask(base({ status: "acked" }));
|
|
101
|
+
const explicitNull = diagnoseTask(base({ status: "acked", targetEverReportedRuntimeEvidence: null }));
|
|
102
|
+
for (const d of [omitted, explicitNull]) {
|
|
103
|
+
expect(d.code).toBe("lifecycle_progress_without_runtime_evidence");
|
|
104
|
+
expect(d.action_hint).toBe("inspect_runtime");
|
|
105
|
+
expect(d.evidence.target_ever_reported_runtime_evidence).toBeNull();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("normalizes invalid SSE counts without widening the evidence", () => {
|
|
110
|
+
expect(diagnoseTask(base({ liveSseConnections: Number.NaN }))).toMatchObject({
|
|
111
|
+
code: "target_no_live_sse",
|
|
112
|
+
evidence: { live_sse_connections: 0 },
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
// 🔴 #1083:与上一条的区别是【时序】vs【能力】。上一条说的是「还没走到那一步」,
|
|
12
|
+
// 这一条说的是「这个目标从来没有报过任何一次运行时证据」——它不会走到。
|
|
13
|
+
// 两者处置相反:前者【等】,后者【别等,去接线】。
|
|
14
|
+
| "runtime_evidence_channel_absent"
|
|
15
|
+
| "delivered_waiting_for_agent";
|
|
16
|
+
|
|
17
|
+
export type TaskDiagnosticAction =
|
|
18
|
+
| "none"
|
|
19
|
+
| "inspect_runtime"
|
|
20
|
+
| "check_target_registration"
|
|
21
|
+
| "restore_target"
|
|
22
|
+
| "restore_sse"
|
|
23
|
+
| "inspect_agent_queue"
|
|
24
|
+
| "wire_runtime_evidence";
|
|
25
|
+
|
|
26
|
+
export interface TaskDiagnosticInput {
|
|
27
|
+
status: string;
|
|
28
|
+
runtimeSubmittedAt: string | null;
|
|
29
|
+
consumedAt: string | null;
|
|
30
|
+
targetSessionStatus: string | null;
|
|
31
|
+
targetSessionExists: boolean;
|
|
32
|
+
liveSseConnections: number;
|
|
33
|
+
/**
|
|
34
|
+
* 🔴 #1083:这个目标节点【历史上是否报过任何一次】运行时证据。
|
|
35
|
+
* `false` = 从来没报过 ⇒ `runtime_submitted_at`/`consumed_at` 为空**推不出**「没消费」,
|
|
36
|
+
* 它只说明这条证据通道不存在。
|
|
37
|
+
* `null`/省略 = Hub 没算这一项 ⇒ 保持旧行为(不因为「不知道」就改判)。
|
|
38
|
+
*
|
|
39
|
+
* 背景:RFC-035 §75 要求 agent-node 接线上报,但 agent 侧至今没有实现
|
|
40
|
+
* (`grep -rn runtime_evidence agent-node/src/ agent-network/src/` = 0 命中),
|
|
41
|
+
* 于是这两列对所有任务恒为 NULL。实测后果:一个**确实回了长信**的任务
|
|
42
|
+
* 与一个**毫无回音**的任务,diagnostic 读数逐字相同 ⇒ 鉴别力 = 0。
|
|
43
|
+
*/
|
|
44
|
+
targetEverReportedRuntimeEvidence?: boolean | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TaskDiagnostic {
|
|
48
|
+
schema_version: 1;
|
|
49
|
+
code: TaskDiagnosticCode;
|
|
50
|
+
action_hint: TaskDiagnosticAction;
|
|
51
|
+
evidence: {
|
|
52
|
+
task_status: string;
|
|
53
|
+
target_session_status: string | null;
|
|
54
|
+
live_sse_connections: number;
|
|
55
|
+
runtime_submitted: boolean;
|
|
56
|
+
runtime_consumed: boolean;
|
|
57
|
+
/** `null` = Hub 没算这一项;`false` = 该目标从来没报过运行时证据。 */
|
|
58
|
+
target_ever_reported_runtime_evidence: boolean | null;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify only facts the Hub can prove. This deliberately does not claim
|
|
64
|
+
* that a model session has (or lacks) MCP tools: that capability lives in an
|
|
65
|
+
* external runtime and is not observable from the Hub wire.
|
|
66
|
+
*/
|
|
67
|
+
export function diagnoseTask(input: TaskDiagnosticInput): TaskDiagnostic {
|
|
68
|
+
const liveSseConnections = Number.isSafeInteger(input.liveSseConnections) && input.liveSseConnections > 0
|
|
69
|
+
? input.liveSseConnections
|
|
70
|
+
: 0;
|
|
71
|
+
const evidence: TaskDiagnostic["evidence"] = {
|
|
72
|
+
task_status: input.status,
|
|
73
|
+
target_session_status: input.targetSessionStatus,
|
|
74
|
+
live_sse_connections: liveSseConnections,
|
|
75
|
+
runtime_submitted: Boolean(input.runtimeSubmittedAt),
|
|
76
|
+
runtime_consumed: Boolean(input.consumedAt),
|
|
77
|
+
target_ever_reported_runtime_evidence:
|
|
78
|
+
input.targetEverReportedRuntimeEvidence ?? null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let code: TaskDiagnosticCode;
|
|
82
|
+
let action_hint: TaskDiagnosticAction;
|
|
83
|
+
if (TERMINAL_STATUSES.has(input.status)) {
|
|
84
|
+
code = "terminal";
|
|
85
|
+
action_hint = "none";
|
|
86
|
+
} else if (input.consumedAt) {
|
|
87
|
+
code = "runtime_consumed_nonterminal";
|
|
88
|
+
action_hint = "inspect_runtime";
|
|
89
|
+
} else if (input.runtimeSubmittedAt) {
|
|
90
|
+
code = "runtime_submitted_waiting_for_signal";
|
|
91
|
+
action_hint = "inspect_runtime";
|
|
92
|
+
} else if (!input.targetSessionExists) {
|
|
93
|
+
code = "target_session_missing";
|
|
94
|
+
action_hint = "check_target_registration";
|
|
95
|
+
} else if (input.targetSessionStatus === "offline") {
|
|
96
|
+
code = "target_session_offline";
|
|
97
|
+
action_hint = "restore_target";
|
|
98
|
+
} else if (liveSseConnections === 0) {
|
|
99
|
+
code = "target_no_live_sse";
|
|
100
|
+
action_hint = "restore_sse";
|
|
101
|
+
} else if (input.status === "acked" || input.status === "running") {
|
|
102
|
+
// 🔴 #1083:这里原本只有一档,于是【还没走到】和【不会走到】共用同一个 code
|
|
103
|
+
// 和同一个 action_hint(`inspect_runtime`)。后者被支去查一个没坏的东西。
|
|
104
|
+
// 只有在 Hub **确知**该目标从未报过证据时才改判;`null`(没算) 保持旧行为。
|
|
105
|
+
if (input.targetEverReportedRuntimeEvidence === false) {
|
|
106
|
+
code = "runtime_evidence_channel_absent";
|
|
107
|
+
action_hint = "wire_runtime_evidence";
|
|
108
|
+
} else {
|
|
109
|
+
code = "lifecycle_progress_without_runtime_evidence";
|
|
110
|
+
action_hint = "inspect_runtime";
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
code = "delivered_waiting_for_agent";
|
|
114
|
+
action_hint = "inspect_agent_queue";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { schema_version: 1, code, action_hint, evidence };
|
|
118
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
actual_to: {
|
|
118
|
+
alias: TARGET,
|
|
119
|
+
to_node_id: "id_node_chat_idem",
|
|
120
|
+
network_id: NET,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
124
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("request-id reuse with changed content fails closed without a second row", async () => {
|
|
128
|
+
const handler = sendTaskHandler();
|
|
129
|
+
const base = {
|
|
130
|
+
alias: TARGET,
|
|
131
|
+
priority: "normal",
|
|
132
|
+
meta: { client_request_id: "dreq_0123456789abcdef" },
|
|
133
|
+
};
|
|
134
|
+
expect((await call(handler, { ...base, task: "first payload" })).ok).toBe(true);
|
|
135
|
+
expect(await call(handler, { ...base, task: "changed payload" })).toEqual({
|
|
136
|
+
ok: false,
|
|
137
|
+
error: "idempotency_conflict",
|
|
138
|
+
message: "client_request_id was already used with a different task payload",
|
|
139
|
+
});
|
|
140
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM tasks WHERE network_id = ?1", [NET])?.count).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -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
|
+
}
|