@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +35 -1
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +278 -12
- package/src/requirements-mcp.test.ts +135 -0
- package/src/requirements-migrate.ts +7 -0
- package/src/requirements-upgrade-e2e.test.ts +1 -1
- package/src/requirements-upgrade-migration.test.ts +9 -0
- package/src/requirements.ts +479 -137
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +92 -1
- package/src/scheduled-tasks.ts +57 -10
- package/src/server.ts +345 -41
- package/src/tools.ts +202 -31
|
@@ -31,7 +31,7 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
31
31
|
// —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
|
|
32
32
|
authMeNetwork: [
|
|
33
33
|
"network_id", "network_name", "owner_id", "description", "settings",
|
|
34
|
-
"created_at", "updated_at", "visibility", "max_members", "member_role",
|
|
34
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access",
|
|
35
35
|
],
|
|
36
36
|
networkDetail: [
|
|
37
37
|
"network_id", "network_name", "owner_id", "description", "settings",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 多用户 Agent 权限 —— 受限成员与上传文件。
|
|
2
|
+
// 文件按网络归属;受限成员(只看授权 Agent 的成员)在受限网络里不能凭 file_id 读网络里任意文件,
|
|
3
|
+
// 也不能把自己看不见的 file_id 塞进发给授权 Agent 的任务(那等于借 Agent 的网络令牌去读)。
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
import { db } from "./db.js";
|
|
6
|
+
import { addOwnTrafficScope, type RestNetworkScope } from "./network-scope.js";
|
|
7
|
+
import { FILE_ID_REGEX, indexEntryPath, validateIndexEntry } from "./uploads.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 受限成员能不能看见这个文件:只认**对方(授权 Agent / 给他发私信的人)放进来**的附件 ——
|
|
11
|
+
* user_inbox 里发给自己的私信附件;
|
|
12
|
+
* 授权 Agent 发给自己的任务 / inbox 行的 attachments;
|
|
13
|
+
* 自己发给授权 Agent 的任务上,Agent 回复写进的 reply_attachments。
|
|
14
|
+
* 自己随任务带出去的 attachments **不算**:否则把任意 file_id 塞进自己的任务就能「解锁」别人的文件。
|
|
15
|
+
*/
|
|
16
|
+
export function restrictedMemberSeesFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
17
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
18
|
+
const listed = (metaJson: string | null | undefined, key: "attachments" | "reply_attachments"): boolean => {
|
|
19
|
+
if (!metaJson) return false;
|
|
20
|
+
try {
|
|
21
|
+
const list = JSON.parse(metaJson)?.[key];
|
|
22
|
+
return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
};
|
|
25
|
+
// LIKE 只做预筛(file_id 只含 [A-Za-z0-9_-],`_` 转义后逐字匹配),真正的判定在 listed() 里按键解析。
|
|
26
|
+
const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
|
|
27
|
+
for (const row of db.all<{ meta_json: string | null }>(
|
|
28
|
+
"SELECT meta_json FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50",
|
|
29
|
+
userId, networkId, needle,
|
|
30
|
+
)) {
|
|
31
|
+
if (listed(row.meta_json, "attachments")) return true;
|
|
32
|
+
}
|
|
33
|
+
const scope: RestNetworkScope = { networkId, networkIds: null, agentRestriction: { userId, username, networkIds: [networkId] } };
|
|
34
|
+
const taskParams: unknown[] = [needle];
|
|
35
|
+
const taskSql = addOwnTrafficScope("SELECT from_name, to_name, meta_json FROM tasks WHERE meta_json LIKE ?1 ESCAPE '\\'", taskParams, scope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
36
|
+
for (const row of db.all<{ from_name: string; to_name: string; meta_json: string | null }>(taskSql + " LIMIT 50", ...taskParams)) {
|
|
37
|
+
if (row.to_name === username && listed(row.meta_json, "attachments")) return true;
|
|
38
|
+
if (row.from_name === username && listed(row.meta_json, "reply_attachments")) return true;
|
|
39
|
+
}
|
|
40
|
+
const inboxParams: unknown[] = [needle];
|
|
41
|
+
const inboxSql = addOwnTrafficScope("SELECT session_name, meta_json FROM inbox WHERE meta_json LIKE ?1 ESCAPE '\\'", inboxParams, scope, { from: "from_session", to: "session_name" });
|
|
42
|
+
for (const row of db.all<{ session_name: string; meta_json: string | null }>(inboxSql + " LIMIT 50", ...inboxParams)) {
|
|
43
|
+
if (row.session_name === username && listed(row.meta_json, "attachments")) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 受限成员能不能用这个文件(下载 / 当附件转给 Agent):自己传的,或 restrictedMemberSeesFile。 */
|
|
49
|
+
export function restrictedMemberCanUseFile(userId: string, username: string, networkId: string, fileId: string): boolean {
|
|
50
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
51
|
+
const path = indexEntryPath(fileId);
|
|
52
|
+
if (!path || !existsSync(path)) return false;
|
|
53
|
+
let entry: any;
|
|
54
|
+
try { entry = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
|
|
55
|
+
if (!validateIndexEntry(entry) || entry.file_id !== fileId) return false;
|
|
56
|
+
if (entry.network_id !== networkId) return false;
|
|
57
|
+
if (entry.owner_id === userId) return true;
|
|
58
|
+
return restrictedMemberSeesFile(userId, username, networkId, fileId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 受限成员发给 Agent 的任务 / 消息里的附件:每个 file_id 都必须是他自己能用的文件。
|
|
63
|
+
* 返回第一个不合格的 file_id(调用方回 403),全部合格返回 null。
|
|
64
|
+
*/
|
|
65
|
+
export function restrictedMemberAttachmentsDenied(userId: string, username: string, networkId: string, raw: unknown): string | null {
|
|
66
|
+
if (!Array.isArray(raw)) return null;
|
|
67
|
+
for (const item of raw) {
|
|
68
|
+
const fileId = item && typeof item === "object" ? (item as { file_id?: unknown }).file_id : undefined;
|
|
69
|
+
if (fileId === undefined) continue;
|
|
70
|
+
if (typeof fileId !== "string" || !restrictedMemberCanUseFile(userId, username, networkId, fileId)) return String(fileId);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { createSSEStream } from "./push.js";
|
|
4
|
+
import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
|
|
5
|
+
|
|
6
|
+
// A claude-code node's channel server reports status without node_id, so its
|
|
7
|
+
// sessions row has node_id NULL. The scheduler used to look the session up by
|
|
8
|
+
// node_id only, judged every occurrence "queued" and never rang the doorbell:
|
|
9
|
+
// the task sat in the inbox until an unrelated push drained it (20–36 min in
|
|
10
|
+
// production).
|
|
11
|
+
|
|
12
|
+
const NET = "net_sched_doorbell_nodeless";
|
|
13
|
+
const NODE = "n_sched_doorbell_cc";
|
|
14
|
+
const ALIAS = "sched-doorbell-cc";
|
|
15
|
+
|
|
16
|
+
function cleanup(): void {
|
|
17
|
+
for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
|
|
18
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function seedNode(): void {
|
|
23
|
+
db.run(
|
|
24
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
|
|
25
|
+
VALUES (?1, ?2, ?2, 'claude-code-cli', ?3, 'active')`,
|
|
26
|
+
[NODE, ALIAS, NET],
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function seedSession(status: string, nodeId: string | null): void {
|
|
31
|
+
db.run(
|
|
32
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, agent, updated_at, last_seen_at)
|
|
33
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'claude-code', datetime('now'), datetime('now'))`,
|
|
34
|
+
[`cc-${crypto.randomUUID()}`, ALIAS, status, nodeId, NET],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function seedSchedule(): any {
|
|
39
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
40
|
+
db.run(
|
|
41
|
+
`INSERT INTO scheduled_tasks
|
|
42
|
+
(schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
|
|
43
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
44
|
+
VALUES (?1, ?2, 'doorbell', ?3, ?4, 'scheduled doorbell task', 'normal', 'interval',
|
|
45
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
|
|
46
|
+
[scheduleId, NET, NODE, ALIAS],
|
|
47
|
+
);
|
|
48
|
+
return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subscribe like the channel server does and collect decoded SSE frames. */
|
|
52
|
+
function subscribe(): { frames: any[]; close: () => Promise<void> } {
|
|
53
|
+
const res = createSSEStream(ALIAS, NET);
|
|
54
|
+
const reader = res.body!.getReader();
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
const frames: any[] = [];
|
|
57
|
+
let buffer = "";
|
|
58
|
+
let open = true;
|
|
59
|
+
(async () => {
|
|
60
|
+
while (open) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
buffer += decoder.decode(value, { stream: true });
|
|
64
|
+
const blocks = buffer.split("\n\n");
|
|
65
|
+
buffer = blocks.pop() || "";
|
|
66
|
+
for (const block of blocks) {
|
|
67
|
+
const line = block.split("\n").find((l) => l.startsWith("data: "));
|
|
68
|
+
if (line) frames.push(JSON.parse(line.slice(6)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})().catch(() => {});
|
|
72
|
+
return {
|
|
73
|
+
frames,
|
|
74
|
+
close: async () => {
|
|
75
|
+
open = false;
|
|
76
|
+
await reader.cancel().catch(() => {});
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function settle(): Promise<void> {
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
cleanup();
|
|
87
|
+
seedNode();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(cleanup);
|
|
91
|
+
|
|
92
|
+
describe("scheduler doorbell for sessions reported without node_id", () => {
|
|
93
|
+
test("idle node_id-less session is delivered and its subscriber receives new_task", async () => {
|
|
94
|
+
seedSession("idle", null);
|
|
95
|
+
const sub = subscribe();
|
|
96
|
+
try {
|
|
97
|
+
await settle();
|
|
98
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
99
|
+
await settle();
|
|
100
|
+
expect(result.status).toBe("delivered");
|
|
101
|
+
expect(db.get<{ status: string }>("SELECT status FROM scheduled_task_runs WHERE run_id = ?1", result.runId)?.status).toBe("delivered");
|
|
102
|
+
const wake = sub.frames.filter((f) => f.type === "new_task");
|
|
103
|
+
expect(wake).toHaveLength(1);
|
|
104
|
+
expect(wake[0].from).toBe("scheduler");
|
|
105
|
+
expect(wake[0].inbox_count).toBe(1);
|
|
106
|
+
} finally {
|
|
107
|
+
await sub.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a live subscriber counts even when the session row says offline", async () => {
|
|
112
|
+
seedSession("offline", null);
|
|
113
|
+
const sub = subscribe();
|
|
114
|
+
try {
|
|
115
|
+
await settle();
|
|
116
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
117
|
+
await settle();
|
|
118
|
+
expect(result.status).toBe("delivered");
|
|
119
|
+
expect(sub.frames.filter((f) => f.type === "new_task")).toHaveLength(1);
|
|
120
|
+
} finally {
|
|
121
|
+
await sub.close();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("offline session with nobody listening stays queued", () => {
|
|
126
|
+
seedSession("offline", null);
|
|
127
|
+
const result = dispatchScheduledOccurrence(seedSchedule(), new Date().toISOString(), false);
|
|
128
|
+
expect(result.status).toBe("queued");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { dispatchScheduledOccurrence, handleScheduledTaskRequest } from "./scheduled-tasks.js";
|
|
4
|
+
|
|
5
|
+
// A skip used to record only error_code=previous_run_active. The owner saw
|
|
6
|
+
// dozens of identical 「已跳过」 rows while the node sat idle, because the
|
|
7
|
+
// open task had never reached it. The skip record now names the blocking task
|
|
8
|
+
// and whether the node had picked it up.
|
|
9
|
+
|
|
10
|
+
const NET = "net_sched_skip_blocking";
|
|
11
|
+
const NODE = "n_sched_skip_blocking";
|
|
12
|
+
const ALIAS = "sched-skip-blocking";
|
|
13
|
+
|
|
14
|
+
function cleanup(): void {
|
|
15
|
+
for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes"]) {
|
|
16
|
+
try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function seedSchedule(): any {
|
|
21
|
+
const scheduleId = `sched_${crypto.randomUUID()}`;
|
|
22
|
+
db.run(
|
|
23
|
+
`INSERT INTO scheduled_tasks
|
|
24
|
+
(schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
|
|
25
|
+
schedule_type, schedule_json, timezone, status, next_run_at)
|
|
26
|
+
VALUES (?1, ?2, 'skip detail', ?3, ?4, 'scheduled task', 'normal', 'interval',
|
|
27
|
+
'{"type":"interval","every_seconds":60}', 'UTC', 'active', datetime('now', '+1 minute'))`,
|
|
28
|
+
[scheduleId, NET, NODE, ALIAS],
|
|
29
|
+
);
|
|
30
|
+
return db.get("SELECT * FROM scheduled_tasks WHERE schedule_id = ?1", scheduleId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(runId: string): any {
|
|
34
|
+
return db.get(
|
|
35
|
+
"SELECT status, error_code, error_message, blocked_by_task_id, blocked_by_state FROM scheduled_task_runs WHERE run_id = ?1",
|
|
36
|
+
runId,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
|
|
44
|
+
VALUES (?1, ?2, ?2, 'codex-sdk', ?3, 'active')`,
|
|
45
|
+
[NODE, ALIAS, NET],
|
|
46
|
+
);
|
|
47
|
+
db.run(
|
|
48
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
49
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
50
|
+
[`r_${crypto.randomUUID()}`, ALIAS, NODE, NET],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(cleanup);
|
|
55
|
+
|
|
56
|
+
describe("skipped run names the task that blocked it", () => {
|
|
57
|
+
test("open task still unacked in the inbox → not_received", () => {
|
|
58
|
+
const schedule = seedSchedule();
|
|
59
|
+
const first = dispatchScheduledOccurrence(schedule, "2026-09-29T08:00:00.000Z", false);
|
|
60
|
+
expect(first.status).toBe("delivered");
|
|
61
|
+
|
|
62
|
+
const second = dispatchScheduledOccurrence(schedule, "2026-09-29T08:01:00.000Z", false);
|
|
63
|
+
expect(second.status).toBe("skipped");
|
|
64
|
+
const row = run(second.runId);
|
|
65
|
+
expect(row.error_code).toBe("previous_run_active");
|
|
66
|
+
expect(row.blocked_by_task_id).toBe(first.taskId);
|
|
67
|
+
expect(row.blocked_by_state).toBe("not_received");
|
|
68
|
+
expect(row.error_message).toContain(first.taskId!);
|
|
69
|
+
expect(row.error_message).toContain("not been received");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("open task whose inbox row the node acked → in_progress", () => {
|
|
73
|
+
const schedule = seedSchedule();
|
|
74
|
+
const first = dispatchScheduledOccurrence(schedule, "2026-09-29T09:00:00.000Z", false);
|
|
75
|
+
db.run("UPDATE inbox SET acked = 1 WHERE task_id = ?1", [first.taskId]);
|
|
76
|
+
|
|
77
|
+
const second = dispatchScheduledOccurrence(schedule, "2026-09-29T09:01:00.000Z", false);
|
|
78
|
+
expect(second.status).toBe("skipped");
|
|
79
|
+
const row = run(second.runId);
|
|
80
|
+
expect(row.blocked_by_task_id).toBe(first.taskId);
|
|
81
|
+
expect(row.blocked_by_state).toBe("in_progress");
|
|
82
|
+
expect(row.error_message).toContain("still in progress");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("task status running counts as received even without an acked inbox row", () => {
|
|
86
|
+
const schedule = seedSchedule();
|
|
87
|
+
const first = dispatchScheduledOccurrence(schedule, "2026-09-29T10:00:00.000Z", false);
|
|
88
|
+
db.run("UPDATE tasks SET status = 'running' WHERE task_id = ?1", [first.taskId]);
|
|
89
|
+
|
|
90
|
+
const second = dispatchScheduledOccurrence(schedule, "2026-09-29T10:01:00.000Z", false);
|
|
91
|
+
expect(run(second.runId).blocked_by_state).toBe("in_progress");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("GET /runs exposes the blocking task and state", async () => {
|
|
95
|
+
const schedule = seedSchedule();
|
|
96
|
+
const first = dispatchScheduledOccurrence(schedule, "2026-09-29T12:00:00.000Z", false);
|
|
97
|
+
const second = dispatchScheduledOccurrence(schedule, "2026-09-29T12:01:00.000Z", false);
|
|
98
|
+
const url = new URL(`http://hub.test/api/scheduled-tasks/${schedule.schedule_id}/runs`);
|
|
99
|
+
const res = await handleScheduledTaskRequest({
|
|
100
|
+
req: new Request(url, { method: "GET" }),
|
|
101
|
+
url,
|
|
102
|
+
auth: { userId: "u_skip_detail", networkId: NET, username: "skip-detail" },
|
|
103
|
+
isAdmin: false,
|
|
104
|
+
isNodeToken: false,
|
|
105
|
+
scope: { networkId: NET, networkIds: [NET] },
|
|
106
|
+
});
|
|
107
|
+
const body = await res!.json() as any;
|
|
108
|
+
const skipped = body.runs.find((r: any) => r.run_id === second.runId);
|
|
109
|
+
expect(skipped.blocked_by_task_id).toBe(first.taskId);
|
|
110
|
+
expect(skipped.blocked_by_state).toBe("not_received");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a delivered run carries no blocking fields", () => {
|
|
114
|
+
const schedule = seedSchedule();
|
|
115
|
+
const first = dispatchScheduledOccurrence(schedule, "2026-09-29T11:00:00.000Z", false);
|
|
116
|
+
const row = run(first.runId);
|
|
117
|
+
expect(row.blocked_by_task_id).toBeNull();
|
|
118
|
+
expect(row.blocked_by_state).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -41,7 +41,8 @@ beforeAll(async () => {
|
|
|
41
41
|
expect(viewer.ok).toBe(true);
|
|
42
42
|
viewerToken = viewer.token!;
|
|
43
43
|
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
|
|
44
|
-
|
|
44
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
45
|
+
addNetworkMember(networkId, viewerId, "viewer", ownerId, { agentAccess: "all" });
|
|
45
46
|
const ntok = createNetworkTokenForNode(ownerId, networkId, "scheduler-node");
|
|
46
47
|
expect(ntok.ok).toBe(true);
|
|
47
48
|
nodeToken = ntok.token!;
|
|
@@ -431,6 +432,96 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
431
432
|
expect(["Concurrent editor A", "Concurrent editor B"]).toContain(latest.name);
|
|
432
433
|
});
|
|
433
434
|
|
|
435
|
+
// Owner report 2026-09-29: editing a 2-minute schedule failed with
|
|
436
|
+
// revision_conflict whenever a run landed while the dialog was open, and the
|
|
437
|
+
// client dropped the draft. Runs are bookkeeping, not edits.
|
|
438
|
+
test("a scheduler run between GET and PATCH does not conflict with the edit", async () => {
|
|
439
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
440
|
+
method: "POST",
|
|
441
|
+
body: JSON.stringify({ network_id: networkId, name: "Run-during-edit", target_node_id: nodeId, task: "before edit", timezone: "Asia/Shanghai", schedule: { type: "interval", every_seconds: 120 } }),
|
|
442
|
+
});
|
|
443
|
+
expect(created.status).toBe(201);
|
|
444
|
+
const id = created.body.schedule.schedule_id;
|
|
445
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
446
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
447
|
+
|
|
448
|
+
const firedAt = new Date(Date.now() - 1_000).toISOString();
|
|
449
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
|
|
450
|
+
runDueScheduledTasks();
|
|
451
|
+
const afterRun = (await api(ownerToken, path)).body.schedule;
|
|
452
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1", id)!.n).toBe(1);
|
|
453
|
+
expect(afterRun.last_run_at).toBe(firedAt);
|
|
454
|
+
expect(new Date(afterRun.next_run_at).getTime()).toBeGreaterThan(Date.now());
|
|
455
|
+
expect(afterRun.revision).toBe(opened.revision);
|
|
456
|
+
|
|
457
|
+
const saved = await api(ownerToken, path, {
|
|
458
|
+
method: "PATCH",
|
|
459
|
+
body: JSON.stringify({ revision: opened.revision, task: "edited while it ran", name: opened.name, schedule: opened.schedule, timezone: opened.timezone }),
|
|
460
|
+
});
|
|
461
|
+
expect(saved.status).toBe(200);
|
|
462
|
+
expect(saved.body.schedule.task_content).toBe("edited while it ran");
|
|
463
|
+
expect(saved.body.schedule.revision).toBe(opened.revision + 1);
|
|
464
|
+
// Cadence edit-preservation must keep the scheduler's advanced slot, not
|
|
465
|
+
// resurrect the occurrence that already fired.
|
|
466
|
+
expect(saved.body.schedule.next_run_at).toBe(afterRun.next_run_at);
|
|
467
|
+
|
|
468
|
+
// A second device still holding the pre-edit revision is a true conflict.
|
|
469
|
+
const stale = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "other device" }) });
|
|
470
|
+
expect(stale.status).toBe(409);
|
|
471
|
+
expect(stale.body.error).toBe("revision_conflict");
|
|
472
|
+
await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a run that lands while the PATCH body is in flight keeps the advanced next_run_at", async () => {
|
|
476
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
477
|
+
method: "POST",
|
|
478
|
+
body: JSON.stringify({ network_id: networkId, name: "Run-during-body", target_node_id: nodeId, task: "before", timezone: "Asia/Shanghai", schedule: { type: "interval", every_seconds: 120 } }),
|
|
479
|
+
});
|
|
480
|
+
const id = created.body.schedule.schedule_id;
|
|
481
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
482
|
+
const firedAt = new Date(Date.now() - 1_000).toISOString();
|
|
483
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
|
|
484
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
485
|
+
|
|
486
|
+
// The handler reads the row before awaiting the body. Hold the body open,
|
|
487
|
+
// run the scheduler, then release it — the PATCH now carries a stale
|
|
488
|
+
// row.next_run_at (the slot that just fired).
|
|
489
|
+
let release!: () => void;
|
|
490
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
491
|
+
const payload = new TextEncoder().encode(JSON.stringify({ revision: opened.revision, task: "edited mid-run" }));
|
|
492
|
+
const body = new ReadableStream({ async pull(c) { await gate; c.enqueue(payload); c.close(); } });
|
|
493
|
+
const pending = fetch(`${base}${path}`, { method: "PATCH", body, duplex: "half", headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" } } as any);
|
|
494
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
495
|
+
runDueScheduledTasks();
|
|
496
|
+
const advanced = db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", id)!.next_run_at;
|
|
497
|
+
expect(advanced).not.toBe(firedAt);
|
|
498
|
+
release();
|
|
499
|
+
const res = await pending;
|
|
500
|
+
const saved = await res.json() as any;
|
|
501
|
+
expect(res.status).toBe(200);
|
|
502
|
+
expect(saved.schedule.task_content).toBe("edited mid-run");
|
|
503
|
+
expect(saved.schedule.next_run_at).toBe(advanced);
|
|
504
|
+
await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("one-shot auto-complete is a user-visible status change and still bumps revision", async () => {
|
|
508
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
509
|
+
method: "POST",
|
|
510
|
+
body: JSON.stringify({ network_id: networkId, name: "One-shot", target_node_id: nodeId, task: "once", timezone: "Asia/Shanghai", schedule: { type: "once", run_at: new Date(Date.now() + 3_600_000).toISOString() } }),
|
|
511
|
+
});
|
|
512
|
+
const id = created.body.schedule.schedule_id;
|
|
513
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
514
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
515
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1_000).toISOString(), id]);
|
|
516
|
+
runDueScheduledTasks();
|
|
517
|
+
const done = (await api(ownerToken, path)).body.schedule;
|
|
518
|
+
expect(done.status).toBe("completed");
|
|
519
|
+
expect(done.revision).toBe(opened.revision + 1);
|
|
520
|
+
const edit = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "too late" }) });
|
|
521
|
+
expect(edit.status).toBe(409);
|
|
522
|
+
expect(edit.body.error).toBe("schedule_completed");
|
|
523
|
+
});
|
|
524
|
+
|
|
434
525
|
test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => {
|
|
435
526
|
const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
|
|
436
527
|
revision = latest.body.schedule.revision;
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { db, logTaskEvent } from "./db.js";
|
|
2
2
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
3
3
|
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
4
|
-
import {
|
|
4
|
+
import { canMessageAgent } from "./agent-access.js";
|
|
5
|
+
import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
5
6
|
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
6
7
|
|
|
7
8
|
export type ScheduleSpec =
|
|
@@ -219,8 +220,15 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
219
220
|
|
|
220
221
|
if (row.overlap_policy === "skip") {
|
|
221
222
|
const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
// received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
|
|
224
|
+
// 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
|
|
225
|
+
// 后者才是真的上一次还没做完。
|
|
226
|
+
const open = db.get<{ task_id: string; received: number }>(
|
|
227
|
+
`SELECT r.task_id,
|
|
228
|
+
CASE WHEN t.status IN ('acked', 'running')
|
|
229
|
+
OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
|
|
230
|
+
THEN 1 ELSE 0 END AS received
|
|
231
|
+
FROM scheduled_task_runs r
|
|
224
232
|
JOIN tasks t ON t.task_id = r.task_id
|
|
225
233
|
WHERE r.schedule_id = ?1 AND t.status IN (${placeholders})
|
|
226
234
|
ORDER BY r.created_at DESC LIMIT 1`,
|
|
@@ -228,9 +236,18 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
228
236
|
);
|
|
229
237
|
if (open) {
|
|
230
238
|
finalStatus = "skipped";
|
|
239
|
+
const blockedState = open.received ? "in_progress" : "not_received";
|
|
231
240
|
db.run(
|
|
232
|
-
|
|
233
|
-
|
|
241
|
+
`UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', error_message = ?2,
|
|
242
|
+
blocked_by_task_id = ?3, blocked_by_state = ?4, completed_at = datetime('now') WHERE run_id = ?1`,
|
|
243
|
+
[
|
|
244
|
+
runId,
|
|
245
|
+
blockedState === "not_received"
|
|
246
|
+
? `previous run task ${open.task_id} has not been received by the node yet`
|
|
247
|
+
: `previous run task ${open.task_id} is still in progress on the node`,
|
|
248
|
+
open.task_id,
|
|
249
|
+
blockedState,
|
|
250
|
+
],
|
|
234
251
|
);
|
|
235
252
|
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
236
253
|
return;
|
|
@@ -250,6 +267,17 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
250
267
|
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
251
268
|
return;
|
|
252
269
|
}
|
|
270
|
+
// 多用户 Agent 权限:排程是「建它的人」的委托。建的人后来被设成受限成员、而且没被授权
|
|
271
|
+
// 给目标 Agent 发任务时,不再代他派发(否则受限前建的排程会永远绕过授权)。
|
|
272
|
+
if (row.created_by && !canMessageAgent(row.created_by, row.network_id, { alias: node.alias, nodeId: node.node_id })) {
|
|
273
|
+
finalStatus = "failed";
|
|
274
|
+
db.run(
|
|
275
|
+
"UPDATE scheduled_task_runs SET status = 'failed', error_code = 'creator_access_revoked', error_message = 'The schedule creator is no longer allowed to message this agent', completed_at = datetime('now') WHERE run_id = ?1",
|
|
276
|
+
[runId],
|
|
277
|
+
);
|
|
278
|
+
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
253
281
|
const lifecycle = assertNodeActive(node.alias, row.network_id);
|
|
254
282
|
if (!lifecycle.ok) {
|
|
255
283
|
finalStatus = "failed";
|
|
@@ -262,11 +290,19 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
262
290
|
}
|
|
263
291
|
|
|
264
292
|
const taskId = crypto.randomUUID();
|
|
293
|
+
// claude-code 裸节点的 node-server 上报 report_status 不带 node_id,它的 sessions 行
|
|
294
|
+
// node_id 永远是 NULL —— 只按 node_id 找会话,这类节点每一次都判成 queued、门铃不响,
|
|
295
|
+
// 任务在 inbox 里等到别的消息顺带捎进去(生产上 20–36 分钟)。按 node_id 找不到时退回
|
|
296
|
+
// 同网同别名、node_id 为空的会话;SSE 订阅者注册表是「有没有人在听」的实测,也算数。
|
|
265
297
|
const session = db.get<{ status: string | null }>(
|
|
266
298
|
"SELECT status FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
|
|
267
299
|
row.target_node_id, row.network_id,
|
|
300
|
+
) ?? db.get<{ status: string | null }>(
|
|
301
|
+
"SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
|
|
302
|
+
node.alias, row.network_id,
|
|
268
303
|
);
|
|
269
|
-
const deliveryState: "delivered" | "queued" =
|
|
304
|
+
const deliveryState: "delivered" | "queued" =
|
|
305
|
+
(session && session.status !== "offline") || hasSubscribers(node.alias, row.network_id) ? "delivered" : "queued";
|
|
270
306
|
const metaJson = JSON.stringify({
|
|
271
307
|
scheduled_task_id: row.schedule_id,
|
|
272
308
|
scheduled_run_id: runId,
|
|
@@ -310,6 +346,12 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
310
346
|
return { runId, taskId: createdTaskId, status: finalStatus, event };
|
|
311
347
|
}
|
|
312
348
|
|
|
349
|
+
// `revision` is the optimistic-concurrency token for *user edits*. Run
|
|
350
|
+
// bookkeeping (next_run_at/last_run_at) must not bump it: a 2-minute schedule
|
|
351
|
+
// would otherwise turn every edit that spans a run into a 409 and the client
|
|
352
|
+
// would drop the user's draft. Terminal completion still bumps, because it is
|
|
353
|
+
// a status change the user sees and it is what stops an in-flight PATCH (whose
|
|
354
|
+
// UPDATE is guarded by `AND revision = ?`) from reviving a finished one-shot.
|
|
313
355
|
function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
|
|
314
356
|
const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
|
|
315
357
|
const next = nextOccurrence(spec, row.timezone, after);
|
|
@@ -321,7 +363,7 @@ function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), re
|
|
|
321
363
|
);
|
|
322
364
|
} else {
|
|
323
365
|
db.run(
|
|
324
|
-
"UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2,
|
|
366
|
+
"UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3",
|
|
325
367
|
[iso(next), lastRunAt, row.schedule_id],
|
|
326
368
|
);
|
|
327
369
|
}
|
|
@@ -461,7 +503,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
461
503
|
if (sub === "runs" && req.method === "GET") {
|
|
462
504
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
|
|
463
505
|
const runs = db.all(
|
|
464
|
-
"SELECT run_id, schedule_id, scheduled_for, task_id, status, error_code, error_message, created_at, completed_at FROM scheduled_task_runs WHERE schedule_id = ?1 AND network_id = ?2 ORDER BY created_at DESC LIMIT ?3",
|
|
506
|
+
"SELECT run_id, schedule_id, scheduled_for, task_id, status, error_code, error_message, blocked_by_task_id, blocked_by_state, created_at, completed_at FROM scheduled_task_runs WHERE schedule_id = ?1 AND network_id = ?2 ORDER BY created_at DESC LIMIT ?3",
|
|
465
507
|
row.schedule_id, row.network_id, limit,
|
|
466
508
|
);
|
|
467
509
|
return Response.json({ ok: true, runs });
|
|
@@ -532,11 +574,16 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
532
574
|
? nextOccurrence(parsed.spec, parsed.timezone, new Date())
|
|
533
575
|
: new Date(row.next_run_at);
|
|
534
576
|
if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
|
|
577
|
+
// Runs no longer bump revision, so the scheduler may have advanced
|
|
578
|
+
// next_run_at after `row` was read. When cadence is preserved, keep the
|
|
579
|
+
// stored value instead of writing back the already-fired occurrence.
|
|
580
|
+
const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
|
|
535
581
|
const updated = db.run(
|
|
536
582
|
`UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
|
|
537
|
-
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
|
|
583
|
+
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
|
|
584
|
+
next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
|
|
538
585
|
misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
|
|
539
|
-
[name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision],
|
|
586
|
+
[name, target.node_id, target.alias, content, priority, parsed.spec.type, scheduleJson, parsed.timezone, requestedStatus, next ? iso(next) : null, misfirePolicy, row.schedule_id, row.revision, keepStoredNext],
|
|
540
587
|
);
|
|
541
588
|
if (updated.changes !== 1) return jsonError("revision_conflict", 409);
|
|
542
589
|
return Response.json({ ok: true, schedule: decodeRow(db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, row.schedule_id)!) });
|