@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.
Files changed (56) hide show
  1. package/bin/commhub.ts +4 -0
  2. package/package.json +4 -4
  3. package/src/ack-create-request-transport.test.ts +159 -0
  4. package/src/ack-create-request.test.ts +5 -5
  5. package/src/admin-networks-http.test.ts +84 -0
  6. package/src/api-host-supervisors-fallback.test.ts +1 -6
  7. package/src/auth.ts +88 -14
  8. package/src/dashboard-slash-routing-http.test.ts +103 -0
  9. package/src/db-adapter-guard.test.ts +85 -0
  10. package/src/db-adapter.ts +64 -23
  11. package/src/db.ts +279 -32
  12. package/src/ext-token-shared.test.ts +60 -0
  13. package/src/ext-validation-regression.test.ts +10 -17
  14. package/src/external-schedule-edits-http.test.ts +316 -0
  15. package/src/external-schedule-edits.ts +314 -0
  16. package/src/file-download-authz-dev-open.test.ts +1 -1
  17. package/src/file-download-authz.test.ts +21 -1
  18. package/src/file-download-date-bucket.test.ts +1 -1
  19. package/src/file-network-scope-dev-open.test.ts +1 -1
  20. package/src/file-network-scope.test.ts +1 -1
  21. package/src/health-redaction.test.ts +12 -0
  22. package/src/index.ts +6 -2
  23. package/src/network-name-http.test.ts +71 -0
  24. package/src/network-scope.ts +20 -0
  25. package/src/node-attrs.test.ts +1 -1
  26. package/src/peer-reply-atomic.test.ts +455 -0
  27. package/src/peer-reply-capability.test.ts +70 -0
  28. package/src/require-explicit-test-db.ts +9 -0
  29. package/src/rest-explicit-columns-http.test.ts +149 -0
  30. package/src/rest-identity.test.ts +120 -0
  31. package/src/rest-identity.ts +93 -0
  32. package/src/rest-projections.ts +67 -0
  33. package/src/scheduled-run-terminal.test.ts +268 -0
  34. package/src/scheduled-tasks-http.test.ts +460 -0
  35. package/src/scheduled-tasks.ts +538 -0
  36. package/src/send-reply-agent-warning.test.ts +38 -38
  37. package/src/server.ts +315 -60
  38. package/src/shared/external-schedule-contract.ts +60 -0
  39. package/src/skillhub-http.test.ts +88 -0
  40. package/src/task-auth-origin-http.test.ts +87 -0
  41. package/src/task-auth-origin.test.ts +24 -0
  42. package/src/task-auth-origin.ts +7 -0
  43. package/src/task-consumption.test.ts +329 -0
  44. package/src/task-diagnostic-http.test.ts +145 -0
  45. package/src/task-diagnostic.test.ts +82 -0
  46. package/src/task-diagnostic.ts +89 -0
  47. package/src/task-idempotency-mcp.test.ts +137 -0
  48. package/src/task-idempotency.test.ts +60 -0
  49. package/src/task-idempotency.ts +75 -0
  50. package/src/task-lifecycle-watcher.test.ts +160 -0
  51. package/src/task-lifecycle-watcher.ts +49 -0
  52. package/src/task-network-resolution-http.test.ts +137 -0
  53. package/src/tasks-pagination-http.test.ts +137 -0
  54. package/src/tools.ts +635 -107
  55. package/src/uploads-http.test.ts +1 -3
  56. package/src/uploads.ts +14 -3
