@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// 完成时间 completed_at 与仪表盘聚合 GET /api/requirements/stats —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
|
|
2
|
+
// test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
|
|
3
|
+
//
|
|
4
|
+
// 场景:Hub 管理员 admin 的网络 NET;节点令牌 bot(绑定一个节点);成员:
|
|
5
|
+
// alice —— 新建成员(经授权接口显式设成任务 scoped、Agent 受限):只看得见自己负责的卡;
|
|
6
|
+
// carol —— 任务 'all'、Agent 受限('granted' 且没授权 bot):卡全看得见,但 bot 不该以名字出现在统计里。
|
|
7
|
+
// 正向:移进完成记时刻与操作者、完成列里改别的不动、移出清空、再移进换新时刻;直接建在完成列;归档的卡照样计数;
|
|
8
|
+
// tz / days / from / to 的语义。
|
|
9
|
+
// 反向:scoped 成员的统计不含他看不见的卡;受限成员看不到隐藏节点的 id(计入 unattributed);非法参数 400。
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
18
|
+
import { localDate } from "./requirements-stats.js";
|
|
19
|
+
|
|
20
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-req-stats-"));
|
|
21
|
+
const PW = "ReqStatsPassw0rd!x";
|
|
22
|
+
let BASE = "";
|
|
23
|
+
let hub: any = null;
|
|
24
|
+
let NET = "";
|
|
25
|
+
let admin = { token: "", id: "" };
|
|
26
|
+
let alice = { token: "", id: "" };
|
|
27
|
+
let carol = { token: "", id: "" };
|
|
28
|
+
let bot = { token: "", nodeId: "" };
|
|
29
|
+
let PROJ = "";
|
|
30
|
+
const C: Record<string, string> = {};
|
|
31
|
+
|
|
32
|
+
type R = { status: number; body: any; text: string };
|
|
33
|
+
async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
|
|
34
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
35
|
+
method,
|
|
36
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
37
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
38
|
+
});
|
|
39
|
+
const text = await res.text();
|
|
40
|
+
let body: any = null;
|
|
41
|
+
try { body = JSON.parse(text); } catch {}
|
|
42
|
+
return { status: res.status, body, text };
|
|
43
|
+
}
|
|
44
|
+
const card = async (token: string, name: string, extra: Record<string, unknown> = {}) => {
|
|
45
|
+
const r = await send(token, "POST", "/api/requirements", { network_id: NET, name, ...extra });
|
|
46
|
+
expect(r.status).toBe(201);
|
|
47
|
+
C[name] = r.body.requirement.id;
|
|
48
|
+
return r.body.requirement;
|
|
49
|
+
};
|
|
50
|
+
const patch = async (token: string, name: string, body: Record<string, unknown>) => {
|
|
51
|
+
const r = await send(token, "PATCH", `/api/requirements/${C[name]}?network_id=${NET}`, body);
|
|
52
|
+
expect(r.status).toBe(200);
|
|
53
|
+
return r.body.requirement;
|
|
54
|
+
};
|
|
55
|
+
const stats = (token: string, qs = "") => send(token, "GET", `/api/requirements/stats?network_id=${NET}${qs}`);
|
|
56
|
+
const tick = () => new Promise(r => setTimeout(r, 5));
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
60
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
61
|
+
process.env.HOST = "127.0.0.1";
|
|
62
|
+
const a = register(`rs_admin_${Date.now()}`, PW, undefined, "Admin");
|
|
63
|
+
expect(a.ok).toBe(true);
|
|
64
|
+
admin = { token: a.token!, id: a.user!.user_id };
|
|
65
|
+
NET = a.network_id!;
|
|
66
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
|
|
67
|
+
const mod: any = await import("./server.js");
|
|
68
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
69
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
70
|
+
|
|
71
|
+
const mk = async (username: string) => {
|
|
72
|
+
const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
|
|
73
|
+
expect(r.status).toBe(200);
|
|
74
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
75
|
+
expect(login.status).toBe(200);
|
|
76
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
77
|
+
};
|
|
78
|
+
const stamp = Date.now();
|
|
79
|
+
alice = await mk(`rs_alice_${stamp}`);
|
|
80
|
+
carol = await mk(`rs_carol_${stamp}`);
|
|
81
|
+
// 新成员默认值 NEW_MEMBER_TASK_ACCESS 暂时是 'all',alice 要显式设成 scoped。
|
|
82
|
+
expect((await send(admin.token, "PUT", `/api/networks/${NET}/members/${alice.id}/task-grants`, { task_access: "scoped" })).status).toBe(200);
|
|
83
|
+
db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
|
|
84
|
+
|
|
85
|
+
const t = createNetworkTokenForNode(admin.id, NET, "rs-bot");
|
|
86
|
+
expect(t.token).toBeTruthy();
|
|
87
|
+
bot.token = t.token!;
|
|
88
|
+
|
|
89
|
+
const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "rs-P" });
|
|
90
|
+
expect(p.status).toBe(201);
|
|
91
|
+
PROJ = p.body.project.id;
|
|
92
|
+
}, 30_000);
|
|
93
|
+
|
|
94
|
+
afterAll(() => {
|
|
95
|
+
try { hub?.stop?.(true); } catch {}
|
|
96
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("completed_at 随列变化", () => {
|
|
100
|
+
test("不在完成列的卡:completedAt = null,approx = false;capability 广播 completed_at / stats", async () => {
|
|
101
|
+
const r = await card(admin.token, "rs-pool");
|
|
102
|
+
expect(r.completedAt).toBeNull();
|
|
103
|
+
expect(r.completedBy).toBeNull();
|
|
104
|
+
expect(r.completedAtApprox).toBe(false);
|
|
105
|
+
await card(admin.token, "rs-doing", { column: "doing" });
|
|
106
|
+
const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
107
|
+
expect(list.body.capabilities).toContain("completed_at");
|
|
108
|
+
expect(list.body.capabilities).toContain("stats");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("直接建在完成列:completedAt = createdAt,completedBy = 建卡的人", async () => {
|
|
112
|
+
const r = await card(admin.token, "rs-born-done", { column: "done", project_id: PROJ });
|
|
113
|
+
expect(r.completedAt).toBe(r.createdAt);
|
|
114
|
+
expect(r.completedBy).toEqual({ kind: "user", id: admin.id });
|
|
115
|
+
expect(r.completedAtApprox).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("移进完成记时刻;完成列里改名 / 勾子任务不动它;移出清空;再移进是新时刻", async () => {
|
|
119
|
+
await card(admin.token, "rs-move", { checklist: [{ id: "i1", text: "x", done: false }] });
|
|
120
|
+
expect((await patch(admin.token, "rs-move", { column: "doing" })).completedAt).toBeNull();
|
|
121
|
+
const done1 = await patch(admin.token, "rs-move", { column: "done" });
|
|
122
|
+
expect(done1.completedAt).toBe(done1.updatedAt);
|
|
123
|
+
expect(done1.completedBy).toEqual({ kind: "user", id: admin.id });
|
|
124
|
+
await tick();
|
|
125
|
+
const renamed = await patch(admin.token, "rs-move", { name: "rs-move 改名" });
|
|
126
|
+
expect(renamed.updatedAt).not.toBe(done1.updatedAt);
|
|
127
|
+
expect(renamed.completedAt).toBe(done1.completedAt);
|
|
128
|
+
const again = await patch(admin.token, "rs-move", { column: "done" }); // done → done
|
|
129
|
+
expect(again.completedAt).toBe(done1.completedAt);
|
|
130
|
+
const ck = await send(admin.token, "PATCH", `/api/requirements/${C["rs-move"]}/checklist/i1?network_id=${NET}`, { done: true });
|
|
131
|
+
expect(ck.status).toBe(200);
|
|
132
|
+
expect(ck.body.requirement.completedAt).toBe(done1.completedAt);
|
|
133
|
+
const out = await patch(admin.token, "rs-move", { column: "pool" });
|
|
134
|
+
expect(out.completedAt).toBeNull();
|
|
135
|
+
expect(out.completedBy).toBeNull();
|
|
136
|
+
await tick();
|
|
137
|
+
const done2 = await patch(admin.token, "rs-move", { column: "done" });
|
|
138
|
+
expect(done2.completedAt).not.toBeNull();
|
|
139
|
+
expect(Date.parse(done2.completedAt)).toBeGreaterThan(Date.parse(done1.completedAt));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("节点令牌移进完成:completedBy 是那个节点", async () => {
|
|
143
|
+
await card(bot.token, "rs-bot-card");
|
|
144
|
+
const done = await patch(bot.token, "rs-bot-card", { column: "done" });
|
|
145
|
+
expect(done.completedBy?.kind).toBe("node");
|
|
146
|
+
bot.nodeId = done.completedBy.id;
|
|
147
|
+
expect(bot.nodeId).toBeTruthy();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("alice 负责的卡由 alice 移进完成;归档一张完成的卡", async () => {
|
|
151
|
+
await card(admin.token, "rs-alice", { owner: { kind: "user", id: alice.id } });
|
|
152
|
+
const done = await patch(alice.token, "rs-alice", { column: "done" });
|
|
153
|
+
expect(done.completedBy).toEqual({ kind: "user", id: alice.id });
|
|
154
|
+
await card(admin.token, "rs-archived", { column: "done" });
|
|
155
|
+
const arch = await patch(admin.token, "rs-archived", { archived: true });
|
|
156
|
+
expect(arch.archived).toBe(true);
|
|
157
|
+
expect(arch.completedAt).not.toBeNull(); // 归档不是移出完成列
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("GET /api/requirements/stats", () => {
|
|
162
|
+
// 此时:done = born-done / move / bot-card / alice / archived(5);doing = rs-doing;pool = rs-pool;共 7 张。
|
|
163
|
+
test("管理员:总数、归档的完成卡照样计数、按项目 / 完成者、每日曲线", async () => {
|
|
164
|
+
const r = await stats(admin.token, "&tz=UTC");
|
|
165
|
+
expect(r.status).toBe(200);
|
|
166
|
+
const s = r.body;
|
|
167
|
+
expect(s.totals.done).toBe(5);
|
|
168
|
+
expect(s.totals.done_approx).toBe(0);
|
|
169
|
+
expect(s.totals.doing).toBe(1);
|
|
170
|
+
expect(s.totals.pool).toBe(1);
|
|
171
|
+
expect(s.totals.created).toBe(7);
|
|
172
|
+
expect(s.totals.created_done).toBe(5);
|
|
173
|
+
expect(s.totals.completion_rate).toBeCloseTo(5 / 7, 6);
|
|
174
|
+
// 列表默认不含归档的卡,统计含:这正是「完成的卡被归档后今天完成数偏少」的那一张。
|
|
175
|
+
const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
|
|
176
|
+
expect((list.body.requirements as any[]).some(x => x.id === C["rs-archived"])).toBe(false);
|
|
177
|
+
expect(s.by_project).toEqual([{ project_id: null, n: 4 }, { project_id: PROJ, n: 1 }]);
|
|
178
|
+
const by = Object.fromEntries((s.by_completer as any[]).map(c => [`${c.kind}:${c.id}`, c]));
|
|
179
|
+
expect(by[`user:${admin.id}`].n).toBe(3);
|
|
180
|
+
expect(by[`user:${alice.id}`].n).toBe(1);
|
|
181
|
+
expect(by[`node:${bot.nodeId}`].n).toBe(1);
|
|
182
|
+
expect(by[`user:${admin.id}`].spark.length).toBe(14);
|
|
183
|
+
expect(s.unattributed).toBe(0);
|
|
184
|
+
expect(s.daily.length).toBe(30);
|
|
185
|
+
expect(s.daily.reduce((a: number, d: any) => a + d.n, 0)).toBe(5);
|
|
186
|
+
expect(s.daily.at(-1).date).toBe(localDate(Date.parse(s.range.to), "UTC"));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("recent:最近完成的卡(新 → 旧),带短号 / 标题 / 完成者;recent=0 空;非法 400", async () => {
|
|
190
|
+
const r = await stats(admin.token, "&recent=3");
|
|
191
|
+
expect(r.status).toBe(200);
|
|
192
|
+
const rec = r.body.recent as any[];
|
|
193
|
+
expect(rec.map(x => x.name)).toEqual(["rs-archived", "rs-alice", "rs-bot-card"]);
|
|
194
|
+
expect(rec[0].archived).toBe(true);
|
|
195
|
+
expect(rec[1].completed_by).toEqual({ kind: "user", id: alice.id });
|
|
196
|
+
expect(rec[2].completed_by?.kind).toBe("node");
|
|
197
|
+
expect(typeof rec[0].seq).toBe("number");
|
|
198
|
+
for (let i = 1; i < rec.length; i++) expect(rec[i - 1].completed_at >= rec[i].completed_at).toBe(true);
|
|
199
|
+
expect((await stats(admin.token)).body.recent.length).toBe(5); // 缺省 10,只有 5 张完成
|
|
200
|
+
expect((await stats(admin.token, "&recent=0")).body.recent).toEqual([]);
|
|
201
|
+
for (const bad of ["-1", "51", "x"]) expect((await stats(admin.token, `&recent=${bad}`)).body.error).toBe("invalid_recent");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("scoped 成员:统计只含他看得见的卡(反向:别人的卡、项目、完成者都不出现)", async () => {
|
|
205
|
+
const r = await stats(alice.token);
|
|
206
|
+
expect(r.status).toBe(200);
|
|
207
|
+
expect(r.body.totals.done).toBe(1);
|
|
208
|
+
expect(r.body.totals.doing).toBe(0);
|
|
209
|
+
expect(r.body.totals.pool).toBe(0);
|
|
210
|
+
expect(r.body.totals.created).toBe(1);
|
|
211
|
+
expect(r.body.by_project).toEqual([{ project_id: null, n: 1 }]);
|
|
212
|
+
expect(r.body.by_completer).toEqual([expect.objectContaining({ kind: "user", id: alice.id, n: 1 })]);
|
|
213
|
+
expect(r.text).not.toContain(admin.id);
|
|
214
|
+
expect((r.body.recent as any[]).map(x => x.name)).toEqual(["rs-alice"]);
|
|
215
|
+
expect(r.text).not.toContain(PROJ);
|
|
216
|
+
expect(r.text).not.toContain(bot.nodeId);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("Agent 受限成员:隐藏节点完成的卡照样计数,但记在 unattributed,节点 id 不出现", async () => {
|
|
220
|
+
expect(isAgentRestricted(carol.id, NET)).toBe(true); // 前置:carol 确实受限,否则下面的断言恒真
|
|
221
|
+
const r = await stats(carol.token);
|
|
222
|
+
expect(r.status).toBe(200);
|
|
223
|
+
expect(r.body.totals.done).toBe(5);
|
|
224
|
+
expect(r.body.unattributed).toBe(1);
|
|
225
|
+
expect((r.body.by_completer as any[]).some(c => c.kind === "node")).toBe(false);
|
|
226
|
+
expect(r.text).not.toContain(bot.nodeId);
|
|
227
|
+
expect((r.body.recent as any[]).find(x => x.name === "rs-bot-card").completed_by).toBeNull();
|
|
228
|
+
// 卡片详情也藏掉 completedBy(与 updated_by 同一规则)
|
|
229
|
+
const one = await send(carol.token, "GET", `/api/requirements/${C["rs-bot-card"]}?network_id=${NET}`);
|
|
230
|
+
expect(one.status).toBe(200);
|
|
231
|
+
expect(one.body.requirement.completedBy).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("节点令牌可以读统计", async () => {
|
|
235
|
+
const r = await stats(bot.token);
|
|
236
|
+
expect(r.status).toBe(200);
|
|
237
|
+
expect(r.body.totals.done).toBe(5);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("from / to:范围外的完成不计入总数;daily 按自己的窗口", async () => {
|
|
241
|
+
const future = new Date(Date.now() + 86_400_000).toISOString();
|
|
242
|
+
const r1 = await stats(admin.token, `&from=${encodeURIComponent(future)}&to=${encodeURIComponent(new Date(Date.now() + 2 * 86_400_000).toISOString())}`);
|
|
243
|
+
expect(r1.status).toBe(200);
|
|
244
|
+
expect(r1.body.totals.done).toBe(0);
|
|
245
|
+
expect(r1.body.totals.created).toBe(0);
|
|
246
|
+
expect(r1.body.totals.completion_rate).toBeNull();
|
|
247
|
+
expect(r1.body.totals.doing).toBe(1); // 「进行中」是当前状态,不看范围
|
|
248
|
+
const past = new Date(Date.now() - 86_400_000).toISOString();
|
|
249
|
+
const r2 = await stats(admin.token, `&to=${encodeURIComponent(past)}`);
|
|
250
|
+
expect(r2.body.totals.done).toBe(0);
|
|
251
|
+
expect(r2.body.daily.reduce((a: number, d: any) => a + d.n, 0)).toBe(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("tz:最后一天按调用方时区;UTC+14 与 UTC-12 相差一天", async () => {
|
|
255
|
+
const east = await stats(admin.token, "&tz=Pacific/Kiritimati&days=1");
|
|
256
|
+
const west = await stats(admin.token, "&tz=Etc/GMT%2B12&days=1");
|
|
257
|
+
expect(east.status).toBe(200);
|
|
258
|
+
expect(west.status).toBe(200);
|
|
259
|
+
const to = Date.parse(east.body.range.to);
|
|
260
|
+
expect(east.body.daily.at(-1).date).toBe(localDate(to, "Pacific/Kiritimati"));
|
|
261
|
+
expect(west.body.daily.at(-1).date).toBe(localDate(Date.parse(west.body.range.to), "Etc/GMT+12"));
|
|
262
|
+
expect(east.body.daily.at(-1).date > west.body.daily.at(-1).date).toBe(true);
|
|
263
|
+
// 今天刚完成的 5 张都落在「今天」这一格(两边各自的今天)
|
|
264
|
+
expect(east.body.daily.at(-1).n).toBe(5);
|
|
265
|
+
expect(west.body.daily.at(-1).n).toBe(5);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("days=371 画一整年;非法参数 400", async () => {
|
|
269
|
+
const year = await stats(admin.token, "&days=371");
|
|
270
|
+
expect(year.body.daily.length).toBe(371);
|
|
271
|
+
for (const [qs, err] of [
|
|
272
|
+
["&tz=Mars/Base", "invalid_tz"], ["&days=0", "invalid_days"], ["&days=372", "invalid_days"], ["&days=1.5", "invalid_days"],
|
|
273
|
+
["&from=garbage", "invalid_from"], ["&to=nope", "invalid_to"],
|
|
274
|
+
[`&from=${encodeURIComponent(new Date(Date.now() + 86_400_000).toISOString())}&to=${encodeURIComponent(new Date().toISOString())}`, "invalid_range"],
|
|
275
|
+
] as const) {
|
|
276
|
+
const r = await stats(admin.token, qs);
|
|
277
|
+
expect([qs, r.status, r.body?.error]).toEqual([qs, 400, err]);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("不带令牌 401;不是 /:id(「stats」不会被当成卡号)", async () => {
|
|
282
|
+
const anon = await send("", "GET", `/api/requirements/stats?network_id=${NET}`);
|
|
283
|
+
expect(anon.status).toBe(401);
|
|
284
|
+
const r = await stats(admin.token);
|
|
285
|
+
expect(r.body.ok).toBe(true);
|
|
286
|
+
expect(r.body.requirement).toBeUndefined();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// 任务仪表盘的聚合(GET /api/requirements/stats)。纯函数:行由 requirements.ts 按调用者的可见范围读出来,
|
|
2
|
+
// 这里只按时间范围 / 时区分桶、按项目 / 完成者计数。放在 JS 里而不是 SQL:按 IANA 时区切「哪一天」在
|
|
3
|
+
// SQLite 和 PostgreSQL 上写法不同,而一个网络的卡片数是几百到几千,整表轻量列读一次就够。
|
|
4
|
+
import { isoInstant } from "./requirements-migrate.js";
|
|
5
|
+
|
|
6
|
+
export const STATS_DEFAULT_DAYS = 30;
|
|
7
|
+
export const STATS_MAX_DAYS = 371; // 53 周:够画一整年的贡献热力图
|
|
8
|
+
export const STATS_SPARK_DAYS = 14;
|
|
9
|
+
export const STATS_TOP_COMPLETERS = 20;
|
|
10
|
+
export const STATS_DEFAULT_RECENT = 10;
|
|
11
|
+
export const STATS_MAX_RECENT = 50;
|
|
12
|
+
|
|
13
|
+
export type StatsRow = {
|
|
14
|
+
requirement_id: string;
|
|
15
|
+
seq: number | null;
|
|
16
|
+
title: string;
|
|
17
|
+
project_id: string | null;
|
|
18
|
+
column_name: string;
|
|
19
|
+
archived: number | null;
|
|
20
|
+
created_at: string | null;
|
|
21
|
+
completed_at: string | null;
|
|
22
|
+
completed_at_approx: number | null;
|
|
23
|
+
completed_by_json: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number };
|
|
27
|
+
type Ref = { kind: "user" | "node"; id: string };
|
|
28
|
+
|
|
29
|
+
/** 解析 from / to / tz / days。不合法 → 错误码(400)。to 缺省 = 现在;from 缺省 = 不设下限(全部)。 */
|
|
30
|
+
export function parseStatsQuery(q: URLSearchParams, nowMs: number): StatsQuery | { error: string } {
|
|
31
|
+
const instant = (name: string): number | null | { error: string } => {
|
|
32
|
+
const raw = q.get(name);
|
|
33
|
+
if (raw === null || raw === "") return null;
|
|
34
|
+
const ms = Date.parse(raw);
|
|
35
|
+
return Number.isFinite(ms) ? ms : { error: `invalid_${name}` };
|
|
36
|
+
};
|
|
37
|
+
const from = instant("from");
|
|
38
|
+
if (from !== null && typeof from === "object") return from;
|
|
39
|
+
const to = instant("to");
|
|
40
|
+
if (to !== null && typeof to === "object") return to;
|
|
41
|
+
const toMs = to ?? nowMs;
|
|
42
|
+
if (from !== null && from > toMs) return { error: "invalid_range" };
|
|
43
|
+
const tz = q.get("tz") || "UTC";
|
|
44
|
+
if (tz.length > 64 || !validTimeZone(tz)) return { error: "invalid_tz" };
|
|
45
|
+
const daysRaw = q.get("days");
|
|
46
|
+
const days = daysRaw === null || daysRaw === "" ? STATS_DEFAULT_DAYS : Number(daysRaw);
|
|
47
|
+
if (!Number.isInteger(days) || days < 1 || days > STATS_MAX_DAYS) return { error: "invalid_days" };
|
|
48
|
+
const recentRaw = q.get("recent");
|
|
49
|
+
const recent = recentRaw === null || recentRaw === "" ? STATS_DEFAULT_RECENT : Number(recentRaw);
|
|
50
|
+
if (!Number.isInteger(recent) || recent < 0 || recent > STATS_MAX_RECENT) return { error: "invalid_recent" };
|
|
51
|
+
return { fromMs: from, toMs, tz, days, recent };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validTimeZone(tz: string): boolean {
|
|
55
|
+
try { new Intl.DateTimeFormat("en-CA", { timeZone: tz }); return true; } catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 某一时刻在 tz 里是哪一天(YYYY-MM-DD)。 */
|
|
59
|
+
export function localDate(ms: number, tz: string): string {
|
|
60
|
+
// en-CA 的日期格式就是 YYYY-MM-DD。
|
|
61
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(ms));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 以 lastDay(YYYY-MM-DD)结尾、往前数 n 天的日期列表(旧 → 新)。纯日历运算,与时区无关。 */
|
|
65
|
+
export function dayRange(lastDay: string, n: number): string[] {
|
|
66
|
+
const [y, m, d] = lastDay.split("-").map(Number);
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (let i = n - 1; i >= 0; i--) out.push(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10));
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const msOf = (v: string | null): number | null => {
|
|
73
|
+
const iso = isoInstant(v);
|
|
74
|
+
return iso === null ? null : Date.parse(iso);
|
|
75
|
+
};
|
|
76
|
+
const parseRef = (json: string | null): Ref | null => {
|
|
77
|
+
if (!json) return null;
|
|
78
|
+
try {
|
|
79
|
+
const r = JSON.parse(json);
|
|
80
|
+
return r && (r.kind === "user" || r.kind === "node") && typeof r.id === "string" ? { kind: r.kind, id: r.id } : null;
|
|
81
|
+
} catch { return null; }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* hidden:受限成员看不见的节点(与列表里 hiddenNodeFilter 同一个判定)。这些节点完成的卡照样计数,
|
|
86
|
+
* 但归到 unattributed,不出现在 by_completer 里 —— 不给他留「那个节点做了几张」的探测口。
|
|
87
|
+
*/
|
|
88
|
+
export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId: string) => boolean) | null) {
|
|
89
|
+
const inRange = (ms: number | null) => ms !== null && ms <= q.toMs && (q.fromMs === null || ms >= q.fromMs);
|
|
90
|
+
const days = dayRange(localDate(q.toMs, q.tz), q.days);
|
|
91
|
+
const firstDay = days[0];
|
|
92
|
+
const lastDay = days[days.length - 1];
|
|
93
|
+
const sparkDays = new Set(days.slice(-STATS_SPARK_DAYS));
|
|
94
|
+
const daily = new Map(days.map(d => [d, 0]));
|
|
95
|
+
|
|
96
|
+
let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, unattributed = 0;
|
|
97
|
+
const byProject = new Map<string | null, number>();
|
|
98
|
+
const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
|
|
99
|
+
const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
|
|
100
|
+
|
|
101
|
+
for (const row of rows) {
|
|
102
|
+
// 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
|
|
103
|
+
if (!row.archived && row.column_name === "doing") doing++;
|
|
104
|
+
if (!row.archived && row.column_name === "pool") pool++;
|
|
105
|
+
const completedMs = row.column_name === "done" ? msOf(row.completed_at) : null;
|
|
106
|
+
const createdMs = msOf(row.created_at);
|
|
107
|
+
if (inRange(createdMs)) {
|
|
108
|
+
created++;
|
|
109
|
+
if (completedMs !== null) createdDone++;
|
|
110
|
+
}
|
|
111
|
+
if (completedMs === null) continue;
|
|
112
|
+
// 每日曲线按自己的 days 窗口算(与 from 无关),这样一次请求就能同时画 30 天柱图和一年热力图。
|
|
113
|
+
if (completedMs <= q.toMs) {
|
|
114
|
+
const day = localDate(completedMs, q.tz);
|
|
115
|
+
if (day >= firstDay && day <= lastDay) daily.set(day, (daily.get(day) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
if (!inRange(completedMs)) continue;
|
|
118
|
+
const who = parseRef(row.completed_by_json);
|
|
119
|
+
recent.push({ row, ms: completedMs, by: who && !(who.kind === "node" && hidden?.(who.id)) ? who : null });
|
|
120
|
+
done++;
|
|
121
|
+
if (row.completed_at_approx) approx++;
|
|
122
|
+
byProject.set(row.project_id, (byProject.get(row.project_id) ?? 0) + 1);
|
|
123
|
+
const ref = parseRef(row.completed_by_json);
|
|
124
|
+
if (!ref || (ref.kind === "node" && hidden?.(ref.id))) { unattributed++; continue; }
|
|
125
|
+
const key = `${ref.kind}:${ref.id}`;
|
|
126
|
+
let c = byCompleter.get(key);
|
|
127
|
+
if (!c) byCompleter.set(key, c = { ref, n: 0, spark: new Map() });
|
|
128
|
+
c.n++;
|
|
129
|
+
const day = localDate(completedMs, q.tz);
|
|
130
|
+
if (sparkDays.has(day)) c.spark.set(day, (c.spark.get(day) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const spark = days.slice(-STATS_SPARK_DAYS);
|
|
134
|
+
return {
|
|
135
|
+
range: { from: q.fromMs === null ? null : new Date(q.fromMs).toISOString(), to: new Date(q.toMs).toISOString(), tz: q.tz },
|
|
136
|
+
totals: {
|
|
137
|
+
done,
|
|
138
|
+
// 其中完成时刻是升级前按 updated_at 补的近似值的张数;界面据此标「近似」。
|
|
139
|
+
done_approx: approx,
|
|
140
|
+
created,
|
|
141
|
+
created_done: createdDone,
|
|
142
|
+
// 完成率 = 期内新建的卡里,现在已完成的比例;期内没有新建 → null(不是 0%)。
|
|
143
|
+
completion_rate: created ? createdDone / created : null,
|
|
144
|
+
doing,
|
|
145
|
+
pool,
|
|
146
|
+
},
|
|
147
|
+
daily: days.map(date => ({ date, n: daily.get(date) ?? 0 })),
|
|
148
|
+
by_project: [...byProject].map(([project_id, n]) => ({ project_id, n })).sort((a, b) => b.n - a.n || String(a.project_id).localeCompare(String(b.project_id))),
|
|
149
|
+
by_completer: [...byCompleter.values()]
|
|
150
|
+
.sort((a, b) => b.n - a.n || `${a.ref.kind}:${a.ref.id}`.localeCompare(`${b.ref.kind}:${b.ref.id}`))
|
|
151
|
+
.slice(0, STATS_TOP_COMPLETERS)
|
|
152
|
+
.map(c => ({ ...c.ref, n: c.n, spark: spark.map(d => c.spark.get(d) ?? 0) })),
|
|
153
|
+
unattributed,
|
|
154
|
+
// 最近完成(新 → 旧):仪表盘的「最近完成」时间线和分享图的「今天完成的任务」。只含期内、调用者看得见的卡;
|
|
155
|
+
// 完成者是隐藏节点 → completed_by = null。
|
|
156
|
+
recent: recent
|
|
157
|
+
.sort((a, b) => b.ms - a.ms || (a.row.requirement_id < b.row.requirement_id ? 1 : -1))
|
|
158
|
+
.slice(0, q.recent)
|
|
159
|
+
.map(({ row, ms, by }) => ({
|
|
160
|
+
id: row.requirement_id, seq: row.seq == null ? null : Number(row.seq), name: row.title, project_id: row.project_id,
|
|
161
|
+
completed_at: new Date(ms).toISOString(), completed_at_approx: !!row.completed_at_approx, completed_by: by, archived: !!row.archived,
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// 负责人 / 负责 Agent 拆分:一次性 Hub 进程的升级端到端。
|
|
2
|
+
// 真起两次 `bun run src/index.ts`(HOME=临时目录、随机端口、绝不是 9200、独立库):
|
|
3
|
+
// 第一次:注册、建节点、建一张「旧式」卡(owner 是节点 —— 用 SQL 写进去,因为新 Hub 已不接受);
|
|
4
|
+
// 再把 agent_owner_json 列删掉,库回到 #2065 时代的样子。
|
|
5
|
+
// 第二次:同一个库启动 = 升级。启动迁移把节点挪到 agent_owner,HTTP 读回;然后走一遍新规则。
|
|
6
|
+
import { afterAll, expect, test } from "bun:test";
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
const home = mkdtempSync(join(tmpdir(), "anet-req-agent-owner-e2e-"));
|
|
14
|
+
const dbFile = join(home, "hub.db");
|
|
15
|
+
const port = 20000 + Math.floor(Math.random() * 20000);
|
|
16
|
+
if (port === 9200) throw new Error("9200 is the production hub port");
|
|
17
|
+
const base = `http://127.0.0.1:${port}`;
|
|
18
|
+
const serverDir = resolve(import.meta.dir, "..");
|
|
19
|
+
let proc: ChildProcess | null = null;
|
|
20
|
+
|
|
21
|
+
async function startHub() {
|
|
22
|
+
proc = spawn("bun", ["run", "src/index.ts"], {
|
|
23
|
+
cwd: serverDir,
|
|
24
|
+
env: { PATH: process.env.PATH || "", HOME: home, COMMHUB_DB: dbFile, COMMHUB_SERVER: "1", PORT: String(port), HOST: "127.0.0.1" },
|
|
25
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
let err = "";
|
|
28
|
+
proc.stderr?.on("data", d => { err += String(d); });
|
|
29
|
+
for (let i = 0; i < 100; i++) {
|
|
30
|
+
try { if ((await fetch(`${base}/health`)).ok) return; } catch {}
|
|
31
|
+
await new Promise(r => setTimeout(r, 150));
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`hub did not start on ${port}: ${err.slice(-600)}`);
|
|
34
|
+
}
|
|
35
|
+
async function stopHub() {
|
|
36
|
+
if (!proc) return;
|
|
37
|
+
const p = proc;
|
|
38
|
+
proc = null;
|
|
39
|
+
await new Promise<void>(res => { p.once("exit", () => res()); p.kill("SIGTERM"); setTimeout(() => { p.kill("SIGKILL"); res(); }, 5000); });
|
|
40
|
+
}
|
|
41
|
+
afterAll(async () => { await stopHub(); try { rmSync(home, { recursive: true, force: true }); } catch {} });
|
|
42
|
+
|
|
43
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
44
|
+
const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
|
|
45
|
+
return { status: res.status, body: await res.json() as any };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("an upgraded hub moves node owners to agent_owner and then enforces human owner / agent executor", async () => {
|
|
49
|
+
await startHub();
|
|
50
|
+
const reg = await (await fetch(`${base}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "e2e_owner", password: "E2eOwner123!pass" }) })).json() as any;
|
|
51
|
+
expect(reg.ok).toBe(true);
|
|
52
|
+
const token = reg.token as string, net = reg.network_id as string, uid = reg.user.user_id as string;
|
|
53
|
+
const human = { kind: "user", id: uid }, agent = { kind: "node", id: "node_e2e_exec" };
|
|
54
|
+
{
|
|
55
|
+
const db = new Database(dbFile);
|
|
56
|
+
db.run("INSERT INTO nodes (node_id,node_name,alias,network_id) VALUES (?1,'e2e-exec','e2e-exec',?2)", [agent.id, net]);
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
const card = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "旧式卡", network_id: net, participants: [human] }) });
|
|
60
|
+
expect(card.status).toBe(201);
|
|
61
|
+
const keep = await api(token, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "人类负责人卡", network_id: net, owner: human }) });
|
|
62
|
+
expect(keep.status).toBe(201);
|
|
63
|
+
await stopHub();
|
|
64
|
+
{
|
|
65
|
+
// 退回旧库:节点当负责人,且没有 agent_owner_json 列
|
|
66
|
+
const db = new Database(dbFile);
|
|
67
|
+
db.run("UPDATE requirements SET owner_json = ?1 WHERE requirement_id = ?2", [JSON.stringify(agent), card.body.requirement.id]);
|
|
68
|
+
db.exec("ALTER TABLE requirements DROP COLUMN agent_owner_json");
|
|
69
|
+
const cols = (db.query("PRAGMA table_info(requirements)").all() as { name: string }[]).map(c => c.name);
|
|
70
|
+
expect(cols.includes("agent_owner_json")).toBe(false);
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
await startHub();
|
|
74
|
+
const listed = await api(token, `/api/requirements?network_id=${net}`);
|
|
75
|
+
expect(listed.status).toBe(200);
|
|
76
|
+
expect(listed.body.requirements.length).toBe(2);
|
|
77
|
+
const upgraded = listed.body.requirements.find((r: any) => r.id === card.body.requirement.id);
|
|
78
|
+
expect(upgraded.owner).toBeNull();
|
|
79
|
+
expect(upgraded.agent_owner).toEqual(agent);
|
|
80
|
+
expect(upgraded.participants).toEqual([human]);
|
|
81
|
+
expect(upgraded.name).toBe("旧式卡");
|
|
82
|
+
const untouched = listed.body.requirements.find((r: any) => r.id === keep.body.requirement.id);
|
|
83
|
+
expect(untouched.owner).toEqual(human);
|
|
84
|
+
expect(untouched.agent_owner).toBeNull();
|
|
85
|
+
|
|
86
|
+
const path = `/api/requirements/${upgraded.id}?network_id=${net}`;
|
|
87
|
+
const both = await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: human }) });
|
|
88
|
+
expect(both.status).toBe(200);
|
|
89
|
+
expect(both.body.requirement.owner).toEqual(human);
|
|
90
|
+
expect(both.body.requirement.agent_owner).toEqual(agent);
|
|
91
|
+
expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ owner: agent, agent_owner: agent }) })).body.error).toBe("owner_must_be_human");
|
|
92
|
+
expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ agent_owner: human }) })).body.error).toBe("agent_owner_must_be_agent");
|
|
93
|
+
|
|
94
|
+
// 预计完成:旧卡的全天值(升级前写的)原样读回;改成带时刻的,存 UTC
|
|
95
|
+
const dated = await api(token, path, { method: "PATCH", body: JSON.stringify({ due: "2026-10-01" }) });
|
|
96
|
+
expect(dated.body.requirement.due).toBe("2026-10-01");
|
|
97
|
+
// 描述 / 子任务:升级后的旧卡可写,单项勾选只动那一项
|
|
98
|
+
expect(upgraded.description).toBe("");
|
|
99
|
+
expect(upgraded.checklist).toEqual([]);
|
|
100
|
+
const withList = await api(token, path, { method: "PATCH", body: JSON.stringify({ description: "# 验收\n- 能用", checklist: [{ id: "one", text: "第一步" }, { id: "two", text: "第二步" }] }) });
|
|
101
|
+
expect(withList.status).toBe(200);
|
|
102
|
+
const ticked = await api(token, `/api/requirements/${upgraded.id}/checklist/two?network_id=${net}`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
103
|
+
expect(ticked.body.requirement.checklist).toEqual([{ id: "one", text: "第一步", done: false }, { id: "two", text: "第二步", done: true }]);
|
|
104
|
+
|
|
105
|
+
// 项目:升级后没有任何项目(不预置),建一个、给旧卡挂上
|
|
106
|
+
expect((await api(token, `/api/requirements/projects?network_id=${net}`)).body.projects).toEqual([]);
|
|
107
|
+
const proj = await api(token, `/api/requirements/projects?network_id=${net}`, { method: "POST", body: JSON.stringify({ name: "军团项目" }) });
|
|
108
|
+
expect(proj.status).toBe(201);
|
|
109
|
+
expect((await api(token, path, { method: "PATCH", body: JSON.stringify({ project_id: proj.body.project.id }) })).body.requirement.project_id).toBe(proj.body.project.id);
|
|
110
|
+
|
|
111
|
+
// 第三次启动(同一个库):迁移不再改任何东西
|
|
112
|
+
await stopHub();
|
|
113
|
+
await startHub();
|
|
114
|
+
const again = (await api(token, `/api/requirements?network_id=${net}`)).body.requirements;
|
|
115
|
+
expect(again.length).toBe(2);
|
|
116
|
+
const same = again.find((r: any) => r.id === upgraded.id);
|
|
117
|
+
expect(same.owner).toEqual(human);
|
|
118
|
+
expect(same.agent_owner).toEqual(agent);
|
|
119
|
+
expect(same.description).toBe("# 验收\n- 能用");
|
|
120
|
+
expect(same.checklist.map((i: any) => i.done)).toEqual([false, true]);
|
|
121
|
+
expect(same.project_id).toBe(proj.body.project.id);
|
|
122
|
+
expect(same.due).toBe("2026-10-01");
|
|
123
|
+
const timed = await api(token, `/api/requirements/${upgraded.id}?network_id=${net}`, { method: "PATCH", body: JSON.stringify({ due: "2026-10-01T18:30:45+08:00" }) });
|
|
124
|
+
expect(timed.body.requirement.due).toBe("2026-10-01T10:30:45Z");
|
|
125
|
+
expect((await api(token, `/api/requirements/projects?network_id=${net}`)).body.projects.map((p: any) => p.name)).toEqual(["军团项目"]);
|
|
126
|
+
}, 60_000);
|