@sleep2agi/commhub-server 0.9.0-preview.73 → 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.
- package/package.json +1 -1
- package/src/agent-acl-http.test.ts +2 -0
- package/src/auth.ts +16 -7
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/human-dm.ts +35 -8
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +73 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-http.test.ts +3 -2
- package/src/requirements-migrate.ts +60 -0
- package/src/requirements-seq-http.test.ts +2 -1
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-migration.test.ts +13 -0
- package/src/requirements.ts +193 -19
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/server.ts +81 -10
- package/src/task-access-http.test.ts +375 -0
- package/src/task-access.ts +284 -0
- package/src/tools.ts +47 -7
- package/src/uploads.ts +8 -0
package/src/requirements.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
720
|
-
has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived,
|
|
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
|
|
873
|
-
const
|
|
874
|
-
|
|
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
|
+
});
|