@sleep2agi/commhub-server 0.9.0-preview.72 → 0.9.0-preview.74

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.
@@ -2,11 +2,16 @@
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { createHash } from "node:crypto";
4
4
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
5
- import { db } from "./db.js";
6
- import { normalizeTags, storedTags } from "./requirement-tags.js";
5
+ import { db, logAudit } from "./db.js";
6
+ import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
7
+ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
7
8
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
8
9
  import { isAgentRestricted, visibleAgents } from "./agent-access.js";
9
- import { ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
10
+ import {
11
+ addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
12
+ isTaskScoped, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
13
+ } from "./task-access.js";
14
+ import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
10
15
 
11
16
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
12
17
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
@@ -15,6 +20,8 @@ migrateRequirementAgentOwners(db);
15
20
  ensureRequirementProjects(db);
16
21
  ensureRequirementIndexes(db);
17
22
  ensureRequirementSeq(db);
23
+ ensureRequirementCompletedAt(db);
24
+ ensureNetworkTags(db);
18
25
 
19
26
  type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
20
27
 
@@ -61,12 +68,36 @@ type Row = {
61
68
  tags_json: string | null;
62
69
  created_at: string;
63
70
  seq: number | null;
71
+ completed_at: string | null;
72
+ completed_by_json: string | null;
73
+ completed_at_approx: number | null;
64
74
  };
65
75
 
66
76
  function jsonError(error: string, status: number): Response {
67
77
  return Response.json({ ok: false, error }, { status });
68
78
  }
69
79
 
80
+ // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
81
+ // 节点令牌(Agent)、Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
82
+ function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
83
+ if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
84
+ return { userId: ctx.auth.userId };
85
+ }
86
+ /** 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。 */
87
+ function taskDenied(ctx: RequirementsRequestContext, row: Row, error: "task_read_only" | "task_delete_denied"): Response {
88
+ if (ctx.auth && shouldAuditDenied(ctx.auth.userId, row.requirement_id)) {
89
+ logAudit(ctx.auth.userId, ctx.auth.username || null, "task_access_denied", "requirement", row.requirement_id, JSON.stringify({ error }), undefined, row.network_id);
90
+ }
91
+ return jsonError(error, 403);
92
+ }
93
+ /** 父卡对调用者不可见 = 与不存在同一个错误(不给 scoped 成员留卡片存在性探测)。 */
94
+ function parentHidden(ctx: RequirementsRequestContext, parentId: string): boolean {
95
+ const caller = taskCaller(ctx);
96
+ if (!caller) return false;
97
+ const parent = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, parentId);
98
+ return !!parent && !canSeeTask(caller, parent);
99
+ }
100
+
70
101
  function dueOk(due: string): boolean {
71
102
  if (!DUE.test(due)) return false;
72
103
  const [y, m, d] = due.split("-").map(Number);
@@ -219,10 +250,15 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
219
250
  if (!networkId) return jsonError("network_id_required", 400);
220
251
  const one = url.pathname.match(/^\/api\/requirements\/projects\/([^/]+)$/);
221
252
  if (url.pathname === "/api/requirements/projects" && req.method === "GET") {
222
- const rows = db.all<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1 ORDER BY sort, created_at`, networkId);
253
+ // scoped 成员只列授权给自己的项目(RFC-038 §9.3)。
254
+ const projectParams: unknown[] = [networkId];
255
+ const projectSql = addProjectVisibilityScope(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1`, projectParams, taskCaller(ctx), networkId);
256
+ const rows = db.all<ProjectRow>(`${projectSql} ORDER BY sort, created_at`, ...projectParams);
223
257
  return Response.json({ ok: true, projects: rows.map(projectPublic) });
224
258
  }
225
259
  if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
260
+ // 项目管理(建 / 改名 / 归档 / 删)只给不受任务范围限制的人。
261
+ if (taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId)) return jsonError("permission_denied", 403);
226
262
  let body: Record<string, unknown> = {};
227
263
  if (req.method === "POST" || req.method === "PATCH") {
228
264
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
@@ -248,6 +284,7 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
248
284
  // 先把卡片上的引用置空,再删项目:卡片一张不少。
249
285
  db.run("UPDATE requirements SET project_id = NULL WHERE project_id = ?1", [current.project_id]);
250
286
  db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
287
+ deleteTaskGrantsForProject(current.project_id);
251
288
  return Response.json({ ok: true });
252
289
  }
