@sleep2agi/commhub-server 0.9.0-preview.87 → 0.9.0-preview.89

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.
@@ -0,0 +1,153 @@
1
+ // #464 —— 定时任务被一次没结束的执行卡住:告警 + 超时放行。
2
+ //
3
+ // 现场:overlap=skip 的排程,上一次派出的任务节点一直没有终态回复 → 之后每一次都记成
4
+ // skipped / previous_run_active,没有超时、也没人被告诉(当天 ~25 分钟,10-01 ~7 小时)。
5
+ //
6
+ // 1. 告警:同一个「卡住的任务」(blocked_by_task_id)连续挡掉 N 次 → 给排程的创建者发**一条**通知
7
+ // (agent-notice.ts,与 #462 同一条路,出现在与目标节点的会话里)。按挡路的任务计数,所以
8
+ // 一段卡住只发一次;任务一换(下一次真派出去了)自然重新上膛。不依赖内存,Hub 重启不会重发。
9
+ // 2. 超时:挡路的任务开着超过 stuckTimeoutMs(间隔)就按**已有的** expired 状态结束它(同 TTL 巡检:
10
+ // tasks.status='expired' + 收件箱行置 acked + db.ts 的镜像把这次 run 记成 expired / task_expired),
11
+ // 然后这一次照常派发。任务行不删,照样能查;晚到的回复被 send_reply 以 reply_task_terminal 拒掉,
12
+ // 什么都不改(不重开 run、不碰新派出去的那条)。
13
+ // 如果超时发生在 N 次之前(长间隔的排程,比如每天一次),那一刻补发一条「已超时结束」—— 每段卡住
14
+ // 恰好一条通知。
15
+ //
16
+ // 默认值(PR 里有推导):N=3;超时 = clamp(6 × 间隔, 下限 1 小时, 上限 24 小时)。
17
+ // 下限 1 小时:一轮 agent 任务正常要跑几十分钟,不能因为排程间隔短就把还在干活的任务掐掉。
18
+ // 上限 24 小时:Hub 本来就给定时任务 24 小时的 expires_at(没被取走时由巡检过期),开着超过一天的
19
+ // 定时任务不是「还在做」。6 × 间隔:间隔长的排程(每小时一次)给它 6 小时,而不是 1 小时。
20
+ // 环境变量可改(运维 / 测试):COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS、COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR、
21
+ // COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC、COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC。
22
+
23
+ import { db, syncScheduledRunForTask } from "./db.js";
24
+ import { parseDbTimestampMs } from "./db-timestamp.js";
25
+ import { sendAgentNotice } from "./agent-notice.js";
26
+
27
+ export const SCHEDULE_STUCK_NOTICE_KIND = "schedule_stuck";
28
+
29
+ function envNumber(name: string, fallback: number, min: number): number {
30
+ const raw = process.env[name];
31
+ if (raw === undefined || raw.trim() === "") return fallback;
32
+ const n = Number(raw);
33
+ return Number.isFinite(n) && n >= min ? n : fallback;
34
+ }
35
+
36
+ export function stuckNoticeSkips(): number {
37
+ return Math.floor(envNumber("COMMHUB_SCHEDULE_STUCK_NOTICE_SKIPS", 3, 1));
38
+ }
39
+
40
+ /** 排程的名义间隔(秒):interval 取 every_seconds;daily / weekly 取一天(两次之间最短一天);once 取 0。 */
41
+ export function nominalIntervalSeconds(scheduleJson: string): number {
42
+ try {
43
+ const spec = JSON.parse(scheduleJson) as { type?: string; every_seconds?: number };
44
+ if (spec.type === "interval" && Number.isFinite(spec.every_seconds)) return Number(spec.every_seconds);
45
+ if (spec.type === "daily" || spec.type === "weekly") return 86_400;
46
+ } catch {}
47
+ return 0;
48
+ }
49
+
50
+ export function stuckTimeoutMs(scheduleJson: string): number {
51
+ const factor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FACTOR", 6, 0);
52
+ const floor = envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_FLOOR_SEC", 3_600, 1);
53
+ const cap = Math.max(floor, envNumber("COMMHUB_SCHEDULE_STUCK_TIMEOUT_CAP_SEC", 86_400, 1));
54
+ return Math.min(cap, Math.max(floor, factor * nominalIntervalSeconds(scheduleJson))) * 1000;
55
+ }
56
+
57
+ type Blocker = { task_id: string; created_at: string; status: string };
58
+
59
+ /** 挡路的任务开了多久已经到超时 → true。读不出时间 = 不超时(宁可多挡一次,不错杀)。 */
60
+ export function blockerTimedOut(blocker: Pick<Blocker, "created_at">, scheduleJson: string, now: Date): boolean {
61
+ const started = parseDbTimestampMs(blocker.created_at);
62
+ if (!Number.isFinite(started)) return false;
63
+ return now.getTime() - started >= stuckTimeoutMs(scheduleJson);
64
+ }
65
+
66
+ /**
67
+ * 在调用方的事务里按超时结束挡路的任务。只动还开着的任务(条件写),返回是否真的结束了它。
68
+ * 与 TTL 巡检(server.ts patrolExpiredTasks)同一套写法:status=expired、收件箱行置 acked、镜像到 run。
69
+ */
70
+ export function expireStuckBlocker(taskId: string, networkId: string, ageMs: number, limitMs: number): boolean {
71
+ const changed = db.run(
72
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
73
+ WHERE task_id = ?1 AND network_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`,
74
+ [taskId, networkId],
75
+ );
76
+ if (changed.changes < 1) return false;
77
+ db.run("UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0", [taskId]);
78
+ syncScheduledRunForTask(taskId, networkId);
79
+ db.run(
80
+ "UPDATE scheduled_task_runs SET error_message = ?1 WHERE task_id = ?2 AND network_id = ?3",
81
+ [`timed out by the scheduler: open for ${Math.round(ageMs / 60_000)} min (limit ${Math.round(limitMs / 60_000)} min); the next occurrence was dispatched`, taskId, networkId],
82
+ );
83
+ return true;
84
+ }
85
+
86
+ /** 这个任务已经挡掉了几次(只算 previous_run_active 的跳过)。 */
87
+ export function skipsBlockedBy(scheduleId: string, taskId: string): number {
88
+ return db.get<{ n: number }>(
89
+ "SELECT COUNT(*) AS n FROM scheduled_task_runs WHERE schedule_id = ?1 AND blocked_by_task_id = ?2 AND error_code = 'previous_run_active'",
90
+ scheduleId, taskId,
91
+ )?.n ?? 0;
92
+ }
93
+
94
+ export type StuckNotice = {
95
+ reason: "stuck" | "timed_out";
96
+ scheduleId: string;
97
+ scheduleName: string;
98
+ networkId: string;
99
+ createdBy: string | null;
100
+ alias: string;
101
+ timezone: string;
102
+ taskId: string;
103
+ taskCreatedAt: string;
104
+ skips: number;
105
+ timeoutMs: number;
106
+ };
107
+
108
+ function fmtTime(dbTs: string, timezone: string): string {
109
+ const ms = parseDbTimestampMs(dbTs);
110
+ if (!Number.isFinite(ms)) return dbTs;
111
+ try {
112
+ const s = new Intl.DateTimeFormat("sv-SE", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(ms));
113
+ return `${s} (${timezone})`;
114
+ } catch { return dbTs; }
115
+ }
116
+
117
+ function fmtDuration(ms: number): string {
118
+ const min = Math.round(ms / 60_000);
119
+ if (min < 60) return `${min} 分钟`;
120
+ const h = Math.floor(min / 60), m = min % 60;
121
+ return m ? `${h} 小时 ${m} 分钟` : `${h} 小时`;
122
+ }
123
+
124
+ export function stuckNoticeText(n: StuckNotice): { title: string; text: string } {
125
+ const started = fmtTime(n.taskCreatedAt, n.timezone);
126
+ if (n.reason === "stuck") {
127
+ return {
128
+ title: "定时任务被卡住",
129
+ text: `定时任务「${n.scheduleName}」被上一次的执行卡住了:任务 ${n.taskId} 于 ${started} 派给节点 ${n.alias},到现在还没有完成,之后的 ${n.skips} 次执行都被跳过。\n\n`
130
+ + `它开着超过 ${fmtDuration(n.timeoutMs)} 后会被自动按超时结束,下一次照常派发。等不及可以在任务列表里取消这条任务。`,
131
+ };
132
+ }
133
+ return {
134
+ title: "定时任务已超时放行",
135
+ text: `定时任务「${n.scheduleName}」上一次的任务 ${n.taskId}(${started} 派给节点 ${n.alias})开着超过 ${fmtDuration(n.timeoutMs)} 还没有完成,已按超时结束,不再挡后面的执行。`,
136
+ };
137
+ }
138
+
139
+ /** 提交之后调用。没有创建者 / 创建者不在网络里 → 不发。 */
140
+ export function sendStuckNotice(n: StuckNotice): string | null {
141
+ if (!n.createdBy) return null;
142
+ try {
143
+ const { title, text } = stuckNoticeText(n);
144
+ return sendAgentNotice({
145
+ networkId: n.networkId, userId: n.createdBy, fromAlias: n.alias, kind: SCHEDULE_STUCK_NOTICE_KIND, title, text,
146
+ meta: { schedule_stuck: { reason: n.reason, schedule_id: n.scheduleId, blocked_by_task_id: n.taskId, skips: n.skips } },
147
+ idPrefix: "dm_sched_",
148
+ });
149
+ } catch (e: any) {
150
+ console.error(`[scheduled-tasks] stuck notice failed schedule=${n.scheduleId}: ${e?.message || e}`);
151
+ return null;
152
+ }
153
+ }
@@ -5,6 +5,8 @@ import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetwork
5
5
  import { canMessageAgent } from "./agent-access.js";
6
6
  import { hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
7
  import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
8
+ import { parseDbTimestampMs } from "./db-timestamp.js";
9
+ import { blockerTimedOut, expireStuckBlocker, sendStuckNotice, skipsBlockedBy, stuckNoticeSkips, stuckTimeoutMs, type StuckNotice } from "./scheduled-stuck.js";
8
10
 
9
11
  export type ScheduleSpec =
10
12
  | { type: "once"; run_at: string }
@@ -203,6 +205,13 @@ function validateTarget(networkId: string, nodeId: unknown): { node_id: string;
203
205
  return { node_id: node.node_id, alias: node.alias };
204
206
  }
205
207
 
208
+ function stuckNoticeFor(row: ScheduledRow, reason: StuckNotice["reason"], blocker: { task_id: string; created_at: string }, skips: number, timeoutMs: number): StuckNotice {
209
+ return {
210
+ reason, scheduleId: row.schedule_id, scheduleName: row.name, networkId: row.network_id, createdBy: row.created_by,
211
+ alias: row.target_alias, timezone: row.timezone, taskId: blocker.task_id, taskCreatedAt: blocker.created_at, skips, timeoutMs,
212
+ };
213
+ }
214
+
206
215
  type DispatchEvent = { alias: string; networkId: string; taskId: string; priority: string; state: "delivered" | "queued" };
207
216
 
208
217
  export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: string, advanceSchedule: boolean, advanceAfter = new Date()): { runId: string; taskId?: string; status: string; event?: DispatchEvent } {
@@ -210,6 +219,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
210
219
  let event: DispatchEvent | undefined;
211
220
  let finalStatus = "failed";
212
221
  let createdTaskId: string | undefined;
222
+ const notices: StuckNotice[] = [];
223
+ const timedOut: Array<{ taskId: string; fromStatus: string }> = [];
213
224
 
214
225
  db.transaction(() => {
215
226
  // UNIQUE(schedule_id, scheduled_for) is the cross-process claim. If two
@@ -225,8 +236,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
225
236
  // received:节点那边有没有把这条任务取走过(inbox 行被 ack,或任务已进 acked/running)。
226
237
  // 跳过记录带上它,客户端才分得清「节点还没收到」和「节点在处理」—— 前者是投递故障,
227
238
  // 后者才是真的上一次还没做完。
228
- const open = db.get<{ task_id: string; received: number }>(
229
- `SELECT r.task_id,
239
+ const findOpen = () => db.get<{ task_id: string; received: number; created_at: string; status: string }>(
240
+ `SELECT r.task_id, t.created_at, t.status,
230
241
  CASE WHEN t.status IN ('acked', 'running')
231
242
  OR EXISTS (SELECT 1 FROM inbox i WHERE (i.task_id = r.task_id OR i.id = r.task_id) AND i.acked = 1)
232
243
  THEN 1 ELSE 0 END AS received
@@ -236,6 +247,20 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
236
247
  ORDER BY r.created_at DESC LIMIT 1`,
237
248
  row.schedule_id, ...OPEN_TASK_STATUSES,
238
249
  );
250
+ let open = findOpen();
251
+ // #464 — a blocker open past the stuck timeout is ended as `expired` (the existing terminal state) and
252
+ // this occurrence dispatches normally. Bounded: each pass ends one exact open task or stops.
253
+ for (let guard = 0; open && guard < 20 && blockerTimedOut(open, row.schedule_json, advanceAfter); guard++) {
254
+ const limitMs = stuckTimeoutMs(row.schedule_json);
255
+ const ageMs = advanceAfter.getTime() - parseDbTimestampMs(open.created_at);
256
+ if (!expireStuckBlocker(open.task_id, row.network_id, ageMs, limitMs)) break;
257
+ timedOut.push({ taskId: open.task_id, fromStatus: open.status });
258
+ // Each stuck episode gets exactly one notice: if the stuck notice already went out (N skips), stay quiet.
259
+ if (skipsBlockedBy(row.schedule_id, open.task_id) < stuckNoticeSkips()) {
260
+ notices.push(stuckNoticeFor(row, "timed_out", open, skipsBlockedBy(row.schedule_id, open.task_id), limitMs));
261
+ }
262
+ open = findOpen();
263
+ }
239
264
  if (open) {
240
265
  finalStatus = "skipped";
241
266
  const blockedState = open.received ? "in_progress" : "not_received";
@@ -251,6 +276,9 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
251
276
  blockedState,
252
277
  ],
253
278
  );
279
+ // #464 — the Nth consecutive skip behind the same task tells the schedule's creator, once.
280
+ const skips = skipsBlockedBy(row.schedule_id, open.task_id);
281
+ if (skips === stuckNoticeSkips()) notices.push(stuckNoticeFor(row, "stuck", open, skips, stuckTimeoutMs(row.schedule_json)));
254
282
  if (advanceSchedule) advance(row, scheduledFor, advanceAfter);
255
283
  return;
256
284
  }
