@sleep2agi/commhub-server 0.9.0-preview.66 → 0.9.0-preview.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,15 @@
2
2
  // 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
3
3
  import { db } from "./db.js";
4
4
  import { addNetworkScope, canRestWriteNetwork, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
5
- import { ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
5
+ import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners } from "./requirements-migrate.js";
6
6
 
7
7
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
8
8
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
9
9
  migrateRequirementAgentOwners(db);
10
10
  ensureRequirementProjects(db);
11
+ ensureRequirementIndexes(db);
11
12
 
12
- type RequestAuth = { userId: string; networkId: string | null; username: string } | null;
13
+ type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
13
14
 
14
15
  export type RequirementsRequestContext = {
15
16
  req: Request;
@@ -30,6 +31,16 @@ type Row = {
30
31
  description: string | null;
31
32
  checklist_json: string | null;
32
33
  project_id: string | null;
34
+ external_ref: string | null;
35
+ external_url: string | null;
36
+ archived: number | null;
37
+ created_by: string | null;
38
+ created_by_json: string | null;
39
+ updated_by_json: string | null;
40
+ updated_at: string | null;
41
+ parent_id: string | null;
42
+ children_total: number | null;
43
+ children_done: number | null;
33
44
  participants_json: string;
34
45
  requirement_id: string;
35
46
  network_id: string;
@@ -271,7 +282,18 @@ function toPublic(row: Row) {
271
282
  due: row.due_on || "",
272
283
  column: row.column_name,
273
284
  createdAt: row.created_at,
285
+ updatedAt: row.updated_at || row.created_at,
274
286
  issues: storedIssues(row.issues_json),
287
+ // 同步用的外部引用(如 github:owner/repo#123)和链接;归档的卡默认不在列表里(include_archived=1 才有)。
288
+ external_ref: row.external_ref || null,
289
+ external_url: row.external_url || null,
290
+ archived: !!row.archived,
291
+ // 谁建的 / 谁最后改的:{kind:"user"|"node", id}。旧卡只有 created_by(用户 id)。
292
+ created_by: row.created_by_json ? JSON.parse(row.created_by_json) : row.created_by ? { kind: "user", id: row.created_by } : null,
293
+ updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
294
+ // 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
295
+ parent_id: row.parent_id || null,
296
+ children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
275
297
  };
276
298
  }
277
299
 
@@ -288,10 +310,14 @@ function writeNetwork(body: Record<string, unknown>, ctx: RequirementsRequestCon
288
310
  }
289
311
 
290
312
  function canWrite(ctx: RequirementsRequestContext, networkId: string | null): boolean {
291
- return !ctx.isNodeToken && canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
313
+ // 节点令牌只能写它绑定的那个网络(scope 已强制;这里再核一次,防止别的入口绕过 scope)。
314
+ if (ctx.isNodeToken && (!ctx.auth?.networkId || ctx.auth.networkId !== networkId)) return false;
315
+ return canRestWriteNetwork(ctx.auth, networkId, ctx.isAdmin);
292
316
  }
293
317
 
294
- const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id";
318
+ const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_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, " +
319
+ "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
320
+ "(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";
295
321
 
296
322
  type PersonRef = { kind: 'user' | 'node'; id: string };
297
323
  function personRef(value: unknown, networkId: string): PersonRef {
@@ -307,13 +333,22 @@ function personRef(value: unknown, networkId: string): PersonRef {
307
333
 
308
334
  // 负责人只能是人类,负责 Agent 只能是节点;参与人两种都行。库里旧的节点负责人照样读出来(启动迁移会挪走),
309
335
  // 只有这次请求显式写了 owner / agent_owner 才校验种类。
336
+ // 旧客户端兼容(App ≤ 0.2.142 只有一个「负责人」,可以选节点):请求里 owner 是节点、又**没带** agent_owner
337
+ // → 当成设置负责 Agent,负责人清空,照常 200,并回 owner_coerced_to_agent_owner: true。
338
+ // 带了 agent_owner 的就是新客户端,保持严格:owner 是节点 → 400 owner_must_be_human。
310
339
  function assignments(body: Record<string, unknown>, networkId: string, current?: Row) {
311
340
  let owner = current?.owner_json ? JSON.parse(current.owner_json) : null;
341
+ let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
342
+ let coerced: PersonRef | null = null;
312
343
  if ('owner' in body) {
313
344
  owner = body.owner === null ? null : personRef(body.owner, networkId);
314
- if (owner && owner.kind !== 'user') throw new Error('owner_must_be_human');
345
+ if (owner && owner.kind !== 'user') {
346
+ if ('agent_owner' in body) throw new Error('owner_must_be_human');
347
+ coerced = owner;
348
+ agentOwner = owner;
349
+ owner = null;
350
+ }
315
351
  }
316
- let agentOwner = current?.agent_owner_json ? JSON.parse(current.agent_owner_json) : null;
317
352
  if ('agent_owner' in body) {
318
353
  agentOwner = body.agent_owner === null ? null : personRef(body.agent_owner, networkId);
319
354
  if (agentOwner && agentOwner.kind !== 'node') throw new Error('agent_owner_must_be_agent');
@@ -325,29 +360,334 @@ function assignments(body: Record<string, unknown>, networkId: string, current?:
325
360
  participants = [...new Map(refs.map(ref => [`${ref.kind}:${ref.id}`, ref])).values()];
326
361
  }
327
362
  return {
363
+ coerced,
328
364
  ownerJson: owner === null ? null : JSON.stringify(owner),
329
365
  agentOwnerJson: agentOwner === null ? null : JSON.stringify(agentOwner),
330
366
  participantsJson: JSON.stringify(participants),
331
367
  };
332
368
  }
333
369
 
334
- /** 节点令牌可用的操作。空 = 与之前一样全部拒绝(本 PR 不放宽)。候选:'read' | 'patch' | 'checklist_item'。 */
335
- const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>();
370
+ /**
371
+ * 节点令牌(Agent,比如把 GitHub issue 同步进任务的 TM 节点)可用的操作 —— 只在它自己的网络里
372
+ * (令牌绑定的网络,范围由 resolveRestNetworkScope 强制)。删除卡片、管理项目仍然只给人。
373
+ */
374
+ const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "upsert", "projects_read"]);
336
375
  function operationOf(req: Request, url: URL): string {
376
+ if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
337
377
  if (req.method === "GET") return "read";
338
- if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return "projects";
339
378
  if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
379
+ if (url.pathname === "/api/requirements/upsert") return "upsert";
380
+ if (req.method === "DELETE") return "delete";
340
381
  if (req.method === "PATCH") return "patch";
341
382
  return "create";
342
383
  }
343
384
 
344
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime"] as const;
385
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements"] as const;
386
+
387
+ // ── 子需求 ──
388
+ // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
389
+ export const MAX_REQUIREMENT_DEPTH = 5;
390
+ function levelOf(id: string): number {
391
+ let level = 1;
392
+ let cur: string | null = id;
393
+ const seen = new Set<string>();
394
+ while (cur) {
395
+ if (seen.has(cur)) return Number.POSITIVE_INFINITY; // 已存在的环(不该有):当作超深,拒绝挂上去
396
+ seen.add(cur);
397
+ const up = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur);
398
+ cur = up?.parent_id ?? null;
399
+ if (cur) level++;
400
+ }
401
+ return level;
402
+ }
403
+ /** 以 id 为根的子树还有几层(叶子 = 0)。 */
404
+ function heightBelow(id: string): number {
405
+ let height = 0;
406
+ let frontier = [id];
407
+ const seen = new Set<string>([id]);
408
+ while (frontier.length && height <= MAX_REQUIREMENT_DEPTH) {
409
+ const next: string[] = [];
410
+ for (const f of frontier) {
411
+ for (const c of db.all<{ requirement_id: string }>("SELECT requirement_id FROM requirements WHERE parent_id = ?1", f)) {
412
+ if (!seen.has(c.requirement_id)) { seen.add(c.requirement_id); next.push(c.requirement_id); }
413
+ }
414
+ }
415
+ if (!next.length) break;
416
+ height++;
417
+ frontier = next;
418
+ }
419
+ return height;
420
+ }
421
+ /** 校验要挂的父卡:同网络、存在、不是自己或自己的后代(不成环)、挂上后不超过 5 层。返回错误码或 null。 */
422
+ function parentError(networkId: string, parentId: string, selfId: string | null): string | null {
423
+ const parent = db.get<{ network_id: string; requirement_id: string }>("SELECT network_id, requirement_id FROM requirements WHERE requirement_id = ?1", parentId);
424
+ if (!parent || parent.network_id !== networkId) return "parent_not_found";
425
+ if (selfId) {
426
+ // 从父卡往上走,碰到自己 = 成环
427
+ let cur: string | null = parentId;
428
+ const seen = new Set<string>();
429
+ while (cur && !seen.has(cur)) {
430
+ if (cur === selfId) return "parent_cycle";
431
+ seen.add(cur);
432
+ cur = db.get<{ parent_id: string | null }>("SELECT parent_id FROM requirements WHERE requirement_id = ?1", cur)?.parent_id ?? null;
433
+ }
434
+ }
435
+ const depth = levelOf(parentId) + 1 + (selfId ? heightBelow(selfId) : 0);
436
+ return depth > MAX_REQUIREMENT_DEPTH ? "parent_too_deep" : null;
437
+ }
438
+ function parentIdOf(value: unknown): string | null | undefined {
439
+ if (value === null || value === "") return null;
440
+ return typeof value === "string" && /^[A-Za-z0-9_-]{1,200}$/.test(value) ? value : undefined;
441
+ }
442
+
443
+ // ── 谁做的 ──
444
+ // 用户令牌 = {kind:"user", id:user_id};节点令牌 = {kind:"node", id:令牌绑定的 node_id}(老令牌没绑 node_id 时按
445
+ // 令牌名里的 alias 在本网络找;都找不到记 token 本身,不冒充任何节点)。写进 created_by / updated_by。
446
+ type Actor = { kind: "user" | "node"; id: string };
447
+ function actorOf(ctx: RequirementsRequestContext): Actor | null {
448
+ if (!ctx.auth) return null;
449
+ if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
450
+ const tokenId = ctx.auth.tokenId ?? null;
451
+ const bound = tokenId ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id : null;
452
+ if (bound) return { kind: "node", id: bound };
453
+ const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
454
+ if (alias && ctx.auth.networkId) {
455
+ const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
456
+ if (node) return { kind: "node", id: node.node_id };
457
+ }
458
+ return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
459
+ }
460
+
461
+ // ── 外部引用(同步用) ──
462
+ // external_ref:如 github:owner/repo#123,同一网络里唯一(部分唯一索引,见 requirements-migrate.ts)。
463
+ // external_url:http(s) 链接,给界面显示成「在 GitHub 打开」。
464
+ const EXTERNAL_REF = /^[A-Za-z0-9][A-Za-z0-9_.:\/#@+-]{0,199}$/;
465
+ function externalRef(value: unknown): string | null | undefined {
466
+ if (value === null) return null;
467
+ if (typeof value !== "string") return undefined;
468
+ const v = value.trim();
469
+ return EXTERNAL_REF.test(v) ? v : undefined;
470
+ }
471
+ function externalUrl(value: unknown): string | null | undefined {
472
+ if (value === null || value === "") return null;
473
+ if (typeof value !== "string") return undefined;
474
+ const v = value.trim();
475
+ return /^https?:\/\/[^\s]{1,500}$/i.test(v) ? v : undefined;
476
+ }
477
+ function rowByExternalRef(networkId: string, ref: string): Row | undefined {
478
+ return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND external_ref = ?2`, networkId, ref) ?? undefined;
479
+ }
480
+
481
+ // ── 新建 ──
482
+ async function createRequirement(ctx: RequirementsRequestContext, body: Record<string, unknown>): Promise<Response> {
483
+ const networkId = writeNetwork(body, ctx);
484
+ if (!networkId) return jsonError("network_id_required", 400);
485
+ if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
486
+ const name = typeof body.name === "string" ? body.name.trim() : "";
487
+ if (!name || name.length > 80) return jsonError("invalid_name", 400);
488
+ const priority = typeof body.priority === "string" ? body.priority : "normal";
489
+ if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
490
+ const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
491
+ if (due === null) return jsonError("invalid_due", 400);
492
+ const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
493
+ const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
494
+ if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
495
+ const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
496
+ const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
497
+ if (issues === null) return jsonError("invalid_issues", 400);
498
+ const description = body.description === undefined ? "" : normalizeDescription(body.description);
499
+ if (description === null) return jsonError("invalid_description", 400);
500
+ const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
501
+ if (checklist === null) return jsonError("invalid_checklist", 400);
502
+ let projectId: string | null = null;
503
+ if (body.project_id !== undefined) {
504
+ try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
505
+ }
506
+ const ref = body.external_ref === undefined ? null : externalRef(body.external_ref);
507
+ if (ref === undefined) return jsonError("invalid_external_ref", 400);
508
+ const extUrl = body.external_url === undefined ? null : externalUrl(body.external_url);
509
+ if (extUrl === undefined) return jsonError("invalid_external_url", 400);
510
+ const parentId = body.parent_id === undefined ? null : parentIdOf(body.parent_id);
511
+ if (parentId === undefined) return jsonError("invalid_parent_id", 400);
512
+ if (parentId) {
513
+ const err = parentError(networkId, parentId, null);
514
+ if (err) return jsonError(err, 400);
515
+ }
516
+ if (ref) {
517
+ const existing = rowByExternalRef(networkId, ref);
518
+ // 同一个外部条目再建一次 = 冲突,回已有的 id(同步方改用 upsert,或按这个 id PATCH)。
519
+ if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
520
+ }
521
+ if (clientId) {
522
+ const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
523
+ if (existing) return Response.json({ ok: true, requirement: toPublic(existing) });
524
+ }
525
+ const id = `req_${crypto.randomUUID()}`;
526
+ const createdAt = new Date().toISOString();
527
+ let people;
528
+ try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
529
+ const actor = JSON.stringify(actorOf(ctx));
530
+ try {
531
+ db.run(
532
+ `INSERT INTO requirements
533
+ (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)
534
+ 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)`,
535
+ [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],
536
+ );
537
+ } catch {
538
+ // 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
539
+ if (ref) {
540
+ const existing = rowByExternalRef(networkId, ref);
541
+ if (existing) return Response.json({ ok: false, error: "external_ref_exists", existing_id: existing.requirement_id }, { status: 409 });
542
+ }
543
+ if (!clientId) return jsonError("insert_failed", 500);
544
+ const existing = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`, networkId, clientId);
545
+ if (!existing) return jsonError("insert_failed", 500);
546
+ return Response.json({ ok: true, requirement: toPublic(existing) });
547
+ }
548
+ const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
549
+ return Response.json(withLegacyOwner(toPublic(created), people.coerced), { status: 201 });
550
+ }
551
+
552
+ // ── 修改(省略的字段保留原值) ──
553
+ const PATCH_FIELDS = ["column", "issues", "name", "priority", "due", "assignee", "owner", "agent_owner", "participants", "description", "checklist", "project_id", "external_ref", "external_url", "archived", "parent_id"];
554
+ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Record<string, unknown>): Response {
555
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
556
+ const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
557
+ if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
558
+ if (has("column") && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
559
+ const issues = has("issues") ? normalizeIssues(body.issues) : null;
560
+ if (has("issues") && issues === null) return jsonError("invalid_issues", 400);
561
+ const description = has("description") ? normalizeDescription(body.description) : null;
562
+ if (has("description") && description === null) return jsonError("invalid_description", 400);
563
+ const checklist = has("checklist") ? normalizeChecklist(body.checklist) : null;
564
+ if (has("checklist") && checklist === null) return jsonError("invalid_checklist", 400);
565
+ let name = row.title;
566
+ if (has("name")) {
567
+ name = typeof body.name === "string" ? body.name.trim() : "";
568
+ if (!name || name.length > 80) return jsonError("invalid_name", 400);
569
+ }
570
+ let priority = row.priority;
571
+ if (has("priority")) {
572
+ if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
573
+ priority = body.priority;
574
+ }
575
+ let due = row.due_on || "";
576
+ if (has("due")) {
577
+ if (typeof body.due !== "string") return jsonError("invalid_due", 400);
578
+ const next = normalizeDue(body.due);
579
+ if (next === null) return jsonError("invalid_due", 400);
580
+ due = next;
581
+ }
582
+ let assignee = row.assignee || "";
583
+ if (has("assignee")) {
584
+ if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
585
+ assignee = body.assignee.trim().slice(0, 80);
586
+ }
587
+ let people;
588
+ try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
589
+ let projectId = row.project_id;
590
+ if (has("project_id")) {
591
+ try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
592
+ }
593
+ let ref = row.external_ref;
594
+ if (has("external_ref")) {
595
+ const next = externalRef(body.external_ref);
596
+ if (next === undefined) return jsonError("invalid_external_ref", 400);
597
+ if (next && next !== row.external_ref) {
598
+ const taken = rowByExternalRef(row.network_id, next);
599
+ if (taken) return Response.json({ ok: false, error: "external_ref_exists", existing_id: taken.requirement_id }, { status: 409 });
600
+ }
601
+ ref = next;
602
+ }
603
+ let extUrl = row.external_url;
604
+ if (has("external_url")) {
605
+ const next = externalUrl(body.external_url);
606
+ if (next === undefined) return jsonError("invalid_external_url", 400);
607
+ extUrl = next;
608
+ }
609
+ let parentId = row.parent_id;
610
+ if (has("parent_id")) {
611
+ const next = parentIdOf(body.parent_id);
612
+ if (next === undefined) return jsonError("invalid_parent_id", 400);
613
+ if (next) {
614
+ const err = parentError(row.network_id, next, row.requirement_id);
615
+ if (err) return jsonError(err, 400);
616
+ }
617
+ parentId = next;
618
+ }
619
+ let archived = row.archived ? 1 : 0;
620
+ if (has("archived")) {
621
+ if (typeof body.archived !== "boolean") return jsonError("invalid_archived", 400);
622
+ archived = body.archived ? 1 : 0;
623
+ }
624
+ const updatedAt = new Date().toISOString();
625
+ db.run(
626
+ `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,
627
+ 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
628
+ WHERE requirement_id = ?3`,
629
+ [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,
630
+ has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, JSON.stringify(actorOf(ctx)), parentId],
631
+ );
632
+ const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
633
+ return Response.json(withLegacyOwner(toPublic(updated), people.coerced));
634
+ }
635
+
636
+ /**
637
+ * 被兼容改写的请求(旧客户端):响应里的 owner 按旧客户端的理解回显那个节点(它会拿响应核对「保存生效没有」,
638
+ * 0.2.142 的核对不通过就报「这个 Hub 还不能修改」),真实存储是 owner 空、agent_owner = 节点;agent_owner 也一并返回。
639
+ * GET 不这么做:新 App 读 owner / agent_owner 两个字段,GET 回显会让新 App 把 Agent 当成负责人。
640
+ */
641
+ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: PersonRef | null) {
642
+ if (!coerced) return { ok: true, requirement };
643
+ return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
644
+ }
645
+
646
+ function scopedRow(ctx: RequirementsRequestContext, id: string): Row | undefined {
647
+ const params: unknown[] = [id];
648
+ let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
649
+ sql = addNetworkScope(sql, params, ctx.scope);
650
+ return db.get<Row>(sql, ...params) ?? undefined;
651
+ }
652
+
653
+ /** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
654
+ function listFilters(url: URL, sql: string, params: unknown[]): string | Response {
655
+ const q = url.searchParams;
656
+ const status = q.get("status");
657
+ if (status !== null) {
658
+ if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
659
+ sql += ` AND column_name = ?${params.push(status)}`;
660
+ }
661
+ const project = q.get("project_id");
662
+ if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
663
+ for (const [param, column] of [["owner", "owner_json"], ["agent_owner", "agent_owner_json"]] as const) {
664
+ const v = q.get(param);
665
+ if (v === null) continue;
666
+ if (v === "none") { sql += ` AND ${column} IS NULL`; continue; }
667
+ const m = /^(user|node):(.+)$/.exec(v);
668
+ if (!m) return jsonError(`invalid_${param}`, 400);
669
+ sql += ` AND ${column} = ?${params.push(JSON.stringify({ kind: m[1], id: m[2] }))}`;
670
+ }
671
+ const since = q.get("updated_since");
672
+ if (since !== null) {
673
+ const ms = Date.parse(since);
674
+ if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
675
+ sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
676
+ }
677
+ const ref = q.get("external_ref");
678
+ if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
679
+ const parent = q.get("parent_id");
680
+ if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
681
+ if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
682
+ if (q.get("include_archived") !== "1") sql += " AND COALESCE(archived, 0) = 0";
683
+ return sql;
684
+ }
345
685
 
346
686
  export async function handleRequirementsRequest(ctx: RequirementsRequestContext): Promise<Response | null> {
347
687
  const { req, url } = ctx;
348
688
  if (url.pathname !== "/api/requirements" && !url.pathname.startsWith("/api/requirements/")) return null;
349
- // 节点令牌(Agent)现在一律拒绝。以后要让 Agent 读写描述、勾子任务,把对应操作加进
350
- // NODE_TOKEN_OPERATIONS(并给 canWrite 加上节点所属网络的判断)即可 —— 路由和请求体不用改。
689
+ // 节点令牌只能做 NODE_TOKEN_OPERATIONS 里的事(读 / 建 / 改 / 勾子任务 / upsert / 读项目),
690
+ // 而且只在它绑定的网络里 —— 范围由 scope 强制,写入再由 canWrite 核一次。
351
691
  if (ctx.isNodeToken && !NODE_TOKEN_OPERATIONS.has(operationOf(req, url))) return jsonError("user_token_required", 403);
352
692
 
353
693
  if (url.pathname === "/api/requirements/projects" || url.pathname.startsWith("/api/requirements/projects/")) return handleProjects(ctx);
@@ -368,8 +708,9 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
368
708
  const params: unknown[] = [];
369
709
  let sql = `SELECT ${SELECT} FROM requirements WHERE 1=1`;
370
710
  sql = addNetworkScope(sql, params, ctx.scope);
371
- sql += " ORDER BY created_at DESC LIMIT 500";
372
- const rows = db.all<Row>(sql, ...params).map(toPublic);
711
+ const filtered = listFilters(url, sql, params);
712
+ if (typeof filtered !== "string") return filtered;
713
+ const rows = db.all<Row>(`${filtered} ORDER BY created_at DESC LIMIT 500`, ...params).map(toPublic);
373
714
  // capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
374
715
  return Response.json({ ok: true, requirements: rows, capabilities: REQUIREMENT_CAPABILITIES });
375
716
  }
@@ -377,58 +718,32 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
377
718
  if (url.pathname === "/api/requirements" && req.method === "POST") {
378
719
  let body: Record<string, unknown>;
379
720
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
721
+ return createRequirement(ctx, body);
722
+ }
723
+
724
+ // 按 external_ref 建或改(同步用,幂等):同一网络里已有这个 external_ref → 按 PATCH 语义改它
725
+ // (省略的字段保留,包括状态);没有 → 新建。重复同步同一个 issue 只会改,不会重复建。
726
+ if (url.pathname === "/api/requirements/upsert" && req.method === "POST") {
727
+ let body: Record<string, unknown>;
728
+ try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
729
+ const ref = externalRef(body.external_ref);
730
+ if (!ref) return jsonError("external_ref_required", 400);
380
731
  const networkId = writeNetwork(body, ctx);
381
732
  if (!networkId) return jsonError("network_id_required", 400);
382
733
  if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
383
- const name = typeof body.name === "string" ? body.name.trim() : "";
384
- if (!name || name.length > 80) return jsonError("invalid_name", 400);
385
- const priority = typeof body.priority === "string" ? body.priority : "normal";
386
- if (!PRIORITIES.has(priority)) return jsonError("invalid_priority", 400);
387
- const due = typeof body.due === "string" ? normalizeDue(body.due) : "";
388
- if (due === null) return jsonError("invalid_due", 400);
389
- const assignee = typeof body.assignee === "string" ? body.assignee.trim().slice(0, 80) : "";
390
- const clientId = typeof body.client_id === "string" ? body.client_id.trim() : "";
391
- if (clientId && !/^[A-Za-z0-9._-]{1,80}$/.test(clientId)) return jsonError("invalid_client_id", 400);
392
- const column = typeof body.column === "string" && COLUMNS.has(body.column) ? body.column : "pool";
393
- const issues = body.issues === undefined ? [] : normalizeIssues(body.issues);
394
- if (issues === null) return jsonError("invalid_issues", 400);
395
- const description = body.description === undefined ? "" : normalizeDescription(body.description);
396
- if (description === null) return jsonError("invalid_description", 400);
397
- const checklist = body.checklist === undefined ? [] : normalizeChecklist(body.checklist);
398
- if (checklist === null) return jsonError("invalid_checklist", 400);
399
- let projectId: string | null = null;
400
- if (body.project_id !== undefined) {
401
- try { projectId = projectRef(body.project_id, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
734
+ const existing = rowByExternalRef(networkId, ref);
735
+ if (!existing) {
736
+ const res = await createRequirement(ctx, body);
737
+ if (res.status !== 201) return res;
738
+ const data = await res.json() as Record<string, unknown>;
739
+ return Response.json({ ...data, created: true }, { status: 201 });
402
740
  }
403
- if (clientId) {
404
- const existing = db.get<Row>(
405
- `SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
406
- networkId, clientId,
407
- );
408
- if (existing) return Response.json({ ok: true, requirement: toPublic(existing) });
409
- }
410
- const id = `req_${crypto.randomUUID()}`;
411
- const createdAt = new Date().toISOString();
412
- let people;
413
- try { people = assignments(body, networkId); } catch (e) { return jsonError((e as Error).message, 400); }
414
- try {
415
- db.run(
416
- `INSERT INTO requirements
417
- (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)
418
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17)`,
419
- [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],
420
- );
421
- } catch {
422
- if (!clientId) return jsonError("insert_failed", 500);
423
- const existing = db.get<Row>(
424
- `SELECT ${SELECT} FROM requirements WHERE network_id = ?1 AND client_id = ?2`,
425
- networkId, clientId,
426
- );
427
- if (!existing) return jsonError("insert_failed", 500);
428
- return Response.json({ ok: true, requirement: toPublic(existing) });
429
- }
430
- const created = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, id)!;
431
- return Response.json({ ok: true, requirement: toPublic(created) }, { status: 201 });
741
+ const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
742
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublic(existing), created: false });
743
+ const res = patchRequirement(ctx, existing, patch);
744
+ if (res.status !== 200) return res;
745
+ const data = await res.json() as Record<string, unknown>;
746
+ return Response.json({ ...data, created: false });
432
747
  }
433
748
 
434
749
  const itemMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)\/checklist\/([^/]+)$/);
@@ -437,10 +752,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
437
752
  let body: Record<string, unknown>;
438
753
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
439
754
  if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
440
- const params: unknown[] = [decodeURIComponent(itemMatch[1])];
441
- let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
442
- sql = addNetworkScope(sql, params, ctx.scope);
443
- const row = db.get<Row>(sql, ...params);
755
+ const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
444
756
  if (!row) return jsonError("requirement_not_found", 404);
445
757
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
446
758
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
@@ -449,73 +761,33 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
449
761
  const item = items.find(entry => entry.id === itemId);
450
762
  if (!item) return jsonError("checklist_item_not_found", 404);
451
763
  item.done = body.done;
452
- db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2 WHERE requirement_id = ?3", [JSON.stringify(items), new Date().toISOString(), row.requirement_id]);
764
+ db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), new Date().toISOString(), JSON.stringify(actorOf(ctx)), row.requirement_id]);
453
765
  const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
454
766
  return Response.json({ ok: true, requirement: toPublic(updated) });
455
767
  }
456
768
 
457
769
  const match = url.pathname.match(/^\/api\/requirements\/([^/]+)$/);
458
- if (!match || req.method !== "PATCH") return jsonError("not_found", 404);
770
+ if (!match) return jsonError("not_found", 404);
459
771
  const id = decodeURIComponent(match[1]);
772
+ if (req.method === "GET") {
773
+ const row = scopedRow(ctx, id);
774
+ return row ? Response.json({ ok: true, requirement: toPublic(row) }) : jsonError("requirement_not_found", 404);
775
+ }
776
+ if (req.method === "DELETE") {
777
+ // 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
778
+ const row = scopedRow(ctx, id);
779
+ if (!row) return jsonError("requirement_not_found", 404);
780
+ if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
781
+ // 子需求不跟着删:先解挂(变成顶层),再删父卡。
782
+ db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
783
+ db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
784
+ return Response.json({ ok: true, deleted: row.requirement_id });
785
+ }
786
+ if (req.method !== "PATCH") return jsonError("not_found", 404);
460
787
  let body: Record<string, unknown>;
461
788
  try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
462
- const hasColumn = 'column' in body;
463
- const hasIssues = Object.prototype.hasOwnProperty.call(body, "issues");
464
- const hasName = 'name' in body;
465
- const hasPriority = 'priority' in body;
466
- const hasDue = 'due' in body;
467
- const hasAssignee = 'assignee' in body;
468
- const hasDescription = 'description' in body;
469
- const hasChecklist = 'checklist' in body;
470
- const hasProject = 'project_id' in body;
471
- if (!hasColumn && !hasIssues && !hasName && !hasPriority && !hasDue && !hasAssignee && !('owner' in body) && !('agent_owner' in body) && !('participants' in body) && !hasDescription && !hasChecklist && !hasProject) return jsonError('empty_patch', 400);
472
- const description = hasDescription ? normalizeDescription(body.description) : null;
473
- if (hasDescription && description === null) return jsonError("invalid_description", 400);
474
- const checklist = hasChecklist ? normalizeChecklist(body.checklist) : null;
475
- if (hasChecklist && checklist === null) return jsonError("invalid_checklist", 400);
476
- if (hasColumn && !COLUMNS.has(String(body.column))) return jsonError("invalid_column", 400);
477
- const issues = hasIssues ? normalizeIssues(body.issues) : null;
478
- if (hasIssues && issues === null) return jsonError("invalid_issues", 400);
479
- const params: unknown[] = [id];
480
- let sql = `SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`;
481
- sql = addNetworkScope(sql, params, ctx.scope);
482
- const row = db.get<Row>(sql, ...params);
789
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
790
+ const row = scopedRow(ctx, id);
483
791
  if (!row) return jsonError("requirement_not_found", 404);
484
- if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
485
- let name = row.title;
486
- if (hasName) {
487
- name = typeof body.name === "string" ? body.name.trim() : "";
488
- if (!name || name.length > 80) return jsonError("invalid_name", 400);
489
- }
490
- let priority = row.priority;
491
- if (hasPriority) {
492
- if (typeof body.priority !== "string" || !PRIORITIES.has(body.priority)) return jsonError("invalid_priority", 400);
493
- priority = body.priority;
494
- }
495
- let due = row.due_on || "";
496
- if (hasDue) {
497
- if (typeof body.due !== "string") return jsonError("invalid_due", 400);
498
- const next = normalizeDue(body.due);
499
- if (next === null) return jsonError("invalid_due", 400);
500
- due = next;
501
- }
502
- let assignee = row.assignee || "";
503
- if (hasAssignee) {
504
- if (typeof body.assignee !== "string") return jsonError("invalid_assignee", 400);
505
- assignee = body.assignee.trim().slice(0, 80);
506
- }
507
- let people;
508
- try { people = assignments(body, row.network_id, row); } catch (e) { return jsonError((e as Error).message, 400); }
509
- let projectId = row.project_id;
510
- if (hasProject) {
511
- try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return jsonError((e as Error).message, 400); }
512
- }
513
- const updatedAt = new Date().toISOString();
514
- db.run(
515
- "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, agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14 WHERE requirement_id = ?3",
516
- [hasColumn ? body.column : row.column_name, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, hasIssues ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
517
- hasDescription ? (description || null) : row.description, hasChecklist ? JSON.stringify(checklist) : row.checklist_json, projectId],
518
- );
519
- const updated = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
520
- return Response.json({ ok: true, requirement: toPublic(updated) });
792
+ return patchRequirement(ctx, row, body);
521
793
  }