@sleep2agi/commhub-server 0.9.0-preview.97 → 0.9.0-preview.98

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.97",
3
+ "version": "0.9.0-preview.98",
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",
@@ -235,3 +235,110 @@ describe("#506 GET /api/requirements last_event", () => {
235
235
  }
236
236
  });
237
237
  });
238
+
239
+ // #506 跟进 —— 评论不动 updated_at,所以增量读(changes=1 + updated_since)过去看不见它:「评论了」要等下次整读。
240
+ // 现在 changes=1(且带 last_event)时,游标之后有新流水的卡也回。updated_at 语义不变(评论不改卡)。
241
+ describe("#506 changes=1 returns cards whose newest event is after the cursor", () => {
242
+ const tick = () => new Promise(r => setTimeout(r, 5));
243
+ const changes = (t: string, since: string, extra = "", headers: Record<string, string> = {}) =>
244
+ list(t, `&changes=1&updated_since=${encodeURIComponent(since)}${extra}`, headers);
245
+ const cursorNow = async (t: string) => {
246
+ const base = await changes(t, new Date().toISOString());
247
+ expect(base.status).toBe(200);
248
+ await tick();
249
+ return base.body.server_time as string;
250
+ };
251
+
252
+ test("a comment after the cursor → that row comes back with the new last_event; untouched rows do not", async () => {
253
+ const a = await create(admin.token, { name: "增量评论 A" });
254
+ const b = await create(admin.token, { name: "增量评论 B" });
255
+ const before = rowOf(await list(admin.token), a.id);
256
+ await tick();
257
+ const since = await cursorNow(admin.token);
258
+ expect((await comment(member.token, a.id, "增量里看得到这条")).status).toBe(201);
259
+ const r = await changes(admin.token, since);
260
+ expect(r.status).toBe(200);
261
+ const ids = (r.body.requirements as any[]).map(x => x.id);
262
+ expect(ids).toEqual([a.id]);
263
+ expect(ids).not.toContain(b.id);
264
+ const row = rowOf(r, a.id);
265
+ expect(row.updatedAt).toBe(before.updatedAt); // updated_at 语义不变:评论不改卡
266
+ expect(row.last_event).toMatchObject({ type: "comment", actor: { id: member.id, kind: "user" }, summary: "增量里看得到这条" });
267
+ expect(r.body.deleted).toEqual([]);
268
+ // summary 视图同样回
269
+ const s = await changes(admin.token, since, "&view=summary");
270
+ expect((s.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
271
+ expect(rowOf(s, a.id).last_event.summary).toBe("增量里看得到这条");
272
+ });
273
+
274
+ test("no event and no update after the cursor → nothing returned; a comment then changes the result and its ETag", async () => {
275
+ const a = await create(admin.token, { name: "增量空" });
276
+ await tick();
277
+ const since = await cursorNow(admin.token);
278
+ const empty = await changes(admin.token, since);
279
+ expect(empty.status).toBe(200);
280
+ expect(empty.body.requirements).toEqual([]);
281
+ expect(empty.body.deleted).toEqual([]);
282
+ expect(empty.etag).toBeTruthy();
283
+ expect((await comment(admin.token, a.id, "空之后的评论")).status).toBe(201);
284
+ const after = await changes(admin.token, since, "", { "If-None-Match": empty.etag! });
285
+ expect(after.status).toBe(200);
286
+ expect(after.etag).not.toBe(empty.etag);
287
+ expect((after.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
288
+ });
289
+
290
+ test("full-list ETag changes on a comment (list cache invalidated by the write)", async () => {
291
+ const a = await create(admin.token, { name: "整表 ETag" });
292
+ const first = await list(admin.token);
293
+ expect((await list(admin.token, "", { "If-None-Match": first.etag! })).status).toBe(304);
294
+ expect((await comment(admin.token, a.id, "整表也变")).status).toBe(201);
295
+ const after = await list(admin.token, "", { "If-None-Match": first.etag! });
296
+ expect(after.status).toBe(200);
297
+ expect(after.etag).not.toBe(first.etag);
298
+ });
299
+
300
+ test("unchanged contracts: last_event=0 (MCP default) and plain updated_since (no changes=1) still filter by updated_at only", async () => {
301
+ const a = await create(admin.token, { name: "旧语义" });
302
+ await tick();
303
+ const since = await cursorNow(admin.token);
304
+ expect((await comment(admin.token, a.id, "只是一条评论")).status).toBe(201);
305
+ expect((await changes(admin.token, since, "&last_event=0")).body.requirements).toEqual([]);
306
+ const plain = await list(admin.token, `&updated_since=${encodeURIComponent(since)}`);
307
+ expect(plain.status).toBe(200);
308
+ expect(rowOf(plain, a.id)).toBeUndefined();
309
+ // MCP changes=true 默认不带 last_event → 同样不回;include_last_event=true → 回
310
+ const mDef = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since });
311
+ expect(mDef.isError).toBe(false);
312
+ expect(mDef.data.requirements.map((r: any) => r.id)).not.toContain(a.id);
313
+ const mOn = await mcp(nodeToken, "requirements_list", { changes: true, updated_since: since, include_last_event: true });
314
+ expect(mOn.isError).toBe(false);
315
+ expect(mOn.data.requirements.find((r: any) => r.id === a.id)?.last_event).toMatchObject({ type: "comment", summary: "只是一条评论" });
316
+ });
317
+
318
+ test("a real update after the cursor still comes back (updated_at path); a delete is reported in `deleted`, not as a row", async () => {
319
+ const a = await create(admin.token, { name: "增量改动" });
320
+ const d = await create(admin.token, { name: "增量删除" });
321
+ await comment(admin.token, d.id, "删之前的评论");
322
+ await tick();
323
+ const since = await cursorNow(admin.token);
324
+ expect((await send(admin.token, "PATCH", `/api/requirements/${a.id}?network_id=${NET}`, { priority: "high" })).status).toBe(200);
325
+ expect((await send(admin.token, "DELETE", `/api/requirements/${d.id}?network_id=${NET}`)).status).toBe(200);
326
+ const r = await changes(admin.token, since);
327
+ expect((r.body.requirements as any[]).map(x => x.id)).toEqual([a.id]);
328
+ expect(rowOf(r, a.id).last_event).toMatchObject({ type: "changed", field: "priority" });
329
+ expect(r.body.deleted).toEqual([d.id]);
330
+ });
331
+
332
+ test("visibility: a scoped member does not get a card it cannot see, even when it was commented on", async () => {
333
+ const hidden = await create(admin.token, { name: "scoped 看不见" });
334
+ const mine = await create(admin.token, { name: "scoped 看得见", owner: { kind: "user", id: scoped.id } });
335
+ await tick();
336
+ const since = await cursorNow(scoped.token);
337
+ expect((await comment(admin.token, hidden.id, "别人的卡上的评论")).status).toBe(201);
338
+ expect((await comment(admin.token, mine.id, "他的卡上的评论")).status).toBe(201);
339
+ const r = await changes(scoped.token, since);
340
+ expect(r.status).toBe(200);
341
+ expect((r.body.requirements as any[]).map(x => x.id)).toEqual([mine.id]);
342
+ expect(JSON.stringify(r.body)).not.toContain(hidden.id);
343
+ });
344
+ });
@@ -1105,7 +1105,16 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1105
1105
  if (since !== null) {
1106
1106
  const ms = Date.parse(since);
1107
1107
  if (!Number.isFinite(ms)) return jsonError("invalid_updated_since", 400);
1108
- sql += ` AND updated_at >= ?${params.push(new Date(ms).toISOString())}`;
1108
+ const n = params.push(new Date(ms).toISOString());
1109
+ // #506 —— 增量(changes=1)且带 last_event 时,「之后有新流水」的卡也算改过:评论只追加 requirement_events、
1110
+ // 不动 updated_at(updatedAt 是「卡本身最后一次被改」,评论不算 —— 见 last_event 测试),不这样评论要等下次整读才出现。
1111
+ // 同一个占位符用两次(SQLite / PostgreSQL 都认,没有绑了不用的参数);子查询走 idx_requirement_events_created,
1112
+ // 只扫游标之后的流水。不带 changes 的 updated_since 照旧只按 updated_at 过滤(同步方 / MCP 筛选语义不变);
1113
+ // last_event=0(MCP 默认)行上没有动态可看,也照旧。
1114
+ const eventsToo = q.get("changes") === "1" && q.get("last_event") !== "0";
1115
+ sql += eventsToo
1116
+ ? ` AND (updated_at >= ?${n} OR requirement_id IN (SELECT requirement_id FROM requirement_events WHERE created_at >= ?${n}))`
1117
+ : ` AND updated_at >= ?${n}`;
1109
1118
  }
1110
1119
  const seq = q.get("seq");
1111
1120
  if (seq !== null) {
package/src/tools.ts CHANGED
@@ -5977,7 +5977,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5977
5977
  limit: z.number().int().min(1).max(1000).optional().describe(`page size, default ${REQ_LIST_MCP_DEFAULT_LIMIT}`),
5978
5978
  cursor: z.string().max(600).optional().describe("next_cursor from the previous page"),
5979
5979
  view: z.enum(["full", "summary"]).optional().describe("default summary (no description / checklist bodies; has_description, checklist_count instead); full = everything"),
5980
- changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
5980
+ changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included; with include_last_event also ones with a newer event, e.g. a comment) + deleted ids + server_time"),
5981
5981
  include_last_event: z.boolean().optional().describe("default false; true adds last_event per row (newest event incl. comments: type, field, actor, at, summary; null if none), ~150 B/row"),
5982
5982
  };
5983
5983
  const reqListKeys = Object.keys(reqListShape);