@sleep2agi/commhub-server 0.9.0-preview.95 → 0.9.0-preview.97
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/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/push.ts +146 -4
- package/src/requirements-errors.ts +1 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-last-event-http.test.ts +237 -0
- package/src/requirements-list-slim-http.test.ts +5 -1
- package/src/requirements.ts +97 -2
- package/src/server.ts +27 -2
- package/src/tools.ts +15 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.97",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// #507 — two live copies of one node (directory copied verbatim: same node token,
|
|
2
|
+
// same alias) used to hold two SSE subscriptions on `${net}:${alias}`. Every new
|
|
3
|
+
// task was pushed to BOTH (each copy ran it), and either copy's shutdown
|
|
4
|
+
// report_status(offline) took the node offline under the other → alias_offline.
|
|
5
|
+
//
|
|
6
|
+
// Pinned here, through the real HTTP entrances (GET /events/:alias with the node
|
|
7
|
+
// token, POST /mcp report_status, POST /api/task, GET /api/stats/sse):
|
|
8
|
+
// 1. newest connection wins: the older node stream gets node_connection_superseded
|
|
9
|
+
// (reason superseded_by_new_connection) and is closed; /api/stats/sse shows 1.
|
|
10
|
+
// 2. a new task reaches exactly ONE node subscriber; a dashboard monitor on the
|
|
11
|
+
// same channel is untouched and still sees it.
|
|
12
|
+
// 3. node_identity_conflict reaches the network observer stream and
|
|
13
|
+
// /api/stats/sse.identity_conflicts, with both remotes and no token.
|
|
14
|
+
// 4. offline only when the last node connection leaves; tasks in between are
|
|
15
|
+
// delivered, not alias_offline.
|
|
16
|
+
// 5. same instance id = a reconnect (replaced_by_reconnect, no conflict);
|
|
17
|
+
// different ids = instance_match "different"; another node's token watching
|
|
18
|
+
// this alias never supersedes it; a plain single-node stop is offline at once.
|
|
19
|
+
//
|
|
20
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-identity-conflict-http.test.ts
|
|
21
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
27
|
+
import { db } from "./db.js";
|
|
28
|
+
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-node-conflict-"));
|
|
30
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
31
|
+
if (!activeDbPath) throw new Error("node-identity-conflict requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
32
|
+
|
|
33
|
+
const stamp = Date.now();
|
|
34
|
+
let server: any;
|
|
35
|
+
let base = "";
|
|
36
|
+
let ownerToken = "", ownerId = "", NET = "";
|
|
37
|
+
const nodes: Record<string, { alias: string; nodeId: string; token: string }> = {};
|
|
38
|
+
|
|
39
|
+
type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
|
|
40
|
+
|
|
41
|
+
function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
|
|
42
|
+
const ctrl = new AbortController();
|
|
43
|
+
const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
|
|
44
|
+
let markReady!: () => void;
|
|
45
|
+
sub.ready = new Promise<void>((r) => { markReady = r; });
|
|
46
|
+
(async () => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", ...headers }, signal: ctrl.signal });
|
|
49
|
+
if (!res.ok || !res.body) throw new Error(`subscribe ${path} → ${res.status}`);
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const dec = new TextDecoder();
|
|
52
|
+
let buf = "";
|
|
53
|
+
while (true) {
|
|
54
|
+
const { done, value } = await reader.read();
|
|
55
|
+
if (done) break;
|
|
56
|
+
buf += dec.decode(value, { stream: true });
|
|
57
|
+
const lines = buf.split("\n"); buf = lines.pop() || "";
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line.startsWith("data: ")) continue;
|
|
60
|
+
const ev = JSON.parse(line.slice(6));
|
|
61
|
+
sub.events.push(ev);
|
|
62
|
+
if (ev.type === "connected") markReady();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch { /* aborted */ }
|
|
66
|
+
sub.done = true;
|
|
67
|
+
markReady();
|
|
68
|
+
})();
|
|
69
|
+
return sub;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function until(cond: () => boolean, what: string, ms = 3000) {
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
while (!cond()) {
|
|
75
|
+
if (Date.now() - t0 > ms) throw new Error(`timed out waiting for: ${what}`);
|
|
76
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const settle = () => new Promise((r) => setTimeout(r, 150));
|
|
80
|
+
|
|
81
|
+
async function api(token: string, method: string, path: string, body?: unknown) {
|
|
82
|
+
const res = await fetch(`${base}${path}`, {
|
|
83
|
+
method,
|
|
84
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
85
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
86
|
+
});
|
|
87
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
91
|
+
const res = await fetch(`${base}/mcp`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
94
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
95
|
+
});
|
|
96
|
+
const raw = await res.text();
|
|
97
|
+
const data = raw.split("\n").filter((x) => x.startsWith("data:"));
|
|
98
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
99
|
+
const text = payload.result.content[0].text;
|
|
100
|
+
if (!text.startsWith("{")) throw new Error(`${name}: ${text}`);
|
|
101
|
+
return JSON.parse(text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const reportStatus = (n: { alias: string; nodeId: string; token: string }, status: string) =>
|
|
105
|
+
tool(n.token, "report_status", { resume_id: `sdk-${n.nodeId}`, alias: n.alias, status, node_id: n.nodeId, network_id: NET });
|
|
106
|
+
const storedStatus = (alias: string) =>
|
|
107
|
+
db.get<{ status: string }>("SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, NET)?.status;
|
|
108
|
+
let seq = 0;
|
|
109
|
+
const sendTask = (alias: string) => api(ownerToken, "POST", "/api/task", { alias, task: `conflict task ${stamp} #${++seq}`, network_id: NET });
|
|
110
|
+
const nodeStream = (n: { alias: string; token: string }, headers: Record<string, string> = {}) =>
|
|
111
|
+
subscribe(`/events/${encodeURIComponent(n.alias)}`, n.token, headers);
|
|
112
|
+
const newTasks = (s: Sub) => s.events.filter((e) => e.type === "new_task");
|
|
113
|
+
const sseCount = async (alias: string) => (await api(nodes.a.token, "GET", "/api/stats/sse")).body.sessions[`${NET}:${alias}`] ?? 0;
|
|
114
|
+
|
|
115
|
+
beforeAll(async () => {
|
|
116
|
+
process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
|
|
117
|
+
process.env.HOST = "127.0.0.1";
|
|
118
|
+
const owner = register(`nic_owner_${stamp}`, "NodeConflictOwner123!", undefined, "seed");
|
|
119
|
+
ownerToken = owner.token!; ownerId = owner.user!.user_id; NET = owner.network_id!;
|
|
120
|
+
for (const key of ["a", "b", "c", "d", "e"]) {
|
|
121
|
+
const alias = `nic-${key}-${stamp}`;
|
|
122
|
+
const nodeId = `n_nic_${key}_${stamp}`;
|
|
123
|
+
const minted = createNetworkTokenForNode(ownerId, NET, alias, nodeId);
|
|
124
|
+
expect(minted.ok).toBe(true);
|
|
125
|
+
nodes[key] = { alias, nodeId, token: minted.token! };
|
|
126
|
+
}
|
|
127
|
+
const mod: any = await import("./server.js");
|
|
128
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
129
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
130
|
+
for (const n of Object.values(nodes)) expect((await reportStatus(n, "idle")).ok).toBe(true);
|
|
131
|
+
}, 30_000);
|
|
132
|
+
|
|
133
|
+
afterAll(() => {
|
|
134
|
+
try { server?.stop?.(true); } catch {}
|
|
135
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("#507 two copies of one node (same token, same alias)", () => {
|
|
139
|
+
const subs: Record<string, Sub> = {};
|
|
140
|
+
|
|
141
|
+
test("newest connection wins: the older node stream is told why and closed", async () => {
|
|
142
|
+
const n = nodes.a;
|
|
143
|
+
subs.observer = subscribe(`/events/network/${NET}`, ownerToken);
|
|
144
|
+
subs.monitor = subscribe(`/events/${encodeURIComponent(n.alias)}?network_id=${NET}`, ownerToken);
|
|
145
|
+
await subs.observer.ready; await subs.monitor.ready;
|
|
146
|
+
subs.copyA = nodeStream(n, { "User-Agent": "copy-A" });
|
|
147
|
+
await subs.copyA.ready;
|
|
148
|
+
expect(await sseCount(n.alias)).toBe(2); // copy A + dashboard monitor
|
|
149
|
+
|
|
150
|
+
subs.copyB = nodeStream(n, { "User-Agent": "copy-B" });
|
|
151
|
+
await subs.copyB.ready;
|
|
152
|
+
await until(() => subs.copyA.done, "copy A's stream to be closed");
|
|
153
|
+
const notice = subs.copyA.events.find((e) => e.type === "node_connection_superseded");
|
|
154
|
+
expect(notice).toBeTruthy();
|
|
155
|
+
expect(notice.reason).toBe("superseded_by_new_connection");
|
|
156
|
+
expect(notice.instance_match).toBe("unknown");
|
|
157
|
+
expect(subs.copyB.done).toBe(false);
|
|
158
|
+
expect(subs.monitor.done).toBe(false); // a monitor is never superseded
|
|
159
|
+
expect(await sseCount(n.alias)).toBe(2); // copy B + monitor
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a new task reaches exactly one node subscriber (the monitor still sees it)", async () => {
|
|
163
|
+
const r = await sendTask(nodes.a.alias);
|
|
164
|
+
expect(r.status).toBe(200);
|
|
165
|
+
expect(r.body.ok).toBe(true);
|
|
166
|
+
await until(() => newTasks(subs.copyB).length >= 1 && newTasks(subs.monitor).length >= 1, "new_task on copy B + monitor");
|
|
167
|
+
await settle();
|
|
168
|
+
expect(newTasks(subs.copyB).length).toBe(1);
|
|
169
|
+
expect(newTasks(subs.copyA).length).toBe(0);
|
|
170
|
+
expect(newTasks(subs.monitor).length).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("node_identity_conflict reaches the observer stream and /api/stats/sse, with remotes and no token", async () => {
|
|
174
|
+
await until(() => subs.observer.events.some((e) => e.type === "node_identity_conflict"), "observer conflict event");
|
|
175
|
+
const ev = subs.observer.events.find((e) => e.type === "node_identity_conflict");
|
|
176
|
+
expect(ev.alias).toBe(nodes.a.alias);
|
|
177
|
+
expect(ev.node_id).toBe(nodes.a.nodeId);
|
|
178
|
+
expect(ev.policy).toBe("newest_connection_wins");
|
|
179
|
+
expect(ev.superseded.user_agent).toBe("copy-A");
|
|
180
|
+
expect(ev.current.user_agent).toBe("copy-B");
|
|
181
|
+
expect(ev.superseded.remote).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
182
|
+
expect(ev.current.remote).toMatch(/^127\.0\.0\.1:\d+$/);
|
|
183
|
+
expect(ev.superseded.remote).not.toBe(ev.current.remote);
|
|
184
|
+
const stats = await api(nodes.a.token, "GET", "/api/stats/sse");
|
|
185
|
+
const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === nodes.a.alias);
|
|
186
|
+
expect(mine.length).toBe(1);
|
|
187
|
+
expect(mine[0].network_id).toBe(NET);
|
|
188
|
+
const raw = JSON.stringify(stats.body) + JSON.stringify(ev);
|
|
189
|
+
expect(raw).not.toContain("ntok_");
|
|
190
|
+
expect(raw).not.toContain(nodes.a.token);
|
|
191
|
+
const audit = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_identity_conflict' AND target_id = ?1", nodes.a.nodeId);
|
|
192
|
+
expect(audit?.n).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("offline only when the last node connection leaves; tasks in between are delivered", async () => {
|
|
196
|
+
const n = nodes.a;
|
|
197
|
+
// Copy A (the superseded one) shuts down and says so.
|
|
198
|
+
const fromA = await reportStatus(n, "offline");
|
|
199
|
+
expect(fromA.ok).toBe(true);
|
|
200
|
+
expect(fromA.offline_deferred).toBe(true);
|
|
201
|
+
expect(storedStatus(n.alias)).not.toBe("offline");
|
|
202
|
+
const r = await sendTask(n.alias);
|
|
203
|
+
expect(r.status).toBe(200);
|
|
204
|
+
expect(r.body.ok).toBe(true);
|
|
205
|
+
expect(r.body.error).toBeUndefined();
|
|
206
|
+
await until(() => newTasks(subs.copyB).length >= 2, "second new_task on copy B");
|
|
207
|
+
|
|
208
|
+
// Copy B stops too: it reports offline while its stream is still open, then exits.
|
|
209
|
+
const fromB = await reportStatus(n, "offline");
|
|
210
|
+
expect(fromB.offline_deferred).toBe(true);
|
|
211
|
+
expect(storedStatus(n.alias)).not.toBe("offline");
|
|
212
|
+
subs.copyB.abort();
|
|
213
|
+
await until(() => storedStatus(n.alias) === "offline", "deferred offline applied once the last node stream closed");
|
|
214
|
+
expect((await sendTask(n.alias)).body.error).toBe("alias_offline");
|
|
215
|
+
subs.monitor.abort(); subs.observer.abort();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("#507 what is and is not a conflict", () => {
|
|
220
|
+
test("same instance id = a reconnect of the same process: replaced, no conflict recorded", async () => {
|
|
221
|
+
const n = nodes.b;
|
|
222
|
+
const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
|
|
223
|
+
await first.ready;
|
|
224
|
+
const second = nodeStream(n, { "X-Anet-Instance-Id": "inst-b-1" });
|
|
225
|
+
await second.ready;
|
|
226
|
+
await until(() => first.done, "old stream replaced");
|
|
227
|
+
expect(first.events.find((e) => e.type === "node_connection_superseded")?.reason).toBe("replaced_by_reconnect");
|
|
228
|
+
const stats = await api(n.token, "GET", "/api/stats/sse");
|
|
229
|
+
expect(stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias)).toEqual([]);
|
|
230
|
+
// No conflict → a stop is exactly as before: offline at once.
|
|
231
|
+
const r = await reportStatus(n, "offline");
|
|
232
|
+
expect(r.offline_deferred).toBeUndefined();
|
|
233
|
+
expect(storedStatus(n.alias)).toBe("offline");
|
|
234
|
+
second.abort();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("different instance ids are reported as instance_match=different", async () => {
|
|
238
|
+
const n = nodes.c;
|
|
239
|
+
const first = nodeStream(n, { "X-Anet-Instance-Id": "inst-c-host1" });
|
|
240
|
+
await first.ready;
|
|
241
|
+
const second = subscribe(`/events/${encodeURIComponent(n.alias)}?instance_id=inst-c-host2`, n.token);
|
|
242
|
+
await second.ready;
|
|
243
|
+
await until(() => first.done, "old stream superseded");
|
|
244
|
+
const stats = await api(n.token, "GET", "/api/stats/sse");
|
|
245
|
+
const mine = stats.body.identity_conflicts.filter((c: any) => c.alias === n.alias);
|
|
246
|
+
expect(mine.length).toBe(1);
|
|
247
|
+
expect(mine[0].instance_match).toBe("different");
|
|
248
|
+
expect(mine[0].superseded.instance_id).toBe("inst-c-host1");
|
|
249
|
+
expect(mine[0].current.instance_id).toBe("inst-c-host2");
|
|
250
|
+
second.abort();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("another node's token watching this alias never supersedes the node", async () => {
|
|
254
|
+
const n = nodes.d;
|
|
255
|
+
const own = nodeStream(n);
|
|
256
|
+
await own.ready;
|
|
257
|
+
const watcher = subscribe(`/events/${encodeURIComponent(n.alias)}`, nodes.e.token);
|
|
258
|
+
await watcher.ready;
|
|
259
|
+
await settle();
|
|
260
|
+
expect(own.done).toBe(false);
|
|
261
|
+
expect(own.events.some((e) => e.type === "node_connection_superseded")).toBe(false);
|
|
262
|
+
watcher.abort();
|
|
263
|
+
// And a plain single-node stop (no conflict) is offline immediately, as before.
|
|
264
|
+
const r = await reportStatus(n, "offline");
|
|
265
|
+
expect(r.offline_deferred).toBeUndefined();
|
|
266
|
+
expect(storedStatus(n.alias)).toBe("offline");
|
|
267
|
+
own.abort();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// #507 — two live processes holding the same node identity (a node directory
|
|
2
|
+
// copied verbatim and started elsewhere: same node token, same alias).
|
|
3
|
+
//
|
|
4
|
+
// push.ts enforces "at most one node-scoped SSE subscriber per (network, alias)",
|
|
5
|
+
// newest connection wins (policy + rationale next to enforceSingleNodeSubscriber).
|
|
6
|
+
// This module is the Hub-side bookkeeping around that:
|
|
7
|
+
//
|
|
8
|
+
// 1. node_identity_conflict — every supersede between connections that are not
|
|
9
|
+
// provably the same process is logged, written to audit_log, pushed to the
|
|
10
|
+
// network observer stream (dashboard) and kept in a small ring that
|
|
11
|
+
// GET /api/stats/sse returns. Carries both connections' remote info; never
|
|
12
|
+
// a token.
|
|
13
|
+
//
|
|
14
|
+
// 2. offline while another copy is still connected — a copy's shutdown
|
|
15
|
+
// report_status(offline) used to mark the node offline even though the other
|
|
16
|
+
// copy was connected, so new tasks got alias_offline. While a conflict is
|
|
17
|
+
// recent for that alias and a node-scoped subscriber is still connected, the
|
|
18
|
+
// offline is DEFERRED: stored status stays as it was, and the offline is
|
|
19
|
+
// applied only when the last node-scoped subscriber leaves (or dropped when the
|
|
20
|
+
// surviving copy reports a fresh status). Outside a conflict window nothing
|
|
21
|
+
// changes: a normal single-node stop marks offline immediately, as before.
|
|
22
|
+
|
|
23
|
+
import { db, logAudit } from "./db.js";
|
|
24
|
+
import {
|
|
25
|
+
hasNodeSubscriber,
|
|
26
|
+
onNodeIdentityConflict,
|
|
27
|
+
onNodeSubscribersDrained,
|
|
28
|
+
pushEvent,
|
|
29
|
+
pushNetworkObserverEvent,
|
|
30
|
+
type NodeIdentityConflict,
|
|
31
|
+
} from "./push.js";
|
|
32
|
+
|
|
33
|
+
/** How long after a conflict an offline report is treated as "maybe the other copy". */
|
|
34
|
+
export const CONFLICT_WINDOW_MS = 10 * 60 * 1000;
|
|
35
|
+
const RING_MAX = 50;
|
|
36
|
+
/** Observer/audit emission is rate-limited per alias (logs are not). A flapping
|
|
37
|
+
* pair supersedes about once a second; one event a minute is enough to see it. */
|
|
38
|
+
const EMIT_EVERY_MS = 60 * 1000;
|
|
39
|
+
|
|
40
|
+
const ring: NodeIdentityConflict[] = [];
|
|
41
|
+
const lastConflictAt = new Map<string, number>();
|
|
42
|
+
const lastEmittedAt = new Map<string, number>();
|
|
43
|
+
const pendingOffline = new Map<string, { resumeId: string; at: number }>();
|
|
44
|
+
|
|
45
|
+
const k = (alias: string, networkId: string | null | undefined) => `${networkId || "global"}:${alias}`;
|
|
46
|
+
|
|
47
|
+
function publicInfo(i: NodeIdentityConflict["current"]) {
|
|
48
|
+
return {
|
|
49
|
+
instance_id: i.instanceId,
|
|
50
|
+
remote: i.remote,
|
|
51
|
+
user_agent: i.userAgent,
|
|
52
|
+
connected_at: new Date(i.connectedAt).toISOString(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function conflictPayload(c: NodeIdentityConflict) {
|
|
57
|
+
return {
|
|
58
|
+
type: "node_identity_conflict",
|
|
59
|
+
alias: c.alias,
|
|
60
|
+
node_id: c.nodeId,
|
|
61
|
+
instance_match: c.instanceMatch,
|
|
62
|
+
policy: "newest_connection_wins",
|
|
63
|
+
superseded: publicInfo(c.superseded),
|
|
64
|
+
current: publicInfo(c.current),
|
|
65
|
+
at: new Date(c.at).toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleConflict(c: NodeIdentityConflict): void {
|
|
70
|
+
const key = k(c.alias, c.networkId);
|
|
71
|
+
lastConflictAt.set(key, c.at);
|
|
72
|
+
ring.push(c);
|
|
73
|
+
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
|
|
74
|
+
const payload = conflictPayload(c);
|
|
75
|
+
console.warn(`[node_identity_conflict] ${c.key} node_id=${c.nodeId ?? "?"} instance_match=${c.instanceMatch} ` +
|
|
76
|
+
`superseded=${c.superseded.remote ?? "?"}(${c.superseded.instanceId ?? "no-instance-id"}) ` +
|
|
77
|
+
`by=${c.current.remote ?? "?"}(${c.current.instanceId ?? "no-instance-id"}) — ` +
|
|
78
|
+
`two processes are using this node's identity (copied node directory?); newest connection kept`);
|
|
79
|
+
const last = lastEmittedAt.get(key) ?? 0;
|
|
80
|
+
if (c.at - last < EMIT_EVERY_MS) return;
|
|
81
|
+
lastEmittedAt.set(key, c.at);
|
|
82
|
+
pushNetworkObserverEvent(c.networkId, payload);
|
|
83
|
+
logAudit(null, null, "node_identity_conflict", "node", c.nodeId ?? c.alias, JSON.stringify(payload), c.current.remote ?? undefined, c.networkId ?? undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleDrained(alias: string, networkId: string | null): void {
|
|
87
|
+
const key = k(alias, networkId);
|
|
88
|
+
const pending = pendingOffline.get(key);
|
|
89
|
+
if (!pending) return;
|
|
90
|
+
pendingOffline.delete(key);
|
|
91
|
+
try {
|
|
92
|
+
db.run(
|
|
93
|
+
"UPDATE sessions SET status = 'offline', updated_at = datetime('now') WHERE resume_id = ?1 AND alias = ?2 AND network_id = ?3",
|
|
94
|
+
[pending.resumeId, alias, networkId ?? "default"],
|
|
95
|
+
);
|
|
96
|
+
console.log(`[node_identity_conflict] ${key} last node connection gone → deferred offline applied`);
|
|
97
|
+
pushEvent(alias, { type: "status_update", alias, status: "offline", progress: null, host: null, process_telemetry: null }, networkId);
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
console.log(`[node_identity_conflict] deferred offline failed for ${key}: ${e?.message || e}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onNodeIdentityConflict(handleConflict);
|
|
104
|
+
onNodeSubscribersDrained(handleDrained);
|
|
105
|
+
|
|
106
|
+
/** report_status hook. Returns true when an offline report must NOT be stored now
|
|
107
|
+
* (another copy of this node is still connected); it is then applied when the last
|
|
108
|
+
* node-scoped subscriber leaves. Any non-offline report clears a pending offline. */
|
|
109
|
+
export function deferOfflineIfAnotherCopyConnected(
|
|
110
|
+
alias: string,
|
|
111
|
+
networkId: string | null | undefined,
|
|
112
|
+
status: string,
|
|
113
|
+
resumeId: string,
|
|
114
|
+
now = Date.now(),
|
|
115
|
+
): boolean {
|
|
116
|
+
const key = k(alias, networkId);
|
|
117
|
+
if (status !== "offline") {
|
|
118
|
+
pendingOffline.delete(key);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const conflictAt = lastConflictAt.get(key);
|
|
122
|
+
if (conflictAt === undefined || now - conflictAt > CONFLICT_WINDOW_MS) return false;
|
|
123
|
+
if (!hasNodeSubscriber(alias, networkId)) return false;
|
|
124
|
+
pendingOffline.set(key, { resumeId, at: now });
|
|
125
|
+
console.warn(`[node_identity_conflict] ${key} report_status(offline) deferred: a node connection is still open ` +
|
|
126
|
+
`and a second copy was seen ${Math.round((now - conflictAt) / 1000)}s ago`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Recent conflicts, newest last, optionally limited to one network. */
|
|
131
|
+
export function recentNodeIdentityConflicts(networkId?: string | null) {
|
|
132
|
+
return ring
|
|
133
|
+
.filter((c) => networkId === undefined || c.networkId === networkId)
|
|
134
|
+
.map((c) => ({ network_id: c.networkId, ...conflictPayload(c) }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function __resetNodeIdentityConflictsForTest(): void {
|
|
138
|
+
ring.length = 0;
|
|
139
|
+
lastConflictAt.clear();
|
|
140
|
+
lastEmittedAt.clear();
|
|
141
|
+
pendingOffline.clear();
|
|
142
|
+
}
|
package/src/push.ts
CHANGED
|
@@ -88,6 +88,36 @@ type SSEClient = {
|
|
|
88
88
|
subscriberUserId?: string;
|
|
89
89
|
/** 只推满足条件的事件(受限成员的网络观察流用)。缺省 = 全推。 */
|
|
90
90
|
eventFilter?: (event: Record<string, unknown>) => boolean;
|
|
91
|
+
/** #507 — set only on a node's OWN task stream (/events/<alias> opened with the
|
|
92
|
+
* node token bound to that alias). Dashboard monitors / master-token streams on
|
|
93
|
+
* the same key leave it unset and are never superseded or counted here. */
|
|
94
|
+
node?: NodeSubscriberInfo;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** #507 — what the Hub knows about one node-scoped SSE connection. No tokens. */
|
|
98
|
+
export type NodeSubscriberInfo = {
|
|
99
|
+
nodeId: string | null;
|
|
100
|
+
/** Optional, client-supplied (`X-Anet-Instance-Id` header or `?instance_id=`).
|
|
101
|
+
* null = the client did not say (every agent-node released so far). */
|
|
102
|
+
instanceId: string | null;
|
|
103
|
+
remote: string | null;
|
|
104
|
+
userAgent: string | null;
|
|
105
|
+
connectedAt: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** #507 — one supersede of a node-scoped connection by a newer one. */
|
|
109
|
+
export type NodeIdentityConflict = {
|
|
110
|
+
key: string;
|
|
111
|
+
alias: string;
|
|
112
|
+
networkId: string | null;
|
|
113
|
+
nodeId: string | null;
|
|
114
|
+
/** "same" = both connections carried the same instance id (a reconnect of the
|
|
115
|
+
* same process; not reported as a conflict). "different" = both carried ids and
|
|
116
|
+
* they differ (two live copies). "unknown" = at least one carried none. */
|
|
117
|
+
instanceMatch: "same" | "different" | "unknown";
|
|
118
|
+
superseded: NodeSubscriberInfo;
|
|
119
|
+
current: NodeSubscriberInfo;
|
|
120
|
+
at: number;
|
|
91
121
|
};
|
|
92
122
|
|
|
93
123
|
// 一个 session 可能有多个 SSE 连接(重连时短暂并存)
|
|
@@ -158,6 +188,9 @@ function closeClient(client: SSEClient, reason: string): void {
|
|
|
158
188
|
// we're done with it.
|
|
159
189
|
}
|
|
160
190
|
console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
|
|
191
|
+
if (client.node && reason !== SUPERSEDED_REASON && !hasLiveNodeClient(client.key)) {
|
|
192
|
+
notifyNodeDrained(client.key);
|
|
193
|
+
}
|
|
161
194
|
if (isUserStreamClient(client)) {
|
|
162
195
|
touchUserSeen(client.subscriberUserId!);
|
|
163
196
|
if (!isUserOnline(client.subscriberUserId!)) notifyPresence(client.subscriberUserId!, false);
|
|
@@ -260,14 +293,114 @@ function ensureLivenessSweep(): void {
|
|
|
260
293
|
// Public API
|
|
261
294
|
// ─────────────────────────────────────────────────────────────────────
|
|
262
295
|
|
|
263
|
-
/** 创建 SSE Response 并注册到 clients map
|
|
264
|
-
|
|
296
|
+
/** 创建 SSE Response 并注册到 clients map。
|
|
297
|
+
* #507 — pass `opts.node` only for a node's own stream (node token bound to this
|
|
298
|
+
* alias); such a stream supersedes any older node-scoped stream on the same key. */
|
|
299
|
+
export function createSSEStream(
|
|
300
|
+
sessionName: string,
|
|
301
|
+
networkId?: string | null,
|
|
302
|
+
opts: { node?: Omit<NodeSubscriberInfo, "connectedAt"> } = {},
|
|
303
|
+
): Response {
|
|
265
304
|
return createStreamForKey(
|
|
266
305
|
clientKey(sessionName, networkId),
|
|
267
306
|
{ type: "connected", session: sessionName, network_id: networkId ?? null },
|
|
307
|
+
opts.node ? { node: { ...opts.node, connectedAt: Date.now() } } : {},
|
|
268
308
|
);
|
|
269
309
|
}
|
|
270
310
|
|
|
311
|
+
// ── #507 node identity conflict: at most ONE node-scoped subscriber per key ──
|
|
312
|
+
//
|
|
313
|
+
// Before #507 every connection on `${networkId}:${alias}` received every push. A
|
|
314
|
+
// node directory copied verbatim (same node token, same alias) and started
|
|
315
|
+
// elsewhere therefore got every new_task twice — both copies ran it — and either
|
|
316
|
+
// copy's shutdown report_status(offline) marked the node offline while the other
|
|
317
|
+
// was still connected.
|
|
318
|
+
//
|
|
319
|
+
// Policy: NEWEST CONNECTION WINS. Measured reconnect path (agent-node connectSSE →
|
|
320
|
+
// superviseChild): a restarted process's old socket is closed by the OS, so a
|
|
321
|
+
// legit restart never overlaps its own previous stream; an overlap only happens
|
|
322
|
+
// with (a) a half-open ghost the Hub has not reaped yet (up to STUCK_CLOSE_MS) or
|
|
323
|
+
// (b) a second live copy. In (a) the new connection is the real one — keeping the
|
|
324
|
+
// old would park tasks on a dead socket — so the newest must win. In (b) the
|
|
325
|
+
// loser reconnects ~1 s later and wins back: the copies flap, which is loud
|
|
326
|
+
// (log + node_identity_conflict event + /api/stats/sse) instead of silently
|
|
327
|
+
// double-executing. An "only if both alive > N s" rule cannot tell (a) from (b):
|
|
328
|
+
// a ghost looks alive to the Hub until it is reaped.
|
|
329
|
+
export const SUPERSEDED_REASON = "superseded_by_new_connection";
|
|
330
|
+
const REPLACED_REASON = "replaced_by_reconnect";
|
|
331
|
+
|
|
332
|
+
let nodeConflictListener: ((c: NodeIdentityConflict) => void) | null = null;
|
|
333
|
+
let nodeDrainedListener: ((alias: string, networkId: string | null) => void) | null = null;
|
|
334
|
+
|
|
335
|
+
/** Called for every supersede whose instances are not provably the same process. */
|
|
336
|
+
export function onNodeIdentityConflict(cb: ((c: NodeIdentityConflict) => void) | null): void {
|
|
337
|
+
nodeConflictListener = cb;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Called when the LAST node-scoped subscriber of a key goes away (not when it is
|
|
341
|
+
* superseded — the newer connection is still there). */
|
|
342
|
+
export function onNodeSubscribersDrained(cb: ((alias: string, networkId: string | null) => void) | null): void {
|
|
343
|
+
nodeDrainedListener = cb;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function splitClientKey(key: string): { alias: string; networkId: string | null } {
|
|
347
|
+
const i = key.indexOf(":");
|
|
348
|
+
const net = key.slice(0, i);
|
|
349
|
+
return { alias: key.slice(i + 1), networkId: net === "global" ? null : net };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasLiveNodeClient(key: string): boolean {
|
|
353
|
+
return (clients.get(key) ?? []).some((c) => !c.closed && !!c.node);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function notifyNodeDrained(key: string): void {
|
|
357
|
+
if (!nodeDrainedListener) return;
|
|
358
|
+
const { alias, networkId } = splitClientKey(key);
|
|
359
|
+
try { nodeDrainedListener(alias, networkId); } catch (e: any) {
|
|
360
|
+
console.log(`[${ts()}] node drained listener failed: ${e?.message || e}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Is a node-scoped (node-token) subscriber connected on this alias right now? */
|
|
365
|
+
export function hasNodeSubscriber(sessionName: string, networkId?: string | null): boolean {
|
|
366
|
+
return hasLiveNodeClient(clientKey(sessionName, networkId));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Keep only `keep` among the node-scoped clients of `key`; close the others with
|
|
370
|
+
* a final `node_connection_superseded` frame. Dashboard monitors are untouched. */
|
|
371
|
+
function enforceSingleNodeSubscriber(key: string, keep: SSEClient): void {
|
|
372
|
+
const arr = clients.get(key);
|
|
373
|
+
if (!arr || !keep.node) return;
|
|
374
|
+
for (const old of arr) {
|
|
375
|
+
if (old === keep || old.closed || !old.node) continue;
|
|
376
|
+
const a = old.node.instanceId, b = keep.node.instanceId;
|
|
377
|
+
const instanceMatch: NodeIdentityConflict["instanceMatch"] = a && b ? (a === b ? "same" : "different") : "unknown";
|
|
378
|
+
const reason = instanceMatch === "same" ? REPLACED_REASON : SUPERSEDED_REASON;
|
|
379
|
+
tryEnqueueBytes(old, old.encoder.encode(`data: ${JSON.stringify({
|
|
380
|
+
type: "node_connection_superseded",
|
|
381
|
+
reason,
|
|
382
|
+
instance_match: instanceMatch,
|
|
383
|
+
// The newer connection's network origin, so the loser's log says WHERE the
|
|
384
|
+
// other copy runs. Never a token.
|
|
385
|
+
by: { instance_id: keep.node.instanceId, remote: keep.node.remote },
|
|
386
|
+
})}\n\n`));
|
|
387
|
+
closeClient(old, reason);
|
|
388
|
+
if (instanceMatch !== "same" && nodeConflictListener) {
|
|
389
|
+
const { alias, networkId } = splitClientKey(key);
|
|
390
|
+
try {
|
|
391
|
+
nodeConflictListener({
|
|
392
|
+
key: printableKey(key), alias, networkId,
|
|
393
|
+
nodeId: keep.node.nodeId ?? old.node.nodeId,
|
|
394
|
+
instanceMatch, superseded: old.node, current: keep.node, at: Date.now(),
|
|
395
|
+
});
|
|
396
|
+
} catch (e: any) {
|
|
397
|
+
console.log(`[${ts()}] node conflict listener failed: ${e?.message || e}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
pruneClosed(key);
|
|
402
|
+
}
|
|
403
|
+
|
|
271
404
|
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
272
405
|
* Shares the exact same registration / backpressure / liveness
|
|
273
406
|
* machinery as session streams; only the key scheme differs. */
|
|
@@ -295,7 +428,7 @@ export function createUserEventStream(networkId: string, userId: string): Respon
|
|
|
295
428
|
function createStreamForKey(
|
|
296
429
|
key: string,
|
|
297
430
|
initialEvent: Record<string, unknown>,
|
|
298
|
-
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
|
|
431
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean; node?: NodeSubscriberInfo } = {},
|
|
299
432
|
): Response {
|
|
300
433
|
const encoder = new TextEncoder();
|
|
301
434
|
let client: SSEClient;
|
|
@@ -310,6 +443,7 @@ function createStreamForKey(
|
|
|
310
443
|
key,
|
|
311
444
|
...(opts.subscriberUserId ? { subscriberUserId: opts.subscriberUserId } : {}),
|
|
312
445
|
...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
|
|
446
|
+
...(opts.node ? { node: opts.node } : {}),
|
|
313
447
|
};
|
|
314
448
|
|
|
315
449
|
const presenceUser = isUserStreamClient(client) ? client.subscriberUserId! : null;
|
|
@@ -318,6 +452,7 @@ function createStreamForKey(
|
|
|
318
452
|
clients.get(key)!.push(client);
|
|
319
453
|
if (presenceUser) touchUserSeen(presenceUser);
|
|
320
454
|
console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
|
|
455
|
+
if (client.node) enforceSingleNodeSubscriber(key, client);
|
|
321
456
|
|
|
322
457
|
// Send initial connected frame through the backpressure guard so
|
|
323
458
|
// even the first byte respects the contract.
|
|
@@ -406,9 +541,16 @@ function rekeyClient(oldSessionName: string, newSessionName: string, networkId?:
|
|
|
406
541
|
for (const c of oldClients) c.key = newKey;
|
|
407
542
|
|
|
408
543
|
const existing = clients.get(newKey) || [];
|
|
409
|
-
|
|
544
|
+
const merged = existing.concat(oldClients);
|
|
545
|
+
clients.set(newKey, merged);
|
|
410
546
|
clients.delete(oldKey);
|
|
411
547
|
console.log(`[${ts()}] SSE ↔ rekey ${oldKey} → ${newKey} (${oldClients.length} clients)`);
|
|
548
|
+
// #507 — a rename can merge two node-scoped streams onto one key; newest wins.
|
|
549
|
+
const nodeClients = merged.filter((c) => !c.closed && c.node);
|
|
550
|
+
if (nodeClients.length > 1) {
|
|
551
|
+
const newest = nodeClients.reduce((a, b) => (b.node!.connectedAt >= a.node!.connectedAt ? b : a));
|
|
552
|
+
enforceSingleNodeSubscriber(newKey, newest);
|
|
553
|
+
}
|
|
412
554
|
return oldClients.length;
|
|
413
555
|
}
|
|
414
556
|
|
|
@@ -106,6 +106,7 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
106
106
|
client_id_taken: { field: 'client_id', message: 'That client_id was already used for another task.', hint: 'Generate a new client_id for each new task.' },
|
|
107
107
|
// ── 列表 / 事件 ──
|
|
108
108
|
invalid_view: { field: 'view', message: 'view must be full or summary.' },
|
|
109
|
+
invalid_last_event: { field: 'last_event', message: 'last_event must be 1 or 0 (REST). On MCP use include_last_event: true / false.' },
|
|
109
110
|
invalid_overdue: { field: 'overdue', message: 'overdue must be true or false (REST: 1 / 0 / true / false).' },
|
|
110
111
|
invalid_due_within_days: { field: 'due_within_days', message: 'due_within_days must be an integer from 0 to 365 (0 = due later today).' },
|
|
111
112
|
invalid_limit: { field: 'limit', message: 'limit is out of range.' },
|
|
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
|
|
|
373
373
|
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
374
374
|
const listed = await api(ownerToken, "/api/requirements");
|
|
375
375
|
expect(listed.body.capabilities).toContain("due_datetime");
|
|
376
|
-
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events"]);
|
|
376
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event"]);
|
|
377
377
|
});
|
|
378
378
|
|
|
379
379
|
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// #506 —— GET /api/requirements 每行带 last_event:这张卡最新的一条动态(requirement_events,评论也算)。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,临时库;PG 梯子上用 COMMHUB_TEST_PG_URL 原样再跑一遍)。钉住:
|
|
3
|
+
// - 形状:{ type, field, actor: { id, kind, display_name } | null, at, summary? };没有流水的卡 = null(字段总在);
|
|
4
|
+
// - 评论(不改卡的任何一列)成为 last_event,summary 是正文摘要;
|
|
5
|
+
// - 节点(节点令牌)改状态 → actor.kind = "node",display_name = 节点名,summary「pool → doing」;
|
|
6
|
+
// - ETag:没变时 304,加一条评论之后同一个 If-None-Match 不再命中(列表缓存也作废);
|
|
7
|
+
// - 可见范围同行:受限成员看不见的节点 actor = null;scoped 成员看不见的卡根本不在列表里;
|
|
8
|
+
// - view=summary 同样带 last_event;REST last_event=0 不带(其他值 400 invalid_last_event);
|
|
9
|
+
// - MCP requirements_list 默认不带(守住 #471 的上下文预算),include_last_event=true 才带,参数严格(非布尔 → 报错)。
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-req-last-event-"));
|
|
16
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
17
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
18
|
+
|
|
19
|
+
const PW = "LastEventPassw0rd!x";
|
|
20
|
+
let BASE = "";
|
|
21
|
+
let hub: any = null;
|
|
22
|
+
let NET = "";
|
|
23
|
+
let db: any = null;
|
|
24
|
+
let admin = { token: "", id: "" };
|
|
25
|
+
let member = { token: "", id: "" };
|
|
26
|
+
let scoped = { token: "", id: "" };
|
|
27
|
+
let restricted = { token: "", id: "" };
|
|
28
|
+
let nodeToken = "";
|
|
29
|
+
let NODE = "";
|
|
30
|
+
|
|
31
|
+
type R = { status: number; body: any; etag: string | null };
|
|
32
|
+
async function send(token: string, method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
|
|
33
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json", ...headers },
|
|
36
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
37
|
+
});
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let body: any = null;
|
|
40
|
+
try { body = JSON.parse(text); } catch {}
|
|
41
|
+
return { status: res.status, body, etag: res.headers.get("etag") };
|
|
42
|
+
}
|
|
43
|
+
const create = async (t: string, card: Record<string, unknown>) => {
|
|
44
|
+
const r = await send(t, "POST", "/api/requirements", { network_id: NET, ...card });
|
|
45
|
+
expect(r.status).toBe(201);
|
|
46
|
+
return r.body.requirement as { id: string; seq: number };
|
|
47
|
+
};
|
|
48
|
+
const list = (t: string, qs = "", headers: Record<string, string> = {}) => send(t, "GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
|
|
49
|
+
const rowOf = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id);
|
|
50
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<{ data: any; text: string; isError: boolean }> {
|
|
51
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
54
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
55
|
+
});
|
|
56
|
+
const raw = await res.text();
|
|
57
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
58
|
+
const out = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
59
|
+
const text = out.result?.content?.[0]?.text ?? out.error?.message ?? "";
|
|
60
|
+
let data: any = null;
|
|
61
|
+
try { data = JSON.parse(text); } catch {}
|
|
62
|
+
return { data, text, isError: !!out.error || !!out.result?.isError };
|
|
63
|
+
}
|
|
64
|
+
const comment = (t: string, id: string, text: string) => send(t, "POST", `/api/requirements/${id}/comments?network_id=${NET}`, { text });
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
process.env.HOST = "127.0.0.1";
|
|
68
|
+
const { register, createNetworkTokenForNode } = await import("./auth.js");
|
|
69
|
+
db = (await import("./db.js")).db;
|
|
70
|
+
const a = register(`le_admin_${Date.now()}`, PW, undefined, "示例管理员");
|
|
71
|
+
expect(a.ok).toBe(true);
|
|
72
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
73
|
+
NET = a.network_id!;
|
|
74
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
75
|
+
const mod: any = await import("./server.js");
|
|
76
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
77
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
78
|
+
const mk = async (username: string) => {
|
|
79
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
|
|
80
|
+
expect(r.status).toBe(200);
|
|
81
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
82
|
+
expect(login.status).toBe(200);
|
|
83
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
84
|
+
};
|
|
85
|
+
const stamp = Date.now();
|
|
86
|
+
member = await mk(`le_member_${stamp}`);
|
|
87
|
+
scoped = await mk(`le_scoped_${stamp}`);
|
|
88
|
+
restricted = await mk(`le_restricted_${stamp}`);
|
|
89
|
+
db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["示例成员", member.id]);
|
|
90
|
+
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, member.id]);
|
|
91
|
+
db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, scoped.id]);
|
|
92
|
+
// 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
|
|
93
|
+
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
|
|
94
|
+
NODE = `n_le_${stamp}`;
|
|
95
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "le-node", "le-node", NET]);
|
|
96
|
+
const ntok = createNetworkTokenForNode(admin.id, NET, "le-node");
|
|
97
|
+
expect(ntok.ok).toBe(true);
|
|
98
|
+
nodeToken = ntok.token!;
|
|
99
|
+
}, 30_000);
|
|
100
|
+
|
|
101
|
+
afterAll(() => {
|
|
102
|
+
try { hub?.stop?.(true); } catch {}
|
|
103
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("#506 GET /api/requirements last_event", () => {
|
|
107
|
+
test("capability `last_event` is advertised; a fresh card's last_event is its `created` event", async () => {
|
|
108
|
+
const card = await create(member.token, { name: "最新动态示例一" });
|
|
109
|
+
const r = await list(admin.token);
|
|
110
|
+
expect(r.status).toBe(200);
|
|
111
|
+
expect(r.body.capabilities).toContain("last_event");
|
|
112
|
+
const row = rowOf(r, card.id);
|
|
113
|
+
expect(row.last_event).toEqual({ type: "created", field: null, actor: { id: member.id, kind: "user", display_name: "示例成员" }, at: row.last_event.at });
|
|
114
|
+
expect(Date.parse(row.last_event.at)).toBeGreaterThan(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("rows with no events → last_event null (field present)", async () => {
|
|
118
|
+
const card = await create(admin.token, { name: "没有流水的旧卡" });
|
|
119
|
+
// 动态上线前建的卡 / 过了 180 天保留期的卡:流水里没有它
|
|
120
|
+
db.run("DELETE FROM requirement_events WHERE requirement_id = ?1", [card.id]);
|
|
121
|
+
const row = rowOf(await list(admin.token), card.id);
|
|
122
|
+
expect("last_event" in row).toBe(true);
|
|
123
|
+
expect(row.last_event).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a comment becomes last_event (type comment, summary = text), though it changes no column", async () => {
|
|
127
|
+
const card = await create(admin.token, { name: "评论示例" });
|
|
128
|
+
const before = rowOf(await list(admin.token), card.id);
|
|
129
|
+
expect((await comment(member.token, card.id, "第一条进展:\n已经复现, 正在修。")).status).toBe(201);
|
|
130
|
+
const row = rowOf(await list(admin.token), card.id);
|
|
131
|
+
expect(row.updatedAt).toBe(before.updatedAt); // 评论不改卡
|
|
132
|
+
expect(row.last_event).toMatchObject({ type: "comment", field: null, actor: { id: member.id, kind: "user", display_name: "示例成员" }, summary: "第一条进展: 已经复现, 正在修。" });
|
|
133
|
+
// 很长的评论:摘要截断到 120 字
|
|
134
|
+
await comment(admin.token, card.id, "长".repeat(500));
|
|
135
|
+
const long = rowOf(await list(admin.token), card.id).last_event;
|
|
136
|
+
expect(long.actor).toEqual({ id: admin.id, kind: "user", display_name: "示例管理员" });
|
|
137
|
+
expect(long.summary.length).toBe(120);
|
|
138
|
+
expect(long.summary.endsWith("…")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a status change by a node shows kind=node with the node's name and `pool → doing`", async () => {
|
|
142
|
+
const card = await create(admin.token, { name: "节点改状态示例" });
|
|
143
|
+
const p = await send(nodeToken, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { column: "doing" });
|
|
144
|
+
expect(p.status).toBe(200);
|
|
145
|
+
const row = rowOf(await list(admin.token), card.id);
|
|
146
|
+
expect(row.last_event).toMatchObject({ type: "changed", field: "column", actor: { id: NODE, kind: "node", display_name: "le-node" }, summary: "pool → doing" });
|
|
147
|
+
// 节点自己读列表(MCP requirements_list 走同一条路,默认 view=summary)也带着
|
|
148
|
+
const viaNode = rowOf(await list(nodeToken, "&view=summary"), card.id);
|
|
149
|
+
expect(viaNode.last_event).toEqual(row.last_event);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("ETag: 304 while nothing changed; adding a comment changes the ETag (cache invalidated)", async () => {
|
|
153
|
+
const card = await create(admin.token, { name: "ETag 示例" });
|
|
154
|
+
const first = await list(admin.token);
|
|
155
|
+
expect(first.etag).toBeTruthy();
|
|
156
|
+
const again = await list(admin.token, "", { "If-None-Match": first.etag! });
|
|
157
|
+
expect(again.status).toBe(304);
|
|
158
|
+
expect((await comment(admin.token, card.id, "加一条评论")).status).toBe(201);
|
|
159
|
+
const after = await list(admin.token, "", { "If-None-Match": first.etag! });
|
|
160
|
+
expect(after.status).toBe(200);
|
|
161
|
+
expect(after.etag).not.toBe(first.etag);
|
|
162
|
+
expect(rowOf(after, card.id).last_event).toMatchObject({ type: "comment", summary: "加一条评论" });
|
|
163
|
+
// summary 视图的缓存键不同,同样作废
|
|
164
|
+
const s1 = await list(admin.token, "&view=summary");
|
|
165
|
+
await comment(admin.token, card.id, "再加一条");
|
|
166
|
+
const s2 = await list(admin.token, "&view=summary", { "If-None-Match": s1.etag! });
|
|
167
|
+
expect(s2.status).toBe(200);
|
|
168
|
+
expect(rowOf(s2, card.id).last_event.summary).toBe("再加一条");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("visibility: a restricted member sees the node actor masked; a scoped member does not get the row at all", async () => {
|
|
172
|
+
const card = await create(admin.token, { name: "可见范围示例" });
|
|
173
|
+
expect((await send(nodeToken, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
|
|
174
|
+
const seen = rowOf(await list(restricted.token), card.id);
|
|
175
|
+
expect(seen.last_event).toMatchObject({ type: "changed", field: "priority", actor: null, summary: "normal → high" });
|
|
176
|
+
expect(JSON.stringify(seen.last_event)).not.toContain(NODE);
|
|
177
|
+
expect(JSON.stringify(seen.last_event)).not.toContain("le-node");
|
|
178
|
+
// 只看相关任务的成员:这张卡与他无关 → 不在列表里,last_event 也就无从谈起
|
|
179
|
+
const s = await list(scoped.token);
|
|
180
|
+
expect(s.status).toBe(200);
|
|
181
|
+
expect(rowOf(s, card.id)).toBeUndefined();
|
|
182
|
+
expect(JSON.stringify(s.body)).not.toContain(NODE);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("restricted member: an agent_owner change between hidden nodes degrades to a bare `changed` (no field / summary)", async () => {
|
|
186
|
+
const other = `n_le_other_${Date.now()}`;
|
|
187
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [other, "le-other", "le-other", NET]);
|
|
188
|
+
const card = await create(admin.token, { name: "隐去示例", agent_owner: { kind: "node", id: NODE } });
|
|
189
|
+
expect((await send(admin.token, "PATCH", `/api/requirements/${card.id}?network_id=${NET}`, { agent_owner: { kind: "node", id: other } })).status).toBe(200);
|
|
190
|
+
expect(rowOf(await list(admin.token), card.id).last_event).toMatchObject({ type: "changed", field: "agent_owner", actor: { id: admin.id, kind: "user" } });
|
|
191
|
+
const seen = rowOf(await list(restricted.token), card.id).last_event;
|
|
192
|
+
expect(seen).toEqual({ type: "changed", field: null, actor: { id: admin.id, kind: "user", display_name: "示例管理员" }, at: seen.at });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("REST last_event=0 drops the field; any other value is 400 invalid_last_event with field", async () => {
|
|
196
|
+
const card = await create(admin.token, { name: "REST 开关示例" });
|
|
197
|
+
const off = await list(admin.token, "&last_event=0");
|
|
198
|
+
expect(off.status).toBe(200);
|
|
199
|
+
expect("last_event" in rowOf(off, card.id)).toBe(false);
|
|
200
|
+
expect(off.body.requirements.some((r: any) => "last_event" in r)).toBe(false);
|
|
201
|
+
expect(rowOf(await list(admin.token, "&last_event=1"), card.id).last_event).toMatchObject({ type: "created" });
|
|
202
|
+
const bad = await list(admin.token, "&last_event=yes");
|
|
203
|
+
expect(bad.status).toBe(400);
|
|
204
|
+
expect(bad.body).toMatchObject({ ok: false, error: "invalid_last_event", field: "last_event" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("MCP requirements_list: no last_event by default; include_last_event=true adds it; non-boolean rejected", async () => {
|
|
208
|
+
const card = await create(admin.token, { name: "MCP 开关示例" });
|
|
209
|
+
await comment(admin.token, card.id, "MCP 能看到的评论");
|
|
210
|
+
const def = await mcp(nodeToken, "requirements_list", {});
|
|
211
|
+
expect(def.isError).toBe(false);
|
|
212
|
+
expect(def.data.requirements.length).toBeGreaterThan(0);
|
|
213
|
+
expect(def.data.requirements.some((r: any) => "last_event" in r)).toBe(false);
|
|
214
|
+
expect(def.text).not.toContain(`"last_event":`); // (capabilities still lists "last_event" — the Hub supports it)
|
|
215
|
+
const off = await mcp(nodeToken, "requirements_list", { include_last_event: false });
|
|
216
|
+
expect(off.data.requirements.some((r: any) => "last_event" in r)).toBe(false);
|
|
217
|
+
const on = await mcp(nodeToken, "requirements_list", { include_last_event: true });
|
|
218
|
+
expect(on.isError).toBe(false);
|
|
219
|
+
expect(on.data.requirements.every((r: any) => "last_event" in r)).toBe(true);
|
|
220
|
+
expect(on.data.requirements.find((r: any) => r.id === card.id).last_event).toMatchObject({ type: "comment", actor: { id: admin.id, kind: "user" }, summary: "MCP 能看到的评论" });
|
|
221
|
+
const bad = await mcp(nodeToken, "requirements_list", { include_last_event: "yes" });
|
|
222
|
+
expect(bad.isError).toBe(true);
|
|
223
|
+
expect(bad.text).toContain("include_last_event");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("one page of many cards: every row gets its own latest event", async () => {
|
|
227
|
+
const ids: string[] = [];
|
|
228
|
+
for (let i = 0; i < 12; i++) ids.push((await create(admin.token, { name: `批量 ${i}` })).id);
|
|
229
|
+
for (let i = 0; i < ids.length; i += 2) await comment(member.token, ids[i], `评论 ${i}`);
|
|
230
|
+
const r = await list(admin.token, "&limit=50");
|
|
231
|
+
for (let i = 0; i < ids.length; i++) {
|
|
232
|
+
const ev = rowOf(r, ids[i]).last_event;
|
|
233
|
+
if (i % 2 === 0) expect(ev).toMatchObject({ type: "comment", summary: `评论 ${i}` });
|
|
234
|
+
else expect(ev).toMatchObject({ type: "created", actor: { id: admin.id } });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -124,7 +124,11 @@ describe("view=summary", () => {
|
|
|
124
124
|
expect(summary.body.requirements.map((r: any) => r.id)).toEqual(full.body.requirements.map((r: any) => r.id));
|
|
125
125
|
const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
|
|
126
126
|
expect(one.body.requirement.description).toBe(f.description);
|
|
127
|
-
|
|
127
|
+
// 比的是 summary 省掉的那部分(正文 / 子任务):两边一样多的 last_event(#506,每行约 150 B)不算,
|
|
128
|
+
// 否则这张只有几行的小表上,固定开销会把比例拉过一半。
|
|
129
|
+
const bare = (body: any) => JSON.stringify({ ...body, requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
|
|
130
|
+
expect(summary.body.requirements.every((r: any) => "last_event" in r)).toBe(true);
|
|
131
|
+
expect(bare(summary.body)).toBeLessThan(bare(full.body) / 2);
|
|
128
132
|
});
|
|
129
133
|
|
|
130
134
|
test("view=full is the plain list; an unknown view is a 400", async () => {
|
package/src/requirements.ts
CHANGED
|
@@ -729,7 +729,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
729
729
|
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
730
730
|
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
731
731
|
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
732
|
-
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events"] as const;
|
|
732
|
+
export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event"] as const;
|
|
733
733
|
|
|
734
734
|
// ── 子需求 ──
|
|
735
735
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -1260,6 +1260,95 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
|
|
|
1260
1260
|
return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
|
|
1261
1261
|
}
|
|
1262
1262
|
|
|
1263
|
+
// ── 列表每行的「最新动态」(#506)──
|
|
1264
|
+
// 卡片上一行「谁 · 刚刚 · 干了什么」。以前 App 只能拿 updatedAt / updated_by 拼,评论不改卡、所以不算;也说不出改了什么。
|
|
1265
|
+
// 这里从 requirement_events(评论也在里面,kind=comment)取每张卡最新的一条:一页只一次查询
|
|
1266
|
+
// (MAX(id) GROUP BY,走 idx_requirement_events_card;SQLite / PostgreSQL 同一句),再按出现的人一次查名字。
|
|
1267
|
+
// 只算这一页的卡(已经按调用者可见范围筛过);受限成员看不见的节点:actor 隐去,
|
|
1268
|
+
// 人员字段(owner / agent_owner / participants)的改动隐去之后前后一样的,只剩「改过」(field / summary 都不给)——
|
|
1269
|
+
// 与 updatedAt 透露的信息相同,不多泄一个字。180 天保留期外 / 没有流水的卡 = null。
|
|
1270
|
+
export type LastEvent = {
|
|
1271
|
+
type: string;
|
|
1272
|
+
field: string | null;
|
|
1273
|
+
actor: { id: string; kind: "user" | "node"; display_name: string | null } | null;
|
|
1274
|
+
at: string;
|
|
1275
|
+
summary?: string;
|
|
1276
|
+
};
|
|
1277
|
+
const LAST_EVENT_SUMMARY_MAX = 120;
|
|
1278
|
+
const clip = (s: string) => (s.length > LAST_EVENT_SUMMARY_MAX ? `${s.slice(0, LAST_EVENT_SUMMARY_MAX - 1)}…` : s);
|
|
1279
|
+
const isRef = (v: unknown): v is { kind: string; id: string } => !!v && typeof v === "object" && typeof (v as { id?: unknown }).id === "string" && ((v as { kind?: unknown }).kind === "user" || (v as { kind?: unknown }).kind === "node");
|
|
1280
|
+
|
|
1281
|
+
/** 一条流水 → 一句话(人员字段不给:名字另有 actor 显示,引用里的 id 对卡片没用)。 */
|
|
1282
|
+
function lastEventSummary(ev: ReturnType<typeof eventPublic>): string | undefined {
|
|
1283
|
+
const text = (v: unknown) => (v === null || v === undefined || v === "" ? "—" : String(v));
|
|
1284
|
+
if (ev.kind === "comment") {
|
|
1285
|
+
const t = (ev.new as { text?: unknown } | null)?.text;
|
|
1286
|
+
return typeof t === "string" ? clip(t.replace(/\s+/g, " ").trim()) : undefined;
|
|
1287
|
+
}
|
|
1288
|
+
if (ev.kind !== "changed") return undefined;
|
|
1289
|
+
switch (ev.field) {
|
|
1290
|
+
case "column": case "priority": case "due": case "start": case "archived": case "project": case "parent":
|
|
1291
|
+
return clip(`${text(ev.old)} → ${text(ev.new)}`);
|
|
1292
|
+
case "title": return typeof ev.new === "string" ? clip(ev.new) : undefined;
|
|
1293
|
+
case "checklist_item": {
|
|
1294
|
+
const item = ev.new as { text?: unknown; done?: unknown } | null;
|
|
1295
|
+
return item && typeof item.text === "string" ? clip(`${item.done ? "[x]" : "[ ]"} ${item.text}`) : undefined;
|
|
1296
|
+
}
|
|
1297
|
+
case "checklist": {
|
|
1298
|
+
const c = ev.new as { total?: unknown; done?: unknown } | null;
|
|
1299
|
+
return c ? `${Number(c.done ?? 0)}/${Number(c.total ?? 0)}` : undefined;
|
|
1300
|
+
}
|
|
1301
|
+
case "tags": return Array.isArray(ev.new) ? clip(ev.new.join(", ")) : undefined;
|
|
1302
|
+
default: return undefined;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function lastEventsFor(ctx: RequirementsRequestContext, rows: readonly Row[]): Map<string, LastEvent> {
|
|
1307
|
+
const out = new Map<string, LastEvent>();
|
|
1308
|
+
if (!rows.length) return out;
|
|
1309
|
+
const ids = rows.map(r => r.requirement_id);
|
|
1310
|
+
const marks = ids.map((_, i) => `?${i + 1}`).join(",");
|
|
1311
|
+
const events = db.all<EventRow>(
|
|
1312
|
+
`SELECT id, network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at FROM requirement_events
|
|
1313
|
+
WHERE id IN (SELECT MAX(id) FROM requirement_events WHERE requirement_id IN (${marks}) GROUP BY requirement_id)`,
|
|
1314
|
+
...ids,
|
|
1315
|
+
);
|
|
1316
|
+
if (!events.length) return out;
|
|
1317
|
+
const pubs = events.map(eventPublic);
|
|
1318
|
+
const userIds = new Set<string>(), nodeIds = new Set<string>();
|
|
1319
|
+
for (const ev of pubs) if (isRef(ev.actor)) (ev.actor.kind === "user" ? userIds : nodeIds).add(ev.actor.id);
|
|
1320
|
+
// 名字规则同 GET /api/requirements/people 的 name(没设显示名 → 用户名 / alias / node_name)。
|
|
1321
|
+
const names = new Map<string, string>();
|
|
1322
|
+
if (userIds.size) {
|
|
1323
|
+
const u = [...userIds];
|
|
1324
|
+
for (const r of db.all<{ id: string; name: string }>(`SELECT user_id AS id, COALESCE(NULLIF(display_name,''), username) AS name FROM users WHERE user_id IN (${u.map((_, i) => `?${i + 1}`).join(",")})`, ...u)) names.set(`user:${r.id}`, r.name);
|
|
1325
|
+
}
|
|
1326
|
+
if (nodeIds.size) {
|
|
1327
|
+
const n = [...nodeIds];
|
|
1328
|
+
for (const r of db.all<{ id: string; name: string }>(`SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE node_id IN (${n.map((_, i) => `?${i + 1}`).join(",")})`, ...n)) names.set(`node:${r.id}`, r.name);
|
|
1329
|
+
}
|
|
1330
|
+
const networkOf = new Map(rows.map(r => [r.requirement_id, r.network_id] as const));
|
|
1331
|
+
const hiddenBy = new Map<string, HiddenNode>();
|
|
1332
|
+
for (const ev of pubs) {
|
|
1333
|
+
const networkId = networkOf.get(ev.requirement_id)!;
|
|
1334
|
+
if (!hiddenBy.has(networkId)) hiddenBy.set(networkId, hiddenNodeFilter(ctx, networkId));
|
|
1335
|
+
const hidden = hiddenBy.get(networkId)!;
|
|
1336
|
+
const actor = isRef(ev.actor) && !isHiddenRef(ev.actor, hidden)
|
|
1337
|
+
? { id: ev.actor.id, kind: ev.actor.kind as "user" | "node", display_name: names.get(`${ev.actor.kind}:${ev.actor.id}`) ?? null }
|
|
1338
|
+
: null;
|
|
1339
|
+
let field = ev.field;
|
|
1340
|
+
let summary = lastEventSummary(ev);
|
|
1341
|
+
if (hidden && (field === "owner" || field === "agent_owner" || field === "participants")) {
|
|
1342
|
+
const strip = (v: unknown) => (Array.isArray(v) ? v.filter(ref => !isHiddenRef(ref, hidden)) : isHiddenRef(v, hidden) ? null : v);
|
|
1343
|
+
if (JSON.stringify(strip(ev.old)) === JSON.stringify(strip(ev.new))) { field = null; summary = undefined; }
|
|
1344
|
+
}
|
|
1345
|
+
const last: LastEvent = { type: ev.kind, field, actor, at: ev.at };
|
|
1346
|
+
if (summary !== undefined) last.summary = summary;
|
|
1347
|
+
out.set(ev.requirement_id, last);
|
|
1348
|
+
}
|
|
1349
|
+
return out;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1263
1352
|
// ── 任务动态(requirement-events.ts)──
|
|
1264
1353
|
// GET /api/requirements/events?network_id&since&limit&cursor&requirement_id:一个网络的改动流水,按 id 从新到旧。
|
|
1265
1354
|
// 可见范围与列表相同:卡还在 → 按当前这张卡判断调用者看不看得见;删掉了 → 按墓碑(过了墓碑保留期的删除,
|
|
@@ -1491,6 +1580,10 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1491
1580
|
if (view !== null && view !== "full" && view !== "summary") return jsonError("invalid_view", 400);
|
|
1492
1581
|
const changes = url.searchParams.get("changes") === "1";
|
|
1493
1582
|
if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
|
|
1583
|
+
// last_event=0:不带每行的 last_event(MCP requirements_list 默认这么调,保住 #471 的 Agent 上下文预算)。不带 / =1 = 带。
|
|
1584
|
+
const lastEventParam = url.searchParams.get("last_event");
|
|
1585
|
+
if (lastEventParam !== null && lastEventParam !== "0" && lastEventParam !== "1") return jsonError("invalid_last_event", 400);
|
|
1586
|
+
const withLastEvent = lastEventParam !== "0";
|
|
1494
1587
|
// changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
|
|
1495
1588
|
const serverTime = new Date().toISOString();
|
|
1496
1589
|
nodeReadNote(ctx, "GET /api/requirements", null);
|
|
@@ -1505,7 +1598,9 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1505
1598
|
return conditionalBody(ctx.req, cached.body, cached.etag);
|
|
1506
1599
|
}
|
|
1507
1600
|
const page = listPage(ctx, filtered, params, lq);
|
|
1508
|
-
|
|
1601
|
+
// last_event(#506):每行最新一条动态(含评论),没有 = null。旧 App 不认识,忽略即可。
|
|
1602
|
+
const lastEvents = withLastEvent ? lastEventsFor(ctx, page.rows) : null;
|
|
1603
|
+
const rows = lastEvents ? page.rows.map(row => ({ ...toPublicFor(ctx, row), last_event: lastEvents.get(row.requirement_id) ?? null })) : page.rows.map(row => toPublicFor(ctx, row));
|
|
1509
1604
|
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
1510
1605
|
// has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
|
|
1511
1606
|
const payload: Record<string, unknown> = { ok: true, requirements: view === "summary" ? rows.map(toSummary) : rows, capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor };
|
package/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
18
18
|
import { z } from "zod/v4";
|
|
19
19
|
import { registerTools } from "./tools.js";
|
|
20
20
|
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
21
|
+
import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
|
|
21
22
|
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
22
23
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
23
24
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
@@ -245,6 +246,24 @@ function getClientIP(req: Request, server?: any): string {
|
|
|
245
246
|
return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// #507 — what identifies one node-scoped SSE connection (no tokens). instance_id is
|
|
250
|
+
// optional and client-supplied (`X-Anet-Instance-Id` header or `?instance_id=`); no
|
|
251
|
+
// released agent-node sends it yet, so absence means "unknown instance".
|
|
252
|
+
const INSTANCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
253
|
+
function nodeSubscriberInfo(req: Request, server: any, tokenId: string | null, alias: string, networkId: string) {
|
|
254
|
+
const rawInstance = req.headers.get("x-anet-instance-id") ?? new URL(req.url).searchParams.get("instance_id");
|
|
255
|
+
const instanceId = rawInstance && INSTANCE_ID_RE.test(rawInstance) ? rawInstance : null;
|
|
256
|
+
const direct = server?.requestIP?.(req);
|
|
257
|
+
const fwd = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
258
|
+
const hop = direct?.address ? `${direct.address}:${direct.port}` : null;
|
|
259
|
+
const remote = fwd ? `${fwd}${hop ? ` via ${hop}` : ""}` : hop;
|
|
260
|
+
const bound = tokenId
|
|
261
|
+
? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id ?? null
|
|
262
|
+
: null;
|
|
263
|
+
const nodeId = bound ?? db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, networkId)?.node_id ?? null;
|
|
264
|
+
return { nodeId, instanceId, remote, userAgent: req.headers.get("user-agent")?.slice(0, 200) ?? null };
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
function isLocalhostIP(ip: string): boolean {
|
|
249
268
|
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
|
|
250
269
|
}
|
|
@@ -1134,6 +1153,12 @@ return Bun.serve({
|
|
|
1134
1153
|
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
|
|
1135
1154
|
}));
|
|
1136
1155
|
if (streamDenied) return streamDenied;
|
|
1156
|
+
// #507 — the node's OWN stream (token bound to this alias) is node-scoped:
|
|
1157
|
+
// at most one per (network, alias), newest wins. A node token watching some
|
|
1158
|
+
// other alias stays a plain monitor and can never supersede that node.
|
|
1159
|
+
if (authCtx.tokenName === `node:${sessionName}` && authCtx.networkId === scopedNetId) {
|
|
1160
|
+
return createSSEStream(sessionName, scopedNetId, { node: nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId) });
|
|
1161
|
+
}
|
|
1137
1162
|
return createSSEStream(sessionName, scopedNetId);
|
|
1138
1163
|
}
|
|
1139
1164
|
|
|
@@ -2573,13 +2598,13 @@ return Bun.serve({
|
|
|
2573
2598
|
const bound = restAuth.networkId;
|
|
2574
2599
|
const sse = getSSEStats();
|
|
2575
2600
|
const sessions = Object.fromEntries(Object.entries(sse.sessions).filter(([key]) => key.split(":").includes(bound)));
|
|
2576
|
-
return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound }));
|
|
2601
|
+
return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound, identity_conflicts: recentNodeIdentityConflicts(bound) }));
|
|
2577
2602
|
}
|
|
2578
2603
|
if (restAuth && !isAdmin) {
|
|
2579
2604
|
return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
|
|
2580
2605
|
}
|
|
2581
2606
|
const sse = getSSEStats();
|
|
2582
|
-
return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions }));
|
|
2607
|
+
return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions, identity_conflicts: recentNodeIdentityConflicts() }));
|
|
2583
2608
|
}
|
|
2584
2609
|
|
|
2585
2610
|
// ── REST: all sessions status ──
|
package/src/tools.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { parseAliasFilter } from "./alias-filter.js";
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
10
10
|
import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
|
|
11
|
+
import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
|
|
11
12
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
12
13
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
13
14
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
@@ -963,6 +964,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
963
964
|
db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
|
|
964
965
|
} catch {}
|
|
965
966
|
}
|
|
967
|
+
// #507 — a second copy of this node (same token + alias) may still be connected;
|
|
968
|
+
// its sibling's shutdown offline must not take the node offline under it.
|
|
969
|
+
const offlineDeferred = deferOfflineIfAnotherCopyConnected(effectiveAlias, sessionNetId, status, resume_id);
|
|
970
|
+
const priorStatus = offlineDeferred
|
|
971
|
+
? db.get<{ status: string | null }>("SELECT status FROM sessions WHERE resume_id = ?1", resume_id)?.status
|
|
972
|
+
: null;
|
|
973
|
+
const storedStatus = offlineDeferred ? (priorStatus && priorStatus !== "offline" ? priorStatus : "idle") : status;
|
|
966
974
|
const trimmedOutput = output?.slice(0, 4000);
|
|
967
975
|
const hostHostname = host?.hostname || hn || null;
|
|
968
976
|
const hostIp = host?.ip || clientIP || null;
|
|
@@ -1063,7 +1071,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1063
1071
|
external_schedules = COALESCE(?34, sessions.external_schedules),
|
|
1064
1072
|
peer_reply_inbox_capable = ?35,
|
|
1065
1073
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
1066
|
-
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null,
|
|
1074
|
+
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
|
|
1067
1075
|
);
|
|
1068
1076
|
if (handover) {
|
|
1069
1077
|
// 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
|
|
@@ -1133,7 +1141,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1133
1141
|
type: "status_update",
|
|
1134
1142
|
alias: effectiveAlias,
|
|
1135
1143
|
...(canonical.renamed ? { renamed_from: alias } : {}),
|
|
1136
|
-
status,
|
|
1144
|
+
status: storedStatus,
|
|
1137
1145
|
progress: progress ?? null,
|
|
1138
1146
|
host: statusHostTelemetry,
|
|
1139
1147
|
process_telemetry: statusProcessTelemetry,
|
|
@@ -1205,6 +1213,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1205
1213
|
resume_id,
|
|
1206
1214
|
alias: effectiveAlias,
|
|
1207
1215
|
...(canonical.renamed ? { renamed_from: alias } : {}),
|
|
1216
|
+
...(offlineDeferred ? { offline_deferred: true, reason: "node_identity_conflict" } : {}),
|
|
1208
1217
|
inbox_count: row?.cnt ?? 0,
|
|
1209
1218
|
}),
|
|
1210
1219
|
},
|
|
@@ -5969,12 +5978,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5969
5978
|
cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
|
|
5970
5979
|
view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
|
|
5971
5980
|
changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
|
|
5981
|
+
include_last_event: z.boolean().optional().describe("default false; true adds last_event per row (newest event incl. comments: type, field, actor, at, summary; null if none), ~150 B/row"),
|
|
5972
5982
|
};
|
|
5973
5983
|
const reqListKeys = Object.keys(reqListShape);
|
|
5974
5984
|
server.registerTool(
|
|
5975
5985
|
"requirements_list",
|
|
5976
5986
|
{
|
|
5977
|
-
description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), overdue / due_within_days (open tasks only), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}. changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
|
|
5987
|
+
description: `List requirement tasks in your network, newest first. Defaults (MCP): view='summary' (no description / checklist bodies; has_description, checklist_count instead; ~0.5 KB per task) and ${REQ_LIST_MCP_DEFAULT_LIMIT} per page; has_more + next_cursor → pass cursor. Full text: requirements_get, or view='full' with a small limit (≤ 1000). Filters: seq (#N), status, project_id ('none'), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), tag (exact), updated_since (ISO), overdue / due_within_days (open tasks only), external_ref, parent_id / top_level, department_id (owner in it or below), include_archived; q searches title / description / people / project / tags (terms ANDed). Rows carry children {total, done}; include_last_event=true adds last_event (newest event incl. comments). changes=true (with updated_since) returns only tasks changed since then, archived included, plus deleted ids and server_time for the next updated_since. Unknown parameters are rejected (-32602).`,
|
|
5978
5988
|
// Strict: registerTool honours a constructed object's unknownKeys (server.tool() would strip them silently).
|
|
5979
5989
|
inputSchema: z.strictObject(reqListShape, {
|
|
5980
5990
|
error: (iss: any) => iss.code === "unrecognized_keys"
|
|
@@ -5990,6 +6000,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5990
6000
|
if (args.include_archived) q.set("include_archived", "1");
|
|
5991
6001
|
if (args.top_level) q.set("top_level", "1");
|
|
5992
6002
|
if (args.changes) q.set("changes", "1");
|
|
6003
|
+
// #506 —— REST 每行默认带 last_event(App 用);MCP 默认不带,守住 #471 的上下文预算。
|
|
6004
|
+
if (!args.include_last_event) q.set("last_event", "0");
|
|
5993
6005
|
if (args.overdue !== undefined) q.set("overdue", args.overdue ? "1" : "0");
|
|
5994
6006
|
if (args.due_within_days !== undefined) q.set("due_within_days", String(args.due_within_days));
|
|
5995
6007
|
return requirementsCall("GET", `/api/requirements?${q}`, args.network_id);
|