@sleep2agi/commhub-server 0.9.0-preview.112 → 0.9.0-preview.114

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.
@@ -4,7 +4,8 @@ import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limi
4
4
  import { assertNodeActive } from "./lifecycle-guard.js";
5
5
  import { assertNodeHealthy } from "./node-health-guard.js";
6
6
  import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
7
- import { canMessageAgent } from "./agent-access.js";
7
+ import { canMessageAgent, type AgentRef } from "./agent-access.js";
8
+ import { dispatchVerdict } from "./node-permissions.js";
8
9
  import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
9
10
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
10
11
  import { parseDbTimestampMs } from "./db-timestamp.js";
@@ -19,10 +20,11 @@ export type ScheduleSpec =
19
20
 
20
21
  export type MisfirePolicy = "catch_up_once" | "skip";
21
22
 
22
- type ScheduledRow = {
23
+ export type ScheduledRow = {
23
24
  schedule_id: string;
24
25
  network_id: string;
25
26
  created_by: string | null;
27
+ created_by_node_id: string | null;
26
28
  name: string;
27
29
  target_node_id: string;
28
30
  target_alias: string;
@@ -178,13 +180,135 @@ export function nextOccurrence(spec: ScheduleSpec, timezone: string, after: Date
178
180
  throw new Error("next_occurrence_unresolvable");
179
181
  }
180
182
 
183
+ /** REST shape (pinned by rest-explicit-columns-http.test.ts): creator ids stay server-side. */
181
184
  function decodeRow(row: ScheduledRow): Record<string, unknown> {
182
185
  let schedule: unknown = null;
183
186
  try { schedule = JSON.parse(row.schedule_json); } catch {}
184
- const { created_by: _createdBy, schedule_json: _scheduleJson, ...publicRow } = row;
187
+ const { created_by: _createdBy, created_by_node_id: _createdByNode, schedule_json: _scheduleJson, ...publicRow } = row;
185
188
  return { ...publicRow, schedule };
186
189
  }
187
190
 
191
+ /** MCP (agent) shape: REST shape + which node created it (null = a person / old row). Never the user id. */
192
+ export function decodeRowForAgent(row: ScheduledRow): Record<string, unknown> {
193
+ return { ...decodeRow(row), created_by_node_id: row.created_by_node_id ?? null };
194
+ }
195
+
196
+ export function getScheduleRow(scheduleId: string): ScheduledRow | null {
197
+ return db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId);
198
+ }
199
+
200
+ export class ScheduleInputError extends Error {
201
+ constructor(public code: string, public status: number, public extra: Record<string, unknown> = {}) { super(code); }
202
+ }
203
+
204
+ export type ValidatedScheduleCreate = {
205
+ name: string; content: string; priority: string; misfirePolicy: MisfirePolicy;
206
+ target: { node_id: string; alias: string }; spec: ScheduleSpec; timezone: string; next: Date;
207
+ };
208
+
209
+ /** Shared by REST POST and MCP schedule_create. Throws Error(code) like before. */
210
+ export function validateScheduleCreate(networkId: string, body: Record<string, unknown>): ValidatedScheduleCreate {
211
+ const name = typeof body.name === "string" ? body.name.trim() : "";
212
+ const content = typeof body.task === "string" ? body.task.trim() : "";
213
+ if (!name || name.length > 120) throw new Error("invalid_name");
214
+ if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
215
+ const priority = typeof body.priority === "string" ? body.priority : "normal";
216
+ if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
217
+ const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
218
+ const target = validateTarget(networkId, body.target_node_id);
219
+ const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
220
+ const next = nextOccurrence(spec, timezone, new Date());
221
+ if (!next) throw new Error("schedule_has_no_future_occurrence");
222
+ return { name, content, priority, misfirePolicy, target, spec, timezone, next };
223
+ }
224
+
225
+ export function insertSchedule(networkId: string, createdBy: string | null, createdByNodeId: string | null, v: ValidatedScheduleCreate): ScheduledRow {
226
+ const scheduleId = `sched_${crypto.randomUUID()}`;
227
+ db.run(
228
+ `INSERT INTO scheduled_tasks
229
+ (schedule_id, network_id, created_by, created_by_node_id, name, target_node_id, target_alias, task_content, priority, schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, next_run_at)
230
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'skip', ?13, ?14)`,
231
+ [scheduleId, networkId, createdBy, createdByNodeId, v.name, v.target.node_id, v.target.alias, v.content, v.priority, v.spec.type, JSON.stringify(v.spec), v.timezone, v.misfirePolicy, iso(v.next)],
232
+ );
233
+ return getScheduleRow(scheduleId)!;
234
+ }
235
+
236
+ /** Idempotent cancel (shared by REST DELETE / POST cancel and MCP schedule_cancel). */
237
+ export function cancelSchedule(row: ScheduledRow): void {
238
+ if (row.status === "cancelled") return;
239
+ 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]);
240
+ }
241
+
242
+ export function scheduleRuns(row: ScheduledRow, limit: number): Record<string, unknown> {
243
+ const runs = db.all(
244
+ // #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
245
+ `SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
246
+ CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
247
+ r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
248
+ FROM scheduled_task_runs r
249
+ LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
250
+ WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
251
+ row.schedule_id, row.network_id, limit,
252
+ );
253
+ return { runs, ...failureSummary(row.schedule_id) };
254
+ }
255
+
256
+ /** Shared PATCH semantics (REST PATCH and MCP schedule_update). Throws ScheduleInputError. */
257
+ export function patchSchedule(row: ScheduledRow, body: Record<string, unknown>): ScheduledRow {
258
+ // Cancellation/completion are terminal states. Editing must never become
259
+ // an implicit resurrection path (including by supplying status=active).
260
+ if (row.status === "cancelled") throw new ScheduleInputError("schedule_cancelled", 409);
261
+ if (row.status === "completed") throw new ScheduleInputError("schedule_completed", 409);
262
+ if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) throw new ScheduleInputError("revision_conflict", 409, { current_revision: row.revision });
263
+ try {
264
+ const name = body.name === undefined ? row.name : String(body.name).trim();
265
+ const content = body.task === undefined ? row.task_content : String(body.task).trim();
266
+ const priority = body.priority === undefined ? row.priority : String(body.priority);
267
+ if (!name || name.length > 120) throw new Error("invalid_name");
268
+ if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
269
+ if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
270
+ 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);
271
+ const parsed = body.schedule === undefined
272
+ ? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
273
+ : parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
274
+ if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
275
+ const requestedStatus = body.status === undefined ? row.status : String(body.status);
276
+ if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
277
+ const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
278
+ // Editing descriptive fields must not silently reset the schedule's
279
+ // cadence. Recompute only when the scheduling inputs change, when a
280
+ // paused schedule resumes, or when repairing an impossible active row
281
+ // with no next occurrence. The recompute uses the same DST-safe helper
282
+ // as creation and dispatch advancement.
283
+ const scheduleJson = JSON.stringify(parsed.spec);
284
+ const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
285
+ const resumed = row.status !== "active" && requestedStatus === "active";
286
+ const next = requestedStatus !== "active"
287
+ ? null
288
+ : schedulingChanged || resumed || !row.next_run_at
289
+ ? nextOccurrence(parsed.spec, parsed.timezone, new Date())
290
+ : new Date(row.next_run_at);
291
+ if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
292
+ // Runs no longer bump revision, so the scheduler may have advanced
293
+ // next_run_at after `row` was read. When cadence is preserved, keep the
294
+ // stored value instead of writing back the already-fired occurrence.
295
+ const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
296
+ const updated = db.run(
297
+ `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
298
+ priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
299
+ next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
300
+ misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
301
+ [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],
302
+ );
303
+ if (updated.changes !== 1) throw new ScheduleInputError("revision_conflict", 409);
304
+ return getScheduleRow(row.schedule_id)!;
305
+ } catch (e: any) {
306
+ if (e instanceof ScheduleInputError) throw e;
307
+ const code = String(e?.message || "invalid_schedule");
308
+ throw new ScheduleInputError(code, code === "target_node_not_found" ? 404 : 400);
309
+ }
310
+ }
311
+
188
312
  function scopedSchedule(scheduleId: string, scope: RestNetworkScope): ScheduledRow | null {
189
313
  const params: unknown[] = [scheduleId];
190
314
  let sql = `SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`;
@@ -215,6 +339,25 @@ function stuckNoticeFor(row: ScheduledRow, reason: StuckNotice["reason"], blocke
215
339
  };
216
340
  }
