@wrongstack/core 0.313.0 → 0.313.1
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/dist/chronicle/index.js +84 -6391
- package/dist/chronicle/project-server.js +84 -4868
- package/dist/chunk-2BUUIMQB.js +225 -0
- package/dist/chunk-2FK4WTFF.js +207 -0
- package/dist/chunk-2FQL4PDC.js +292 -0
- package/dist/chunk-2KKODIQU.js +2256 -0
- package/dist/chunk-2YU4WVBW.js +134 -0
- package/dist/chunk-3E244UDA.js +356 -0
- package/dist/chunk-3XPVB7BA.js +135 -0
- package/dist/chunk-4LJJYBAU.js +388 -0
- package/dist/chunk-4VWO3ZXL.js +342 -0
- package/dist/chunk-4XV6IR37.js +187 -0
- package/dist/chunk-4ZHTKJHM.js +183 -0
- package/dist/chunk-52RTVH4M.js +361 -0
- package/dist/chunk-57GEWK5X.js +26 -0
- package/dist/chunk-5DSMEVA4.js +931 -0
- package/dist/chunk-5RP3QTRL.js +1223 -0
- package/dist/chunk-5S437PRC.js +50 -0
- package/dist/chunk-5XDUGTKN.js +511 -0
- package/dist/chunk-62V2AFVG.js +422 -0
- package/dist/chunk-65RDIOR2.js +2470 -0
- package/dist/chunk-6AKOTGLM.js +597 -0
- package/dist/chunk-7ZHZE5EO.js +28 -0
- package/dist/chunk-A455ATZB.js +140 -0
- package/dist/chunk-AD7ZFZCS.js +46 -0
- package/dist/chunk-AI4LLQ2U.js +856 -0
- package/dist/chunk-ASX6AW36.js +789 -0
- package/dist/chunk-AT4V4XGI.js +3522 -0
- package/dist/chunk-AWNQ6ED6.js +311 -0
- package/dist/chunk-B4AMLZPB.js +9 -0
- package/dist/chunk-BAKVJLEV.js +417 -0
- package/dist/chunk-BJHVULHO.js +55 -0
- package/dist/chunk-CB2HFKTP.js +9 -0
- package/dist/chunk-CE4ZOCOI.js +1640 -0
- package/dist/chunk-CQQL4ZOQ.js +151 -0
- package/dist/chunk-DEIQMBD2.js +65 -0
- package/dist/chunk-E6I5RXVK.js +58 -0
- package/dist/chunk-EFEHDQGY.js +1589 -0
- package/dist/chunk-EODDVJBM.js +82 -0
- package/dist/chunk-EPC3GZD4.js +308 -0
- package/dist/chunk-FAYRX2AD.js +13 -0
- package/dist/chunk-FFSX2QDQ.js +362 -0
- package/dist/chunk-FHAMQS7Q.js +5856 -0
- package/dist/chunk-FIV4IJL7.js +2263 -0
- package/dist/chunk-FNXVT7HF.js +52 -0
- package/dist/chunk-FSWCWSTS.js +395 -0
- package/dist/chunk-FW5LDDMJ.js +16464 -0
- package/dist/chunk-FXQLIX3K.js +211 -0
- package/dist/chunk-G5AAKEEZ.js +228 -0
- package/dist/chunk-G5S3S5ZN.js +38 -0
- package/dist/chunk-GAZEWQEZ.js +231 -0
- package/dist/chunk-GFJOMSHG.js +125 -0
- package/dist/chunk-GFXZTHNI.js +158 -0
- package/dist/chunk-GM54GJBU.js +960 -0
- package/dist/chunk-GPQ3NJPR.js +900 -0
- package/dist/chunk-GTCU6DZ3.js +1 -0
- package/dist/chunk-H5E77KUJ.js +15 -0
- package/dist/chunk-H72H4XDU.js +1023 -0
- package/dist/chunk-HA4ZYA6T.js +178 -0
- package/dist/chunk-I45KAW2N.js +5020 -0
- package/dist/chunk-IQ35HMQL.js +1396 -0
- package/dist/chunk-IYFBFX34.js +86 -0
- package/dist/chunk-JJPMWPB6.js +109 -0
- package/dist/chunk-JTHKOYWU.js +663 -0
- package/dist/chunk-JVGPR3TS.js +365 -0
- package/dist/chunk-JVKB2TBL.js +48 -0
- package/dist/chunk-JX7VBZDK.js +31 -0
- package/dist/chunk-K3G6JBUC.js +5232 -0
- package/dist/chunk-K4YUNKU7.js +589 -0
- package/dist/chunk-KBZGE6LN.js +710 -0
- package/dist/chunk-KDQJCT2J.js +45 -0
- package/dist/chunk-KI4L2DUL.js +560 -0
- package/dist/chunk-KJ2ZTQSL.js +47 -0
- package/dist/chunk-KSJ5UTP7.js +154 -0
- package/dist/chunk-LHOA5DWA.js +28 -0
- package/dist/chunk-LX5QWLBL.js +27 -0
- package/dist/chunk-LZDDLNCZ.js +279 -0
- package/dist/chunk-M2WF6AXG.js +115 -0
- package/dist/chunk-M7BS2K6U.js +1 -0
- package/dist/chunk-NDBIPFNY.js +102 -0
- package/dist/chunk-NEMIWEXC.js +1199 -0
- package/dist/chunk-NFAYHPRH.js +323 -0
- package/dist/chunk-O37BQ65S.js +21 -0
- package/dist/chunk-O74MJHXU.js +187 -0
- package/dist/chunk-OJZEX73F.js +112 -0
- package/dist/chunk-PAAWLLZX.js +74 -0
- package/dist/chunk-PFXFQQRT.js +339 -0
- package/dist/chunk-PFZO3C6H.js +14 -0
- package/dist/chunk-PHH7LD45.js +113 -0
- package/dist/chunk-QRTG4XTI.js +188 -0
- package/dist/chunk-QVOVMXJR.js +1351 -0
- package/dist/chunk-RCQYT2I4.js +757 -0
- package/dist/chunk-RD6DEE36.js +1178 -0
- package/dist/chunk-RJIZEJ42.js +30 -0
- package/dist/chunk-RPOA7IAE.js +150 -0
- package/dist/chunk-SN2DQRL6.js +1172 -0
- package/dist/chunk-SQJVFEBV.js +41 -0
- package/dist/chunk-SQP64RCS.js +5391 -0
- package/dist/chunk-SVT4RNTR.js +49 -0
- package/dist/chunk-SWUFN7XM.js +43 -0
- package/dist/chunk-SXMVYC5H.js +174 -0
- package/dist/chunk-SZ6AYUIZ.js +23 -0
- package/dist/chunk-T3KGZBU5.js +3171 -0
- package/dist/chunk-TBRL2RCC.js +9 -0
- package/dist/chunk-TRT2HRWZ.js +18 -0
- package/dist/chunk-TYW2GI5I.js +1077 -0
- package/dist/chunk-UIWONWVG.js +4959 -0
- package/dist/chunk-VB3H5WWB.js +151 -0
- package/dist/chunk-W4A6QA3D.js +258 -0
- package/dist/chunk-WBPVKEEE.js +227 -0
- package/dist/chunk-WEBMSQP2.js +123 -0
- package/dist/chunk-WOEZV3Q4.js +333 -0
- package/dist/chunk-WQQON3SV.js +473 -0
- package/dist/chunk-WQTOVMGV.js +119 -0
- package/dist/chunk-WZO23AWO.js +2779 -0
- package/dist/chunk-XBDP4QPW.js +7 -0
- package/dist/chunk-XEBHSJLT.js +103 -0
- package/dist/chunk-XQ2ECNK3.js +1 -0
- package/dist/chunk-XQ2XGIBF.js +2086 -0
- package/dist/chunk-Y2GE5TSI.js +355 -0
- package/dist/chunk-YBUJCZ57.js +351 -0
- package/dist/chunk-YNCVSFGF.js +149 -0
- package/dist/chunk-YUPMXOGP.js +282 -0
- package/dist/chunk-YY6APYUM.js +49 -0
- package/dist/chunk-Z2IJUO45.js +691 -0
- package/dist/chunk-ZEHUCJHY.js +129 -0
- package/dist/chunk-ZYU2YOJ6.js +1496 -0
- package/dist/chunk-ZZ4V3WIZ.js +163 -0
- package/dist/coordination/agents/index.js +145 -6260
- package/dist/coordination/index.js +365 -34324
- package/dist/coordination/mailbox-project-server.js +48 -3276
- package/dist/coordination/provider-status-tracker.d.ts +4 -0
- package/dist/core/agent.d.ts +14 -1
- package/dist/core/index.js +267 -12480
- package/dist/core/model-ref.js +5 -23
- package/dist/core/provider-runner.d.ts +36 -1
- package/dist/design/index.js +50 -1087
- package/dist/execution/index.d.ts +1 -1
- package/dist/execution/index.js +1599 -21888
- package/dist/execution/prompt-enhancer.js +16 -535
- package/dist/execution/retry-policy.d.ts +31 -1
- package/dist/extension/index.js +7 -311
- package/dist/goal/index.js +33 -3435
- package/dist/hooks/index.js +14 -1138
- package/dist/hq/index.js +112 -5139
- package/dist/hq/protocol.js +21 -668
- package/dist/index.js +1410 -92656
- package/dist/infrastructure/index.js +64 -4811
- package/dist/kernel/events/provider-events.d.ts +15 -0
- package/dist/kernel/index.js +14 -992
- package/dist/models/index.js +40 -1117
- package/dist/notifications/index.js +3 -108
- package/dist/observability/index.js +17 -279
- package/dist/plugin/index.js +584 -14835
- package/dist/prompts/index.js +14 -948
- package/dist/registry/index.js +9 -822
- package/dist/registry-R23UBE7H.js +14 -0
- package/dist/replay/index.js +6 -84
- package/dist/review-finding-store-DKAV3AS6.js +21 -0
- package/dist/review-report-store-OWY3EC3N.js +19 -0
- package/dist/security/index.js +114 -2202
- package/dist/session-catalog/index.js +36 -2103
- package/dist/session-catalog/project-server.js +42 -1435
- package/dist/skills/index.js +35 -1578
- package/dist/storage/index.js +182 -15600
- package/dist/tasking/index.js +10 -676
- package/dist/tools/index.js +52 -9590
- package/dist/types/config/autonomy.d.ts +5 -0
- package/dist/types/config/ui.d.ts +1 -1
- package/dist/types/errors.d.ts +18 -0
- package/dist/types/index.js +121 -1342
- package/dist/types/provider-runner.d.ts +8 -0
- package/dist/types/session-markers.js +8 -144
- package/dist/utils/error.js +3 -4
- package/dist/utils/expect-defined.js +3 -9
- package/dist/utils/heap-watchdog.js +6 -706
- package/dist/utils/index.js +269 -5685
- package/dist/utils/sage-output-block.js +5 -42
- package/dist/wiring/proxy-rewrite.d.ts +18 -0
- package/dist/wiring/proxy-rewrite.js +18 -121
- package/dist/worktree/index.js +6 -887
- package/package.json +4 -4
|
@@ -0,0 +1,2256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveHqRedactionPolicy,
|
|
3
|
+
scrubAndTruncateHqPreview,
|
|
4
|
+
summarizeHqToolArgs
|
|
5
|
+
} from "./chunk-ZYU2YOJ6.js";
|
|
6
|
+
import {
|
|
7
|
+
HQ_TRANSCRIPT_TEXT_CAP,
|
|
8
|
+
isHqKanbanSnapshotPayload
|
|
9
|
+
} from "./chunk-Z2IJUO45.js";
|
|
10
|
+
import {
|
|
11
|
+
SECRET_FILE_MODE
|
|
12
|
+
} from "./chunk-H5E77KUJ.js";
|
|
13
|
+
import {
|
|
14
|
+
effectiveInputTokens
|
|
15
|
+
} from "./chunk-NFAYHPRH.js";
|
|
16
|
+
import {
|
|
17
|
+
CHAT_MARKER_SOURCES,
|
|
18
|
+
sessionEventToMarker
|
|
19
|
+
} from "./chunk-KSJ5UTP7.js";
|
|
20
|
+
import {
|
|
21
|
+
sessionScopedPath
|
|
22
|
+
} from "./chunk-AD7ZFZCS.js";
|
|
23
|
+
import {
|
|
24
|
+
atomicReplaceWithWriter,
|
|
25
|
+
atomicWrite,
|
|
26
|
+
withFileLock
|
|
27
|
+
} from "./chunk-7ZHZE5EO.js";
|
|
28
|
+
import {
|
|
29
|
+
resolveWstackPaths
|
|
30
|
+
} from "./chunk-YNCVSFGF.js";
|
|
31
|
+
|
|
32
|
+
// src/hq/session-bridge.ts
|
|
33
|
+
import * as fs from "node:fs";
|
|
34
|
+
import * as fsp from "node:fs/promises";
|
|
35
|
+
|
|
36
|
+
// src/hq/transcript-mapper.ts
|
|
37
|
+
function blocksToText(content) {
|
|
38
|
+
if (typeof content === "string") return content;
|
|
39
|
+
if (Array.isArray(content)) {
|
|
40
|
+
return content.filter(
|
|
41
|
+
(b) => !!b && typeof b === "object" && b.type === "text" && typeof b.text === "string"
|
|
42
|
+
).map((b) => b.text).join("\n");
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
function asString(v) {
|
|
47
|
+
if (typeof v === "string") return v;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(v, null, 2);
|
|
50
|
+
} catch {
|
|
51
|
+
return String(v);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function argsEntry(ts, name, input, id) {
|
|
55
|
+
return {
|
|
56
|
+
ts,
|
|
57
|
+
role: "tool",
|
|
58
|
+
tool: String(name ?? "tool"),
|
|
59
|
+
toolInput: input !== void 0 && input !== null ? asString(input) : "{}",
|
|
60
|
+
text: "",
|
|
61
|
+
...typeof id === "string" ? { toolUseId: id } : {}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function mapSessionEventToEntries(ev) {
|
|
65
|
+
const ts = typeof ev["ts"] === "string" ? ev["ts"] : "";
|
|
66
|
+
const make = (role, text, extra) => ({
|
|
67
|
+
ts,
|
|
68
|
+
role,
|
|
69
|
+
text,
|
|
70
|
+
...extra
|
|
71
|
+
});
|
|
72
|
+
switch (ev["type"]) {
|
|
73
|
+
case "user_input": {
|
|
74
|
+
const t = blocksToText(ev["content"]);
|
|
75
|
+
return t.trim() ? [make("user", t)] : [];
|
|
76
|
+
}
|
|
77
|
+
case "llm_response": {
|
|
78
|
+
const out = [];
|
|
79
|
+
const content = ev["content"];
|
|
80
|
+
if (Array.isArray(content)) {
|
|
81
|
+
const thinking = content.filter(
|
|
82
|
+
(b) => !!b && typeof b === "object" && b.type === "thinking" && typeof b.thinking === "string"
|
|
83
|
+
).map((b) => b.thinking).join("\n");
|
|
84
|
+
if (thinking.trim()) out.push(make("thinking", thinking));
|
|
85
|
+
}
|
|
86
|
+
const t = blocksToText(content);
|
|
87
|
+
if (t.trim()) out.push(make("assistant", t));
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
for (const b of content) {
|
|
90
|
+
if (b && typeof b === "object" && b.type === "tool_use") {
|
|
91
|
+
const block = b;
|
|
92
|
+
out.push(argsEntry(ts, block.name, block.input, block.id));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
case "tool_use":
|
|
99
|
+
return [argsEntry(ts, ev["name"], ev["input"], ev["id"])];
|
|
100
|
+
case "tool_call_start":
|
|
101
|
+
return [argsEntry(ts, ev["name"], ev["input"] ?? ev["args"], ev["id"])];
|
|
102
|
+
case "tool_result": {
|
|
103
|
+
const isError = ev["isError"] === true;
|
|
104
|
+
const content = ev["content"] ?? ev["output"];
|
|
105
|
+
const outStr = content !== void 0 && content !== null ? asString(content) : "";
|
|
106
|
+
return [
|
|
107
|
+
make(isError ? "error" : "tool", outStr, {
|
|
108
|
+
tool: "\u21B3 result",
|
|
109
|
+
...isError ? { isError: true } : {},
|
|
110
|
+
...typeof ev["id"] === "string" ? { toolUseId: ev["id"] } : {}
|
|
111
|
+
})
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
case "tool_call_end": {
|
|
115
|
+
const isError = ev["isError"] === true;
|
|
116
|
+
const content = ev["output"] ?? ev["content"];
|
|
117
|
+
const outStr = content !== void 0 && content !== null ? asString(content) : "";
|
|
118
|
+
if (!outStr.trim() && !isError && typeof ev["durationMs"] !== "number") return [];
|
|
119
|
+
return [
|
|
120
|
+
make(isError ? "error" : "tool", outStr, {
|
|
121
|
+
tool: typeof ev["name"] === "string" ? String(ev["name"]) : "\u21B3 result",
|
|
122
|
+
...typeof ev["durationMs"] === "number" ? { durationMs: ev["durationMs"] } : {},
|
|
123
|
+
...isError ? { isError: true } : {},
|
|
124
|
+
...typeof ev["id"] === "string" ? { toolUseId: ev["id"] } : {}
|
|
125
|
+
})
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
case "task_completed":
|
|
129
|
+
return [make("system", `task done: ${String(ev["title"] ?? "")}`)];
|
|
130
|
+
case "task_failed":
|
|
131
|
+
return [make("system", `task failed: ${String(ev["title"] ?? "")}`)];
|
|
132
|
+
default: {
|
|
133
|
+
if (!CHAT_MARKER_SOURCES.has(ev["type"])) return [];
|
|
134
|
+
const marker = sessionEventToMarker(ev);
|
|
135
|
+
if (!marker) return [];
|
|
136
|
+
return [
|
|
137
|
+
make(marker.level === "error" ? "error" : "system", marker.text, {
|
|
138
|
+
...marker.level === "error" ? { isError: true } : {},
|
|
139
|
+
...marker.agentId !== void 0 ? { agentId: marker.agentId } : {}
|
|
140
|
+
})
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function isResultEntry(e) {
|
|
146
|
+
return (e.role === "tool" || e.role === "error") && e.toolUseId !== void 0 && e.toolInput === void 0;
|
|
147
|
+
}
|
|
148
|
+
function mergeToolResults(flat) {
|
|
149
|
+
const out = [];
|
|
150
|
+
const argsById = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const src of flat) {
|
|
152
|
+
const e = { ...src };
|
|
153
|
+
if (isResultEntry(e) && e.toolUseId !== void 0) {
|
|
154
|
+
const tgt = argsById.get(e.toolUseId);
|
|
155
|
+
if (tgt) {
|
|
156
|
+
tgt.text = e.text || "";
|
|
157
|
+
if (e.durationMs !== void 0) tgt.durationMs = e.durationMs;
|
|
158
|
+
if (e.isError) {
|
|
159
|
+
tgt.role = "error";
|
|
160
|
+
tgt.isError = true;
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
out.push(e);
|
|
166
|
+
if (e.role === "tool" && e.toolUseId !== void 0 && e.toolInput !== void 0) {
|
|
167
|
+
argsById.set(e.toolUseId, e);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
function buildTranscriptFromEvents(events) {
|
|
173
|
+
const flat = [];
|
|
174
|
+
for (const ev of events) {
|
|
175
|
+
for (const e of mapSessionEventToEntries(ev)) flat.push(e);
|
|
176
|
+
}
|
|
177
|
+
return mergeToolResults(flat);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/hq/session-bridge.ts
|
|
181
|
+
var VALID_AGENT_STATUS = /* @__PURE__ */ new Set([
|
|
182
|
+
"idle",
|
|
183
|
+
"running",
|
|
184
|
+
"streaming",
|
|
185
|
+
"waiting_user",
|
|
186
|
+
"error"
|
|
187
|
+
]);
|
|
188
|
+
var MAX_TRANSCRIPT_BATCH_ENTRIES = 32;
|
|
189
|
+
var MAX_TRANSCRIPT_BATCH_BYTES = 512 * 1024;
|
|
190
|
+
var HQ_STALE_SNAPSHOT_WINDOW_MS = 5 * 6e4;
|
|
191
|
+
var KEEPALIVE_REPUBLISH_MS = Math.floor(HQ_STALE_SNAPSHOT_WINDOW_MS * 0.8);
|
|
192
|
+
function toAgentSummary(a) {
|
|
193
|
+
const status = VALID_AGENT_STATUS.has(a.status) ? a.status : "idle";
|
|
194
|
+
return {
|
|
195
|
+
id: a.id,
|
|
196
|
+
name: a.name,
|
|
197
|
+
status,
|
|
198
|
+
iterations: a.iterations,
|
|
199
|
+
toolCalls: a.toolCalls,
|
|
200
|
+
lastActivityAt: a.lastActivityAt,
|
|
201
|
+
...a.startedAt !== void 0 ? { startedAt: a.startedAt } : {},
|
|
202
|
+
...a.currentTool !== void 0 ? { currentTool: a.currentTool } : {},
|
|
203
|
+
...a.costUsd !== void 0 ? { costUsd: a.costUsd } : {},
|
|
204
|
+
...a.tokensIn !== void 0 ? { tokensIn: a.tokensIn } : {},
|
|
205
|
+
...a.tokensOut !== void 0 ? { tokensOut: a.tokensOut } : {},
|
|
206
|
+
...a.ctxPct !== void 0 ? { ctxPct: a.ctxPct } : {},
|
|
207
|
+
...a.model !== void 0 ? { model: a.model } : {},
|
|
208
|
+
...a.partialText !== void 0 ? { partialText: a.partialText } : {}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function deriveSessionStatus(agents) {
|
|
212
|
+
return agents.some(
|
|
213
|
+
(a) => a.status === "running" || a.status === "streaming" || a.status === "waiting_user"
|
|
214
|
+
) ? "active" : "idle";
|
|
215
|
+
}
|
|
216
|
+
function downgradeStaleAgentStatuses(agents, nowMs) {
|
|
217
|
+
const cutoff = nowMs - HQ_STALE_SNAPSHOT_WINDOW_MS;
|
|
218
|
+
return agents.map((agent) => {
|
|
219
|
+
if (agent.status !== "running" && agent.status !== "streaming") return agent;
|
|
220
|
+
const lastActivityAt = Date.parse(agent.lastActivityAt);
|
|
221
|
+
if (!Number.isFinite(lastActivityAt) || lastActivityAt >= cutoff) return agent;
|
|
222
|
+
return { ...agent, status: "idle" };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function startSessionTelemetryBridge(opts) {
|
|
226
|
+
const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
227
|
+
const publisher = opts.publisher;
|
|
228
|
+
const identity = publisher.identity;
|
|
229
|
+
const project = publisher.project;
|
|
230
|
+
const startedAt = opts.startedAt ?? now();
|
|
231
|
+
const wpaths = resolveWstackPaths({
|
|
232
|
+
projectRoot: opts.projectRoot,
|
|
233
|
+
...opts.globalRoot !== void 0 ? { globalRoot: opts.globalRoot } : {}
|
|
234
|
+
});
|
|
235
|
+
const sessionFile = sessionScopedPath(wpaths.projectSessions, opts.sessionId, ".jsonl");
|
|
236
|
+
let agents = (opts.initialAgents ?? []).map(toAgentSummary);
|
|
237
|
+
let lastActivityAt = agents.reduce(
|
|
238
|
+
(latest, agent) => agent.lastActivityAt > latest ? agent.lastActivityAt : latest,
|
|
239
|
+
startedAt
|
|
240
|
+
);
|
|
241
|
+
let lastSnapshotHash = "";
|
|
242
|
+
let lastPublishedAtMs = Date.now();
|
|
243
|
+
let disposed = false;
|
|
244
|
+
function buildSnapshot() {
|
|
245
|
+
const effectiveAgents = downgradeStaleAgentStatuses(agents, Date.parse(now()));
|
|
246
|
+
return {
|
|
247
|
+
sessionId: opts.sessionId,
|
|
248
|
+
clientKind: identity.kind,
|
|
249
|
+
machineId: identity.machineId,
|
|
250
|
+
projectId: project.projectId,
|
|
251
|
+
projectName: opts.projectName ?? project.projectName,
|
|
252
|
+
projectRoot: opts.projectRoot,
|
|
253
|
+
status: deriveSessionStatus(effectiveAgents),
|
|
254
|
+
startedAt,
|
|
255
|
+
lastActivityAt,
|
|
256
|
+
agentCount: effectiveAgents.length,
|
|
257
|
+
agents: effectiveAgents,
|
|
258
|
+
...identity.hostname !== void 0 ? { hostname: identity.hostname } : {},
|
|
259
|
+
...identity.pid !== void 0 ? { pid: identity.pid } : {},
|
|
260
|
+
...opts.gitBranch !== void 0 ? { gitBranch: opts.gitBranch } : {}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function publishSnapshot(force = false) {
|
|
264
|
+
if (disposed) return;
|
|
265
|
+
const snap = buildSnapshot();
|
|
266
|
+
const hash = JSON.stringify({ ...snap, lastActivityAt: "" });
|
|
267
|
+
if (!force && hash === lastSnapshotHash) return;
|
|
268
|
+
lastSnapshotHash = hash;
|
|
269
|
+
try {
|
|
270
|
+
publisher.publishSessionSnapshot(snap);
|
|
271
|
+
lastPublishedAtMs = Date.now();
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function publishTranscriptEntries(entries) {
|
|
276
|
+
let batch = [];
|
|
277
|
+
let batchFromSeq = seqEmitted;
|
|
278
|
+
const publishBatch = () => {
|
|
279
|
+
if (batch.length === 0) return;
|
|
280
|
+
try {
|
|
281
|
+
publisher.publishTranscriptAppend({
|
|
282
|
+
sessionId: opts.sessionId,
|
|
283
|
+
fromSeq: batchFromSeq,
|
|
284
|
+
entries: batch
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
seqEmitted += batch.length;
|
|
289
|
+
batch = [];
|
|
290
|
+
batchFromSeq = seqEmitted;
|
|
291
|
+
};
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
const candidate = [...batch, entry];
|
|
294
|
+
const candidatePayload = {
|
|
295
|
+
sessionId: opts.sessionId,
|
|
296
|
+
fromSeq: batchFromSeq,
|
|
297
|
+
entries: candidate
|
|
298
|
+
};
|
|
299
|
+
const tooManyEntries = candidate.length > MAX_TRANSCRIPT_BATCH_ENTRIES;
|
|
300
|
+
const tooLarge = batch.length > 0 && Buffer.byteLength(JSON.stringify(candidatePayload), "utf8") > MAX_TRANSCRIPT_BATCH_BYTES;
|
|
301
|
+
if (tooManyEntries || tooLarge) publishBatch();
|
|
302
|
+
batch.push(entry);
|
|
303
|
+
}
|
|
304
|
+
publishBatch();
|
|
305
|
+
}
|
|
306
|
+
const offAgents = opts.events?.on("session.agents_updated", (payload) => {
|
|
307
|
+
agents = payload.agents.map(toAgentSummary);
|
|
308
|
+
lastActivityAt = now();
|
|
309
|
+
publishSnapshot();
|
|
310
|
+
});
|
|
311
|
+
publishSnapshot(true);
|
|
312
|
+
let prevOnAppend;
|
|
313
|
+
if (opts.writer?.setOnAppend) {
|
|
314
|
+
prevOnAppend = opts.writer.onAppend;
|
|
315
|
+
opts.writer.setOnAppend((event) => {
|
|
316
|
+
if (disposed) return;
|
|
317
|
+
try {
|
|
318
|
+
prevOnAppend?.(event);
|
|
319
|
+
} catch {
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const entries = mapSessionEventToEntries(event);
|
|
323
|
+
if (entries.length > 0) {
|
|
324
|
+
publishTranscriptEntries(entries);
|
|
325
|
+
lastActivityAt = now();
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
let seqEmitted = 0;
|
|
332
|
+
let watcher = null;
|
|
333
|
+
let tailTimer = null;
|
|
334
|
+
const snapshotTimer = setInterval(() => {
|
|
335
|
+
publishSnapshot(Date.now() - lastPublishedAtMs >= KEEPALIVE_REPUBLISH_MS);
|
|
336
|
+
}, opts.snapshotIntervalMs ?? 2500);
|
|
337
|
+
snapshotTimer.unref?.();
|
|
338
|
+
if (!opts.writer?.setOnAppend) {
|
|
339
|
+
let setupWatcher2 = function() {
|
|
340
|
+
if (disposed || watcher) return;
|
|
341
|
+
try {
|
|
342
|
+
const nextWatcher = fs.watch(sessionFile, () => {
|
|
343
|
+
if (watchPending || disposed) return;
|
|
344
|
+
watchPending = true;
|
|
345
|
+
setTimeout(() => {
|
|
346
|
+
watchPending = false;
|
|
347
|
+
void tail();
|
|
348
|
+
}, 25);
|
|
349
|
+
});
|
|
350
|
+
nextWatcher.on("error", () => {
|
|
351
|
+
try {
|
|
352
|
+
nextWatcher.close();
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
if (watcher === nextWatcher) watcher = null;
|
|
356
|
+
});
|
|
357
|
+
watcher = nextWatcher;
|
|
358
|
+
} catch {
|
|
359
|
+
watcher = null;
|
|
360
|
+
}
|
|
361
|
+
}, scheduleTail2 = function() {
|
|
362
|
+
if (disposed) return;
|
|
363
|
+
tailTimer = setTimeout(
|
|
364
|
+
() => {
|
|
365
|
+
void tail().finally(scheduleTail2);
|
|
366
|
+
},
|
|
367
|
+
watcher ? relaxedTailMs : fastTailMs
|
|
368
|
+
);
|
|
369
|
+
tailTimer.unref?.();
|
|
370
|
+
};
|
|
371
|
+
var setupWatcher = setupWatcher2, scheduleTail = scheduleTail2;
|
|
372
|
+
let offset = 0;
|
|
373
|
+
let partial = "";
|
|
374
|
+
let tailing = false;
|
|
375
|
+
let watchPending = false;
|
|
376
|
+
async function tail() {
|
|
377
|
+
if (disposed || tailing) return;
|
|
378
|
+
tailing = true;
|
|
379
|
+
try {
|
|
380
|
+
const stat3 = await fsp.stat(sessionFile).catch(() => null);
|
|
381
|
+
if (disposed) return;
|
|
382
|
+
if (!stat3) return;
|
|
383
|
+
setupWatcher2();
|
|
384
|
+
if (stat3.size <= offset) return;
|
|
385
|
+
const fd = await fsp.open(sessionFile, "r");
|
|
386
|
+
try {
|
|
387
|
+
if (disposed) return;
|
|
388
|
+
const len = stat3.size - offset;
|
|
389
|
+
const buf = Buffer.allocUnsafe(len);
|
|
390
|
+
const { bytesRead } = await fd.read(buf, 0, len, offset);
|
|
391
|
+
const actualBuf = bytesRead < len ? buf.subarray(0, bytesRead) : buf;
|
|
392
|
+
offset += bytesRead;
|
|
393
|
+
partial += actualBuf.toString("utf8");
|
|
394
|
+
const lines = partial.split("\n");
|
|
395
|
+
partial = lines.pop() ?? "";
|
|
396
|
+
const entries = [];
|
|
397
|
+
for (const line of lines) {
|
|
398
|
+
const trimmed = line.trim();
|
|
399
|
+
if (!trimmed) continue;
|
|
400
|
+
let obj;
|
|
401
|
+
try {
|
|
402
|
+
obj = JSON.parse(trimmed);
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
for (const entry of mapSessionEventToEntries(obj)) entries.push(entry);
|
|
407
|
+
}
|
|
408
|
+
if (entries.length > 0) {
|
|
409
|
+
publishTranscriptEntries(entries);
|
|
410
|
+
lastActivityAt = now();
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
await fd.close();
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
} finally {
|
|
417
|
+
tailing = false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const fastTailMs = opts.transcriptIntervalMs ?? 500;
|
|
421
|
+
const relaxedTailMs = opts.transcriptFallbackIntervalMs ?? (opts.transcriptIntervalMs !== void 0 ? opts.transcriptIntervalMs : 5e3);
|
|
422
|
+
void tail().finally(scheduleTail2);
|
|
423
|
+
}
|
|
424
|
+
return () => {
|
|
425
|
+
if (disposed) return;
|
|
426
|
+
disposed = true;
|
|
427
|
+
if (opts.writer?.setOnAppend) {
|
|
428
|
+
try {
|
|
429
|
+
opts.writer.setOnAppend(prevOnAppend);
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
offAgents?.();
|
|
434
|
+
if (watcher) {
|
|
435
|
+
try {
|
|
436
|
+
watcher.close();
|
|
437
|
+
} catch {
|
|
438
|
+
}
|
|
439
|
+
watcher = null;
|
|
440
|
+
}
|
|
441
|
+
clearInterval(snapshotTimer);
|
|
442
|
+
if (tailTimer) {
|
|
443
|
+
clearTimeout(tailTimer);
|
|
444
|
+
tailTimer = null;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
publisher.publishSessionEnded({ sessionId: opts.sessionId, endedAt: now() });
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/hq/bridge-context.ts
|
|
454
|
+
function createBridgeContext(options) {
|
|
455
|
+
const publisher = options.publisher;
|
|
456
|
+
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
457
|
+
const bridgeSessionId = options.sessionId;
|
|
458
|
+
const offs = [];
|
|
459
|
+
function sessionIdTag(eventSessionId) {
|
|
460
|
+
const tag = eventSessionId ?? bridgeSessionId;
|
|
461
|
+
return tag !== void 0 && tag.length > 0 ? { sessionId: tag } : {};
|
|
462
|
+
}
|
|
463
|
+
function safePublish(publishOptions) {
|
|
464
|
+
try {
|
|
465
|
+
publisher.publishEvent(publishOptions);
|
|
466
|
+
} catch {
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
function track(off) {
|
|
470
|
+
offs.push(off);
|
|
471
|
+
}
|
|
472
|
+
function dispose() {
|
|
473
|
+
for (const off of offs) {
|
|
474
|
+
try {
|
|
475
|
+
off();
|
|
476
|
+
} catch {
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
offs.length = 0;
|
|
480
|
+
}
|
|
481
|
+
return { now, sessionIdTag, safePublish, track, dispose };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/hq/fleet-bridge.ts
|
|
485
|
+
function startFleetTelemetryBridge(opts) {
|
|
486
|
+
const { events, publisher, runId } = opts;
|
|
487
|
+
const ctx = createBridgeContext(opts);
|
|
488
|
+
let lastHash = "";
|
|
489
|
+
function buildPayload(stats) {
|
|
490
|
+
const subagents = stats.subagentStatuses.map((s) => ({
|
|
491
|
+
subagentId: s.subagentId,
|
|
492
|
+
...s.taskId ? { task: s.taskId } : {},
|
|
493
|
+
status: normalizeSubagentStatus(s.status),
|
|
494
|
+
...s.model !== void 0 ? { model: s.model } : {},
|
|
495
|
+
...s.costUsd !== void 0 ? { costUsd: s.costUsd } : {},
|
|
496
|
+
...s.runtimeMs !== void 0 ? { runtimeMs: s.runtimeMs } : {},
|
|
497
|
+
...s.lastActivityAt !== void 0 ? { lastActivityAt: s.lastActivityAt } : {}
|
|
498
|
+
}));
|
|
499
|
+
return {
|
|
500
|
+
runId,
|
|
501
|
+
activeSubagents: stats.running + stats.idle,
|
|
502
|
+
queuedTasks: stats.pending,
|
|
503
|
+
completedTasks: stats.completed,
|
|
504
|
+
failedTasks: stats.stopped,
|
|
505
|
+
...stats.totalCostUsd !== void 0 ? { totalCostUsd: stats.totalCostUsd } : {},
|
|
506
|
+
subagents,
|
|
507
|
+
...stats.maxConcurrent !== void 0 ? { maxConcurrent: stats.maxConcurrent } : {},
|
|
508
|
+
...stats.maxSpawns !== void 0 ? { maxSpawns: stats.maxSpawns } : {},
|
|
509
|
+
...stats.usedSpawns !== void 0 ? { usedSpawns: stats.usedSpawns } : {},
|
|
510
|
+
...stats.remainingSpawns !== void 0 ? { remainingSpawns: stats.remainingSpawns } : {},
|
|
511
|
+
...stats.effectiveSource !== void 0 ? { effectiveSource: stats.effectiveSource } : {},
|
|
512
|
+
...stats.checkpointMaxSpawns !== void 0 ? { checkpointMaxSpawns: stats.checkpointMaxSpawns } : {},
|
|
513
|
+
...stats.ceilingMismatch ? { ceilingMismatch: true } : {}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const off = events.on("coordinator.stats", (stats) => {
|
|
517
|
+
const payload = buildPayload(stats);
|
|
518
|
+
const hash = JSON.stringify(payload);
|
|
519
|
+
if (hash === lastHash) return;
|
|
520
|
+
lastHash = hash;
|
|
521
|
+
try {
|
|
522
|
+
publisher.publishFleetSnapshot(payload, {
|
|
523
|
+
...ctx.sessionIdTag(),
|
|
524
|
+
timestamp: ctx.now()
|
|
525
|
+
});
|
|
526
|
+
} catch {
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
ctx.track(off);
|
|
530
|
+
return ctx.dispose;
|
|
531
|
+
}
|
|
532
|
+
var FLEET_STATUS_MAP = {
|
|
533
|
+
running: "running",
|
|
534
|
+
idle: "idle",
|
|
535
|
+
pending: "pending",
|
|
536
|
+
completed: "completed",
|
|
537
|
+
failed: "failed",
|
|
538
|
+
stopped: "stopped",
|
|
539
|
+
timeout: "stopped",
|
|
540
|
+
budget_exhausted: "stopped"
|
|
541
|
+
};
|
|
542
|
+
function normalizeSubagentStatus(raw) {
|
|
543
|
+
return FLEET_STATUS_MAP[raw] ?? "idle";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/hq/brain-bridge.ts
|
|
547
|
+
function extractRequestFields(req) {
|
|
548
|
+
if (req === void 0) return {};
|
|
549
|
+
const out = {};
|
|
550
|
+
if (typeof req.id === "string") out.requestId = req.id;
|
|
551
|
+
if (typeof req.question === "string") out.question = req.question;
|
|
552
|
+
if (typeof req.source === "string") out.source = req.source;
|
|
553
|
+
if (typeof req.risk === "string") out.risk = req.risk;
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
function extractDecisionFields(dec) {
|
|
557
|
+
if (dec === void 0) return {};
|
|
558
|
+
const out = {};
|
|
559
|
+
out.decision = dec.type;
|
|
560
|
+
if (dec.type === "answer") {
|
|
561
|
+
out.detail = dec.text;
|
|
562
|
+
} else if (dec.type === "ask_human") {
|
|
563
|
+
out.detail = dec.prompt;
|
|
564
|
+
} else if (dec.type === "deny") {
|
|
565
|
+
out.detail = dec.reason;
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
function startBrainTelemetryBridge(opts) {
|
|
570
|
+
const { events } = opts;
|
|
571
|
+
const ctx = createBridgeContext(opts);
|
|
572
|
+
function publish(kind, payload, at) {
|
|
573
|
+
ctx.safePublish({
|
|
574
|
+
type: "brain.event",
|
|
575
|
+
payload: { kind, at, ...payload },
|
|
576
|
+
...ctx.sessionIdTag(),
|
|
577
|
+
timestamp: ctx.now()
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
ctx.track(
|
|
581
|
+
events.on("brain.decision_requested", (p) => {
|
|
582
|
+
publish("decision_requested", extractRequestFields(p.request), p.at);
|
|
583
|
+
})
|
|
584
|
+
);
|
|
585
|
+
ctx.track(
|
|
586
|
+
events.on("brain.decision_answered", (p) => {
|
|
587
|
+
publish("decision_answered", {
|
|
588
|
+
...extractRequestFields(p.request),
|
|
589
|
+
...extractDecisionFields(p.decision)
|
|
590
|
+
}, p.at);
|
|
591
|
+
})
|
|
592
|
+
);
|
|
593
|
+
ctx.track(
|
|
594
|
+
events.on("brain.decision_ask_human", (p) => {
|
|
595
|
+
publish("decision_ask_human", {
|
|
596
|
+
...extractRequestFields(p.request),
|
|
597
|
+
...extractDecisionFields(p.decision)
|
|
598
|
+
}, p.at);
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
ctx.track(
|
|
602
|
+
events.on("brain.decision_denied", (p) => {
|
|
603
|
+
publish("decision_denied", {
|
|
604
|
+
...extractRequestFields(p.request),
|
|
605
|
+
...extractDecisionFields(p.decision)
|
|
606
|
+
}, p.at);
|
|
607
|
+
})
|
|
608
|
+
);
|
|
609
|
+
ctx.track(
|
|
610
|
+
events.on("brain.human_answered", (p) => {
|
|
611
|
+
const detail = typeof p.text === "string" ? p.text : p.optionId;
|
|
612
|
+
publish("human_answered", {
|
|
613
|
+
requestId: p.id,
|
|
614
|
+
...detail !== void 0 ? { detail } : {},
|
|
615
|
+
...p.deny === true ? { decision: "deny" } : {}
|
|
616
|
+
}, p.at);
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
ctx.track(
|
|
620
|
+
events.on("brain.intervention", (p) => {
|
|
621
|
+
publish("intervention", {
|
|
622
|
+
...extractRequestFields(p.request),
|
|
623
|
+
...extractDecisionFields(p.decision),
|
|
624
|
+
interventionKind: p.kind,
|
|
625
|
+
intervened: p.intervened
|
|
626
|
+
}, p.at);
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
return ctx.dispose;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/hq/worktree-bridge.ts
|
|
633
|
+
function startWorktreeTelemetryBridge(opts) {
|
|
634
|
+
const { events } = opts;
|
|
635
|
+
const ctx = createBridgeContext(opts);
|
|
636
|
+
function publish(payload, eventSessionId) {
|
|
637
|
+
ctx.safePublish({
|
|
638
|
+
type: "worktree.event",
|
|
639
|
+
payload,
|
|
640
|
+
...ctx.sessionIdTag(eventSessionId),
|
|
641
|
+
timestamp: ctx.now()
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
ctx.track(
|
|
645
|
+
events.on("worktree.allocated", (p) => {
|
|
646
|
+
publish(
|
|
647
|
+
{
|
|
648
|
+
kind: "allocated",
|
|
649
|
+
handleId: p.handleId,
|
|
650
|
+
ownerId: p.ownerId,
|
|
651
|
+
ownerLabel: p.ownerLabel,
|
|
652
|
+
slug: p.slug,
|
|
653
|
+
branch: p.branch,
|
|
654
|
+
baseBranch: p.baseBranch
|
|
655
|
+
},
|
|
656
|
+
p.sessionId
|
|
657
|
+
);
|
|
658
|
+
})
|
|
659
|
+
);
|
|
660
|
+
ctx.track(
|
|
661
|
+
events.on("worktree.committed", (p) => {
|
|
662
|
+
publish(
|
|
663
|
+
{
|
|
664
|
+
kind: "committed",
|
|
665
|
+
handleId: p.handleId,
|
|
666
|
+
ownerId: p.ownerId,
|
|
667
|
+
branch: p.branch,
|
|
668
|
+
insertions: p.insertions,
|
|
669
|
+
deletions: p.deletions,
|
|
670
|
+
files: p.files,
|
|
671
|
+
...p.sha !== void 0 ? { sha: p.sha } : {}
|
|
672
|
+
},
|
|
673
|
+
p.sessionId
|
|
674
|
+
);
|
|
675
|
+
})
|
|
676
|
+
);
|
|
677
|
+
ctx.track(
|
|
678
|
+
events.on("worktree.merged", (p) => {
|
|
679
|
+
publish(
|
|
680
|
+
{
|
|
681
|
+
kind: "merged",
|
|
682
|
+
handleId: p.handleId,
|
|
683
|
+
ownerId: p.ownerId,
|
|
684
|
+
branch: p.branch,
|
|
685
|
+
baseBranch: p.baseBranch,
|
|
686
|
+
squash: p.squash
|
|
687
|
+
},
|
|
688
|
+
p.sessionId
|
|
689
|
+
);
|
|
690
|
+
})
|
|
691
|
+
);
|
|
692
|
+
ctx.track(
|
|
693
|
+
events.on("worktree.conflict", (p) => {
|
|
694
|
+
publish(
|
|
695
|
+
{
|
|
696
|
+
kind: "conflict",
|
|
697
|
+
handleId: p.handleId,
|
|
698
|
+
ownerId: p.ownerId,
|
|
699
|
+
branch: p.branch,
|
|
700
|
+
conflictFiles: p.conflictFiles
|
|
701
|
+
},
|
|
702
|
+
p.sessionId
|
|
703
|
+
);
|
|
704
|
+
})
|
|
705
|
+
);
|
|
706
|
+
ctx.track(
|
|
707
|
+
events.on("worktree.released", (p) => {
|
|
708
|
+
publish(
|
|
709
|
+
{
|
|
710
|
+
kind: "released",
|
|
711
|
+
handleId: p.handleId,
|
|
712
|
+
ownerId: p.ownerId,
|
|
713
|
+
branch: p.branch,
|
|
714
|
+
kept: p.kept
|
|
715
|
+
},
|
|
716
|
+
p.sessionId
|
|
717
|
+
);
|
|
718
|
+
})
|
|
719
|
+
);
|
|
720
|
+
ctx.track(
|
|
721
|
+
events.on("worktree.failed", (p) => {
|
|
722
|
+
publish(
|
|
723
|
+
{
|
|
724
|
+
kind: "failed",
|
|
725
|
+
handleId: p.handleId,
|
|
726
|
+
ownerId: p.ownerId,
|
|
727
|
+
...p.branch !== void 0 ? { branch: p.branch } : {},
|
|
728
|
+
error: p.error
|
|
729
|
+
},
|
|
730
|
+
p.sessionId
|
|
731
|
+
);
|
|
732
|
+
})
|
|
733
|
+
);
|
|
734
|
+
return ctx.dispose;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/hq/tool-bridge.ts
|
|
738
|
+
function startToolTelemetryBridge(opts) {
|
|
739
|
+
const { events, publisher } = opts;
|
|
740
|
+
const ctx = createBridgeContext(opts);
|
|
741
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
742
|
+
const offStarted = events.on("tool.started", (p) => {
|
|
743
|
+
inFlight.set(p.id, { name: p.name, startedAt: Date.now() });
|
|
744
|
+
const payload = {
|
|
745
|
+
toolName: p.name,
|
|
746
|
+
...p.input !== void 0 ? {
|
|
747
|
+
inputSummary: summarizeHqToolArgs(p.input, {
|
|
748
|
+
policy: publisher.redactionPolicy,
|
|
749
|
+
...opts.projectRoot !== void 0 ? { projectRoot: opts.projectRoot } : {}
|
|
750
|
+
})
|
|
751
|
+
} : {}
|
|
752
|
+
};
|
|
753
|
+
ctx.safePublish({
|
|
754
|
+
type: "tool.started",
|
|
755
|
+
payload,
|
|
756
|
+
...ctx.sessionIdTag(p.sessionId),
|
|
757
|
+
timestamp: ctx.now()
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
const offExecuted = events.on("tool.executed", (p) => {
|
|
761
|
+
const id = p.id;
|
|
762
|
+
if (id !== void 0) inFlight.delete(id);
|
|
763
|
+
const payload = {
|
|
764
|
+
toolName: p.name,
|
|
765
|
+
status: p.ok ? "success" : "error",
|
|
766
|
+
durationMs: p.durationMs,
|
|
767
|
+
...p.output !== void 0 && p.output.length > 0 ? { outputSummary: truncateForSummary(p.output, publisher.redactionPolicy) } : {}
|
|
768
|
+
};
|
|
769
|
+
ctx.safePublish({
|
|
770
|
+
type: "tool.completed",
|
|
771
|
+
payload,
|
|
772
|
+
...ctx.sessionIdTag(p.sessionId),
|
|
773
|
+
timestamp: ctx.now()
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
ctx.track(offStarted);
|
|
777
|
+
ctx.track(offExecuted);
|
|
778
|
+
return () => {
|
|
779
|
+
ctx.dispose();
|
|
780
|
+
inFlight.clear();
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function truncateForSummary(output, policy, max = 280) {
|
|
784
|
+
const resolved = resolveHqRedactionPolicy(policy);
|
|
785
|
+
const limit = resolved.rawContent ? Math.max(max, HQ_TRANSCRIPT_TEXT_CAP) : max;
|
|
786
|
+
return scrubAndTruncateHqPreview(output, limit) ?? "";
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/hq/cost-bridge.ts
|
|
790
|
+
function startCostTelemetryBridge(opts) {
|
|
791
|
+
const { events } = opts;
|
|
792
|
+
const ctx = createBridgeContext(opts);
|
|
793
|
+
const off = events.on("token.accounted", (p) => {
|
|
794
|
+
const usage = p.deltaUsage ?? p.usage;
|
|
795
|
+
const totalPromptTokens = effectiveInputTokens(usage);
|
|
796
|
+
const payload = {
|
|
797
|
+
inputTokens: totalPromptTokens,
|
|
798
|
+
outputTokens: usage.output,
|
|
799
|
+
totalTokens: totalPromptTokens + usage.output,
|
|
800
|
+
costUsd: p.cost.total
|
|
801
|
+
};
|
|
802
|
+
if (p.provider !== void 0) payload.provider = p.provider;
|
|
803
|
+
if (p.model !== void 0) payload.model = p.model;
|
|
804
|
+
if (usage.cacheRead !== void 0) payload.cacheRead = usage.cacheRead;
|
|
805
|
+
if (usage.cacheWrite !== void 0) payload.cacheWrite = usage.cacheWrite;
|
|
806
|
+
const sessionId = opts.sessionId ?? p.sessionId;
|
|
807
|
+
ctx.safePublish({
|
|
808
|
+
type: "session.usage",
|
|
809
|
+
payload,
|
|
810
|
+
...sessionId !== void 0 ? { sessionId } : {},
|
|
811
|
+
timestamp: ctx.now()
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
ctx.track(off);
|
|
815
|
+
return ctx.dispose;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/hq/kanban-store.ts
|
|
819
|
+
import { createHash } from "node:crypto";
|
|
820
|
+
import * as fs2 from "node:fs/promises";
|
|
821
|
+
import * as path from "node:path";
|
|
822
|
+
|
|
823
|
+
// src/hq/write-queues.ts
|
|
824
|
+
var BestEffortBatchQueue = class _BestEffortBatchQueue {
|
|
825
|
+
constructor(writeBatch) {
|
|
826
|
+
this.writeBatch = writeBatch;
|
|
827
|
+
}
|
|
828
|
+
writeBatch;
|
|
829
|
+
pending = [];
|
|
830
|
+
activeWrite = null;
|
|
831
|
+
drainScheduled = false;
|
|
832
|
+
static MAX_PENDING = 1e4;
|
|
833
|
+
enqueue(item) {
|
|
834
|
+
if (this.pending.length >= _BestEffortBatchQueue.MAX_PENDING) this.pending.shift();
|
|
835
|
+
this.pending.push(item);
|
|
836
|
+
if (this.drainScheduled) return;
|
|
837
|
+
this.drainScheduled = true;
|
|
838
|
+
queueMicrotask(() => this.flushPending());
|
|
839
|
+
}
|
|
840
|
+
async drain() {
|
|
841
|
+
for (; ; ) {
|
|
842
|
+
this.flushPending();
|
|
843
|
+
const observed = this.activeWrite;
|
|
844
|
+
if (observed) await observed;
|
|
845
|
+
if (!this.activeWrite && this.pending.length === 0) return;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
flushPending() {
|
|
849
|
+
this.drainScheduled = false;
|
|
850
|
+
if (this.activeWrite || this.pending.length === 0) return;
|
|
851
|
+
const batch = this.pending.splice(0);
|
|
852
|
+
const active = this.writeBatch(batch).catch((err) => {
|
|
853
|
+
console.error(
|
|
854
|
+
`[BestEffortBatchQueue] failed to write batch of ${batch.length} item(s):`,
|
|
855
|
+
err
|
|
856
|
+
);
|
|
857
|
+
}).finally(() => {
|
|
858
|
+
if (this.activeWrite === active) this.activeWrite = null;
|
|
859
|
+
if (this.pending.length > 0) this.flushPending();
|
|
860
|
+
});
|
|
861
|
+
this.activeWrite = active;
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
var BestEffortLatestQueue = class {
|
|
865
|
+
constructor(writeLatest) {
|
|
866
|
+
this.writeLatest = writeLatest;
|
|
867
|
+
}
|
|
868
|
+
writeLatest;
|
|
869
|
+
pending;
|
|
870
|
+
activeWrite = null;
|
|
871
|
+
drainScheduled = false;
|
|
872
|
+
enqueue(value) {
|
|
873
|
+
this.pending = value;
|
|
874
|
+
if (this.drainScheduled) return;
|
|
875
|
+
this.drainScheduled = true;
|
|
876
|
+
queueMicrotask(() => this.flushPending());
|
|
877
|
+
}
|
|
878
|
+
async drain() {
|
|
879
|
+
for (; ; ) {
|
|
880
|
+
this.flushPending();
|
|
881
|
+
const observed = this.activeWrite;
|
|
882
|
+
if (observed) await observed;
|
|
883
|
+
if (!this.activeWrite && this.pending === void 0) return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
flushPending() {
|
|
887
|
+
this.drainScheduled = false;
|
|
888
|
+
if (this.activeWrite || this.pending === void 0) return;
|
|
889
|
+
const latest = this.pending;
|
|
890
|
+
this.pending = void 0;
|
|
891
|
+
const active = this.writeLatest(latest).catch((err) => {
|
|
892
|
+
console.warn(
|
|
893
|
+
JSON.stringify({
|
|
894
|
+
level: "warn",
|
|
895
|
+
event: "hq.persistence_latest_write_failed",
|
|
896
|
+
message: err instanceof Error ? err.message : String(err),
|
|
897
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
898
|
+
})
|
|
899
|
+
);
|
|
900
|
+
}).finally(() => {
|
|
901
|
+
if (this.activeWrite === active) this.activeWrite = null;
|
|
902
|
+
if (this.pending !== void 0) this.flushPending();
|
|
903
|
+
});
|
|
904
|
+
this.activeWrite = active;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
// src/hq/kanban-store.ts
|
|
909
|
+
var MAX_HQ_KANBAN_CACHE_PROJECTS = 32;
|
|
910
|
+
var HqKanbanStore = class {
|
|
911
|
+
dirPath;
|
|
912
|
+
cache = /* @__PURE__ */ new Map();
|
|
913
|
+
writers = /* @__PURE__ */ new Map();
|
|
914
|
+
mergeChains = /* @__PURE__ */ new Map();
|
|
915
|
+
constructor(dataDir) {
|
|
916
|
+
this.dirPath = path.join(dataDir, "kanban");
|
|
917
|
+
}
|
|
918
|
+
async load(projectId) {
|
|
919
|
+
const cached = this.cache.get(projectId);
|
|
920
|
+
if (cached !== void 0) {
|
|
921
|
+
this.cache.delete(projectId);
|
|
922
|
+
this.cache.set(projectId, cached);
|
|
923
|
+
return structuredClone(cached);
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
const content = await fs2.readFile(this.filePath(projectId), "utf8");
|
|
927
|
+
const snapshot = JSON.parse(content);
|
|
928
|
+
if (!isHqKanbanSnapshotPayload(snapshot) || snapshot.projectId !== projectId) {
|
|
929
|
+
return emptyKanbanSnapshot(projectId);
|
|
930
|
+
}
|
|
931
|
+
this.setCached(projectId, snapshot);
|
|
932
|
+
return structuredClone(snapshot);
|
|
933
|
+
} catch {
|
|
934
|
+
return emptyKanbanSnapshot(projectId);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/** Merge by revision, then timestamp. HQ never lets a stale writer win. */
|
|
938
|
+
merge(incoming) {
|
|
939
|
+
const previous = this.mergeChains.get(incoming.projectId) ?? Promise.resolve(emptyKanbanSnapshot(incoming.projectId));
|
|
940
|
+
const next = previous.catch(() => emptyKanbanSnapshot(incoming.projectId)).then(() => this.mergeNow(incoming));
|
|
941
|
+
this.mergeChains.set(incoming.projectId, next);
|
|
942
|
+
void next.then(
|
|
943
|
+
() => {
|
|
944
|
+
if (this.mergeChains.get(incoming.projectId) === next) {
|
|
945
|
+
this.mergeChains.delete(incoming.projectId);
|
|
946
|
+
this.trimCache();
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
() => {
|
|
950
|
+
if (this.mergeChains.get(incoming.projectId) === next) {
|
|
951
|
+
this.mergeChains.delete(incoming.projectId);
|
|
952
|
+
this.trimCache();
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
);
|
|
956
|
+
return next;
|
|
957
|
+
}
|
|
958
|
+
async mergeNow(incoming) {
|
|
959
|
+
const current = await this.load(incoming.projectId);
|
|
960
|
+
const records = /* @__PURE__ */ new Map();
|
|
961
|
+
for (const record of [...current.boards, ...current.tombstones])
|
|
962
|
+
records.set(record.boardId, record);
|
|
963
|
+
for (const record of [...incoming.boards, ...incoming.tombstones]) {
|
|
964
|
+
const existing = records.get(record.boardId);
|
|
965
|
+
if (existing === void 0 || compareKanbanRecord(record, existing) > 0) {
|
|
966
|
+
records.set(record.boardId, record);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const merged = {
|
|
970
|
+
projectId: incoming.projectId,
|
|
971
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
972
|
+
boards: [],
|
|
973
|
+
tombstones: []
|
|
974
|
+
};
|
|
975
|
+
for (const record of records.values()) {
|
|
976
|
+
if ("board" in record) merged.boards.push(record);
|
|
977
|
+
else merged.tombstones.push(record);
|
|
978
|
+
}
|
|
979
|
+
merged.boards.sort((a, b) => a.boardId.localeCompare(b.boardId));
|
|
980
|
+
merged.tombstones.sort((a, b) => a.boardId.localeCompare(b.boardId));
|
|
981
|
+
this.setCached(incoming.projectId, merged);
|
|
982
|
+
const writer = this.writer(incoming.projectId);
|
|
983
|
+
writer.enqueue(merged);
|
|
984
|
+
void writer.drain().then(() => {
|
|
985
|
+
if (this.writers.get(incoming.projectId) === writer) {
|
|
986
|
+
this.writers.delete(incoming.projectId);
|
|
987
|
+
this.trimCache();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
return structuredClone(merged);
|
|
991
|
+
}
|
|
992
|
+
async drain() {
|
|
993
|
+
await Promise.allSettled(this.mergeChains.values());
|
|
994
|
+
await Promise.all([...this.writers.values()].map((writer) => writer.drain()));
|
|
995
|
+
}
|
|
996
|
+
writer(projectId) {
|
|
997
|
+
let writer = this.writers.get(projectId);
|
|
998
|
+
if (writer === void 0) {
|
|
999
|
+
writer = new BestEffortLatestQueue(async (snapshot) => {
|
|
1000
|
+
await fs2.mkdir(this.dirPath, { recursive: true });
|
|
1001
|
+
await atomicWrite(this.filePath(projectId), JSON.stringify(snapshot), { mode: 384 });
|
|
1002
|
+
});
|
|
1003
|
+
this.writers.set(projectId, writer);
|
|
1004
|
+
}
|
|
1005
|
+
return writer;
|
|
1006
|
+
}
|
|
1007
|
+
setCached(projectId, snapshot) {
|
|
1008
|
+
this.cache.delete(projectId);
|
|
1009
|
+
this.cache.set(projectId, snapshot);
|
|
1010
|
+
this.trimCache(projectId);
|
|
1011
|
+
}
|
|
1012
|
+
trimCache(protectedProjectId) {
|
|
1013
|
+
if (this.cache.size <= MAX_HQ_KANBAN_CACHE_PROJECTS) return;
|
|
1014
|
+
for (const projectId of this.cache.keys()) {
|
|
1015
|
+
if (this.cache.size <= MAX_HQ_KANBAN_CACHE_PROJECTS) break;
|
|
1016
|
+
if (projectId === protectedProjectId || this.writers.has(projectId) || this.mergeChains.has(projectId)) {
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
this.cache.delete(projectId);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
filePath(projectId) {
|
|
1023
|
+
const safe = createHash("sha256").update(projectId).digest("hex");
|
|
1024
|
+
return path.join(this.dirPath, `${safe}.json`);
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
function emptyKanbanSnapshot(projectId) {
|
|
1028
|
+
return { projectId, generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), boards: [], tombstones: [] };
|
|
1029
|
+
}
|
|
1030
|
+
function compareKanbanRecord(a, b) {
|
|
1031
|
+
if (a.revision !== b.revision) return a.revision - b.revision;
|
|
1032
|
+
const aTime = "board" in a ? a.updatedAt : a.deletedAt;
|
|
1033
|
+
const bTime = "board" in b ? b.updatedAt : b.deletedAt;
|
|
1034
|
+
if (aTime !== bTime) return aTime.localeCompare(bTime);
|
|
1035
|
+
if ("board" in a === "board" in b) return 0;
|
|
1036
|
+
return "board" in a ? -1 : 1;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/hq/persistence/event-log.ts
|
|
1040
|
+
import * as fs4 from "node:fs/promises";
|
|
1041
|
+
import * as path2 from "node:path";
|
|
1042
|
+
|
|
1043
|
+
// src/hq/persistence/jsonl-io.ts
|
|
1044
|
+
import * as fs3 from "node:fs/promises";
|
|
1045
|
+
import { StringDecoder } from "node:string_decoder";
|
|
1046
|
+
var RECENT_READ_CHUNK_BYTES = 64 * 1024;
|
|
1047
|
+
var LINE_COUNT_CHUNK_BYTES = 256 * 1024;
|
|
1048
|
+
async function readTailNonEmptyLines(filePath, limit, estimatedLineCount) {
|
|
1049
|
+
if (!(limit > 0)) return [];
|
|
1050
|
+
const handle = await fs3.open(filePath, "r");
|
|
1051
|
+
try {
|
|
1052
|
+
const size = (await handle.stat()).size;
|
|
1053
|
+
let position = size;
|
|
1054
|
+
const chunks = [];
|
|
1055
|
+
let totalNewlines = 0;
|
|
1056
|
+
let nextReadBytes = Math.min(
|
|
1057
|
+
size,
|
|
1058
|
+
Math.max(
|
|
1059
|
+
RECENT_READ_CHUNK_BYTES,
|
|
1060
|
+
estimatedLineCount > 0 ? Math.ceil(size * limit * 1.1 / estimatedLineCount) + RECENT_READ_CHUNK_BYTES : Math.ceil(limit * 512) + RECENT_READ_CHUNK_BYTES
|
|
1061
|
+
)
|
|
1062
|
+
);
|
|
1063
|
+
while (position > 0) {
|
|
1064
|
+
const length = Math.min(nextReadBytes, position);
|
|
1065
|
+
position -= length;
|
|
1066
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
1067
|
+
let bytesRead = 0;
|
|
1068
|
+
while (bytesRead < length) {
|
|
1069
|
+
const result = await handle.read(
|
|
1070
|
+
buffer,
|
|
1071
|
+
bytesRead,
|
|
1072
|
+
length - bytesRead,
|
|
1073
|
+
position + bytesRead
|
|
1074
|
+
);
|
|
1075
|
+
if (result.bytesRead === 0) break;
|
|
1076
|
+
bytesRead += result.bytesRead;
|
|
1077
|
+
}
|
|
1078
|
+
if (bytesRead === 0) break;
|
|
1079
|
+
const chunk = bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
1080
|
+
chunks.unshift(chunk);
|
|
1081
|
+
for (let index = 0; index < chunk.length; index++) {
|
|
1082
|
+
if (chunk[index] === 10) totalNewlines++;
|
|
1083
|
+
}
|
|
1084
|
+
if (position === 0 || totalNewlines >= limit) {
|
|
1085
|
+
const lines = Buffer.concat(chunks).toString("utf8").split("\n").filter((line) => line.length > 0);
|
|
1086
|
+
return lines.slice(-limit).reverse();
|
|
1087
|
+
}
|
|
1088
|
+
nextReadBytes = Math.min(position, nextReadBytes * 2);
|
|
1089
|
+
}
|
|
1090
|
+
return [];
|
|
1091
|
+
} finally {
|
|
1092
|
+
await handle.close().catch(() => {
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
async function findTailStartOffset(filePath, keepLines, maxBytes) {
|
|
1097
|
+
const handle = await fs3.open(filePath, "r");
|
|
1098
|
+
try {
|
|
1099
|
+
const size = (await handle.stat()).size;
|
|
1100
|
+
if (size === 0) return { offset: 0, size };
|
|
1101
|
+
const tailByte = Buffer.allocUnsafe(1);
|
|
1102
|
+
await handle.read(tailByte, 0, 1, size - 1);
|
|
1103
|
+
const target = keepLines + (tailByte[0] === 10 ? 1 : 0);
|
|
1104
|
+
if (target <= 0) return { offset: size, size };
|
|
1105
|
+
const buffer = Buffer.allocUnsafe(LINE_COUNT_CHUNK_BYTES);
|
|
1106
|
+
let position = size;
|
|
1107
|
+
let seen = 0;
|
|
1108
|
+
let boundary = size;
|
|
1109
|
+
while (position > 0) {
|
|
1110
|
+
const length = Math.min(LINE_COUNT_CHUNK_BYTES, position);
|
|
1111
|
+
position -= length;
|
|
1112
|
+
let read = 0;
|
|
1113
|
+
while (read < length) {
|
|
1114
|
+
const result = await handle.read(buffer, read, length - read, position + read);
|
|
1115
|
+
if (result.bytesRead === 0) break;
|
|
1116
|
+
read += result.bytesRead;
|
|
1117
|
+
}
|
|
1118
|
+
if (read === 0) break;
|
|
1119
|
+
for (let index = read - 1; index >= 0; index--) {
|
|
1120
|
+
if (buffer[index] !== 10) continue;
|
|
1121
|
+
const candidate = position + index + 1;
|
|
1122
|
+
if (size - candidate > maxBytes) {
|
|
1123
|
+
return { offset: boundary < size ? boundary : candidate, size };
|
|
1124
|
+
}
|
|
1125
|
+
boundary = candidate;
|
|
1126
|
+
seen++;
|
|
1127
|
+
if (seen >= target) return { offset: boundary, size };
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return { offset: 0, size };
|
|
1131
|
+
} finally {
|
|
1132
|
+
await handle.close().catch(() => {
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async function copyTailToHandle(sourcePath, offset, destination) {
|
|
1137
|
+
const handle = await fs3.open(sourcePath, "r");
|
|
1138
|
+
try {
|
|
1139
|
+
const buffer = Buffer.allocUnsafe(LINE_COUNT_CHUNK_BYTES);
|
|
1140
|
+
let position = offset;
|
|
1141
|
+
let bytes = 0;
|
|
1142
|
+
let lines = 0;
|
|
1143
|
+
let lineHasBytes = false;
|
|
1144
|
+
for (; ; ) {
|
|
1145
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
1146
|
+
if (bytesRead === 0) break;
|
|
1147
|
+
position += bytesRead;
|
|
1148
|
+
const chunk = buffer.subarray(0, bytesRead);
|
|
1149
|
+
for (let index = 0; index < bytesRead; index++) {
|
|
1150
|
+
if (chunk[index] === 10) {
|
|
1151
|
+
if (lineHasBytes) lines++;
|
|
1152
|
+
lineHasBytes = false;
|
|
1153
|
+
} else {
|
|
1154
|
+
lineHasBytes = true;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
await destination.write(chunk);
|
|
1158
|
+
bytes += bytesRead;
|
|
1159
|
+
}
|
|
1160
|
+
return { bytes, lines: lines + (lineHasBytes ? 1 : 0) };
|
|
1161
|
+
} finally {
|
|
1162
|
+
await handle.close().catch(() => {
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
async function* readJsonlLines(filePath) {
|
|
1167
|
+
const handle = await fs3.open(filePath, "r");
|
|
1168
|
+
try {
|
|
1169
|
+
const buffer = Buffer.allocUnsafe(LINE_COUNT_CHUNK_BYTES);
|
|
1170
|
+
const decoder = new StringDecoder("utf8");
|
|
1171
|
+
let position = 0;
|
|
1172
|
+
let carry = "";
|
|
1173
|
+
for (; ; ) {
|
|
1174
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
1175
|
+
if (bytesRead === 0) break;
|
|
1176
|
+
position += bytesRead;
|
|
1177
|
+
const text = carry + decoder.write(buffer.subarray(0, bytesRead));
|
|
1178
|
+
let start = 0;
|
|
1179
|
+
for (; ; ) {
|
|
1180
|
+
const newline = text.indexOf("\n", start);
|
|
1181
|
+
if (newline === -1) break;
|
|
1182
|
+
const line = text.slice(start, newline).trim();
|
|
1183
|
+
start = newline + 1;
|
|
1184
|
+
if (line) yield line;
|
|
1185
|
+
}
|
|
1186
|
+
carry = text.slice(start);
|
|
1187
|
+
}
|
|
1188
|
+
const last = (carry + decoder.end()).trim();
|
|
1189
|
+
if (last) yield last;
|
|
1190
|
+
} finally {
|
|
1191
|
+
await handle.close();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
async function countNonEmptyLines(filePath) {
|
|
1195
|
+
let handle;
|
|
1196
|
+
try {
|
|
1197
|
+
handle = await fs3.open(filePath, "r");
|
|
1198
|
+
} catch {
|
|
1199
|
+
return 0;
|
|
1200
|
+
}
|
|
1201
|
+
try {
|
|
1202
|
+
const buffer = Buffer.allocUnsafe(LINE_COUNT_CHUNK_BYTES);
|
|
1203
|
+
let count = 0;
|
|
1204
|
+
let position = 0;
|
|
1205
|
+
let lineHasBytes = false;
|
|
1206
|
+
for (; ; ) {
|
|
1207
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
1208
|
+
if (bytesRead === 0) break;
|
|
1209
|
+
position += bytesRead;
|
|
1210
|
+
let start = 0;
|
|
1211
|
+
for (; ; ) {
|
|
1212
|
+
const newline = buffer.indexOf(10, start);
|
|
1213
|
+
if (newline < 0 || newline >= bytesRead) {
|
|
1214
|
+
if (start < bytesRead) lineHasBytes = true;
|
|
1215
|
+
break;
|
|
1216
|
+
}
|
|
1217
|
+
if (lineHasBytes || newline > start) count++;
|
|
1218
|
+
lineHasBytes = false;
|
|
1219
|
+
start = newline + 1;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return count + (lineHasBytes ? 1 : 0);
|
|
1223
|
+
} finally {
|
|
1224
|
+
await handle.close().catch(() => {
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/hq/persistence/event-log.ts
|
|
1230
|
+
var DEFAULT_EVENT_LOG_MAX_LINES = 5e4;
|
|
1231
|
+
var DEFAULT_EVENT_LOG_ROTATE_KEEP = 2e4;
|
|
1232
|
+
var DEFAULT_EVENT_LOG_MAX_BYTES = 32 * 1024 * 1024;
|
|
1233
|
+
var DEFAULT_EVENT_LOG_ROTATE_KEEP_BYTES = 16 * 1024 * 1024;
|
|
1234
|
+
var RECENT_TAIL_SCAN_BYTES = 1024 * 1024;
|
|
1235
|
+
var FILTERED_RECENT_TAIL_SCAN_BYTES = 256 * 1024;
|
|
1236
|
+
var BULK_PREFIX_READ_CAP = 8 * 1024 * 1024;
|
|
1237
|
+
var HQ_EVENT_LOG_PRESETS = Object.freeze({
|
|
1238
|
+
vps8: { maxBytes: 8 * 1024 * 1024, rotateKeepBytes: 2 * 1024 * 1024 },
|
|
1239
|
+
vps32: { maxBytes: 32 * 1024 * 1024, rotateKeepBytes: 16 * 1024 * 1024 },
|
|
1240
|
+
desktop: { maxBytes: 64 * 1024 * 1024, rotateKeepBytes: 24 * 1024 * 1024 }
|
|
1241
|
+
});
|
|
1242
|
+
function hqEventLogPresetFields(preset) {
|
|
1243
|
+
const fields = HQ_EVENT_LOG_PRESETS[preset];
|
|
1244
|
+
if (fields === void 0) {
|
|
1245
|
+
throw new Error(
|
|
1246
|
+
`Unknown HqEventLogPreset: ${String(preset)}. Allowed: ${Object.keys(HQ_EVENT_LOG_PRESETS).join(", ")}.`
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
return fields;
|
|
1250
|
+
}
|
|
1251
|
+
var HqEventLog = class {
|
|
1252
|
+
filePath;
|
|
1253
|
+
maxLines;
|
|
1254
|
+
rotateKeep;
|
|
1255
|
+
writer;
|
|
1256
|
+
maxBytes;
|
|
1257
|
+
rotateKeepBytes;
|
|
1258
|
+
lineCount = 0;
|
|
1259
|
+
byteCount = 0;
|
|
1260
|
+
counted = false;
|
|
1261
|
+
hydration;
|
|
1262
|
+
constructor(opts) {
|
|
1263
|
+
this.filePath = path2.join(opts.dataDir, "events.jsonl");
|
|
1264
|
+
this.maxLines = opts.maxLines ?? DEFAULT_EVENT_LOG_MAX_LINES;
|
|
1265
|
+
this.rotateKeep = opts.rotateKeep ?? DEFAULT_EVENT_LOG_ROTATE_KEEP;
|
|
1266
|
+
this.maxBytes = opts.maxBytes ?? DEFAULT_EVENT_LOG_MAX_BYTES;
|
|
1267
|
+
this.rotateKeepBytes = opts.rotateKeepBytes ?? DEFAULT_EVENT_LOG_ROTATE_KEEP_BYTES;
|
|
1268
|
+
this.writer = new BestEffortBatchQueue((events) => this.appendInternal(events));
|
|
1269
|
+
}
|
|
1270
|
+
/** Append an event envelope as one JSON line. Best-effort, never rejects. */
|
|
1271
|
+
append(event) {
|
|
1272
|
+
this.writer.enqueue(event);
|
|
1273
|
+
}
|
|
1274
|
+
/** Resolves once all queued appends have settled. For tests. */
|
|
1275
|
+
async drain() {
|
|
1276
|
+
await this.writer.drain();
|
|
1277
|
+
}
|
|
1278
|
+
async appendInternal(events) {
|
|
1279
|
+
await this.ensureLineCount();
|
|
1280
|
+
const lines = events.map((event) => JSON.stringify(event)).join("\n") + "\n";
|
|
1281
|
+
await fs4.appendFile(this.filePath, lines, { encoding: "utf8", mode: SECRET_FILE_MODE });
|
|
1282
|
+
this.lineCount += events.length;
|
|
1283
|
+
this.byteCount += Buffer.byteLength(lines, "utf8");
|
|
1284
|
+
if (this.lineCount >= this.maxLines || this.byteCount >= this.maxBytes) {
|
|
1285
|
+
await this.rotate();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Compact the log down to its most recent lines.
|
|
1290
|
+
*
|
|
1291
|
+
* Streams the retained tail straight from the old file into the replacement
|
|
1292
|
+
* rather than materializing it. The previous implementation read the tail
|
|
1293
|
+
* into a `string[]`, `join`ed it, and handed the result to `atomicWrite` —
|
|
1294
|
+
* at the observed 14.5 KB average line that is ~277 MB of UTF-8 inflated to
|
|
1295
|
+
* ~555 MB of JS strings, plus another ~555 MB for the join, all allocated
|
|
1296
|
+
* inside the file lock. Rotation is now O(one chunk) no matter how much is
|
|
1297
|
+
* retained, so the operation that exists to *reclaim* space stops being the
|
|
1298
|
+
* largest allocation the process ever makes.
|
|
1299
|
+
*/
|
|
1300
|
+
async rotate() {
|
|
1301
|
+
await withFileLock(this.filePath, async () => {
|
|
1302
|
+
try {
|
|
1303
|
+
const retainCount = Math.max(0, Math.floor(this.rotateKeep));
|
|
1304
|
+
const { offset, size } = await findTailStartOffset(
|
|
1305
|
+
this.filePath,
|
|
1306
|
+
retainCount,
|
|
1307
|
+
this.rotateKeepBytes
|
|
1308
|
+
);
|
|
1309
|
+
if (offset === 0) {
|
|
1310
|
+
this.byteCount = size;
|
|
1311
|
+
this.lineCount = await this.countLines();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const copied = await atomicReplaceWithWriter(
|
|
1315
|
+
this.filePath,
|
|
1316
|
+
(handle) => copyTailToHandle(this.filePath, offset, handle)
|
|
1317
|
+
);
|
|
1318
|
+
this.lineCount = copied.lines;
|
|
1319
|
+
this.byteCount = copied.bytes;
|
|
1320
|
+
} catch {
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
async countLines() {
|
|
1325
|
+
let handle;
|
|
1326
|
+
try {
|
|
1327
|
+
handle = await fs4.open(this.filePath, "r");
|
|
1328
|
+
} catch {
|
|
1329
|
+
return 0;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const buffer = Buffer.allocUnsafe(LINE_COUNT_CHUNK_BYTES);
|
|
1333
|
+
let count = 0;
|
|
1334
|
+
let position = 0;
|
|
1335
|
+
let lineHasBytes = false;
|
|
1336
|
+
for (; ; ) {
|
|
1337
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
1338
|
+
if (bytesRead === 0) break;
|
|
1339
|
+
position += bytesRead;
|
|
1340
|
+
let start = 0;
|
|
1341
|
+
for (; ; ) {
|
|
1342
|
+
const newline = buffer.indexOf(10, start);
|
|
1343
|
+
if (newline < 0 || newline >= bytesRead) {
|
|
1344
|
+
for (let index = start; index < bytesRead; index++) {
|
|
1345
|
+
if (buffer[index] !== 10) {
|
|
1346
|
+
lineHasBytes = true;
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1352
|
+
if (lineHasBytes || newline > start) count++;
|
|
1353
|
+
lineHasBytes = false;
|
|
1354
|
+
start = newline + 1;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return count + (lineHasBytes ? 1 : 0);
|
|
1358
|
+
} catch {
|
|
1359
|
+
return 0;
|
|
1360
|
+
} finally {
|
|
1361
|
+
await handle.close().catch(() => {
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
async ensureLineCount() {
|
|
1366
|
+
if (this.counted) return;
|
|
1367
|
+
this.hydration ??= Promise.all([
|
|
1368
|
+
this.countLines(),
|
|
1369
|
+
fs4.stat(this.filePath).then(
|
|
1370
|
+
(stat3) => stat3.size,
|
|
1371
|
+
() => 0
|
|
1372
|
+
)
|
|
1373
|
+
]).then(([count, size]) => {
|
|
1374
|
+
this.lineCount = count;
|
|
1375
|
+
this.byteCount = size;
|
|
1376
|
+
this.counted = true;
|
|
1377
|
+
});
|
|
1378
|
+
try {
|
|
1379
|
+
await this.hydration;
|
|
1380
|
+
} catch {
|
|
1381
|
+
this.hydration = void 0;
|
|
1382
|
+
throw new Error("ensureLineCount hydration rejected; cache cleared");
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Read the most recent `limit` events, optionally filtered by envelope
|
|
1387
|
+
* `type`. Newest first. Returns `[]` if the file doesn't exist yet.
|
|
1388
|
+
*
|
|
1389
|
+
* Reads are best-effort against a concurrent appender: the file size is
|
|
1390
|
+
* sampled once at entry and may return one duplicate or miss one tail
|
|
1391
|
+
* line if another process appends during the scan. When a `typeFilter`
|
|
1392
|
+
* is set and matches are sparse, the scan walks the whole file in
|
|
1393
|
+
* bounded windows but is still guaranteed to reach every line.
|
|
1394
|
+
*/
|
|
1395
|
+
async recent(limit, typeFilter) {
|
|
1396
|
+
return readRecentEvents(this.filePath, limit, typeFilter);
|
|
1397
|
+
}
|
|
1398
|
+
/** Initialize the line count cache from disk (call once at boot). */
|
|
1399
|
+
async hydrate() {
|
|
1400
|
+
await this.ensureLineCount();
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
async function readRecentEvents(filePath, limit, typeFilter) {
|
|
1404
|
+
if (!(limit > 0)) return [];
|
|
1405
|
+
let handle;
|
|
1406
|
+
try {
|
|
1407
|
+
handle = await fs4.open(filePath, "r");
|
|
1408
|
+
} catch {
|
|
1409
|
+
return [];
|
|
1410
|
+
}
|
|
1411
|
+
const out = [];
|
|
1412
|
+
const acceptText = (raw) => {
|
|
1413
|
+
const line = raw.trim();
|
|
1414
|
+
if (!line) return false;
|
|
1415
|
+
try {
|
|
1416
|
+
const event = JSON.parse(line);
|
|
1417
|
+
if (typeFilter === void 0 || event.type === typeFilter) out.push(event);
|
|
1418
|
+
} catch {
|
|
1419
|
+
}
|
|
1420
|
+
return out.length >= limit;
|
|
1421
|
+
};
|
|
1422
|
+
const acceptLine = (parts) => acceptText(Buffer.concat(parts).toString("utf8"));
|
|
1423
|
+
try {
|
|
1424
|
+
let position = (await handle.stat()).size;
|
|
1425
|
+
let carry = [];
|
|
1426
|
+
let tailBytesRead = 0;
|
|
1427
|
+
const tailScanLimit = typeFilter === void 0 ? RECENT_TAIL_SCAN_BYTES : FILTERED_RECENT_TAIL_SCAN_BYTES;
|
|
1428
|
+
while (position > 0) {
|
|
1429
|
+
const length = Math.min(RECENT_READ_CHUNK_BYTES, position);
|
|
1430
|
+
position -= length;
|
|
1431
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
1432
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
1433
|
+
if (bytesRead === 0) break;
|
|
1434
|
+
tailBytesRead += bytesRead;
|
|
1435
|
+
const chunk = bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
1436
|
+
let lineEnd = chunk.length;
|
|
1437
|
+
for (let index = chunk.length - 1; index >= 0; index--) {
|
|
1438
|
+
if (chunk[index] !== 10) continue;
|
|
1439
|
+
if (acceptLine([chunk.subarray(index + 1, lineEnd), ...carry])) return out;
|
|
1440
|
+
carry = [];
|
|
1441
|
+
lineEnd = index;
|
|
1442
|
+
}
|
|
1443
|
+
if (lineEnd > 0) carry.unshift(chunk.subarray(0, lineEnd));
|
|
1444
|
+
if (position > 0 && tailBytesRead >= tailScanLimit) {
|
|
1445
|
+
let sliceEnd = position;
|
|
1446
|
+
let priorSeam = "";
|
|
1447
|
+
while (sliceEnd > 0) {
|
|
1448
|
+
const readLen = Math.min(sliceEnd, BULK_PREFIX_READ_CAP);
|
|
1449
|
+
const readOffset = sliceEnd - readLen;
|
|
1450
|
+
const prefix = Buffer.allocUnsafe(readLen);
|
|
1451
|
+
let offset = 0;
|
|
1452
|
+
while (offset < readLen) {
|
|
1453
|
+
const result = await handle.read(prefix, offset, readLen - offset, readOffset + offset);
|
|
1454
|
+
if (result.bytesRead === 0) break;
|
|
1455
|
+
offset += result.bytesRead;
|
|
1456
|
+
}
|
|
1457
|
+
const prefixRead = offset === prefix.length ? prefix : prefix.subarray(0, offset);
|
|
1458
|
+
const text = Buffer.concat([prefixRead, ...carry]).toString("utf8");
|
|
1459
|
+
carry = [];
|
|
1460
|
+
const lines = text.split("\n");
|
|
1461
|
+
for (let index = lines.length - 1; index >= 1; index--) {
|
|
1462
|
+
const line = lines[index];
|
|
1463
|
+
if (line === void 0) continue;
|
|
1464
|
+
if (acceptText(line)) return out;
|
|
1465
|
+
}
|
|
1466
|
+
const boundary = lines[0] ?? "";
|
|
1467
|
+
if (boundary !== priorSeam && acceptText(boundary)) return out;
|
|
1468
|
+
if (readOffset === 0) break;
|
|
1469
|
+
carry = [Buffer.from(boundary, "utf8")];
|
|
1470
|
+
priorSeam = boundary;
|
|
1471
|
+
sliceEnd = readOffset;
|
|
1472
|
+
}
|
|
1473
|
+
return out;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (carry.length > 0) acceptLine(carry);
|
|
1477
|
+
return out;
|
|
1478
|
+
} catch {
|
|
1479
|
+
return [];
|
|
1480
|
+
} finally {
|
|
1481
|
+
await handle.close().catch(() => {
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// src/hq/persistence/simple-log.ts
|
|
1487
|
+
import * as fs5 from "node:fs/promises";
|
|
1488
|
+
import * as path3 from "node:path";
|
|
1489
|
+
var HqSimpleLog = class {
|
|
1490
|
+
filePath;
|
|
1491
|
+
maxLines;
|
|
1492
|
+
rotateKeep;
|
|
1493
|
+
readLimit;
|
|
1494
|
+
writer;
|
|
1495
|
+
lineCount = 0;
|
|
1496
|
+
counted = false;
|
|
1497
|
+
hydration;
|
|
1498
|
+
constructor(opts) {
|
|
1499
|
+
this.filePath = path3.join(opts.dataDir, opts.filename);
|
|
1500
|
+
this.maxLines = opts.maxLines ?? Infinity;
|
|
1501
|
+
this.rotateKeep = opts.rotateKeep ?? this.maxLines;
|
|
1502
|
+
this.readLimit = opts.readLimit ?? Infinity;
|
|
1503
|
+
this.writer = new BestEffortBatchQueue((records) => this.appendInternal(records));
|
|
1504
|
+
}
|
|
1505
|
+
/** Append one record as a JSON line. Best-effort, never rejects. */
|
|
1506
|
+
append(record) {
|
|
1507
|
+
this.writer.enqueue(record);
|
|
1508
|
+
}
|
|
1509
|
+
/** Resolves once all queued appends have settled. For tests. */
|
|
1510
|
+
async drain() {
|
|
1511
|
+
await this.writer.drain();
|
|
1512
|
+
}
|
|
1513
|
+
async appendInternal(records) {
|
|
1514
|
+
if (Number.isFinite(this.maxLines)) await this.ensureLineCount();
|
|
1515
|
+
await fs5.appendFile(
|
|
1516
|
+
this.filePath,
|
|
1517
|
+
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
|
1518
|
+
{ encoding: "utf8" }
|
|
1519
|
+
);
|
|
1520
|
+
this.lineCount += records.length;
|
|
1521
|
+
if (this.lineCount < this.maxLines) return;
|
|
1522
|
+
await withFileLock(this.filePath, async () => {
|
|
1523
|
+
const retainCount = Math.max(0, Math.floor(this.rotateKeep));
|
|
1524
|
+
const newest = await readTailNonEmptyLines(this.filePath, retainCount + 1, this.lineCount);
|
|
1525
|
+
if (newest.length <= retainCount) {
|
|
1526
|
+
this.lineCount = newest.length;
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const kept = newest.slice(0, retainCount).reverse();
|
|
1530
|
+
await atomicWrite(this.filePath, kept.join("\n") + "\n", { mode: 384 });
|
|
1531
|
+
this.lineCount = kept.length;
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
async ensureLineCount() {
|
|
1535
|
+
if (this.counted) return;
|
|
1536
|
+
this.hydration ??= countNonEmptyLines(this.filePath).catch(() => 0).then((count) => {
|
|
1537
|
+
this.lineCount = count;
|
|
1538
|
+
this.counted = true;
|
|
1539
|
+
});
|
|
1540
|
+
await this.hydration;
|
|
1541
|
+
}
|
|
1542
|
+
/** Read all records oldest-first. Returns `[]` if the file doesn't exist. */
|
|
1543
|
+
async readAll() {
|
|
1544
|
+
let lines;
|
|
1545
|
+
try {
|
|
1546
|
+
lines = (await readTailNonEmptyLines(
|
|
1547
|
+
this.filePath,
|
|
1548
|
+
this.readLimit,
|
|
1549
|
+
this.counted ? this.lineCount : 0
|
|
1550
|
+
)).reverse();
|
|
1551
|
+
} catch {
|
|
1552
|
+
return [];
|
|
1553
|
+
}
|
|
1554
|
+
const out = [];
|
|
1555
|
+
for (const line of lines) {
|
|
1556
|
+
const trimmed = line.trim();
|
|
1557
|
+
if (!trimmed) continue;
|
|
1558
|
+
try {
|
|
1559
|
+
out.push(JSON.parse(trimmed));
|
|
1560
|
+
} catch {
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
return out;
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
// src/hq/persistence/snapshot-store.ts
|
|
1568
|
+
import * as fs6 from "node:fs/promises";
|
|
1569
|
+
import * as path4 from "node:path";
|
|
1570
|
+
var HqSnapshotStore = class {
|
|
1571
|
+
filePath;
|
|
1572
|
+
writer;
|
|
1573
|
+
constructor(opts) {
|
|
1574
|
+
this.filePath = path4.join(opts.dataDir, "snapshot.json");
|
|
1575
|
+
this.writer = new BestEffortLatestQueue(
|
|
1576
|
+
(snapshot) => atomicWrite(this.filePath, JSON.stringify(snapshot), { mode: 384 })
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
/** Persist a snapshot. Best-effort, never rejects. */
|
|
1580
|
+
save(snapshot) {
|
|
1581
|
+
this.writer.enqueue(snapshot);
|
|
1582
|
+
}
|
|
1583
|
+
/** Resolves once all queued saves have settled. For tests. */
|
|
1584
|
+
async drain() {
|
|
1585
|
+
await this.writer.drain();
|
|
1586
|
+
}
|
|
1587
|
+
/** Read the last persisted snapshot, or `null` if none. */
|
|
1588
|
+
async load() {
|
|
1589
|
+
try {
|
|
1590
|
+
const content = await fs6.readFile(this.filePath, "utf8");
|
|
1591
|
+
return JSON.parse(content);
|
|
1592
|
+
} catch {
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
// src/hq/persistence/timeseries-store.ts
|
|
1599
|
+
import * as fs7 from "node:fs/promises";
|
|
1600
|
+
import * as path5 from "node:path";
|
|
1601
|
+
var HqTimeseriesStore = class {
|
|
1602
|
+
filePath;
|
|
1603
|
+
bucketMs;
|
|
1604
|
+
maxBuckets;
|
|
1605
|
+
buckets = /* @__PURE__ */ new Map();
|
|
1606
|
+
dirtyVersions = /* @__PURE__ */ new Map();
|
|
1607
|
+
writer;
|
|
1608
|
+
nextDirtyVersion = 0;
|
|
1609
|
+
diskLineCount;
|
|
1610
|
+
loaded = false;
|
|
1611
|
+
loadPromise;
|
|
1612
|
+
constructor(opts) {
|
|
1613
|
+
this.filePath = path5.join(opts.dataDir, "timeseries.jsonl");
|
|
1614
|
+
this.bucketMs = opts.bucketMs ?? 5 * 60 * 1e3;
|
|
1615
|
+
this.maxBuckets = opts.maxBuckets ?? 2016;
|
|
1616
|
+
this.writer = new BestEffortLatestQueue((snapshot) => this.flushInternal(snapshot));
|
|
1617
|
+
}
|
|
1618
|
+
bucketStart(ts) {
|
|
1619
|
+
return Math.floor(ts / this.bucketMs) * this.bucketMs;
|
|
1620
|
+
}
|
|
1621
|
+
/** Fold a cost/tool signal into the current bucket. Best-effort. */
|
|
1622
|
+
record(signal) {
|
|
1623
|
+
const start = this.bucketStart(signal.ts ?? Date.now());
|
|
1624
|
+
let bucket = this.buckets.get(start);
|
|
1625
|
+
if (!bucket) {
|
|
1626
|
+
bucket = { ts: start, costUsd: 0, inputTokens: 0, outputTokens: 0, toolCalls: 0 };
|
|
1627
|
+
this.buckets.set(start, bucket);
|
|
1628
|
+
}
|
|
1629
|
+
if (signal.costUsd !== void 0) bucket.costUsd += signal.costUsd;
|
|
1630
|
+
if (signal.inputTokens !== void 0) bucket.inputTokens += signal.inputTokens;
|
|
1631
|
+
if (signal.outputTokens !== void 0) bucket.outputTokens += signal.outputTokens;
|
|
1632
|
+
if (signal.toolCalls !== void 0) bucket.toolCalls += signal.toolCalls;
|
|
1633
|
+
if (signal.activeAgents !== void 0) bucket.activeAgents = signal.activeAgents;
|
|
1634
|
+
if (signal.model !== void 0 || signal.provider !== void 0) {
|
|
1635
|
+
const entry = {
|
|
1636
|
+
costUsd: signal.costUsd ?? 0,
|
|
1637
|
+
inputTokens: signal.inputTokens ?? 0,
|
|
1638
|
+
outputTokens: signal.outputTokens ?? 0,
|
|
1639
|
+
...signal.cacheRead !== void 0 ? { cacheRead: signal.cacheRead } : {},
|
|
1640
|
+
...signal.cacheWrite !== void 0 ? { cacheWrite: signal.cacheWrite } : {}
|
|
1641
|
+
};
|
|
1642
|
+
if (signal.model !== void 0) {
|
|
1643
|
+
bucket.byModel = foldBreakdown(bucket.byModel, signal.model, entry);
|
|
1644
|
+
}
|
|
1645
|
+
if (signal.provider !== void 0) {
|
|
1646
|
+
bucket.byProvider = foldBreakdown(bucket.byProvider, signal.provider, entry);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
this.dirtyVersions.set(start, ++this.nextDirtyVersion);
|
|
1650
|
+
this.pruneBuckets({ dropDirty: true });
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Drop the oldest buckets beyond `maxBuckets`.
|
|
1654
|
+
*
|
|
1655
|
+
* `dropDirty` also forgets the pending flush version for an evicted bucket —
|
|
1656
|
+
* correct when the eviction happens on the record path (the bucket is gone,
|
|
1657
|
+
* so there is nothing left to write). The load path leaves `dirtyVersions`
|
|
1658
|
+
* alone because it has not produced any.
|
|
1659
|
+
*/
|
|
1660
|
+
pruneBuckets(opts = {}) {
|
|
1661
|
+
if (this.buckets.size <= this.maxBuckets) return;
|
|
1662
|
+
const sorted = Array.from(this.buckets.keys()).sort((a, b) => a - b);
|
|
1663
|
+
while (this.buckets.size > this.maxBuckets && sorted.length > 0) {
|
|
1664
|
+
const oldest = sorted.shift();
|
|
1665
|
+
if (oldest === void 0) break;
|
|
1666
|
+
this.buckets.delete(oldest);
|
|
1667
|
+
if (opts.dropDirty) this.dirtyVersions.delete(oldest);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Persist accumulated buckets to disk (append under lock), prune to maxBuckets.
|
|
1672
|
+
*
|
|
1673
|
+
* Note: this dispatcher coalesces — if `flush()` is called multiple times
|
|
1674
|
+
* before the previous flush settles, only the most-recent snapshot is
|
|
1675
|
+
* written. The dropped snapshots came from the same in-memory `buckets`
|
|
1676
|
+
* map, so their data is folded into the surviving snapshot rather than
|
|
1677
|
+
* lost. Callers that need every intermediate window on disk should use a
|
|
1678
|
+
* BestEffortBatchQueue instead of BestEffortLatestQueue here.
|
|
1679
|
+
*/
|
|
1680
|
+
flush() {
|
|
1681
|
+
if (this.dirtyVersions.size === 0) return;
|
|
1682
|
+
const versions = new Map(this.dirtyVersions);
|
|
1683
|
+
for (const [timestamp, version] of versions) {
|
|
1684
|
+
if (this.buckets.has(timestamp)) continue;
|
|
1685
|
+
versions.delete(timestamp);
|
|
1686
|
+
if (this.dirtyVersions.get(timestamp) === version) this.dirtyVersions.delete(timestamp);
|
|
1687
|
+
}
|
|
1688
|
+
const samples = Array.from(versions.keys()).map((timestamp) => this.buckets.get(timestamp)).filter((sample) => sample !== void 0).map(cloneTimeseriesSample).sort((a, b) => a.ts - b.ts);
|
|
1689
|
+
if (samples.length === 0) return;
|
|
1690
|
+
this.writer.enqueue({ samples, versions });
|
|
1691
|
+
}
|
|
1692
|
+
/** Resolves once all queued flushes have settled. For tests. */
|
|
1693
|
+
async drain() {
|
|
1694
|
+
await this.writer.drain();
|
|
1695
|
+
}
|
|
1696
|
+
async flushInternal(snapshot) {
|
|
1697
|
+
const lines = snapshot.samples.map((bucket) => JSON.stringify(bucket)).join("\n") + "\n";
|
|
1698
|
+
await withFileLock(this.filePath, async () => {
|
|
1699
|
+
this.diskLineCount ??= await countNonEmptyLines(this.filePath);
|
|
1700
|
+
await fs7.appendFile(this.filePath, lines, { encoding: "utf8", mode: SECRET_FILE_MODE });
|
|
1701
|
+
this.diskLineCount += snapshot.samples.length;
|
|
1702
|
+
const compactAt = Math.max(this.maxBuckets + 1, this.maxBuckets * 4);
|
|
1703
|
+
if (this.diskLineCount >= compactAt) {
|
|
1704
|
+
const retained = Array.from(this.buckets.values()).map(cloneTimeseriesSample).sort((a, b) => a.ts - b.ts);
|
|
1705
|
+
await atomicWrite(
|
|
1706
|
+
this.filePath,
|
|
1707
|
+
retained.map((bucket) => JSON.stringify(bucket)).join("\n") + "\n",
|
|
1708
|
+
{ mode: 384 }
|
|
1709
|
+
);
|
|
1710
|
+
this.diskLineCount = retained.length;
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
for (const [timestamp, version] of snapshot.versions) {
|
|
1714
|
+
if (this.dirtyVersions.get(timestamp) === version) this.dirtyVersions.delete(timestamp);
|
|
1715
|
+
}
|
|
1716
|
+
this.pruneBuckets();
|
|
1717
|
+
}
|
|
1718
|
+
/** Read buckets within `[since, now]`, oldest-first. */
|
|
1719
|
+
async read(sinceMs) {
|
|
1720
|
+
if (!this.loaded && this.buckets.size === 0) await this.load();
|
|
1721
|
+
const since = sinceMs ?? 0;
|
|
1722
|
+
return Array.from(this.buckets.values()).filter((b) => b.ts >= since).sort((a, b) => a.ts - b.ts);
|
|
1723
|
+
}
|
|
1724
|
+
/** Rehydrate buckets from disk (deduped, latest-per-bucket wins). */
|
|
1725
|
+
async load() {
|
|
1726
|
+
if (this.loaded) return;
|
|
1727
|
+
this.loadPromise ??= this.loadInternal();
|
|
1728
|
+
await this.loadPromise;
|
|
1729
|
+
}
|
|
1730
|
+
async loadInternal() {
|
|
1731
|
+
let lineCount = 0;
|
|
1732
|
+
try {
|
|
1733
|
+
for await (const line of readJsonlLines(this.filePath)) {
|
|
1734
|
+
lineCount++;
|
|
1735
|
+
try {
|
|
1736
|
+
const sample = JSON.parse(line);
|
|
1737
|
+
this.buckets.set(sample.ts, sample);
|
|
1738
|
+
} catch {
|
|
1739
|
+
}
|
|
1740
|
+
if (this.buckets.size > this.maxBuckets * 2) this.pruneBuckets();
|
|
1741
|
+
}
|
|
1742
|
+
} catch {
|
|
1743
|
+
this.diskLineCount = lineCount;
|
|
1744
|
+
this.loaded = true;
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
this.diskLineCount = lineCount;
|
|
1748
|
+
this.loaded = true;
|
|
1749
|
+
this.pruneBuckets();
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
function cloneTimeseriesSample(sample) {
|
|
1753
|
+
return {
|
|
1754
|
+
...sample,
|
|
1755
|
+
...sample.byModel ? {
|
|
1756
|
+
byModel: Object.fromEntries(
|
|
1757
|
+
Object.entries(sample.byModel).map(([key, value]) => [key, { ...value }])
|
|
1758
|
+
)
|
|
1759
|
+
} : {},
|
|
1760
|
+
...sample.byProvider ? {
|
|
1761
|
+
byProvider: Object.fromEntries(
|
|
1762
|
+
Object.entries(sample.byProvider).map(([key, value]) => [key, { ...value }])
|
|
1763
|
+
)
|
|
1764
|
+
} : {}
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
function foldBreakdown(map, key, entry) {
|
|
1768
|
+
const next = { ...map };
|
|
1769
|
+
const existing = next[key];
|
|
1770
|
+
if (existing === void 0) {
|
|
1771
|
+
next[key] = { ...entry };
|
|
1772
|
+
} else {
|
|
1773
|
+
next[key] = {
|
|
1774
|
+
costUsd: existing.costUsd + entry.costUsd,
|
|
1775
|
+
inputTokens: existing.inputTokens + entry.inputTokens,
|
|
1776
|
+
outputTokens: existing.outputTokens + entry.outputTokens,
|
|
1777
|
+
cacheRead: existing.cacheRead !== void 0 || entry.cacheRead !== void 0 ? (existing.cacheRead ?? 0) + (entry.cacheRead ?? 0) : void 0,
|
|
1778
|
+
cacheWrite: existing.cacheWrite !== void 0 || entry.cacheWrite !== void 0 ? (existing.cacheWrite ?? 0) + (entry.cacheWrite ?? 0) : void 0
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
return next;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/hq/persistence.ts
|
|
1785
|
+
function createHqPersistence(dataDir) {
|
|
1786
|
+
return {
|
|
1787
|
+
eventLog: new HqEventLog({ dataDir }),
|
|
1788
|
+
snapshotStore: new HqSnapshotStore({ dataDir }),
|
|
1789
|
+
timeseries: new HqTimeseriesStore({ dataDir }),
|
|
1790
|
+
kanban: new HqKanbanStore(dataDir),
|
|
1791
|
+
commandLog: new HqSimpleLog({
|
|
1792
|
+
dataDir,
|
|
1793
|
+
filename: "commands.jsonl",
|
|
1794
|
+
maxLines: 8e3,
|
|
1795
|
+
rotateKeep: 4e3,
|
|
1796
|
+
readLimit: 4e3
|
|
1797
|
+
}),
|
|
1798
|
+
alertLog: new HqSimpleLog({
|
|
1799
|
+
dataDir,
|
|
1800
|
+
filename: "alerts.jsonl",
|
|
1801
|
+
maxLines: 2e3,
|
|
1802
|
+
rotateKeep: 1e3,
|
|
1803
|
+
readLimit: 1e3
|
|
1804
|
+
})
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// src/hq/commands.ts
|
|
1809
|
+
var HQ_COMMAND_TYPES = [
|
|
1810
|
+
"steer",
|
|
1811
|
+
"btw",
|
|
1812
|
+
"queue",
|
|
1813
|
+
"abort",
|
|
1814
|
+
"spawn",
|
|
1815
|
+
"broadcast",
|
|
1816
|
+
"run-command"
|
|
1817
|
+
];
|
|
1818
|
+
var HQ_COMMAND_TYPE_SET = new Set(HQ_COMMAND_TYPES);
|
|
1819
|
+
function validateHqCommand(queued) {
|
|
1820
|
+
if (!HQ_COMMAND_TYPE_SET.has(queued.type)) return null;
|
|
1821
|
+
const p = queued.payload;
|
|
1822
|
+
if (p === null || typeof p !== "object") return null;
|
|
1823
|
+
switch (queued.type) {
|
|
1824
|
+
case "steer": {
|
|
1825
|
+
if (typeof p["to"] !== "string" || typeof p["subject"] !== "string" || typeof p["body"] !== "string") {
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
const result = { type: "steer", to: p["to"], subject: p["subject"], body: p["body"] };
|
|
1829
|
+
if (p["priority"] === "low" || p["priority"] === "normal" || p["priority"] === "high") {
|
|
1830
|
+
result.priority = p["priority"];
|
|
1831
|
+
}
|
|
1832
|
+
return result;
|
|
1833
|
+
}
|
|
1834
|
+
case "btw": {
|
|
1835
|
+
if (typeof p["to"] !== "string" || typeof p["subject"] !== "string" || typeof p["body"] !== "string") {
|
|
1836
|
+
return null;
|
|
1837
|
+
}
|
|
1838
|
+
const result = { type: "btw", to: p["to"], subject: p["subject"], body: p["body"] };
|
|
1839
|
+
if (p["priority"] === "low" || p["priority"] === "normal" || p["priority"] === "high") {
|
|
1840
|
+
result.priority = p["priority"];
|
|
1841
|
+
}
|
|
1842
|
+
return result;
|
|
1843
|
+
}
|
|
1844
|
+
case "queue": {
|
|
1845
|
+
if (typeof p["to"] !== "string" || typeof p["subject"] !== "string" || typeof p["body"] !== "string") {
|
|
1846
|
+
return null;
|
|
1847
|
+
}
|
|
1848
|
+
const result = { type: "queue", to: p["to"], subject: p["subject"], body: p["body"] };
|
|
1849
|
+
if (p["priority"] === "low" || p["priority"] === "normal" || p["priority"] === "high") {
|
|
1850
|
+
result.priority = p["priority"];
|
|
1851
|
+
}
|
|
1852
|
+
return result;
|
|
1853
|
+
}
|
|
1854
|
+
case "abort":
|
|
1855
|
+
if (typeof p["target"] !== "string") return null;
|
|
1856
|
+
return { type: "abort", target: p["target"] };
|
|
1857
|
+
case "spawn": {
|
|
1858
|
+
if (typeof p["role"] !== "string") return null;
|
|
1859
|
+
const result = { type: "spawn", role: p["role"] };
|
|
1860
|
+
if (typeof p["task"] === "string") result.task = p["task"];
|
|
1861
|
+
if (typeof p["maxIterations"] === "number") result.maxIterations = p["maxIterations"];
|
|
1862
|
+
return result;
|
|
1863
|
+
}
|
|
1864
|
+
case "broadcast": {
|
|
1865
|
+
if (typeof p["subject"] !== "string" || typeof p["body"] !== "string") return null;
|
|
1866
|
+
const result = { type: "broadcast", subject: p["subject"], body: p["body"] };
|
|
1867
|
+
if (p["priority"] === "low" || p["priority"] === "normal" || p["priority"] === "high") {
|
|
1868
|
+
result.priority = p["priority"];
|
|
1869
|
+
}
|
|
1870
|
+
return result;
|
|
1871
|
+
}
|
|
1872
|
+
case "run-command": {
|
|
1873
|
+
if (typeof p["command"] !== "string") return null;
|
|
1874
|
+
const result = { type: "run-command", command: p["command"] };
|
|
1875
|
+
if (typeof p["cwd"] === "string") result.cwd = p["cwd"];
|
|
1876
|
+
return result;
|
|
1877
|
+
}
|
|
1878
|
+
default:
|
|
1879
|
+
return null;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
var HqCommandAuditLog = class {
|
|
1883
|
+
entries = [];
|
|
1884
|
+
max;
|
|
1885
|
+
onPersist;
|
|
1886
|
+
constructor(max = 1e3, onPersist) {
|
|
1887
|
+
this.max = max;
|
|
1888
|
+
this.onPersist = onPersist;
|
|
1889
|
+
}
|
|
1890
|
+
record(entry) {
|
|
1891
|
+
this.entries.push(entry);
|
|
1892
|
+
if (this.entries.length > this.max) {
|
|
1893
|
+
this.entries.splice(0, this.entries.length - this.max);
|
|
1894
|
+
}
|
|
1895
|
+
this.onPersist?.(entry);
|
|
1896
|
+
}
|
|
1897
|
+
update(commandId, patch) {
|
|
1898
|
+
const entry = this.entries.find((e) => e.commandId === commandId);
|
|
1899
|
+
if (entry) {
|
|
1900
|
+
Object.assign(entry, patch);
|
|
1901
|
+
this.onPersist?.(entry);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
get(commandId) {
|
|
1905
|
+
return this.entries.find((entry) => entry.commandId === commandId);
|
|
1906
|
+
}
|
|
1907
|
+
/** Update only when the command belongs to the authenticated client. */
|
|
1908
|
+
updateForClient(commandId, clientId, patch) {
|
|
1909
|
+
const entry = this.entries.find(
|
|
1910
|
+
(candidate) => candidate.commandId === commandId && candidate.clientId === clientId
|
|
1911
|
+
);
|
|
1912
|
+
if (!entry) return false;
|
|
1913
|
+
Object.assign(entry, patch);
|
|
1914
|
+
this.onPersist?.(entry);
|
|
1915
|
+
return true;
|
|
1916
|
+
}
|
|
1917
|
+
/** Seed the ring from a durable store on boot (no persist callback fired). */
|
|
1918
|
+
seed(entries) {
|
|
1919
|
+
for (const entry of entries) {
|
|
1920
|
+
this.entries.push(entry);
|
|
1921
|
+
}
|
|
1922
|
+
if (this.entries.length > this.max) {
|
|
1923
|
+
this.entries.splice(0, this.entries.length - this.max);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
recent(limit = 200) {
|
|
1927
|
+
return this.entries.slice(-limit);
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
// src/hq/alerts.ts
|
|
1932
|
+
var DEFAULT_CONFIG = {
|
|
1933
|
+
costThresholdUsd: 50,
|
|
1934
|
+
staleMachineSeconds: 120,
|
|
1935
|
+
maxAgents: 0,
|
|
1936
|
+
tokenExpiryWarningHours: 24
|
|
1937
|
+
};
|
|
1938
|
+
function resolveConfig(config) {
|
|
1939
|
+
return { ...DEFAULT_CONFIG, ...config ?? {} };
|
|
1940
|
+
}
|
|
1941
|
+
var RULES = [
|
|
1942
|
+
{
|
|
1943
|
+
id: "fleet-cost-threshold",
|
|
1944
|
+
severity: "warn",
|
|
1945
|
+
evaluate: (snapshot, config) => {
|
|
1946
|
+
if (snapshot === null) return null;
|
|
1947
|
+
const cost = snapshot.totals.totalCostUsd ?? 0;
|
|
1948
|
+
if (cost >= config.costThresholdUsd) {
|
|
1949
|
+
return `Fleet cost $${cost.toFixed(2)} exceeded threshold $${config.costThresholdUsd.toFixed(2)}`;
|
|
1950
|
+
}
|
|
1951
|
+
return null;
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
{
|
|
1955
|
+
id: "all-machines-stale",
|
|
1956
|
+
severity: "warn",
|
|
1957
|
+
evaluate: (snapshot, config, now) => {
|
|
1958
|
+
if (snapshot === null) return null;
|
|
1959
|
+
const machines = snapshot.machines ?? [];
|
|
1960
|
+
if (machines.length === 0) return null;
|
|
1961
|
+
const cutoff = now - config.staleMachineSeconds * 1e3;
|
|
1962
|
+
const stale = machines.filter((m) => Date.parse(m.lastActivityAt) < cutoff);
|
|
1963
|
+
if (stale.length === machines.length) {
|
|
1964
|
+
return `All ${machines.length} machine(s) silent for ${config.staleMachineSeconds}s`;
|
|
1965
|
+
}
|
|
1966
|
+
return null;
|
|
1967
|
+
}
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
id: "high-concurrency",
|
|
1971
|
+
severity: "info",
|
|
1972
|
+
evaluate: (snapshot, config) => {
|
|
1973
|
+
if (snapshot === null || config.maxAgents <= 0) return null;
|
|
1974
|
+
const agents = snapshot.totals.activeAgents ?? 0;
|
|
1975
|
+
if (agents >= config.maxAgents) {
|
|
1976
|
+
return `High fleet concurrency: ${agents} active agents (limit ${config.maxAgents})`;
|
|
1977
|
+
}
|
|
1978
|
+
return null;
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
id: "fleet-failure-spike",
|
|
1983
|
+
severity: "warn",
|
|
1984
|
+
evaluate: (snapshot) => {
|
|
1985
|
+
if (snapshot === null) return null;
|
|
1986
|
+
const fleets = snapshot.fleets ?? [];
|
|
1987
|
+
const failed = fleets.reduce((sum, f) => sum + f.failedTasks, 0);
|
|
1988
|
+
if (failed >= 5) {
|
|
1989
|
+
return `${failed} failed task(s) across the fleet`;
|
|
1990
|
+
}
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
},
|
|
1994
|
+
{
|
|
1995
|
+
id: "token-expired-present",
|
|
1996
|
+
severity: "error",
|
|
1997
|
+
evaluate: (snapshot) => {
|
|
1998
|
+
if (snapshot === null) return null;
|
|
1999
|
+
const stats = snapshot.totals.tokenStats;
|
|
2000
|
+
if (stats === void 0 || stats.expired <= 0) return null;
|
|
2001
|
+
return `${stats.expired} expired token(s) filtered from live auth \u2014 rotate or revoke them`;
|
|
2002
|
+
}
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
id: "token-expiry-imminent",
|
|
2006
|
+
severity: "warn",
|
|
2007
|
+
evaluate: (snapshot, config) => {
|
|
2008
|
+
if (snapshot === null) return null;
|
|
2009
|
+
if (config.tokenExpiryWarningHours <= 0) return null;
|
|
2010
|
+
const stats = snapshot.totals.tokenStats;
|
|
2011
|
+
if (stats === void 0 || stats.expiringSoon <= 0) return null;
|
|
2012
|
+
return `${stats.expiringSoon} token(s) expire within ${config.tokenExpiryWarningHours}h \u2014 mint replacements before they lapse`;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
];
|
|
2016
|
+
var HqAlertEngine = class {
|
|
2017
|
+
active = /* @__PURE__ */ new Map();
|
|
2018
|
+
history = [];
|
|
2019
|
+
maxHistory;
|
|
2020
|
+
timer = null;
|
|
2021
|
+
onAlert;
|
|
2022
|
+
onPersist;
|
|
2023
|
+
constructor(opts) {
|
|
2024
|
+
this.onAlert = opts.onAlert;
|
|
2025
|
+
this.maxHistory = opts.maxHistory ?? 500;
|
|
2026
|
+
this.onPersist = opts.onPersist;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Evaluate all rules against the snapshot. Emits (via the `onAlert`
|
|
2030
|
+
* callback) only for rules that newly transition to firing. Clears rules
|
|
2031
|
+
* that are no longer firing. Returns the list of newly-fired alerts.
|
|
2032
|
+
*/
|
|
2033
|
+
evaluate(snapshot, config, now = Date.now()) {
|
|
2034
|
+
const resolved = resolveConfig(config);
|
|
2035
|
+
const fired = [];
|
|
2036
|
+
const firingIds = /* @__PURE__ */ new Set();
|
|
2037
|
+
for (const rule of RULES) {
|
|
2038
|
+
const message = rule.evaluate(snapshot, resolved, now);
|
|
2039
|
+
if (message !== null) {
|
|
2040
|
+
firingIds.add(rule.id);
|
|
2041
|
+
const existing = this.active.get(rule.id);
|
|
2042
|
+
if (existing === void 0) {
|
|
2043
|
+
const alert = {
|
|
2044
|
+
id: `${rule.id}-${now}`,
|
|
2045
|
+
ruleId: rule.id,
|
|
2046
|
+
severity: rule.severity,
|
|
2047
|
+
message,
|
|
2048
|
+
firstFiredAt: now,
|
|
2049
|
+
lastFiredAt: now
|
|
2050
|
+
};
|
|
2051
|
+
this.active.set(rule.id, alert);
|
|
2052
|
+
this.history.push(alert);
|
|
2053
|
+
if (this.history.length > this.maxHistory) this.history.splice(0, this.history.length - this.maxHistory);
|
|
2054
|
+
fired.push(alert);
|
|
2055
|
+
this.onAlert(alert);
|
|
2056
|
+
this.onPersist?.(alert);
|
|
2057
|
+
} else {
|
|
2058
|
+
existing.lastFiredAt = now;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
for (const id of Array.from(this.active.keys())) {
|
|
2063
|
+
if (!firingIds.has(id)) {
|
|
2064
|
+
this.active.delete(id);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
return fired;
|
|
2068
|
+
}
|
|
2069
|
+
/** Currently-active (firing) alerts. */
|
|
2070
|
+
activeAlerts() {
|
|
2071
|
+
return Array.from(this.active.values());
|
|
2072
|
+
}
|
|
2073
|
+
/** Historical alerts (newest-last), capped at maxHistory. */
|
|
2074
|
+
recentAlerts(limit = 100) {
|
|
2075
|
+
return this.history.slice(-limit);
|
|
2076
|
+
}
|
|
2077
|
+
/** Seed the history from a durable store on boot (no alert callbacks fired). */
|
|
2078
|
+
seed(alerts) {
|
|
2079
|
+
for (const alert of alerts) {
|
|
2080
|
+
this.history.push(alert);
|
|
2081
|
+
}
|
|
2082
|
+
if (this.history.length > this.maxHistory) {
|
|
2083
|
+
this.history.splice(0, this.history.length - this.maxHistory);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Start periodic evaluation against a snapshot getter. The timer is
|
|
2088
|
+
* unref'd so it never keeps the process alive. Returns a disposer.
|
|
2089
|
+
*/
|
|
2090
|
+
startPeriodic(getSnapshot, config, intervalMs = 15e3) {
|
|
2091
|
+
if (this.timer !== null) return () => void 0;
|
|
2092
|
+
const tick = () => {
|
|
2093
|
+
try {
|
|
2094
|
+
const cfg = typeof config === "function" ? config() : config;
|
|
2095
|
+
this.evaluate(getSnapshot(), cfg);
|
|
2096
|
+
} catch {
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
this.timer = setInterval(tick, intervalMs);
|
|
2100
|
+
this.timer.unref?.();
|
|
2101
|
+
return () => {
|
|
2102
|
+
if (this.timer !== null) {
|
|
2103
|
+
clearInterval(this.timer);
|
|
2104
|
+
this.timer = null;
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
function toAlertMessage(alert) {
|
|
2110
|
+
return {
|
|
2111
|
+
type: "hq.alert",
|
|
2112
|
+
severity: alert.severity,
|
|
2113
|
+
message: `[${alert.ruleId}] ${alert.message}`,
|
|
2114
|
+
timestamp: new Date(alert.lastFiredAt).toISOString()
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// src/hq/bootstrap-store.ts
|
|
2119
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2120
|
+
var MAX_PENDING_CODES = 32;
|
|
2121
|
+
var BOOTSTRAP_CODE_TTL_MS = 12e4;
|
|
2122
|
+
var HqBootstrapCodeStore = class {
|
|
2123
|
+
entries = /* @__PURE__ */ new Map();
|
|
2124
|
+
/** Mint a new one-time bootstrap code for the given token. */
|
|
2125
|
+
issue(source) {
|
|
2126
|
+
this.sweepExpired();
|
|
2127
|
+
while (this.entries.size >= MAX_PENDING_CODES) {
|
|
2128
|
+
const oldest = this.entries.keys().next().value;
|
|
2129
|
+
if (oldest === void 0) break;
|
|
2130
|
+
this.entries.delete(oldest);
|
|
2131
|
+
}
|
|
2132
|
+
const code = randomBytes(32).toString("base64url");
|
|
2133
|
+
this.entries.set(code, {
|
|
2134
|
+
code,
|
|
2135
|
+
expiresAt: Date.now() + BOOTSTRAP_CODE_TTL_MS,
|
|
2136
|
+
tokenId: source.tokenId,
|
|
2137
|
+
...source.capabilities !== void 0 ? { capabilities: source.capabilities } : {}
|
|
2138
|
+
});
|
|
2139
|
+
return code;
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Atomically consume a code. Returns the entry on match (and deletes it
|
|
2143
|
+
* so it can never be reused), or `undefined` if the code is unknown,
|
|
2144
|
+
* already consumed, or expired.
|
|
2145
|
+
*/
|
|
2146
|
+
consume(supplied) {
|
|
2147
|
+
this.sweepExpired();
|
|
2148
|
+
for (const [key, entry] of this.entries) {
|
|
2149
|
+
const a = Buffer.from(key);
|
|
2150
|
+
const b = Buffer.from(supplied);
|
|
2151
|
+
if (a.length === b.length && timingSafeEqual(a, b)) {
|
|
2152
|
+
this.entries.delete(key);
|
|
2153
|
+
return Date.now() < entry.expiresAt ? entry : void 0;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return void 0;
|
|
2157
|
+
}
|
|
2158
|
+
/** Remove all codes (called on server shutdown). */
|
|
2159
|
+
clear() {
|
|
2160
|
+
this.entries.clear();
|
|
2161
|
+
}
|
|
2162
|
+
/** Current number of pending (not-yet-consumed) codes. */
|
|
2163
|
+
get size() {
|
|
2164
|
+
return this.entries.size;
|
|
2165
|
+
}
|
|
2166
|
+
sweepExpired() {
|
|
2167
|
+
const now = Date.now();
|
|
2168
|
+
for (const [key, entry] of this.entries) {
|
|
2169
|
+
if (now >= entry.expiresAt) this.entries.delete(key);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
// src/hq/exposure.ts
|
|
2175
|
+
import { isIP } from "node:net";
|
|
2176
|
+
var HQ_CLI_DEFAULT_HOST = "0.0.0.0";
|
|
2177
|
+
var HqInsecureExposureError = class extends Error {
|
|
2178
|
+
constructor(message) {
|
|
2179
|
+
super(message);
|
|
2180
|
+
this.name = "HqInsecureExposureError";
|
|
2181
|
+
}
|
|
2182
|
+
};
|
|
2183
|
+
function isLoopbackHost(host) {
|
|
2184
|
+
const normalized = host.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
2185
|
+
if (normalized === "localhost") return true;
|
|
2186
|
+
const family = isIP(normalized);
|
|
2187
|
+
if (family === 4) {
|
|
2188
|
+
return normalized.startsWith("127.");
|
|
2189
|
+
}
|
|
2190
|
+
if (family === 6) {
|
|
2191
|
+
return normalized === "::1" || /^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized);
|
|
2192
|
+
}
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
function isOpenMode(input) {
|
|
2196
|
+
return !input.hasBrowserTokens && !input.hasPassword;
|
|
2197
|
+
}
|
|
2198
|
+
function assessHqExposure(input) {
|
|
2199
|
+
if (isLoopbackHost(input.host)) return { kind: "ok" };
|
|
2200
|
+
if (isOpenMode(input)) {
|
|
2201
|
+
const detail = `HQ is configured in open mode (no browser tokens and no password), so every request \u2014 including POST /api/command, which runs commands in your live sessions \u2014 is served unauthenticated. Binding that to ${input.host} exposes it to every peer on the network.`;
|
|
2202
|
+
if (input.allowInsecure) {
|
|
2203
|
+
return {
|
|
2204
|
+
kind: "warn",
|
|
2205
|
+
message: `${detail} Continuing because --insecure-open was passed.`
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
return {
|
|
2209
|
+
kind: "refuse",
|
|
2210
|
+
message: `${detail}
|
|
2211
|
+
|
|
2212
|
+
Fix one of these, then retry:
|
|
2213
|
+
\u2022 bind locally: wstack hq --host 127.0.0.1
|
|
2214
|
+
\u2022 mint a token: wstack hq token create
|
|
2215
|
+
\u2022 set a password: wstack hq --password <value>
|
|
2216
|
+
\u2022 accept the risk: wstack hq --insecure-open`
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
return {
|
|
2220
|
+
kind: "warn",
|
|
2221
|
+
message: `HQ is bound to ${input.host} (all interfaces) without TLS. Access tokens travel in cleartext and appear in the URL query. Prefer --host 127.0.0.1 unless you intend HQ to be reachable from other machines.`
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
export {
|
|
2226
|
+
mapSessionEventToEntries,
|
|
2227
|
+
mergeToolResults,
|
|
2228
|
+
buildTranscriptFromEvents,
|
|
2229
|
+
startSessionTelemetryBridge,
|
|
2230
|
+
createBridgeContext,
|
|
2231
|
+
startFleetTelemetryBridge,
|
|
2232
|
+
startBrainTelemetryBridge,
|
|
2233
|
+
startWorktreeTelemetryBridge,
|
|
2234
|
+
startToolTelemetryBridge,
|
|
2235
|
+
startCostTelemetryBridge,
|
|
2236
|
+
HqKanbanStore,
|
|
2237
|
+
HQ_EVENT_LOG_PRESETS,
|
|
2238
|
+
hqEventLogPresetFields,
|
|
2239
|
+
HqEventLog,
|
|
2240
|
+
HqSimpleLog,
|
|
2241
|
+
HqSnapshotStore,
|
|
2242
|
+
HqTimeseriesStore,
|
|
2243
|
+
createHqPersistence,
|
|
2244
|
+
HQ_COMMAND_TYPES,
|
|
2245
|
+
validateHqCommand,
|
|
2246
|
+
HqCommandAuditLog,
|
|
2247
|
+
HqAlertEngine,
|
|
2248
|
+
toAlertMessage,
|
|
2249
|
+
HqBootstrapCodeStore,
|
|
2250
|
+
HQ_CLI_DEFAULT_HOST,
|
|
2251
|
+
HqInsecureExposureError,
|
|
2252
|
+
isLoopbackHost,
|
|
2253
|
+
isOpenMode,
|
|
2254
|
+
assessHqExposure
|
|
2255
|
+
};
|
|
2256
|
+
//# sourceMappingURL=chunk-2KKODIQU.js.map
|