@sleep2agi/commhub-server 0.9.0-preview.65 → 0.9.0-preview.67

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.65",
3
+ "version": "0.9.0-preview.67",
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
@@ -133,6 +133,9 @@ for (const col of [
133
133
  // Project folder view — the same session answers files_list / file_read on the
134
134
  // rules-file doorbell (node-files.ts). Sticky, bound-token only, like above.
135
135
  { name: "files_capable", def: "INTEGER NOT NULL DEFAULT 0" },
136
+ // Node run-log view — the same session answers logs_tail on the rules-file
137
+ // doorbell (node-logs.ts). Sticky, bound-token only, like above.
138
+ { name: "logs_capable", def: "INTEGER NOT NULL DEFAULT 0" },
136
139
  ]) {
137
140
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
138
141
  }
@@ -972,7 +975,7 @@ db.exec(`
972
975
  try { db.exec("ALTER TABLE requirements ADD COLUMN client_id TEXT"); } catch {}
973
976
  try { db.exec("ALTER TABLE requirements ADD COLUMN issues_json TEXT"); } catch {}
974
977
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_requirements_client ON requirements(network_id, client_id) WHERE client_id IS NOT NULL AND client_id != ''");
975
- for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'"]) {
978
+ for (const column of ["owner_json TEXT", "participants_json TEXT NOT NULL DEFAULT '[]'", "agent_owner_json TEXT", "description TEXT", "checklist_json TEXT", "project_id TEXT", "external_ref TEXT", "external_url TEXT", "archived INTEGER NOT NULL DEFAULT 0", "created_by_json TEXT", "updated_by_json TEXT", "parent_id TEXT"]) {
976
979
  try { db.exec(`ALTER TABLE requirements ADD COLUMN ${column}`); } catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
977
980
  }
978
981
 
@@ -1513,6 +1516,10 @@ db.exec(`
1513
1516
  ON scheduled_task_runs(network_id, created_at DESC);
1514
1517
  `);
1515
1518
  try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN misfire_policy TEXT NOT NULL DEFAULT 'catch_up_once'"); } catch {}
1519
+ // Skipped runs (overlap_policy=skip) name the open task that blocked them and
1520
+ // whether the node had ever picked it up: not_received | in_progress.
1521
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_task_id TEXT"); } catch {}
1522
+ try { db.exec("ALTER TABLE scheduled_task_runs ADD COLUMN blocked_by_state TEXT"); } catch {}
1516
1523
 
1517
1524
  // RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
1518
1525
  // This is intentionally separate from Hub scheduled_tasks: these rows are
@@ -0,0 +1,263 @@
1
+ // Node run-log view — tail_node_logs rides the rules-file queue + doorbell (op logs_tail).
2
+ //
3
+ // Hub-side contract verified here:
4
+ // 1. report_status { logs_capable: true } is sticky and set only by the node's
5
+ // own token; /api/status-shaped rows expose it.
6
+ // 2. The pulled request carries only the normalised filter JSON — no path field
7
+ // exists anywhere in the tool schema; unknown args are dropped.
8
+ // 3. Who may ask: user logins only (node tokens refused); the network owner/admin,
9
+ // or the node's owner (nodes.owner_user_id). A member who does not own the
10
+ // node, a viewer, and a user of another network are refused before any row.
11
+ // 4. Who may read: only the token that asked (like the project folder).
12
+ // 5. Storage: the result is handed out ONCE — the first terminal read purges
13
+ // result_content and the filter JSON; the second read says content_purged.
14
+ // An unread terminal result is purged by the sweep after LOGS_CONTENT_TTL_MS.
15
+ // 6. Own single-flight lane; logs acks may carry up to 1 MiB.
16
+ //
17
+ // 跑法:cd server && COMMHUB_DB=/tmp/logs-transport.db bun test src/node-logs-transport.test.ts
18
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import { db } from "./db.js";
23
+ import { registerTools } from "./tools.js";
24
+ import { LOGS_CONTENT_TTL_MS, sweepNodeRequestContent } from "./node-request-retention.js";
25
+
26
+ const NET = "net_logs_t";
27
+ const OTHER_NET = "net_logs_t_other";
28
+ const OWNER = "u_logs_net_owner";
29
+ const MEMBER = "u_logs_member_owns_a";
30
+ const MEMBER2 = "u_logs_member_plain";
31
+ const VIEWER = "u_logs_viewer";
32
+ const OUTSIDER = "u_logs_outsider";
33
+ const NODE_A = "logs-node-a";
34
+ const NODE_B = "logs-node-b";
35
+ const NODE_A_ID = "node_logs_a";
36
+ const NODE_B_ID = "node_logs_b";
37
+ const T_A = "tok_logs_node_a";
38
+ const T_B = "tok_logs_node_b";
39
+
40
+ function cleanup() {
41
+ for (const net of [NET, OTHER_NET]) {
42
+ for (const t of ["node_rules_requests", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
43
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [net]); } catch {}
44
+ }
45
+ }
46
+ for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
47
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
48
+ }
49
+ }
50
+
51
+ function seedWorld() {
52
+ for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
53
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [u, u]);
54
+ }
55
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
56
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [OTHER_NET, OTHER_NET, OUTSIDER]);
57
+ const member = (u: string, net: string, role: string) =>
58
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
59
+ member(OWNER, NET, "owner");
60
+ member(MEMBER, NET, "member");
61
+ member(MEMBER2, NET, "member");
62
+ member(VIEWER, NET, "viewer");
63
+ member(OUTSIDER, OTHER_NET, "owner");
64
+ const node = (id: string, alias: string, owner: string | null) => db.run(
65
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
66
+ [id, alias, alias, NET, owner],
67
+ );
68
+ node(NODE_A_ID, NODE_A, MEMBER);
69
+ node(NODE_B_ID, NODE_B, OWNER);
70
+ const tok = (id: string, alias: string, bound: string) => db.run(
71
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
72
+ [id, OWNER, NET, `node:${alias}`, `hash_${id}`, bound],
73
+ );
74
+ tok(T_A, NODE_A, NODE_A_ID);
75
+ tok(T_B, NODE_B, NODE_B_ID);
76
+ }
77
+
78
+ type Identity = { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
79
+ const asOwner: Identity = { net: null, user: OWNER, alias: OWNER, isNetworkToken: false, tokenId: "tok_logs_owner_login" };
80
+ const asOwnerOtherLogin: Identity = { ...asOwner, tokenId: "tok_logs_owner_login_2" };
81
+ const asMember: Identity = { net: null, user: MEMBER, alias: MEMBER, isNetworkToken: false, tokenId: "tok_logs_member_login" };
82
+ const asMember2: Identity = { net: null, user: MEMBER2, alias: MEMBER2, isNetworkToken: false, tokenId: "tok_logs_member2_login" };
83
+ const asViewer: Identity = { net: null, user: VIEWER, alias: VIEWER, isNetworkToken: false, tokenId: "tok_logs_viewer_login" };
84
+ const asOutsider: Identity = { net: null, user: OUTSIDER, alias: OUTSIDER, isNetworkToken: false, tokenId: "tok_logs_outsider_login" };
85
+ const asA: Identity = { net: NET, user: OWNER, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
86
+ const asB: Identity = { net: NET, user: OWNER, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
87
+
88
+ async function connect(id: Identity) {
89
+ const server = new McpServer({ name: "logs-test", version: "1" });
90
+ registerTools(server, undefined, id.net, id.user, id.alias, id.isNetworkToken, id.tokenId);
91
+ const client = new Client({ name: "logs-client", version: "1" });
92
+ const [ct, st] = InMemoryTransport.createLinkedPair();
93
+ await server.connect(st);
94
+ await client.connect(ct);
95
+ const call = async (name: string, args: Record<string, unknown>) => {
96
+ const r: any = await client.callTool({ name, arguments: args });
97
+ const first = r.content?.[0];
98
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
99
+ try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
100
+ };
101
+ const close = async () => { await client.close(); await server.close(); };
102
+ return { call, close };
103
+ }
104
+
105
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
106
+ const row = (id: string) => db.get<any>("SELECT op, content, result_content, content_purged_at FROM node_rules_requests WHERE request_id = ?1", id);
107
+ const SAMPLE = JSON.stringify({ files: ["2026-09-29.log"], lines: [{ ts: 1, level: "info", text: "[10:00:00] [INFO ] [x] hello", key: "2026-09-29.log:0" }], truncated: false, matched: 1, now_ts: 2 });
108
+
109
+ beforeEach(() => { cleanup(); seedWorld(); });
110
+ afterAll(cleanup);
111
+
112
+ describe("logs_capable", () => {
113
+ test("set by the node's own token, sticky, not settable by a user login", async () => {
114
+ const a = await connect(asA);
115
+ try {
116
+ await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, logs_capable: true });
117
+ await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID });
118
+ } finally { await a.close(); }
119
+ expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.c).toBe(1);
120
+ const u = await connect(asOwner);
121
+ try {
122
+ await u.call("report_status", { resume_id: "user-logs-report", alias: NODE_B, status: "idle", network_id: NET, logs_capable: true });
123
+ } finally { await u.close(); }
124
+ expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_B, NET)?.c ?? 0).toBe(0);
125
+ }, 20_000);
126
+ });
127
+
128
+ describe("round trip", () => {
129
+ test("the node pulls only the normalised filter JSON (no path), acks, the asker reads it once", async () => {
130
+ const u = await connect(asOwner);
131
+ const a = await connect(asA);
132
+ try {
133
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 99999 > 2000 ? 2000 : 1, level: "warn", grep: "boom", since_ts: 5, path: "/etc/passwd" });
134
+ expect(enq).toMatchObject({ ok: true, op: "logs_tail" });
135
+ const pulled = (await a.call("get_rules_file_request", {})).request;
136
+ expect(pulled.op).toBe("logs_tail");
137
+ expect(JSON.parse(pulled.content)).toEqual({ lines: 2000, level: "warn", grep: "boom", since_ts: 5 });
138
+ expect(pulled.content).not.toContain("passwd");
139
+ expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "logs", exists: true, content: SAMPLE })).toMatchObject({ ok: true, status: "done" });
140
+
141
+ const first = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
142
+ expect(first).toMatchObject({ ok: true, op: "logs_tail", status: "done", content: SAMPLE });
143
+ // Read-once: purged in the same call, including the filter JSON.
144
+ expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
145
+ expect(row(enq.request_id).content_purged_at).toBeGreaterThan(0);
146
+ const second = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
147
+ expect(second.content).toBeUndefined();
148
+ expect(second.content_purged).toBe(true);
149
+ } finally { await u.close(); await a.close(); }
150
+ }, 20_000);
151
+
152
+ test("lines above 2000 are refused by the schema; bad level refused", async () => {
153
+ const u = await connect(asOwner);
154
+ try {
155
+ expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 2001 })).ok).not.toBe(true);
156
+ expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, level: "debug" })).ok).not.toBe(true);
157
+ expect(rowCount()).toBe(0);
158
+ } finally { await u.close(); }
159
+ }, 20_000);
160
+ });
161
+
162
+ describe("who may ask", () => {
163
+ test("node tokens are refused — for other nodes and for themselves", async () => {
164
+ const a = await connect(asA);
165
+ try {
166
+ expect(await a.call("tail_node_logs", { node_id: NODE_B_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
167
+ expect(await a.call("tail_node_logs", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
168
+ expect(rowCount()).toBe(0);
169
+ } finally { await a.close(); }
170
+ }, 20_000);
171
+
172
+ test("node owner yes; plain member no; viewer no; network owner yes", async () => {
173
+ const m = await connect(asMember);
174
+ const m2 = await connect(asMember2);
175
+ const v = await connect(asViewer);
176
+ const o = await connect(asOwner);
177
+ try {
178
+ expect(await m.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
179
+ expect(await m.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
180
+ expect(await m2.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
181
+ expect((await v.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).ok).toBe(false);
182
+ expect(await o.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: true });
183
+ expect(rowCount()).toBe(2);
184
+ } finally { await m.close(); await m2.close(); await v.close(); await o.close(); }
185
+ }, 20_000);
186
+
187
+ test("a user of another network is refused (cross network)", async () => {
188
+ const x = await connect(asOutsider);
189
+ try {
190
+ const r1 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
191
+ expect(r1.ok).toBe(false);
192
+ const r2 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: OTHER_NET });
193
+ expect(r2).toMatchObject({ ok: false, error: "cross_network_node" });
194
+ expect(rowCount()).toBe(0);
195
+ } finally { await x.close(); }
196
+ }, 20_000);
197
+ });
198
+
199
+ describe("who may read", () => {
200
+ test("only the login that asked; another login of the same user, node tokens and the serving node cannot", async () => {
201
+ const u = await connect(asOwner);
202
+ const other = await connect(asOwnerOtherLogin);
203
+ const b = await connect(asB);
204
+ const a = await connect(asA);
205
+ try {
206
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
207
+ await a.call("get_rules_file_request", {});
208
+ await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
209
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ ok: false, error: "request_not_found" });
210
+ expect(await b.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
211
+ expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
212
+ // Those refusals did not consume the read-once result.
213
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ status: "done", content: SAMPLE });
214
+ } finally { await u.close(); await other.close(); await b.close(); await a.close(); }
215
+ }, 20_000);
216
+
217
+ test("node B never pulls or acks node A's logs request", async () => {
218
+ const u = await connect(asOwner);
219
+ const a = await connect(asA);
220
+ const b = await connect(asB);
221
+ try {
222
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
223
+ expect((await b.call("get_rules_file_request", {})).request).toBeNull();
224
+ expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
225
+ expect((await a.call("get_rules_file_request", {})).request.request_id).toBe(enq.request_id);
226
+ } finally { await u.close(); await a.close(); await b.close(); }
227
+ }, 20_000);
228
+ });
229
+
230
+ describe("storage and lanes", () => {
231
+ test("an unread terminal result is purged by the sweep after the logs TTL; other ops keep theirs", async () => {
232
+ const u = await connect(asOwner);
233
+ const a = await connect(asA);
234
+ try {
235
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
236
+ const rules = await u.call("read_node_rules_file", { node_id: NODE_A_ID, network_id: NET });
237
+ await a.call("get_rules_file_request", {});
238
+ await a.call("get_rules_file_request", {});
239
+ await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
240
+ await a.call("ack_rules_file_request", { request_id: rules.request_id, status: "done", content: "# rules" });
241
+ sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS - 5_000);
242
+ expect(row(enq.request_id).result_content).toBe(SAMPLE);
243
+ sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS + 5_000);
244
+ expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
245
+ expect(row(rules.request_id).result_content).toBe("# rules");
246
+ } finally { await u.close(); await a.close(); }
247
+ }, 20_000);
248
+
249
+ test("own single-flight lane; a 1 MiB-class ack is accepted", async () => {
250
+ const u = await connect(asOwner);
251
+ const a = await connect(asA);
252
+ try {
253
+ expect(await u.call("list_node_files", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
254
+ const first = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
255
+ expect(first).toMatchObject({ ok: true });
256
+ expect(await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
257
+ await a.call("get_rules_file_request", {});
258
+ await a.call("get_rules_file_request", {});
259
+ const big = "x".repeat(700 * 1024);
260
+ expect(await a.call("ack_rules_file_request", { request_id: first.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
261
+ } finally { await u.close(); await a.close(); }
262
+ }, 20_000);
263
+ });
@@ -29,6 +29,13 @@
29
29
  // was just nulled (it would otherwise receive `content: ""` and write an
30
30
  // empty rules file).
31
31
  // 3. Rows older than ROW_TTL_MS are deleted outright.
32
+ // 4. Run logs (op = logs_tail) are stricter: read-once with NO grace — the
33
+ // first authorized terminal read purges them in the same call
34
+ // (purgeLogsResultNow), because the app never follows another request's
35
+ // logs result (it simply asks again) and a process log is the most
36
+ // sensitive thing on this queue. Unread terminal results are purged after
37
+ // LOGS_CONTENT_TTL_MS (the requester closed the page). The filter JSON in
38
+ // `content` (it can hold a user-typed grep) goes with it.
32
39
  //
33
40
  // Every statement is bounded (LIMIT via `request_id IN (SELECT … LIMIT n)` —
34
41
  // SQLite's UPDATE/DELETE … LIMIT needs a compile flag we don't control) and
@@ -44,13 +51,15 @@ export const CONTENT_GRACE_MS = 60_000;
44
51
  export const CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
45
52
  export const ROW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
46
53
  export const SWEEP_BATCH = 500;
54
+ export const LOGS_CONTENT_TTL_MS = 5 * 60 * 1000;
47
55
 
48
56
  export type NodeRequestSweepResult = { timedOut: number; purged: number; deleted: number };
49
57
 
50
58
  // `content` is only a file body for op=write. For skill_read / file_read it is
51
59
  // the skill name / relative path the requester asked for — audit metadata, kept.
60
+ // For logs_tail it is the filter JSON, which may carry a user-typed grep — purged.
52
61
  const PURGE_SET =
53
- "result_content = NULL, content = CASE WHEN op = 'write' THEN NULL ELSE content END, content_purged_at = ?1";
62
+ "result_content = NULL, content = CASE WHEN op IN ('write', 'logs_tail') THEN NULL ELSE content END, content_purged_at = ?1";
54
63
 
55
64
  function purgeWhere(where: string, params: (string | number)[], now: number): number {
56
65
  const r = db.run(
@@ -77,6 +86,7 @@ export function sweepNodeRequestContent(now: number = Date.now()): NodeRequestSw
77
86
  // ?1 = now (PURGE_SET), ?2… = where params.
78
87
  out.purged += purgeWhere("first_read_at IS NOT NULL AND first_read_at <= ?2 AND status IN ('done', 'failed', 'timeout')", [now - CONTENT_GRACE_MS], now);
79
88
  out.purged += purgeWhere("created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [ttlCutoff], now);
89
+ out.purged += purgeWhere("op = 'logs_tail' AND created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [now - LOGS_CONTENT_TTL_MS], now);
80
90
  } catch (e: any) {
81
91
  console.log(`[commhub retention] node_rules_requests sweep failed: ${e?.message ?? e}`);
82
92
  }
@@ -102,3 +112,9 @@ export function noteTerminalResultRead(requestId: string, now: number = Date.now
102
112
  );
103
113
  return { purged: row?.content_purged_at != null };
104
114
  }
115
+
116
+ /** Run-log results are read-once: called by get_rules_file_result right after it
117
+ * handed a terminal logs_tail result to its (authorized) requester. */
118
+ export function purgeLogsResultNow(requestId: string, now: number = Date.now()): void {
119
+ db.run(`UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id = ?2 AND op = 'logs_tail' AND content_purged_at IS NULL`, [now, requestId]);
120
+ }