@@ -0,0 +1,538 @@
1
+ import { db, logTaskEvent } from "./db.js";
2
+ import { assertNodeActive } from "./lifecycle-guard.js";
3
+ import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
4
+ import { pushEvent, pushNetworkObserverEvent } from "./push.js";
5
+ import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
6
+
7
+ export type ScheduleSpec =
8
+ | { type: "once"; run_at: string }
9
+ | { type: "interval"; every_seconds: number }
10
+ | { type: "daily"; time: string }
11
+ | { type: "weekly"; time: string; weekdays: number[] };
12
+
13
+ export type MisfirePolicy = "catch_up_once" | "skip";
14
+
15
+ type ScheduledRow = {
16
+ schedule_id: string;
17
+ network_id: string;
18
+ created_by: string | null;
19
+ name: string;
20
+ target_node_id: string;
21
+ target_alias: string;
22
+ task_content: string;
23
+ priority: "high" | "normal" | "low";
24
+ schedule_type: ScheduleSpec["type"];
25
+ schedule_json: string;
26
+ timezone: string;
27
+ overlap_policy: "skip" | "allow";
28
+ misfire_policy: MisfirePolicy;
29
+ status: "active" | "paused" | "completed" | "cancelled";
30
+ next_run_at: string | null;
31
+ last_run_at: string | null;
32
+ revision: number;
33
+ created_at: string;
34
+ updated_at: string;
35
+ };
36
+
37
+ type RequestAuth = {
38
+ userId: string;
39
+ networkId: string | null;
40
+ username: string;
41
+ } | null;
42
+
43
+ export type ScheduledRequestContext = {
44
+ req: Request;
45
+ url: URL;
46
+ auth: RequestAuth;
47
+ isAdmin: boolean;
48
+ isNodeToken: boolean;
49
+ scope: RestNetworkScope;
50
+ };
51
+
52
+ const PRIORITIES = new Set(["high", "normal", "low"]);
53
+ const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
54
+ const OPEN_TASK_STATUSES = ["created", "delivered", "acked", "running"];
55
+ const MISFIRE_POLICIES = new Set<MisfirePolicy>(["catch_up_once", "skip"]);
56
+ export const MISFIRE_GRACE_MS = 60_000;
57
+
58
+ export function assertScheduledTaskBackendSupported(): void {
59
+ if (db.dialect !== "sqlite") {
60
+ throw new Error("scheduled_tasks_require_transactional_sqlite_backend");
61
+ }
62
+ }
63
+
64
+ function jsonError(error: string, status: number, extra: Record<string, unknown> = {}): Response {
65
+ return Response.json({ ok: false, error, ...extra }, { status });
66
+ }
67
+
68
+ function iso(date: Date): string {
69
+ return date.toISOString();
70
+ }
71
+
72
+ function validTimezone(timezone: string): boolean {
73
+ try {
74
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone }).format(new Date());
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function parseMisfirePolicy(raw: unknown, fallback: MisfirePolicy = "catch_up_once"): MisfirePolicy {
82
+ if (raw === undefined) return fallback;
83
+ if (typeof raw !== "string" || !MISFIRE_POLICIES.has(raw as MisfirePolicy)) throw new Error("invalid_misfire_policy");
84
+ return raw as MisfirePolicy;
85
+ }
86
+
87
+ function zonedParts(date: Date, timezone: string): { date: string; time: string; weekday: number } {
88
+ const parts = new Intl.DateTimeFormat("en-US", {
89
+ timeZone: timezone,
90
+ year: "numeric",
91
+ month: "2-digit",
92
+ day: "2-digit",
93
+ hourCycle: "h23",
94
+ hour: "2-digit",
95
+ minute: "2-digit",
96
+ weekday: "short",
97
+ }).formatToParts(date);
98
+ const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value || "";
99
+ const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(value("weekday"));
100
+ return { date: `${value("year")}-${value("month")}-${value("day")}`, time: `${value("hour")}:${value("minute")}`, weekday };
101
+ }
102
+
103
+ /** Parse and validate the public schedule shape. No cron expressions: the
104
+ * supported forms are intentionally portable between Dashboard and mobile. */
105
+ export function parseScheduleSpec(raw: unknown, timezoneRaw: unknown): { spec: ScheduleSpec; timezone: string } {
106
+ if (!raw || typeof raw !== "object") throw new Error("schedule_required");
107
+ const obj = raw as Record<string, unknown>;
108
+ const type = obj.type;
109
+ const timezone = typeof timezoneRaw === "string" && timezoneRaw.trim() ? timezoneRaw.trim() : "UTC";
110
+ if (!validTimezone(timezone)) throw new Error("invalid_timezone");
111
+
112
+ if (type === "once") {
113
+ if (typeof obj.run_at !== "string") throw new Error("run_at_required");
114
+ const when = new Date(obj.run_at);
115
+ if (!Number.isFinite(when.getTime())) throw new Error("invalid_run_at");
116
+ return { spec: { type, run_at: iso(when) }, timezone };
117
+ }
118
+ if (type === "interval") {
119
+ const every = Number(obj.every_seconds);
120
+ if (!Number.isSafeInteger(every) || every < 60 || every > 365 * 86400) throw new Error("invalid_interval");
121
+ return { spec: { type, every_seconds: every }, timezone };
122
+ }
123
+ if (type === "daily") {
124
+ if (typeof obj.time !== "string" || !TIME_RE.test(obj.time)) throw new Error("invalid_time");
125
+ return { spec: { type, time: obj.time }, timezone };
126
+ }
127
+ if (type === "weekly") {
128
+ if (typeof obj.time !== "string" || !TIME_RE.test(obj.time)) throw new Error("invalid_time");
129
+ if (!Array.isArray(obj.weekdays) || obj.weekdays.length === 0) throw new Error("weekdays_required");
130
+ const weekdays = [...new Set(obj.weekdays.map(Number))].sort();
131
+ if (weekdays.some((d) => !Number.isSafeInteger(d) || d < 0 || d > 6)) throw new Error("invalid_weekdays");
132
+ return { spec: { type, time: obj.time, weekdays }, timezone };
133
+ }
134
+ throw new Error("invalid_schedule_type");
135
+ }
136
+
137
+ /** Return the first occurrence strictly after `after`. Daily/weekly scans UTC
138
+ * minutes and compares IANA-zone wall time, so DST gaps/folds follow the
139
+ * platform timezone database instead of a hand-written offset guess. */
140
+ export function nextOccurrence(spec: ScheduleSpec, timezone: string, after: Date): Date | null {
141
+ if (spec.type === "once") {
142
+ const when = new Date(spec.run_at);
143
+ return when.getTime() > after.getTime() ? when : null;
144
+ }
145
+ if (spec.type === "interval") return new Date(after.getTime() + spec.every_seconds * 1000);
146
+
147
+ // A wall-clock time can occur twice when DST falls back. If today's local
148
+ // occurrence already happened at or before `after`, skip every duplicate
149
+ // carrying that same local date. Otherwise a "daily 01:30" schedule runs
150
+ // twice on the fall-back day.
151
+ const afterLocal = zonedParts(after, timezone);
152
+ let alreadyOccurredDate: string | null = null;
153
+ const backward = new Date(Math.floor(after.getTime() / 60000) * 60000);
154
+ for (let i = 0; i <= 26 * 60; i++, backward.setTime(backward.getTime() - 60000)) {
155
+ const local = zonedParts(backward, timezone);
156
+ if (local.date === afterLocal.date && local.time === spec.time && (spec.type === "daily" || spec.weekdays.includes(local.weekday))) {
157
+ alreadyOccurredDate = local.date;
158
+ break;
159
+ }
160
+ }
161
+
162
+ const cursor = new Date(Math.floor(after.getTime() / 60000) * 60000 + 60000);
163
+ const max = 8 * 24 * 60;
164
+ for (let i = 0; i < max; i++, cursor.setTime(cursor.getTime() + 60000)) {
165
+ const local = zonedParts(cursor, timezone);
166
+ if (local.time !== spec.time) continue;
167
+ if (local.date === alreadyOccurredDate) continue;
168
+ if (spec.type === "daily" || spec.weekdays.includes(local.weekday)) return new Date(cursor);
169
+ }
170
+ throw new Error("next_occurrence_unresolvable");
171
+ }
172
+
173
+ function decodeRow(row: ScheduledRow): Record<string, unknown> {
174
+ let schedule: unknown = null;
175
+ try { schedule = JSON.parse(row.schedule_json); } catch {}
176
+ const { created_by: _createdBy, schedule_json: _scheduleJson, ...publicRow } = row;
177
+ return { ...publicRow, schedule };
178
+ }
179
+
180
+ function scopedSchedule(scheduleId: string, scope: RestNetworkScope): ScheduledRow | null {
181
+ const params: unknown[] = [scheduleId];
182
+ let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`;
183
+ sql = addNetworkScope(sql, params, scope);
184
+ return db.get<ScheduledRow>(sql, ...params);
185
+ }
186
+
187
+ function resolveWriteNetwork(body: Record<string, unknown>, ctx: ScheduledRequestContext): string | null {
188
+ if (ctx.auth?.networkId) return ctx.auth.networkId;
189
+ if (typeof body.network_id === "string" && body.network_id) return body.network_id;
190
+ return singleNetworkId(ctx.scope);
191
+ }
192
+
193
+ function validateTarget(networkId: string, nodeId: unknown): { node_id: string; alias: string } {
194
+ if (typeof nodeId !== "string" || nodeId.length < 2 || nodeId.length > 200) throw new Error("invalid_target_node_id");
195
+ const node = db.get<{ node_id: string; alias: string | null }>(
196
+ "SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
197
+ nodeId, networkId,
198
+ );
199
+ if (!node?.alias) throw new Error("target_node_not_found");
200
+ return { node_id: node.node_id, alias: node.alias };
201
+ }
202
+
203
+ type DispatchEvent = { alias: string; networkId: string; taskId: string; priority: string; state: "delivered" | "queued" };
204
+
205
+ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: string, advanceSchedule: boolean, advanceAfter = new Date()): { runId: string; taskId?: string; status: string; event?: DispatchEvent } {
206
+ const runId = `srun_${crypto.randomUUID()}`;
207
+ let event: DispatchEvent | undefined;
208
+ let finalStatus = "failed";
209
+ let createdTaskId: string | undefined;
210
+
211
+ db.transaction(() => {
212
+ // UNIQUE(schedule_id, scheduled_for) is the cross-process claim. If two
213
+ // Hub processes race, one INSERT wins and the other transaction aborts.
214
+ db.run(
215
+ `INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, status)
216
+ VALUES (?1, ?2, ?3, ?4, 'claiming')`,
217
+ [runId, row.schedule_id, row.network_id, scheduledFor],
218
+ );
219
+
220
+ if (row.overlap_policy === "skip") {
221
+ const placeholders = OPEN_TASK_STATUSES.map((_, i) => `?${i + 2}`).join(", ");
222
+ const open = db.get<{ task_id: string }>(
223
+ `SELECT r.task_id FROM scheduled_task_runs r
224
+ JOIN tasks t ON t.task_id = r.task_id
225
+ WHERE r.schedule_id = ?1 AND t.status IN (${placeholders})
226
+ ORDER BY r.created_at DESC LIMIT 1`,
227
+ row.schedule_id, ...OPEN_TASK_STATUSES,
228
+ );
229
+ if (open) {
230
+ finalStatus = "skipped";
231
+ db.run(
232
+ "UPDATE scheduled_task_runs SET status = 'skipped', error_code = 'previous_run_active', completed_at = datetime('now') WHERE run_id = ?1",
233
+ [runId],
234
+ );
235
+ if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
236
+ return;
237
+ }
238
+ }
239
+
240
+ const node = db.get<{ node_id: string; alias: string | null }>(
241
+ "SELECT node_id, alias FROM nodes WHERE node_id = ?1 AND network_id = ?2",
242
+ row.target_node_id, row.network_id,
243
+ );
244
+ if (!node?.alias) {
245
+ finalStatus = "failed";
246
+ db.run(
247
+ "UPDATE scheduled_task_runs SET status = 'failed', error_code = 'target_node_not_found', error_message = 'The bound node no longer exists in this network', completed_at = datetime('now') WHERE run_id = ?1",
248
+ [runId],
249
+ );
250
+ if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
251
+ return;
252
+ }
253
+ const lifecycle = assertNodeActive(node.alias, row.network_id);
254
+ if (!lifecycle.ok) {
255
+ finalStatus = "failed";
256
+ db.run(
257
+ "UPDATE scheduled_task_runs SET status = 'failed', error_code = 'target_not_active', error_message = ?1, completed_at = datetime('now') WHERE run_id = ?2",
258
+ [String((lifecycle as any).error || "target_not_active").slice(0, 500), runId],
259
+ );
260
+ if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
261
+ return;
262
+ }
263
+
264
+ const taskId = crypto.randomUUID();
265
+ const session = db.get<{ status: string | null }>(
266
+ "SELECT status FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY updated_at DESC LIMIT 1",
267
+ row.target_node_id, row.network_id,
268
+ );
269
+ const deliveryState: "delivered" | "queued" = session && session.status !== "offline" ? "delivered" : "queued";
270
+ const metaJson = JSON.stringify({
271
+ scheduled_task_id: row.schedule_id,
272
+ scheduled_run_id: runId,
273
+ scheduled_for: scheduledFor,
274
+ auth_origin: "hub_scheduler",
275
+ });
276
+ db.run(
277
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
278
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, 'scheduler', 'reply', ?6, ?7)`,
279
+ [taskId, node.alias, node.node_id, row.priority, row.task_content, row.network_id, metaJson],
280
+ );
281
+ db.run(
282
+ `INSERT INTO tasks (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, meta_json)
283
+ VALUES (?1, 'scheduler', ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+86400 seconds'), ?6, ?7)`,
284
+ [taskId, node.node_id, node.alias, row.priority, row.task_content, row.network_id, metaJson],
285
+ );
286
+ // Dispatch is not completion. Keep the run open until the exact bound
287
+ // task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
288
+ db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
289
+ db.run("UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3", [row.task_content.slice(0, 200), node.node_id, row.network_id]);
290
+ db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
291
+ if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
292
+ createdTaskId = taskId;
293
+ finalStatus = deliveryState;
294
+ event = { alias: node.alias, networkId: row.network_id, taskId, priority: row.priority, state: deliveryState };
295
+ });
296
+
297
+ // Everything below is a post-commit doorbell. A logging/SSE failure must
298
+ // never turn a durably-created task into an API-level failure: callers may
299
+ // retry an apparent failure and create a second manual occurrence.
300
+ try {
301
+ if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
302
+ if (event) {
303
+ const pending = db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND acked = 0", event.alias, event.networkId);
304
+ if (event.state === "delivered") pushEvent(event.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: event.priority, from: "scheduler" }, event.networkId);
305
+ pushNetworkObserverEvent(event.networkId, { type: "new_task", task_id: event.taskId, from: "scheduler", to: event.alias, status: event.state, priority: event.priority });
306
+ }
307
+ } catch (e: any) {
308
+ console.error(`[scheduled-tasks] post-commit notification failed schedule=${row.schedule_id} run=${runId}: ${e?.message || e}`);
309
+ }
310
+ return { runId, taskId: createdTaskId, status: finalStatus, event };
311
+ }
312
+
313
+ function advance(row: ScheduledRow, scheduledFor: string, after = new Date(), recordLastRun = true): void {
314
+ const spec = JSON.parse(row.schedule_json) as ScheduleSpec;
315
+ const next = nextOccurrence(spec, row.timezone, after);
316
+ const lastRunAt = recordLastRun ? scheduledFor : row.last_run_at;
317
+ if (spec.type === "once" || !next) {
318
+ db.run(
319
+ "UPDATE scheduled_tasks SET status = 'completed', next_run_at = NULL, last_run_at = ?1, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?2",
320
+ [lastRunAt, row.schedule_id],
321
+ );
322
+ } else {
323
+ db.run(
324
+ "UPDATE scheduled_tasks SET next_run_at = ?1, last_run_at = ?2, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?3",
325
+ [iso(next), lastRunAt, row.schedule_id],
326
+ );
327
+ }
328
+ }
329
+
330
+ function isMissedOccurrence(scheduledFor: string, now: Date): boolean {
331
+ const scheduledAt = new Date(scheduledFor).getTime();
332
+ return Number.isFinite(scheduledAt) && now.getTime() - scheduledAt > MISFIRE_GRACE_MS;
333
+ }
334
+
335
+ function skipScheduledMisfire(row: ScheduledRow, scheduledFor: string, now: Date): void {
336
+ const runId = `srun_${crypto.randomUUID()}`;
337
+ db.transaction(() => {
338
+ db.run(
339
+ `INSERT INTO scheduled_task_runs
340
+ (run_id, schedule_id, network_id, scheduled_for, status, error_code, error_message, completed_at)
341
+ VALUES (?1, ?2, ?3, ?4, 'skipped', 'misfire_skipped', 'The occurrence was overdue and the schedule is configured to skip missed runs', datetime('now'))`,
342
+ [runId, row.schedule_id, row.network_id, scheduledFor],
343
+ );
344
+ // A skipped occurrence is auditable but is not an execution: preserve
345
+ // last_run_at while moving the schedule directly to its next future slot.
346
+ advance(row, scheduledFor, now, false);
347
+ });
348
+ }
349
+
350
+ export function runDueScheduledTasks(now = new Date(), limit = 100): { processed: number; failed: number } {
351
+ const due = db.all<ScheduledRow>(
352
+ `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?1 ORDER BY next_run_at LIMIT ?2`,
353
+ iso(now), limit,
354
+ );
355
+ let processed = 0;
356
+ let failed = 0;
357
+ for (const row of due) {
358
+ try {
359
+ // Re-read inside the claim path so a pause/cancel immediately before the
360
+ // tick wins. The unique occurrence key handles a second scheduler.
361
+ const current = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1 AND status = 'active' AND next_run_at = ?2`, row.schedule_id, row.next_run_at);
362
+ if (!current?.next_run_at) continue;
363
+ if (current.misfire_policy === "skip" && isMissedOccurrence(current.next_run_at, now)) {
364
+ skipScheduledMisfire(current, current.next_run_at, now);
365
+ } else {
366
+ // catch_up_once is also the compatibility default for pre-migration
367
+ // rows. Advancing from `now` collapses any number of missed slots into
368
+ // one task instead of replaying an outage as a dispatch storm.
369
+ dispatchScheduledOccurrence(current, current.next_run_at, true, now);
370
+ }
371
+ processed++;
372
+ } catch (e: any) {
373
+ if (/UNIQUE|duplicate key/i.test(String(e?.message))) continue;
374
+ failed++;
375
+ console.error(`[scheduled-tasks] occurrence failed schedule=${row.schedule_id}: ${e?.message || e}`);
376
+ }
377
+ }
378
+ return { processed, failed };
379
+ }
380
+
381
+ export function startScheduledTaskScheduler(intervalMs?: number): ReturnType<typeof setInterval> {
382
+ const configured = Number(process.env.COMMHUB_SCHEDULER_TICK_MS);
383
+ const ms = intervalMs && intervalMs > 0 ? intervalMs : configured > 0 ? configured : 10_000;
384
+ // First tick is immediate so a restarted Hub converges without waiting one
385
+ // full cadence. Recurrences advance from "now", intentionally collapsing a
386
+ // long outage into one catch-up occurrence instead of a dispatch storm.
387
+ runDueScheduledTasks();
388
+ return setInterval(() => runDueScheduledTasks(), ms);
389
+ }
390
+
391
+ async function bodyObject(req: Request): Promise<Record<string, unknown>> {
392
+ try {
393
+ const value = await req.json();
394
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
395
+ return value as Record<string, unknown>;
396
+ } catch {
397
+ throw new Error("invalid_json");
398
+ }
399
+ }
400
+
401
+ function writeAllowed(ctx: ScheduledRequestContext, networkId: string | null): boolean {
402
+ return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
403
+ }
404
+
405
+ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext): Promise<Response | null> {
406
+ const { req, url } = ctx;
407
+ if (url.pathname !== "/api/scheduled-tasks" && !url.pathname.startsWith("/api/scheduled-tasks/")) return null;
408
+ if (ctx.isNodeToken) return jsonError("user_token_required", 403);
409
+
410
+ if (url.pathname === "/api/scheduled-tasks" && req.method === "GET") {
411
+ const params: unknown[] = [];
412
+ let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE 1=1`;
413
+ sql = addNetworkScope(sql, params, ctx.scope);
414
+ const status = url.searchParams.get("status");
415
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
416
+ sql += " ORDER BY updated_at DESC LIMIT 500";
417
+ return Response.json({ ok: true, schedules: db.all<ScheduledRow>(sql, ...params).map(decodeRow) });
418
+ }
419
+
420
+ if (url.pathname === "/api/scheduled-tasks" && req.method === "POST") {
421
+ let body: Record<string, unknown>;
422
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
423
+ const networkId = resolveWriteNetwork(body, ctx);
424
+ if (!networkId) return jsonError("network_id_required", 400);
425
+ if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
426
+ try {
427
+ const name = typeof body.name === "string" ? body.name.trim() : "";
428
+ const content = typeof body.task === "string" ? body.task.trim() : "";
429
+ if (!name || name.length > 120) throw new Error("invalid_name");
430
+ if (!content || content.length > 10_000) throw new Error("invalid_task");
431
+ const priority = typeof body.priority === "string" ? body.priority : "normal";
432
+ if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
433
+ const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
434
+ const target = validateTarget(networkId, body.target_node_id);
435
+ const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
436
+ const next = nextOccurrence(spec, timezone, new Date());
437
+ if (!next) throw new Error("schedule_has_no_future_occurrence");
438
+ const scheduleId = `sched_${crypto.randomUUID()}`;
439
+ db.run(
440
+ `INSERT INTO scheduled_tasks
441
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
442
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'skip', ?12, ?13)`,
443
+ [scheduleId, networkId, ctx.auth?.userId ?? null, name, target.node_id, target.alias, content, priority, spec.type, JSON.stringify(spec), timezone, misfirePolicy, iso(next)],
444
+ );
445
+ const created = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId)!;
446
+ return Response.json({ ok: true, schedule: decodeRow(created) }, { status: 201 });
447
+ } catch (e: any) {
448
+ const code = String(e?.message || "invalid_schedule");
449
+ const status = code === "target_node_not_found" ? 404 : 400;
450
+ return jsonError(code, status);
451
+ }
452
+ }
453
+
454
+ const match = url.pathname.match(/^\/api\/scheduled-tasks\/([^/]+)(?:\/(runs|run-now))?$/);
455
+ if (!match) return jsonError("not_found", 404);
456
+ const scheduleId = decodeURIComponent(match[1]);
457
+ const sub = match[2] || null;
458
+ const row = scopedSchedule(scheduleId, ctx.scope);
459
+ if (!row) return jsonError("schedule_not_found", 404);
460
+
461
+ if (sub === "runs" && req.method === "GET") {
462
+ const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
463
+ 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",
465
+ row.schedule_id, row.network_id, limit,
466
+ );
467
+ return Response.json({ ok: true, runs });
468
+ }
469
+ if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
470
+ if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);
471
+
472
+ if (sub === "run-now" && req.method === "POST") {
473
+ if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
474
+ try {
475
+ const result = dispatchScheduledOccurrence(row, iso(new Date()), false);
476
+ return Response.json({ ok: true, ...result }, { status: result.status === "failed" ? 409 : 202 });
477
+ } catch (e: any) {
478
+ return jsonError("dispatch_failed", 409, { message: String(e?.message || e).slice(0, 500) });
479
+ }
480
+ }
481
+
482
+ if (!sub && req.method === "DELETE") {
483
+ db.run("UPDATE scheduled_tasks SET status = 'cancelled', next_run_at = NULL, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?1", [row.schedule_id]);
484
+ return Response.json({ ok: true, status: "cancelled" });
485
+ }
486
+
487
+ if (!sub && req.method === "PATCH") {
488
+ let body: Record<string, unknown>;
489
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
490
+ // Cancellation/completion are terminal states. Editing must never become
491
+ // an implicit resurrection path (including by supplying status=active).
492
+ if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
493
+ if (row.status === "completed") return jsonError("schedule_completed", 409);
494
+ if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) return jsonError("revision_conflict", 409, { current_revision: row.revision });
495
+ try {
496
+ const name = body.name === undefined ? row.name : String(body.name).trim();
497
+ const content = body.task === undefined ? row.task_content : String(body.task).trim();
498
+ const priority = body.priority === undefined ? row.priority : String(body.priority);
499
+ if (!name || name.length > 120) throw new Error("invalid_name");
500
+ if (!content || content.length > 10_000) throw new Error("invalid_task");
501
+ if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
502
+ const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
503
+ const parsed = body.schedule === undefined
504
+ ? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
505
+ : parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
506
+ if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
507
+ const requestedStatus = body.status === undefined ? row.status : String(body.status);
508
+ if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
509
+ const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
510
+ // Editing descriptive fields must not silently reset the schedule's
511
+ // cadence. Recompute only when the scheduling inputs change, when a
512
+ // paused schedule resumes, or when repairing an impossible active row
513
+ // with no next occurrence. The recompute uses the same DST-safe helper
514
+ // as creation and dispatch advancement.
515
+ const scheduleJson = JSON.stringify(parsed.spec);
516
+ const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
517
+ const resumed = row.status !== "active" && requestedStatus === "active";
518
+ const next = requestedStatus !== "active"
519
+ ? null
520
+ : schedulingChanged || resumed || !row.next_run_at
521
+ ? nextOccurrence(parsed.spec, parsed.timezone, new Date())
522
+ : new Date(row.next_run_at);
523
+ if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
524
+ const updated = db.run(
525
+ `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
526
+ priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9, next_run_at = ?10,
527
+ misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
528
+ [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],
529
+ );
530
+ if (updated.changes !== 1) return jsonError("revision_conflict", 409);
531
+ 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)!) });
532
+ } catch (e: any) {
533
+ const code = String(e?.message || "invalid_schedule");
534
+ return jsonError(code, code === "target_node_not_found" ? 404 : 400);
535
+ }
536
+ }
537
+ return jsonError("method_not_allowed", 405);
538
+ }
@@ -3,25 +3,17 @@ import { db } from "./db.js";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { registerTools } from "./tools.js";
5
5
 
6
- // #498 regression — the send_reply hub response gains a `warning` field
7
- // pointing at commhub_send_task ONLY when the target alias resolves to
8
- // a real agent node (i.e. not hub / not api / not an unbound alias).
9
- // Silence-affirms the Dashboard path.
10
- //
11
- // Why this test exists (通信龙 2026-07-30 裁定): the warning is a pure
12
- // hint — deleting it doesn't break any behavior, so it's exactly the
13
- // kind of code a future refactor drops silently. This test is the
14
- // tripwire: if the branch goes away, this test turns red.
15
- //
16
- // witnessed-red methodology (`feedback_witnessed_red_before_witnessed_green`):
17
- // I confirmed both assertions fail cleanly when the `warning` branch is
18
- // reverted (see docs/tests/p-498-reply-warning/witnessed-red.txt), so
19
- // this suite genuinely catches regressions rather than green-when-empty.
6
+ // #498 compatibility warning remains load-bearing during the mixed-version
7
+ // rollout. Capability-negotiated agents use send_peer_reply; old send_reply
8
+ // callers targeting an agent get explicit migration guidance while Dashboard
9
+ // targets stay quiet.
20
10
 
21
11
  const NET_ID = "net_498_warn";
22
12
  const USER_ID = "u_498_warn";
23
13
  const NODE_ID = "node_498_warn";
24
14
  const AGENT_ALIAS = "peer-agent-498";
15
+ const DISPATCHER_NODE_ID = "node_498_dispatcher";
16
+ const DISPATCHER_ALIAS = "dispatcher";
25
17
  const AGENT_TOK_ID = "tok_498_warn";
26
18
 
27
19
  interface ToolHandler {
@@ -57,11 +49,21 @@ function seed() {
57
49
  VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
58
50
  [USER_ID, USER_ID],
59
51
  );
52
+ db.run(
53
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
54
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
55
+ [DISPATCHER_NODE_ID, DISPATCHER_ALIAS, DISPATCHER_ALIAS, NET_ID, `host-${DISPATCHER_ALIAS}`],
56
+ );
60
57
  db.run(
61
58
  `INSERT INTO networks (network_id, network_name, owner_id, created_at)
62
59
  VALUES (?1, ?2, ?3, datetime('now'))`,
63
60
  [NET_ID, NET_ID, USER_ID],
64
61
  );
62
+ db.run(
63
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
64
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
65
+ [`res_${DISPATCHER_ALIAS}`, DISPATCHER_ALIAS, DISPATCHER_NODE_ID, NET_ID],
66
+ );
65
67
  db.run(
66
68
  `INSERT INTO network_members (user_id, network_id, role, joined_at)
67
69
  VALUES (?1, ?2, 'owner', datetime('now'))`,
@@ -84,12 +86,12 @@ function seed() {
84
86
  );
85
87
  }
86
88
 
87
- function makeTask(to: string): string {
89
+ function makeTask(from: string): string {
88
90
  const id = "t_" + Math.random().toString(36).slice(2, 14);
89
91
  db.run(
90
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
91
- VALUES (?1, ?2, ?3, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?4)`,
92
- [id, "dispatcher", to, NET_ID],
92
+ `INSERT INTO tasks (task_id, from_name, to_name, to_node_id, priority, status, content, requires_response, created_at, network_id)
93
+ VALUES (?1, ?2, ?3, ?4, 'normal', 'delivered', 'test', 'reply', datetime('now'), ?5)`,
94
+ [id, from, AGENT_ALIAS, NODE_ID, NET_ID],
93
95
  );
94
96
  return id;
95
97
  }