253
290
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -274,6 +311,65 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
274
311
  return Response.json({ ok: true, project: projectPublic(db.get<ProjectRow>(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE project_id = ?1`, current.project_id)!) });
275
312
  }
276
313
 
314
+ // ── 标签管理(RFC-038 §9 的取舍:同项目管理) ──
315
+ // 改名 / 合并 / 删除要改写网络里**每一张**带这个标签的卡,包括调用者看不见的;所以只给不受任务范围限制的人
316
+ // (网络 owner / 管理员 / task_access='all' 的成员),scoped 成员一律 403 —— 不做「只改我看得见的那几张」:
317
+ // 那样同一个标签会被拆成两半,而且改动条数会泄露看不见的卡有几张。节点令牌在入口已被拒(tags_write)。
318
+ function canManageTags(ctx: RequirementsRequestContext, networkId: string): boolean {
319
+ if (ctx.isNodeToken || !canWrite(ctx, networkId)) return false;
320
+ return !(taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId));
321
+ }
322
+
323
+ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
324
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
325
+ if (!networkId) return jsonError("network_id_required", 400);
326
+ if (!canManageTags(ctx, networkId)) return jsonError("permission_denied", 403);
327
+ let body: Record<string, unknown>;
328
+ try { body = await bodyObject(ctx.req); } catch { return jsonError("invalid_json", 400); }
329
+ const op = parseTagOp(body);
330
+ if ("error" in op) return jsonError(op.error, 400);
331
+ const now = new Date().toISOString();
332
+ const actor = JSON.stringify(actorOf(ctx));
333
+ // 读-改-写整个网络的标签在一个事务里:中途失败一张都不改;颜色表跟着一起挪。
334
+ const result = db.transaction(() => {
335
+ const cards = db.all<{ requirement_id: string; tags_json: string | null }>(
336
+ "SELECT requirement_id, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
337
+ );
338
+ const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
339
+ const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
340
+ const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null);
341
+ if (!known) return null;
342
+ if (op.op === "color") {
343
+ if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
344
+ else db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (network_id, name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at", [networkId, op.tag, op.color, now]);
345
+ return { affected: 0 };
346
+ }
347
+ let affected = 0;
348
+ for (const card of cards) {
349
+ const next = applyTagOp(storedTags(card.tags_json), op);
350
+ if (!next) continue;
351
+ db.run("UPDATE requirements SET tags_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(next), now, actor, card.requirement_id]);
352
+ affected++;
353
+ }
354
+ moveTagColors(networkId, op, colorOf, now);
355
+ return { affected };
356
+ });
357
+ if (!result) return jsonError("tag_not_found", 404);
358
+ if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
359
+ return Response.json({ ok: true, op: op.op, affected: result.affected });
360
+ }
361
+
362
+ /** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
363
+ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) => string | null, now: string): void {
364
+ if (op.op === "color") return;
365
+ const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
366
+ if (op.op !== "delete" && colorOf(op.to) === null) {
367
+ const inherited = sources.map(colorOf).find(color => color !== null);
368
+ if (inherited) db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4)", [networkId, op.to, inherited, now]);
369
+ }
370
+ for (const tag of sources) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, tag]);
371
+ }
372
+
277
373
  function toPublic(row: Row) {
278
374
  return {
279
375
  owner: row.owner_json ? JSON.parse(row.owner_json) : null,
@@ -309,6 +405,11 @@ function toPublic(row: Row) {
309
405
  // 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
310
406
  parent_id: row.parent_id || null,
311
407
  children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
408
+ // 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
409
+ // 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
410
+ completedAt: row.completed_at || null,
411
+ completedAtApprox: !!row.completed_at_approx,
412
+ completedBy: row.completed_by_json ? JSON.parse(row.completed_by_json) : null,
312
413
  };
313
414
  }
314
415
 
@@ -348,8 +449,14 @@ function isHiddenRef(ref: unknown, hidden: HiddenNode): boolean {
348
449
  const r = ref as { kind?: unknown; id?: unknown };
349
450
  return r.kind === "node" && typeof r.id === "string" && hidden(r.id);
350
451
  }
452
+ // 每个请求一个权限解析器(按网络缓存成员行与项目授权)。
453
+ const permsByCtx = new WeakMap<RequirementsRequestContext, ReturnType<typeof taskPermissionsResolver>>();
351
454
  function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
352
- const pub = toPublic(row);
455
+ let perms = permsByCtx.get(ctx);
456
+ if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
457
+ // viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
458
+ const can = perms(row);
459
+ const pub = can ? { ...toPublic(row), viewer_can: can } : toPublic(row);
353
460
  const hidden = hiddenNodeFilter(ctx, row.network_id);
354
461
  if (!hidden) return pub;
355
462
  return {
@@ -359,10 +466,11 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
359
466
  participants: Array.isArray(pub.participants) ? pub.participants.filter((ref: unknown) => !isHiddenRef(ref, hidden)) : pub.participants,
360
467
  created_by: isHiddenRef(pub.created_by, hidden) ? null : pub.created_by,
361
468
  updated_by: isHiddenRef(pub.updated_by, hidden) ? null : pub.updated_by,
469
+ completedBy: isHiddenRef(pub.completedBy, hidden) ? null : pub.completedBy,
362
470
  };
363
471
  }
364
472
 
365
- const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, " +
473
+ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, completed_at, completed_by_json, completed_at_approx, " +
366
474
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
367
475
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
368
476
 
@@ -428,6 +536,7 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
428
536
  const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
429
537
  function operationOf(req: Request, url: URL): string {
430
538
  if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
539
+ if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
431
540
  if (req.method === "GET") return "read";
432
541
  if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
433
542
  if (url.pathname === "/api/requirements/upsert") return "upsert";
@@ -457,8 +566,9 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
457
566
  return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
458
567
  }
459
568
 
569
+ // tag_ops:有 POST /api/requirements/tags/ops(标签改名 / 合并 / 删除 / 颜色),GET /api/requirements/tags 带 counts / colors / can_manage。
460
570
  // search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
461
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging"] as const;
571
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops"] as const;
462
572
 
463
573
  // ── 子需求 ──
464
574
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -579,18 +689,24 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
579
689
  if (description === null) return jsonError("invalid_description", 400);
580
690
  const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
581
691
  if (checklist === null) return jsonError("invalid_checklist", 400);
692
+ const caller = taskCaller(ctx);
693
+ const scoped = !!caller && isTaskScoped(caller.userId, networkId);
582
694
  let projectId: string | null = null;
583
695
  if (body.project_id !== undefined) {
584
696
  try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
697
+ // scoped 成员只能建进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
698
+ if (!canUseProject(caller, networkId, projectId)) return jsonError("project_not_in_network", 400);
585
699
  }
586
700
  const ref = body.external_ref === undefined ? null : externalRef(body.external_ref);
587
701
  if (ref === undefined) return jsonError("invalid_external_ref", 400);
702
+ // scoped 成员不能带 external_ref:「external_ref_exists」会把别人的卡号回给他。
703
+ if (ref && scoped) return jsonError("external_ref_not_allowed", 403);
588
704
  const extUrl = body.external_url === undefined ? null : externalUrl(body.external_url);
589
705
  if (extUrl === undefined) return jsonError("invalid_external_url", 400);
590
706
  const parentId = body.parent_id === undefined ? null : parentIdOf(body.parent_id);
591
707
  if (parentId === undefined) return jsonError("invalid_parent_id", 400);
592
708
  if (parentId) {
593
- const err = parentError(networkId, parentId, null);
709
+ const err = parentHidden(ctx, parentId) ? "parent_not_found" : parentError(networkId, parentId, null);
594
710
  if (err) return jsonError(err, 400);
595
711
  }
596
712
  if (ref) {
@@ -600,7 +716,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
600
716
  }
601
717
  if (clientId) {
602
718
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
603
- if (existing) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
719
+ // 重放只回调用者看得见的卡;撞上别人的卡(scoped 看不见)= 固定的 409,不把那张卡回给他。
720
+ if (existing) return canSeeTask(caller, existing) ? Response.json({ ok: true, requirement: toPublicFor(ctx, existing) }) : jsonError("client_id_taken", 409);
604
721
  }
605
722
  const id = `req_${crypto.randomUUID()}`;
606
723
  const createdAt = new Date().toISOString();
@@ -611,9 +728,11 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
611
728
  // 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
612
729
  db.transaction(() => db.run(
613
730
  `INSERT INTO requirements
614
- (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq)
615
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24)`,
616
- [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, nextRequirementSeq(db, networkId)],
731
+ (requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq, completed_at, completed_by_json, completed_at_approx)
732
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24, ?25, ?26, 0)`,
733
+ [id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, nextRequirementSeq(db, networkId),
734
+ // 直接建在「完成」列 = 此刻完成、由建卡的人完成。
735
+ column === "done" ? createdAt : null, column === "done" ? actor : null],
617
736
  ));
618
737
  } catch {
619
738
  // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
@@ -624,6 +743,7 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
624
743
  if (!clientId) return jsonError("insert_failed", 500);
625
744
  const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
626
745
  if (!existing) return jsonError("insert_failed", 500);
746
+ if (!canSeeTask(caller, existing)) return jsonError("client_id_taken", 409);
627
747
  return Response.json({ ok: true, requirement: toPublicFor(ctx, existing) });
628
748
  }