@@ -349,6 +377,8 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
349
377
  // never turn a durably-created task into an API-level failure: callers may
350
378
  // retry an apparent failure and create a second manual occurrence.
351
379
  try {
380
+ for (const t of timedOut) logTaskEvent(t.taskId, t.fromStatus, "expired", "hub-scheduler", `stuck timeout; schedule=${row.schedule_id} run=${runId}`);
381
+ for (const n of notices) sendStuckNotice(n);
352
382
  if (createdTaskId) logTaskEvent(createdTaskId, null, "delivered", "hub-scheduler", `schedule=${row.schedule_id} run=${runId}`);
353
383
  if (event) {
354
384
  const pending = db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND network_id = ?2 AND acked = 0", event.alias, event.networkId);
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { buildServeErrorResponse } from "./serve-error.js";
2
- import { maybeGzipResponse, trimLightTask } from "./http-gzip";
2
+ import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
3
+ import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
3
4
  import { statusAliasResolverRead } from "./status-alias-resolver.js";
4
5
  import { readNodeHealth } from "./node-health-store.js";
5
6
  import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
@@ -2310,6 +2311,11 @@ return Bun.serve({
2310
2311
  sql += ` AND node_id = ?${params.length}`;
2311
2312
  }
2312
2313
  sql += " ORDER BY updated_at DESC";
2314
+ // #431 —— 同一份正文不重复算(status-read-cache.ts):键 = 最终 SQL + 参数(含调用者能看到谁)+ 投影;
2315
+ // 写 sessions / 健康上报 / 健康过期就失效。正文逐字节等于当场重算(status-read-cache-http.test.ts)。
2316
+ const memoKey = JSON.stringify([sql, params, isLight, withNodeId]);
2317
+ const { body: statusBody, etag: statusEtag } = memoStatusBody(memoKey, () => {
2318
+ let timeDependent = false;
2313
2319
  // `model` comes straight from the explicit sessions projection; `runtime` is
2314
2320
  // derived from the raw `agent` field. Both default to null for old nodes
2315
2321
  // that never reported a model — the dashboard falls back to a placeholder.
@@ -2372,6 +2378,8 @@ return Bun.serve({
2372
2378
  // null = not reported / stale / older agent-node — never "healthy".
2373
2379
  ...(() => {
2374
2380
  const h = readNodeHealth(s.network_id, s.alias);
2381
+ // health_observed_ms_ago 随时间走:带上它的正文不能缓存。
2382
+ if (h) timeDependent = true;
2375
2383
  return { health: h?.health ?? null, health_observed_ms_ago: h?.observed_ms_ago ?? null };
2376
2384
  })(),
2377
2385
  ...degradedField(s.network_id, s.alias),
@@ -2384,8 +2392,16 @@ return Bun.serve({
2384
2392
  else acc.idle++;
2385
2393
  return acc;
2386
2394
  }, { idle: 0, working: 0, offline: 0, total: sessions.length });
2387
- const res = Response.json({ ok: true, sessions, summary });
2388
- if (aliasResolverRead) res.headers.set("X-Status-Projection", "alias-resolver");
2395
+ return { body: JSON.stringify({ ok: true, sessions, summary }), cacheable: !timeDependent };
2396
+ });
2397
+ // 强 ETag = 正文哈希;If-None-Match 命中回 304 空体(旧 agent-node 不发,app / dashboard 接上后受益)。
2398
+ const statusHeaders: Record<string, string> = { ETag: statusEtag, "Cache-Control": "private, no-cache" };
2399
+ if (aliasResolverRead) statusHeaders["X-Status-Projection"] = "alias-resolver";
2400
+ if (ifNoneMatchHits(req.headers.get("if-none-match"), statusEtag)) {
2401
+ return withCors(req, new Response(null, { status: 304, headers: statusHeaders }));
2402
+ }
2403
+ // Content-Type 与 Response.json 的一致;同一份正文的 gzip 结果按 ETag 复用(http-gzip.ts)。
2404
+ const res = markGzipReusable(new Response(statusBody, { headers: { ...statusHeaders, "Content-Type": "application/json;charset=utf-8" } }), statusEtag);
2389
2405
  return withCors(req, res);
2390
2406
  }
2391
2407
 
@@ -0,0 +1,229 @@
1
+ // #431 —— GET /api/status 记忆化 + 强 ETag(status-read-cache.ts)。钉住:
2
+ // - 正文逐字节不变:随机写序列(改 sessions、健康上报、健康过期、改名清理)之后,每种读法「可能命中缓存的那一份」
3
+ // 都等于「清空缓存当场重算的那一份」;而且确实命中过(不是全靠重算凑出来的绿);
4
+ // - JSON.stringify 与原来的 Response.json 对这些正文逐字节一致;Content-Type 不变;
5
+ // - 不同调用者(另一个网络的用户、节点令牌的别名解析读法)键不串;
6
+ // - 全量投影里有健康报告(health_observed_ms_ago 随时间走)就不缓存;
7
+ // - ETag / If-None-Match → 304;gzip 解开等于原文。
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { gunzipSync } from "zlib";
13
+ import { register, createNetworkTokenForNode } from "./auth.js";
14
+ import { db } from "./db.js";
15
+ import { clearNodeHealthStore, recordNodeHealth, NODE_HEALTH_TTL_MS } from "./node-health-store.js";
16
+ import { __resetStatusCacheForTest, __setStatusCacheBypassForTest, noteStatement, statusCacheStats, statusWriteGeneration } from "./status-read-cache.js";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-status-read-cache-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ const PW = "StatusReadCachePassw0rd!";
22
+ let userToken = "", userId = "", NET = "", nodeToken = "", otherToken = "", OTHER_NET = "";
23
+ const ROWS = 60;
24
+
25
+ // 时间戳在 JS 里算好再绑定(SQLite 的 datetime('now', …) 拼接在 PostgreSQL 上没有,这份测试两种库都跑)。
26
+ const ts = (offsetSec: number) => new Date(Date.now() + offsetSec * 1000).toISOString().replace("T", " ").slice(0, 19);
27
+ function seed(i: number) {
28
+ const node = `node_src_${i}`, alias = i % 2 ? `示例-${i}` : `src-${i}`;
29
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, NET, userId]);
30
+ db.run(
31
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, output, project_dir, hostname, server, agent, version,
32
+ cpu_load_1min, mem_total_gb, process_rss_bytes, external_schedules, updated_at, last_seen_at, registered_at)
33
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, '2.5.0-preview.88', '1.5', '61.4', 121868288, ?11,
34
+ ?12, ?13, '2026-09-01 00:00:00')`,
35
+ [`r_${node}`, alias, node, i % 3 ? "idle" : "offline", NET, `示例任务 ${i}:\n "引号" \\ 反斜杠 
 `.repeat(1 + (i % 5)),
36
+ i % 4 ? null : `示例输出 ${i}`, `/home/user/project-${i}`, `sample-host-${i % 4}`,
37
+ i % 2 ? "agent-node:codex-app-server" : "claude-code", i % 3 ? null : JSON.stringify([{ id: `s${i}`, every: 60 }]), ts(-i), ts(0)],
38
+ );
39
+ }
40
+
41
+ async function get(path: string, headers: Record<string, string>) {
42
+ const res = await fetch(`${BASE}${path}`, { headers });
43
+ const buf = new Uint8Array(await res.arrayBuffer());
44
+ return { status: res.status, buf, headers: res.headers };
45
+ }
46
+ const text = (b: Uint8Array) => new TextDecoder().decode(b);
47
+
48
+ type Variant = { name: string; path: () => string; headers: () => Record<string, string> };
49
+ const VARIANTS: Variant[] = [
50
+ { name: "user full", path: () => `/api/status?network_id=${NET}`, headers: () => ({ Authorization: `Bearer ${userToken}` }) },
51
+ { name: "user light", path: () => `/api/status?network_id=${NET}&light=1`, headers: () => ({ Authorization: `Bearer ${userToken}` }) },
52
+ { name: "user alias", path: () => `/api/status?network_id=${NET}&alias=${encodeURIComponent("示例-7")}`, headers: () => ({ Authorization: `Bearer ${userToken}` }) },
53
+ { name: "node light node_id", path: () => `/api/status?network_id=${NET}&light=1&node_id=node_src_9`, headers: () => ({ Authorization: `Bearer ${nodeToken}` }) },
54
+ { name: "old alias resolver", path: () => `/api/status?network_id=${NET}`, headers: () => ({ Authorization: `Bearer ${nodeToken}`, Accept: "application/json" }) },
55
+ { name: "node full=1", path: () => `/api/status?network_id=${NET}&full=1`, headers: () => ({ Authorization: `Bearer ${nodeToken}`, Accept: "application/json" }) },
56
+ { name: "other network user", path: () => `/api/status?network_id=${OTHER_NET}`, headers: () => ({ Authorization: `Bearer ${otherToken}` }) },
57
+ ];
58
+
59
+ beforeAll(async () => {
60
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
61
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
62
+ process.env.HOST = "127.0.0.1";
63
+ const a = register(`src_owner_${Date.now()}`, PW);
64
+ userToken = a.token!; userId = a.user!.user_id; NET = a.network_id!;
65
+ for (let i = 0; i < ROWS; i++) seed(i);
66
+ nodeToken = createNetworkTokenForNode(userId, NET, "src-8", "node_src_8").token!;
67
+ const b = register(`src_other_${Date.now()}`, PW);
68
+ otherToken = b.token!; OTHER_NET = b.network_id!;
69
+ const mod: any = await import("./server.js");
70
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
71
+ BASE = `http://127.0.0.1:${hub.port}`;
72
+ }, 30_000);
73
+
74
+ afterAll(() => {
75
+ try { hub?.stop?.(true); } catch {}
76
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
77
+ });
78
+
79
+ describe("write detection", () => {
80
+ test("writes that name sessions bump the generation; reads and other tables do not", () => {
81
+ const g = statusWriteGeneration();
82
+ noteStatement("SELECT * FROM sessions");
83
+ noteStatement("UPDATE tasks SET status = 'x'");
84
+ noteStatement("INSERT INTO inbox (id) VALUES (1)");
85
+ expect(statusWriteGeneration()).toBe(g);
86
+ noteStatement(" update sessions set status='idle'");
87
+ noteStatement("INSERT INTO sessions (resume_id) VALUES ('x') RETURNING resume_id");
88
+ noteStatement("DELETE FROM sessions WHERE alias = 'x'");
89
+ noteStatement("WITH t AS (SELECT 1) UPDATE sessions SET task = NULL");
90
+ expect(statusWriteGeneration()).toBe(g + 4);
91
+ });
92
+
93
+ test("the hook sits on the shared db adapter (every module's writes go through it)", () => {
94
+ const g = statusWriteGeneration();
95
+ db.run("UPDATE sessions SET progress = progress WHERE 1=0");
96
+ expect(statusWriteGeneration()).toBe(g + 1);
97
+ });
98
+
99
+ test("no foreign key / trigger can change sessions without naming it", () => {
100
+ if (db.dialect === "postgres") {
101
+ const fks = db.all<{ c: number }>(`SELECT COUNT(*) AS c FROM information_schema.referential_constraints rc
102
+ JOIN information_schema.table_constraints tc ON tc.constraint_name = rc.unique_constraint_name WHERE tc.table_name = 'sessions'`);
103
+ expect(Number(fks[0]?.c ?? 0)).toBe(0);
104
+ const trg = db.all<{ c: number }>(`SELECT COUNT(*) AS c FROM information_schema.triggers WHERE action_statement ILIKE '%sessions%' OR event_object_table = 'sessions'`);
105
+ expect(Number(trg[0]?.c ?? 0)).toBe(0);
106
+ return;
107
+ }
108
+ const fks = db.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL").map(r => r.sql);
109
+ expect(fks.filter(s => /references\s+sessions\b/i.test(s))).toEqual([]);
110
+ expect(fks.filter(s => /create\s+trigger/i.test(s) && /\bsessions\b/i.test(s))).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("GET /api/status bodies are byte-identical with the cache", () => {
115
+ test("random writes / health reports / expiry: cached == recomputed, and the cache is really used", async () => {
116
+ __resetStatusCacheForTest();
117
+ let rnd = 461;
118
+ const r = () => (rnd = (rnd * 1103515245 + 12345) % 2147483648) / 2147483648;
119
+ let compared = 0, hits = 0;
120
+ for (let step = 0; step < 60; step++) {
121
+ const op = Math.floor(r() * 6);
122
+ const i = Math.floor(r() * ROWS);
123
+ const alias = i % 2 ? `示例-${i}` : `src-${i}`;
124
+ if (op === 0) db.run(`UPDATE sessions SET status = ?1, updated_at = ?2 WHERE node_id = ?3`, [r() < 0.5 ? "idle" : "working", ts(step), `node_src_${i}`]);
125
+ if (op === 1) db.run(`UPDATE sessions SET task = ?1 WHERE node_id = ?2`, [`新任务 ${step}`, `node_src_${i}`]);
126
+ if (op === 2) recordNodeHealth(NET, alias, { bridge: "ok", app_server: { ok: r() < 0.5, rtt_ms: 3, last_error: null }, model_auth: "ok" });
127
+ if (op === 3) {
128
+ // A report that expires a few ms from now: the degraded field must disappear on time.
129
+ recordNodeHealth(NET, alias, { bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "closed (code=1006)" }, model_auth: "ok" }, Date.now() - NODE_HEALTH_TTL_MS + 30);
130
+ await Bun.sleep(40);
131
+ }
132
+ if (op === 4) db.run(`UPDATE sessions SET alias = ?1 WHERE node_id = ?2`, [`${alias}-改`, `node_src_${i}`]);
133
+ // op 5: no write at all — the next reads must be cache hits for the cacheable variants.
134
+ for (const v of VARIANTS) {
135
+ const first = await get(v.path(), v.headers());
136
+ const before = statusCacheStats.hits;
137
+ const again = await get(v.path(), v.headers());
138
+ hits += statusCacheStats.hits - before;
139
+ // Fresh = recomputed right now, without dropping what the cache holds (stale entries must stay in play
140
+ // across steps — clearing here would hide a missed invalidation).
141
+ __setStatusCacheBypassForTest(true);
142
+ const fresh = await get(v.path(), v.headers()).finally(() => __setStatusCacheBypassForTest(false));
143
+ expect(first.status).toBe(200);
144
+ const norm = (b: Uint8Array) => text(b).replace(/"health_observed_ms_ago":\d+/g, '"health_observed_ms_ago":0');
145
+ expect(norm(first.buf)).toBe(norm(fresh.buf));
146
+ expect(norm(again.buf)).toBe(norm(fresh.buf));
147
+ if (!text(fresh.buf).includes('"health_observed_ms_ago":')) expect(text(again.buf)).toBe(text(fresh.buf));
148
+ compared++;
149
+ }
150
+ }
151
+ expect(compared).toBe(60 * VARIANTS.length);
152
+ // Cacheable variants (everything except a full body carrying a health report) read twice in a row must hit.
153
+ expect(hits).toBeGreaterThan(60 * 4);
154
+ }, 120_000);
155
+
156
+ test("JSON.stringify == Response.json for these bodies; headers keep Content-Type, add ETag", async () => {
157
+ for (const v of VARIANTS) {
158
+ const res = await get(v.path(), v.headers());
159
+ const body = text(res.buf);
160
+ expect(await Response.json(JSON.parse(body)).text()).toBe(body);
161
+ expect(res.headers.get("content-type")).toBe("application/json;charset=utf-8");
162
+ expect(res.headers.get("etag")).toMatch(/^"s-[0-9a-z]+-[0-9a-z]+"$/);
163
+ }
164
+ });
165
+
166
+ test("keys do not leak across callers", async () => {
167
+ const owner = JSON.parse(text((await get(`/api/status?network_id=${NET}`, { Authorization: `Bearer ${userToken}` })).buf));
168
+ const other = JSON.parse(text((await get(`/api/status?network_id=${OTHER_NET}`, { Authorization: `Bearer ${otherToken}` })).buf));
169
+ expect(owner.sessions.length).toBe(ROWS);
170
+ expect(other.sessions.length).toBe(0);
171
+ const resolver = await get(`/api/status?network_id=${NET}`, { Authorization: `Bearer ${nodeToken}`, Accept: "application/json" });
172
+ expect(resolver.headers.get("x-status-projection")).toBe("alias-resolver");
173
+ expect(Object.keys(JSON.parse(text(resolver.buf)).sessions[0])).not.toContain("host");
174
+ });
175
+
176
+ test("a health report expiring (no write at all) invalidates the cached body on time", async () => {
177
+ __resetStatusCacheForTest();
178
+ clearNodeHealthStore();
179
+ const v = VARIANTS[1]; // user light: carries `degraded` for a fresh down app_server
180
+ const [{ alias: target }] = db.all<{ alias: string }>("SELECT alias FROM sessions WHERE node_id = 'node_src_2'");
181
+ recordNodeHealth(NET, target, { bridge: "ok", app_server: { ok: false, rtt_ms: null, last_error: "closed (code=1006)" }, model_auth: "ok" }, Date.now() - NODE_HEALTH_TTL_MS + 1_500);
182
+ const during = text((await get(v.path(), v.headers())).buf);
183
+ expect(during).toContain('"degraded":[{"layer":"app_server"');
184
+ const hitsBefore = statusCacheStats.hits;
185
+ expect(text((await get(v.path(), v.headers())).buf)).toBe(during);
186
+ expect(statusCacheStats.hits).toBe(hitsBefore + 1); // it was cached
187
+ await Bun.sleep(1_600);
188
+ const after = text((await get(v.path(), v.headers())).buf);
189
+ expect(after).not.toContain('"degraded"');
190
+ __resetStatusCacheForTest();
191
+ expect(text((await get(v.path(), v.headers())).buf)).toBe(after);
192
+ });
193
+
194
+ test("full projection with a health report is not cached (its body moves with time)", async () => {
195
+ __resetStatusCacheForTest();
196
+ recordNodeHealth(NET, "src-0", { bridge: "ok", model_auth: "ok" });
197
+ const v = VARIANTS[0];
198
+ const a = text((await get(v.path(), v.headers())).buf);
199
+ await Bun.sleep(15);
200
+ const b = text((await get(v.path(), v.headers())).buf);
201
+ expect(statusCacheStats.uncacheable).toBeGreaterThanOrEqual(2);
202
+ expect(a).not.toBe(b); // health_observed_ms_ago moved — a cached copy would have frozen it
203
+ });
204
+ });
205
+
206
+ describe("ETag / 304 / gzip", () => {
207
+ test("If-None-Match with the current ETag → 304 empty; after a write → 200 with a new ETag", async () => {
208
+ const h = { Authorization: `Bearer ${nodeToken}`, Accept: "application/json" };
209
+ const first = await get(`/api/status?network_id=${NET}`, h);
210
+ const etag = first.headers.get("etag")!;
211
+ const not = await get(`/api/status?network_id=${NET}`, { ...h, "If-None-Match": etag });
212
+ expect(not.status).toBe(304);
213
+ expect(not.buf.byteLength).toBe(0);
214
+ expect(not.headers.get("x-status-projection")).toBe("alias-resolver");
215
+ db.run(`UPDATE sessions SET status = 'working', updated_at = ?1 WHERE node_id = 'node_src_3'`, [ts(120)]);
216
+ const changed = await get(`/api/status?network_id=${NET}`, { ...h, "If-None-Match": etag });
217
+ expect(changed.status).toBe(200);
218
+ expect(changed.headers.get("etag")).not.toBe(etag);
219
+ });
220
+
221
+ test("gzip (the old node's Accept-Encoding: gzip, deflate) decodes to the same bytes", async () => {
222
+ const h = { Authorization: `Bearer ${nodeToken}`, Accept: "application/json" };
223
+ const plain = await get(`/api/status?network_id=${NET}`, h);
224
+ const res = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: { ...h, "Accept-Encoding": "gzip, deflate" }, decompress: false } as any);
225
+ expect(res.headers.get("content-encoding")).toBe("gzip");
226
+ const raw = new Uint8Array(await res.arrayBuffer());
227
+ expect(text(gunzipSync(raw))).toBe(text(plain.buf));
228
+ });
229
+ });
@@ -0,0 +1,108 @@
1
+ // #431(app「连接较慢」)—— GET /api/status 的序列化结果记忆化 + 强 ETag。
2
+ //
3
+ // 现场(2026-10-02 生产 /api/stats/routes,15 分钟窗口):GET /api/status 2000 次 ≈ 2.2 次/秒,全部来自 UA「node」
4
+ // (旧版 agent-node 的别名解析器,.82 起给它 light+node_id 投影),线上平均 30 KB/次(已经是 gzip 后的字节:
5
+ // node 的 fetch 发 `Accept-Encoding: gzip, deflate`)。两次读之间表多半没变(report_status 约 0.75 次/秒),
6
+ // 但每次都重新 SELECT 300 行、拼对象、JSON.stringify、再 gzip 一遍。
7
+ //
8
+ // 这里只做「同一份正文不重复算」,**不改任何正文**:
9
+ // 键 = 最终 SQL + 参数(里面已经编码了调用者能看到哪些节点)+ 投影开关;
10
+ // 失效 = 任何写 sessions 表的语句(db.ts 里统一挂钩,SQLite / PG 同一处)、任何健康上报、
11
+ // 任何一份健康报告过 TTL(那一刻 degraded / health 字段会变)。
12
+ // 全量投影里 `health_observed_ms_ago` 随时间走:只要有一行带健康报告,这份正文就是时间的函数,不缓存。
13
+ // 命中时正文和当场重算逐字节相同(status-read-cache-http.test.ts 用随机写序列钉住)。
14
+ //
15
+ // ETag:正文的哈希,`If-None-Match` 命中回 304 空体。🔴 旧 agent-node 不发 If-None-Match(fetch 没有 HTTP 缓存),
16
+ // 所以 ETag 只帮得上会带它的客户端(app / dashboard 以后接上);隧道上省字节靠的是同一份 gzip 只压一次
17
+ // 且用更高的压缩级别(http-gzip.ts:可复用的正文压一次、存起来,级别 9)。
18
+
19
+ import type { DbAdapter } from "./db-adapter";
20
+ import { nodeHealthNextExpiryAt, nodeHealthVersion } from "./node-health-store.js";
21
+
22
+ let sessionsWriteGen = 0;
23
+
24
+ const WRITE_START = /^\s*(?:insert|update|delete|replace|upsert|merge|alter|drop|create|truncate|with)\b/i;
25
+ const SESSIONS_WORD = /\bsessions\b/i;
26
+
27
+ /**
28
+ * 每条经过 db 的语句都过一遍。写语句(含带 RETURNING 的、DDL)里点名了 sessions → 代数 +1。读语句不动。
29
+ * 不会有「没点名 sessions 却改了它」的写:两种方言的 schema 里都没有指向 sessions 的外键 / 级联删除
30
+ * (status-read-cache.test.ts 钉住这一点)。
31
+ */
32
+ export function noteStatement(sql: string): void {
33
+ if (WRITE_START.test(sql) && SESSIONS_WORD.test(sql)) sessionsWriteGen++;
34
+ }
35
+
36
+ /** 原生 exec(迁移 / DDL / 多语句):不逐句判断,一律失效。 */
37
+ export function noteExec(): void { sessionsWriteGen++; }
38
+
39
+ export function statusWriteGeneration(): number { return sessionsWriteGen; }
40
+
41
+ /**
42
+ * db.ts 用它包住唯一的那个适配器:每条语句先报给上面两个函数。挂在这一个出口上,SQLite / PG 一样,
43
+ * 不靠每个写 sessions 的地方各自记得。直接换掉实例上的四个方法(不用 Proxy):有测试按 `db.all = …`
44
+ * 临时打桩数查询次数,方法得是普通可写属性。
45
+ */
46
+ export function withStatementHook(inner: DbAdapter): DbAdapter {
47
+ const run = inner.run.bind(inner), get = inner.get.bind(inner), all = inner.all.bind(inner), exec = inner.exec.bind(inner);
48
+ inner.run = (sql: string, params?: any[]) => { noteStatement(sql); return run(sql, params); };
49
+ inner.get = ((sql: string, ...params: any[]) => { noteStatement(sql); return get(sql, ...params); }) as DbAdapter["get"];
50
+ inner.all = ((sql: string, ...params: any[]) => { noteStatement(sql); return all(sql, ...params); }) as DbAdapter["all"];
51
+ inner.exec = (sql: string) => { noteExec(); return exec(sql); };
52
+ return inner;
53
+ }
54
+
55
+ type Entry = { gen: number; healthVer: number; validUntil: number; body: string; etag: string };
56
+ const MAX_ENTRIES = 64;
57
+ const memo = new Map<string, Entry>();
58
+ export const statusCacheStats = { hits: 0, misses: 0, uncacheable: 0 };
59
+
60
+ function etagOf(body: string): string {
61
+ return `"s-${Bun.hash(body).toString(36)}-${body.length.toString(36)}"`;
62
+ }
63
+
64
+ /**
65
+ * 取或算。compute 返回正文和它能不能缓存(全量投影里有健康报告 = 正文随时间变 = 不能)。
66
+ * 返回的 etag 总是这份正文的哈希(不缓存的也给,便于 304)。
67
+ */
68
+ let bypass = false;
69
+ /** 测试用:临时绕过缓存(既不读也不写),拿「当场重算」的那一份来对比,而不清掉缓存里已有的条目。 */
70
+ export function __setStatusCacheBypassForTest(on: boolean): void { bypass = on; }
71
+
72
+ export function memoStatusBody(key: string, compute: () => { body: string; cacheable: boolean }, now = Date.now()): { body: string; etag: string; hit: boolean } {
73
+ if (bypass) { const { body } = compute(); return { body, etag: etagOf(body), hit: false }; }
74
+ const gen = sessionsWriteGen;
75
+ const healthVer = nodeHealthVersion();
76
+ const hit = memo.get(key);
77
+ if (hit && hit.gen === gen && hit.healthVer === healthVer && now < hit.validUntil) {
78
+ statusCacheStats.hits++;
79
+ memo.delete(key);
80
+ memo.set(key, hit); // LRU
81
+ return { body: hit.body, etag: hit.etag, hit: true };
82
+ }
83
+ const { body, cacheable } = compute();
84
+ const etag = etagOf(body);
85
+ // 以算之前的代数入账:算的过程中若有写(改名清理之类),下一次比较就对不上、重算 —— 宁可多算一次也不发旧正文。
86
+ if (!cacheable) {
87
+ statusCacheStats.uncacheable++;
88
+ memo.delete(key);
89
+ return { body, etag, hit: false };
90
+ }
91
+ statusCacheStats.misses++;
92
+ memo.set(key, { gen, healthVer, validUntil: nodeHealthNextExpiryAt(now), body, etag });
93
+ while (memo.size > MAX_ENTRIES) memo.delete(memo.keys().next().value as string);
94
+ return { body, etag, hit: false };
95
+ }
96
+
97
+ /** If-None-Match 里有这个 ETag(或 `*`)。弱比较:去掉 W/ 前缀。 */
98
+ export function ifNoneMatchHits(header: string | null, etag: string): boolean {
99
+ if (!header) return false;
100
+ const bare = (t: string) => t.trim().replace(/^W\//, "");
101
+ return header.split(",").some((t) => t.trim() === "*" || bare(t) === bare(etag));
102
+ }
103
+
104
+ /** 测试用。 */
105
+ export function __resetStatusCacheForTest(): void {
106
+ memo.clear();
107
+ statusCacheStats.hits = statusCacheStats.misses = statusCacheStats.uncacheable = 0;
108
+ }