@sleep2agi/commhub-server 0.9.0-preview.76 → 0.9.0-preview.78
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/mcp-route-label-http.test.ts +120 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements.ts +142 -29
- package/src/route-timing.test.ts +106 -9
- package/src/route-timing.ts +115 -32
- package/src/server.ts +39 -3
- package/src/status-node-id-filter-http.test.ts +161 -0
package/src/requirements.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
|
|
13
13
|
isTaskScoped, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
|
|
14
14
|
} from "./task-access.js";
|
|
15
|
+
import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
|
|
15
16
|
import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
16
17
|
|
|
17
18
|
// 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
|
|
@@ -24,6 +25,7 @@ ensureRequirementSeq(db);
|
|
|
24
25
|
ensureRequirementCompletedAt(db);
|
|
25
26
|
ensureNetworkTags(db);
|
|
26
27
|
ensureRequirementTombstones(db);
|
|
28
|
+
ensureRequirementEvents(db);
|
|
27
29
|
|
|
28
30
|
type RequestAuth = { userId: string; networkId: string | null; username: string; tokenId?: string | null; tokenName?: string | null } | null;
|
|
29
31
|
|
|
@@ -285,8 +287,15 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
|
|
|
285
287
|
if (req.method === "DELETE") {
|
|
286
288
|
// 先把卡片上的引用置空,再删项目:卡片一张不少。
|
|
287
289
|
// updated_at 跟着动:按 updated_since 增量同步的客户端要看得见「项目被清空」这一改动。
|
|
288
|
-
|
|
289
|
-
|
|
290
|
+
// 每张被清空项目的卡记一条「项目 X → 无」(与清空同一个事务)。
|
|
291
|
+
const at = new Date().toISOString();
|
|
292
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
293
|
+
db.transaction(() => {
|
|
294
|
+
const cards = db.all<{ requirement_id: string; network_id: string; seq: number | null; title: string }>("SELECT requirement_id, network_id, seq, title FROM requirements WHERE project_id = ?1", current.project_id);
|
|
295
|
+
db.run("UPDATE requirements SET project_id = NULL, updated_at = ?2 WHERE project_id = ?1", [current.project_id, at]);
|
|
296
|
+
for (const card of cards) recordRequirementEvents(db, card, actor, [{ kind: "changed", field: "project", old: current.project_id, new: null }], at);
|
|
297
|
+
db.run("DELETE FROM requirement_projects WHERE project_id = ?1", [current.project_id]);
|
|
298
|
+
});
|
|
290
299
|
deleteTaskGrantsForProject(current.project_id);
|
|
291
300
|
return Response.json({ ok: true });
|
|
292
301
|
}
|
|
@@ -335,8 +344,8 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
|
|
|
335
344
|
const actor = JSON.stringify(actorOf(ctx));
|
|
336
345
|
// 读-改-写整个网络的标签在一个事务里:中途失败一张都不改;颜色表跟着一起挪。
|
|
337
346
|
const result = db.transaction(() => {
|
|
338
|
-
const cards = db.all<{ requirement_id: string; tags_json: string | null }>(
|
|
339
|
-
"SELECT requirement_id, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
|
|
347
|
+
const cards = db.all<{ requirement_id: string; network_id: string; seq: number | null; title: string; tags_json: string | null }>(
|
|
348
|
+
"SELECT requirement_id, network_id, seq, title, tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
|
|
340
349
|
);
|
|
341
350
|
const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
|
|
342
351
|
const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
|
|
@@ -352,6 +361,7 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
|
|
|
352
361
|
const next = applyTagOp(storedTags(card.tags_json), op);
|
|
353
362
|
if (!next) continue;
|
|
354
363
|
db.run("UPDATE requirements SET tags_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(next), now, actor, card.requirement_id]);
|
|
364
|
+
recordRequirementEvents(db, card, actor, [{ kind: "changed", field: "tags", old: storedTags(card.tags_json), new: next }], now);
|
|
355
365
|
affected++;
|
|
356
366
|
}
|
|
357
367
|
moveTagColors(networkId, op, colorOf, now);
|
|
@@ -581,7 +591,8 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
|
|
|
581
591
|
// search:GET 认 q=(服务端搜索,语义同 App 的任务搜索);paging:认 limit / cursor,响应带 has_more / next_cursor。
|
|
582
592
|
// list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
|
|
583
593
|
// (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
|
|
584
|
-
|
|
594
|
+
// events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
|
|
595
|
+
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"] as const;
|
|
585
596
|
|
|
586
597
|
// ── 子需求 ──
|
|
587
598
|
// parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
|
|
@@ -738,15 +749,19 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
|
|
|
738
749
|
try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId)); } catch (e) { return jsonError((e as Error).message, 400); }
|
|
739
750
|
const actor = JSON.stringify(actorOf(ctx));
|
|
740
751
|
try {
|
|
741
|
-
//
|
|
742
|
-
db.transaction(() =>
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
column
|
|
749
|
-
|
|
752
|
+
// 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
|
|
753
|
+
db.transaction(() => {
|
|
754
|
+
const seq = nextRequirementSeq(db, networkId);
|
|
755
|
+
db.run(
|
|
756
|
+
`INSERT INTO requirements
|
|
757
|
+
(requirement_id, network_id, title, column_name, priority, due_on, assignee, client_id, issues_json, created_by, created_at, updated_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, created_by_json, updated_by_json, archived, parent_id, tags_json, start_on, seq, completed_at, completed_by_json, completed_at_approx)
|
|
758
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?20, 0, ?21, ?22, ?23, ?24, ?25, ?26, 0)`,
|
|
759
|
+
[id, networkId, name, column, priority, due || null, assignee, clientId || null, JSON.stringify(issues), ctx.auth?.userId ?? null, createdAt, people.ownerJson, people.participantsJson, people.agentOwnerJson, description || null, JSON.stringify(checklist), projectId, ref, extUrl, actor, parentId, JSON.stringify(tags), start || null, seq,
|
|
760
|
+
// 直接建在「完成」列 = 此刻完成、由建卡的人完成。
|
|
761
|
+
column === "done" ? createdAt : null, column === "done" ? actor : null],
|
|
762
|
+
);
|
|
763
|
+
recordRequirementEvents(db, { requirement_id: id, network_id: networkId, seq, title: name }, actor, [{ kind: "created", field: null, old: null, new: { title: name, column } }], createdAt);
|
|
764
|
+
});
|
|
750
765
|
} catch {
|
|
751
766
|
// 并发的同一个 external_ref / client_id:唯一索引挡住了第二个,回已有的那条。
|
|
752
767
|
if (ref) {
|
|
@@ -857,16 +872,21 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
857
872
|
const completed = column !== "done" ? { at: null, by: null, approx: 0 }
|
|
858
873
|
: row.column_name !== "done" ? { at: updatedAt, by: actor, approx: 0 }
|
|
859
874
|
: { at: row.completed_at, by: row.completed_by_json, approx: row.completed_at_approx ? 1 : 0 };
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
875
|
+
// 改卡和记动态(改前 row → 改后 updated 的字段差异)在同一个事务里:要么都在,要么都不在。
|
|
876
|
+
const updated = db.transaction(() => {
|
|
877
|
+
db.run(
|
|
878
|
+
`UPDATE requirements SET column_name = ?1, updated_at = ?2, title = ?7, priority = ?8, due_on = ?9, assignee = ?10, owner_json = ?4, participants_json = ?5, issues_json = ?6,
|
|
879
|
+
agent_owner_json = ?11, description = ?12, checklist_json = ?13, project_id = ?14, external_ref = ?15, external_url = ?16, archived = ?17, updated_by_json = ?18, parent_id = ?19, tags_json = ?20, start_on = ?21,
|
|
880
|
+
completed_at = ?22, completed_by_json = ?23, completed_at_approx = ?24
|
|
881
|
+
WHERE requirement_id = ?3`,
|
|
882
|
+
[column, updatedAt, row.requirement_id, people.ownerJson, people.participantsJson, has("issues") ? JSON.stringify(issues) : row.issues_json, name, priority, due || null, assignee, people.agentOwnerJson,
|
|
883
|
+
has("description") ? (description || null) : row.description, has("checklist") ? JSON.stringify(checklist) : row.checklist_json, projectId, ref, extUrl, archived, actor, parentId, has("tags") ? JSON.stringify(tags) : row.tags_json, start || null,
|
|
884
|
+
completed.at, completed.by, completed.approx],
|
|
885
|
+
);
|
|
886
|
+
const after = db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
887
|
+
recordRequirementEvents(db, after, actor, diffRequirement(row, after), updatedAt);
|
|
888
|
+
return after;
|
|
889
|
+
});
|
|
870
890
|
return Response.json(withLegacyOwner(toPublicFor(ctx, updated), people.coerced));
|
|
871
891
|
}
|
|
872
892
|
|
|
@@ -1046,6 +1066,86 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
|
|
|
1046
1066
|
return db.all<TombstoneRow>(sql, ...params).filter(row => canSeeTask(caller, row)).map(row => row.requirement_id);
|
|
1047
1067
|
}
|
|
1048
1068
|
|
|
1069
|
+
// ── 任务动态(requirement-events.ts)──
|
|
1070
|
+
// GET /api/requirements/events?network_id&since&limit&cursor&requirement_id:一个网络的改动流水,按 id 从新到旧。
|
|
1071
|
+
// 可见范围与列表相同:卡还在 → 按当前这张卡判断调用者看不看得见;删掉了 → 按墓碑(过了墓碑保留期的删除,
|
|
1072
|
+
// 只有不受任务范围限制的调用者看得到)。受限成员看不见的节点:操作者隐去,负责人 / 负责 Agent / 参与人里的隐去,
|
|
1073
|
+
// 隐去之后前后一样的那条整条不回(否则是「这张卡的 Agent 换过」的探测器)。
|
|
1074
|
+
// 按可见性过滤是在读出来之后做的:一页最多扫 EVENTS_SCAN_MAX 条,扫满还没凑够就带 next_cursor 回去,客户端接着翻。
|
|
1075
|
+
const EVENTS_PAGE_DEFAULT = 200;
|
|
1076
|
+
const EVENTS_PAGE_MAX = 500;
|
|
1077
|
+
const EVENTS_SCAN_MAX = 5000;
|
|
1078
|
+
type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
|
|
1079
|
+
|
|
1080
|
+
function listEvents(ctx: RequirementsRequestContext): Response {
|
|
1081
|
+
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1082
|
+
if (!networkId) return jsonError("network_id_required", 400);
|
|
1083
|
+
const q = ctx.url.searchParams;
|
|
1084
|
+
const limit = q.get("limit") === null ? EVENTS_PAGE_DEFAULT : Number(q.get("limit"));
|
|
1085
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > EVENTS_PAGE_MAX) return jsonError("invalid_limit", 400);
|
|
1086
|
+
let since: string | null = null;
|
|
1087
|
+
if (q.get("since") !== null) {
|
|
1088
|
+
const ms = Date.parse(q.get("since")!);
|
|
1089
|
+
if (!Number.isFinite(ms)) return jsonError("invalid_since", 400);
|
|
1090
|
+
since = new Date(ms).toISOString();
|
|
1091
|
+
}
|
|
1092
|
+
const cursor = q.get("cursor");
|
|
1093
|
+
if (cursor !== null && !/^[1-9]\d{0,17}$/.test(cursor)) return jsonError("invalid_cursor", 400);
|
|
1094
|
+
const card = q.get("requirement_id");
|
|
1095
|
+
// server_time 取在读表之前:下次拿它当 since,读的期间写进来的不会漏(>= 会重复回,客户端按 id 去重)。
|
|
1096
|
+
const serverTime = new Date().toISOString();
|
|
1097
|
+
const caller = taskCaller(ctx);
|
|
1098
|
+
const hidden = hiddenNodeFilter(ctx, networkId);
|
|
1099
|
+
const seen = new Map<string, boolean>();
|
|
1100
|
+
const canSee = (id: string): boolean => {
|
|
1101
|
+
if (!caller) return true;
|
|
1102
|
+
let ok = seen.get(id);
|
|
1103
|
+
if (ok === undefined) {
|
|
1104
|
+
const cols = "network_id, owner_json, participants_json, created_by, created_by_json, project_id";
|
|
1105
|
+
const row = db.get<VisibilityRow>(`SELECT ${cols} FROM requirements WHERE requirement_id = ?1`, id)
|
|
1106
|
+
?? db.get<VisibilityRow>(`SELECT ${cols} FROM requirement_tombstones WHERE requirement_id = ?1`, id);
|
|
1107
|
+
seen.set(id, ok = !!row && row.network_id === networkId && canSeeTask(caller, row));
|
|
1108
|
+
}
|
|
1109
|
+
return ok;
|
|
1110
|
+
};
|
|
1111
|
+
const shown = (row: EventRow) => {
|
|
1112
|
+
if (!canSee(row.requirement_id)) return null;
|
|
1113
|
+
const ev = eventPublic(row);
|
|
1114
|
+
if (!hidden) return ev;
|
|
1115
|
+
const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
|
|
1116
|
+
const masked = { ...ev, actor: mask(ev.actor) };
|
|
1117
|
+
if (ev.field === "owner" || ev.field === "agent_owner") { masked.old = mask(ev.old); masked.new = mask(ev.new); }
|
|
1118
|
+
if (ev.field === "participants") {
|
|
1119
|
+
masked.old = Array.isArray(ev.old) ? ev.old.filter(ref => !isHiddenRef(ref, hidden)) : ev.old;
|
|
1120
|
+
masked.new = Array.isArray(ev.new) ? ev.new.filter(ref => !isHiddenRef(ref, hidden)) : ev.new;
|
|
1121
|
+
}
|
|
1122
|
+
return ev.field && JSON.stringify(masked.old) === JSON.stringify(masked.new) ? null : masked;
|
|
1123
|
+
};
|
|
1124
|
+
const events: ReturnType<typeof eventPublic>[] = [];
|
|
1125
|
+
let before = cursor === null ? null : Number(cursor);
|
|
1126
|
+
let scanned = 0;
|
|
1127
|
+
let nextCursor: string | null = null;
|
|
1128
|
+
scan: for (;;) {
|
|
1129
|
+
const params: unknown[] = [networkId];
|
|
1130
|
+
let sql = "SELECT id, network_id, requirement_id, seq, title, actor_json, kind, field, old_json, new_json, created_at FROM requirement_events WHERE network_id = ?1";
|
|
1131
|
+
if (since) sql += ` AND created_at >= ?${params.push(since)}`;
|
|
1132
|
+
if (card) sql += ` AND requirement_id = ?${params.push(card)}`;
|
|
1133
|
+
if (before !== null) sql += ` AND id < ?${params.push(before)}`;
|
|
1134
|
+
const batch = db.all<EventRow>(`${sql} ORDER BY id DESC LIMIT ${limit + 1}`, ...params);
|
|
1135
|
+
for (const row of batch) {
|
|
1136
|
+
const ev = shown(row);
|
|
1137
|
+
if (ev && events.length === limit) { nextCursor = events[events.length - 1].id; break scan; }
|
|
1138
|
+
scanned++;
|
|
1139
|
+
before = Number(row.id);
|
|
1140
|
+
if (ev) events.push(ev);
|
|
1141
|
+
}
|
|
1142
|
+
if (batch.length < limit + 1) break;
|
|
1143
|
+
// 扫满了(调用者看不见的太多):从扫到的地方接着翻。
|
|
1144
|
+
if (scanned >= EVENTS_SCAN_MAX) { nextCursor = String(before); break; }
|
|
1145
|
+
}
|
|
1146
|
+
return Response.json({ ok: true, network_id: networkId, events, has_more: nextCursor !== null, next_cursor: nextCursor, server_time: serverTime });
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1049
1149
|
/**
|
|
1050
1150
|
* 需求表的写入代数:每个经过 handleRequirementsRequest 的非 GET 请求处理完就 +1(REST 和 MCP 的写入都走它,
|
|
1051
1151
|
* tools.ts 调的是同一个处理函数)。列表缓存只在代数没变时复用。进程重启 = 缓存清空。
|
|
@@ -1152,6 +1252,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1152
1252
|
return conditionalJson(ctx.req, { ok: true, networkId, ...aggregateStats(rows, sq, hiddenNodeFilter(ctx, networkId)) });
|
|
1153
1253
|
}
|
|
1154
1254
|
|
|
1255
|
+
if (url.pathname === "/api/requirements/events" && req.method === "GET") return listEvents(ctx);
|
|
1256
|
+
|
|
1155
1257
|
if (url.pathname === '/api/requirements/people' && req.method === 'GET') {
|
|
1156
1258
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
1157
1259
|
if (!networkId) return jsonError('network_id_required', 400);
|
|
@@ -1261,9 +1363,16 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1261
1363
|
const itemId = decodeURIComponent(itemMatch[2]);
|
|
1262
1364
|
const item = items.find(entry => entry.id === itemId);
|
|
1263
1365
|
if (!item) return jsonError("checklist_item_not_found", 404);
|
|
1366
|
+
const was = item.done;
|
|
1264
1367
|
item.done = body.done;
|
|
1265
|
-
|
|
1266
|
-
const
|
|
1368
|
+
const at = new Date().toISOString();
|
|
1369
|
+
const actor = JSON.stringify(actorOf(ctx));
|
|
1370
|
+
const updated = db.transaction(() => {
|
|
1371
|
+
db.run("UPDATE requirements SET checklist_json = ?1, updated_at = ?2, updated_by_json = ?3 WHERE requirement_id = ?4", [JSON.stringify(items), at, actor, row.requirement_id]);
|
|
1372
|
+
// 重复勾同一个值(done 是显式值,幂等)不算一次改动,不记。
|
|
1373
|
+
if (was !== item.done) recordRequirementEvents(db, row, actor, [{ kind: "changed", field: "checklist_item", old: { ...item, done: was }, new: { ...item } }], at);
|
|
1374
|
+
return db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, row.requirement_id)!;
|
|
1375
|
+
});
|
|
1267
1376
|
return Response.json({ ok: true, requirement: toPublicFor(ctx, updated) });
|
|
1268
1377
|
}
|
|
1269
1378
|
|
|
@@ -1282,9 +1391,13 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1282
1391
|
if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
|
|
1283
1392
|
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
1284
1393
|
const deletedAt = new Date().toISOString();
|
|
1285
|
-
db.
|
|
1286
|
-
|
|
1287
|
-
|
|
1394
|
+
db.transaction(() => {
|
|
1395
|
+
db.run("UPDATE requirements SET parent_id = NULL, updated_at = ?2 WHERE parent_id = ?1", [row.requirement_id, deletedAt]);
|
|
1396
|
+
db.run("DELETE FROM requirements WHERE requirement_id = ?1", [row.requirement_id]);
|
|
1397
|
+
recordTombstone(row, deletedAt);
|
|
1398
|
+
// 卡没了,动态里还要画得出「谁删了 #N 标题」:标题 / 短号随行存(seq、title 列)。
|
|
1399
|
+
recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "deleted", field: null, old: null, new: null }], deletedAt);
|
|
1400
|
+
});
|
|
1288
1401
|
// 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
|
|
1289
1402
|
if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
|
|
1290
1403
|
return Response.json({ ok: true, deleted: row.requirement_id });
|
package/src/route-timing.test.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
__resetRouteTimingForTest, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
|
|
4
|
+
ROUTE_BUCKET_MS, ROUTE_BUCKET_RETENTION, ROUTE_MAX_KEYS_PER_BUCKET, ROUTE_OVERFLOW_KEY,
|
|
5
|
+
} from "./route-timing";
|
|
3
6
|
|
|
4
7
|
const u = (s: string) => new URL(`http://hub${s}`);
|
|
8
|
+
const req = (path: string, method = "GET") => new Request(`http://hub${path}`, { method });
|
|
9
|
+
const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
|
|
10
|
+
const at = (ms: number) => performance.now() - ms;
|
|
5
11
|
|
|
6
12
|
test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
|
|
7
13
|
expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
|
|
@@ -13,12 +19,9 @@ test("routeKey collapses ids, aliases and file names, keeps payload-shaping para
|
|
|
13
19
|
expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
|
|
14
20
|
});
|
|
15
21
|
|
|
16
|
-
test("routeStats groups the window, sorts by total time, and
|
|
22
|
+
test("routeStats groups the window, sorts by total time, and counts every request (no sample cap)", () => {
|
|
17
23
|
__resetRouteTimingForTest();
|
|
18
|
-
const now =
|
|
19
|
-
const req = (path: string) => new Request(`http://hub${path}`);
|
|
20
|
-
const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
|
|
21
|
-
const at = (ms: number) => performance.now() - ms;
|
|
24
|
+
const now = 1_000_000_000;
|
|
22
25
|
recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
|
|
23
26
|
recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
|
|
24
27
|
recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
|
|
@@ -32,9 +35,103 @@ test("routeStats groups the window, sorts by total time, and the ring keeps only
|
|
|
32
35
|
expect(r0.bytes).toBe(1500);
|
|
33
36
|
expect(r0.max_ms).toBeGreaterThanOrEqual(29);
|
|
34
37
|
expect(r0.total_ms).toBeGreaterThanOrEqual(39);
|
|
38
|
+
expect(r0.p95_ms).toBeLessThanOrEqual(r0.max_ms);
|
|
35
39
|
expect(stats.routes[1].errors).toBe(1);
|
|
36
|
-
|
|
40
|
+
// The old 4096-sample ring dropped everything past its size; the buckets count all of it.
|
|
41
|
+
for (let i = 0; i < 10_000; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
|
|
37
42
|
const after = routeStats(60_000, now);
|
|
38
|
-
expect(after.
|
|
39
|
-
expect(after.
|
|
43
|
+
expect(after.routes.find(r => r.route === "GET /api/tasks")!.count).toBe(10_000);
|
|
44
|
+
expect(after.samples).toBe(10_003);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("?minutes=10 really covers 10 minutes: a steady 15 req/s stream is fully counted, older traffic is not", () => {
|
|
48
|
+
__resetRouteTimingForTest();
|
|
49
|
+
const t0 = 2_000_000_000 - (2_000_000_000 % ROUTE_BUCKET_MS); // bucket-aligned
|
|
50
|
+
// 20 minutes at 15 req/s — the old ring filled in ~4.5 minutes of this.
|
|
51
|
+
for (let s = 0; s < 20 * 60; s++) {
|
|
52
|
+
for (let k = 0; k < 15; k++) recordRouteTiming(req("/mcp", "POST"), at(1), res(), t0 + s * 1000);
|
|
53
|
+
}
|
|
54
|
+
const now = t0 + 20 * 60_000;
|
|
55
|
+
const ten = routeStats(10 * 60_000, now);
|
|
56
|
+
// window starts at t0+10min → the ten minute buckets t0+10 … t0+19
|
|
57
|
+
expect(ten.samples).toBe(10 * 60 * 15);
|
|
58
|
+
expect(ten.oldest_at).toBe(new Date(t0 + 10 * 60_000).toISOString());
|
|
59
|
+
expect(routeStats(20 * 60_000, now).samples).toBe(20 * 60 * 15);
|
|
60
|
+
expect(routeStats(60 * 60_000, now).samples).toBe(20 * 60 * 15);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("buckets older than the retention are dropped", () => {
|
|
64
|
+
__resetRouteTimingForTest();
|
|
65
|
+
const now = 3_000_000_000;
|
|
66
|
+
const old = now - (ROUTE_BUCKET_RETENTION + 5) * ROUTE_BUCKET_MS;
|
|
67
|
+
recordRouteTiming(req("/api/old"), at(1), res(), old);
|
|
68
|
+
expect(routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, old).samples).toBe(1);
|
|
69
|
+
recordRouteTiming(req("/api/new"), at(1), res(), now);
|
|
70
|
+
const s = routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, now);
|
|
71
|
+
expect(s.routes.map(r => r.route)).toEqual(["GET /api/new"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("p95 comes from the histogram: close to the true value and never above max", () => {
|
|
75
|
+
__resetRouteTimingForTest();
|
|
76
|
+
const now = 4_000_000_000;
|
|
77
|
+
// 96 fast (~1 ms) and 4 slow (~200 ms): p95 (the 96th of 100, same rank as the old ring's
|
|
78
|
+
// sorted[floor(n·0.95)]) must be in the fast band, max in the slow band.
|
|
79
|
+
for (let i = 0; i < 96; i++) recordRouteTiming(req("/api/p"), at(1), res(), now);
|
|
80
|
+
for (let i = 0; i < 4; i++) recordRouteTiming(req("/api/p"), at(200), res(), now);
|
|
81
|
+
const r = routeStats(60_000, now).routes[0];
|
|
82
|
+
expect(r.p95_ms).toBeGreaterThanOrEqual(1);
|
|
83
|
+
expect(r.p95_ms).toBeLessThan(5);
|
|
84
|
+
expect(r.max_ms).toBeGreaterThanOrEqual(200);
|
|
85
|
+
__resetRouteTimingForTest();
|
|
86
|
+
recordRouteTiming(req("/api/q"), at(3), res(), now);
|
|
87
|
+
const one = routeStats(60_000, now).routes[0];
|
|
88
|
+
expect(one.p95_ms).toBe(one.max_ms);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("distinct routes per bucket are capped; the rest land in the overflow key", () => {
|
|
92
|
+
__resetRouteTimingForTest();
|
|
93
|
+
const now = 5_000_000_000;
|
|
94
|
+
for (let i = 0; i < ROUTE_MAX_KEYS_PER_BUCKET + 50; i++) {
|
|
95
|
+
const r = req("/mcp", "POST");
|
|
96
|
+
labelRoute(r, `tools/call t${String.fromCharCode(97 + (i % 26))}${Math.floor(i / 26)}x`);
|
|
97
|
+
recordRouteTiming(r, at(1), res(), now);
|
|
98
|
+
}
|
|
99
|
+
const s = routeStats(60_000, now);
|
|
100
|
+
expect(s.routes.length).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 1);
|
|
101
|
+
expect(s.routes.find(r => r.route === ROUTE_OVERFLOW_KEY)!.count).toBe(50);
|
|
102
|
+
expect(s.samples).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 50);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a label is appended to the route shape of that request only", () => {
|
|
106
|
+
__resetRouteTimingForTest();
|
|
107
|
+
const now = 6_000_000_000;
|
|
108
|
+
const a = req("/mcp", "POST");
|
|
109
|
+
labelRoute(a, "tools/call report_status");
|
|
110
|
+
recordRouteTiming(a, at(1), res(), now);
|
|
111
|
+
recordRouteTiming(req("/mcp", "POST"), at(1), res(), now);
|
|
112
|
+
expect(routeStats(60_000, now).routes.map(r => r.route).sort()).toEqual(["POST /mcp", "POST /mcp tools/call report_status"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("mcpRouteLabel: method, tool name, and nothing else", () => {
|
|
116
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: { task: "secret" } } })).toBe("tools/call report_status");
|
|
117
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/list" })).toBe("tools/list");
|
|
118
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", method: "notifications/initialized" })).toBe("notifications/initialized");
|
|
119
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "initialize", params: { name: "x" } })).toBe("initialize");
|
|
120
|
+
// odd shapes collapse to "?"
|
|
121
|
+
expect(mcpRouteLabel([{ method: "tools/list" }])).toBe("batch");
|
|
122
|
+
expect(mcpRouteLabel(null)).toBe("?");
|
|
123
|
+
expect(mcpRouteLabel("tools/list")).toBe("?");
|
|
124
|
+
expect(mcpRouteLabel(42)).toBe("?");
|
|
125
|
+
expect(mcpRouteLabel({})).toBe("?");
|
|
126
|
+
expect(mcpRouteLabel({ method: 7 })).toBe("?");
|
|
127
|
+
expect(mcpRouteLabel({ method: "x".repeat(65) })).toBe("?");
|
|
128
|
+
expect(mcpRouteLabel({ method: "tools/call x" })).toBe("?");
|
|
129
|
+
expect(mcpRouteLabel({ method: "通信" })).toBe("?");
|
|
130
|
+
expect(mcpRouteLabel({ method: "tools/call" })).toBe("tools/call ?");
|
|
131
|
+
expect(mcpRouteLabel({ method: "tools/call", params: "report_status" })).toBe("tools/call ?");
|
|
132
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: { toString: () => "x" } } })).toBe("tools/call ?");
|
|
133
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "a b" } })).toBe("tools/call ?");
|
|
134
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "../../etc/passwd" } })).toBe("tools/call ?");
|
|
135
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "n".repeat(65) } })).toBe("tools/call ?");
|
|
136
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "" } })).toBe("tools/call ?");
|
|
40
137
|
});
|
package/src/route-timing.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
// 说不清是谁在吃 CPU。这里在 fetch
|
|
3
|
-
// GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query
|
|
4
|
-
|
|
1
|
+
// 每个路由的耗时统计(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
|
|
2
|
+
// 说不清是谁在吃 CPU。这里在 fetch 入口按路由形状累计次数、耗时、状态、字节,
|
|
3
|
+
// GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记调用者、不记请求体。
|
|
4
|
+
//
|
|
5
|
+
// 2026-10-01 —— 原来是 4096 条样本的环:生产上 212 s 就写满,?minutes=10 实际只覆盖三分半钟。
|
|
6
|
+
// 改成按分钟分桶的累计器:每个桶里每个路由一行(次数 / 总耗时 / 最大值 / 字节 / 5xx / 耗时直方图),
|
|
7
|
+
// 保留 ROUTE_BUCKET_RETENTION 个桶(24 小时),窗口多长就真覆盖多长。p95 从直方图取(桶上沿,不超过 max)。
|
|
8
|
+
export const ROUTE_BUCKET_MS = 60_000;
|
|
9
|
+
export const ROUTE_BUCKET_RETENTION = 1440;
|
|
10
|
+
/** 每个分钟桶里最多这么多个不同的路由;再多的记到 ROUTE_OVERFLOW_KEY,防止怪路径 / 怪工具名把内存撑大。 */
|
|
11
|
+
export const ROUTE_MAX_KEYS_PER_BUCKET = 256;
|
|
12
|
+
export const ROUTE_OVERFLOW_KEY = "(other)";
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
14
|
+
// 耗时直方图:第 i 格的上沿 = HIST_BASE_MS · 2^(i/2),0.05 ms … ~52 s;最后一格收下更慢的。
|
|
15
|
+
const HIST_BASE_MS = 0.05;
|
|
16
|
+
const HIST_BINS = 41;
|
|
17
|
+
function histBin(ms: number): number {
|
|
18
|
+
if (!(ms > HIST_BASE_MS)) return 0;
|
|
19
|
+
return Math.min(HIST_BINS - 1, Math.ceil(2 * Math.log2(ms / HIST_BASE_MS)));
|
|
20
|
+
}
|
|
21
|
+
const histUpper = (bin: number) => HIST_BASE_MS * 2 ** (bin / 2);
|
|
22
|
+
|
|
23
|
+
type Agg = { count: number; totalMs: number; maxMs: number; bytes: number; errors: number; hist: Uint32Array };
|
|
24
|
+
type Bucket = { minute: number; routes: Map<string, Agg> };
|
|
25
|
+
// 按 minute 升序;新的一分钟追加在尾部,超出保留期从头部丢。
|
|
26
|
+
const buckets: Bucket[] = [];
|
|
9
27
|
|
|
10
28
|
/** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
|
|
11
29
|
const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
|
|
@@ -18,46 +36,112 @@ export function routeKey(method: string, url: URL): string {
|
|
|
18
36
|
return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
|
|
19
37
|
}
|
|
20
38
|
|
|
39
|
+
// 处理器给某个请求补一段标签(比如 POST /mcp 的 JSON-RPC 方法名),recordRouteTiming 拼到路由形状后面。
|
|
40
|
+
const labels = new WeakMap<Request, string>();
|
|
41
|
+
export function labelRoute(req: Request, label: string): void {
|
|
42
|
+
labels.set(req, label);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MCP_METHOD_RE = /^[A-Za-z][A-Za-z0-9_./-]{0,63}$/;
|
|
46
|
+
const MCP_TOOL_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
47
|
+
/**
|
|
48
|
+
* POST /mcp 的标签:JSON-RPC 方法名,tools/call 再带工具名,如 "tools/call report_status"。
|
|
49
|
+
* 只读 method 和 params.name 两个字段,别的(尤其 arguments)一概不碰;形状不对的一律折成 "?",
|
|
50
|
+
* 批量请求记 "batch"。名字只放行短的 ASCII 标识符,防止拿请求体造出任意多、任意长的路由键。
|
|
51
|
+
*/
|
|
52
|
+
export function mcpRouteLabel(body: unknown): string {
|
|
53
|
+
if (Array.isArray(body)) return "batch";
|
|
54
|
+
if (!body || typeof body !== "object") return "?";
|
|
55
|
+
const method = (body as { method?: unknown }).method;
|
|
56
|
+
if (typeof method !== "string" || !MCP_METHOD_RE.test(method)) return "?";
|
|
57
|
+
if (method !== "tools/call") return method;
|
|
58
|
+
const params = (body as { params?: unknown }).params;
|
|
59
|
+
const name = params && typeof params === "object" ? (params as { name?: unknown }).name : undefined;
|
|
60
|
+
return `tools/call ${typeof name === "string" && MCP_TOOL_RE.test(name) ? name : "?"}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
21
63
|
/** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
|
|
22
64
|
export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
|
|
23
65
|
if (!res) return res;
|
|
24
66
|
let url: URL;
|
|
25
67
|
try { url = new URL(req.url); } catch { return res; }
|
|
68
|
+
const label = labels.get(req);
|
|
69
|
+
const route = label ? `${routeKey(req.method, url)} ${label}` : routeKey(req.method, url);
|
|
26
70
|
const length = Number(res.headers.get("content-length"));
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
else ring[next] = sample;
|
|
30
|
-
next = (next + 1) % ROUTE_RING_SIZE;
|
|
71
|
+
const bytes = Number.isFinite(length) && res.headers.has("content-length") ? length : 0;
|
|
72
|
+
addSample(route, performance.now() - started, res.status, bytes, now);
|
|
31
73
|
return res;
|
|
32
74
|
}
|
|
33
75
|
|
|
76
|
+
function addSample(route: string, ms: number, status: number, bytes: number, now: number): void {
|
|
77
|
+
const minute = Math.floor(now / ROUTE_BUCKET_MS);
|
|
78
|
+
let bucket = buckets[buckets.length - 1];
|
|
79
|
+
if (!bucket || minute > bucket.minute) {
|
|
80
|
+
buckets.push(bucket = { minute, routes: new Map() });
|
|
81
|
+
const oldest = minute - ROUTE_BUCKET_RETENTION + 1;
|
|
82
|
+
let drop = 0;
|
|
83
|
+
while (drop < buckets.length && buckets[drop].minute < oldest) drop++;
|
|
84
|
+
if (drop) buckets.splice(0, drop);
|
|
85
|
+
} else if (minute < bucket.minute) {
|
|
86
|
+
// 时间戳比最新的桶早(时钟回拨,或测试里乱序记):放回它自己的那一分钟;比保留期还早的丢掉。
|
|
87
|
+
if (minute < bucket.minute - ROUTE_BUCKET_RETENTION + 1) return;
|
|
88
|
+
let i = buckets.length - 1;
|
|
89
|
+
while (i > 0 && buckets[i - 1].minute >= minute) i--;
|
|
90
|
+
if (buckets[i].minute !== minute) buckets.splice(i, 0, { minute, routes: new Map() });
|
|
91
|
+
bucket = buckets[i];
|
|
92
|
+
}
|
|
93
|
+
let agg = bucket.routes.get(route);
|
|
94
|
+
if (!agg) {
|
|
95
|
+
if (bucket.routes.size >= ROUTE_MAX_KEYS_PER_BUCKET) route = ROUTE_OVERFLOW_KEY;
|
|
96
|
+
agg = bucket.routes.get(route);
|
|
97
|
+
if (!agg) bucket.routes.set(route, agg = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
|
|
98
|
+
}
|
|
99
|
+
agg.count++;
|
|
100
|
+
agg.totalMs += ms;
|
|
101
|
+
if (ms > agg.maxMs) agg.maxMs = ms;
|
|
102
|
+
agg.bytes += bytes;
|
|
103
|
+
if (status >= 500) agg.errors++;
|
|
104
|
+
agg.hist[histBin(ms)]++;
|
|
105
|
+
}
|
|
106
|
+
|
|
34
107
|
export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number };
|
|
35
108
|
|
|
36
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* 最近 windowMs 内的请求按路由汇总,按总耗时降序。粒度是分钟桶:窗口起点落在哪个桶,就从那个桶算起。
|
|
111
|
+
* oldest_at 是窗口里最早一个有数据的桶的起点 —— 它比 now - window_ms 晚很多,说明 hub 是那之后才起来的。
|
|
112
|
+
*/
|
|
37
113
|
export function routeStats(windowMs: number, now: number = Date.now()): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
114
|
+
const fromMinute = Math.floor((now - windowMs) / ROUTE_BUCKET_MS);
|
|
115
|
+
const merged = new Map<string, Agg>();
|
|
40
116
|
let oldest: number | null = null;
|
|
41
|
-
for (const
|
|
42
|
-
if (
|
|
43
|
-
if (oldest === null
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
117
|
+
for (const bucket of buckets) {
|
|
118
|
+
if (bucket.minute < fromMinute) continue;
|
|
119
|
+
if (oldest === null) oldest = bucket.minute * ROUTE_BUCKET_MS;
|
|
120
|
+
for (const [route, a] of bucket.routes) {
|
|
121
|
+
let m = merged.get(route);
|
|
122
|
+
if (!m) merged.set(route, m = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
|
|
123
|
+
m.count += a.count;
|
|
124
|
+
m.totalMs += a.totalMs;
|
|
125
|
+
if (a.maxMs > m.maxMs) m.maxMs = a.maxMs;
|
|
126
|
+
m.bytes += a.bytes;
|
|
127
|
+
m.errors += a.errors;
|
|
128
|
+
for (let i = 0; i < HIST_BINS; i++) m.hist[i] += a.hist[i];
|
|
129
|
+
}
|
|
47
130
|
}
|
|
48
131
|
const round = (n: number) => Math.round(n * 10) / 10;
|
|
49
|
-
const routes = [...
|
|
50
|
-
const
|
|
51
|
-
|
|
132
|
+
const routes = [...merged].map(([route, a]): RouteStat => {
|
|
133
|
+
const rank = Math.min(a.count, Math.floor(a.count * 0.95) + 1);
|
|
134
|
+
let seen = 0, bin = 0;
|
|
135
|
+
for (; bin < HIST_BINS - 1; bin++) { seen += a.hist[bin]; if (seen >= rank) break; }
|
|
52
136
|
return {
|
|
53
137
|
route,
|
|
54
|
-
count:
|
|
55
|
-
total_ms: round(
|
|
56
|
-
avg_ms: round(
|
|
57
|
-
p95_ms: round(
|
|
58
|
-
max_ms: round(
|
|
59
|
-
bytes:
|
|
60
|
-
errors:
|
|
138
|
+
count: a.count,
|
|
139
|
+
total_ms: round(a.totalMs),
|
|
140
|
+
avg_ms: round(a.totalMs / a.count),
|
|
141
|
+
p95_ms: round(Math.min(histUpper(bin), a.maxMs)),
|
|
142
|
+
max_ms: round(a.maxMs),
|
|
143
|
+
bytes: a.bytes,
|
|
144
|
+
errors: a.errors,
|
|
61
145
|
};
|
|
62
146
|
}).sort((a, b) => b.total_ms - a.total_ms);
|
|
63
147
|
return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
|
|
@@ -65,6 +149,5 @@ export function routeStats(windowMs: number, now: number = Date.now()): { window
|
|
|
65
149
|
|
|
66
150
|
/** Test-only. */
|
|
67
151
|
export function __resetRouteTimingForTest(): void {
|
|
68
|
-
|
|
69
|
-
next = 0;
|
|
152
|
+
buckets.length = 0;
|
|
70
153
|
}
|