@sleep2agi/commhub-server 0.9.0-preview.60 → 0.9.0-preview.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/create-node-validate.ts +34 -0
- package/src/create-node-workdir.test.ts +254 -0
- package/src/db.ts +35 -0
- package/src/inbox-count-sse.test.ts +1 -1
- package/src/requirements-http.test.ts +196 -0
- package/src/requirements.ts +256 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/server.ts +83 -5
- package/src/stop-delete-node.test.ts +212 -0
- package/src/tools.ts +87 -10
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
|
+
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { addNetworkScope, canRestWriteNetwork, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
5
|
+
|
|
6
|
+
type RequestAuth = { userId: string; networkId: string | null; username: string } | null;
|
|
7
|
+
|
|
8
|
+
export type RequirementsRequestContext = {
|
|
9
|
+
req: Request;
|
|
10
|
+
url: URL;
|
|
11
|
+
auth: RequestAuth;
|
|
12
|
+
isAdmin: boolean;
|
|
13
|
+
isNodeToken: boolean;
|
|
14
|
+
scope: RestNetworkScope;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const COLUMNS = new Set(["pool", "doing", "done"]);
|
|
18
|
+
const PRIORITIES = new Set(["high", "normal", "low"]);
|
|
19
|
+
const DUE = /^\d{4}-\d{2}-\d{2}$/;
|
|
20
|
+
|
|
21
|
+
type Row = {
|
|
22
|
+
owner_json: string | null;
|
|
23
|
+
participants_json: string;
|
|
24
|
+
requirement_id: string;
|
|
25
|
+
network_id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
column_name: string;
|
|
28
|
+
priority: string;
|
|
29
|
+
due_on: string | null;
|
|
30
|
+
assignee: string | null;
|
|
31
|
+
issues_json: string | null;
|
|
32
|
+
created_at: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function jsonError(error: string, status: number): Response {
|
|
36
|
+
return Response.json({ ok: false, error }, { status });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dueOk(due: string): boolean {
|
|
40
|
+
if (!DUE.test(due)) return false;
|
|
41
|
+
const [y, m, d] = due.split("-").map(Number);
|
|
42
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
43
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
47
|
+
const ISSUE_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)\/?$/;
|
|
48
|
+
|
|
49
|
+
function clipTitle(value: unknown): string {
|
|
50
|
+
return typeof value === "string" ? value.replace(/[\r\n\t]/g, " ").trim().slice(0, 120) : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function issueFromParts(repo: string, number: number, title: string) {
|
|
54
|
+
if (!REPO.test(repo) || !Number.isInteger(number) || number < 1 || number > 10_000_000) return null;
|
|
55
|
+
return { repo, number, title: clipTitle(title) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function oneIssue(item: unknown) {
|
|
59
|
+
if (typeof item === "string") {
|
|
60
|
+
const m = ISSUE_URL.exec(item.trim());
|
|
61
|
+
return m ? issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), "") : null;
|
|
62
|
+
}
|
|
63
|
+
if (!item || typeof item !== "object") return null;
|
|
64
|
+
const row = item as Record<string, unknown>;
|
|
65
|
+
if (typeof row.url === "string") {
|
|
66
|
+
const m = ISSUE_URL.exec(row.url.trim());
|
|
67
|
+
if (m) return issueFromParts(`${m[1]}/${m[2]}`, Number(m[3]), clipTitle(row.title));
|
|
68
|
+
}
|
|
69
|
+
const repo = typeof row.repo === "string" ? row.repo.trim() : "";
|
|
70
|
+
const number = typeof row.number === "number" ? row.number : Number(row.number);
|
|
71
|
+
return issueFromParts(repo, number, clipTitle(row.title));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeIssues(raw: unknown) {
|
|
75
|
+
if (!Array.isArray(raw) || raw.length > 8) return null;
|
|
76
|
+
const out: { repo: string; number: number; title: string }[] = [];
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
for (const item of raw) {
|
|
79
|
+
const issue = oneIssue(item);
|
|
80
|
+
if (!issue) return null;
|
|
81
|
+
const key = `${issue.repo}#${issue.number}`;
|
|
82
|
+
if (seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
out.push(issue);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function storedIssues(raw: string | null) {
|
|
90
|
+
if (!raw) return [];
|
|
91
|
+
try {
|
|
92
|
+
const parsed = normalizeIssues(JSON.parse(raw));
|
|
93
|
+
return parsed ?? [];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toPublic(row: Row) {
|
|
100
|
+
return {
|
|
101
|
+
owner: row.owner_json ? JSON.parse(row.owner_json) : null,
|
|
102
|
+
participants: JSON.parse(row.participants_json || '[]'),
|
|
103
|
+
id: row.requirement_id,
|
|
104
|
+
name: row.title,
|
|
105
|
+
priority: row.priority,
|
|
106
|
+
assignee: row.assignee || "",
|
|
107
|
+
due: row.due_on || "",
|
|
108
|
+
column: row.column_name,
|
|
109
|
+
createdAt: row.created_at,
|
|
110
|
+
issues: storedIssues(row.issues_json),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function bodyObject(req: Request): Promise<Record<string, unknown>> {
|
|
115
|
+
const value = await req.json();
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
|
|
117
|
+
return value as Record<string, unknown>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestContext): string | null {
|
|
121
|
+
if (ctx.auth?.networkId) return ctx.auth.networkId;
|
|
122
|
+
if (typeof body.network_id === "string" && body.network_id.trim()) return body.network_id.trim();
|
|
123
|
+
return resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
|
|
127
|
+
return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json";
|
|
131
|
+
|
|
132
|
+
type PersonRef = { kind: 'user' | 'node'; id: string };
|
|
133
|
+
function personRef(value: unknown, networkId: string): PersonRef {
|
|
134
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_person');
|
|
135
|
+
const row = value as Record<string, unknown>;
|
|
136
|
+
if ((row.kind !== 'user' && row.kind !== 'node') || typeof row.id !== 'string' || !row.id.trim()) throw new Error('invalid_person');
|
|
137
|
+
const exists = row.kind === 'user'
|
|
138
|
+
? db.get("SELECT 1 FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 AND m.user_id=?2", networkId, row.id)
|
|
139
|
+
: db.get("SELECT 1 FROM nodes WHERE network_id=?1 AND node_id=?2", networkId, row.id);
|
|
140
|
+
if (!exists) throw new Error('person_not_in_network');
|
|
141
|
+
return { kind: row.kind, id: row.id };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
|
|
145
|
+
const owner = 'owner' in body ? body.owner === null ? null : personRef(body.owner, networkId) : current?.owner_json ? JSON.parse(current.owner_json) : null;
|
|
146
|
+
let participants = current ? JSON.parse(current.participants_json || '[]') : [];
|
|
147
|
+
if ('participants' in body) {
|
|
148
|
+
if (!Array.isArray(body.participants) || body.participants.length > 100) throw new Error('invalid_participants');
|
|
149
|
+
const refs = body.participants.map(value => personRef(value, networkId));
|
|
150
|
+
participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
|
|
151
|
+
}
|
|
152
|
+
return { ownerJson: owner === null ? null : JSON.stringify(owner), participantsJson: JSON.stringify(participants) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
|
|
156
|
+
const { req, url } = ctx;
|
|
157
|
+
if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
|
|
158
|
+
if (ctx.isNodeToken) return jsonError("user_token_required", 403);
|
|
159
|
+
|
|
160
|
+
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
161
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
162
|
+
if (!networkId) return jsonError('network_id_required', 400);
|
|
163
|
+
const users = db.all<{ id: string; name: string }>(
|
|
164
|
+
"SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1 ORDER BY name, id", networkId,
|
|
165
|
+
);
|
|
166
|
+
const nodes = db.all<{ id: string; name: string }>(
|
|
167
|
+
"SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1 ORDER BY name, id", networkId,
|
|
168
|
+
);
|
|
169
|
+
return Response.json({ ok: true, people: [...users.map(row => ({ ...row, kind: 'user', networkId })), ...nodes.map(row => ({ ...row, kind: 'node', networkId }))] });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
173
|
+
const params: unknown[] = [];
|
|
174
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
|
|
175
|
+
sql = addNetworkScope(sql, params, ctx.scope);
|
|
176
|
+
sql += " ORDER BY created_at DESC LIMIT 500";
|
|
177
|
+
const rows = db.all<Row>(sql, ...params).map(toPublic);
|
|
178
|
+
return Response.json({ ok: true, requirements: rows });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
182
|
+
let body: Record<string, unknown>;
|
|
183
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
184
|
+
const networkId = writeNetwork(body, ctx);
|
|
185
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
186
|
+
if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
|
|
187
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
188
|
+
if (!name || name.length > 80) return jsonError("invalid_name", 400);
|
|
189
|
+
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
190
|
+
if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
|
|
191
|
+
const due = typeof body.due === "string" ? body.due.trim() : "";
|
|
192
|
+
if (due && !dueOk(due)) return jsonError("invalid_due", 400);
|
|
193
|
+
const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
|
|
194
|
+
const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
|
|
195
|
+
if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
|
|
196
|
+
const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
|
|
197
|
+
const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
|
|
198
|
+
if (issues === null) return jsonError("invalid_issues", 400);
|
|
199
|
+
if (clientId) {
|
|
200
|
+
const existing = db.get<Row>(
|
|
201
|
+
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
202
|
+
networkId, clientId,
|
|
203
|
+
);
|
|
204
|
+
if (existing) return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
205
|
+
}
|
|
206
|
+
const id = `req_${crypto.randomUUID()}`;
|
|
207
|
+
const createdAt = new Date().toISOString();
|
|
208
|
+
let people;
|
|
209
|
+
try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
210
|
+
try {
|
|
211
|
+
db.run(
|
|
212
|
+
`INSERT INTO requirements
|
|
213
|
+
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json)
|
|
214
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13)`,
|
|
215
|
+
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson],
|
|
216
|
+
);
|
|
217
|
+
} catch {
|
|
218
|
+
if (!clientId) return jsonError("insert_failed", 500);
|
|
219
|
+
const existing = db.get<Row>(
|
|
220
|
+
`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
|
|
221
|
+
networkId, clientId,
|
|
222
|
+
);
|
|
223
|
+
if (!existing) return jsonError("insert_failed", 500);
|
|
224
|
+
return Response.json({ ok: true, requirement: toPublic(existing) });
|
|
225
|
+
}
|
|
226
|
+
const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
|
|
227
|
+
return Response.json({ ok: true, requirement: toPublic(created) }, { status: 201 });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
|
|
231
|
+
if (!match || req.method !== "PATCH") return jsonError("not_found", 404);
|
|
232
|
+
const id = decodeURIComponent(match[1]);
|
|
233
|
+
let body: Record<string, unknown>;
|
|
234
|
+
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
235
|
+
const hasColumn = 'column' in body;
|
|
236
|
+
const hasIssues = Object.prototype.hasOwnProperty.call(body, "issues");
|
|
237
|
+
if (!hasColumn && !hasIssues && !('owner' in body) && !('participants' in body)) return jsonError('empty_patch', 400);
|
|
238
|
+
if (hasColumn && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
|
|
239
|
+
const issues = hasIssues ? normalizeIssues(body.issues) : null;
|
|
240
|
+
if (hasIssues && issues === null) return jsonError("invalid_issues", 400);
|
|
241
|
+
const params: unknown[] = [id];
|
|
242
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
|
|
243
|
+
sql = addNetworkScope(sql, params, ctx.scope);
|
|
244
|
+
const row = db.get<Row>(sql, ...params);
|
|
245
|
+
if (!row) return jsonError("requirement_not_found", 404);
|
|
246
|
+
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
247
|
+
let people;
|
|
248
|
+
try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
249
|
+
const updatedAt = new Date().toISOString();
|
|
250
|
+
db.run(
|
|
251
|
+
"UPDATE requirements SET column_name = ?1, updated_at = ?2, owner_json = ?4, participants_json = ?5, issues_json = ?6 WHERE requirement_id = ?3",
|
|
252
|
+
[hasColumn ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, hasIssues ? JSON.stringify(issues) : row.issues_json],
|
|
253
|
+
);
|
|
254
|
+
const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
255
|
+
return Response.json({ ok: true, requirement: toPublic(updated) });
|
|
256
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Handler-level regression for the #212 dedup window swallowing human chat
|
|
2
|
+
// messages (see send_dedup.ts header, "Key selection").
|
|
3
|
+
//
|
|
4
|
+
// Before the fix, POST /api/task keyed the dedup window on
|
|
5
|
+
// (from, to, sha256(content)) only and ignored meta.client_request_id, so a
|
|
6
|
+
// user sending the same short text twice within 5 minutes got 200/429/429 and
|
|
7
|
+
// ONE stored row — and the app maps that 429 to "delivered". The table below
|
|
8
|
+
// is the repro, run against both transports:
|
|
9
|
+
//
|
|
10
|
+
// case | before (REST) | after
|
|
11
|
+
// ----------------------------------+---------------+-------------------------
|
|
12
|
+
// same text, 3 different ids | 1 row | 3 rows, 3 x ok
|
|
13
|
+
// same text, same id (retry) | 1 row | 1 row, 2 x replay (same id)
|
|
14
|
+
// different text, same id | 3 rows | 1 row, 2 x idempotency_conflict
|
|
15
|
+
// same text, no id (#212 agents) | 1 row | 1 row, 2 x duplicate_send
|
|
16
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
18
|
+
import { join } from "path";
|
|
19
|
+
import { tmpdir } from "os";
|
|
20
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
|
+
import { register } from "./auth.js";
|
|
22
|
+
import { db } from "./db.js";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
import { sharedSendDedup } from "./send_dedup.js";
|
|
25
|
+
|
|
26
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-send-dedup-rid-"));
|
|
27
|
+
const TARGET = `dedup-rid-target-${process.pid}`;
|
|
28
|
+
const TARGET_NODE_ID = `node_dedup_rid_${process.pid}`;
|
|
29
|
+
|
|
30
|
+
let server: any;
|
|
31
|
+
let base = "";
|
|
32
|
+
let userToken = "";
|
|
33
|
+
let restNet = "";
|
|
34
|
+
let restUser = "";
|
|
35
|
+
|
|
36
|
+
const MCP_NET = `net_dedup_rid_${process.pid}`;
|
|
37
|
+
const MCP_USER = `user_dedup_rid_${process.pid}`;
|
|
38
|
+
|
|
39
|
+
function rid(n: number): string {
|
|
40
|
+
// App shape: dreq_ + 32 lowercase hex.
|
|
41
|
+
return `dreq_${n.toString(16).padStart(32, "0")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function seedTarget(networkId: string, suffix: string) {
|
|
45
|
+
db.run(
|
|
46
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
47
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
48
|
+
[`resume_${TARGET_NODE_ID}_${suffix}`, TARGET, `${TARGET_NODE_ID}_${suffix}`, networkId],
|
|
49
|
+
);
|
|
50
|
+
db.run(
|
|
51
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
|
|
52
|
+
VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
|
|
53
|
+
[`${TARGET_NODE_ID}_${suffix}`, TARGET, networkId],
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
59
|
+
restUser = `dedup_rid_${Date.now()}`;
|
|
60
|
+
const registered = register(restUser, "DedupRid123!", undefined, "seed");
|
|
61
|
+
expect(registered.ok).toBe(true);
|
|
62
|
+
userToken = registered.token!;
|
|
63
|
+
restNet = registered.network_id!;
|
|
64
|
+
seedTarget(restNet, "rest");
|
|
65
|
+
|
|
66
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))", [MCP_USER]);
|
|
67
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [MCP_NET, MCP_USER]);
|
|
68
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [MCP_USER, MCP_NET]);
|
|
69
|
+
seedTarget(MCP_NET, "mcp");
|
|
70
|
+
|
|
71
|
+
const mod: any = await import("./server.js");
|
|
72
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
73
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
74
|
+
}, 30_000);
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
try { server?.stop?.(true); } catch {}
|
|
78
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
for (const net of [restNet, MCP_NET]) {
|
|
83
|
+
if (!net) continue;
|
|
84
|
+
for (const table of ["tasks", "inbox", "task_events"]) {
|
|
85
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [net]); } catch {}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
sharedSendDedup.clear();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function rowCount(networkId: string): number {
|
|
92
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM tasks WHERE network_id = ?1", [networkId])!.n;
|
|
93
|
+
}
|
|
94
|
+
function inboxCount(networkId: string): number {
|
|
95
|
+
return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM inbox WHERE network_id = ?1", [networkId])!.n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── REST ────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async function rest(task: string, requestId?: string): Promise<{ status: number; body: any }> {
|
|
101
|
+
const res = await fetch(`${base}/api/task`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
alias: TARGET,
|
|
106
|
+
task,
|
|
107
|
+
priority: "normal",
|
|
108
|
+
network_id: restNet,
|
|
109
|
+
from: restUser,
|
|
110
|
+
meta: requestId
|
|
111
|
+
? { source: "dashboard-chat", client_request_id: requestId }
|
|
112
|
+
: { source: "dashboard-chat" },
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
return { status: res.status, body: await res.json() };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("POST /api/task dedup key selection", () => {
|
|
119
|
+
test("same text with 3 different request ids -> 3 rows (the reported data loss)", async () => {
|
|
120
|
+
const results = [await rest("好", rid(1)), await rest("好", rid(2)), await rest("好", rid(3))];
|
|
121
|
+
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
|
|
122
|
+
const ids = results.map((r) => r.body.task_id);
|
|
123
|
+
expect(new Set(ids).size).toBe(3);
|
|
124
|
+
expect(rowCount(restNet)).toBe(3);
|
|
125
|
+
expect(inboxCount(restNet)).toBe(3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("same text with the same request id -> 1 row, retries replay the same task id", async () => {
|
|
129
|
+
const first = await rest("好", rid(7));
|
|
130
|
+
const second = await rest("好", rid(7));
|
|
131
|
+
const third = await rest("好", rid(7));
|
|
132
|
+
expect(first.status).toBe(200);
|
|
133
|
+
expect(second.status).toBe(200);
|
|
134
|
+
expect(third.status).toBe(200);
|
|
135
|
+
expect(second.body).toMatchObject({ ok: true, task_id: first.body.task_id, message_id: first.body.task_id, idempotent_replay: true });
|
|
136
|
+
expect(third.body.task_id).toBe(first.body.task_id);
|
|
137
|
+
expect(rowCount(restNet)).toBe(1);
|
|
138
|
+
expect(inboxCount(restNet)).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("different text with the same request id -> 1 row, reuse fails closed (409)", async () => {
|
|
142
|
+
const first = await rest("第一条", rid(9));
|
|
143
|
+
const second = await rest("第二条", rid(9));
|
|
144
|
+
const third = await rest("第三条", rid(9));
|
|
145
|
+
expect(first.status).toBe(200);
|
|
146
|
+
expect(second.status).toBe(409);
|
|
147
|
+
expect(second.body.error).toBe("idempotency_conflict");
|
|
148
|
+
expect(third.status).toBe(409);
|
|
149
|
+
expect(rowCount(restNet)).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#212 guard: same text WITHOUT a request id is still deduped (429 duplicate_send)", async () => {
|
|
153
|
+
const results = [await rest("继续"), await rest("继续"), await rest("继续")];
|
|
154
|
+
expect(results.map((r) => r.status)).toEqual([200, 429, 429]);
|
|
155
|
+
expect(results[1].body.error).toBe("duplicate_send");
|
|
156
|
+
expect(rowCount(restNet)).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("no request id, different text -> each goes through", async () => {
|
|
160
|
+
const results = [await rest("a"), await rest("b"), await rest("c")];
|
|
161
|
+
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
|
|
162
|
+
expect(rowCount(restNet)).toBe(3);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── MCP send_task ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
169
|
+
|
|
170
|
+
function mcpSendTask(): ToolHandler {
|
|
171
|
+
const mcp = new McpServer({ name: "dedup-rid-test", version: "0" }) as any;
|
|
172
|
+
let handler: ToolHandler | undefined;
|
|
173
|
+
const original = mcp.tool.bind(mcp);
|
|
174
|
+
mcp.tool = (name: string, description: string, schema: any, candidate: ToolHandler) => {
|
|
175
|
+
if (name === "send_task") handler = candidate;
|
|
176
|
+
return original(name, description, schema, candidate);
|
|
177
|
+
};
|
|
178
|
+
registerTools(mcp, undefined, MCP_NET, MCP_USER, MCP_USER, false, null);
|
|
179
|
+
if (!handler) throw new Error("send_task handler missing");
|
|
180
|
+
return handler;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function mcp(handler: ToolHandler, task: string, requestId?: string): Promise<any> {
|
|
184
|
+
const result = await handler({
|
|
185
|
+
alias: TARGET,
|
|
186
|
+
task,
|
|
187
|
+
priority: "normal",
|
|
188
|
+
...(requestId ? { meta: { source: "dashboard-chat", client_request_id: requestId } } : {}),
|
|
189
|
+
});
|
|
190
|
+
return JSON.parse(result.content[0].text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
describe("MCP send_task dedup key selection", () => {
|
|
194
|
+
test("same text with 3 different request ids -> 3 rows", async () => {
|
|
195
|
+
const h = mcpSendTask();
|
|
196
|
+
const results = [await mcp(h, "好", rid(11)), await mcp(h, "好", rid(12)), await mcp(h, "好", rid(13))];
|
|
197
|
+
expect(results.map((r) => r.ok)).toEqual([true, true, true]);
|
|
198
|
+
expect(new Set(results.map((r) => r.message_id)).size).toBe(3);
|
|
199
|
+
expect(rowCount(MCP_NET)).toBe(3);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("same text with the same request id -> 1 row, replay returns the same task id", async () => {
|
|
203
|
+
const h = mcpSendTask();
|
|
204
|
+
const first = await mcp(h, "好", rid(17));
|
|
205
|
+
const second = await mcp(h, "好", rid(17));
|
|
206
|
+
const third = await mcp(h, "好", rid(17));
|
|
207
|
+
expect(first.ok).toBe(true);
|
|
208
|
+
expect(second).toMatchObject({ ok: true, task_id: first.message_id, idempotent_replay: true });
|
|
209
|
+
expect(third.task_id).toBe(first.message_id);
|
|
210
|
+
expect(rowCount(MCP_NET)).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("different text with the same request id -> 1 row, idempotency_conflict", async () => {
|
|
214
|
+
const h = mcpSendTask();
|
|
215
|
+
expect((await mcp(h, "第一条", rid(19))).ok).toBe(true);
|
|
216
|
+
expect((await mcp(h, "第二条", rid(19))).error).toBe("idempotency_conflict");
|
|
217
|
+
expect((await mcp(h, "第三条", rid(19))).error).toBe("idempotency_conflict");
|
|
218
|
+
expect(rowCount(MCP_NET)).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("#212 guard: same text WITHOUT a request id is still deduped", async () => {
|
|
222
|
+
const h = mcpSendTask();
|
|
223
|
+
const results = [await mcp(h, "继续"), await mcp(h, "继续"), await mcp(h, "继续")];
|
|
224
|
+
expect(results[0].ok).toBe(true);
|
|
225
|
+
expect(results[1].error).toBe("duplicate_send");
|
|
226
|
+
expect(results[2].error).toBe("duplicate_send");
|
|
227
|
+
expect(rowCount(MCP_NET)).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/send_dedup.test.ts
CHANGED
|
@@ -146,6 +146,60 @@ describe("SendDedup", () => {
|
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
describe("SendDedup key selection (client_request_id vs content)", () => {
|
|
150
|
+
const t0 = 1_700_000_000_000;
|
|
151
|
+
const A = "dreq_00000000000000000000000000000001";
|
|
152
|
+
const B = "dreq_00000000000000000000000000000002";
|
|
153
|
+
|
|
154
|
+
it("keyFor picks the request id when present, content hash otherwise", () => {
|
|
155
|
+
const withId = SendDedup.keyFor("alice", "bob", "好", { requestId: A, networkId: "net1" });
|
|
156
|
+
expect(withId.kind).toBe("request_id");
|
|
157
|
+
expect(withId.key).toBe(`rid|net1|alice|bob|${A}`);
|
|
158
|
+
const noId = SendDedup.keyFor("alice", "bob", "好", {});
|
|
159
|
+
expect(noId.kind).toBe("content");
|
|
160
|
+
expect(noId.key).toBe(SendDedup.key("alice", "bob", "好"));
|
|
161
|
+
// Empty / null ids fall back to the content key.
|
|
162
|
+
expect(SendDedup.keyFor("alice", "bob", "好", { requestId: "" }).kind).toBe("content");
|
|
163
|
+
expect(SendDedup.keyFor("alice", "bob", "好", { requestId: null }).kind).toBe("content");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("same text with different request ids is never a duplicate", () => {
|
|
167
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
168
|
+
d.record("alice", "bob", "好", t0, { requestId: A });
|
|
169
|
+
expect(d.check("alice", "bob", "好", t0 + 1000, { requestId: B })).toEqual({ duplicate: false });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("same request id is a duplicate even with different content (one id = one message)", () => {
|
|
173
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
174
|
+
d.record("alice", "bob", "first", t0, { requestId: A });
|
|
175
|
+
const again = d.check("alice", "bob", "second", t0 + 1000, { requestId: A });
|
|
176
|
+
expect(again.duplicate).toBe(true);
|
|
177
|
+
if (again.duplicate) expect(again.kind).toBe("request_id");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("request-id keys are scoped by network, sender and target", () => {
|
|
181
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
182
|
+
d.record("alice", "bob", "x", t0, { requestId: A, networkId: "net1" });
|
|
183
|
+
expect(d.check("alice", "bob", "x", t0 + 1, { requestId: A, networkId: "net2" })).toEqual({ duplicate: false });
|
|
184
|
+
expect(d.check("carol", "bob", "x", t0 + 1, { requestId: A, networkId: "net1" })).toEqual({ duplicate: false });
|
|
185
|
+
expect(d.check("alice", "dave", "x", t0 + 1, { requestId: A, networkId: "net1" })).toEqual({ duplicate: false });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("#212 regression guard: without an id, same content is still a duplicate and reports kind=content", () => {
|
|
189
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
190
|
+
d.record("agent", "peer", "same task", t0);
|
|
191
|
+
const again = d.check("agent", "peer", "same task", t0 + 1000);
|
|
192
|
+
expect(again.duplicate).toBe(true);
|
|
193
|
+
if (again.duplicate) expect(again.kind).toBe("content");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("an id-bearing send does not shadow a later id-less send of the same text", () => {
|
|
197
|
+
const d = new SendDedup({ windowMs: 300_000, maxKeys: 64 });
|
|
198
|
+
d.record("alice", "bob", "好", t0, { requestId: A });
|
|
199
|
+
expect(d.check("alice", "bob", "好", t0 + 1000)).toEqual({ duplicate: false });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
149
203
|
describe("buildDuplicateSendPayload", () => {
|
|
150
204
|
it("contains the Chinese LLM-facing hint with target alias and window minutes", () => {
|
|
151
205
|
const payload = buildDuplicateSendPayload({
|
package/src/send_dedup.ts
CHANGED
|
@@ -33,6 +33,26 @@
|
|
|
33
33
|
// - The structured response shape is identical between MCP and REST
|
|
34
34
|
// callers so the LLM gets the same parseable hint regardless of
|
|
35
35
|
// transport.
|
|
36
|
+
//
|
|
37
|
+
// Key selection (#2043, follow-up to #212): the content-hash key above is
|
|
38
|
+
// only right for callers that have no other notion of "one message".
|
|
39
|
+
// Human chat clients (the desktop/mobile app, the web Dashboard) mint a
|
|
40
|
+
// fresh `meta.client_request_id` per message bubble and reuse it only
|
|
41
|
+
// when retrying that same bubble. For those sends the content hash is the
|
|
42
|
+
// WRONG identity: a user who types 「好」 twice within five minutes sent
|
|
43
|
+
// two messages, and dropping the second one (while the app maps the 429
|
|
44
|
+
// to "delivered") silently loses it. So:
|
|
45
|
+
// - a send WITH a valid client_request_id is keyed on
|
|
46
|
+
// (network, from, to, client_request_id) — a new id always proceeds,
|
|
47
|
+
// identical text or not; a same-id retry is answered from the durable
|
|
48
|
+
// tasks row by the handler (task-idempotency.ts) before this index is
|
|
49
|
+
// even consulted, so this key is defence in depth, not the primary
|
|
50
|
+
// replay mechanism;
|
|
51
|
+
// - a send WITHOUT one (agent / MCP / scripted traffic) keeps the #212
|
|
52
|
+
// content-hash window unchanged.
|
|
53
|
+
// One id is one message: re-using an id with different content is a
|
|
54
|
+
// client bug and the handlers fail it closed (`idempotency_conflict`)
|
|
55
|
+
// rather than storing a second row or silently replaying the first.
|
|
36
56
|
|
|
37
57
|
import { createHash } from "crypto";
|
|
38
58
|
|
|
@@ -61,9 +81,23 @@ export function readDedupConfig(env: NodeJS.ProcessEnv = process.env): DedupConf
|
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
/** Which identity a dedup key was built from. Logged on every drop. */
|
|
85
|
+
export type DedupKeyKind = "request_id" | "content";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional per-send scope. When `requestId` is a non-empty string the key
|
|
89
|
+
* is the request id (scoped by network/from/to) instead of the content hash.
|
|
90
|
+
* Callers must pass the already-validated id (clientRequestIdFromMeta), not
|
|
91
|
+
* raw client input.
|
|
92
|
+
*/
|
|
93
|
+
export type DedupScope = {
|
|
94
|
+
requestId?: string | null;
|
|
95
|
+
networkId?: string | null;
|
|
96
|
+
};
|
|
97
|
+
|
|
64
98
|
export type DedupCheck =
|
|
65
99
|
| { duplicate: false }
|
|
66
|
-
| { duplicate: true; lastSentMs: number; ageMs: number };
|
|
100
|
+
| { duplicate: true; lastSentMs: number; ageMs: number; kind: DedupKeyKind };
|
|
67
101
|
|
|
68
102
|
export class SendDedup {
|
|
69
103
|
private last = new Map<string, number>();
|
|
@@ -93,6 +127,18 @@ export class SendDedup {
|
|
|
93
127
|
return `${from}|${to}|${hash}`;
|
|
94
128
|
}
|
|
95
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Pick the dedup identity for one send. A validated client request id
|
|
132
|
+
* wins over the content hash; see the header comment for why.
|
|
133
|
+
*/
|
|
134
|
+
static keyFor(from: string, to: string, content: string, scope: DedupScope = {}): { key: string; kind: DedupKeyKind } {
|
|
135
|
+
const requestId = typeof scope.requestId === "string" && scope.requestId.length > 0 ? scope.requestId : null;
|
|
136
|
+
if (requestId) {
|
|
137
|
+
return { key: `rid|${scope.networkId ?? ""}|${from}|${to}|${requestId}`, kind: "request_id" };
|
|
138
|
+
}
|
|
139
|
+
return { key: SendDedup.key(from, to, content), kind: "content" };
|
|
140
|
+
}
|
|
141
|
+
|
|
96
142
|
/**
|
|
97
143
|
* Check whether (from, to, content) was sent recently. Returns
|
|
98
144
|
* `{ duplicate: false }` when the call should proceed, or
|
|
@@ -103,10 +149,10 @@ export class SendDedup {
|
|
|
103
149
|
* AFTER the underlying side effect (inbox insert + pushEvent) succeeds,
|
|
104
150
|
* so failed sends don't accidentally block legitimate retries.
|
|
105
151
|
*/
|
|
106
|
-
check(from: string, to: string, content: string, nowMs: number = Date.now()): DedupCheck {
|
|
152
|
+
check(from: string, to: string, content: string, nowMs: number = Date.now(), scope: DedupScope = {}): DedupCheck {
|
|
107
153
|
if (!this.enabled) return { duplicate: false };
|
|
108
154
|
this.evictExpired(nowMs);
|
|
109
|
-
const k = SendDedup.
|
|
155
|
+
const { key: k, kind } = SendDedup.keyFor(from, to, content, scope);
|
|
110
156
|
const lastSentMs = this.last.get(k);
|
|
111
157
|
if (lastSentMs === undefined) return { duplicate: false };
|
|
112
158
|
const ageMs = nowMs - lastSentMs;
|
|
@@ -114,13 +160,13 @@ export class SendDedup {
|
|
|
114
160
|
this.last.delete(k);
|
|
115
161
|
return { duplicate: false };
|
|
116
162
|
}
|
|
117
|
-
return { duplicate: true, lastSentMs, ageMs };
|
|
163
|
+
return { duplicate: true, lastSentMs, ageMs, kind };
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
/** Record a successful send for future dedup checks. */
|
|
121
|
-
record(from: string, to: string, content: string, nowMs: number = Date.now()): void {
|
|
167
|
+
record(from: string, to: string, content: string, nowMs: number = Date.now(), scope: DedupScope = {}): void {
|
|
122
168
|
if (!this.enabled) return;
|
|
123
|
-
const k = SendDedup.
|
|
169
|
+
const k = SendDedup.keyFor(from, to, content, scope).key;
|
|
124
170
|
this.last.set(k, nowMs);
|
|
125
171
|
if (this.last.size > this.cfg.maxKeys) this.evictOldest();
|
|
126
172
|
}
|