@sleep2agi/commhub-server 0.9.0-preview.53 → 0.9.0-preview.55
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/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/server.ts +35 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.55",
|
|
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",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { maybeGzipResponse, wantsGzip, isCompressibleResponse, trimLightTask, GZIP_MIN_BYTES, LIGHT_TASK_MAX_CHARS } from "./http-gzip";
|
|
3
|
+
|
|
4
|
+
const big = JSON.stringify({ sessions: Array.from({ length: 200 }, (_, i) => ({ alias: `节点${i}`, task: "x".repeat(50) })) });
|
|
5
|
+
const json = (body: string, extra: Record<string, string> = {}) =>
|
|
6
|
+
new Response(body, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ...extra } });
|
|
7
|
+
const reqWith = (ae?: string, method = "GET") =>
|
|
8
|
+
new Request("http://hub/api/status?light=1", { method, headers: ae === undefined ? {} : { "Accept-Encoding": ae } });
|
|
9
|
+
|
|
10
|
+
test("wantsGzip reads Accept-Encoding, including q-values", () => {
|
|
11
|
+
expect(wantsGzip(reqWith("gzip, deflate, br"))).toBe(true);
|
|
12
|
+
expect(wantsGzip(reqWith("br;q=1.0, gzip;q=0.8"))).toBe(true);
|
|
13
|
+
expect(wantsGzip(reqWith("identity"))).toBe(false);
|
|
14
|
+
expect(wantsGzip(reqWith("gzip;q=0"))).toBe(false);
|
|
15
|
+
expect(wantsGzip(reqWith())).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a large JSON body is gzipped only when the client asked, and round-trips", async () => {
|
|
19
|
+
const out = await maybeGzipResponse(reqWith("gzip"), json(big));
|
|
20
|
+
expect(out!.headers.get("Content-Encoding")).toBe("gzip");
|
|
21
|
+
expect(out!.headers.get("Vary")).toContain("Accept-Encoding");
|
|
22
|
+
const bytes = new Uint8Array(await out!.arrayBuffer());
|
|
23
|
+
expect(bytes.byteLength).toBeLessThan(big.length / 3);
|
|
24
|
+
expect(Number(out!.headers.get("Content-Length"))).toBe(bytes.byteLength);
|
|
25
|
+
expect(new TextDecoder().decode(Bun.gunzipSync(bytes))).toBe(big);
|
|
26
|
+
expect(out!.headers.get("Content-Type")).toContain("application/json");
|
|
27
|
+
|
|
28
|
+
const plain = json(big);
|
|
29
|
+
expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain); // untouched object
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("small, streamed, already-encoded, SSE, 204 and HEAD responses are left alone", async () => {
|
|
33
|
+
const small = json("{\"ok\":true}");
|
|
34
|
+
const outSmall = await maybeGzipResponse(reqWith("gzip"), small);
|
|
35
|
+
expect(outSmall!.headers.get("Content-Encoding")).toBeNull();
|
|
36
|
+
expect(await outSmall!.text()).toBe("{\"ok\":true}");
|
|
37
|
+
expect(GZIP_MIN_BYTES).toBe(1024);
|
|
38
|
+
|
|
39
|
+
const sse = new Response(new ReadableStream(), { headers: { "Content-Type": "text/event-stream" } });
|
|
40
|
+
expect(isCompressibleResponse(sse)).toBe(false);
|
|
41
|
+
expect(await maybeGzipResponse(reqWith("gzip"), sse)).toBe(sse);
|
|
42
|
+
|
|
43
|
+
const encoded = json(big, { "Content-Encoding": "br" });
|
|
44
|
+
expect(await maybeGzipResponse(reqWith("gzip"), encoded)).toBe(encoded);
|
|
45
|
+
|
|
46
|
+
const noContent = new Response(null, { status: 204 });
|
|
47
|
+
expect(await maybeGzipResponse(reqWith("gzip"), noContent)).toBe(noContent);
|
|
48
|
+
|
|
49
|
+
const head = json(big);
|
|
50
|
+
expect(await maybeGzipResponse(reqWith("gzip", "HEAD"), head)).toBe(head);
|
|
51
|
+
|
|
52
|
+
// /api/files downloads: Range replies and attachments keep their exact byte lengths (CI #509/#514 caught this)
|
|
53
|
+
const partial = new Response("x".repeat(4096), { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": "bytes 0-4095/8192" } });
|
|
54
|
+
expect(await maybeGzipResponse(reqWith("gzip"), partial)).toBe(partial);
|
|
55
|
+
const attachment = new Response("y".repeat(4096), { status: 200, headers: { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=\"a.txt\"" } });
|
|
56
|
+
expect(await maybeGzipResponse(reqWith("gzip"), attachment)).toBe(attachment);
|
|
57
|
+
|
|
58
|
+
const binary = new Response(new Uint8Array(4096), { headers: { "Content-Type": "image/png" } });
|
|
59
|
+
expect(await maybeGzipResponse(reqWith("gzip"), binary)).toBe(binary);
|
|
60
|
+
|
|
61
|
+
expect(await maybeGzipResponse(reqWith("gzip"), undefined)).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("trimLightTask keeps a one-line preview under the cap", () => {
|
|
65
|
+
expect(trimLightTask(null)).toBeNull();
|
|
66
|
+
expect(trimLightTask(" ")).toBeNull();
|
|
67
|
+
expect(trimLightTask("a\n b")).toBe("a b");
|
|
68
|
+
const long = "字".repeat(LIGHT_TASK_MAX_CHARS + 40);
|
|
69
|
+
const t = trimLightTask(long)!;
|
|
70
|
+
expect(t.endsWith("…")).toBe(true);
|
|
71
|
+
expect([...t].length).toBe(LIGHT_TASK_MAX_CHARS + 1);
|
|
72
|
+
expect(trimLightTask("short")).toBe("short");
|
|
73
|
+
});
|
package/src/http-gzip.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 2026-09-16(Vincent:「发一个消息都要被吞一段时间」「打开窗口历史很久才出来」):
|
|
2
|
+
// 桌面端经 RELAY 连 hub,链路实测只有 ~13 KB/s;/api/status?light=1 128 KB 要 17 s,
|
|
3
|
+
// 一页 20 条任务 41 KB 要 5.6 s,而 hub 从不压缩。gzip 后 128 KB → 31 KB、137 KB → 49 KB。
|
|
4
|
+
// 只在客户端明确 `Accept-Encoding: gzip` 时压;text/event-stream(SSE)与流式 body 一律不碰。
|
|
5
|
+
export const GZIP_MIN_BYTES = 1024;
|
|
6
|
+
|
|
7
|
+
const COMPRESSIBLE = /^(application\/json|text\/(plain|html|css|javascript)|application\/javascript)\b/i;
|
|
8
|
+
|
|
9
|
+
export function wantsGzip(req: Request): boolean {
|
|
10
|
+
const ae = req.headers.get("accept-encoding") || "";
|
|
11
|
+
for (const part of ae.split(",")) {
|
|
12
|
+
const [coding, ...params] = part.trim().split(";").map((x) => x.trim());
|
|
13
|
+
if (coding.toLowerCase() !== "gzip") continue;
|
|
14
|
+
const q = params.find((x) => /^q=/i.test(x));
|
|
15
|
+
if (!q) return true;
|
|
16
|
+
const n = Number(q.slice(2));
|
|
17
|
+
return Number.isFinite(n) && n > 0;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isCompressibleResponse(res: Response): boolean {
|
|
23
|
+
// 只压完整的 200:206 Partial Content / Content-Range / 附件下载(Content-Disposition)都有自己的
|
|
24
|
+
// 字节语义(Range 客户端按 Content-Length 数字节),压了就错 —— CI 的 /api/files Range 测试抓到的。
|
|
25
|
+
if (res.status !== 200 || !res.body) return false;
|
|
26
|
+
if (res.headers.get("content-encoding") || res.headers.get("content-range") || res.headers.get("content-disposition")) return false;
|
|
27
|
+
const ct = res.headers.get("content-type") || "";
|
|
28
|
+
if (/text\/event-stream/i.test(ct)) return false;
|
|
29
|
+
return COMPRESSIBLE.test(ct);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gzip a finished response when the client asked for it and the body is worth it.
|
|
34
|
+
* Reads the body once; callers must not have consumed it. Anything streamed or
|
|
35
|
+
* not compressible is returned untouched (same object).
|
|
36
|
+
*/
|
|
37
|
+
export async function maybeGzipResponse(req: Request, res: Response | undefined | void): Promise<Response | undefined> {
|
|
38
|
+
if (!res) return res as undefined;
|
|
39
|
+
if (req.method === "HEAD" || !wantsGzip(req) || !isCompressibleResponse(res)) return res;
|
|
40
|
+
const raw = new Uint8Array(await res.arrayBuffer());
|
|
41
|
+
if (raw.byteLength < GZIP_MIN_BYTES) {
|
|
42
|
+
return new Response(raw, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
43
|
+
}
|
|
44
|
+
const gz = Bun.gzipSync(raw);
|
|
45
|
+
const headers = new Headers(res.headers);
|
|
46
|
+
headers.set("Content-Encoding", "gzip");
|
|
47
|
+
headers.set("Content-Length", String(gz.byteLength));
|
|
48
|
+
const vary = headers.get("Vary");
|
|
49
|
+
headers.set("Vary", vary && !/accept-encoding/i.test(vary) ? `${vary}, Accept-Encoding` : (vary || "Accept-Encoding"));
|
|
50
|
+
return new Response(gz, { status: res.status, statusText: res.statusText, headers });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 轻量列表里的 task 只给列表预览用;全文占了 light 载荷的一半以上。 */
|
|
54
|
+
export const LIGHT_TASK_MAX_CHARS = 160;
|
|
55
|
+
export function trimLightTask(task: unknown): string | null {
|
|
56
|
+
if (typeof task !== "string") return null;
|
|
57
|
+
const t = task.replace(/\s+/g, " ").trim();
|
|
58
|
+
if (!t) return null;
|
|
59
|
+
return t.length > LIGHT_TASK_MAX_CHARS ? `${t.slice(0, LIGHT_TASK_MAX_CHARS)}…` : t;
|
|
60
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
|
|
8
|
+
// #1909 —— POST /api/messages/ack 的 `{ agent }` 形态:把调用者收到的、来自该 agent 的
|
|
9
|
+
// 两张表(user_inbox + inbox reply/task/message)全部标已读。角标分母 unread_by_agent
|
|
10
|
+
// 数的是全集,按 id 只能 ack 拉到的那一页 ⇒ 老行永远清不掉,这一形态补上。
|
|
11
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-ack-by-agent-http-"));
|
|
12
|
+
let server: any;
|
|
13
|
+
let base = "";
|
|
14
|
+
let tokenA = "";
|
|
15
|
+
let tokenB = "";
|
|
16
|
+
let userA = "";
|
|
17
|
+
let userB = "";
|
|
18
|
+
let userIdA = "";
|
|
19
|
+
let userIdB = "";
|
|
20
|
+
let netA = "";
|
|
21
|
+
let netB = "";
|
|
22
|
+
|
|
23
|
+
const AGENT = "ack-agent-x";
|
|
24
|
+
const OTHER = "ack-agent-y";
|
|
25
|
+
|
|
26
|
+
function seed() {
|
|
27
|
+
const ui = (id: string, net: string, uid: string, from: string) =>
|
|
28
|
+
db.run(
|
|
29
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, content) VALUES (?1, ?2, ?3, ?4, 'agent_message', 'x')`,
|
|
30
|
+
[id, net, uid, from],
|
|
31
|
+
);
|
|
32
|
+
const ib = (id: string, net: string, to: string, from: string, type: string) =>
|
|
33
|
+
db.run(
|
|
34
|
+
`INSERT INTO inbox (id, session_name, type, content, from_session, network_id) VALUES (?1, ?2, ?3, 'x', ?4, ?5)`,
|
|
35
|
+
[id, to, type, from, net],
|
|
36
|
+
);
|
|
37
|
+
ui("ui_a_x_1", netA, userIdA, AGENT);
|
|
38
|
+
ui("ui_a_x_2", netA, userIdA, AGENT);
|
|
39
|
+
ui("ui_a_y_1", netA, userIdA, OTHER);
|
|
40
|
+
ui("ui_b_x_1", netB, userIdB, AGENT);
|
|
41
|
+
ib("ib_a_x_reply", netA, userA, AGENT, "reply");
|
|
42
|
+
ib("ib_a_x_task", netA, userA, AGENT, "task");
|
|
43
|
+
ib("ib_a_x_status", netA, userA, AGENT, "status"); // 不在 reply/task/message 内,不该被 ack
|
|
44
|
+
ib("ib_a_y_reply", netA, userA, OTHER, "reply");
|
|
45
|
+
ib("ib_b_x_reply", netB, userB, AGENT, "reply");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
50
|
+
const stamp = `${Date.now()}_${process.pid}`;
|
|
51
|
+
userA = `ack_agent_a_${stamp}`;
|
|
52
|
+
userB = `ack_agent_b_${stamp}`;
|
|
53
|
+
const a = register(userA, "AckAgent123!", undefined, "seed");
|
|
54
|
+
const b = register(userB, "AckAgent123!", undefined, "seed");
|
|
55
|
+
expect(a.ok).toBe(true);
|
|
56
|
+
expect(b.ok).toBe(true);
|
|
57
|
+
tokenA = a.token!; netA = a.network_id!;
|
|
58
|
+
tokenB = b.token!; netB = b.network_id!;
|
|
59
|
+
userIdA = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userA])!.user_id;
|
|
60
|
+
userIdB = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userB])!.user_id;
|
|
61
|
+
seed();
|
|
62
|
+
const mod: any = await import("./server.js");
|
|
63
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
64
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
65
|
+
}, 30_000);
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
try { server?.stop?.(true); } catch {}
|
|
69
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function ack(token: string, body: unknown): Promise<{ status: number; body: any }> {
|
|
73
|
+
const response = await fetch(`${base}/api/messages/ack`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
});
|
|
78
|
+
return { status: response.status, body: await response.json() };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const uiAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM user_inbox WHERE message_id = ?1", [id])!.acked;
|
|
82
|
+
const ibAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", [id])!.acked;
|
|
83
|
+
|
|
84
|
+
async function unreadByAgent(token: string): Promise<Record<string, number>> {
|
|
85
|
+
const response = await fetch(`${base}/api/messages?scope=user&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
|
|
86
|
+
expect(response.status).toBe(200);
|
|
87
|
+
return ((await response.json()) as any).unread_by_agent ?? {};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("POST /api/messages/ack { agent } (#1909)", () => {
|
|
91
|
+
test("missing everything → message_id_required; agent + ids → ambiguous_ack", async () => {
|
|
92
|
+
const none = await ack(tokenA, {});
|
|
93
|
+
expect(none.status).toBe(400);
|
|
94
|
+
expect(none.body.error).toBe("message_id_required");
|
|
95
|
+
const both = await ack(tokenA, { agent: AGENT, message_ids: ["ui_a_x_1"] });
|
|
96
|
+
expect(both.status).toBe(400);
|
|
97
|
+
expect(both.body.error).toBe("ambiguous_ack");
|
|
98
|
+
// 拒绝的那次不能有副作用
|
|
99
|
+
expect(uiAcked("ui_a_x_1")).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("unread_by_agent counts the seeded rows before ack", async () => {
|
|
103
|
+
const by = await unreadByAgent(tokenA);
|
|
104
|
+
expect(by[AGENT]).toBe(4); // 2 user_inbox + reply + task (status 不计)
|
|
105
|
+
expect(by[OTHER]).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("agent form acks both tables, only that agent, only the caller", async () => {
|
|
109
|
+
const r = await ack(tokenA, { agent: ` ${AGENT} ` });
|
|
110
|
+
expect(r.status).toBe(200);
|
|
111
|
+
expect(r.body).toMatchObject({ ok: true, scope: "agent", agent: AGENT, acked: 4, acked_user_inbox: 2, acked_inbox: 2 });
|
|
112
|
+
expect(uiAcked("ui_a_x_1")).toBe(1);
|
|
113
|
+
expect(uiAcked("ui_a_x_2")).toBe(1);
|
|
114
|
+
expect(ibAcked("ib_a_x_reply")).toBe(1);
|
|
115
|
+
expect(ibAcked("ib_a_x_task")).toBe(1);
|
|
116
|
+
// 不该动的:别的 agent、非 reply/task/message 类型、别的用户
|
|
117
|
+
expect(uiAcked("ui_a_y_1")).toBe(0);
|
|
118
|
+
expect(ibAcked("ib_a_x_status")).toBe(0);
|
|
119
|
+
expect(ibAcked("ib_a_y_reply")).toBe(0);
|
|
120
|
+
expect(uiAcked("ui_b_x_1")).toBe(0);
|
|
121
|
+
expect(ibAcked("ib_b_x_reply")).toBe(0);
|
|
122
|
+
const by = await unreadByAgent(tokenA);
|
|
123
|
+
expect(by[AGENT] ?? 0).toBe(0);
|
|
124
|
+
expect(by[OTHER]).toBe(2);
|
|
125
|
+
expect((await unreadByAgent(tokenB))[AGENT]).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("agent form is idempotent: second call changes 0 rows", async () => {
|
|
129
|
+
const r = await ack(tokenA, { agent: AGENT });
|
|
130
|
+
expect(r.status).toBe(200);
|
|
131
|
+
expect(r.body.acked).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("id form still works and now reports scope ids", async () => {
|
|
135
|
+
const r = await ack(tokenA, { message_ids: ["ui_a_y_1", "ib_a_y_reply", "ui_b_x_1"] });
|
|
136
|
+
expect(r.status).toBe(200);
|
|
137
|
+
expect(r.body).toMatchObject({ ok: true, scope: "ids", acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
|
|
138
|
+
expect(uiAcked("ui_a_y_1")).toBe(1);
|
|
139
|
+
expect(ibAcked("ib_a_y_reply")).toBe(1);
|
|
140
|
+
expect(uiAcked("ui_b_x_1")).toBe(0); // 别人的 id 匹配不到
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
|
+
import { maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
2
3
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -792,6 +793,8 @@ return Bun.serve({
|
|
|
792
793
|
error: (err) => buildServeErrorResponse(err),
|
|
793
794
|
|
|
794
795
|
async fetch(req, server) {
|
|
796
|
+
// 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
|
|
797
|
+
return maybeGzipResponse(req, await (async (): Promise<Response | undefined> => {
|
|
795
798
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
796
799
|
|
|
797
800
|
// ── CORS preflight ──
|
|
@@ -1661,7 +1664,8 @@ return Bun.serve({
|
|
|
1661
1664
|
alias: s.alias,
|
|
1662
1665
|
status: s.status,
|
|
1663
1666
|
agent: s.agent ?? null,
|
|
1664
|
-
|
|
1667
|
+
// 列表预览只要一行;全文 task 占 light 载荷 52%(294 节点 128 KB)。全量 /api/status 不变。
|
|
1668
|
+
task: trimLightTask(s.task),
|
|
1665
1669
|
server: s.server ?? null,
|
|
1666
1670
|
updated_at: s.updated_at ?? null,
|
|
1667
1671
|
runtime: normalizeRuntime(s.agent),
|
|
@@ -2751,6 +2755,34 @@ return Bun.serve({
|
|
|
2751
2755
|
const ids: string[] = Array.isArray(body?.message_ids)
|
|
2752
2756
|
? body.message_ids.filter((x: unknown) => typeof x === "string" && x.length > 0)
|
|
2753
2757
|
: (typeof body?.message_id === "string" && body.message_id ? [body.message_id] : []);
|
|
2758
|
+
// #1909 —— 按 agent 整体 ack:`{ agent: "<alias>" }`。角标的分母是 `unread_by_agent`,
|
|
2759
|
+
// 它数的是**全部**未 ack 行,而客户端一页只看得到最近 200/300 条 ⇒ 老行永远 ack 不到,
|
|
2760
|
+
// 红点永远清不掉(生产 admin:user_inbox 522 + inbox 5660 行)。这一形态把调用者
|
|
2761
|
+
// 收到的、来自该 agent 的两张表全部标已读;同样只动自己的行。
|
|
2762
|
+
const agentRaw = typeof body?.agent === "string" ? body.agent.trim() : "";
|
|
2763
|
+
if (agentRaw && ids.length > 0) {
|
|
2764
|
+
return withCors(req, Response.json({ ok: false, error: "ambiguous_ack" }, { status: 400 }));
|
|
2765
|
+
}
|
|
2766
|
+
if (agentRaw) {
|
|
2767
|
+
const uiParams: any[] = [callerUserId, agentRaw];
|
|
2768
|
+
let uiSql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
|
|
2769
|
+
WHERE user_id = ?1 AND from_session = ?2 AND acked = 0`;
|
|
2770
|
+
uiSql = addNetworkScope(uiSql, uiParams, restScope);
|
|
2771
|
+
const uiAcked = db.run(uiSql, uiParams).changes ?? 0;
|
|
2772
|
+
let agentInboxAcked = 0;
|
|
2773
|
+
const agentCallerUsername = restAuth?.username ?? "";
|
|
2774
|
+
if (agentCallerUsername && !userInboxAliasCollides(agentCallerUsername, restScope)) {
|
|
2775
|
+
const ibAgentParams: any[] = [agentCallerUsername, agentRaw];
|
|
2776
|
+
let ibAgentSql = `UPDATE inbox SET acked = 1
|
|
2777
|
+
WHERE session_name = ?1 AND from_session = ?2 AND acked = 0 AND type IN ('reply', 'task', 'message')`;
|
|
2778
|
+
ibAgentSql = addNetworkScope(ibAgentSql, ibAgentParams, restScope);
|
|
2779
|
+
agentInboxAcked = db.run(ibAgentSql, ibAgentParams).changes ?? 0;
|
|
2780
|
+
}
|
|
2781
|
+
return withCors(req, Response.json({
|
|
2782
|
+
ok: true, scope: "agent", agent: agentRaw,
|
|
2783
|
+
acked: uiAcked + agentInboxAcked, acked_user_inbox: uiAcked, acked_inbox: agentInboxAcked,
|
|
2784
|
+
}));
|
|
2785
|
+
}
|
|
2754
2786
|
if (ids.length === 0) {
|
|
2755
2787
|
return withCors(req, Response.json({ ok: false, error: "message_id_required" }, { status: 400 }));
|
|
2756
2788
|
}
|
|
@@ -2777,7 +2809,7 @@ return Bun.serve({
|
|
|
2777
2809
|
ibSql = addNetworkScope(ibSql, ibParams, restScope);
|
|
2778
2810
|
inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
|
|
2779
2811
|
}
|
|
2780
|
-
return withCors(req, Response.json({ ok: true, acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
|
|
2812
|
+
return withCors(req, Response.json({ ok: true, scope: "ids", acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
|
|
2781
2813
|
}
|
|
2782
2814
|
|
|
2783
2815
|
// ── REST: stats summary ──
|
|
@@ -3571,6 +3603,7 @@ Security: ${SECURITY_LABEL}
|
|
|
3571
3603
|
`,
|
|
3572
3604
|
{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
|
|
3573
3605
|
));
|
|
3606
|
+
})());
|
|
3574
3607
|
},
|
|
3575
3608
|
|
|
3576
3609
|
// ── WebSocket handler for tmux terminal streaming ──
|