629
749
  const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
@@ -636,6 +756,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
636
756
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
637
757
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
638
758
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
759
+ const caller = taskCaller(ctx);
760
+ if (!canEditTask(caller, row)) return taskDenied(ctx, row, "task_read_only");
761
+ // scoped 成员不能写 external_ref:唯一索引冲突会泄露别的卡用了这个 ref。
762
+ if (has("external_ref") && caller && isTaskScoped(caller.userId, row.network_id)) return jsonError("external_ref_not_allowed", 403);
639
763
  if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
640
764
  const issues = has("issues") ? normalizeIssues(body.issues) : null;
641
765
  if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
@@ -679,6 +803,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
679
803
  let projectId = row.project_id;
680
804
  if (has("project_id")) {
681
805
  try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
806
+ // scoped 成员只能挪进自己 can_edit 的项目;没授权的项目与不存在的项目同一个错误。
807
+ if (projectId !== row.project_id && !canUseProject(caller, row.network_id, projectId)) return jsonError("project_not_in_network", 400);
682
808
  }
683
809
  let ref = row.external_ref;
684
810
  if (has("external_ref")) {
@@ -701,7 +827,7 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
701
827
  const next = parentIdOf(body.parent_id);
702
828
  if (next === undefined) return jsonError("invalid_parent_id", 400);
703
829
  if (next) {
704
- const err = parentError(row.network_id, next, row.requirement_id);
830
+ const err = parentHidden(ctx, next) ? "parent_not_found" : parentError(row.network_id, next, row.requirement_id);
705
831
  if (err) return jsonError(err, 400);
706
832
  }
707
833
  parentId = next;
@@ -712,12 +838,20 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
712
838
  archived = body.archived ? 1 : 0;
713
839
  }