217
341
 
342
+ /** The line an Agent-created schedule's task always starts with (board #733). */
343
+ export function agentSchedulePrefix(alias: string | null, nodeId: string): string {
344
+ return `[scheduled by agent ${alias ?? "?"} (${nodeId})]\n`;
345
+ }
346
+
347
+ function agentCreatorDenial(row: ScheduledRow, target: AgentRef): { code: string; message: string } | null {
348
+ const creator = db.get<{ alias: string | null; owner_user_id: string | null; permission_mode: string | null }>(
349
+ "SELECT alias, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2", row.created_by_node_id, row.network_id);
350
+ if (!creator) return { code: "creator_node_gone", message: "The node that created this schedule no longer exists in this network" };
351
+ const mode = creator.permission_mode === "readonly" || creator.permission_mode === "restricted" ? creator.permission_mode : "normal";
352
+ if (mode === "readonly") return { code: "creator_node_readonly", message: "The node that created this schedule is read-only" };
353
+ const verdict = dispatchVerdict({
354
+ networkId: row.network_id, nodeId: row.created_by_node_id, logKey: row.created_by_node_id!,
355
+ aliases: creator.alias ? [creator.alias] : [], ownerUserId: creator.owner_user_id, mode,
356
+ }, target);
357
+ if (verdict) return { code: "creator_access_revoked", message: `The node that created this schedule may not send_task to this agent now (${verdict.reason})` };
358
+ return null;
359
+ }
360
+
218
361
  type DispatchEvent = { alias: string; networkId: string; taskId: string; priority: string; state: "delivered" | "queued" };
