@sleep2agi/commhub-server 0.9.0-preview.28 → 0.9.0-preview.29
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/bin/commhub.ts +4 -0
- package/package.json +4 -4
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-networks-http.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +1 -6
- package/src/auth.ts +88 -14
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +279 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +10 -17
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +1 -1
- package/src/file-download-authz.test.ts +21 -1
- package/src/file-download-date-bucket.test.ts +1 -1
- package/src/file-network-scope-dev-open.test.ts +1 -1
- package/src/file-network-scope.test.ts +1 -1
- package/src/health-redaction.test.ts +12 -0
- package/src/index.ts +6 -2
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +20 -0
- package/src/node-attrs.test.ts +1 -1
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/rest-explicit-columns-http.test.ts +149 -0
- package/src/rest-identity.test.ts +120 -0
- package/src/rest-identity.ts +93 -0
- package/src/rest-projections.ts +67 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +460 -0
- package/src/scheduled-tasks.ts +538 -0
- package/src/send-reply-agent-warning.test.ts +38 -38
- package/src/server.ts +315 -60
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +329 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +82 -0
- package/src/task-diagnostic.ts +89 -0
- package/src/task-idempotency-mcp.test.ts +137 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +160 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +635 -107
- package/src/uploads-http.test.ts +1 -3
- package/src/uploads.ts +14 -3
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
describe("#167 Hub delivered-stale lifecycle watcher", () => {
|
|
49
|
+
test("30s/60s thresholds are exact and non-delivered tasks stay silent", () => {
|
|
50
|
+
const first = recordDeliveredStaleEvents(NOW);
|
|
51
|
+
expect(first.inserted).toBe(3);
|
|
52
|
+
expect(first.by_threshold_seconds).toEqual({ 30: 2, 60: 1 });
|
|
53
|
+
|
|
54
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-29'")!.count).toBe(0);
|
|
55
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-30'")!.count).toBe(1);
|
|
56
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-60'")!.count).toBe(2);
|
|
57
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-acked'")!.count).toBe(0);
|
|
58
|
+
expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM task_events WHERE task_id = 'stale-no-delivery'")!.count).toBe(0);
|
|
59
|
+
|
|
60
|
+
const sixty = db.all<any>("SELECT * FROM task_events WHERE task_id = 'stale-60' ORDER BY event_type");
|
|
61
|
+
expect(sixty.map((row) => row.event_type)).toEqual([
|
|
62
|
+
"task.warning.delivered_stale_30s",
|
|
63
|
+
"task.warning.delivered_stale_60s",
|
|
64
|
+
]);
|
|
65
|
+
expect(sixty.map((row) => row.event_key)).toEqual(sixty.map((row) => row.event_type));
|
|
66
|
+
expect(sixty.every((row) => row.from_status === "delivered" && row.to_status === "delivered")).toBe(true);
|
|
67
|
+
expect(sixty.every((row) => row.actor === "patrol")).toBe(true);
|
|
68
|
+
expect(sixty.every((row) => row.network_id === networkId)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a second patrol is write-once for each task and threshold", () => {
|
|
72
|
+
const second = recordDeliveredStaleEvents(NOW);
|
|
73
|
+
expect(second.inserted).toBe(0);
|
|
74
|
+
expect(second.by_threshold_seconds).toEqual({ 30: 0, 60: 0 });
|
|
75
|
+
expect(db.get<{ count: number }>(
|
|
76
|
+
"SELECT COUNT(*) AS count FROM task_events WHERE event_type LIKE 'task.warning.delivered_stale_%'",
|
|
77
|
+
)!.count).toBe(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("ordinary lifecycle events get standard event names without a dedupe key", () => {
|
|
81
|
+
logTaskEvent("stale-30", null, "delivered", "target");
|
|
82
|
+
logTaskEvent("stale-30", "delivered", "acked", "target");
|
|
83
|
+
logTaskEvent("stale-30", "acked", "running", "target");
|
|
84
|
+
logTaskEvent("stale-30", "running", "replied", "target");
|
|
85
|
+
logTaskEvent("stale-30", "running", "failed", "target");
|
|
86
|
+
logTaskEvent("stale-30", "delivered", "expired", "target");
|
|
87
|
+
const rows = db.all<any>(
|
|
88
|
+
"SELECT event_type, event_key FROM task_events WHERE task_id = 'stale-30' AND actor = 'target' ORDER BY id",
|
|
89
|
+
);
|
|
90
|
+
expect(rows).toEqual([
|
|
91
|
+
{ event_type: "task.send.delivered", event_key: null },
|
|
92
|
+
{ event_type: "task.ack", event_key: null },
|
|
93
|
+
{ event_type: "task.started", event_key: null },
|
|
94
|
+
{ event_type: "task.replied", event_key: null },
|
|
95
|
+
{ event_type: "task.failed", event_key: null },
|
|
96
|
+
{ event_type: "task.expired", event_key: null },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("REST exposes event_type but keeps the internal idempotency key private", async () => {
|
|
101
|
+
expect(TASK_EVENT_REST_COLUMNS).toContain("event_type");
|
|
102
|
+
expect(TASK_EVENT_REST_COLUMNS).not.toContain("event_key" as any);
|
|
103
|
+
const response = await fetch(
|
|
104
|
+
`${base}/api/task_events?network_id=${encodeURIComponent(networkId)}&task_id=stale-60`,
|
|
105
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
106
|
+
);
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
const body = await response.json() as any;
|
|
109
|
+
expect(body.events).toHaveLength(2);
|
|
110
|
+
expect(body.events.every((event: any) => typeof event.event_type === "string")).toBe(true);
|
|
111
|
+
expect(body.events.every((event: any) => event.event_key === undefined)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("startHub owns a live watcher timer instead of relying on import side effects", async () => {
|
|
115
|
+
const childDbPath = `/tmp/test683-live-${process.pid}-${Date.now()}.db`;
|
|
116
|
+
const init = Bun.spawnSync(["bun", "-e", "await import('./server/src/db.js')"], {
|
|
117
|
+
cwd: process.cwd(),
|
|
118
|
+
env: { ...process.env, COMMHUB_DB: childDbPath },
|
|
119
|
+
stdout: "pipe",
|
|
120
|
+
stderr: "pipe",
|
|
121
|
+
});
|
|
122
|
+
expect(init.exitCode).toBe(0);
|
|
123
|
+
|
|
124
|
+
const child = Bun.spawn(["bun", "run", "server/src/index.ts"], {
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
env: {
|
|
127
|
+
...process.env,
|
|
128
|
+
COMMHUB_DB: childDbPath,
|
|
129
|
+
COMMHUB_DELIVERED_STALE_PATROL_MS: "25",
|
|
130
|
+
PORT: "0",
|
|
131
|
+
},
|
|
132
|
+
stdout: "pipe",
|
|
133
|
+
stderr: "pipe",
|
|
134
|
+
});
|
|
135
|
+
await Bun.sleep(800);
|
|
136
|
+
expect(child.exitCode).toBeNull();
|
|
137
|
+
|
|
138
|
+
const taskId = "stale-live-wiring";
|
|
139
|
+
const childDb = new Database(childDbPath);
|
|
140
|
+
try {
|
|
141
|
+
childDb.run(
|
|
142
|
+
`INSERT INTO tasks
|
|
143
|
+
(task_id, from_name, to_name, status, content, delivered_at, network_id)
|
|
144
|
+
VALUES (?1, 'sender', 'target', 'delivered', 'live watcher fixture', ?2, ?3)`,
|
|
145
|
+
[taskId, sqliteUtcTimestamp(new Date(Date.now() - 28_000)), networkId],
|
|
146
|
+
);
|
|
147
|
+
expect(childDb.query<{ count: number }, [string]>(
|
|
148
|
+
"SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1",
|
|
149
|
+
).get(taskId)!.count).toBe(0);
|
|
150
|
+
await Bun.sleep(3_200);
|
|
151
|
+
expect(childDb.query<{ count: number }, [string]>(
|
|
152
|
+
"SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1 AND event_type = 'task.warning.delivered_stale_30s'",
|
|
153
|
+
).get(taskId)!.count).toBe(1);
|
|
154
|
+
} finally {
|
|
155
|
+
childDb.close();
|
|
156
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
157
|
+
await child.exited;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -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
|
+
});
|