@sleep2agi/commhub-server 0.9.0-preview.137 → 0.9.0-preview.139

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.137",
3
+ "version": "0.9.0-preview.139",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -2228,7 +2228,7 @@ export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus
2228
2228
  // RFC-039 F3: queries break created_at ties with SQLite's implicit rowid
2229
2229
  // (insertion order). PostgreSQL has none, so give these tables a real one.
2230
2230
  if (db.dialect === "postgres") {
2231
- for (const table of ["nodes", "user_inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
2231
+ for (const table of ["nodes", "user_inbox", "inbox"]) db.exec(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS rowid BIGSERIAL`);
2232
2232
  }
2233
2233
 
2234
2234
  // Board #625: common SQLite/PostgreSQL DDL. Adoption records authority, not processes.
@@ -0,0 +1,42 @@
1
+ import { db } from "./db.js";
2
+
3
+ /** Metadata only: the body of an ordinary blocked task is never fetched by this scan. */
4
+ export function isAuthenticatedHumanInboxRow(row: { type: string; meta_json: unknown }): boolean {
5
+ if (row.type !== "task" && row.type !== "broadcast") return false;
6
+ let parsed: unknown;
7
+ try { parsed = typeof row.meta_json === "string" ? JSON.parse(row.meta_json) : null; }
8
+ catch { return false; }
9
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
10
+ const meta = parsed as Record<string, unknown>;
11
+ return meta.auth_origin === "user" && meta.source === "dashboard-chat"
12
+ && typeof meta.client_request_id === "string" && /^dreq_[a-f0-9]{32}$/.test(meta.client_request_id);
13
+ }
14
+
15
+ /** Caller supplies the already-authorized recipient/network WHERE clause. Both DB dialects
16
+ * support this scalar keyset; no JSON SQL extension, OFFSET, or arbitrary depth cutoff. */
17
+ export function selectHumanInboxIds(whereSql: string, params: unknown[], limit: number) {
18
+ const ids: string[] = [];
19
+ let cursor: { priority_rank: number; created_at: string; fifo_id: number } | undefined;
20
+ let examined = 0;
21
+ const rank = "CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END";
22
+ while (ids.length < limit) {
23
+ const pageParams = [...params];
24
+ let sql = `SELECT id, type, meta_json, created_at, rowid AS fifo_id, ${rank} AS priority_rank ${whereSql}`;
25
+ if (cursor) {
26
+ const n = pageParams.length + 1;
27
+ sql += ` AND (${rank}, created_at, rowid) > (?${n}, ?${n + 1}, ?${n + 2})`;
28
+ pageParams.push(cursor.priority_rank, cursor.created_at, cursor.fifo_id);
29
+ }
30
+ sql += ` ORDER BY ${rank}, created_at, rowid LIMIT 256`;
31
+ const page = db.all<{ id: string; type: string; meta_json: unknown; priority_rank: number; created_at: string; fifo_id: number }>(sql, ...pageParams);
32
+ if (!page.length) break;
33
+ for (const row of page) {
34
+ examined++;
35
+ if (isAuthenticatedHumanInboxRow(row)) ids.push(row.id);
36
+ if (ids.length === limit) break;
37
+ }
38
+ cursor = page[page.length - 1];
39
+ if (page.length < 256) break;
40
+ }
41
+ return { ids, examined };
42
+ }
@@ -286,3 +286,72 @@ describe("GET /api/requirements/stats", () => {
286
286
  expect(r.body.requirement).toBeUndefined();
287
287
  });
288
288
  });
289
+
290
+ describe("tag_status current snapshot #1091", () => {
291
+ let project = "";
292
+ test("current unarchived four states, distinct memberships and separate null bucket", async () => {
293
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "tag-status" });
294
+ expect(p.status).toBe(201); project = p.body.project.id;
295
+ const seed = (name: string, extra: Record<string, unknown>) => card(admin.token, name, { project_id: project, ...extra });
296
+ await seed("tag-pool", { tags: ["a", "b", "a"] });
297
+ await seed("tag-done", { tags: ["a", "b"], column: "done" });
298
+ await seed("tag-doing", { tags: ["b"], column: "doing" });
299
+ await seed("tag-abandoned", { column: "abandoned" });
300
+ await seed("tag-literal", { tags: ["无标签"] });
301
+ const invalid = await seed("tag-invalid-legacy", { column: "done" });
302
+ db.run("UPDATE requirements SET tags_json='invalid legacy json' WHERE requirement_id=?1", [invalid.id]);
303
+ await seed("tag-visible", { tags: ["visible"], column: "doing", owner: { kind: "user", id: alice.id } });
304
+ await seed("tag-secret", { tags: ["secret"], column: "done" });
305
+ const archived = await seed("tag-archived", { tags: ["archived-only"], column: "doing" });
306
+ expect((await send(admin.token, "PATCH", `/api/requirements/${archived.id}?network_id=${NET}`, { archived: true })).status).toBe(200);
307
+ const r = await stats(admin.token, `&project_id=${project}`);
308
+ expect(r.status).toBe(200);
309
+ expect(r.body.tag_status).toMatchObject({ basis: "current", complete: true, includes_archived: false, project_filter: project, total: 8 });
310
+ const buckets = new Map(r.body.tag_status.by_tag.map((x: any) => [x.tag, x]));
311
+ expect(buckets.get("a")).toEqual({ tag: "a", pool: 1, doing: 0, done: 1, abandoned: 0, total: 2 });
312
+ expect(buckets.get("b")).toEqual({ tag: "b", pool: 1, doing: 1, done: 1, abandoned: 0, total: 3 });
313
+ expect(buckets.get(null)).toEqual({ tag: null, pool: 0, doing: 0, done: 1, abandoned: 1, total: 2 });
314
+ expect(buckets.get("无标签")).toMatchObject({ pool: 1, total: 1 });
315
+ expect(buckets.has("archived-only")).toBe(false);
316
+ expect(r.body.tag_status.by_tag[0].tag).toBe("b");
317
+ expect(r.body.tag_status.by_tag.reduce((sum: number, x: any) => sum + x.total, 0)).toBe(10);
318
+ });
319
+ test("snapshot ignores time range; completion totals still follow it", async () => {
320
+ const future = new Date(Date.now() + 86_400_000).toISOString();
321
+ const r = await stats(admin.token, `&project_id=${project}&from=${encodeURIComponent(future)}&to=${encodeURIComponent(new Date(Date.now()+2*86_400_000).toISOString())}`);
322
+ expect(r.body.totals.done).toBe(0); expect(r.body.tag_status.total).toBe(8);
323
+ expect(r.body.tag_status.by_tag.find((x: any) => x.tag === "a").done).toBe(1);
324
+ });
325
+ test("same visibility scope hides inaccessible tags and project results", async () => {
326
+ const r = await stats(alice.token, `&project_id=${project}`);
327
+ expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(1);
328
+ expect(r.body.tag_status.by_tag).toEqual([{ tag: "visible", pool: 0, doing: 1, done: 0, abandoned: 0, total: 1 }]);
329
+ expect(r.text).not.toContain('"secret"'); expect(r.text).not.toContain('"archived-only"');
330
+ const empty = await stats(alice.token, `&project_id=${PROJ}`);
331
+ expect(empty.body.tag_status.total).toBe(0); expect(empty.body.tag_status.by_tag).toEqual([]);
332
+ });
333
+ test("project none, unknown project and invalid project query", async () => {
334
+ const r = await stats(admin.token, "&project_id=none");
335
+ expect(r.body.tag_status.project_filter).toBe("none");
336
+ expect(r.body.tag_status.by_tag.every((x: any) => x.tag === null)).toBe(true);
337
+ expect((await stats(admin.token, "&project_id=proj_missing")).body.tag_status).toMatchObject({ total: 0, by_tag: [] });
338
+ expect((await stats(admin.token, `&project_id=${"x".repeat(201)}`)).body.error).toBe("invalid_project_id");
339
+ const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
340
+ expect(list.body.capabilities).toContain("tag_status_snapshot");
341
+ });
342
+ test("all visible rows counted beyond client 500 limit; another network excluded", async () => {
343
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "large-tag-status" });
344
+ expect(p.status).toBe(201); const large = p.body.project.id;
345
+ const other = `net_tag_foreign_${Date.now()}`;
346
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [other,admin.id]);
347
+ db.transaction(() => {
348
+ for (let i=0;i<502;i++) db.run(`INSERT INTO requirements(requirement_id,network_id,title,column_name,tags_json,project_id,archived,created_at,updated_at)
349
+ VALUES(?1,?2,'bulk','pool','["bulk"]',?3,0,datetime('now'),datetime('now'))`, [`req_tag_bulk_${large}_${i}`,i===501?other:NET,large]);
350
+ });
351
+ const r = await stats(admin.token, `&project_id=${large}&recent=0`);
352
+ expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(501);
353
+ expect(r.body.tag_status.by_tag).toEqual([{ tag: "bulk", pool: 501, doing: 0, done: 0, abandoned: 0, total: 501 }]);
354
+ const denied = await send(alice.token,"GET",`/api/requirements/stats?network_id=${other}`);
355
+ expect([403,404]).toContain(denied.status);
356
+ });
357
+ });
@@ -2,6 +2,7 @@
2
2
  // 这里只按时间范围 / 时区分桶、按项目 / 完成者计数。放在 JS 里而不是 SQL:按 IANA 时区切「哪一天」在
3
3
  // SQLite 和 PostgreSQL 上写法不同,而一个网络的卡片数是几百到几千,整表轻量列读一次就够。
4
4
  import { isoInstant } from "./requirements-migrate.js";
5
+ import { storedTags } from "./requirement-tags.js";
5
6
 
6
7
  export const STATS_DEFAULT_DAYS = 30;
7
8
  export const STATS_MAX_DAYS = 371; // 53 周:够画一整年的贡献热力图
@@ -21,9 +22,10 @@ export type StatsRow = {
21
22
  completed_at: string | null;
22
23
  completed_at_approx: number | null;
23
24
  completed_by_json: string | null;
25
+ tags_json?: string | null;
24
26
  };
25
27
 
26
- export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number };
28
+ export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number; projectId?: string | null };
27
29
  type Ref = { kind: "user" | "node"; id: string };
28
30
 
29
31
  /** 解析 from / to / tz / days。不合法 → 错误码(400)。to 缺省 = 现在;from 缺省 = 不设下限(全部)。 */
@@ -48,7 +50,10 @@ export function parseStatsQuery(q: URLSearchParams, nowMs: number): StatsQuery |
48
50
  const recentRaw = q.get("recent");
49
51
  const recent = recentRaw === null || recentRaw === "" ? STATS_DEFAULT_RECENT : Number(recentRaw);
50
52
  if (!Number.isInteger(recent) || recent < 0 || recent > STATS_MAX_RECENT) return { error: "invalid_recent" };
51
- return { fromMs: from, toMs, tz, days, recent };
53
+ const project = q.get("project_id");
54
+ if (project && (project.length > 200 || /[\u0000-\u001f\u007f]/u.test(project))) return { error: "invalid_project_id" };
55
+ return { fromMs: from, toMs, tz, days, recent,
56
+ ...(project ? { projectId: project === "none" ? null : project } : {}) };
52
57
  }
53
58
 
54
59
  function validTimeZone(tz: string): boolean {
@@ -86,6 +91,7 @@ const parseRef = (json: string | null): Ref | null => {
86
91
  * 但归到 unattributed,不出现在 by_completer 里 —— 不给他留「那个节点做了几张」的探测口。
87
92
  */
88
93
  export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId: string) => boolean) | null) {
94
+ if (q.projectId !== undefined) rows = rows.filter(row => row.project_id === q.projectId);
89
95
  const inRange = (ms: number | null) => ms !== null && ms <= q.toMs && (q.fromMs === null || ms >= q.fromMs);
90
96
  const days = dayRange(localDate(q.toMs, q.tz), q.days);
91
97
  const firstDay = days[0];
@@ -97,8 +103,21 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
97
103
  const byProject = new Map<string | null, number>();
98
104
  const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
99
105
  const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
106
+ const byTag = new Map<string | null, { tag: string | null; pool: number; doing: number; done: number; abandoned: number; total: number }>();
107
+ let snapshotTotal = 0;
100
108
 
101
109
  for (const row of rows) {
110
+ if (!row.archived && ["pool", "doing", "done", "abandoned"].includes(row.column_name)) {
111
+ snapshotTotal++;
112
+ const tags: (string | null)[] = storedTags(row.tags_json ?? null);
113
+ if (!tags.length) tags.push(null);
114
+ for (const tag of tags) {
115
+ let bucket = byTag.get(tag);
116
+ if (!bucket) byTag.set(tag, bucket = { tag, pool: 0, doing: 0, done: 0, abandoned: 0, total: 0 });
117
+ bucket[row.column_name as "pool" | "doing" | "done" | "abandoned"]++;
118
+ bucket.total++;
119
+ }
120
+ }
102
121
  // 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
103
122
  if (!row.archived && row.column_name === "doing") doing++;
104
123
  if (!row.archived && row.column_name === "pool") pool++;
@@ -133,6 +152,15 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
133
152
 
134
153
  const spark = days.slice(-STATS_SPARK_DAYS);
135
154
  return {
155
+ tag_status: {
156
+ basis: "current", complete: true, includes_archived: false,
157
+ project_filter: q.projectId === undefined ? null : q.projectId ?? "none",
158
+ total: snapshotTotal,
159
+ // Membership counts: a multi-tag card counts once in each distinct tag.
160
+ by_tag: [...byTag.values()].sort((a, b) =>
161
+ (b.pool + b.doing) - (a.pool + a.doing) || b.total - a.total ||
162
+ String(a.tag ?? "\uffff").localeCompare(String(b.tag ?? "\uffff"))),
163
+ },
136
164
  range: { from: q.fromMs === null ? null : new Date(q.fromMs).toISOString(), to: new Date(q.toMs).toISOString(), tz: q.tz },
137
165
  totals: {
138
166
  done,
@@ -770,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
770
770
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
771
771
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
772
772
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
773
- 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", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
773
+ 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_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
774
774
 
775
775
  // ── 子需求 ──
776
776
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1584,7 +1584,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1584
1584
  if ("error" in sq) return jsonError(sq.error, 400);
1585
1585
  const statsParams: unknown[] = [networkId];
1586
1586
  const statsSql = addTaskVisibilityScope(
1587
- "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",
1587
+ "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json, tags_json FROM requirements WHERE network_id = ?1",
1588
1588
  statsParams, taskCaller(ctx),
1589
1589
  );
1590
1590
  const rows = db.all<StatsRow>(statsSql, ...statsParams);
package/src/tools.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { selectHumanInboxIds } from "./human-inbox.js";
1
2
  import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { markTaskActivity } from "./task-activity.js";
@@ -1427,13 +1428,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1427
1428
 
1428
1429
  server.tool(
1429
1430
  "get_inbox",
1430
- "Read your inbox.",
1431
+ "Read inbox.",
1431
1432
  {
1432
- alias: z.string().min(1).max(200).describe("Alias"),
1433
+ alias: z.string().min(1).max(200),
1433
1434
  limit: z.number().min(1).max(100).optional().default(10),
1434
1435
  network_id: z.string().max(200).optional(),
1436
+ human_first: z.boolean().optional(),
1435
1437
  },
1436
- async ({ alias, limit, network_id: netId }) => {
1438
+ async ({ alias, limit, network_id: netId, human_first }) => {
1437
1439
  const effectiveNetId = getNetworkId(netId);
1438
1440
  const recipient = resolveInboxCaller(alias, { tokenId: callerTokenId, userId: enforceUserId, networkId: effectiveNetId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected });
1439
1441
  if (!recipient.ok) return { content: [{ type: "text" as const, text: JSON.stringify(recipient) }] };
@@ -1445,13 +1447,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1445
1447
  countSql = addReadScope(countSql, countParams, readScope);
1446
1448
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
1447
1449
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
1450
+ const scanParams: any[] = [alias];
1451
+ const scanWhere = addReadScope("FROM inbox WHERE session_name = ?1 AND acked = 0", scanParams, readScope);
1452
+ const human = human_first ? selectHumanInboxIds(scanWhere, scanParams, limit) : undefined;
1448
1453
  const rowsParams: any[] = [alias];
1449
1454
  let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
1450
1455
  CASE WHEN EXISTS(SELECT 1 FROM task_late_replies lr WHERE lr.inbox_id=inbox.id) THEN 1 ELSE 0 END AS late,
1451
1456
  CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
1452
1457
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
1453
1458
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
1454
- rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
1459
+ if (human?.ids.length) {
1460
+ const placeholders = human.ids.map((id) => { rowsParams.push(id); return `?${rowsParams.length}`; });
1461
+ rowsSql += ` ORDER BY CASE WHEN id IN (${placeholders.join(",")}) THEN 0 ELSE 1 END,`;
1462
+ } else rowsSql += " ORDER BY";
1463
+ rowsSql += ` CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at, rowid
1455
1464
  LIMIT ?${rowsParams.length + 1}`;
1456
1465
  rowsParams.push(limit);
1457
1466
  const rows = db.all(rowsSql, ...rowsParams).map((row: any) => {
@@ -1461,7 +1470,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1461
1470
  });
1462
1471
 
1463
1472
  return {
1464
- content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows }) }],
1473
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, messages: rows, ...(human ? { human_inbox: { version: 1, mode: "metadata-keyset", examined: human.examined } } : {}) }) }],
1465
1474
  };
1466
1475
  }
1467
1476
  );