219
362
 
220
363
  export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: string, advanceSchedule: boolean, advanceAfter = new Date()): { runId: string; taskId?: string; status: string; event?: DispatchEvent } {
@@ -302,15 +445,25 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
302
445
  }
303
446
  // 多用户 Agent 权限:排程是「建它的人」的委托。建的人后来被设成受限成员、而且没被授权
304
447
  // 给目标 Agent 发任务时,不再代他派发(否则受限前建的排程会永远绕过授权)。
305
- if (row.created_by && !canMessageAgent(row.created_by, row.network_id, { alias: node.alias, nodeId: node.node_id })) {
448
+ // Board #733 — an Agent-created schedule is that node's standing send_task: every run re-evaluates the
449
+ // creating node as it is NOW (exists, not read-only, current owner) with send_task's own dispatchVerdict.
450
+ const creatorDenial = row.created_by_node_id
451
+ ? agentCreatorDenial(row, { alias: node.alias, nodeId: node.node_id })
452
+ : row.created_by && !canMessageAgent(row.created_by, row.network_id, { alias: node.alias, nodeId: node.node_id })
453
+ ? { code: "creator_access_revoked", message: "The schedule creator is no longer allowed to message this agent" }
454
+ : null;
455
+ if (creatorDenial) {
306
456
  finalStatus = "failed";
307
457
  db.run(
308
- "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",
309
- [runId],
458
+ "UPDATE scheduled_task_runs SET status = 'failed', error_code = ?1, error_message = ?2, completed_at = datetime('now') WHERE run_id = ?3",
459
+ [creatorDenial.code, creatorDenial.message, runId],
310
460
  );
311
461
  if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
312
462
  return;
313
463
  }
464
+ const creatorNode = row.created_by_node_id
465
+ ? db.get<{ alias: string | null }>("SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2", row.created_by_node_id, row.network_id)
466
+ : null;
314
467
  const lifecycle = assertNodeActive(node.alias, row.network_id);
315
468
  if (!lifecycle.ok) {
316
469
  finalStatus = "failed";
@@ -349,26 +502,32 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
349
502
  );
350
503
  const deliveryState: "delivered" | "queued" =
351
504
  (session && session.status !== "offline") || hasSubscribers(node.alias, row.network_id) ? "delivered" : "queued";
505
+ // Board #733 — provenance is server-set: an Agent-created schedule names its node in meta and in a
506
+ // prefix the receiving agent always sees first, so it can never pass as a person's schedule.
352
507
  const metaJson = JSON.stringify({
353
508
  scheduled_task_id: row.schedule_id,
354
509
  scheduled_run_id: runId,
355
510
  scheduled_for: scheduledFor,
356
511
  auth_origin: "hub_scheduler",
512
+ ...(row.created_by_node_id ? { scheduled_by_node_id: row.created_by_node_id, scheduled_by_alias: creatorNode?.alias ?? null } : {}),
357
513
  });
514
+ const taskContent = row.created_by_node_id
515
+ ? `${agentSchedulePrefix(creatorNode?.alias ?? null, row.created_by_node_id)}${row.task_content}`
516
+ : row.task_content;
358
517
  db.run(
359
518
  `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
360
519
  VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, 'scheduler', 'reply', ?6, ?7)`,
361
- [taskId, node.alias, node.node_id, row.priority, row.task_content, row.network_id, metaJson],
520
+ [taskId, node.alias, node.node_id, row.priority, taskContent, row.network_id, metaJson],
362
521
  );
363
522
  db.run(
364
523
  `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)
365
524
  VALUES (?1, 'scheduler', ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+86400 seconds'), ?6, ?7)`,
366
- [taskId, node.node_id, node.alias, row.priority, row.task_content, row.network_id, metaJson],
525
+ [taskId, node.node_id, node.alias, row.priority, taskContent, row.network_id, metaJson],
367
526
  );
368
527
  // Dispatch is not completion. Keep the run open until the exact bound
369
528
  // task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
370
529
  db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
371
- db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(row.task_content), node.node_id, row.network_id]);
530
+ db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(taskContent), node.node_id, row.network_id]);
372
531
  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]);
