@sleep2agi/commhub-server 0.9.0-preview.71 → 0.9.0-preview.73
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/README.md +2 -2
- package/bin/commhub.ts +82 -9
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +51 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +26 -9
- package/src/agent-acl-http.test.ts +9 -1
- package/src/agent-groups-perf.test.ts +23 -0
- package/src/auth.ts +22 -6
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/db-adapter-guard.test.ts +63 -0
- package/src/db-adapter-translate.test.ts +17 -1
- package/src/db-adapter.ts +78 -5
- package/src/db.ts +7 -1
- package/src/member-presence-http.test.ts +206 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/push.ts +80 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-mcp.test.ts +6 -0
- package/src/requirements-migrate.ts +61 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +192 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements.ts +102 -20
- package/src/retention.ts +5 -2
- package/src/scheduled-tasks-http.test.ts +22 -9
- package/src/server.ts +17 -3
- package/src/side-thread-command-transport.test.ts +12 -2
- package/src/side-thread.ts +4 -0
- package/src/skillhub-http.test.ts +16 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/tools.ts +13 -10
package/src/requirements.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// 需求池。长期卡片,存在 Hub 上,手机和电脑读同一份。
|
|
2
2
|
// 不是 tasks:tasks 是正在派给节点的活,状态由节点收尾。
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
|
|
4
5
|
import { db } from "./db.js";
|
|
5
6
|
import { normalizeTags, storedTags } from "./requirement-tags.js";
|
|
6
7
|
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
7
8
|
import { isAgentRestricted, visibleAgents } from "./agent-access.js";
|
|
8
|
-
import { ensureRequirementIndexes, ensureRequirementProjects, migrateRequirementAgentOwners, migrateRequirementPriorityCheck } from "./requirements-migrate.js";
|
|
9
|
+
import { ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
9
10
|
|
|
10
11
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
11
12
|
// 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
|
|
@@ -13,6 +14,7 @@ migrateRequirementPriorityCheck(db);
|
|
|
13
14
|
migrateRequirementAgentOwners(db);
|
|
14
15
|
ensureRequirementProjects(db);
|
|
15
16
|
ensureRequirementIndexes(db);
|
|
17
|
+
ensureRequirementSeq(db);
|
|
16
18
|
|
|
17
19
|
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
18
20
|
|
|
@@ -58,6 +60,7 @@ type Row = {
|
|
|
58
60
|
issues_json: string | null;
|
|
59
61
|
tags_json: string | null;
|
|
60
62
|
created_at: string;
|
|
63
|
+
seq: number | null;
|
|
61
64
|
};
|
|
62
65
|
|
|
63
66
|
function jsonError(error: string, status: number): Response {
|
|
@@ -283,6 +286,8 @@ function toPublic(row: Row) {
|
|
|
283
286
|
project_id: row.project_id || null,
|
|
284
287
|
participants: JSON.parse(row.participants_json || '[]'),
|
|
285
288
|
id: row.requirement_id,
|
|
289
|
+
// 短号:每个网络自己的 #1、#2…(不回收)。界面显示 #N;GET / PATCH / MCP 的 id 也收「#N」。旧 App 忽略它。
|
|
290
|
+
seq: row.seq == null ? null : Number(row.seq),
|
|
286
291
|
name: row.title,
|
|
287
292
|
priority: row.priority,
|
|
288
293
|
assignee: row.assignee || "",
|
|
@@ -357,7 +362,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
|
|
|
357
362
|
};
|
|
358
363
|
}
|
|
359
364
|
|
|
360
|
-
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, " +
|
|
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, " +
|
|
361
366
|
"(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
|
|
362
367
|
"(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";
|
|
363
368
|
|
|
@@ -452,7 +457,8 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
452
457
|
return header.split(",").some(tag => tag.trim() === "*" || bare(tag) === bare(etag));
|
|
453
458
|
}
|
|
454
459
|
|
|
455
|
-
|
|
460
|
+
// 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;
|
|
456
462
|
|
|
457
463
|
// ── 子需求 ──
|
|
458
464
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -602,12 +608,13 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
602
608
|
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
603
609
|
const actor = JSON.stringify(actorOf(ctx));
|
|
604
610
|
try {
|
|
605
|
-
|
|
611
|
+
// 领号和插入在同一个事务里:插入被唯一索引挡下时,号一起回滚,不留空洞。
|
|
612
|
+
db.transaction(() => db.run(
|
|
606
613
|
`INSERT INTO requirements
|
|
607
|
-
(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)
|
|
608
|
-
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)`,
|
|
609
|
-
[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],
|
|
610
|
-
);
|
|
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)],
|
|
617
|
+
));
|
|
611
618
|
} catch {
|
|
612
619
|
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
613
620
|
if (ref) {
|
|
@@ -726,14 +733,30 @@ function withLegacyOwner(requirement: ReturnType<typeof toPublic>, coerced: Pers
|
|
|
726
733
|
return { ok: true, requirement: { ...requirement, owner: coerced }, owner_coerced_to_agent_owner: true };
|
|
727
734
|
}
|
|
728
735
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
736
|
+
/** 「#123」→ 123;别的形状(包括裸数字)→ null,仍按 requirement_id 查。 */
|
|
737
|
+
export function seqFromId(id: string): number | null {
|
|
738
|
+
const m = /^#([1-9]\d{0,14})$/.exec(id.trim());
|
|
739
|
+
return m ? Number(m[1]) : null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* 按 id 或「#N」找调用者作用域里的那张卡。短号只在一个网络里唯一:作用域跨多个网络、又各有一张 #N 时
|
|
744
|
+
* 不猜,回 409 ambiguous_seq(带 ?network_id= 再查)。
|
|
745
|
+
*/
|
|
746
|
+
function scopedRow(ctx: RequirementsRequestContext, id: string): Row | Response {
|
|
747
|
+
const seq = seqFromId(id);
|
|
748
|
+
const params: unknown[] = [seq ?? id];
|
|
749
|
+
let sql = `SELECT ${SELECT} FROM requirements WHERE ${seq === null ? "requirement_id" : "seq"} = ?1`;
|
|
732
750
|
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
733
|
-
|
|
751
|
+
const rows = db.all<Row>(`${sql} LIMIT 2`, ...params);
|
|
752
|
+
if (rows.length > 1) {
|
|
753
|
+
const networks = rows.map(r => r.network_id);
|
|
754
|
+
return Response.json({ ok: false, error: "ambiguous_seq", networks, message: `#${seq} exists in more than one network you can see (${networks.join(", ")}); pass network_id to pick one` }, { status: 409 });
|
|
755
|
+
}
|
|
756
|
+
return rows[0] ?? jsonError("requirement_not_found", 404);
|
|
734
757
|
}
|
|
735
758
|
|
|
736
|
-
/** GET 的筛选(同步方 / MCP 用):status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
759
|
+
/** GET 的筛选(同步方 / MCP 用):seq(短号)、status、project_id、owner / agent_owner(kind:id)、updated_since、external_ref、parent_id / top_level、include_archived。 */
|
|
737
760
|
function listFilters(url: URL, sql: string, params: unknown[], ctx?: RequirementsRequestContext): string | Response {
|
|
738
761
|
const q = url.searchParams;
|
|
739
762
|
const status = q.get("status");
|
|
@@ -759,6 +782,11 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
759
782
|
if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
|
|
760
783
|
sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
|
|
761
784
|
}
|
|
785
|
+
const seq = q.get("seq");
|
|
786
|
+
if (seq !== null) {
|
|
787
|
+
if (!/^[1-9]\d{0,14}$/.test(seq)) return jsonError("invalid_seq", 400);
|
|
788
|
+
sql += ` AND seq = ?${params.push(Number(seq))}`;
|
|
789
|
+
}
|
|
762
790
|
const ref = q.get("external_ref");
|
|
763
791
|
if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
|
|
764
792
|
const parent = q.get("parent_id");
|
|
@@ -770,6 +798,55 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
770
798
|
return sql;
|
|
771
799
|
}
|
|
772
800
|
|
|
801
|
+
const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
|
|
802
|
+
type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
|
|
803
|
+
const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* 列表的一页(from = listFilters 拼好的「FROM requirements WHERE …」)。没有搜索词:SQL 直接 LIMIT。有搜索词:先按同样的筛选 + 顺序读轻量列(不算子需求计数),
|
|
807
|
+
* 在这里逐行匹配,凑够 limit + 1 张就停,再按 id 读这一页的完整行。
|
|
808
|
+
*/
|
|
809
|
+
function listPage(ctx: RequirementsRequestContext, from: string, baseParams: unknown[], lq: ListQuery): { rows: Row[]; hasMore: boolean; nextCursor: string | null } {
|
|
810
|
+
const params = [...baseParams];
|
|
811
|
+
let where = from;
|
|
812
|
+
if (lq.cursor) {
|
|
813
|
+
const a = params.push(lq.cursor.createdAt), b = params.push(lq.cursor.id);
|
|
814
|
+
where += ` AND (created_at < ?${a} OR (created_at = ?${a} AND requirement_id < ?${b}))`;
|
|
815
|
+
}
|
|
816
|
+
let rows: Row[];
|
|
817
|
+
if (!lq.terms.length) {
|
|
818
|
+
rows = db.all<Row>(`SELECT ${SELECT} ${where}${LIST_ORDER} LIMIT ${lq.limit + 1}`, ...params);
|
|
819
|
+
} else {
|
|
820
|
+
// seq(任务短号,#2139):启动迁移 ensureRequirementSeq 在 SQLite / PostgreSQL 上都加这一列,SELECT 本来就带它。
|
|
821
|
+
const light = db.all<LightRow>(`SELECT requirement_id, seq, network_id, created_at, title, description, assignee, tags_json, project_id, owner_json, agent_owner_json, participants_json ${where}${LIST_ORDER}`, ...params);
|
|
822
|
+
const maps = new Map<string, NameMaps>();
|
|
823
|
+
const hits: string[] = [];
|
|
824
|
+
for (const r of light) {
|
|
825
|
+
let m = maps.get(r.network_id);
|
|
826
|
+
if (!m) maps.set(r.network_id, m = searchNameMaps(ctx, r.network_id));
|
|
827
|
+
const row = { name: r.title, description: r.description || "", assignee: r.assignee || "", tags: storedTags(r.tags_json), project_id: r.project_id, owner: parseRef(r.owner_json), agent_owner: parseRef(r.agent_owner_json), participants: parseRef(r.participants_json) ?? [] };
|
|
828
|
+
if (matchesTaskId(r, lq.idQuery) || matchesTerms(row, lq.terms, m)) hits.push(r.requirement_id);
|
|
829
|
+
if (hits.length > lq.limit) break;
|
|
830
|
+
}
|
|
831
|
+
const byId = new Map(hits.length ? db.all<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id IN (${hits.map((_, i) => `?${i + 1}`).join(",")})`, ...hits).map(r => [r.requirement_id, r]) : []);
|
|
832
|
+
rows = hits.map(id => byId.get(id)).filter((r): r is Row => !!r);
|
|
833
|
+
}
|
|
834
|
+
const hasMore = rows.length > lq.limit;
|
|
835
|
+
if (hasMore) rows = rows.slice(0, lq.limit);
|
|
836
|
+
const last = rows[rows.length - 1];
|
|
837
|
+
return { rows, hasMore, nextCursor: hasMore && last ? encodeCursor({ createdAt: last.created_at, id: last.requirement_id }) : null };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** 搜索用的名字表(与 GET /api/requirements/people 同一个显示名规则);调用者看不见的节点不放进来。 */
|
|
841
|
+
function searchNameMaps(ctx: RequirementsRequestContext, networkId: string): NameMaps {
|
|
842
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
843
|
+
const people = new Map<string, string>();
|
|
844
|
+
for (const u of db.all<{ id: string; name: string }>("SELECT u.user_id AS id, COALESCE(NULLIF(u.display_name,''), u.username) AS name FROM network_members m JOIN users u ON u.user_id=m.user_id WHERE m.network_id=?1", networkId)) people.set(`user:${u.id}`, u.name);
|
|
845
|
+
for (const n of db.all<{ id: string; name: string }>("SELECT node_id AS id, COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE network_id=?1", networkId)) if (!hidden?.(n.id)) people.set(`node:${n.id}`, n.name);
|
|
846
|
+
const projects = new Map(db.all<{ project_id: string; name: string }>("SELECT project_id, name FROM requirement_projects WHERE network_id=?1", networkId).map(p => [p.project_id, p.name] as const));
|
|
847
|
+
return { people, projects };
|
|
848
|
+
}
|
|
849
|
+
|
|
773
850
|
/** 这个节点对调用者在他作用域内的某个受限网络里是否隐藏。 */
|
|
774
851
|
function hiddenFilterForScope(ctx: RequirementsRequestContext, nodeId: string): boolean {
|
|
775
852
|
const restricted = ctx.scope.agentRestriction?.networkIds ?? [];
|
|
@@ -812,13 +889,18 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
812
889
|
|
|
813
890
|
if (url.pathname === "/api/requirements" && req.method === "GET") {
|
|
814
891
|
const params: unknown[] = [];
|
|
815
|
-
|
|
892
|
+
// 只拼 FROM … WHERE:同一组条件既用来读整行,也用来(搜索时)先读轻量列。
|
|
893
|
+
let sql = "FROM requirements WHERE 1=1";
|
|
816
894
|
sql = addHumanNetworkScope(sql, params, ctx.scope);
|
|
817
895
|
const filtered = listFilters(url, sql, params, ctx);
|
|
818
896
|
if (typeof filtered !== "string") return filtered;
|
|
819
|
-
|
|
897
|
+
// q= / limit / cursor(requirements-search.ts)。都不带 = 旧行为:最新 500 张、同样的顺序。
|
|
898
|
+
const lq = parseListQuery(url.searchParams);
|
|
899
|
+
if ("error" in lq) return jsonError(lq.error, 400);
|
|
900
|
+
const page = listPage(ctx, filtered, params, lq);
|
|
820
901
|
// capabilities:客户端按这个决定显示哪些功能(预计完成能不能带时刻、有没有项目…),不用靠猜字段。
|
|
821
|
-
|
|
902
|
+
// has_more / next_cursor:后面还有没有(带 cursor=next_cursor 再读一页)。旧客户端不认识,忽略即可。
|
|
903
|
+
return conditionalJson(ctx.req, { ok: true, requirements: page.rows.map(row => toPublicFor(ctx, row)), capabilities: REQUIREMENT_CAPABILITIES, has_more: page.hasMore, next_cursor: page.nextCursor });
|
|
822
904
|
}
|
|
823
905
|
|
|
824
906
|
if (url.pathname === "/api/requirements" && req.method === "POST") {
|
|
@@ -859,7 +941,7 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
859
941
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
860
942
|
if (typeof body.done !== "boolean") return jsonError("invalid_done", 400);
|
|
861
943
|
const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
|
|
862
|
-
if (
|
|
944
|
+
if (row instanceof Response) return row;
|
|
863
945
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
864
946
|
// 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
|
|
865
947
|
const items = storedChecklist(row.checklist_json);
|
|
@@ -877,12 +959,12 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
877
959
|
const id = decodeURIComponent(match[1]);
|
|
878
960
|
if (req.method === "GET") {
|
|
879
961
|
const row = scopedRow(ctx, id);
|
|
880
|
-
return row ? Response.json({ ok: true, requirement: toPublicFor(ctx, row) })
|
|
962
|
+
return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
|
|
881
963
|
}
|
|
882
964
|
if (req.method === "DELETE") {
|
|
883
965
|
// 删除只给人(节点令牌在入口已被拒):Agent 用 archived 归档。
|
|
884
966
|
const row = scopedRow(ctx, id);
|
|
885
|
-
if (
|
|
967
|
+
if (row instanceof Response) return row;
|
|
886
968
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
887
969
|
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
888
970
|
db.run("UPDATE requirements SET parent_id = NULL WHERE parent_id = ?1", [row.requirement_id]);
|
|
@@ -894,6 +976,6 @@ export async function handleRequirementsRequest(ctx: RequirementsRequestContext)
|
|
|
894
976
|
try { body = await bodyObject(req); } catch { return jsonError("invalid_json", 400); }
|
|
895
977
|
if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400);
|
|
896
978
|
const row = scopedRow(ctx, id);
|
|
897
|
-
if (
|
|
979
|
+
if (row instanceof Response) return row;
|
|
898
980
|
return patchRequirement(ctx, row, body);
|
|
899
981
|
}
|
package/src/retention.ts
CHANGED
|
@@ -197,10 +197,13 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
|
|
|
197
197
|
// DBs must run a one-time blocking `VACUUM` themselves (out of
|
|
198
198
|
// scope for the sweeper, which deliberately stays non-
|
|
199
199
|
// blocking).
|
|
200
|
+
// Both are SQLite PRAGMAs; on PostgreSQL autovacuum does this job and
|
|
201
|
+
// the two counters stay null (RFC-039 S3).
|
|
200
202
|
let walCheckpointPagesMoved: number | null = null;
|
|
201
203
|
let incrementalFreedPages: number | null = null;
|
|
202
204
|
let errored = false;
|
|
203
|
-
|
|
205
|
+
const sqliteVacuum = db.dialect === "sqlite";
|
|
206
|
+
if (sqliteVacuum) try {
|
|
204
207
|
// wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
|
|
205
208
|
// We expose `log` (page count moved into the main DB) for ops visibility.
|
|
206
209
|
const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
|
|
@@ -211,7 +214,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
|
|
|
211
214
|
console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
|
|
212
215
|
errored = true;
|
|
213
216
|
}
|
|
214
|
-
try {
|
|
217
|
+
if (sqliteVacuum) try {
|
|
215
218
|
// incremental_vacuum is a no-op on databases not created with
|
|
216
219
|
// PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
|
|
217
220
|
// weren't, the operator would have to run a full one-time VACUUM
|
|
@@ -8,8 +8,8 @@ import { db } from "./db.js";
|
|
|
8
8
|
import { assertScheduledTaskBackendSupported, nextOccurrence, runDueScheduledTasks } from "./scheduled-tasks.js";
|
|
9
9
|
|
|
10
10
|
const dir = mkdtempSync(join(tmpdir(), "anet-scheduler-"));
|
|
11
|
-
const activeDbPath = process.env.COMMHUB_DB;
|
|
12
|
-
if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB before module import");
|
|
11
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
12
|
+
if (!activeDbPath) throw new Error("test601 requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
13
13
|
let server: any;
|
|
14
14
|
let base = "";
|
|
15
15
|
let ownerToken = "";
|
|
@@ -128,8 +128,13 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
128
128
|
(db as any).atomicTransactions = original;
|
|
129
129
|
}
|
|
130
130
|
expect(() => assertScheduledTaskBackendSupported()).not.toThrow();
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
if (db.dialect === "sqlite") {
|
|
132
|
+
const misfireColumn = db.all<any>("PRAGMA table_info(scheduled_tasks)").find((column: any) => column.name === "misfire_policy");
|
|
133
|
+
expect(misfireColumn?.dflt_value).toBe("'catch_up_once'");
|
|
134
|
+
} else {
|
|
135
|
+
const misfireColumn = db.get<any>("SELECT column_default FROM information_schema.columns WHERE table_name = 'scheduled_tasks' AND column_name = 'misfire_policy'");
|
|
136
|
+
expect(misfireColumn?.column_default).toBe("'catch_up_once'::text");
|
|
137
|
+
}
|
|
133
138
|
});
|
|
134
139
|
|
|
135
140
|
test("owner creates; viewer and node token cannot mutate", async () => {
|
|
@@ -283,10 +288,17 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
283
288
|
// Test-only fault injection at the second durable write. The inbox row has
|
|
284
289
|
// already been attempted when this aborts, so any missing transaction
|
|
285
290
|
// boundary leaves an observable half-state.
|
|
286
|
-
db.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
291
|
+
if (db.dialect === "sqlite") {
|
|
292
|
+
db.run(`CREATE TRIGGER test601_abort_scheduled_task_insert
|
|
293
|
+
BEFORE INSERT ON tasks
|
|
294
|
+
WHEN NEW.meta_json LIKE '%${atomicScheduleId}%'
|
|
295
|
+
BEGIN SELECT RAISE(ABORT, 'test601_injected_task_failure'); END`);
|
|
296
|
+
} else {
|
|
297
|
+
db.exec(`CREATE FUNCTION test601_abort_scheduled_task_insert() RETURNS trigger AS $$
|
|
298
|
+
BEGIN RAISE EXCEPTION 'test601_injected_task_failure'; END; $$ LANGUAGE plpgsql;
|
|
299
|
+
CREATE TRIGGER test601_abort_scheduled_task_insert BEFORE INSERT ON tasks FOR EACH ROW
|
|
300
|
+
WHEN (NEW.meta_json LIKE '%${atomicScheduleId}%') EXECUTE FUNCTION test601_abort_scheduled_task_insert()`);
|
|
301
|
+
}
|
|
290
302
|
const failed = runDueScheduledTasks();
|
|
291
303
|
expect(failed.failed).toBe(1);
|
|
292
304
|
expect(db.get("SELECT run_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeNull();
|
|
@@ -294,7 +306,8 @@ describe("Hub scheduled task API and dispatcher", () => {
|
|
|
294
306
|
expect(db.get("SELECT task_id FROM tasks WHERE meta_json LIKE '%' || ?1 || '%'", atomicScheduleId)).toBeNull();
|
|
295
307
|
expect(db.get<{ next_run_at: string }>("SELECT next_run_at FROM scheduled_tasks WHERE schedule_id = ?1", atomicScheduleId)!.next_run_at).toBe(due);
|
|
296
308
|
|
|
297
|
-
db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
|
|
309
|
+
if (db.dialect === "sqlite") db.run("DROP TRIGGER test601_abort_scheduled_task_insert");
|
|
310
|
+
else db.exec("DROP TRIGGER test601_abort_scheduled_task_insert ON tasks; DROP FUNCTION test601_abort_scheduled_task_insert()");
|
|
298
311
|
expect(runDueScheduledTasks().processed).toBe(1);
|
|
299
312
|
expect(db.get("SELECT task_id FROM scheduled_task_runs WHERE schedule_id = ?1", atomicScheduleId)).toBeTruthy();
|
|
300
313
|
});
|
package/src/server.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
10
10
|
import { z } from "zod/v4";
|
|
11
11
|
import { registerTools } from "./tools.js";
|
|
12
12
|
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
13
|
-
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork } from "./push.js";
|
|
13
|
+
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
14
14
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
15
15
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
16
16
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
@@ -238,6 +238,16 @@ function isTmuxAllowedIP(ip: string): boolean {
|
|
|
238
238
|
function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
|
|
239
239
|
return !!resolved?.networkId;
|
|
240
240
|
}
|
|
241
|
+
// 人员列表的在线点:某人的第一条用户流连上 / 最后一条断开时,推给他所在每个网络里其他连着用户流的成员。
|
|
242
|
+
// 收件人本来就是该网络成员(用户流入口按成员资格鉴权),与 GET /api/networks/:id/humans 同一可见范围。
|
|
243
|
+
// 字段叫 member_user_id:pushUserEvent 会把 user_id 写成**收件人**自己。
|
|
244
|
+
onUserPresenceChange((userId, online, lastSeenAt) => {
|
|
245
|
+
const nets = db.all<{ network_id: string }>("SELECT network_id FROM network_members WHERE user_id = ?1", userId);
|
|
246
|
+
for (const { network_id } of nets) {
|
|
247
|
+
pushUserEventToNetwork(network_id, { type: "member_presence", member_user_id: userId, online, last_seen_at: lastSeenAt }, userId);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
241
251
|
function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
|
|
242
252
|
return !!resolved && !resolved.networkId && resolved.user.role === "admin";
|
|
243
253
|
}
|
|
@@ -1606,7 +1616,11 @@ return Bun.serve({
|
|
|
1606
1616
|
if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
|
|
1607
1617
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1608
1618
|
}
|
|
1609
|
-
|
|
1619
|
+
const humans = getNetworkHumans(netId);
|
|
1620
|
+
// 在线状态(online / last_seen_at)只给用户令牌:节点令牌拿到的仍是原来的身份字段,不多得任何东西。
|
|
1621
|
+
if (isNodeCredential(resolved)) return withCors(req, Response.json({ ok: true, network_id: netId, humans }));
|
|
1622
|
+
const presence = getUserPresence(humans.map((h) => h.user_id));
|
|
1623
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
|
|
1610
1624
|
}
|
|
1611
1625
|
|
|
1612
1626
|
// ── V3.13: Network members + invites ──
|
|
@@ -3459,7 +3473,7 @@ return Bun.serve({
|
|
|
3459
3473
|
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
3460
3474
|
if (node.alias) {
|
|
3461
3475
|
db.run(
|
|
3462
|
-
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
|
|
3476
|
+
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
|
|
3463
3477
|
[node.alias, node.network_id ?? null]
|
|
3464
3478
|
);
|
|
3465
3479
|
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
|
-
import { SQLiteAdapter } from "./db-adapter";
|
|
3
|
+
import { PgAdapter, SQLiteAdapter, resolveDatabaseTarget, type DbAdapter } from "./db-adapter";
|
|
4
4
|
import { DurableSideThreadCommandPort, SideThreadCommandStore, handleSideThreadCommandRequest } from "./side-thread-command-transport";
|
|
5
5
|
import { SideThreadCoordinator, SideThreadStore } from "./side-thread";
|
|
6
6
|
|
|
7
7
|
function actor(tokenId = "tok-node") { return { tokenId, networkId: "net-1", nodeId: "node-1" }; }
|
|
8
|
+
// RFC-039 S4: with COMMHUB_TEST_PG_URL (validated by the adapter guard) the
|
|
9
|
+
// same tests run on PostgreSQL, each fixture on an emptied schema.
|
|
10
|
+
function freshDb(): DbAdapter {
|
|
11
|
+
if (!process.env.COMMHUB_TEST_PG_URL) return new SQLiteAdapter(new Database(":memory:"));
|
|
12
|
+
const target = resolveDatabaseTarget();
|
|
13
|
+
if (target.kind !== "postgres") throw new Error("COMMHUB_TEST_PG_URL did not select PostgreSQL");
|
|
14
|
+
const pg = new PgAdapter(target.url);
|
|
15
|
+
pg.exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public");
|
|
16
|
+
return pg;
|
|
17
|
+
}
|
|
8
18
|
function fixture() {
|
|
9
|
-
const db =
|
|
19
|
+
const db = freshDb();
|
|
10
20
|
db.exec("CREATE TABLE inbox(id TEXT PRIMARY KEY, content TEXT)");
|
|
11
21
|
const store = new SideThreadCommandStore(db, () => 123);
|
|
12
22
|
const port = new DurableSideThreadCommandPort({ store, networkForNode: (id) => id === "node-1" ? "net-1" : null,
|
package/src/side-thread.ts
CHANGED
|
@@ -450,6 +450,10 @@ export function installSideThreadSchema(database: DbAdapter): void {
|
|
|
450
450
|
if (!/duplicate column|already exists/i.test(error?.message ?? ""))
|
|
451
451
|
throw error;
|
|
452
452
|
}
|
|
453
|
+
// RFC-039 F3: operations are listed in insertion order via SQLite's
|
|
454
|
+
// implicit rowid; PostgreSQL needs a real column for that.
|
|
455
|
+
if (database.dialect === "postgres")
|
|
456
|
+
database.exec("ALTER TABLE side_chat_operations ADD COLUMN IF NOT EXISTS rowid BIGSERIAL");
|
|
453
457
|
}
|
|
454
458
|
|
|
455
459
|
type SideChatRow = {
|
|
@@ -84,6 +84,22 @@ describe("SkillHub real Hub + SQLite", () => {
|
|
|
84
84
|
expect(detail.created_by_user).toBeUndefined(); expect(detail.reviewed_by_user).toBeUndefined(); expect(detail.content_hash).toBeUndefined();
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test("query matches regardless of letter case (SQLite LIKE semantics, kept on PostgreSQL)", async () => {
|
|
88
|
+
for (const query of ["incident", "INCIDENT", "Handoff"]) {
|
|
89
|
+
const found = await tool(nodeToken, "list_skills", { network_id: networkId, query });
|
|
90
|
+
expect(found.skills.map((x: any) => x.skill_id)).toContain(skillId);
|
|
91
|
+
}
|
|
92
|
+
expect((await tool(nodeToken, "list_skills", { network_id: networkId, query: "no-such-skill" })).skills).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("% and _ in the query are literal characters, not wildcards (#2141)", async () => {
|
|
96
|
+
const ids = async (query: string) => (await tool(nodeToken, "list_skills", { network_id: networkId, query })).skills.map((x: any) => x.skill_id);
|
|
97
|
+
expect(await ids("_")).not.toContain(skillId); // was: matched everything
|
|
98
|
+
expect(await ids("%")).not.toContain(skillId);
|
|
99
|
+
expect(await ids("incident_handoff")).not.toContain(skillId); // `_` must not stand for the "-" in the slug
|
|
100
|
+
expect(await ids("incident-handoff")).toContain(skillId);
|
|
101
|
+
});
|
|
102
|
+
|
|
87
103
|
test("foreign user cannot read or review another network", async () => {
|
|
88
104
|
expect((await tool(foreignToken, "list_skills", { network_id: networkId })).ok).toBe(false);
|
|
89
105
|
const review = await tool(foreignToken, "review_skill", { network_id: networkId, skill_id: skillId, decision: "rejected" });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { SQLiteAdapter } from "./db-adapter";
|
|
8
|
+
|
|
9
|
+
// #2151 — a cleanly stopped Hub must leave a complete .db: the -wal is absent
|
|
10
|
+
// or empty, and a copy of the .db file alone still has the data.
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-wal-close-"));
|
|
12
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
const walBytes = (db: string) => (existsSync(`${db}-wal`) ? statSync(`${db}-wal`).size : 0);
|
|
15
|
+
|
|
16
|
+
function copyOnlyDb(db: string): Database {
|
|
17
|
+
const copy = join(dirname(db), `copy-of-${db.split("/").pop()}`);
|
|
18
|
+
copyFileSync(db, copy); // deliberately without -wal / -shm
|
|
19
|
+
return new Database(copy, { readonly: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("#2151 SQLite WAL is checkpointed on close", () => {
|
|
23
|
+
test("SQLiteAdapter.close() after >20 distinct statements leaves a complete .db", () => {
|
|
24
|
+
const path = join(dir, "adapter.db");
|
|
25
|
+
const raw = new Database(path);
|
|
26
|
+
raw.exec("PRAGMA journal_mode=WAL");
|
|
27
|
+
const db = new SQLiteAdapter(raw);
|
|
28
|
+
db.exec("CREATE TABLE t (x TEXT)");
|
|
29
|
+
db.run("INSERT INTO t VALUES (?1)", ["kept"]);
|
|
30
|
+
// bun:sqlite stops being able to close after more than 20 distinct query() strings.
|
|
31
|
+
for (let i = 0; i < 30; i++) db.get(`SELECT ${i} AS v`);
|
|
32
|
+
db.close();
|
|
33
|
+
expect(walBytes(path)).toBe(0);
|
|
34
|
+
const copy = copyOnlyDb(path);
|
|
35
|
+
expect(copy.query("SELECT x FROM t").all()).toEqual([{ x: "kept" }]);
|
|
36
|
+
copy.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a real Hub that served traffic and got SIGTERM leaves a complete .db", async () => {
|
|
40
|
+
const bin = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
|
|
41
|
+
const home = join(dir, "home");
|
|
42
|
+
const path = join(dir, "hub.db");
|
|
43
|
+
const port = String(27000 + Math.floor(Math.random() * 2000));
|
|
44
|
+
const base = `http://127.0.0.1:${port}`;
|
|
45
|
+
const hub = Bun.spawn(["bun", bin, "--port", port, "--host", "127.0.0.1", "--db", path], {
|
|
46
|
+
env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
let up = false;
|
|
50
|
+
for (let i = 0; i < 150 && !up; i++) {
|
|
51
|
+
up = await fetch(`${base}/health`).then((r) => r.ok, () => false);
|
|
52
|
+
if (!up) await Bun.sleep(100);
|
|
53
|
+
}
|
|
54
|
+
expect(up).toBe(true);
|
|
55
|
+
const post = (p: string, body: unknown, token?: string) => fetch(`${base}${p}`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
}).then((r) => r.json() as Promise<any>);
|
|
60
|
+
const reg = await post("/api/auth/register", { username: "walcheck", password: "StrongPassw0rd" });
|
|
61
|
+
expect(reg.ok).toBe(true);
|
|
62
|
+
const login = await post("/api/auth/login", { username: "walcheck", password: "StrongPassw0rd" });
|
|
63
|
+
const net = await post("/api/networks", { name: "wal-net" }, login.token);
|
|
64
|
+
expect(net.ok).toBe(true);
|
|
65
|
+
const ntok = await post("/api/auth/node-token", { network_id: net.network_id, node_name: "wal-agent" }, login.token);
|
|
66
|
+
expect(ntok.ok).toBe(true);
|
|
67
|
+
expect(walBytes(path)).toBeGreaterThan(0); // the writes really are in the WAL before the stop
|
|
68
|
+
} finally {
|
|
69
|
+
hub.kill("SIGTERM");
|
|
70
|
+
await hub.exited;
|
|
71
|
+
}
|
|
72
|
+
expect(walBytes(path)).toBe(0);
|
|
73
|
+
const copy = copyOnlyDb(path);
|
|
74
|
+
expect(copy.query("SELECT COUNT(*) AS n FROM users WHERE username = 'walcheck'").get()).toEqual({ n: 1 });
|
|
75
|
+
expect(copy.query("SELECT COUNT(*) AS n FROM networks WHERE network_name = 'wal-net'").get()).toEqual({ n: 1 });
|
|
76
|
+
copy.close();
|
|
77
|
+
}, 40_000);
|
|
78
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -357,8 +357,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
357
357
|
FROM skillhub_skills WHERE network_id = ?1`;
|
|
358
358
|
if (!showPending) sql += ` AND status = 'published'`;
|
|
359
359
|
if (query?.trim()) {
|
|
360
|
-
params.push(`%${query.trim()}%`);
|
|
361
|
-
|
|
360
|
+
params.push(`%${query.trim().replace(/[\\%_]/g, (c) => `\\${c}`)}%`); // #2141: the query is text, not a pattern
|
|
361
|
+
const like = db.dialect === "postgres" ? "ILIKE" : "LIKE"; // RFC-039 F6: SQLite LIKE ignores ASCII case; keep that on PG
|
|
362
|
+
sql += ` AND (slug ${like} ?${params.length} ESCAPE '\\' OR name ${like} ?${params.length} ESCAPE '\\' OR description ${like} ?${params.length} ESCAPE '\\')`;
|
|
362
363
|
}
|
|
363
364
|
sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
|
|
364
365
|
return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
|
|
@@ -5780,12 +5781,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5780
5781
|
};
|
|
5781
5782
|
const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
|
|
5782
5783
|
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
5784
|
+
const REQ_ID_DESC = "requirement id (req_…) or its short number \"#N\" (e.g. \"#42\", per network; a user token that sees several networks must also pass network_id, else 409 ambiguous_seq)";
|
|
5783
5785
|
|
|
5784
5786
|
server.tool(
|
|
5785
5787
|
"requirements_list",
|
|
5786
|
-
"List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first,
|
|
5788
|
+
"List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}.",
|
|
5787
5789
|
{
|
|
5788
5790
|
network_id: z.string().max(200).optional(),
|
|
5791
|
+
seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
|
|
5789
5792
|
status: z.enum(["pool", "doing", "done"]).optional(),
|
|
5790
5793
|
project_id: z.string().max(200).optional(),
|
|
5791
5794
|
owner: z.string().max(210).optional(),
|
|
@@ -5794,11 +5797,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5794
5797
|
external_ref: z.string().max(200).optional(),
|
|
5795
5798
|
include_archived: z.boolean().optional(),
|
|
5796
5799
|
parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
|
|
5797
|
-
top_level: z.boolean().optional(),
|
|
5800
|
+
top_level: z.boolean().optional(), q: z.string().max(200).optional(), limit: z.number().int().min(1).max(1000).optional(), cursor: z.string().max(600).optional(),
|
|
5798
5801
|
},
|
|
5799
5802
|
async (args) => {
|
|
5800
5803
|
const q = new URLSearchParams();
|
|
5801
|
-
for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
|
|
5804
|
+
for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
|
|
5802
5805
|
if (args.include_archived) q.set("include_archived", "1");
|
|
5803
5806
|
if (args.top_level) q.set("top_level", "1");
|
|
5804
5807
|
return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
|
|
@@ -5807,8 +5810,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5807
5810
|
|
|
5808
5811
|
server.tool(
|
|
5809
5812
|
"requirements_get",
|
|
5810
|
-
"Get one requirement task by id (with description, checklist, owners, project, external_ref).",
|
|
5811
|
-
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
5813
|
+
"Get one requirement task by id or by its short number \"#N\" (with description, checklist, owners, project, external_ref, seq).",
|
|
5814
|
+
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional() },
|
|
5812
5815
|
async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
|
|
5813
5816
|
);
|
|
5814
5817
|
|
|
@@ -5821,15 +5824,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5821
5824
|
|
|
5822
5825
|
server.tool(
|
|
5823
5826
|
"requirements_update",
|
|
5824
|
-
"Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
|
|
5825
|
-
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5827
|
+
"Patch a requirement task (id or \"#N\"); omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
|
|
5828
|
+
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5826
5829
|
async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5827
5830
|
);
|
|
5828
5831
|
|
|
5829
5832
|
server.tool(
|
|
5830
5833
|
"requirements_checklist_toggle",
|
|
5831
5834
|
"Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
|
|
5832
|
-
{ id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
|
|
5835
|
+
{ id: z.string().min(1).max(200).describe(REQ_ID_DESC), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
|
|
5833
5836
|
async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
|
|
5834
5837
|
);
|
|
5835
5838
|
|