714
840
  const updatedAt = new Date().toISOString();
841
+ const column = has("column") ? String(body.column) : row.column_name;
842
+ const actor = JSON.stringify(actorOf(ctx));
843
+ // 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
844
+ const completed = column !== "done" ? { at: null, by: null, approx: 0 }
845
+ : row.column_name !== "done" ? { at: updatedAt, by: actor, approx: 0 }
846
+ : { at: row.completed_at, by: row.completed_by_json, approx: row.completed_at_approx ? 1 : 0 };
715
847
  db.run(
716
848
  `UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
717
- agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20, start_on = ?21
849
+ agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20, start_on = ?21,
850
+ completed_at = ?22, completed_by_json = ?23, completed_at_approx = ?24
718
851
  WHERE requirement_id = ?3`,
719
- [has("column") ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
720
- has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null],
852
+ [column, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
853
+ has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, actor, parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null,
854
+ completed.at, completed.by, completed.approx],
721
855
  );
722
856
  const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
723
857
  return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
@@ -748,6 +882,8 @@ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response
748
882
  const params: unknown[] = [seq ?? id];
749
883
  let sql = `SELECT ${SELECT} FROM requirements WHERE ${seq === null ? "requirement_id" : "seq"} = ?1`;
750
884
  sql = addHumanNetworkScope(sql, params, ctx.scope);
885
+ // 看不见的卡与不存在的卡同一个 404(逐字节相同);#N 的 ambiguous_seq 也只在看得见的行里判定。
886
+ sql = addTaskVisibilityScope(sql, params, taskCaller(ctx));
751
887
  const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