373
532
  if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
374
533
  createdTaskId = taskId;
@@ -516,25 +675,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
516
675
  if (!networkId) return jsonError("network_id_required", 400);
517
676
  if (!writeAllowed(ctx, networkId)) return jsonError("permission_denied", 403);
518
677
  try {
519
- const name = typeof body.name === "string" ? body.name.trim() : "";
520
- const content = typeof body.task === "string" ? body.task.trim() : "";
521
- if (!name || name.length > 120) throw new Error("invalid_name");
522
- if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
523
- const priority = typeof body.priority === "string" ? body.priority : "normal";
524
- if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
525
- const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
526
- const target = validateTarget(networkId, body.target_node_id);
527
- const { spec, timezone } = parseScheduleSpec(body.schedule, body.timezone);
528
- const next = nextOccurrence(spec, timezone, new Date());
529
- if (!next) throw new Error("schedule_has_no_future_occurrence");
530
- const scheduleId = `sched_${crypto.randomUUID()}`;
531
- db.run(
532
- `INSERT INTO scheduled_tasks
533
- (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)
534
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'skip', ?12, ?13)`,
535
- [scheduleId, networkId, ctx.auth?.userId ?? null, name, target.node_id, target.alias, content, priority, spec.type, JSON.stringify(spec), timezone, misfirePolicy, iso(next)],
536
- );
537
- const created = db.get<ScheduledRow>(`SELECT ${SCHEDULED_TASK_STORAGE_SELECT} FROM scheduled_tasks WHERE schedule_id = ?1`, scheduleId)!;
678
+ const created = insertSchedule(networkId, ctx.auth?.userId ?? null, null, validateScheduleCreate(networkId, body));
538
679
  return Response.json({ ok: true, schedule: decodeRow(created) }, { status: 201 });
539
680
  } catch (e: any) {
540
681
  const code = String(e?.message || "invalid_schedule");
@@ -552,17 +693,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
552
693
 
553
694
  if (sub === "runs" && req.method === "GET") {
554
695
  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
555
- const runs = db.all(
556
- // #523 — a task that ended `failed` leaves run.error_message empty; its reason is the task's reply text.
557
- `SELECT r.run_id, r.schedule_id, r.scheduled_for, r.task_id, r.status, r.error_code,
558
- CASE WHEN r.error_message IS NULL AND r.status = 'failed' THEN SUBSTR(t.result, 1, 500) ELSE r.error_message END AS error_message,
559
- r.blocked_by_task_id, r.blocked_by_state, r.created_at, r.completed_at
560
- FROM scheduled_task_runs r
561
- LEFT JOIN tasks t ON t.task_id = r.task_id AND t.network_id = r.network_id
562
- WHERE r.schedule_id = ?1 AND r.network_id = ?2 ORDER BY r.created_at DESC, r.scheduled_for DESC LIMIT ?3`,
563
- row.schedule_id, row.network_id, limit,
564
- );
565
- return Response.json({ ok: true, runs, ...failureSummary(row.schedule_id) });
696
+ return Response.json({ ok: true, ...scheduleRuns(row, limit) });
566
697
  }
567
698
  if (!sub && req.method === "GET") return Response.json({ ok: true, schedule: decodeRow(row) });
568
699
  if (!writeAllowed(ctx, row.network_id)) return jsonError("permission_denied", 403);
@@ -588,64 +719,18 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
588
719
  // "cancelled" between polls) should get the same outcome whether it's the
589
720
  // first click or a retry.
590
721
  if ((!sub && req.method === "DELETE") || (sub === "cancel" && req.method === "POST")) {
591
- if (row.status === "cancelled") return Response.json({ ok: true, status: "cancelled" });
592
- 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]);
722
+ cancelSchedule(row);
593
723
  return Response.json({ ok: true, status: "cancelled" });
