@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30

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.
Files changed (99) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +188 -0
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/peer-reply-atomic.test.ts +455 -0
  44. package/src/peer-reply-capability.test.ts +70 -0
  45. package/src/probe-validate.test.ts +221 -0
  46. package/src/probe-validate.ts +104 -0
  47. package/src/probe.test.ts +80 -0
  48. package/src/probe.ts +200 -0
  49. package/src/push.ts +96 -7
  50. package/src/require-explicit-test-db.ts +9 -0
  51. package/src/resolve-port.test.ts +57 -0
  52. package/src/resolve-port.ts +44 -0
  53. package/src/response-charset.test.ts +130 -0
  54. package/src/rest-explicit-columns-http.test.ts +211 -0
  55. package/src/rest-identity.test.ts +129 -0
  56. package/src/rest-identity.ts +100 -0
  57. package/src/rest-projections.ts +67 -0
  58. package/src/rest-write-network-resolution.test.ts +133 -0
  59. package/src/runtime-label.test.ts +22 -0
  60. package/src/runtime-label.ts +21 -0
  61. package/src/scheduled-run-terminal.test.ts +268 -0
  62. package/src/scheduled-tasks-http.test.ts +479 -0
  63. package/src/scheduled-tasks.ts +549 -0
  64. package/src/send-reply-agent-warning.test.ts +202 -0
  65. package/src/send-reply-attachments.test.ts +437 -0
  66. package/src/server.ts +3492 -0
  67. package/src/shared/external-schedule-contract.ts +60 -0
  68. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  69. package/src/shared/probe-host-allowlist.ts +128 -0
  70. package/src/side-thread-command-transport.test.ts +140 -0
  71. package/src/side-thread-command-transport.ts +309 -0
  72. package/src/side-thread-http-integration.test.ts +229 -0
  73. package/src/side-thread-http.ts +504 -0
  74. package/src/side-thread.test.ts +1038 -0
  75. package/src/side-thread.ts +2241 -0
  76. package/src/skillhub-http.test.ts +88 -0
  77. package/src/stop-delete-node.test.ts +720 -0
  78. package/src/task-auth-origin-http.test.ts +87 -0
  79. package/src/task-auth-origin.test.ts +24 -0
  80. package/src/task-auth-origin.ts +7 -0
  81. package/src/task-consumption.test.ts +398 -0
  82. package/src/task-diagnostic-http.test.ts +145 -0
  83. package/src/task-diagnostic.test.ts +115 -0
  84. package/src/task-diagnostic.ts +118 -0
  85. package/src/task-idempotency-mcp.test.ts +142 -0
  86. package/src/task-idempotency.test.ts +60 -0
  87. package/src/task-idempotency.ts +75 -0
  88. package/src/task-lifecycle-watcher.test.ts +200 -0
  89. package/src/task-lifecycle-watcher.ts +49 -0
  90. package/src/task-network-resolution-http.test.ts +137 -0
  91. package/src/tasks-pagination-http.test.ts +137 -0
  92. package/src/tools.ts +2302 -169
  93. package/src/update-provider.test.ts +234 -0
  94. package/src/upload-early-reject-guard.test.ts +155 -0
  95. package/src/uploads-http.test.ts +92 -14
  96. package/src/uploads.test.ts +66 -0
  97. package/src/uploads.ts +130 -5
  98. package/src/vault.test.ts +200 -0
  99. package/src/vault.ts +156 -0