@@ -127,38 +129,36 @@ async function call(handler: ToolHandler, args: any): Promise<Reply> {
127
129
  return JSON.parse(r.content[0].text) as Reply;
128
130
  }
129
131
 
130
- describe("send_reply warning field (#498)", () => {
131
- test("target alias resolves to a real agent → response includes `warning` field", async () => {
132
+ describe("send_reply legacy/Dashboard compatibility (#698)", () => {
133
+ test("legacy call still closes the original and enqueues one no-response result", async () => {
132
134
  seed();
133
135
  const tools = buildHandlers();
134
136
  const sendReply = tools["send_reply"];
135
137
  expect(sendReply).toBeDefined();
136
138
 
137
- const taskId = makeTask(AGENT_ALIAS);
139
+ const taskId = makeTask(DISPATCHER_ALIAS);
138
140
  const reply = await call(sendReply, {
139
- alias: AGENT_ALIAS,
140
- text: "peer reply (should trigger warning)",
141
+ alias: DISPATCHER_ALIAS,
142
+ text: "peer reply (atomic)",
141
143
  in_reply_to: taskId,
142
144
  status: "replied",
143
145
  from_session: AGENT_ALIAS,
144
146
  });
145
147
 
146
148
  expect(reply.ok).toBe(true);
147
- expect(reply.warning).toBeDefined();
148
- expect(typeof reply.warning).toBe("string");
149
- const w = reply.warning as string;
150
- // Action-pointing content — witnessed-red methodology means each
151
- // assertion here must be one a future revert would break, not a
152
- // shape-only check that passes on empty string.
153
- expect(w).toContain("commhub_send_task");
154
- // Alias value is INLINED (not just "<alias>") so the LLM has a
155
- // copy-pasteable next command. This is the specific design 通信龙
156
- // called out on 2026-07-30.
157
- expect(w).toContain(`alias="${AGENT_ALIAS}"`);
158
- expect(w).toContain("agent peers");
159
- expect(w).toContain("not see this reply in real time");
160
- expect(w).toContain("RFC-030");
161
- expect(w).toContain("全网规则");
149
+ expect(reply.warning).toContain("commhub_send_peer_reply");
150
+ expect(reply.warning).toContain("terminalized the original task");
151
+ expect(reply.warning).not.toContain("commhub_send_task");
152
+ expect(reply.warning).toContain("mixed-version");
153
+ expect(db.get<{ status: string; result: string }>(
154
+ "SELECT status, result FROM tasks WHERE task_id = ?1", taskId,
155
+ )).toEqual(expect.objectContaining({ status: "replied", result: "peer reply (atomic)" }));
156
+ expect(db.all<{ type: string; requires_response: string; session_name: string }>(
157
+ "SELECT type, requires_response, session_name FROM inbox WHERE in_reply_to = ?1", taskId,
158
+ )).toEqual([{ type: "reply", requires_response: "none", session_name: DISPATCHER_ALIAS }]);
159
+ expect(db.get<{ n: number }>(
160
+ "SELECT COUNT(*) AS n FROM tasks WHERE parent_task_id = ?1", taskId,
161
+ )?.n).toBe(0);
162
162
  });
163
163
 
164
164
  test("target alias is `hub` (Dashboard path) → NO warning field", async () => {