594
724
  }
595
725
 
596
726
  if (!sub && req.method === "PATCH") {
597
727
  let body: Record<string, unknown>;
598
728
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
599
- // Cancellation/completion are terminal states. Editing must never become
600
- // an implicit resurrection path (including by supplying status=active).
601
- if (row.status === "cancelled") return jsonError("schedule_cancelled", 409);
602
- if (row.status === "completed") return jsonError("schedule_completed", 409);
603
- if (!Number.isSafeInteger(body.revision) || Number(body.revision) !== row.revision) return jsonError("revision_conflict", 409, { current_revision: row.revision });
604
729
  try {
605
- const name = body.name === undefined ? row.name : String(body.name).trim();
606
- const content = body.task === undefined ? row.task_content : String(body.task).trim();
607
- const priority = body.priority === undefined ? row.priority : String(body.priority);
608
- if (!name || name.length > 120) throw new Error("invalid_name");
609
- if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
610
- if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
611
- 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);
612
- const parsed = body.schedule === undefined
613
- ? { spec: JSON.parse(row.schedule_json) as ScheduleSpec, timezone: body.timezone === undefined ? row.timezone : String(body.timezone) }
614
- : parseScheduleSpec(body.schedule, body.timezone === undefined ? row.timezone : body.timezone);
615
- if (!validTimezone(parsed.timezone)) throw new Error("invalid_timezone");
616
- const requestedStatus = body.status === undefined ? row.status : String(body.status);
617
- if (!new Set(["active", "paused"]).has(requestedStatus)) throw new Error("invalid_status");
618
- const misfirePolicy = parseMisfirePolicy(body.misfire_policy, row.misfire_policy);
619
- // Editing descriptive fields must not silently reset the schedule's
620
- // cadence. Recompute only when the scheduling inputs change, when a
621
- // paused schedule resumes, or when repairing an impossible active row
622
- // with no next occurrence. The recompute uses the same DST-safe helper
623
- // as creation and dispatch advancement.
624
- const scheduleJson = JSON.stringify(parsed.spec);
625
- const schedulingChanged = scheduleJson !== row.schedule_json || parsed.timezone !== row.timezone;
626
- const resumed = row.status !== "active" && requestedStatus === "active";
627
- const next = requestedStatus !== "active"
628
- ? null
629
- : schedulingChanged || resumed || !row.next_run_at
630
- ? nextOccurrence(parsed.spec, parsed.timezone, new Date())
631
- : new Date(row.next_run_at);
632
- if (requestedStatus === "active" && !next) throw new Error("schedule_has_no_future_occurrence");
633
- // Runs no longer bump revision, so the scheduler may have advanced
634
- // next_run_at after `row` was read. When cadence is preserved, keep the
635
- // stored value instead of writing back the already-fired occurrence.
636
- const keepStoredNext = requestedStatus === "active" && !schedulingChanged && !resumed && !!row.next_run_at ? 1 : 0;
637
- const updated = db.run(
638
- `UPDATE scheduled_tasks SET name = ?1, target_node_id = ?2, target_alias = ?3, task_content = ?4,
639
- priority = ?5, schedule_type = ?6, schedule_json = ?7, timezone = ?8, status = ?9,
640
- next_run_at = CASE WHEN ?14 = 1 AND next_run_at IS NOT NULL THEN next_run_at ELSE ?10 END,
641
- misfire_policy = ?11, revision = revision + 1, updated_at = datetime('now') WHERE schedule_id = ?12 AND revision = ?13`,
642
- [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],
643
- );
644
- if (updated.changes !== 1) return jsonError("revision_conflict", 409);
645
- 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)!) });
730
+ return Response.json({ ok: true, schedule: decodeRow(patchSchedule(row, body)) });
646
731
  } catch (e: any) {
647
- const code = String(e?.message || "invalid_schedule");
648
- return jsonError(code, code === "target_node_not_found" ? 404 : 400);
732
+ if (e instanceof ScheduleInputError) return jsonError(e.code, e.status, e.extra);
733
+ throw e;
649
734
  }
