@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db.ts +5 -1
- package/src/requirements-http.test.ts +200 -12
- package/src/requirements-mcp.test.ts +132 -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 +398 -126
- 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 +90 -0
- package/src/scheduled-tasks.ts +45 -10
- package/src/tools.ts +105 -1
|
@@ -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
|
+
});
|
|
@@ -431,6 +431,96 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
431
431
|
expect(["Concurrent editor A", "Concurrent editor B"]).toContain(latest.name);
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
+
// Owner report 2026-09-29: editing a 2-minute schedule failed with
|
|
435
|
+
// revision_conflict whenever a run landed while the dialog was open, and the
|
|
436
|
+
// client dropped the draft. Runs are bookkeeping, not edits.
|
|
437
|
+
test("a scheduler run between GET and PATCH does not conflict with the edit", async () => {
|
|
438
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
439
|
+
method: "POST",
|
|
440
|
+
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 } }),
|
|
441
|
+
});
|
|
442
|
+
expect(created.status).toBe(201);
|
|
443
|
+
const id = created.body.schedule.schedule_id;
|
|
444
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
445
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
446
|
+
|
|
447
|
+
const firedAt = new Date(Date.now() - 1_000).toISOString();
|
|
448
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
|
|
449
|
+
runDueScheduledTasks();
|
|
450
|
+
const afterRun = (await api(ownerToken, path)).body.schedule;
|
|
451
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1", id)!.n).toBe(1);
|
|
452
|
+
expect(afterRun.last_run_at).toBe(firedAt);
|
|
453
|
+
expect(new Date(afterRun.next_run_at).getTime()).toBeGreaterThan(Date.now());
|
|
454
|
+
expect(afterRun.revision).toBe(opened.revision);
|
|
455
|
+
|
|
456
|
+
const saved = await api(ownerToken, path, {
|
|
457
|
+
method: "PATCH",
|
|
458
|
+
body: JSON.stringify({ revision: opened.revision, task: "edited while it ran", name: opened.name, schedule: opened.schedule, timezone: opened.timezone }),
|
|
459
|
+
});
|
|
460
|
+
expect(saved.status).toBe(200);
|
|
461
|
+
expect(saved.body.schedule.task_content).toBe("edited while it ran");
|
|
462
|
+
expect(saved.body.schedule.revision).toBe(opened.revision + 1);
|
|
463
|
+
// Cadence edit-preservation must keep the scheduler's advanced slot, not
|
|
464
|
+
// resurrect the occurrence that already fired.
|
|
465
|
+
expect(saved.body.schedule.next_run_at).toBe(afterRun.next_run_at);
|
|
466
|
+
|
|
467
|
+
// A second device still holding the pre-edit revision is a true conflict.
|
|
468
|
+
const stale = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "other device" }) });
|
|
469
|
+
expect(stale.status).toBe(409);
|
|
470
|
+
expect(stale.body.error).toBe("revision_conflict");
|
|
471
|
+
await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("a run that lands while the PATCH body is in flight keeps the advanced next_run_at", async () => {
|
|
475
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
476
|
+
method: "POST",
|
|
477
|
+
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 } }),
|
|
478
|
+
});
|
|
479
|
+
const id = created.body.schedule.schedule_id;
|
|
480
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
481
|
+
const firedAt = new Date(Date.now() - 1_000).toISOString();
|
|
482
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [firedAt, id]);
|
|
483
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
484
|
+
|
|
485
|
+
// The handler reads the row before awaiting the body. Hold the body open,
|
|
486
|
+
// run the scheduler, then release it — the PATCH now carries a stale
|
|
487
|
+
// row.next_run_at (the slot that just fired).
|
|
488
|
+
let release!: () => void;
|
|
489
|
+
const gate = new Promise<void>((r) => { release = r; });
|
|
490
|
+
const payload = new TextEncoder().encode(JSON.stringify({ revision: opened.revision, task: "edited mid-run" }));
|
|
491
|
+
const body = new ReadableStream({ async pull(c) { await gate; c.enqueue(payload); c.close(); } });
|
|
492
|
+
const pending = fetch(`${base}${path}`, { method: "PATCH", body, duplex: "half", headers: { Authorization: `Bearer ${ownerToken}`, "Content-Type": "application/json" } } as any);
|
|
493
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
494
|
+
runDueScheduledTasks();
|
|
495
|
+
const advanced = db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", id)!.next_run_at;
|
|
496
|
+
expect(advanced).not.toBe(firedAt);
|
|
497
|
+
release();
|
|
498
|
+
const res = await pending;
|
|
499
|
+
const saved = await res.json() as any;
|
|
500
|
+
expect(res.status).toBe(200);
|
|
501
|
+
expect(saved.schedule.task_content).toBe("edited mid-run");
|
|
502
|
+
expect(saved.schedule.next_run_at).toBe(advanced);
|
|
503
|
+
await api(ownerToken, `/api/scheduled-tasks/${id}/cancel?network_id=${encodeURIComponent(networkId)}`, { method: "POST", body: "{}" });
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("one-shot auto-complete is a user-visible status change and still bumps revision", async () => {
|
|
507
|
+
const created = await api(ownerToken, "/api/scheduled-tasks", {
|
|
508
|
+
method: "POST",
|
|
509
|
+
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() } }),
|
|
510
|
+
});
|
|
511
|
+
const id = created.body.schedule.schedule_id;
|
|
512
|
+
const path = `/api/scheduled-tasks/${id}?network_id=${encodeURIComponent(networkId)}`;
|
|
513
|
+
const opened = (await api(ownerToken, path)).body.schedule;
|
|
514
|
+
db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 1_000).toISOString(), id]);
|
|
515
|
+
runDueScheduledTasks();
|
|
516
|
+
const done = (await api(ownerToken, path)).body.schedule;
|
|
517
|
+
expect(done.status).toBe("completed");
|
|
518
|
+
expect(done.revision).toBe(opened.revision + 1);
|
|
519
|
+
const edit = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ revision: opened.revision, task: "too late" }) });
|
|
520
|
+
expect(edit.status).toBe(409);
|
|
521
|
+
expect(edit.body.error).toBe("schedule_completed");
|
|
522
|
+
});
|
|
523
|
+
|
|
434
524
|
test("optimistic revision, pause/resume, run-now and cancel preserve history", async () => {
|
|
435
525
|
const latest = await api(ownerToken, `/api/scheduled-tasks/${scheduleId}?network_id=${encodeURIComponent(networkId)}`);
|
|
436
526
|
revision = latest.body.schedule.revision;
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
4
|
+
import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
5
5
|
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
6
6
|
|
|
7
7
|
export type ScheduleSpec =
|
|
@@ -219,8 +219,15 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
219
219
|
|
|
220
220
|
if (row.overlap_policy === "skip") {
|
|
221
221
|
const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
// received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
|
|
223
|
+
// 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
|
|
224
|
+
// 后者才是真的上一次还没做完。
|
|
225
|
+
const open = db.get<{ task_id: string; received: number }>(
|
|
226
|
+
`SELECT r.task_id,
|
|
227
|
+
CASE WHEN t.status IN ('acked', 'running')
|
|
228
|
+
OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
|
|
229
|
+
THEN 1 ELSE 0 END AS received
|
|
230
|
+
FROM scheduled_task_runs r
|
|
224
231
|
JOIN tasks t ON t.task_id = r.task_id
|
|
225
232
|
WHERE r.schedule_id = ?1 AND t.status IN (${placeholders})
|
|
226
233
|
ORDER BY r.created_at DESC LIMIT 1`,
|
|
@@ -228,9 +235,18 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
228
235
|
);
|
|
229
236
|
if (open) {
|
|
230
237
|
finalStatus = "skipped";
|
|
238
|
+
const blockedState = open.received ? "in_progress" : "not_received";
|
|
231
239
|
db.run(
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
`UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', error_message = ?2,
|
|
241
|
+
blocked_by_task_id = ?3, blocked_by_state = ?4, completed_at = datetime('now') WHERE run_id = ?1`,
|
|
242
|
+
[
|
|
243
|
+
runId,
|
|
244
|
+
blockedState === "not_received"
|
|
245
|
+
? `previous run task ${open.task_id} has not been received by the node yet`
|
|
246
|
+
: `previous run task ${open.task_id} is still in progress on the node`,
|
|
247
|
+
open.task_id,
|
|
248
|
+
blockedState,
|
|
249
|
+
],
|
|
234
250
|
);
|
|
235
251
|
if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
|
|
236
252
|
return;
|
|
@@ -262,11 +278,19 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
262
278
|
}
|
|
263
279
|
|
|
264
280
|
const taskId = crypto.randomUUID();
|
|
281
|
+
// claude-code 裸节点的 node-server 上报 report_status 不带 node_id,它的 sessions 行
|
|
282
|
+
// node_id 永远是 NULL —— 只按 node_id 找会话,这类节点每一次都判成 queued、门铃不响,
|
|
283
|
+
// 任务在 inbox 里等到别的消息顺带捎进去(生产上 20–36 分钟)。按 node_id 找不到时退回
|
|
284
|
+
// 同网同别名、node_id 为空的会话;SSE 订阅者注册表是「有没有人在听」的实测,也算数。
|
|
265
285
|
const session = db.get<{ status: string | null }>(
|
|
266
286
|
"SELECT status FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
|
|
267
287
|
row.target_node_id, row.network_id,
|
|
288
|
+
) ?? db.get<{ status: string | null }>(
|
|
289
|
+
"SELECT status FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NULL ORDER BY updated_at DESC LIMIT 1",
|
|
290
|
+
node.alias, row.network_id,
|
|
268
291
|
);
|
|
269
|
-
const deliveryState: "delivered" | "queued" =
|
|
292
|
+
const deliveryState: "delivered" | "queued" =
|
|
293
|
+
(session && session.status !== "offline") || hasSubscribers(node.alias, row.network_id) ? "delivered" : "queued";
|
|
270
294
|
const metaJson = JSON.stringify({
|
|
271
295
|
scheduled_task_id: row.schedule_id,
|
|
272
296
|
scheduled_run_id: runId,
|
|
@@ -310,6 +334,12 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
310
334
|
return { runId, taskId: createdTaskId, status: finalStatus, event };
|
|
311
335
|
}
|
|
312
336
|
|
|
337
|
+
// `revision` is the optimistic-concurrency token for *user edits*. Run
|
|
338
|
+
// bookkeeping (next_run_at/last_run_at) must not bump it: a 2-minute schedule
|
|
339
|
+
// would otherwise turn every edit that spans a run into a 409 and the client
|
|
340
|
+
// would drop the user's draft. Terminal completion still bumps, because it is
|
|
341
|
+
// a status change the user sees and it is what stops an in-flight PATCH (whose
|
|
342
|
+
// UPDATE is guarded by `AND revision = ?`) from reviving a finished one-shot.
|
|
313
343
|
function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
|
|
314
344
|
const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
|
|
315
345
|
const next = nextOccurrence(spec, row.timezone, after);
|
|
@@ -321,7 +351,7 @@ function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), re
|
|
|
321
351
|
);
|
|
322
352
|
} else {
|
|
323
353
|
db.run(
|
|
324
|
-
"UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2,
|
|
354
|
+
"UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3",
|
|
325
355
|
[iso(next), lastRunAt, row.schedule_id],
|
|
326
356
|
);
|
|
327
357
|
}
|
|
@@ -461,7 +491,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
461
491
|
if (sub === "runs" && req.method === "GET") {
|
|
462
492
|
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
|
|
463
493
|
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",
|
|
494
|
+
"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
495
|
row.schedule_id, row.network_id, limit,
|
|
466
496
|
);
|
|
467
497
|
return Response.json({ ok: true, runs });
|
|
@@ -532,11 +562,16 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
532
562
|
? nextOccurrence(parsed.spec, parsed.timezone, new Date())
|
|
533
563
|
: new Date(row.next_run_at);
|
|
534
564
|
if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
|
|
565
|
+
// Runs no longer bump revision, so the scheduler may have advanced
|
|
566
|
+
// next_run_at after `row` was read. When cadence is preserved, keep the
|
|
567
|
+
// stored value instead of writing back the already-fired occurrence.
|
|
568
|
+
const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
|
|
535
569
|
const updated = db.run(
|
|
536
570
|
`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,
|
|
571
|
+
priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
|
|
572
|
+
next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
|
|
538
573
|
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],
|
|
574
|
+
[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
575
|
);
|
|
541
576
|
if (updated.changes !== 1) return jsonError("revision_conflict", 409);
|
|
542
577
|
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)!) });
|
package/src/tools.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
|
|
|
8
8
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
9
9
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
10
10
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
-
import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
|
|
11
|
+
import { canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId } from "./network-scope.js";
|
|
12
|
+
import { handleRequirementsRequest } from "./requirements.js";
|
|
12
13
|
import {
|
|
13
14
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
14
15
|
validateName as validateChildName,
|
|
@@ -5674,6 +5675,109 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5674
5675
|
}
|
|
5675
5676
|
},
|
|
5676
5677
|
);
|
|
5678
|
+
|
|
5679
|
+
// ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
|
|
5680
|
+
// 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
|
|
5681
|
+
// 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
|
|
5682
|
+
const requirementsCall = async (method: string, path: string, clientNetId?: string | null, body?: Record<string, unknown>) => {
|
|
5683
|
+
const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
|
|
5684
|
+
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
|
|
5685
|
+
const url = new URL(`http://mcp.internal${path}`);
|
|
5686
|
+
const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
|
|
5687
|
+
const res = await handleRequirementsRequest({
|
|
5688
|
+
req, url,
|
|
5689
|
+
auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
|
|
5690
|
+
isAdmin: false,
|
|
5691
|
+
isNodeToken: callerTokenIsNetwork,
|
|
5692
|
+
scope,
|
|
5693
|
+
});
|
|
5694
|
+
const data = res ? await res.json() : { ok: false, error: "not_found" };
|
|
5695
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
|
|
5696
|
+
};
|
|
5697
|
+
const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
|
|
5698
|
+
const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
|
|
5699
|
+
const reqFields = {
|
|
5700
|
+
name: z.string().min(1).max(80).optional(),
|
|
5701
|
+
priority: z.enum(["high", "normal", "low"]).optional(),
|
|
5702
|
+
column: z.enum(["pool", "doing", "done"]).optional(),
|
|
5703
|
+
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
|
|
5704
|
+
description: z.string().max(20_000).optional().describe("markdown"),
|
|
5705
|
+
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
5706
|
+
project_id: z.string().max(200).nullable().optional(),
|
|
5707
|
+
owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
|
|
5708
|
+
agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
|
|
5709
|
+
participants: z.array(reqPerson).max(100).optional(),
|
|
5710
|
+
external_url: z.string().max(500).nullable().optional(),
|
|
5711
|
+
parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
|
|
5712
|
+
};
|
|
5713
|
+
const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
|
|
5714
|
+
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
5715
|
+
|
|
5716
|
+
server.tool(
|
|
5717
|
+
"requirements_list",
|
|
5718
|
+
"List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
|
|
5719
|
+
{
|
|
5720
|
+
network_id: z.string().max(200).optional(),
|
|
5721
|
+
status: z.enum(["pool", "doing", "done"]).optional(),
|
|
5722
|
+
project_id: z.string().max(200).optional(),
|
|
5723
|
+
owner: z.string().max(210).optional(),
|
|
5724
|
+
agent_owner: z.string().max(210).optional(),
|
|
5725
|
+
updated_since: z.string().max(40).optional(),
|
|
5726
|
+
external_ref: z.string().max(200).optional(),
|
|
5727
|
+
include_archived: z.boolean().optional(),
|
|
5728
|
+
parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
|
|
5729
|
+
top_level: z.boolean().optional(),
|
|
5730
|
+
},
|
|
5731
|
+
async (args) => {
|
|
5732
|
+
const q = new URLSearchParams();
|
|
5733
|
+
for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
|
|
5734
|
+
if (args.include_archived) q.set("include_archived", "1");
|
|
5735
|
+
if (args.top_level) q.set("top_level", "1");
|
|
5736
|
+
return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
|
|
5737
|
+
},
|
|
5738
|
+
);
|
|
5739
|
+
|
|
5740
|
+
server.tool(
|
|
5741
|
+
"requirements_get",
|
|
5742
|
+
"Get one requirement task by id (with description, checklist, owners, project, external_ref).",
|
|
5743
|
+
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
5744
|
+
async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
|
|
5745
|
+
);
|
|
5746
|
+
|
|
5747
|
+
server.tool(
|
|
5748
|
+
"requirements_create",
|
|
5749
|
+
"Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
5750
|
+
{ network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
5751
|
+
async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5752
|
+
);
|
|
5753
|
+
|
|
5754
|
+
server.tool(
|
|
5755
|
+
"requirements_update",
|
|
5756
|
+
"Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
|
|
5757
|
+
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5758
|
+
async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5759
|
+
);
|
|
5760
|
+
|
|
5761
|
+
server.tool(
|
|
5762
|
+
"requirements_checklist_toggle",
|
|
5763
|
+
"Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
|
|
5764
|
+
{ id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
|
|
5765
|
+
async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
|
|
5766
|
+
);
|
|
5767
|
+
|
|
5768
|
+
server.tool(
|
|
5769
|
+
"requirements_upsert_by_external_ref",
|
|
5770
|
+
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including status — are kept). Returns { requirement, created }.",
|
|
5771
|
+
{ network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
|
|
5772
|
+
async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5773
|
+
);
|
|
5774
|
+
|
|
5775
|
+
server.tool(
|
|
5776
|
+
"projects_list",
|
|
5777
|
+
"List requirement projects in your network (id, name, color, sort, archived).",
|
|
5778
|
+
{ network_id: z.string().max(200).optional() },
|
|
5779
|
+
async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
|
|
5780
|
+
);
|
|
5677
5781
|
}
|
|
5678
5782
|
|
|
5679
5783
|
// ────────────────────────────────────────────────────────────────────
|