752
888
  if (rows.length > 1) {
753
889
  const networks = rows.map(r => r.network_id);
@@ -869,9 +1005,40 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
869
1005
  if (url.pathname === "/api/requirements/tags" && req.method === "GET") {
870
1006
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
871
1007
  if (!networkId) return jsonError("network_id_required", 400);
872
- const rows = db.all<{ tags_json: string | null }>("SELECT tags_json FROM requirements WHERE network_id=?1", networkId);
873
- const tags = [...new Set(rows.flatMap(row => storedTags(row.tags_json)))].sort();
874
- return Response.json({ ok: true, networkId, tags });
1008
+ const tagParams: unknown[] = [networkId];
1009
+ const tagSql = addTaskVisibilityScope("SELECT tags_json FROM requirements WHERE network_id=?1", tagParams, taskCaller(ctx));
1010
+ const rows = db.all<{ tags_json: string | null }>(tagSql, ...tagParams);
1011
+ // counts:调用者看得见的卡里(含归档)每个标签用了几张;colors:只回这些标签的颜色(看不见的卡上的标签不外泄)。
1012
+ // can_manage:这个调用者能不能用 /tags/ops。旧 App 只读 tags,多出的字段忽略。
1013
+ const counts: Record<string, number> = {};
1014
+ for (const row of rows) for (const tag of storedTags(row.tags_json)) counts[tag] = (counts[tag] ?? 0) + 1;
1015
+ const tags = Object.keys(counts).sort();
1016
+ const colors: Record<string, string> = {};
1017
+ for (const row of db.all<{ name: string; color: string }>("SELECT name, color FROM network_tags WHERE network_id = ?1", networkId)) {
1018
+ if (counts[row.name]) colors[row.name] = row.color;
1019
+ }
1020
+ return Response.json({ ok: true, networkId, tags, counts, colors, can_manage: canManageTags(ctx, networkId) });
1021
+ }
1022
+
1023
+ if (url.pathname === "/api/requirements/tags/ops") {
1024
+ if (req.method !== "POST") return jsonError("not_found", 404);
1025
+ return handleTagOp(ctx);
1026
+ }
1027
+
1028
+ // 仪表盘聚合(requirements-stats.ts):一个网络、调用者看得见的卡(与列表同一个可见范围),含归档的卡 ——
1029
+ // 完成的卡常被归档,不算它们「今天完成了多少」就会偏少。
1030
+ if (url.pathname === "/api/requirements/stats" && req.method === "GET") {
1031
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
1032
+ if (!networkId) return jsonError("network_id_required", 400);
1033
+ const sq = parseStatsQuery(url.searchParams, Date.now());
1034
+ if ("error" in sq) return jsonError(sq.error, 400);
1035
+ const statsParams: unknown[] = [networkId];
1036
+ const statsSql = addTaskVisibilityScope(
1037
+ "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
1038
+ statsParams, taskCaller(ctx),
1039
+ );
1040
+ const rows = db.all<StatsRow>(statsSql, ...statsParams);
1041
+ return conditionalJson(ctx.req, { ok: true, networkId, ...aggregateStats(rows, sq, hiddenNodeFilter(ctx, networkId)) });
875
1042
  }
876
1043
 
877
1044
  if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
@@ -892,6 +1059,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
892
1059
  // 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
893
1060
  let sql = "FROM requirements WHERE 1=1";
894
1061
  sql = addHumanNetworkScope(sql, params, ctx.scope);
1062
+ sql = addTaskVisibilityScope(sql, params, taskCaller(ctx));
895
1063
  const filtered = listFilters(url, sql, params, ctx);
896
1064
  if (typeof filtered !== "string") return filtered;
897
1065
  // q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
@@ -919,6 +1087,8 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
919
1087
  const networkId = writeNetwork(body, ctx);
920
1088
  if (!networkId) return jsonError("network_id_required", 400);
921
1089
  if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
1090
+ // scoped 成员不能用同步接口:external_ref 的唯一索引会把「这个 ref 已经有卡」泄露出去。固定错误,不看 ref。
1091
+ if (taskCaller(ctx) && isTaskScoped(ctx.auth!.userId, networkId)) return jsonError("upsert_not_allowed", 403);
922
1092
  const existing = rowByExternalRef(networkId, ref);
923
1093
  if (!existing) {
924
1094
  const res = await createRequirement(ctx, body);
@@ -943,6 +1113,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
943
1113
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
944
1114
  if (row instanceof Response) return row;
945
1115
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1116
+ if (!canEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
946
1117
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
947
1118
  const items = storedChecklist(row.checklist_json);
948
1119
  const itemId = decodeURIComponent(itemMatch[2]);
@@ -966,9 +1137,12 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
966
1137
  const row = scopedRow(ctx, id);
967
1138
  if (row instanceof Response) return row;
968
1139
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1140
+ if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
969
1141
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
970
1142
  db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
971
1143
  db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
1144
+ // 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
1145
+ if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
972
1146
  return Response.json({ ok: true, deleted: row.requirement_id });
973
1147
  }
974
1148
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -31,7 +31,7 @@ const GOLDEN_RESPONSE_KEYS = {
31
31
  // —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
32
32
  authMeNetwork: [
33
33
  "network_id", "network_name", "owner_id", "description", "settings",
34
- "created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access",
34
+ "created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access", "task_access",
35
35
  ],
36
36
  networkDetail: [
37
37
  "network_id", "network_name", "owner_id", "description", "settings",
@@ -0,0 +1,212 @@
1
+ // 定时任务的回复要进建排程那个人的未读(Vincent 2026-09-30「定时任务触发的…那个红点它是不显示的」)。
2
+ //
3
+ // scheduler 派的任务 from_name='scheduler',回复以前按 from_name 落进 inbox(session_name='scheduler'):
4
+ // app 会话里看得到(会话取自 tasks 表),但 unread_by_agent 只数 session_name=用户名 的行 ⇒ 永远没红点。
5
+ // 这里走真 Hub(bootServer)+ 真节点令牌回复路径(send_reply,callerTokenIsNetwork=true)。
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
9
+ import { db } from "./db.js";
10
+ import { runDueScheduledTasks } from "./scheduled-tasks.js";
11
+ import { registerTools } from "./tools.js";
12
+
13
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
14
+ if (!activeDbPath) throw new Error("scheduled-reply-unread requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
15
+
16
+ let server: any;
17
+ let base = "";
18
+ let ownerToken = "";
19
+ let ownerName = "";
20
+ let memberToken = "";
21
+ let memberName = "";
22
+ let networkId = "";
23
+ let ownerId = "";
24
+ const stamp = Date.now();
25
+ const ALIAS = `sched-reply-node-${stamp}`;
26
+ const nodeId = `n_sched_reply_${stamp}`;
27
+
28
+ async function api(token: string, path: string, init?: RequestInit) {
29
+ const res = await fetch(`${base}${path}`, {
30
+ ...init,
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
32
+ });
33
+ return { status: res.status, body: await res.json() as any };
34
+ }
35
+
36
+ /** 节点令牌身份调 send_reply(与真节点的 commhub_reply 同一个 handler、同一组归属校验)。 */
37
+ async function nodeReply(args: Record<string, unknown>): Promise<any> {
38
+ const mcp = new McpServer({ name: "sched-reply", version: "0" }) as any;
39
+ const handlers: Record<string, (a: any) => Promise<any>> = {};
40
+ const original = mcp.tool.bind(mcp);
41
+ mcp.tool = (name: string, ...rest: any[]) => {
42
+ const handler = rest.at(-1);
43
+ if (typeof handler === "function") handlers[name] = handler;
44
+ return original(name, ...rest);
45
+ };
46
+ registerTools(mcp, undefined, networkId, ownerId, ALIAS, true, null);
47
+ const out = await handlers.send_reply(args);
48
+ return JSON.parse(out.content[0].text);
49
+ }
50
+
51
+ async function unreadFor(token: string): Promise<Record<string, number>> {
52
+ const res = await api(token, "/api/messages?scope=user&limit=50");
53
+ expect(res.status).toBe(200);
54
+ return res.body.unread_by_agent ?? {};
55
+ }
56
+
57
+ async function ackAgent(token: string) {
58
+ const res = await api(token, "/api/messages/ack", { method: "POST", body: JSON.stringify({ agent: ALIAS }) });
59
+ expect(res.status).toBe(200);
60
+ return res.body;
61
+ }
62
+
63
+ /** 建一个排程、让它立刻到期、跑一次,返回它派出的 task_id。 */
64
+ async function fireSchedule(token: string, name: string): Promise<{ scheduleId: string; taskId: string }> {
65
+ const created = await api(token, "/api/scheduled-tasks", {
66
+ method: "POST",
67
+ body: JSON.stringify({
68
+ network_id: networkId, name, target_node_id: nodeId, task: `${name} tick`,
69
+ timezone: "UTC", schedule: { type: "interval", every_seconds: 120 },
70
+ }),
71
+ });
72
+ expect(created.status).toBe(201);
73
+ const scheduleId = created.body.schedule.schedule_id as string;
74
+ // 别的排程不许在这次 sweep 里一起跑
75
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id != ?2", [new Date(Date.now() + 24 * 3600_000).toISOString(), scheduleId]);
76
+ db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [new Date(Date.now() - 60_000).toISOString(), scheduleId]);
77
+ await runDueScheduledTasks();
78
+ const run = db.get<{ task_id: string | null }>(
79
+ "SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1 ORDER BY scheduled_for DESC LIMIT 1", scheduleId,
80
+ );
81
+ expect(run?.task_id).toBeTruthy();
82
+ const task = db.get<{ from_name: string }>("SELECT from_name FROM tasks WHERE task_id = ?1", run!.task_id!);
83
+ expect(task?.from_name).toBe("scheduler");
84
+ return { scheduleId, taskId: run!.task_id! };
85
+ }
86
+
87
+ function replyRow(taskId: string) {
88
+ return db.get<{ session_name: string; from_session: string; acked: number }>(
89
+ "SELECT session_name, from_session, acked FROM inbox WHERE type = 'reply' AND in_reply_to = ?1", taskId,
90
+ );
91
+ }
92
+
93
+ beforeAll(async () => {
94
+ ownerName = `sched_reply_owner_${stamp}`;
95
+ const owner = register(ownerName, "SchedReplyOwner123!", undefined, "seed");
96
+ expect(owner.ok).toBe(true);
97
+ ownerToken = owner.token!;
98
+ networkId = owner.network_id!;
99
+ ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
100
+
101
+ memberName = `sched_reply_member_${stamp}`;
102
+ const member = register(memberName, "SchedReplyMember123!", undefined, "seed");
103
+ expect(member.ok).toBe(true);
104
+ memberToken = member.token!;
105
+ const memberId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", memberName)!.user_id;
106
+ expect(addNetworkMember(networkId, memberId, "member", ownerId, { agentAccess: "all" }).ok).toBe(true);
107
+
108
+ expect(createNetworkTokenForNode(ownerId, networkId, ALIAS).ok).toBe(true);
109
+ db.run(
110
+ "INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
111
+ [nodeId, ALIAS, networkId],
112
+ );
113
+ db.run(
114
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)",
115
+ [`r_sched_reply_${stamp}`, ALIAS, nodeId, networkId],
116
+ );
117
+ const mod: any = await import("./server.js");
118
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
119
+ base = `http://127.0.0.1:${server.port}`;
120
+ }, 30_000);
121
+
122
+ afterAll(() => {
123
+ try { server?.stop?.(true); } catch {}
124
+ });
125
+
126
+ describe("scheduled-task replies reach the schedule creator's unread", () => {
127
+ test("a direct reply still lands on the sender and counts once", async () => {
128
+ await ackAgent(ownerToken);
129
+ const sent = await api(ownerToken, "/api/task", { method: "POST", body: JSON.stringify({ network_id: networkId, alias: ALIAS, task: "direct hello" }) });
130
+ expect(sent.status).toBe(200);
131
+ const taskId = sent.body.task_id as string;
132
+ const reply = await nodeReply({ in_reply_to: taskId, text: "direct answer", status: "replied" });
133
+ expect(reply.ok).toBe(true);
134
+ expect(replyRow(taskId)?.session_name).toBe(ownerName);
135
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
136
+ await ackAgent(ownerToken);
137
+ expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
138
+ });
139
+
140
+ test("a scheduled-task reply counts for the creator, and ack-by-agent clears it", async () => {
141
+ await ackAgent(ownerToken);
142
+ const { taskId } = await fireSchedule(ownerToken, "owner-every-2m");
143
+ // 节点不带 alias(SDK 运行时)——收件人由 in_reply_to 推出
144
+ const reply = await nodeReply({ in_reply_to: taskId, text: "tick answer", status: "replied" });
145
+ expect(reply.ok).toBe(true);
146
+ expect(replyRow(taskId)).toEqual({ session_name: ownerName, from_session: ALIAS, acked: 0 });
147
+ expect(db.get<{ status: string; result: string }>("SELECT status, result FROM tasks WHERE task_id = ?1", taskId))
148
+ .toEqual({ status: "replied", result: "tick answer" });
149
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
150
+
151
+ const acked = await ackAgent(ownerToken);
152
+ expect(acked.acked_inbox).toBe(1);
153
+ expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
154
+ expect(replyRow(taskId)?.acked).toBe(1);
155
+ });
156
+
157
+ test("a node that names alias='scheduler' explicitly is routed the same way", async () => {
158
+ await ackAgent(ownerToken);
159
+ const { taskId } = await fireSchedule(ownerToken, "owner-explicit-alias");
160
+ const reply = await nodeReply({ alias: "scheduler", in_reply_to: taskId, text: "explicit answer", status: "replied" });
161
+ expect(reply.ok).toBe(true);
162
+ expect(replyRow(taskId)?.session_name).toBe(ownerName);
163
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
164
+ await ackAgent(ownerToken);
165
+ });
166
+
167
+ test("another member of the network does not get the owner's scheduled reply, and gets their own", async () => {
168
+ await ackAgent(ownerToken);
169
+ await ackAgent(memberToken);
170
+ const ownerRun = await fireSchedule(ownerToken, "owner-only");
171
+ expect((await nodeReply({ in_reply_to: ownerRun.taskId, text: "for owner", status: "replied" })).ok).toBe(true);
172
+ expect((await unreadFor(memberToken))[ALIAS]).toBeUndefined();
173
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
174
+
175
+ const memberRun = await fireSchedule(memberToken, "member-only");
176
+ expect((await nodeReply({ in_reply_to: memberRun.taskId, text: "for member", status: "replied" })).ok).toBe(true);
177
+ expect(replyRow(memberRun.taskId)?.session_name).toBe(memberName);
178
+ expect((await unreadFor(memberToken))[ALIAS]).toBe(1);
179
+ expect((await unreadFor(ownerToken))[ALIAS]).toBe(1);
180
+ await ackAgent(ownerToken);
181
+ await ackAgent(memberToken);
182
+ });
183
+
184
+ test("a schedule with no creator falls back to 'scheduler'", async () => {
185
+ await ackAgent(ownerToken);
186
+ const { scheduleId, taskId } = await fireSchedule(ownerToken, "creatorless");
187
+ db.run("UPDATE scheduled_tasks SET created_by = NULL WHERE schedule_id = ?1", [scheduleId]);
188
+ expect((await nodeReply({ in_reply_to: taskId, text: "nobody's", status: "replied" })).ok).toBe(true);
189
+ expect(replyRow(taskId)?.session_name).toBe("scheduler");
190
+ expect((await unreadFor(ownerToken))[ALIAS]).toBeUndefined();
191
+ });
192
+
193
+ test("a creator whose username is also a node alias falls back to 'scheduler' (the collision guard)", async () => {
194
+ await ackAgent(ownerToken);
195
+ const { taskId } = await fireSchedule(ownerToken, "colliding-creator");
196
+ const clashNodeId = `n_clash_${stamp}`;
197
+ db.run(
198
+ "INSERT INTO nodes (node_id, node_name, alias, runtime, network_id) VALUES (?1, ?2, ?2, 'claude-code', ?3)",
199
+ [clashNodeId, ownerName, networkId],
200
+ );
201
+ try {
202
+ expect((await nodeReply({ in_reply_to: taskId, text: "not a todo", status: "replied" })).ok).toBe(true);
203
+ // 那个同名节点收不到这条(它不是节点的待办),用户名也拿不到(inbox 半边整体跳过)
204
+ expect(replyRow(taskId)?.session_name).toBe("scheduler");
205
+ expect(db.get<{ n: number }>(
206
+ "SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND in_reply_to = ?2", ownerName, taskId,
207
+ )?.n).toBe(0);
208
+ } finally {
209
+ db.run("DELETE FROM nodes WHERE node_id = ?1", [clashNodeId]);
210
+ }
211
+ });
212
+ });