650
735
  }
651
736
  return jsonError("method_not_allowed", 405);
package/src/server.ts CHANGED
@@ -39,7 +39,7 @@ import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MO
39
39
  import { validateAvatarUrl } from "./avatar-validate.js";
40
40
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
41
41
  import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
42
- import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
42
+ import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, getUserTokenMeta, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
43
43
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
44
44
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
45
45
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
@@ -78,6 +78,7 @@ import { resolveRestFromSession } from "./rest-identity.js";
78
78
  import { resolveNodeCaller } from "./create-node.js";
79
79
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
80
80
  import { diagnoseTask } from "./task-diagnostic.js";
81
+ import { listTaskLateReplies } from "./task-late-replies.js";
81
82
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
82
83
  import { startDueReminderTimer } from "./requirement-due-reminders.js";
83
84
  import { handleRequirementsRequest } from "./requirements.js";
@@ -1376,7 +1377,7 @@ return Bun.serve({
1376
1377
  }
1377
1378
  try {
1378
1379
  const body = await req.json() as any;
1379
- const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent") } });
1380
+ const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent"), kind: body.client_kind } });
1380
1381
  if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
1381
1382
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1382
1383
  } catch (e: any) {
@@ -1404,7 +1405,7 @@ return Bun.serve({
1404
1405
  { status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
1405
1406
  ));
1406
1407
  }
1407
- const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent") });
1408
+ const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent"), kind: body.client_kind });
1408
1409
  if (result.ok) {
1409
1410
  sharedLoginFailureLockout.recordSuccess(body.username);
1410
1411
  logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
@@ -1473,9 +1474,13 @@ return Bun.serve({
1473
1474
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1474
1475
  try {
1475
1476
  const body = await req.json() as any;
1476
- const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
1477
+ // #711 不带 keep_cli_tokens 的请求(旧 app、dashboard 代理)= 改密码前的行为:撤销全部不绑网络的令牌。
1478
+ // keep_cli_tokens 必须是布尔 true 才保留命令行 / 脚本令牌。
1479
+ const meta = resolved.tokenId ? getUserTokenMeta(resolved.user.user_id, resolved.tokenId) : null;
1480
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true });
1477
1481
  if (result.ok) {
1478
- const issued = issueUserToken(resolved.user.user_id, "password-change");
1482
+ // 换发的新令牌沿用当前这条的种类和设备标识:anet passwd 换完仍是命令行令牌。
1483
+ const issued = issueUserToken(resolved.user.user_id, "password-change", { kind: meta?.kind, clientLabel: meta?.client_label, userAgent: meta?.user_agent });
1479
1484
  if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
1480
1485
  logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
1481
1486
  return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
@@ -3965,7 +3970,9 @@ return Bun.serve({
3965
3970
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
3966
3971
  const alias = url.searchParams.get("alias")?.trim() || null;
3967
3972
  const params: any[] = [since];
3968
- let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id FROM inbox WHERE created_at >= ?1";
3973
+ let sql = `SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id,
3974
+ CASE WHEN EXISTS(SELECT 1 FROM task_late_replies lr WHERE lr.inbox_id=inbox.id) THEN 1 ELSE 0 END AS late
3975
+ FROM inbox WHERE created_at >= ?1`;
3969
3976
  if (alias) {
3970
3977
  params.push(alias);
3971
3978
  sql += ` AND session_name = ?${params.length}`;
@@ -3973,7 +3980,10 @@ return Bun.serve({
3973
3980
  sql = addOwnTrafficScope(sql, params, restScope, { from: "from_session", to: "session_name" });
3974
3981
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
3975
3982
  params.push(limit);
3976
- const rows = db.all(sql, ...params);
3983
+ const rows = db.all<any>(sql, ...params).map((row) => {
3984
+ const { late, ...rest } = row;
3985
+ return { ...rest, ...(late === 1 || late === true ? { late: true } : {}) };
3986
+ });
3977
3987
 
3978
3988
  let pendingCount: number | undefined;
3979
3989
  if (alias) {
@@ -4815,6 +4825,7 @@ return Bun.serve({
4815
4825
  liveSseConnections,
4816
4826
  targetEverReportedRuntimeEvidence,
4817
4827
  });
4828
+ task.late_replies = listTaskLateReplies(db, taskId, taskNetworkId);
4818
4829
  return withCors(req, Response.json({ ok: true, task, diagnostic }));
4819
4830
  }
4820
4831
 
@@ -4861,7 +4872,15 @@ return Bun.serve({
4861
4872
  sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
4862
4873
  params.push(limit);
4863
4874
 
4864
- const rows = db.all(sql, ...params);
4875
+ const rows = db.all<any>(sql, ...params);
4876
+ // Existing desktop clients implement their one-task detail read as
4877
+ // `/api/tasks?task_id=…&limit=1`. Keep broad list responses lean, but
4878
+ // enrich that exact-detail shape as well as `/api/tasks/:id`.
4879
+ if (taskId) {
4880
+ for (const row of rows) {
4881
+ row.late_replies = listTaskLateReplies(db, String(row.task_id), typeof row.network_id === "string" ? row.network_id : null);
4882
+ }
4883
+ }
4865
4884
  if (skipStats) {
4866
4885
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
4867
4886
  }
@@ -17,27 +17,37 @@ import {
17
17
 
18
18
  const HUB_PATH = join(import.meta.dir, "reserved-env.ts");
19
19
  const DAEMON_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "reserved-env.ts");
20
+ const CLI_PATH = join(import.meta.dir, "..", "..", "..", "agent-network", "src", "shared", "reserved-env.ts");
20
21
 
21
22
  describe("RFC-026 v4 §4.4.7 G9 — hub/daemon reserved-env drift guard", () => {
22
23
  test("byte-identical source files", () => {
23
24
  const hub = readFileSync(HUB_PATH, "utf-8");
24
25
  const daemon = readFileSync(DAEMON_PATH, "utf-8");
26
+ const cli = readFileSync(CLI_PATH, "utf-8");
25
27
  expect(daemon).toBe(hub);
28
+ expect(cli).toBe(hub);
26
29
  });
27
30
 
28
31
  test("imported constants are set-equal at runtime", async () => {
29
32
  // Use a dynamic file import path so this test breaks if the file
30
33
  // moves but still references the daemon copy.
31
34
  const daemonMod = await import(DAEMON_PATH);
35
+ const cliMod = await import(CLI_PATH);
32
36
  const daemonExact = daemonMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
33
37
  const daemonPrefixes = daemonMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
38
+ const cliExact = cliMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
39
+ const cliPrefixes = cliMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
34
40
 
35
41
  // EXACT set equality
36
42
  expect(daemonExact.size).toBe(HUB_EXACT.size);
37
43
  for (const k of HUB_EXACT) expect(daemonExact.has(k)).toBe(true);
38
44
  for (const k of daemonExact) expect(HUB_EXACT.has(k)).toBe(true);
45
+ expect(cliExact.size).toBe(HUB_EXACT.size);
46
+ for (const k of HUB_EXACT) expect(cliExact.has(k)).toBe(true);
47
+ for (const k of cliExact) expect(HUB_EXACT.has(k)).toBe(true);
39
48
 
40
49
  // PREFIX array order-independent equality
41
50
  expect(new Set(daemonPrefixes)).toEqual(new Set(HUB_PREFIXES));
51
+ expect(new Set(cliPrefixes)).toEqual(new Set(HUB_PREFIXES));
42
52
  });
43
53
  });
@@ -0,0 +1,54 @@
1
+ import type { DbAdapter } from "./db-adapter";
2
+
3
+ type StoredLateReply = {
4
+ late_reply_id: string;
5
+ task_id: string;
6
+ from_node_id: string;
7
+ thread_id: string;
8
+ turn_id: string;
9
+ status: "replied" | "failed" | "cancelled";
10
+ result: string;
11
+ meta_json: string | null;
12
+ created_at: string;
13
+ };
14
+
15
+ function parsedMeta(raw: string | null): Record<string, unknown> | null {
16
+ if (!raw) return null;
17
+ try {
18
+ const value = JSON.parse(raw);
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as Record<string, unknown>
21
+ : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** Stable detail projection. Storage routing ids stay private. */
28
+ export function listTaskLateReplies(database: DbAdapter, taskId: string, networkId: string | null) {
29
+ const params: unknown[] = [taskId];
30
+ let sql = `SELECT late_reply_id,task_id,from_node_id,thread_id,turn_id,status,result,meta_json,created_at
31
+ FROM task_late_replies WHERE task_id=?1`;
32
+ if (networkId) {
33
+ params.push(networkId);
34
+ sql += " AND network_id=?2";
35
+ } else {
36
+ sql += " AND network_id IS NULL";
37
+ }
38
+ sql += " ORDER BY created_at,late_reply_id";
39
+ return database.all<StoredLateReply>(sql, ...params).map((row) => {
40
+ const meta = parsedMeta(row.meta_json);
41
+ return {
42
+ late_reply_id: row.late_reply_id,
43
+ task_id: row.task_id,
44
+ from_node_id: row.from_node_id,
45
+ thread_id: row.thread_id,
46
+ turn_id: row.turn_id,
47
+ status: row.status,
48
+ result: row.result,
49
+ created_at: row.created_at,
50
+ late: true as const,
51
+ ...(meta ? { meta } : {}),
52
+ };
53
+ });
54
+ }