@@ -0,0 +1,200 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { db, logTaskEvent } from "./db.js";
4
+ import { TASK_EVENT_REST_COLUMNS } from "./rest-projections.js";
5
+ import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
6
+
7
+ const NOW = new Date("2026-08-10T00:01:01.000Z");
8
+ let server: ReturnType<typeof Bun.serve>;
9
+ let base = "";
10
+ let token = "";
11
+ let networkId = "";
12
+
13
+ function insertTask(taskId: string, status: string, deliveredAt: string | null, network = networkId) {
14
+ db.run(
15
+ `INSERT INTO tasks
16
+ (task_id, from_name, to_name, status, content, delivered_at, network_id)
17
+ VALUES (?1, 'sender', 'target', ?2, 'watcher fixture', ?3, ?4)`,
18
+ [taskId, status, deliveredAt, network],
19
+ );
20
+ }
21
+
22
+ function sqliteUtcTimestamp(date: Date): string {
23
+ return date.toISOString().slice(0, 19).replace("T", " ");
24
+ }
25
+
26
+ beforeAll(async () => {
27
+ const { register } = await import("./auth.js");
28
+ const auth = register(`watcher_owner_${Date.now()}`, "WatcherOwner-Strong-1!", undefined, "watcher");
29
+ expect(auth.ok).toBe(true);
30
+ token = auth.token!;
31
+ networkId = auth.network_id!;
32
+
33
+ insertTask("stale-29", "delivered", "2026-08-10 00:00:32");
34
+ insertTask("stale-30", "delivered", "2026-08-10 00:00:31");
35
+ insertTask("stale-60", "delivered", "2026-08-10 00:00:01");
36
+ insertTask("stale-acked", "acked", "2026-08-10 00:00:00");
37
+ insertTask("stale-no-delivery", "delivered", null);
38
+
39
+ const mod = await import("./server.js");
40
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
41
+ base = `http://127.0.0.1:${server.port}`;
42
+ });
43
+
44
+ afterAll(() => {
45
+ try { server?.stop(true); } catch {}
46
+ });
47
+
48
+ /**
49
+ * 轮询到 `ready()` 为真,或到期抛错。
50
+ *
51
+ * 定长 sleep 的问题不是「慢」,是**报错报在错的层**:超时之后测试红在
52
+ * 「事件没写」这条断言上,读的人会去查 watcher,而真实原因可能是子进程
53
+ * 还没起来。这里到期时把那句话直接说出来。
54
+ */
55
+ async function waitUntil(ready: () => boolean, timeoutMs: number, what: string | null): Promise<void> {
56
+ const deadline = Date.now() + timeoutMs;
57
+ for (;;) {
58
+ if (ready()) return;
59
+ if (Date.now() >= deadline) {
60
+ // what === null:到期本身就是期望结果(调用方随后自己断言),不抛。
61
+ if (what === null) return;
62
+ throw new Error(`timed out after ${timeoutMs}ms: ${what}`);
63
+ }
64
+ await Bun.sleep(25);
65
+ }
66
+ }
67
+
68
+ describe("#167 Hub delivered-stale lifecycle watcher", () => {
69
+ test("30s/60s thresholds are exact and non-delivered tasks stay silent", () => {
70
+ const first = recordDeliveredStaleEvents(NOW);
71
+ expect(first.inserted).toBe(3);
72
+ expect(first.by_threshold_seconds).toEqual({ 30: 2, 60: 1 });
73
+
74
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-29'")!.count).toBe(0);
75
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-30'")!.count).toBe(1);
76
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-60'")!.count).toBe(2);
77
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-acked'")!.count).toBe(0);
78
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-no-delivery'")!.count).toBe(0);
79
+
80
+ const sixty = db.all<any>("SELECT * FROM task_events WHERE task_id = 'stale-60' ORDER BY event_type");
81
+ expect(sixty.map((row) => row.event_type)).toEqual([
82
+ "task.warning.delivered_stale_30s",
83
+ "task.warning.delivered_stale_60s",
84
+ ]);
85
+ expect(sixty.map((row) => row.event_key)).toEqual(sixty.map((row) => row.event_type));
86
+ expect(sixty.every((row) => row.from_status === "delivered" && row.to_status === "delivered")).toBe(true);
87
+ expect(sixty.every((row) => row.actor === "patrol")).toBe(true);
88
+ expect(sixty.every((row) => row.network_id === networkId)).toBe(true);
89
+ });
90
+
91
+ test("a second patrol is write-once for each task and threshold", () => {
92
+ const second = recordDeliveredStaleEvents(NOW);
93
+ expect(second.inserted).toBe(0);
94
+ expect(second.by_threshold_seconds).toEqual({ 30: 0, 60: 0 });
95
+ expect(db.get<{ count: number }>(
96
+ "SELECT COUNT(*) AS count FROM task_events WHERE event_type LIKE 'task.warning.delivered_stale_%'",
97
+ )!.count).toBe(3);
98
+ });
99
+
100
+ test("ordinary lifecycle events get standard event names without a dedupe key", () => {
101
+ logTaskEvent("stale-30", null, "delivered", "target");
102
+ logTaskEvent("stale-30", "delivered", "acked", "target");
103
+ logTaskEvent("stale-30", "acked", "running", "target");
104
+ logTaskEvent("stale-30", "running", "replied", "target");
105
+ logTaskEvent("stale-30", "running", "failed", "target");
106
+ logTaskEvent("stale-30", "delivered", "expired", "target");
107
+ const rows = db.all<any>(
108
+ "SELECT event_type, event_key FROM task_events WHERE task_id = 'stale-30' AND actor = 'target' ORDER BY id",
109
+ );
110
+ expect(rows).toEqual([
111
+ { event_type: "task.send.delivered", event_key: null },
112
+ { event_type: "task.ack", event_key: null },
113
+ { event_type: "task.started", event_key: null },
114
+ { event_type: "task.replied", event_key: null },
115
+ { event_type: "task.failed", event_key: null },
116
+ { event_type: "task.expired", event_key: null },
117
+ ]);
118
+ });
119
+
120
+ test("REST exposes event_type but keeps the internal idempotency key private", async () => {
121
+ expect(TASK_EVENT_REST_COLUMNS).toContain("event_type");
122
+ expect(TASK_EVENT_REST_COLUMNS).not.toContain("event_key" as any);
123
+ const response = await fetch(
124
+ `${base}/api/task_events?network_id=${encodeURIComponent(networkId)}&task_id=stale-60`,
125
+ { headers: { Authorization: `Bearer ${token}` } },
126
+ );
127
+ expect(response.status).toBe(200);
128
+ const body = await response.json() as any;
129
+ expect(body.events).toHaveLength(2);
130
+ expect(body.events.every((event: any) => typeof event.event_type === "string")).toBe(true);
131
+ expect(body.events.every((event: any) => event.event_key === undefined)).toBe(true);
132
+ });
133
+
134
+ test("startHub owns a live watcher timer instead of relying on import side effects", async () => {
135
+ const childDbPath = `/tmp/test683-live-${process.pid}-${Date.now()}.db`;
136
+ const init = Bun.spawnSync(["bun", "-e", "await import('./server/src/db.js')"], {
137
+ cwd: process.cwd(),
138
+ env: { ...process.env, COMMHUB_DB: childDbPath },
139
+ stdout: "pipe",
140
+ stderr: "pipe",
141
+ });
142
+ expect(init.exitCode).toBe(0);
143
+
144
+ const child = Bun.spawn(["bun", "run", "server/src/index.ts"], {
145
+ cwd: process.cwd(),
146
+ env: {
147
+ ...process.env,
148
+ COMMHUB_DB: childDbPath,
149
+ COMMHUB_DELIVERED_STALE_PATROL_MS: "25",
150
+ PORT: "0",
151
+ },
152
+ stdout: "pipe",
153
+ stderr: "pipe",
154
+ });
155
+ // 这里**不**等「hub 就绪」——因为没有一个只有 hub 起来了才成立的廉价判据:
156
+ // db 文件在上面那步 init 里就已经存在了,拿它当条件的话这个等待恒真,等于没等。
157
+ // 真正需要「hub 起来了」的是下面那条断言,而它已经改成轮询到目标状态,
158
+ // hub 起得慢只是让它多等几轮。
159
+ //
160
+ // 这一步保留的是原来那条断言的原意:**子进程没有立刻崩**。所以只给它一个
161
+ // 短窗口,并且如果它在窗口内退出就立刻停下来报错,不用把 800ms 睡满。
162
+ await waitUntil(() => child.exitCode !== null, 800, null);
163
+ expect(child.exitCode).toBeNull();
164
+
165
+ const taskId = "stale-live-wiring";
166
+ const childDb = new Database(childDbPath);
167
+ try {
168
+ // startHub may still be finishing its startup migration transaction even
169
+ // though the process has stayed alive. Wait for that short-lived writer
170
+ // instead of turning scheduler timing into an immediate SQLITE_BUSY.
171
+ childDb.exec("PRAGMA busy_timeout = 5000");
172
+ childDb.run(
173
+ `INSERT INTO tasks
174
+ (task_id, from_name, to_name, status, content, delivered_at, network_id)
175
+ VALUES (?1, 'sender', 'target', 'delivered', 'live watcher fixture', ?2, ?3)`,
176
+ [taskId, sqliteUtcTimestamp(new Date(Date.now() - 28_000)), networkId],
177
+ );
178
+ expect(childDb.query<{ count: number }, [string]>(
179
+ "SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1",
180
+ ).get(taskId)!.count).toBe(0);
181
+ // 巡检周期是 COMMHUB_DELIVERED_STALE_PATROL_MS=25ms —— 事件在插入后
182
+ // 几十毫秒内就该出现。原来这里是定长 sleep(3_200),纯粹是余量:
183
+ // 4.0s 的 sleep 装在 bun 默认的 5.0s 单测预算里,只剩 1s 给两次进程启动。
184
+ // 实测在 CI 上被这一条打红过(#798 让这个文件第一次进 CI 才暴露)。
185
+ // 改成「轮询到目标状态,或到期报错」:常态下快 ~60 倍,慢的时候等得起。
186
+ const countStale = () => childDb.query<{ count: number }, [string]>(
187
+ "SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1 AND event_type = 'task.warning.delivered_stale_30s'",
188
+ ).get(taskId)!.count;
189
+ await waitUntil(() => countStale() === 1, 20_000,
190
+ "watcher did not write the delivered_stale_30s event");
191
+ expect(countStale()).toBe(1);
192
+ } finally {
193
+ childDb.close();
194
+ try { child.kill("SIGTERM"); } catch {}
195
+ await child.exited;
196
+ }
197
+ // 🔴 显式超时:这一条要起两个真 bun 进程,bun 默认的 5s 对它不成立。
198
+ // 上面两处已改成轮询,常态用不到这个上限;它只保证「慢」不会被报成「坏」。
199
+ }, 30_000);
200
+ });
@@ -0,0 +1,49 @@
1
+ import { db } from "./db.js";
2
+
3
+ export const DELIVERED_STALE_THRESHOLDS = [30, 60] as const;
4
+
5
+ export type DeliveredStalePatrolResult = {
6
+ inserted: number;
7
+ by_threshold_seconds: Record<(typeof DELIVERED_STALE_THRESHOLDS)[number], number>;
8
+ };
9
+
10
+ function sqliteUtcTimestamp(date: Date): string {
11
+ return date.toISOString().slice(0, 19).replace("T", " ");
12
+ }
13
+
14
+ /**
15
+ * Persist Hub-authoritative warnings for tasks which are still delivered.
16
+ *
17
+ * The task row, not an agent-local timer, is the source of truth. event_key is
18
+ * equal to the stable event name and protected by a unique index, so every
19
+ * threshold is write-once across restarts, patrols, and multiple Hub workers.
20
+ */
21
+ export function recordDeliveredStaleEvents(now = new Date()): DeliveredStalePatrolResult {
22
+ if (!Number.isFinite(now.getTime())) throw new Error("invalid lifecycle patrol time");
23
+
24
+ const byThreshold = { 30: 0, 60: 0 };
25
+ let inserted = 0;
26
+
27
+ for (const thresholdSeconds of DELIVERED_STALE_THRESHOLDS) {
28
+ const eventType = `task.warning.delivered_stale_${thresholdSeconds}s`;
29
+ const cutoff = sqliteUtcTimestamp(new Date(now.getTime() - thresholdSeconds * 1000));
30
+ const detail = thresholdSeconds === 30
31
+ ? "still delivered after 30s; target has not started"
32
+ : "still delivered after 60s; target may be offline or not consuming";
33
+ const result = db.run(
34
+ `INSERT INTO task_events
35
+ (task_id, from_status, to_status, event_type, event_key, actor, detail, network_id)
36
+ SELECT task_id, 'delivered', 'delivered', ?1, ?1, 'patrol', ?2, network_id
37
+ FROM tasks
38
+ WHERE status = 'delivered'
39
+ AND delivered_at IS NOT NULL
40
+ AND delivered_at <= ?3
41
+ ON CONFLICT(task_id, event_key) DO NOTHING`,
42
+ [eventType, detail, cutoff],
43
+ );
44
+ byThreshold[thresholdSeconds] = result.changes;
45
+ inserted += result.changes;
46
+ }
47
+
48
+ return { inserted, by_threshold_seconds: byThreshold };
49
+ }
@@ -0,0 +1,137 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-task-network-resolution-"));
9
+ let server: any;
10
+ let base = "";
11
+
12
+ type Principal = { token: string; userId: string; networkId: string; target: string };
13
+ let adminSingle: Principal;
14
+ let userSingle: Principal;
15
+ let adminMulti: Principal;
16
+ let adminZero: Principal;
17
+
18
+ function registerPrincipal(label: string, admin: boolean): Principal {
19
+ const username = `task_net_${label}_${Date.now()}_${Math.random().toString(16).slice(2)}`;
20
+ const registered = register(username, "TaskNetwork123!", undefined, "seed");
21
+ expect(registered.ok).toBe(true);
22
+ const userId = db.get<{ user_id: string }>(
23
+ "SELECT user_id FROM users WHERE username = ?1",
24
+ [username],
25
+ )!.user_id;
26
+ if (admin) db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [userId]);
27
+ return {
28
+ token: registered.token!,
29
+ userId,
30
+ networkId: registered.network_id!,
31
+ target: `task-net-${label}-${process.pid}`,
32
+ };
33
+ }
34
+
35
+ function seedTarget(principal: Principal): void {
36
+ const nodeId = `node_${principal.target}`;
37
+ db.run(
38
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
39
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
40
+ [`resume_${principal.target}`, principal.target, nodeId, principal.networkId],
41
+ );
42
+ db.run(
43
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
44
+ VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
45
+ [nodeId, principal.target, principal.networkId],
46
+ );
47
+ }
48
+
49
+ beforeAll(async () => {
50
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
51
+ adminSingle = registerPrincipal("admin_single", true);
52
+ userSingle = registerPrincipal("user_single", false);
53
+ adminMulti = registerPrincipal("admin_multi", true);
54
+ adminZero = registerPrincipal("admin_zero", true);
55
+ seedTarget(adminSingle);
56
+ seedTarget(userSingle);
57
+ seedTarget(adminMulti);
58
+
59
+ const secondNetwork = `net_task_second_${process.pid}`;
60
+ db.run(
61
+ "INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, 'second', ?2, datetime('now'))",
62
+ [secondNetwork, adminMulti.userId],
63
+ );
64
+ db.run(
65
+ "INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))",
66
+ [adminMulti.userId, secondNetwork],
67
+ );
68
+ db.run("DELETE FROM network_members WHERE user_id = ?1", [adminZero.userId]);
69
+
70
+ const mod: any = await import("./server.js");
71
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
72
+ base = `http://127.0.0.1:${server.port}`;
73
+ });
74
+
75
+ afterAll(() => {
76
+ try { server?.stop?.(true); } catch {}
77
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
78
+ });
79
+
80
+ async function post(principal: Principal, marker: string, networkId?: string) {
81
+ const response = await fetch(`${base}/api/task`, {
82
+ method: "POST",
83
+ headers: { Authorization: `Bearer ${principal.token}`, "Content-Type": "application/json" },
84
+ body: JSON.stringify({
85
+ alias: principal.target,
86
+ from: "api",
87
+ task: marker,
88
+ priority: "high",
89
+ ...(networkId ? { network_id: networkId } : {}),
90
+ }),
91
+ });
92
+ return { response, body: await response.json() as any };
93
+ }
94
+
95
+ describe("POST /api/task single-network resolution (#448)", () => {
96
+ test("admin with exactly one membership may omit network_id", async () => {
97
+ const marker = `x448-admin-single-${Date.now()}`;
98
+ const { response, body } = await post(adminSingle, marker);
99
+ expect([200, 202]).toContain(response.status);
100
+ expect(body.ok).toBe(true);
101
+ expect(db.get<{ network_id: string }>("SELECT network_id FROM tasks WHERE content = ?1", [marker])?.network_id)
102
+ .toBe(adminSingle.networkId);
103
+ });
104
+
105
+ test("ordinary single-network user behavior remains unchanged", async () => {
106
+ const marker = `x448-user-single-${Date.now()}`;
107
+ const { response, body } = await post(userSingle, marker);
108
+ expect([200, 202]).toContain(response.status);
109
+ expect(body.ok).toBe(true);
110
+ expect(db.get<{ network_id: string }>("SELECT network_id FROM tasks WHERE content = ?1", [marker])?.network_id)
111
+ .toBe(userSingle.networkId);
112
+ });
113
+
114
+ test("multi-network admin must select a network explicitly", async () => {
115
+ const { response, body } = await post(adminMulti, `x448-admin-multi-${Date.now()}`);
116
+ expect(response.status).toBe(400);
117
+ expect(body).toEqual({
118
+ ok: false,
119
+ error: "network_id_required",
120
+ message: "network_id is required when the user token has zero or multiple network memberships",
121
+ });
122
+ });
123
+
124
+ test("zero-membership admin gets the same accurate error", async () => {
125
+ const { response, body } = await post(adminZero, `x448-admin-zero-${Date.now()}`);
126
+ expect(response.status).toBe(400);
127
+ expect(body.error).toBe("network_id_required");
128
+ expect(body.message).toContain("zero or multiple network memberships");
129
+ });
130
+
131
+ test("multi-network admin can still select an explicit member network", async () => {
132
+ const marker = `x448-admin-explicit-${Date.now()}`;
133
+ const { response, body } = await post(adminMulti, marker, adminMulti.networkId);
134
+ expect([200, 202]).toContain(response.status);
135
+ expect(body.ok).toBe(true);
136
+ });
137
+ });
@@ -0,0 +1,137 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-task-pagination-"));
7
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
8
+
9
+ let db: typeof import("./db.js").db;
10
+ let register: typeof import("./auth.js").register;
11
+ let server: ReturnType<typeof Bun.serve>;
12
+ let base = "";
13
+ let tokenA = "";
14
+ let networkA = "";
15
+ let networkB = "";
16
+
17
+ function seedTask(
18
+ taskId: string,
19
+ networkId: string,
20
+ toName: string,
21
+ createdAt: string,
22
+ ): void {
23
+ db.run(
24
+ `INSERT INTO tasks
25
+ (task_id, from_name, to_name, priority, status, content, network_id, created_at)
26
+ VALUES (?1, 'sender', ?2, 'normal', 'delivered', ?1, ?3, ?4)`,
27
+ [taskId, toName, networkId, createdAt],
28
+ );
29
+ }
30
+
31
+ beforeAll(async () => {
32
+ ({ db } = await import("./db.js"));
33
+ ({ register } = await import("./auth.js"));
34
+ const b = register(`page_b_${Date.now()}`, "PageTest-B-Strong!", undefined, "seed-b");
35
+ // The first registered user is the instance admin and may read all
36
+ // networks. Register the foreign fixture first so tokenA exercises the
37
+ // ordinary member-scoped path rather than the admin bypass.
38
+ const a = register(`page_a_${Date.now()}`, "PageTest-A-Strong!", undefined, "seed-a");
39
+ expect(a.ok).toBe(true);
40
+ expect(b.ok).toBe(true);
41
+ tokenA = a.token!;
42
+ networkA = a.network_id!;
43
+ networkB = b.network_id!;
44
+
45
+ seedTask("a-new", networkA, "agent-a", "2026-01-04 00:00:00");
46
+ seedTask("tie-z", networkA, "agent-a", "2026-01-05 00:00:00");
47
+ seedTask("tie-y", networkA, "agent-a", "2026-01-05 00:00:00");
48
+ seedTask("tie-x", networkA, "agent-a", "2026-01-05 00:00:00");
49
+ seedTask("a-cursor", networkA, "agent-a", "2026-01-03 00:00:00");
50
+ seedTask("a-old-2", networkA, "agent-a", "2026-01-02 00:00:00");
51
+ seedTask("a-old-1", networkA, "agent-b", "2026-01-01 00:00:00");
52
+ seedTask("b-old", networkB, "agent-a", "2026-01-02 12:00:00");
53
+
54
+ const mod = await import("./server.js");
55
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
56
+ base = `http://127.0.0.1:${server.port}`;
57
+ });
58
+
59
+ afterAll(() => {
60
+ try { server?.stop(true); } catch {}
61
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
62
+ });
63
+
64
+ async function list(query: string): Promise<{ response: Response; body: any }> {
65
+ const response = await fetch(`${base}/api/tasks?skip_stats=1&${query}`, {
66
+ headers: { Authorization: `Bearer ${tokenA}` },
67
+ });
68
+ return { response, body: await response.json() };
69
+ }
70
+
71
+ describe("GET /api/tasks cursor pagination", () => {
72
+ test("before is exclusive and remains network-scoped", async () => {
73
+ const { response, body } = await list("before=2026-01-03%2000%3A00%3A00&limit=20");
74
+ expect(response.status).toBe(200);
75
+ expect(body.tasks.map((row: any) => row.task_id)).toEqual(["a-old-2", "a-old-1"]);
76
+ expect(body.tasks.some((row: any) => row.task_id === "a-cursor")).toBe(false);
77
+ expect(body.tasks.some((row: any) => row.task_id === "b-old")).toBe(false);
78
+ });
79
+
80
+ test("ISO cursor normalizes to SQLite UTC and composes with to_name", async () => {
81
+ const { body } = await list("before=2026-01-03T00%3A00%3A00Z&to_name=agent-a&limit=20");
82
+ expect(body.tasks.map((row: any) => row.task_id)).toEqual(["a-old-2"]);
83
+ });
84
+
85
+ test("before_task_id preserves rows that share the cursor second", async () => {
86
+ const first = await list("limit=2");
87
+ expect(first.body.tasks.map((row: any) => row.task_id)).toEqual(["tie-z", "tie-y"]);
88
+ const cursor = first.body.tasks.at(-1);
89
+ const second = await list(
90
+ `before=${encodeURIComponent(cursor.created_at)}&before_task_id=${encodeURIComponent(cursor.task_id)}&limit=20`,
91
+ );
92
+ expect(second.response.status).toBe(200);
93
+ expect(second.body.tasks.map((row: any) => row.task_id)).toEqual([
94
+ "tie-x",
95
+ "a-new",
96
+ "a-cursor",
97
+ "a-old-2",
98
+ "a-old-1",
99
+ ]);
100
+ });
101
+
102
+ test("omitting before preserves the existing response shape and newest-first order", async () => {
103
+ const { body } = await list("limit=20");
104
+ expect(body.ok).toBe(true);
105
+ expect(body.count).toBe(7);
106
+ expect(body.tasks.map((row: any) => row.task_id)).toEqual([
107
+ "tie-z",
108
+ "tie-y",
109
+ "tie-x",
110
+ "a-new",
111
+ "a-cursor",
112
+ "a-old-2",
113
+ "a-old-1",
114
+ ]);
115
+ expect(body.stats).toBeUndefined();
116
+ });
117
+
118
+ test("empty, malformed, and rolled-over cursors fail closed", async () => {
119
+ for (const cursor of ["", "not-a-time", "2026-02-31 00:00:00"]) {
120
+ const { response, body } = await list(`before=${encodeURIComponent(cursor)}`);
121
+ expect(response.status).toBe(400);
122
+ expect(body).toEqual({ ok: false, error: "invalid_before" });
123
+ }
124
+ });
125
+
126
+ test("before_task_id requires a valid timestamp cursor and task id", async () => {
127
+ for (const query of [
128
+ "before_task_id=tie-y",
129
+ "before=2026-01-05%2000%3A00%3A00&before_task_id=",
130
+ `before=2026-01-05%2000%3A00%3A00&before_task_id=${"x".repeat(257)}`,
131
+ ]) {
132
+ const { response, body } = await list(query);
133
+ expect(response.status).toBe(400);
134
+ expect(body).toEqual({ ok: false, error: "invalid_before_task_id" });
135
+ }
136
+ });
137
+ });