@sleep2agi/commhub-server 0.9.0-preview.64 → 0.9.0-preview.66
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/db.ts +4 -1
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.ts +17 -1
- package/src/requirements-http.test.ts +250 -4
- package/src/requirements-migrate.ts +44 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +76 -0
- package/src/requirements.ts +278 -13
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -0
- package/src/server.ts +1 -0
- package/src/tools.ts +78 -10
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.66",
|
|
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",
|
package/src/db.ts
CHANGED
|
@@ -133,6 +133,9 @@ for (const col of [
|
|
|
133
133
|
// Project folder view — the same session answers files_list / file_read on the
|
|
134
134
|
// rules-file doorbell (node-files.ts). Sticky, bound-token only, like above.
|
|
135
135
|
{ name: "files_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
136
|
+
// Node run-log view — the same session answers logs_tail on the rules-file
|
|
137
|
+
// doorbell (node-logs.ts). Sticky, bound-token only, like above.
|
|
138
|
+
{ name: "logs_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
136
139
|
]) {
|
|
137
140
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
138
141
|
}
|
|
@@ -972,7 +975,7 @@ db.exec(`
|
|
|
972
975
|
try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
|
|
973
976
|
try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
|
|
974
977
|
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
|
|
975
|
-
for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'"]) {
|
|
978
|
+
for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT"]) {
|
|
976
979
|
try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
|
|
977
980
|
}
|
|
978
981
|
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Node run-log view — tail_node_logs rides the rules-file queue + doorbell (op logs_tail).
|
|
2
|
+
//
|
|
3
|
+
// Hub-side contract verified here:
|
|
4
|
+
// 1. report_status { logs_capable: true } is sticky and set only by the node's
|
|
5
|
+
// own token; /api/status-shaped rows expose it.
|
|
6
|
+
// 2. The pulled request carries only the normalised filter JSON — no path field
|
|
7
|
+
// exists anywhere in the tool schema; unknown args are dropped.
|
|
8
|
+
// 3. Who may ask: user logins only (node tokens refused); the network owner/admin,
|
|
9
|
+
// or the node's owner (nodes.owner_user_id). A member who does not own the
|
|
10
|
+
// node, a viewer, and a user of another network are refused before any row.
|
|
11
|
+
// 4. Who may read: only the token that asked (like the project folder).
|
|
12
|
+
// 5. Storage: the result is handed out ONCE — the first terminal read purges
|
|
13
|
+
// result_content and the filter JSON; the second read says content_purged.
|
|
14
|
+
// An unread terminal result is purged by the sweep after LOGS_CONTENT_TTL_MS.
|
|
15
|
+
// 6. Own single-flight lane; logs acks may carry up to 1 MiB.
|
|
16
|
+
//
|
|
17
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/logs-transport.db bun test src/node-logs-transport.test.ts
|
|
18
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
20
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { db } from "./db.js";
|
|
23
|
+
import { registerTools } from "./tools.js";
|
|
24
|
+
import { LOGS_CONTENT_TTL_MS, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
25
|
+
|
|
26
|
+
const NET = "net_logs_t";
|
|
27
|
+
const OTHER_NET = "net_logs_t_other";
|
|
28
|
+
const OWNER = "u_logs_net_owner";
|
|
29
|
+
const MEMBER = "u_logs_member_owns_a";
|
|
30
|
+
const MEMBER2 = "u_logs_member_plain";
|
|
31
|
+
const VIEWER = "u_logs_viewer";
|
|
32
|
+
const OUTSIDER = "u_logs_outsider";
|
|
33
|
+
const NODE_A = "logs-node-a";
|
|
34
|
+
const NODE_B = "logs-node-b";
|
|
35
|
+
const NODE_A_ID = "node_logs_a";
|
|
36
|
+
const NODE_B_ID = "node_logs_b";
|
|
37
|
+
const T_A = "tok_logs_node_a";
|
|
38
|
+
const T_B = "tok_logs_node_b";
|
|
39
|
+
|
|
40
|
+
function cleanup() {
|
|
41
|
+
for (const net of [NET, OTHER_NET]) {
|
|
42
|
+
for (const t of ["node_rules_requests", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
|
|
43
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [net]); } catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
|
|
47
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function seedWorld() {
|
|
52
|
+
for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
|
|
53
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [u, u]);
|
|
54
|
+
}
|
|
55
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
|
|
56
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [OTHER_NET, OTHER_NET, OUTSIDER]);
|
|
57
|
+
const member = (u: string, net: string, role: string) =>
|
|
58
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
|
|
59
|
+
member(OWNER, NET, "owner");
|
|
60
|
+
member(MEMBER, NET, "member");
|
|
61
|
+
member(MEMBER2, NET, "member");
|
|
62
|
+
member(VIEWER, NET, "viewer");
|
|
63
|
+
member(OUTSIDER, OTHER_NET, "owner");
|
|
64
|
+
const node = (id: string, alias: string, owner: string | null) => db.run(
|
|
65
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
|
|
66
|
+
[id, alias, alias, NET, owner],
|
|
67
|
+
);
|
|
68
|
+
node(NODE_A_ID, NODE_A, MEMBER);
|
|
69
|
+
node(NODE_B_ID, NODE_B, OWNER);
|
|
70
|
+
const tok = (id: string, alias: string, bound: string) => db.run(
|
|
71
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
72
|
+
[id, OWNER, NET, `node:${alias}`, `hash_${id}`, bound],
|
|
73
|
+
);
|
|
74
|
+
tok(T_A, NODE_A, NODE_A_ID);
|
|
75
|
+
tok(T_B, NODE_B, NODE_B_ID);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Identity = { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
79
|
+
const asOwner: Identity = { net: null, user: OWNER, alias: OWNER, isNetworkToken: false, tokenId: "tok_logs_owner_login" };
|
|
80
|
+
const asOwnerOtherLogin: Identity = { ...asOwner, tokenId: "tok_logs_owner_login_2" };
|
|
81
|
+
const asMember: Identity = { net: null, user: MEMBER, alias: MEMBER, isNetworkToken: false, tokenId: "tok_logs_member_login" };
|
|
82
|
+
const asMember2: Identity = { net: null, user: MEMBER2, alias: MEMBER2, isNetworkToken: false, tokenId: "tok_logs_member2_login" };
|
|
83
|
+
const asViewer: Identity = { net: null, user: VIEWER, alias: VIEWER, isNetworkToken: false, tokenId: "tok_logs_viewer_login" };
|
|
84
|
+
const asOutsider: Identity = { net: null, user: OUTSIDER, alias: OUTSIDER, isNetworkToken: false, tokenId: "tok_logs_outsider_login" };
|
|
85
|
+
const asA: Identity = { net: NET, user: OWNER, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
|
|
86
|
+
const asB: Identity = { net: NET, user: OWNER, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
|
|
87
|
+
|
|
88
|
+
async function connect(id: Identity) {
|
|
89
|
+
const server = new McpServer({ name: "logs-test", version: "1" });
|
|
90
|
+
registerTools(server, undefined, id.net, id.user, id.alias, id.isNetworkToken, id.tokenId);
|
|
91
|
+
const client = new Client({ name: "logs-client", version: "1" });
|
|
92
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
93
|
+
await server.connect(st);
|
|
94
|
+
await client.connect(ct);
|
|
95
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
96
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
97
|
+
const first = r.content?.[0];
|
|
98
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
|
|
99
|
+
try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
|
|
100
|
+
};
|
|
101
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
102
|
+
return { call, close };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
|
|
106
|
+
const row = (id: string) => db.get<any>("SELECT op, content, result_content, content_purged_at FROM node_rules_requests WHERE request_id = ?1", id);
|
|
107
|
+
const SAMPLE = JSON.stringify({ files: ["2026-09-29.log"], lines: [{ ts: 1, level: "info", text: "[10:00:00] [INFO ] [x] hello", key: "2026-09-29.log:0" }], truncated: false, matched: 1, now_ts: 2 });
|
|
108
|
+
|
|
109
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
110
|
+
afterAll(cleanup);
|
|
111
|
+
|
|
112
|
+
describe("logs_capable", () => {
|
|
113
|
+
test("set by the node's own token, sticky, not settable by a user login", async () => {
|
|
114
|
+
const a = await connect(asA);
|
|
115
|
+
try {
|
|
116
|
+
await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, logs_capable: true });
|
|
117
|
+
await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID });
|
|
118
|
+
} finally { await a.close(); }
|
|
119
|
+
expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.c).toBe(1);
|
|
120
|
+
const u = await connect(asOwner);
|
|
121
|
+
try {
|
|
122
|
+
await u.call("report_status", { resume_id: "user-logs-report", alias: NODE_B, status: "idle", network_id: NET, logs_capable: true });
|
|
123
|
+
} finally { await u.close(); }
|
|
124
|
+
expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_B, NET)?.c ?? 0).toBe(0);
|
|
125
|
+
}, 20_000);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("round trip", () => {
|
|
129
|
+
test("the node pulls only the normalised filter JSON (no path), acks, the asker reads it once", async () => {
|
|
130
|
+
const u = await connect(asOwner);
|
|
131
|
+
const a = await connect(asA);
|
|
132
|
+
try {
|
|
133
|
+
const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 99999 > 2000 ? 2000 : 1, level: "warn", grep: "boom", since_ts: 5, path: "/etc/passwd" });
|
|
134
|
+
expect(enq).toMatchObject({ ok: true, op: "logs_tail" });
|
|
135
|
+
const pulled = (await a.call("get_rules_file_request", {})).request;
|
|
136
|
+
expect(pulled.op).toBe("logs_tail");
|
|
137
|
+
expect(JSON.parse(pulled.content)).toEqual({ lines: 2000, level: "warn", grep: "boom", since_ts: 5 });
|
|
138
|
+
expect(pulled.content).not.toContain("passwd");
|
|
139
|
+
expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "logs", exists: true, content: SAMPLE })).toMatchObject({ ok: true, status: "done" });
|
|
140
|
+
|
|
141
|
+
const first = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
|
|
142
|
+
expect(first).toMatchObject({ ok: true, op: "logs_tail", status: "done", content: SAMPLE });
|
|
143
|
+
// Read-once: purged in the same call, including the filter JSON.
|
|
144
|
+
expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
|
|
145
|
+
expect(row(enq.request_id).content_purged_at).toBeGreaterThan(0);
|
|
146
|
+
const second = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
|
|
147
|
+
expect(second.content).toBeUndefined();
|
|
148
|
+
expect(second.content_purged).toBe(true);
|
|
149
|
+
} finally { await u.close(); await a.close(); }
|
|
150
|
+
}, 20_000);
|
|
151
|
+
|
|
152
|
+
test("lines above 2000 are refused by the schema; bad level refused", async () => {
|
|
153
|
+
const u = await connect(asOwner);
|
|
154
|
+
try {
|
|
155
|
+
expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 2001 })).ok).not.toBe(true);
|
|
156
|
+
expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, level: "debug" })).ok).not.toBe(true);
|
|
157
|
+
expect(rowCount()).toBe(0);
|
|
158
|
+
} finally { await u.close(); }
|
|
159
|
+
}, 20_000);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("who may ask", () => {
|
|
163
|
+
test("node tokens are refused — for other nodes and for themselves", async () => {
|
|
164
|
+
const a = await connect(asA);
|
|
165
|
+
try {
|
|
166
|
+
expect(await a.call("tail_node_logs", { node_id: NODE_B_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
|
|
167
|
+
expect(await a.call("tail_node_logs", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
|
|
168
|
+
expect(rowCount()).toBe(0);
|
|
169
|
+
} finally { await a.close(); }
|
|
170
|
+
}, 20_000);
|
|
171
|
+
|
|
172
|
+
test("node owner yes; plain member no; viewer no; network owner yes", async () => {
|
|
173
|
+
const m = await connect(asMember);
|
|
174
|
+
const m2 = await connect(asMember2);
|
|
175
|
+
const v = await connect(asViewer);
|
|
176
|
+
const o = await connect(asOwner);
|
|
177
|
+
try {
|
|
178
|
+
expect(await m.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
|
|
179
|
+
expect(await m.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
|
|
180
|
+
expect(await m2.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
|
|
181
|
+
expect((await v.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).ok).toBe(false);
|
|
182
|
+
expect(await o.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: true });
|
|
183
|
+
expect(rowCount()).toBe(2);
|
|
184
|
+
} finally { await m.close(); await m2.close(); await v.close(); await o.close(); }
|
|
185
|
+
}, 20_000);
|
|
186
|
+
|
|
187
|
+
test("a user of another network is refused (cross network)", async () => {
|
|
188
|
+
const x = await connect(asOutsider);
|
|
189
|
+
try {
|
|
190
|
+
const r1 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
|
|
191
|
+
expect(r1.ok).toBe(false);
|
|
192
|
+
const r2 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: OTHER_NET });
|
|
193
|
+
expect(r2).toMatchObject({ ok: false, error: "cross_network_node" });
|
|
194
|
+
expect(rowCount()).toBe(0);
|
|
195
|
+
} finally { await x.close(); }
|
|
196
|
+
}, 20_000);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("who may read", () => {
|
|
200
|
+
test("only the login that asked; another login of the same user, node tokens and the serving node cannot", async () => {
|
|
201
|
+
const u = await connect(asOwner);
|
|
202
|
+
const other = await connect(asOwnerOtherLogin);
|
|
203
|
+
const b = await connect(asB);
|
|
204
|
+
const a = await connect(asA);
|
|
205
|
+
try {
|
|
206
|
+
const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
|
|
207
|
+
await a.call("get_rules_file_request", {});
|
|
208
|
+
await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
|
|
209
|
+
expect(await other.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
210
|
+
expect(await b.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
211
|
+
expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
212
|
+
// Those refusals did not consume the read-once result.
|
|
213
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ status: "done", content: SAMPLE });
|
|
214
|
+
} finally { await u.close(); await other.close(); await b.close(); await a.close(); }
|
|
215
|
+
}, 20_000);
|
|
216
|
+
|
|
217
|
+
test("node B never pulls or acks node A's logs request", async () => {
|
|
218
|
+
const u = await connect(asOwner);
|
|
219
|
+
const a = await connect(asA);
|
|
220
|
+
const b = await connect(asB);
|
|
221
|
+
try {
|
|
222
|
+
const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
|
|
223
|
+
expect((await b.call("get_rules_file_request", {})).request).toBeNull();
|
|
224
|
+
expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
|
|
225
|
+
expect((await a.call("get_rules_file_request", {})).request.request_id).toBe(enq.request_id);
|
|
226
|
+
} finally { await u.close(); await a.close(); await b.close(); }
|
|
227
|
+
}, 20_000);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("storage and lanes", () => {
|
|
231
|
+
test("an unread terminal result is purged by the sweep after the logs TTL; other ops keep theirs", async () => {
|
|
232
|
+
const u = await connect(asOwner);
|
|
233
|
+
const a = await connect(asA);
|
|
234
|
+
try {
|
|
235
|
+
const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
|
|
236
|
+
const rules = await u.call("read_node_rules_file", { node_id: NODE_A_ID, network_id: NET });
|
|
237
|
+
await a.call("get_rules_file_request", {});
|
|
238
|
+
await a.call("get_rules_file_request", {});
|
|
239
|
+
await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
|
|
240
|
+
await a.call("ack_rules_file_request", { request_id: rules.request_id, status: "done", content: "# rules" });
|
|
241
|
+
sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS - 5_000);
|
|
242
|
+
expect(row(enq.request_id).result_content).toBe(SAMPLE);
|
|
243
|
+
sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS + 5_000);
|
|
244
|
+
expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
|
|
245
|
+
expect(row(rules.request_id).result_content).toBe("# rules");
|
|
246
|
+
} finally { await u.close(); await a.close(); }
|
|
247
|
+
}, 20_000);
|
|
248
|
+
|
|
249
|
+
test("own single-flight lane; a 1 MiB-class ack is accepted", async () => {
|
|
250
|
+
const u = await connect(asOwner);
|
|
251
|
+
const a = await connect(asA);
|
|
252
|
+
try {
|
|
253
|
+
expect(await u.call("list_node_files", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
|
|
254
|
+
const first = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
|
|
255
|
+
expect(first).toMatchObject({ ok: true });
|
|
256
|
+
expect(await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
|
|
257
|
+
await a.call("get_rules_file_request", {});
|
|
258
|
+
await a.call("get_rules_file_request", {});
|
|
259
|
+
const big = "x".repeat(700 * 1024);
|
|
260
|
+
expect(await a.call("ack_rules_file_request", { request_id: first.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
|
|
261
|
+
} finally { await u.close(); await a.close(); }
|
|
262
|
+
}, 20_000);
|
|
263
|
+
});
|
|
@@ -29,6 +29,13 @@
|
|
|
29
29
|
// was just nulled (it would otherwise receive `content: ""` and write an
|
|
30
30
|
// empty rules file).
|
|
31
31
|
// 3. Rows older than ROW_TTL_MS are deleted outright.
|
|
32
|
+
// 4. Run logs (op = logs_tail) are stricter: read-once with NO grace — the
|
|
33
|
+
// first authorized terminal read purges them in the same call
|
|
34
|
+
// (purgeLogsResultNow), because the app never follows another request's
|
|
35
|
+
// logs result (it simply asks again) and a process log is the most
|
|
36
|
+
// sensitive thing on this queue. Unread terminal results are purged after
|
|
37
|
+
// LOGS_CONTENT_TTL_MS (the requester closed the page). The filter JSON in
|
|
38
|
+
// `content` (it can hold a user-typed grep) goes with it.
|
|
32
39
|
//
|
|
33
40
|
// Every statement is bounded (LIMIT via `request_id IN (SELECT … LIMIT n)` —
|
|
34
41
|
// SQLite's UPDATE/DELETE … LIMIT needs a compile flag we don't control) and
|
|
@@ -44,13 +51,15 @@ export const CONTENT_GRACE_MS = 60_000;
|
|
|
44
51
|
export const CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
45
52
|
export const ROW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
46
53
|
export const SWEEP_BATCH = 500;
|
|
54
|
+
export const LOGS_CONTENT_TTL_MS = 5 * 60 * 1000;
|
|
47
55
|
|
|
48
56
|
export type NodeRequestSweepResult = { timedOut: number; purged: number; deleted: number };
|
|
49
57
|
|
|
50
58
|
// `content` is only a file body for op=write. For skill_read / file_read it is
|
|
51
59
|
// the skill name / relative path the requester asked for — audit metadata, kept.
|
|
60
|
+
// For logs_tail it is the filter JSON, which may carry a user-typed grep — purged.
|
|
52
61
|
const PURGE_SET =
|
|
53
|
-
"result_content = NULL, content = CASE WHEN op
|
|
62
|
+
"result_content = NULL, content = CASE WHEN op IN ('write', 'logs_tail') THEN NULL ELSE content END, content_purged_at = ?1";
|
|
54
63
|
|
|
55
64
|
function purgeWhere(where: string, params: (string | number)[], now: number): number {
|
|
56
65
|
const r = db.run(
|
|
@@ -77,6 +86,7 @@ export function sweepNodeRequestContent(now: number = Date.now()): NodeRequestSw
|
|
|
77
86
|
// ?1 = now (PURGE_SET), ?2… = where params.
|
|
78
87
|
out.purged += purgeWhere("first_read_at IS NOT NULL AND first_read_at <= ?2 AND status IN ('done', 'failed', 'timeout')", [now - CONTENT_GRACE_MS], now);
|
|
79
88
|
out.purged += purgeWhere("created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [ttlCutoff], now);
|
|
89
|
+
out.purged += purgeWhere("op = 'logs_tail' AND created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [now - LOGS_CONTENT_TTL_MS], now);
|
|
80
90
|
} catch (e: any) {
|
|
81
91
|
console.log(`[commhub retention] node_rules_requests sweep failed: ${e?.message ?? e}`);
|
|
82
92
|
}
|
|
@@ -102,3 +112,9 @@ export function noteTerminalResultRead(requestId: string, now: number = Date.now
|
|
|
102
112
|
);
|
|
103
113
|
return { purged: row?.content_purged_at != null };
|
|
104
114
|
}
|
|
115
|
+
|
|
116
|
+
/** Run-log results are read-once: called by get_rules_file_result right after it
|
|
117
|
+
* handed a terminal logs_tail result to its (authorized) requester. */
|
|
118
|
+
export function purgeLogsResultNow(requestId: string, now: number = Date.now()): void {
|
|
119
|
+
db.run(`UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id = ?2 AND op = 'logs_tail' AND content_purged_at IS NULL`, [now, requestId]);
|
|
120
|
+
}
|
|
@@ -72,9 +72,17 @@ describe("requirements stay on the hub", () => {
|
|
|
72
72
|
expect(created.body.requirement.owner).toEqual(user);
|
|
73
73
|
expect(created.body.requirement.participants).toEqual([user, node]);
|
|
74
74
|
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
75
|
-
|
|
75
|
+
// 负责人只能是人类,负责 Agent 只能是节点(参与人两种都行)。
|
|
76
|
+
const humanAsAgent = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: user }) });
|
|
77
|
+
expect(humanAsAgent.status).toBe(400);
|
|
78
|
+
expect(humanAsAgent.body.error).toBe('agent_owner_must_be_agent');
|
|
79
|
+
const agentAsOwner = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node }) });
|
|
80
|
+
expect(agentAsOwner.status).toBe(400);
|
|
81
|
+
expect(agentAsOwner.body.error).toBe('owner_must_be_human');
|
|
82
|
+
const changed = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: node }) });
|
|
76
83
|
expect(changed.status).toBe(200);
|
|
77
|
-
expect(changed.body.requirement.owner).toEqual(
|
|
84
|
+
expect(changed.body.requirement.owner).toEqual(user);
|
|
85
|
+
expect(changed.body.requirement.agent_owner).toEqual(node);
|
|
78
86
|
expect(changed.body.requirement.participants).toEqual([user, node]);
|
|
79
87
|
expect(changed.body.requirement.column).toBe('pool');
|
|
80
88
|
for (const token of [viewerToken, nodeToken]) {
|
|
@@ -85,13 +93,206 @@ describe("requirements stay on the hub", () => {
|
|
|
85
93
|
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ participants: [invalid] }) })).status).toBe(400);
|
|
86
94
|
}
|
|
87
95
|
const listed = await api(ownerToken, '/api/requirements');
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
const row = listed.body.requirements.find((row: any) => row.id === created.body.requirement.id);
|
|
97
|
+
expect(row.owner).toEqual(user);
|
|
98
|
+
expect(row.agent_owner).toEqual(node);
|
|
99
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null, agent_owner: null, participants: [] }) });
|
|
90
100
|
expect(cleared.status).toBe(200);
|
|
91
101
|
expect(cleared.body.requirement.owner).toBeNull();
|
|
102
|
+
expect(cleared.body.requirement.agent_owner).toBeNull();
|
|
92
103
|
expect(cleared.body.requirement.participants).toEqual([]);
|
|
93
104
|
});
|
|
94
105
|
|
|
106
|
+
test("a card is created with a human owner and an agent owner; each role rejects the other kind", async () => {
|
|
107
|
+
const { db } = await import('./db.js');
|
|
108
|
+
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
109
|
+
db.run('INSERT OR IGNORE INTO nodes(node_id,node_name,alias,network_id) VALUES (?1,?2,?2,?3)', ['req-exec-node', 'req-exec-node', ownerNetwork]);
|
|
110
|
+
const human = { kind: 'user', id: ownerId };
|
|
111
|
+
const agent = { kind: 'node', id: 'req-exec-node' };
|
|
112
|
+
const both = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '两个角色', owner: human, agent_owner: agent }) });
|
|
113
|
+
expect(both.status).toBe(201);
|
|
114
|
+
expect(both.body.requirement.owner).toEqual(human);
|
|
115
|
+
expect(both.body.requirement.agent_owner).toEqual(agent);
|
|
116
|
+
const plain = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '都不填' }) });
|
|
117
|
+
expect(plain.body.requirement.agent_owner).toBeNull();
|
|
118
|
+
const wrongOwner = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '节点当负责人', owner: agent }) });
|
|
119
|
+
expect(wrongOwner.status).toBe(400);
|
|
120
|
+
expect(wrongOwner.body.error).toBe('owner_must_be_human');
|
|
121
|
+
const wrongAgent = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '人当负责 Agent', agent_owner: human }) });
|
|
122
|
+
expect(wrongAgent.status).toBe(400);
|
|
123
|
+
expect(wrongAgent.body.error).toBe('agent_owner_must_be_agent');
|
|
124
|
+
const foreign = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '外网节点', agent_owner: { kind: 'node', id: 'foreign-person-node' } }) });
|
|
125
|
+
expect(foreign.status).toBe(400);
|
|
126
|
+
// 只改 agent_owner 是一次有效 PATCH(不是 empty_patch),其余字段不动
|
|
127
|
+
const path = `/api/requirements/${both.body.requirement.id}`;
|
|
128
|
+
const onlyAgent = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: null }) });
|
|
129
|
+
expect(onlyAgent.status).toBe(200);
|
|
130
|
+
expect(onlyAgent.body.requirement.agent_owner).toBeNull();
|
|
131
|
+
expect(onlyAgent.body.requirement.owner).toEqual(human);
|
|
132
|
+
expect(onlyAgent.body.requirement.name).toBe('两个角色');
|
|
133
|
+
const viewerWrite = await api(viewerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: agent }) });
|
|
134
|
+
expect(viewerWrite.status).toBe(403);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("description (markdown) and checklist: create, replace-all PATCH, per-item done, limits", async () => {
|
|
138
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: JSON.stringify({ name: "带描述和子任务", description: "## 目标\r\n- 一\n- 二", checklist: [{ id: "a", text: "写接口" }, { text: "写测试", done: true }] }),
|
|
141
|
+
});
|
|
142
|
+
expect(created.status).toBe(201);
|
|
143
|
+
const card = created.body.requirement;
|
|
144
|
+
expect(card.description).toBe("## 目标\n- 一\n- 二");
|
|
145
|
+
expect(card.checklist.length).toBe(2);
|
|
146
|
+
expect(card.checklist[0]).toEqual({ id: "a", text: "写接口", done: false });
|
|
147
|
+
expect(card.checklist[1].done).toBe(true);
|
|
148
|
+
expect(card.checklist[1].id).toMatch(/^ck_[0-9a-f]{16}$/);
|
|
149
|
+
const plain = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "什么都没有" }) });
|
|
150
|
+
expect(plain.body.requirement.description).toBe("");
|
|
151
|
+
expect(plain.body.requirement.checklist).toEqual([]);
|
|
152
|
+
|
|
153
|
+
const path = `/api/requirements/${card.id}`;
|
|
154
|
+
const b = card.checklist[1].id;
|
|
155
|
+
// 单项勾选:只动那一项,显式 done,重复请求结果一样
|
|
156
|
+
const done = await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
157
|
+
expect(done.status).toBe(200);
|
|
158
|
+
expect(done.body.requirement.checklist.map((i: any) => i.done)).toEqual([true, true]);
|
|
159
|
+
const again = await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
160
|
+
expect(again.body.requirement.checklist.map((i: any) => i.done)).toEqual([true, true]);
|
|
161
|
+
expect(again.body.requirement.name).toBe("带描述和子任务");
|
|
162
|
+
expect((await api(ownerToken, `${path}/checklist/${b}`, { method: "PATCH", body: JSON.stringify({ done: false }) })).body.requirement.checklist[1].done).toBe(false);
|
|
163
|
+
expect((await api(ownerToken, `${path}/checklist/missing`, { method: "PATCH", body: JSON.stringify({ done: true }) })).body.error).toBe("checklist_item_not_found");
|
|
164
|
+
expect((await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: "yes" }) })).status).toBe(400);
|
|
165
|
+
expect((await api(ownerToken, `${path}/checklist/a`, { method: "POST", body: JSON.stringify({ done: true }) })).status).toBe(404);
|
|
166
|
+
expect((await api(viewerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(403);
|
|
167
|
+
expect((await api(nodeToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).body.error).toBe("user_token_required");
|
|
168
|
+
expect((await api(otherToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
|
|
169
|
+
|
|
170
|
+
// 整个替换:排序 / 增删;只改描述时清单不动;只改清单是有效 PATCH
|
|
171
|
+
const reordered = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: [{ id: b, text: "写测试", done: false }, { id: "a", text: "写接口", done: true }, { id: "c", text: "发版" }] }) });
|
|
172
|
+
expect(reordered.status).toBe(200);
|
|
173
|
+
expect(reordered.body.requirement.checklist.map((i: any) => i.id)).toEqual([b, "a", "c"]);
|
|
174
|
+
const described = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "改过的描述" }) });
|
|
175
|
+
expect(described.body.requirement.description).toBe("改过的描述");
|
|
176
|
+
expect(described.body.requirement.checklist.length).toBe(3);
|
|
177
|
+
const cleared = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "", checklist: [] }) });
|
|
178
|
+
expect(cleared.body.requirement.description).toBe("");
|
|
179
|
+
expect(cleared.body.requirement.checklist).toEqual([]);
|
|
180
|
+
|
|
181
|
+
// 上限与坏输入整体拒绝,原值不变
|
|
182
|
+
const tooLong = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "x".repeat(20_001) }) });
|
|
183
|
+
expect(tooLong.body.error).toBe("invalid_description");
|
|
184
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "x".repeat(20_000) }) })).status).toBe(200);
|
|
185
|
+
const many = Array.from({ length: 101 }, (_, i) => ({ id: `i${i}`, text: `第 ${i} 项` }));
|
|
186
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: many }) })).body.error).toBe("invalid_checklist");
|
|
187
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: many.slice(0, 100) }) })).status).toBe(200);
|
|
188
|
+
for (const bad of [[{ id: "a", text: "x" }, { id: "a", text: "y" }], [{ text: " " }], [{ id: "有空格 的id", text: "x" }], [{ text: "x", done: 1 }], ["字符串"]]) {
|
|
189
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: bad }) })).body.error).toBe("invalid_checklist");
|
|
190
|
+
}
|
|
191
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
192
|
+
const row = listed.body.requirements.find((r: any) => r.id === card.id);
|
|
193
|
+
expect(row.checklist.length).toBe(100);
|
|
194
|
+
expect(row.description.length).toBe(20_000);
|
|
195
|
+
expect((await api(viewerToken, path, { method: "PATCH", body: JSON.stringify({ description: "越权" }) })).status).toBe(403);
|
|
196
|
+
expect((await api(nodeToken, path, { method: "PATCH", body: JSON.stringify({ description: "Agent 暂不能写" }) })).body.error).toBe("user_token_required");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("projects: CRUD per network, project_id on cards, delete nulls references, archive keeps them", async () => {
|
|
200
|
+
const q = `?network_id=${ownerNetwork}`;
|
|
201
|
+
const empty = await api(ownerToken, `/api/requirements/projects${q}`);
|
|
202
|
+
expect(empty.status).toBe(200);
|
|
203
|
+
expect(empty.body.projects).toEqual([]); // 不预置任何项目
|
|
204
|
+
const legion = await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "军团项目" }) });
|
|
205
|
+
expect(legion.status).toBe(201);
|
|
206
|
+
expect(legion.body.project.name).toBe("军团项目");
|
|
207
|
+
expect(legion.body.project.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
|
208
|
+
expect(legion.body.project.archived).toBe(false);
|
|
209
|
+
const tmai = await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "TMAI", color: "#7c3aed" }) });
|
|
210
|
+
expect(tmai.body.project.color).toBe("#7c3aed");
|
|
211
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "TMAI" }) })).status).toBe(409);
|
|
212
|
+
for (const bad of [{ name: " " }, { name: "x".repeat(41) }, { name: "色", color: "red" }, { name: "序", sort: 1.5 }]) {
|
|
213
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify(bad) })).status).toBe(400);
|
|
214
|
+
}
|
|
215
|
+
const listed = await api(ownerToken, `/api/requirements/projects${q}`);
|
|
216
|
+
expect(listed.body.projects.map((p: any) => p.name)).toEqual(["军团项目", "TMAI"]);
|
|
217
|
+
// 权限:viewer 能读不能写;节点令牌一律拒绝;别的网络看不见、改不了
|
|
218
|
+
expect((await api(viewerToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
|
|
219
|
+
expect((await api(viewerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
|
|
220
|
+
expect((await api(nodeToken, `/api/requirements/projects${q}`)).body.error).toBe("user_token_required");
|
|
221
|
+
expect((await api(otherToken, "/api/requirements/projects")).body.projects).toEqual([]);
|
|
222
|
+
expect((await api(otherToken, `/api/requirements/projects/${tmai.body.project.id}`, { method: "PATCH", body: JSON.stringify({ name: "抢" }) })).status).toBe(404);
|
|
223
|
+
const foreign = await api(otherToken, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "别人的项目" }) });
|
|
224
|
+
expect(foreign.status).toBe(201);
|
|
225
|
+
|
|
226
|
+
// 卡片上的 project_id:POST / PATCH / GET;别的网络的项目被拒
|
|
227
|
+
const card = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "有项目的卡", project_id: legion.body.project.id }) });
|
|
228
|
+
expect(card.status).toBe(201);
|
|
229
|
+
expect(card.body.requirement.project_id).toBe(legion.body.project.id);
|
|
230
|
+
const bare = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "没项目的卡" }) });
|
|
231
|
+
expect(bare.body.requirement.project_id).toBeNull();
|
|
232
|
+
expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "外网项目", project_id: foreign.body.project.id }) })).body.error).toBe("project_not_in_network");
|
|
233
|
+
const path = `/api/requirements/${card.body.requirement.id}`;
|
|
234
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ project_id: foreign.body.project.id }) })).body.error).toBe("project_not_in_network");
|
|
235
|
+
const moved = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ project_id: tmai.body.project.id }) });
|
|
236
|
+
expect(moved.status).toBe(200);
|
|
237
|
+
expect(moved.body.requirement.project_id).toBe(tmai.body.project.id);
|
|
238
|
+
expect(moved.body.requirement.name).toBe("有项目的卡");
|
|
239
|
+
|
|
240
|
+
// 改名 / 改色 / 排序
|
|
241
|
+
const renamed = await api(ownerToken, `/api/requirements/projects/${tmai.body.project.id}${q}`, { method: "PATCH", body: JSON.stringify({ name: "TMAI 平台", color: "#0891b2", sort: -1 }) });
|
|
242
|
+
expect(renamed.body.project).toMatchObject({ name: "TMAI 平台", color: "#0891b2", sort: -1 });
|
|
243
|
+
expect((await api(ownerToken, `/api/requirements/projects/${tmai.body.project.id}${q}`, { method: "PATCH", body: JSON.stringify({ name: "军团项目" }) })).status).toBe(409);
|
|
244
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`)).body.projects[0].name).toBe("TMAI 平台");
|
|
245
|
+
|
|
246
|
+
// 归档:引用保留,但不能再被选
|
|
247
|
+
const archived = await api(ownerToken, `/api/requirements/projects/${tmai.body.project.id}${q}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
|
|
248
|
+
expect(archived.body.project.archived).toBe(true);
|
|
249
|
+
const kept = (await api(ownerToken, "/api/requirements")).body.requirements.find((r: any) => r.id === card.body.requirement.id);
|
|
250
|
+
expect(kept.project_id).toBe(tmai.body.project.id);
|
|
251
|
+
expect((await api(ownerToken, `/api/requirements/${bare.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ project_id: tmai.body.project.id }) })).body.error).toBe("project_archived");
|
|
252
|
+
|
|
253
|
+
// 删除:卡片一张不少,引用置空
|
|
254
|
+
await api(ownerToken, `/api/requirements/${bare.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ project_id: legion.body.project.id }) });
|
|
255
|
+
const before = (await api(ownerToken, "/api/requirements")).body.requirements.length;
|
|
256
|
+
expect((await api(viewerToken, `/api/requirements/projects/${legion.body.project.id}${q}`, { method: "DELETE" })).status).toBe(403);
|
|
257
|
+
const del = await api(ownerToken, `/api/requirements/projects/${legion.body.project.id}${q}`, { method: "DELETE" });
|
|
258
|
+
expect(del.status).toBe(200);
|
|
259
|
+
const after = (await api(ownerToken, "/api/requirements")).body.requirements;
|
|
260
|
+
expect(after.length).toBe(before);
|
|
261
|
+
expect(after.find((r: any) => r.id === bare.body.requirement.id).project_id).toBeNull();
|
|
262
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`)).body.projects.map((p: any) => p.name)).toEqual(["TMAI 平台"]);
|
|
263
|
+
// 清空卡片项目
|
|
264
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ project_id: null }) })).body.requirement.project_id).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("due accepts date-only (all day) and an ISO datetime with offset, stored as UTC to the second", async () => {
|
|
268
|
+
const make = (due: unknown) => api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "期限", due }) });
|
|
269
|
+
const shanghai = await make("2026-10-01T18:30:45+08:00");
|
|
270
|
+
expect(shanghai.status).toBe(201);
|
|
271
|
+
expect(shanghai.body.requirement.due).toBe("2026-10-01T10:30:45Z");
|
|
272
|
+
expect((await make("2026-10-01T10:30:45Z")).body.requirement.due).toBe("2026-10-01T10:30:45Z");
|
|
273
|
+
expect((await make("2026-10-01T10:30Z")).body.requirement.due).toBe("2026-10-01T10:30:00Z");
|
|
274
|
+
expect((await make("2026-10-01T10:30:45.987-04:00")).body.requirement.due).toBe("2026-10-01T14:30:45Z");
|
|
275
|
+
// 跨日:东八区的 00:30 是 UTC 前一天
|
|
276
|
+
expect((await make("2026-10-01T00:30:00+08:00")).body.requirement.due).toBe("2026-09-30T16:30:00Z");
|
|
277
|
+
// 旧的全天值原样
|
|
278
|
+
const legacy = await make("2026-10-01");
|
|
279
|
+
expect(legacy.body.requirement.due).toBe("2026-10-01");
|
|
280
|
+
for (const bad of ["2026-10-01T18:30:45", "2026-10-01 18:30:45+08:00", "2026-02-30T10:00:00Z", "2026-10-01T24:00:00Z", "2026-10-01T10:60:00Z", "2026-10-01T10:00:61Z", "2026-10-01T10:00:00+15:00", "明天", "2026-10-1"]) {
|
|
281
|
+
const r = await make(bad);
|
|
282
|
+
expect(r.status).toBe(400);
|
|
283
|
+
expect(r.body.error).toBe("invalid_due");
|
|
284
|
+
}
|
|
285
|
+
const path = `/api/requirements/${legacy.body.requirement.id}`;
|
|
286
|
+
const patched = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "2026-12-31T23:59:59-08:00" }) });
|
|
287
|
+
expect(patched.body.requirement.due).toBe("2027-01-01T07:59:59Z");
|
|
288
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "2026-12-31" }) })).body.requirement.due).toBe("2026-12-31");
|
|
289
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "T" }) })).status).toBe(400);
|
|
290
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
291
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
292
|
+
expect(listed.body.capabilities).toContain("due_datetime");
|
|
293
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime"]);
|
|
294
|
+
});
|
|
295
|
+
|
|
95
296
|
test("owner creates a card in the pool", async () => {
|
|
96
297
|
const created = await api(ownerToken, "/api/requirements", {
|
|
97
298
|
method: "POST",
|
|
@@ -187,6 +388,51 @@ describe("requirements stay on the hub", () => {
|
|
|
187
388
|
expect(bad.status).toBe(400);
|
|
188
389
|
});
|
|
189
390
|
|
|
391
|
+
test("an existing card can change its text without dropping omitted fields", async () => {
|
|
392
|
+
const { db } = await import("./db.js");
|
|
393
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
394
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
395
|
+
method: "POST",
|
|
396
|
+
body: JSON.stringify({
|
|
397
|
+
name: "旧标题",
|
|
398
|
+
priority: "low",
|
|
399
|
+
assignee: "node-a",
|
|
400
|
+
due: "2026-10-01",
|
|
401
|
+
column: "doing",
|
|
402
|
+
owner: { kind: "user", id: ownerId },
|
|
403
|
+
}),
|
|
404
|
+
});
|
|
405
|
+
expect(created.status).toBe(201);
|
|
406
|
+
const id = created.body.requirement.id;
|
|
407
|
+
const renamed = await api(ownerToken, `/api/requirements/${id}`, {
|
|
408
|
+
method: "PATCH",
|
|
409
|
+
body: JSON.stringify({ name: "新标题" }),
|
|
410
|
+
});
|
|
411
|
+
expect(renamed.status).toBe(200);
|
|
412
|
+
expect(renamed.body.requirement.name).toBe("新标题");
|
|
413
|
+
expect(renamed.body.requirement.priority).toBe("low");
|
|
414
|
+
expect(renamed.body.requirement.assignee).toBe("node-a");
|
|
415
|
+
expect(renamed.body.requirement.due).toBe("2026-10-01");
|
|
416
|
+
expect(renamed.body.requirement.column).toBe("doing");
|
|
417
|
+
expect(renamed.body.requirement.owner).toEqual({ kind: "user", id: ownerId });
|
|
418
|
+
const cleared = await api(ownerToken, `/api/requirements/${id}`, {
|
|
419
|
+
method: "PATCH",
|
|
420
|
+
body: JSON.stringify({ due: "", assignee: "", priority: "high" }),
|
|
421
|
+
});
|
|
422
|
+
expect(cleared.status).toBe(200);
|
|
423
|
+
expect(cleared.body.requirement.name).toBe("新标题");
|
|
424
|
+
expect(cleared.body.requirement.due).toBe("");
|
|
425
|
+
expect(cleared.body.requirement.assignee).toBe("");
|
|
426
|
+
expect(cleared.body.requirement.priority).toBe("high");
|
|
427
|
+
expect(cleared.body.requirement.owner).toEqual({ kind: "user", id: ownerId });
|
|
428
|
+
expect((await api(ownerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: " " }) })).status).toBe(400);
|
|
429
|
+
expect((await api(ownerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-13-01" }) })).status).toBe(400);
|
|
430
|
+
expect((await api(viewerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
|
|
431
|
+
expect((await api(otherToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "别人的" }) })).status).toBe(404);
|
|
432
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
433
|
+
expect(listed.body.requirements.find((row: { id: string }) => row.id === id).name).toBe("新标题");
|
|
434
|
+
});
|
|
435
|
+
|
|
190
436
|
test("empty name and a bad date are rejected", async () => {
|
|
191
437
|
const empty = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: " " }) });
|
|
192
438
|
expect(empty.status).toBe(400);
|