@fml-inc/fml 0.7.0 → 0.8.0-rc.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/.claude-plugin/plugin.json +2 -4
- package/README.md +312 -79
- package/agent-assets/fml/SKILL.md +180 -0
- package/bin/fml +4 -18
- package/bin/hook-handler +7 -0
- package/bin/mcp-server +4 -18
- package/bin/observer +6 -0
- package/dist/chunk-7DR5X7XI.js +1522 -0
- package/dist/chunk-CK5MEL5J.js +140 -0
- package/dist/chunk-DEMVOVVY.js +4442 -0
- package/dist/chunk-F66IBM3Z.js +1037 -0
- package/dist/chunk-HUR2HCUG.js +953 -0
- package/dist/chunk-I4GZPTBM.js +109 -0
- package/dist/chunk-JXSSPQ5B.js +3331 -0
- package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
- package/dist/{chunk-PTUZMKSN.js → chunk-P3QLARXE.js} +34 -23
- package/dist/chunk-T5YVLSUC.js +138 -0
- package/dist/cli.js +7523 -1005
- package/dist/{esm-2EUDFZWP.js → esm-4YKLKFRX.js} +546 -741
- package/dist/hook-handler.js +190 -0
- package/dist/mcp/server.js +112 -98
- package/dist/observer-server.js +1566 -0
- package/dist/sync-cycle.js +31 -0
- package/dist/{token-store-3IQSLOTQ.js → token-store-R7WXEPLI.js} +10 -3
- package/hooks/hooks.json +290 -0
- package/package.json +24 -27
- package/dist/chunk-PTUZMKSN.js.map +0 -1
- package/dist/chunk-PZ5AY32C.js.map +0 -1
- package/dist/chunk-S4TJNLEN.js +0 -61
- package/dist/chunk-S4TJNLEN.js.map +0 -1
- package/dist/chunk-VV4JHH4H.js +0 -437
- package/dist/chunk-VV4JHH4H.js.map +0 -1
- package/dist/chunk-YH5ROR7G.js +0 -69
- package/dist/chunk-YH5ROR7G.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/daemon-MSLDHLMH.js +0 -21
- package/dist/daemon-MSLDHLMH.js.map +0 -1
- package/dist/daemon-utils-OODBIYB7.js +0 -10
- package/dist/daemon-utils-OODBIYB7.js.map +0 -1
- package/dist/esm-2EUDFZWP.js.map +0 -1
- package/dist/mcp/server.js.map +0 -1
- package/dist/postinstall.js +0 -16
- package/dist/postinstall.js.map +0 -1
- package/dist/token-store-3IQSLOTQ.js.map +0 -1
- package/scripts/postinstall.cjs +0 -25
|
@@ -0,0 +1,4442 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FML_HOOK_INBOX_DIR
|
|
3
|
+
} from "./chunk-7DR5X7XI.js";
|
|
4
|
+
|
|
5
|
+
// ../../packages/panopticon/src/observer/auth.ts
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
function readObserverAuthToken(tokenFile) {
|
|
10
|
+
try {
|
|
11
|
+
const token = fs.readFileSync(tokenFile, "utf8").trim();
|
|
12
|
+
return token.length > 0 ? token : null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function getOrCreateObserverAuthToken(tokenFile) {
|
|
18
|
+
const existing = readObserverAuthToken(tokenFile);
|
|
19
|
+
if (existing) return existing;
|
|
20
|
+
fs.mkdirSync(path.dirname(tokenFile), { recursive: true, mode: 448 });
|
|
21
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
22
|
+
try {
|
|
23
|
+
fs.writeFileSync(tokenFile, `${token}
|
|
24
|
+
`, { flag: "wx", mode: 384 });
|
|
25
|
+
return token;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code === "EEXIST") {
|
|
28
|
+
const raced = readObserverAuthToken(tokenFile);
|
|
29
|
+
if (raced) return raced;
|
|
30
|
+
try {
|
|
31
|
+
if (fs.readFileSync(tokenFile, "utf8").trim().length === 0) {
|
|
32
|
+
fs.rmSync(tokenFile);
|
|
33
|
+
return getOrCreateObserverAuthToken(tokenFile);
|
|
34
|
+
}
|
|
35
|
+
} catch (repairError) {
|
|
36
|
+
if (repairError.code === "ENOENT") {
|
|
37
|
+
return getOrCreateObserverAuthToken(tokenFile);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isObserverAuthToken(presented, expected) {
|
|
45
|
+
const leftBytes = Buffer.from(presented, "utf8");
|
|
46
|
+
const rightBytes = Buffer.from(expected, "utf8");
|
|
47
|
+
return leftBytes.length === rightBytes.length && crypto.timingSafeEqual(leftBytes, rightBytes);
|
|
48
|
+
}
|
|
49
|
+
function hasObserverBearerToken(req, expected) {
|
|
50
|
+
const authorization = req.headers.authorization;
|
|
51
|
+
if (typeof authorization !== "string" || !authorization.startsWith("Bearer ")) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return isObserverAuthToken(
|
|
55
|
+
authorization.slice("Bearer ".length).trim(),
|
|
56
|
+
expected
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
function requireObserverBearerToken(req, res, expected) {
|
|
60
|
+
if (hasObserverBearerToken(req, expected)) return true;
|
|
61
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
62
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ../../packages/agent-targets/src/hooks/normalize.ts
|
|
67
|
+
import { createHash } from "crypto";
|
|
68
|
+
|
|
69
|
+
// ../../packages/agent-targets/src/hooks/types.ts
|
|
70
|
+
var HOOK_TARGETS = Object.freeze([
|
|
71
|
+
"claude",
|
|
72
|
+
"codex",
|
|
73
|
+
"gemini",
|
|
74
|
+
"grok"
|
|
75
|
+
]);
|
|
76
|
+
function isHookTarget(value) {
|
|
77
|
+
return typeof value === "string" && HOOK_TARGETS.some((targetId) => targetId === value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ../../packages/agent-targets/src/catalog/hook-events.json
|
|
81
|
+
var hook_events_default = {
|
|
82
|
+
claude: [
|
|
83
|
+
"SessionStart",
|
|
84
|
+
"Setup",
|
|
85
|
+
"InstructionsLoaded",
|
|
86
|
+
"UserPromptSubmit",
|
|
87
|
+
"UserPromptExpansion",
|
|
88
|
+
"PreToolUse",
|
|
89
|
+
"PermissionRequest",
|
|
90
|
+
"PostToolUse",
|
|
91
|
+
"PostToolUseFailure",
|
|
92
|
+
"PostToolBatch",
|
|
93
|
+
"PermissionDenied",
|
|
94
|
+
"Notification",
|
|
95
|
+
"SubagentStart",
|
|
96
|
+
"SubagentStop",
|
|
97
|
+
"TaskCreated",
|
|
98
|
+
"TaskCompleted",
|
|
99
|
+
"Stop",
|
|
100
|
+
"StopFailure",
|
|
101
|
+
"TeammateIdle",
|
|
102
|
+
"ConfigChange",
|
|
103
|
+
"CwdChanged",
|
|
104
|
+
"PreCompact",
|
|
105
|
+
"PostCompact",
|
|
106
|
+
"SessionEnd",
|
|
107
|
+
"Elicitation",
|
|
108
|
+
"ElicitationResult"
|
|
109
|
+
],
|
|
110
|
+
codex: [
|
|
111
|
+
"SessionStart",
|
|
112
|
+
"UserPromptSubmit",
|
|
113
|
+
"PreToolUse",
|
|
114
|
+
"PermissionRequest",
|
|
115
|
+
"PostToolUse",
|
|
116
|
+
"Stop"
|
|
117
|
+
],
|
|
118
|
+
gemini: [
|
|
119
|
+
"SessionStart",
|
|
120
|
+
"BeforeAgent",
|
|
121
|
+
"BeforeTool",
|
|
122
|
+
"AfterTool",
|
|
123
|
+
"AfterAgent"
|
|
124
|
+
],
|
|
125
|
+
grok: [
|
|
126
|
+
"SessionStart",
|
|
127
|
+
"UserPromptSubmit",
|
|
128
|
+
"PreToolUse",
|
|
129
|
+
"PostToolUse",
|
|
130
|
+
"PostToolUseFailure",
|
|
131
|
+
"PermissionDenied",
|
|
132
|
+
"Stop",
|
|
133
|
+
"StopFailure",
|
|
134
|
+
"Notification",
|
|
135
|
+
"SubagentStart",
|
|
136
|
+
"SubagentStop",
|
|
137
|
+
"PreCompact",
|
|
138
|
+
"PostCompact",
|
|
139
|
+
"SessionEnd"
|
|
140
|
+
]
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ../../packages/agent-targets/src/catalog/hook-events.ts
|
|
144
|
+
var CLAUDE_HOOK_EVENTS = Object.freeze([
|
|
145
|
+
...hook_events_default.claude
|
|
146
|
+
]);
|
|
147
|
+
var CODEX_HOOK_EVENTS = Object.freeze([
|
|
148
|
+
...hook_events_default.codex
|
|
149
|
+
]);
|
|
150
|
+
var GEMINI_HOOK_EVENTS = Object.freeze([
|
|
151
|
+
...hook_events_default.gemini
|
|
152
|
+
]);
|
|
153
|
+
var GROK_HOOK_EVENTS = Object.freeze([
|
|
154
|
+
...hook_events_default.grok
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
// ../../packages/agent-targets/src/hooks/normalize.ts
|
|
158
|
+
var MAX_EXTRACTED_TEXT_BYTES = 64 * 1024;
|
|
159
|
+
function isRecord(value) {
|
|
160
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
161
|
+
}
|
|
162
|
+
function firstString(record, keys) {
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const value = record[key];
|
|
165
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
166
|
+
}
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
function extractedText(value) {
|
|
170
|
+
if (typeof value === "string") return value;
|
|
171
|
+
if (value === void 0 || value === null) return void 0;
|
|
172
|
+
try {
|
|
173
|
+
return JSON.stringify(value);
|
|
174
|
+
} catch {
|
|
175
|
+
return void 0;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function firstText(record, keys) {
|
|
179
|
+
for (const key of keys) {
|
|
180
|
+
const value = extractedText(record[key]);
|
|
181
|
+
if (value !== void 0 && value.length > 0) return value;
|
|
182
|
+
}
|
|
183
|
+
return void 0;
|
|
184
|
+
}
|
|
185
|
+
function utf8Prefix(value, maxBytes) {
|
|
186
|
+
let low = 0;
|
|
187
|
+
let high = value.length;
|
|
188
|
+
while (low < high) {
|
|
189
|
+
const middle = Math.ceil((low + high) / 2);
|
|
190
|
+
if (Buffer.byteLength(value.slice(0, middle), "utf8") <= maxBytes) {
|
|
191
|
+
low = middle;
|
|
192
|
+
} else {
|
|
193
|
+
high = middle - 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
let end = low;
|
|
197
|
+
if (end > 0 && end < value.length && value.charCodeAt(end - 1) >= 55296 && value.charCodeAt(end - 1) <= 56319 && value.charCodeAt(end) >= 56320 && value.charCodeAt(end) <= 57343) {
|
|
198
|
+
end -= 1;
|
|
199
|
+
}
|
|
200
|
+
return value.slice(0, end);
|
|
201
|
+
}
|
|
202
|
+
function boundedExtractedText(value, field) {
|
|
203
|
+
if (value === void 0) return void 0;
|
|
204
|
+
const originalBytes = Buffer.byteLength(value, "utf8");
|
|
205
|
+
if (originalBytes <= MAX_EXTRACTED_TEXT_BYTES) return value;
|
|
206
|
+
const digest = createHash("sha256").update(value).digest("hex");
|
|
207
|
+
const marker = `
|
|
208
|
+
[fml:${field}:truncated originalBytes=${originalBytes} sha256=${digest}]`;
|
|
209
|
+
return `${utf8Prefix(
|
|
210
|
+
value,
|
|
211
|
+
MAX_EXTRACTED_TEXT_BYTES - Buffer.byteLength(marker, "utf8")
|
|
212
|
+
)}${marker}`;
|
|
213
|
+
}
|
|
214
|
+
function nestedRecord(record, keys) {
|
|
215
|
+
for (const key of keys) {
|
|
216
|
+
const value = record[key];
|
|
217
|
+
if (isRecord(value)) return value;
|
|
218
|
+
}
|
|
219
|
+
return void 0;
|
|
220
|
+
}
|
|
221
|
+
function timestampMs(payload, fallback) {
|
|
222
|
+
for (const key of ["timestamp_ms", "timestampMs", "timestamp"]) {
|
|
223
|
+
const value = payload[key];
|
|
224
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
225
|
+
const milliseconds = Math.round(
|
|
226
|
+
value < 1e10 ? value * 1e3 : value
|
|
227
|
+
);
|
|
228
|
+
if (Number.isSafeInteger(milliseconds) && milliseconds > 0) {
|
|
229
|
+
return milliseconds;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (typeof value === "string") {
|
|
233
|
+
const parsed = Date.parse(value);
|
|
234
|
+
if (Number.isSafeInteger(parsed) && parsed >= 0) return parsed;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return fallback;
|
|
238
|
+
}
|
|
239
|
+
function geminiEventType(eventType) {
|
|
240
|
+
switch (eventType) {
|
|
241
|
+
case "BeforeAgent":
|
|
242
|
+
case "BeforeModel":
|
|
243
|
+
return "UserPromptSubmit";
|
|
244
|
+
case "BeforeTool":
|
|
245
|
+
return "PreToolUse";
|
|
246
|
+
case "AfterTool":
|
|
247
|
+
return "PostToolUse";
|
|
248
|
+
case "AfterAgent":
|
|
249
|
+
return "Stop";
|
|
250
|
+
default:
|
|
251
|
+
return eventType;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
var GROK_EVENT_TYPES = Object.freeze({
|
|
255
|
+
session_start: "SessionStart",
|
|
256
|
+
user_prompt_submit: "UserPromptSubmit",
|
|
257
|
+
pre_tool_use: "PreToolUse",
|
|
258
|
+
post_tool_use: "PostToolUse",
|
|
259
|
+
post_tool_use_failure: "PostToolUseFailure",
|
|
260
|
+
permission_denied: "PermissionDenied",
|
|
261
|
+
stop: "Stop",
|
|
262
|
+
stop_failure: "StopFailure",
|
|
263
|
+
notification: "Notification",
|
|
264
|
+
subagent_start: "SubagentStart",
|
|
265
|
+
subagent_stop: "SubagentStop",
|
|
266
|
+
pre_compact: "PreCompact",
|
|
267
|
+
post_compact: "PostCompact",
|
|
268
|
+
session_end: "SessionEnd"
|
|
269
|
+
});
|
|
270
|
+
function grokEventType(eventType) {
|
|
271
|
+
return Object.hasOwn(GROK_EVENT_TYPES, eventType) ? GROK_EVENT_TYPES[eventType] ?? eventType : eventType;
|
|
272
|
+
}
|
|
273
|
+
function geminiUserPrompt(payload) {
|
|
274
|
+
const request = payload.llm_request;
|
|
275
|
+
if (!isRecord(request) || !Array.isArray(request.messages)) return void 0;
|
|
276
|
+
const userMessage = [...request.messages].reverse().find(
|
|
277
|
+
(message) => isRecord(message) && message.role === "user"
|
|
278
|
+
);
|
|
279
|
+
if (!userMessage) return void 0;
|
|
280
|
+
if (typeof userMessage.content === "string") {
|
|
281
|
+
return userMessage.content || void 0;
|
|
282
|
+
}
|
|
283
|
+
if (!Array.isArray(userMessage.content)) return void 0;
|
|
284
|
+
const text = userMessage.content.filter(
|
|
285
|
+
(part) => isRecord(part) && part.type === "text" && typeof part.text === "string"
|
|
286
|
+
).map((part) => part.text).join("\n");
|
|
287
|
+
return text || void 0;
|
|
288
|
+
}
|
|
289
|
+
function normalizedEvent(target, payload, context) {
|
|
290
|
+
const sessionId = firstString(payload, [
|
|
291
|
+
"session_id",
|
|
292
|
+
"sessionId",
|
|
293
|
+
"conversation_id",
|
|
294
|
+
"conversationId"
|
|
295
|
+
]);
|
|
296
|
+
const rawEventType = firstString(payload, [
|
|
297
|
+
"hook_event_name",
|
|
298
|
+
"hookEventName",
|
|
299
|
+
"event_name",
|
|
300
|
+
"eventName",
|
|
301
|
+
"event"
|
|
302
|
+
]);
|
|
303
|
+
if (!sessionId || !rawEventType) return null;
|
|
304
|
+
const eventType = target === "gemini" ? geminiEventType(rawEventType) : target === "grok" ? grokEventType(rawEventType) : rawEventType;
|
|
305
|
+
const toolInput = nestedRecord(payload, ["tool_input", "toolInput"]);
|
|
306
|
+
const cwd = firstString(payload, [
|
|
307
|
+
"cwd",
|
|
308
|
+
"working_directory",
|
|
309
|
+
"workingDirectory",
|
|
310
|
+
"workspaceRoot"
|
|
311
|
+
]) ?? context.cwd;
|
|
312
|
+
const filePath = firstString(payload, ["file_path", "filePath", "path"]) ?? (toolInput ? firstString(toolInput, ["file_path", "filePath", "path"]) : void 0);
|
|
313
|
+
const command = boundedExtractedText(
|
|
314
|
+
firstText(payload, ["command"]) ?? (toolInput ? firstText(toolInput, ["command", "cmd"]) : void 0),
|
|
315
|
+
"command"
|
|
316
|
+
);
|
|
317
|
+
const toolName = firstString(payload, ["tool_name", "toolName", "name"]);
|
|
318
|
+
const userPrompt = boundedExtractedText(
|
|
319
|
+
firstText(payload, ["prompt", "user_prompt", "userPrompt"]) ?? (target === "gemini" ? geminiUserPrompt(payload) : void 0),
|
|
320
|
+
"user_prompt"
|
|
321
|
+
);
|
|
322
|
+
const plan = boundedExtractedText(firstText(payload, ["plan"]), "plan");
|
|
323
|
+
const allowedPrompts = boundedExtractedText(
|
|
324
|
+
firstText(payload, ["allowed_prompts", "allowedPrompts"]),
|
|
325
|
+
"allowed_prompts"
|
|
326
|
+
);
|
|
327
|
+
const toolResult = boundedExtractedText(
|
|
328
|
+
firstText(payload, [
|
|
329
|
+
"tool_result",
|
|
330
|
+
"toolResult",
|
|
331
|
+
"tool_response",
|
|
332
|
+
"toolResponse"
|
|
333
|
+
]),
|
|
334
|
+
"tool_result"
|
|
335
|
+
);
|
|
336
|
+
const nativeEventId = firstString(payload, [
|
|
337
|
+
"hook_id",
|
|
338
|
+
"hookId",
|
|
339
|
+
"event_id",
|
|
340
|
+
"eventId",
|
|
341
|
+
"tool_use_id",
|
|
342
|
+
"toolUseId",
|
|
343
|
+
"tool_call_id",
|
|
344
|
+
"toolCallId",
|
|
345
|
+
"prompt_id",
|
|
346
|
+
"promptId"
|
|
347
|
+
]) ?? (eventType === "UserPromptSubmit" || eventType === "Stop" ? firstString(payload, ["turn_id", "turnId"]) : void 0);
|
|
348
|
+
return {
|
|
349
|
+
sessionId,
|
|
350
|
+
eventType,
|
|
351
|
+
timestampMs: timestampMs(payload, context.nowMs),
|
|
352
|
+
...nativeEventId ? { nativeEventId } : {},
|
|
353
|
+
...cwd ? { cwd } : {},
|
|
354
|
+
...toolName ? { toolName } : {},
|
|
355
|
+
...userPrompt ? { userPrompt } : {},
|
|
356
|
+
...filePath ? { filePath } : {},
|
|
357
|
+
...command ? { command } : {},
|
|
358
|
+
...plan ? { plan } : {},
|
|
359
|
+
...allowedPrompts ? { allowedPrompts } : {},
|
|
360
|
+
...toolResult ? { toolResult } : {}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function capability(targetId, nativeEvents) {
|
|
364
|
+
return Object.freeze({
|
|
365
|
+
targetId,
|
|
366
|
+
nativeEvents,
|
|
367
|
+
normalize: (payload, context) => normalizedEvent(targetId, payload, context)
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
var HOOK_NORMALIZATION_CAPABILITIES = Object.freeze([
|
|
371
|
+
capability("claude", CLAUDE_HOOK_EVENTS),
|
|
372
|
+
capability("codex", CODEX_HOOK_EVENTS),
|
|
373
|
+
capability("gemini", GEMINI_HOOK_EVENTS),
|
|
374
|
+
capability("grok", GROK_HOOK_EVENTS)
|
|
375
|
+
]);
|
|
376
|
+
function parseHookCaptureRequest(value) {
|
|
377
|
+
if (!isRecord(value) || !isHookTarget(value.target)) return null;
|
|
378
|
+
if (!isRecord(value.payload)) return null;
|
|
379
|
+
return {
|
|
380
|
+
target: value.target,
|
|
381
|
+
payload: value.payload,
|
|
382
|
+
...typeof value.cwd === "string" && value.cwd.length > 0 ? { cwd: value.cwd } : {}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function normalizeTargetHookCapture(request, context) {
|
|
386
|
+
const capability2 = HOOK_NORMALIZATION_CAPABILITIES.find(
|
|
387
|
+
(candidate) => candidate.targetId === request.target
|
|
388
|
+
);
|
|
389
|
+
return capability2?.normalize(request.payload, {
|
|
390
|
+
...context,
|
|
391
|
+
cwd: context.cwd ?? request.cwd
|
|
392
|
+
}) ?? null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ../../packages/panopticon/src/observer/capture.ts
|
|
396
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
397
|
+
function hookSyncId(request, sessionId, eventType, nativeEventId) {
|
|
398
|
+
const identity = nativeEventId ?? `delivery:${randomUUID()}`;
|
|
399
|
+
const digest = createHash2("sha256").update(`${request.target}\0${sessionId}\0${eventType}\0${identity}`).digest("hex");
|
|
400
|
+
return `hook|${request.target}|${digest}`;
|
|
401
|
+
}
|
|
402
|
+
function normalizeObserverHookCapture(request, options = {}) {
|
|
403
|
+
const now = (options.nowMs ?? Date.now)();
|
|
404
|
+
const normalized = normalizeTargetHookCapture(request, {
|
|
405
|
+
nowMs: now,
|
|
406
|
+
cwd: request.cwd
|
|
407
|
+
});
|
|
408
|
+
if (!normalized) return null;
|
|
409
|
+
return {
|
|
410
|
+
syncId: options.syncId?.() ?? hookSyncId(
|
|
411
|
+
request,
|
|
412
|
+
normalized.sessionId,
|
|
413
|
+
normalized.eventType,
|
|
414
|
+
normalized.nativeEventId
|
|
415
|
+
),
|
|
416
|
+
sessionId: normalized.sessionId,
|
|
417
|
+
eventType: normalized.eventType,
|
|
418
|
+
timestampMs: normalized.timestampMs,
|
|
419
|
+
target: request.target,
|
|
420
|
+
payload: request.payload,
|
|
421
|
+
...normalized.cwd ? { cwd: normalized.cwd } : {},
|
|
422
|
+
...options.repository ? { repository: options.repository } : {},
|
|
423
|
+
...normalized.toolName ? { toolName: normalized.toolName } : {},
|
|
424
|
+
...normalized.userPrompt ? { userPrompt: normalized.userPrompt } : {},
|
|
425
|
+
...normalized.filePath ? { filePath: normalized.filePath } : {},
|
|
426
|
+
...normalized.command ? { command: normalized.command } : {},
|
|
427
|
+
...normalized.plan ? { plan: normalized.plan } : {},
|
|
428
|
+
...normalized.allowedPrompts ? { allowedPrompts: normalized.allowedPrompts } : {},
|
|
429
|
+
...normalized.toolResult ? { toolResult: normalized.toolResult } : {},
|
|
430
|
+
capturedAtMs: now
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ../../packages/panopticon/src/observer/database.ts
|
|
435
|
+
import crypto2 from "crypto";
|
|
436
|
+
import fs2 from "fs";
|
|
437
|
+
import path2 from "path";
|
|
438
|
+
var ObserverHookSpoolEntryTooLargeError = class extends Error {
|
|
439
|
+
constructor(logicalBytes, maxBytes) {
|
|
440
|
+
super(
|
|
441
|
+
`Hook event requires ${logicalBytes} logical bytes, exceeding the configured ${maxBytes}-byte spool maximum`
|
|
442
|
+
);
|
|
443
|
+
this.logicalBytes = logicalBytes;
|
|
444
|
+
this.maxBytes = maxBytes;
|
|
445
|
+
this.name = "ObserverHookSpoolEntryTooLargeError";
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
var SCHEMA_VERSION = 10;
|
|
449
|
+
var MAX_SYNC_RUN_RECORDS = 1e4;
|
|
450
|
+
var DEFAULT_HOOK_OUTBOX_BATCH_SIZE = 100;
|
|
451
|
+
var MAX_HOOK_OUTBOX_BATCH_SIZE = 100;
|
|
452
|
+
var MAX_HOOK_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
|
453
|
+
var MAX_HOOK_EXTRACTED_FIELD_BYTES = 16 * 1024 * 1024;
|
|
454
|
+
var MAX_HOOK_SYNC_ID_BYTES = 1024;
|
|
455
|
+
var MAX_HOOK_SESSION_ID_BYTES = 4096;
|
|
456
|
+
var MAX_HOOK_EVENT_TYPE_BYTES = 1024;
|
|
457
|
+
var MAX_HOOK_TARGET_BYTES = 256;
|
|
458
|
+
var MAX_HOOK_REJECTION_REASON_BYTES = 4 * 1024;
|
|
459
|
+
var MAX_HOOK_DESTINATION_URL_BYTES = 4 * 1024;
|
|
460
|
+
var MAX_HOOK_DESTINATION_IDENTITY_BYTES = 4 * 1024;
|
|
461
|
+
var DEFAULT_OTLP_OUTBOX_BATCH_SIZE = 100;
|
|
462
|
+
var MAX_OTLP_OUTBOX_BATCH_SIZE = 100;
|
|
463
|
+
var MAX_OTLP_ENVELOPE_BYTES = 16 * 1024 * 1024;
|
|
464
|
+
var DEFAULT_OTLP_SPOOL_MAX_BYTES = 1024 * 1024 * 1024;
|
|
465
|
+
var MAX_OTLP_ENVELOPE_ID_BYTES = 1024;
|
|
466
|
+
var MAX_OTLP_OBSERVER_INSTANCE_ID_BYTES = 4 * 1024;
|
|
467
|
+
var MAX_OTLP_OBSERVER_VERSION_BYTES = 4 * 1024;
|
|
468
|
+
var MAX_OTLP_DROP_REASON_BYTES = 4 * 1024;
|
|
469
|
+
var MAX_OTLP_DESTINATION_ORGANIZATION_ID_BYTES = 128;
|
|
470
|
+
var MAX_OTLP_DESTINATION_ORGANIZATION_SLUG_BYTES = 4 * 1024;
|
|
471
|
+
var OWNERLESS_OTLP_DROP_REASON = "ownerless";
|
|
472
|
+
var OTLP_CAPACITY_DROP_REASON = "spool_capacity";
|
|
473
|
+
var OTLP_ENTRY_TOO_LARGE_DROP_REASON = "spool_entry_too_large";
|
|
474
|
+
var OTLP_ROW_FIXED_LOGICAL_BYTES = 256;
|
|
475
|
+
var MAX_TIMESTAMP_MS = 864e13;
|
|
476
|
+
var QUARANTINE_PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
|
|
477
|
+
var HOOK_OUTBOX_LOGICAL_BYTES_SQL = `
|
|
478
|
+
length(CAST(sync_id AS BLOB)) +
|
|
479
|
+
length(CAST(session_id AS BLOB)) +
|
|
480
|
+
length(CAST(event_type AS BLOB)) +
|
|
481
|
+
length(CAST(target AS BLOB)) +
|
|
482
|
+
length(CAST(payload_json AS BLOB)) +
|
|
483
|
+
length(CAST(COALESCE(cwd, '') AS BLOB)) +
|
|
484
|
+
length(CAST(COALESCE(repository, '') AS BLOB)) +
|
|
485
|
+
length(CAST(COALESCE(tool_name, '') AS BLOB)) +
|
|
486
|
+
length(CAST(COALESCE(user_prompt, '') AS BLOB)) +
|
|
487
|
+
length(CAST(COALESCE(file_path, '') AS BLOB)) +
|
|
488
|
+
length(CAST(COALESCE(command, '') AS BLOB)) +
|
|
489
|
+
length(CAST(COALESCE(plan, '') AS BLOB)) +
|
|
490
|
+
length(CAST(COALESCE(allowed_prompts, '') AS BLOB)) +
|
|
491
|
+
length(CAST(COALESCE(tool_result, '') AS BLOB)) +
|
|
492
|
+
length(CAST(COALESCE(destination_url, '') AS BLOB)) +
|
|
493
|
+
length(CAST(COALESCE(destination_identity, '') AS BLOB))
|
|
494
|
+
`;
|
|
495
|
+
var OTLP_LOGICAL_BYTES_SQL = `
|
|
496
|
+
${OTLP_ROW_FIXED_LOGICAL_BYTES} +
|
|
497
|
+
length(CAST(envelope_id AS BLOB)) +
|
|
498
|
+
length(CAST(observer_instance_id AS BLOB)) +
|
|
499
|
+
length(CAST(observer_version AS BLOB)) +
|
|
500
|
+
length(CAST(signal AS BLOB)) +
|
|
501
|
+
length(CAST(content_type AS BLOB)) +
|
|
502
|
+
length(CAST(content_encoding AS BLOB)) +
|
|
503
|
+
length(body) +
|
|
504
|
+
length(CAST(sha256 AS BLOB)) +
|
|
505
|
+
length(CAST(COALESCE(destination_url, '') AS BLOB)) +
|
|
506
|
+
length(CAST(COALESCE(destination_identity, '') AS BLOB)) +
|
|
507
|
+
CASE
|
|
508
|
+
WHEN destination_url IS NOT NULL AND destination_organization_id IS NULL
|
|
509
|
+
THEN ${MAX_OTLP_DESTINATION_ORGANIZATION_ID_BYTES}
|
|
510
|
+
ELSE length(CAST(COALESCE(destination_organization_id, '') AS BLOB))
|
|
511
|
+
END +
|
|
512
|
+
CASE
|
|
513
|
+
WHEN destination_url IS NOT NULL AND destination_organization_slug IS NULL
|
|
514
|
+
THEN ${MAX_OTLP_DESTINATION_ORGANIZATION_SLUG_BYTES}
|
|
515
|
+
ELSE length(CAST(COALESCE(destination_organization_slug, '') AS BLOB))
|
|
516
|
+
END
|
|
517
|
+
`;
|
|
518
|
+
var RECONCILE_HOOK_SPOOL_STATE_SQL = `
|
|
519
|
+
INSERT INTO hook_spool_state (
|
|
520
|
+
singleton,
|
|
521
|
+
logical_bytes,
|
|
522
|
+
evicted_records,
|
|
523
|
+
evicted_bytes,
|
|
524
|
+
evicted_pending_records,
|
|
525
|
+
evicted_quarantined_records,
|
|
526
|
+
last_evicted_at_ms
|
|
527
|
+
)
|
|
528
|
+
SELECT
|
|
529
|
+
1,
|
|
530
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM hook_event_outbox) +
|
|
531
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM hook_event_quarantine),
|
|
532
|
+
0,
|
|
533
|
+
0,
|
|
534
|
+
0,
|
|
535
|
+
0,
|
|
536
|
+
NULL
|
|
537
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
538
|
+
logical_bytes = excluded.logical_bytes,
|
|
539
|
+
evicted_records = CASE
|
|
540
|
+
WHEN typeof(hook_spool_state.evicted_records) = 'integer'
|
|
541
|
+
AND hook_spool_state.evicted_records >= 0
|
|
542
|
+
AND hook_spool_state.evicted_records <= 9007199254740991
|
|
543
|
+
THEN hook_spool_state.evicted_records ELSE 0 END,
|
|
544
|
+
evicted_bytes = CASE
|
|
545
|
+
WHEN typeof(hook_spool_state.evicted_bytes) = 'integer'
|
|
546
|
+
AND hook_spool_state.evicted_bytes >= 0
|
|
547
|
+
AND hook_spool_state.evicted_bytes <= 9007199254740991
|
|
548
|
+
THEN hook_spool_state.evicted_bytes ELSE 0 END,
|
|
549
|
+
evicted_pending_records = CASE
|
|
550
|
+
WHEN typeof(hook_spool_state.evicted_pending_records) = 'integer'
|
|
551
|
+
AND hook_spool_state.evicted_pending_records >= 0
|
|
552
|
+
AND hook_spool_state.evicted_pending_records <= 9007199254740991
|
|
553
|
+
THEN hook_spool_state.evicted_pending_records ELSE 0 END,
|
|
554
|
+
evicted_quarantined_records = CASE
|
|
555
|
+
WHEN typeof(hook_spool_state.evicted_quarantined_records) = 'integer'
|
|
556
|
+
AND hook_spool_state.evicted_quarantined_records >= 0
|
|
557
|
+
AND hook_spool_state.evicted_quarantined_records <= 9007199254740991
|
|
558
|
+
THEN hook_spool_state.evicted_quarantined_records ELSE 0 END,
|
|
559
|
+
last_evicted_at_ms = CASE
|
|
560
|
+
WHEN hook_spool_state.last_evicted_at_ms IS NULL THEN NULL
|
|
561
|
+
WHEN typeof(hook_spool_state.last_evicted_at_ms) = 'integer'
|
|
562
|
+
AND hook_spool_state.last_evicted_at_ms >= 0
|
|
563
|
+
AND hook_spool_state.last_evicted_at_ms <= 8640000000000000
|
|
564
|
+
THEN hook_spool_state.last_evicted_at_ms ELSE NULL END
|
|
565
|
+
`;
|
|
566
|
+
var ObserverHookSpoolStateInvariantError = class extends Error {
|
|
567
|
+
};
|
|
568
|
+
function jsonStringify(value, description) {
|
|
569
|
+
let serialized;
|
|
570
|
+
try {
|
|
571
|
+
serialized = JSON.stringify(value);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
throw new Error(`${description} must be JSON-serializable`, {
|
|
574
|
+
cause: error
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (serialized === void 0) {
|
|
578
|
+
throw new Error(`${description} must be JSON-serializable`);
|
|
579
|
+
}
|
|
580
|
+
return serialized;
|
|
581
|
+
}
|
|
582
|
+
function requireBoundedString(value, description, maxBytes) {
|
|
583
|
+
if (value.length === 0) {
|
|
584
|
+
throw new Error(`${description} must not be empty`);
|
|
585
|
+
}
|
|
586
|
+
if (Buffer.byteLength(value, "utf8") > maxBytes) {
|
|
587
|
+
throw new Error(`${description} exceeds ${maxBytes} bytes`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function validateOptionalHookField(value, description) {
|
|
591
|
+
if (value !== void 0 && Buffer.byteLength(value, "utf8") > MAX_HOOK_EXTRACTED_FIELD_BYTES) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`${description} exceeds ${MAX_HOOK_EXTRACTED_FIELD_BYTES} bytes`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function requireTimestamp(value, description) {
|
|
598
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
599
|
+
throw new Error(`${description} must be a non-negative safe integer`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function optionalString(value) {
|
|
603
|
+
return typeof value === "string" ? value : void 0;
|
|
604
|
+
}
|
|
605
|
+
function validateHookDestination(destination) {
|
|
606
|
+
requireBoundedString(
|
|
607
|
+
destination.targetUrl,
|
|
608
|
+
"Hook destination URL",
|
|
609
|
+
MAX_HOOK_DESTINATION_URL_BYTES
|
|
610
|
+
);
|
|
611
|
+
requireBoundedString(
|
|
612
|
+
destination.destinationIdentity,
|
|
613
|
+
"Hook destination identity",
|
|
614
|
+
MAX_HOOK_DESTINATION_IDENTITY_BYTES
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
function validateOtlpDestination(destination) {
|
|
618
|
+
validateHookDestination(destination);
|
|
619
|
+
if (destination.organizationId === void 0 !== (destination.organizationSlug === void 0)) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
"OTLP destination organization ID and slug must be provided together"
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
if (destination.organizationId !== void 0) {
|
|
625
|
+
requireBoundedString(
|
|
626
|
+
destination.organizationId,
|
|
627
|
+
"OTLP destination organization ID",
|
|
628
|
+
MAX_OTLP_DESTINATION_ORGANIZATION_ID_BYTES
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (destination.organizationSlug !== void 0) {
|
|
632
|
+
requireBoundedString(
|
|
633
|
+
destination.organizationSlug,
|
|
634
|
+
"OTLP destination organization slug",
|
|
635
|
+
MAX_OTLP_DESTINATION_ORGANIZATION_SLUG_BYTES
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function hookDestinationFromRow(row) {
|
|
640
|
+
const targetUrl = optionalString(row.destination_url);
|
|
641
|
+
const destinationIdentity = optionalString(row.destination_identity);
|
|
642
|
+
if (targetUrl === void 0 && destinationIdentity === void 0) {
|
|
643
|
+
return void 0;
|
|
644
|
+
}
|
|
645
|
+
if (targetUrl === void 0 || destinationIdentity === void 0) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
"FML observer hook outbox has a partial destination binding"
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
targetUrl,
|
|
652
|
+
destinationIdentity
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function otlpDestinationFromRow(row) {
|
|
656
|
+
const destination = hookDestinationFromRow(row);
|
|
657
|
+
if (destination === void 0) return void 0;
|
|
658
|
+
const organizationId = optionalString(row.destination_organization_id);
|
|
659
|
+
const organizationSlug = optionalString(row.destination_organization_slug);
|
|
660
|
+
if (organizationId === void 0 !== (organizationSlug === void 0)) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
"FML observer OTLP outbox has a partial organization binding"
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
...destination,
|
|
667
|
+
...organizationId === void 0 ? {} : { organizationId },
|
|
668
|
+
...organizationSlug === void 0 ? {} : { organizationSlug }
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function hookOutboxRecordFromRow(row) {
|
|
672
|
+
const destination = hookDestinationFromRow(row);
|
|
673
|
+
return {
|
|
674
|
+
id: row.id,
|
|
675
|
+
syncId: row.sync_id,
|
|
676
|
+
sessionId: row.session_id,
|
|
677
|
+
eventType: row.event_type,
|
|
678
|
+
timestampMs: row.timestamp_ms,
|
|
679
|
+
target: row.target,
|
|
680
|
+
payload: JSON.parse(row.payload_json),
|
|
681
|
+
cwd: optionalString(row.cwd),
|
|
682
|
+
repository: optionalString(row.repository),
|
|
683
|
+
toolName: optionalString(row.tool_name),
|
|
684
|
+
userPrompt: optionalString(row.user_prompt),
|
|
685
|
+
filePath: optionalString(row.file_path),
|
|
686
|
+
command: optionalString(row.command),
|
|
687
|
+
plan: optionalString(row.plan),
|
|
688
|
+
allowedPrompts: optionalString(row.allowed_prompts),
|
|
689
|
+
toolResult: optionalString(row.tool_result),
|
|
690
|
+
capturedAtMs: row.captured_at_ms,
|
|
691
|
+
retryCount: row.retry_count,
|
|
692
|
+
nextAttemptAtMs: row.next_attempt_at_ms,
|
|
693
|
+
...destination === void 0 ? {} : { destination }
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function utf8Bytes(value) {
|
|
697
|
+
return value === void 0 ? 0 : Buffer.byteLength(value, "utf8");
|
|
698
|
+
}
|
|
699
|
+
function hookOutboxLogicalBytes(envelope, payloadJson) {
|
|
700
|
+
return utf8Bytes(envelope.syncId) + utf8Bytes(envelope.sessionId) + utf8Bytes(envelope.eventType) + utf8Bytes(envelope.target) + utf8Bytes(payloadJson) + utf8Bytes(envelope.cwd) + utf8Bytes(envelope.repository) + utf8Bytes(envelope.toolName) + utf8Bytes(envelope.userPrompt) + utf8Bytes(envelope.filePath) + utf8Bytes(envelope.command) + utf8Bytes(envelope.plan) + utf8Bytes(envelope.allowedPrompts) + utf8Bytes(envelope.toolResult) + utf8Bytes(envelope.destination?.targetUrl) + utf8Bytes(envelope.destination?.destinationIdentity);
|
|
701
|
+
}
|
|
702
|
+
function otlpEnvelopeLogicalBytes(envelope, envelopeId) {
|
|
703
|
+
return OTLP_ROW_FIXED_LOGICAL_BYTES + envelope.body.byteLength + utf8Bytes(envelopeId) + utf8Bytes(envelope.observerInstanceId) + utf8Bytes(envelope.observerVersion) + utf8Bytes(envelope.signal) + utf8Bytes(envelope.contentType) + utf8Bytes(envelope.contentEncoding) + // The persisted digest is always lowercase hexadecimal SHA-256.
|
|
704
|
+
64 + utf8Bytes(envelope.destination?.targetUrl) + utf8Bytes(envelope.destination?.destinationIdentity) + (envelope.destination && envelope.destination.organizationId === void 0 ? MAX_OTLP_DESTINATION_ORGANIZATION_ID_BYTES : utf8Bytes(envelope.destination?.organizationId)) + (envelope.destination && envelope.destination.organizationSlug === void 0 ? MAX_OTLP_DESTINATION_ORGANIZATION_SLUG_BYTES : utf8Bytes(envelope.destination?.organizationSlug));
|
|
705
|
+
}
|
|
706
|
+
function requireOtlpSignal(value) {
|
|
707
|
+
if (value === "logs" || value === "metrics" || value === "traces") {
|
|
708
|
+
return value;
|
|
709
|
+
}
|
|
710
|
+
throw new Error("OTLP envelope signal must be logs, metrics, or traces");
|
|
711
|
+
}
|
|
712
|
+
function requireOtlpContentType(value) {
|
|
713
|
+
if (value === "application/json" || value === "application/x-protobuf") {
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
throw new Error(
|
|
717
|
+
"OTLP envelope content type must be application/json or application/x-protobuf"
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
function requireOtlpContentEncoding(value) {
|
|
721
|
+
if (value === "identity" || value === "gzip") return value;
|
|
722
|
+
throw new Error("OTLP envelope content encoding must be identity or gzip");
|
|
723
|
+
}
|
|
724
|
+
function otlpOutboxRecordFromRow(row) {
|
|
725
|
+
const rawBody = row.body;
|
|
726
|
+
if (!(rawBody instanceof Uint8Array)) {
|
|
727
|
+
throw new Error("FML observer OTLP outbox body is not a BLOB");
|
|
728
|
+
}
|
|
729
|
+
const destination = otlpDestinationFromRow(row);
|
|
730
|
+
return {
|
|
731
|
+
id: row.id,
|
|
732
|
+
envelopeId: row.envelope_id,
|
|
733
|
+
observerInstanceId: row.observer_instance_id,
|
|
734
|
+
observerVersion: row.observer_version,
|
|
735
|
+
signal: requireOtlpSignal(row.signal),
|
|
736
|
+
contentType: requireOtlpContentType(row.content_type),
|
|
737
|
+
contentEncoding: requireOtlpContentEncoding(row.content_encoding),
|
|
738
|
+
body: Buffer.from(rawBody),
|
|
739
|
+
sha256: row.sha256,
|
|
740
|
+
capturedAtMs: row.captured_at_ms,
|
|
741
|
+
retryCount: row.retry_count,
|
|
742
|
+
nextAttemptAtMs: row.next_attempt_at_ms,
|
|
743
|
+
...destination === void 0 ? {} : { destination }
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function validateHookBatchLimit(limit) {
|
|
747
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HOOK_OUTBOX_BATCH_SIZE) {
|
|
748
|
+
throw new Error(
|
|
749
|
+
`Hook outbox batch limit must be an integer from 1 to ${MAX_HOOK_OUTBOX_BATCH_SIZE}`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
function validateOtlpBatchLimit(limit) {
|
|
754
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_OTLP_OUTBOX_BATCH_SIZE) {
|
|
755
|
+
throw new Error(
|
|
756
|
+
`OTLP outbox batch limit must be an integer from 1 to ${MAX_OTLP_OUTBOX_BATCH_SIZE}`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
function enforceOwnerOnlyMode(filePath, mode) {
|
|
761
|
+
if (process.platform === "win32") return;
|
|
762
|
+
try {
|
|
763
|
+
fs2.chmodSync(filePath, mode);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
if (error.code !== "ENOENT") throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function openObserverDatabase(databaseFile, options = {}) {
|
|
769
|
+
const maxHookSpoolBytes = options.maxHookSpoolBytes;
|
|
770
|
+
const maxOtlpSpoolBytes = options.maxOtlpSpoolBytes ?? DEFAULT_OTLP_SPOOL_MAX_BYTES;
|
|
771
|
+
const maxHookQuarantineAgeMs = options.maxHookQuarantineAgeMs;
|
|
772
|
+
if (maxHookSpoolBytes !== void 0 && (!Number.isSafeInteger(maxHookSpoolBytes) || maxHookSpoolBytes < 1)) {
|
|
773
|
+
throw new Error(
|
|
774
|
+
"Hook spool maximum must be a positive safe integer number of bytes"
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (!Number.isSafeInteger(maxOtlpSpoolBytes) || maxOtlpSpoolBytes < 1) {
|
|
778
|
+
throw new Error(
|
|
779
|
+
"OTLP spool maximum must be a positive safe integer number of bytes"
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
if (maxHookQuarantineAgeMs !== void 0 && (!Number.isSafeInteger(maxHookQuarantineAgeMs) || maxHookQuarantineAgeMs < 1)) {
|
|
783
|
+
throw new Error(
|
|
784
|
+
"Hook quarantine maximum age must be a positive safe integer number of milliseconds"
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
const getBuiltinModule = process.getBuiltinModule;
|
|
788
|
+
const sqlite = getBuiltinModule?.("node:sqlite");
|
|
789
|
+
if (!sqlite?.DatabaseSync) {
|
|
790
|
+
throw new Error("FML observer requires the Node.js built-in SQLite module");
|
|
791
|
+
}
|
|
792
|
+
const databaseDirectory = path2.dirname(databaseFile);
|
|
793
|
+
fs2.mkdirSync(databaseDirectory, { recursive: true, mode: 448 });
|
|
794
|
+
if (options.restrictDatabaseDirectory !== false) {
|
|
795
|
+
enforceOwnerOnlyMode(databaseDirectory, 448);
|
|
796
|
+
}
|
|
797
|
+
const database = new sqlite.DatabaseSync(databaseFile);
|
|
798
|
+
enforceOwnerOnlyMode(databaseFile, 384);
|
|
799
|
+
try {
|
|
800
|
+
database.exec(`
|
|
801
|
+
PRAGMA busy_timeout = 5000;
|
|
802
|
+
PRAGMA journal_mode = WAL;
|
|
803
|
+
-- Inbox files and OTLP requests are acknowledged immediately after a
|
|
804
|
+
-- database commit. FULL syncs each WAL commit before it can become the
|
|
805
|
+
-- sole durable copy.
|
|
806
|
+
PRAGMA synchronous = FULL;
|
|
807
|
+
`);
|
|
808
|
+
const readSchemaVersion = () => {
|
|
809
|
+
const version2 = database.prepare("PRAGMA user_version").get()?.user_version;
|
|
810
|
+
if (typeof version2 !== "number" || !Number.isInteger(version2)) {
|
|
811
|
+
throw new Error("FML observer database has an invalid schema version");
|
|
812
|
+
}
|
|
813
|
+
return version2;
|
|
814
|
+
};
|
|
815
|
+
const version = readSchemaVersion();
|
|
816
|
+
if (version > SCHEMA_VERSION) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`FML observer database schema ${version} is newer than supported schema ${SCHEMA_VERSION}`
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const runMigration = (targetVersion, sql) => {
|
|
822
|
+
database.exec("BEGIN IMMEDIATE");
|
|
823
|
+
try {
|
|
824
|
+
const lockedVersion = readSchemaVersion();
|
|
825
|
+
if (lockedVersion > SCHEMA_VERSION) {
|
|
826
|
+
throw new Error(
|
|
827
|
+
`FML observer database schema ${lockedVersion} is newer than supported schema ${SCHEMA_VERSION}`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
if (lockedVersion < targetVersion) {
|
|
831
|
+
database.exec(typeof sql === "string" ? sql : sql());
|
|
832
|
+
database.exec(`PRAGMA user_version = ${targetVersion}`);
|
|
833
|
+
}
|
|
834
|
+
database.exec("COMMIT");
|
|
835
|
+
} catch (error) {
|
|
836
|
+
try {
|
|
837
|
+
database.exec("ROLLBACK");
|
|
838
|
+
} catch {
|
|
839
|
+
}
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
if (version < 1) {
|
|
844
|
+
runMigration(
|
|
845
|
+
1,
|
|
846
|
+
`
|
|
847
|
+
CREATE TABLE IF NOT EXISTS runtime_state (
|
|
848
|
+
key TEXT PRIMARY KEY,
|
|
849
|
+
value_json TEXT NOT NULL,
|
|
850
|
+
updated_at_ms INTEGER NOT NULL
|
|
851
|
+
);
|
|
852
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
853
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
854
|
+
started_at_ms INTEGER NOT NULL,
|
|
855
|
+
completed_at_ms INTEGER NOT NULL,
|
|
856
|
+
environment TEXT,
|
|
857
|
+
outcome TEXT NOT NULL,
|
|
858
|
+
uploaded_bytes INTEGER NOT NULL,
|
|
859
|
+
failed INTEGER NOT NULL,
|
|
860
|
+
error TEXT
|
|
861
|
+
);
|
|
862
|
+
CREATE INDEX IF NOT EXISTS sync_runs_completed_at
|
|
863
|
+
ON sync_runs(completed_at_ms DESC);
|
|
864
|
+
`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
if (version < 2) {
|
|
868
|
+
runMigration(
|
|
869
|
+
2,
|
|
870
|
+
`
|
|
871
|
+
CREATE TABLE hook_event_outbox (
|
|
872
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
873
|
+
sync_id TEXT NOT NULL UNIQUE,
|
|
874
|
+
session_id TEXT NOT NULL,
|
|
875
|
+
event_type TEXT NOT NULL,
|
|
876
|
+
timestamp_ms INTEGER NOT NULL,
|
|
877
|
+
target TEXT NOT NULL,
|
|
878
|
+
payload_json TEXT NOT NULL,
|
|
879
|
+
cwd TEXT,
|
|
880
|
+
repository TEXT,
|
|
881
|
+
tool_name TEXT,
|
|
882
|
+
user_prompt TEXT,
|
|
883
|
+
file_path TEXT,
|
|
884
|
+
command TEXT,
|
|
885
|
+
plan TEXT,
|
|
886
|
+
allowed_prompts TEXT,
|
|
887
|
+
tool_result TEXT,
|
|
888
|
+
captured_at_ms INTEGER NOT NULL
|
|
889
|
+
);
|
|
890
|
+
CREATE INDEX hook_event_outbox_captured_at
|
|
891
|
+
ON hook_event_outbox(captured_at_ms, id);
|
|
892
|
+
`
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
if (version < 3) {
|
|
896
|
+
runMigration(
|
|
897
|
+
3,
|
|
898
|
+
`
|
|
899
|
+
CREATE TABLE hook_event_quarantine (
|
|
900
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
901
|
+
outbox_id INTEGER NOT NULL,
|
|
902
|
+
sync_id TEXT NOT NULL UNIQUE,
|
|
903
|
+
session_id TEXT NOT NULL,
|
|
904
|
+
event_type TEXT NOT NULL,
|
|
905
|
+
timestamp_ms INTEGER NOT NULL,
|
|
906
|
+
target TEXT NOT NULL,
|
|
907
|
+
payload_json TEXT NOT NULL,
|
|
908
|
+
cwd TEXT,
|
|
909
|
+
repository TEXT,
|
|
910
|
+
tool_name TEXT,
|
|
911
|
+
user_prompt TEXT,
|
|
912
|
+
file_path TEXT,
|
|
913
|
+
command TEXT,
|
|
914
|
+
plan TEXT,
|
|
915
|
+
allowed_prompts TEXT,
|
|
916
|
+
tool_result TEXT,
|
|
917
|
+
captured_at_ms INTEGER NOT NULL,
|
|
918
|
+
rejection_reason TEXT NOT NULL,
|
|
919
|
+
quarantined_at_ms INTEGER NOT NULL
|
|
920
|
+
);
|
|
921
|
+
CREATE INDEX hook_event_quarantine_quarantined_at
|
|
922
|
+
ON hook_event_quarantine(quarantined_at_ms DESC, id DESC);
|
|
923
|
+
`
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
if (version < 4) {
|
|
927
|
+
runMigration(
|
|
928
|
+
4,
|
|
929
|
+
`
|
|
930
|
+
ALTER TABLE hook_event_outbox ADD COLUMN destination_url TEXT;
|
|
931
|
+
ALTER TABLE hook_event_outbox ADD COLUMN destination_organization TEXT;
|
|
932
|
+
ALTER TABLE hook_event_outbox ADD COLUMN destination_identity TEXT;
|
|
933
|
+
CREATE INDEX hook_event_outbox_destination
|
|
934
|
+
ON hook_event_outbox(
|
|
935
|
+
destination_identity,
|
|
936
|
+
destination_url,
|
|
937
|
+
destination_organization,
|
|
938
|
+
id
|
|
939
|
+
);
|
|
940
|
+
ALTER TABLE hook_event_quarantine ADD COLUMN destination_url TEXT;
|
|
941
|
+
ALTER TABLE hook_event_quarantine ADD COLUMN destination_organization TEXT;
|
|
942
|
+
ALTER TABLE hook_event_quarantine ADD COLUMN destination_identity TEXT;
|
|
943
|
+
`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
if (version < 5) {
|
|
947
|
+
runMigration(
|
|
948
|
+
5,
|
|
949
|
+
`
|
|
950
|
+
DROP INDEX hook_event_outbox_destination;
|
|
951
|
+
ALTER TABLE hook_event_outbox DROP COLUMN destination_organization;
|
|
952
|
+
ALTER TABLE hook_event_quarantine DROP COLUMN destination_organization;
|
|
953
|
+
CREATE INDEX hook_event_outbox_destination
|
|
954
|
+
ON hook_event_outbox(destination_identity, destination_url, id);
|
|
955
|
+
`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (version < 6) {
|
|
959
|
+
runMigration(
|
|
960
|
+
6,
|
|
961
|
+
`
|
|
962
|
+
ALTER TABLE hook_event_outbox
|
|
963
|
+
ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0;
|
|
964
|
+
ALTER TABLE hook_event_outbox
|
|
965
|
+
ADD COLUMN next_attempt_at_ms INTEGER NOT NULL DEFAULT 0;
|
|
966
|
+
ALTER TABLE hook_event_outbox
|
|
967
|
+
ADD COLUMN logical_bytes INTEGER NOT NULL DEFAULT 0;
|
|
968
|
+
UPDATE hook_event_outbox
|
|
969
|
+
SET logical_bytes = ${HOOK_OUTBOX_LOGICAL_BYTES_SQL};
|
|
970
|
+
CREATE INDEX IF NOT EXISTS hook_event_outbox_attempt
|
|
971
|
+
ON hook_event_outbox(next_attempt_at_ms, id);
|
|
972
|
+
|
|
973
|
+
ALTER TABLE hook_event_quarantine
|
|
974
|
+
ADD COLUMN logical_bytes INTEGER NOT NULL DEFAULT 0;
|
|
975
|
+
UPDATE hook_event_quarantine
|
|
976
|
+
SET logical_bytes =
|
|
977
|
+
${HOOK_OUTBOX_LOGICAL_BYTES_SQL} +
|
|
978
|
+
length(CAST(rejection_reason AS BLOB));
|
|
979
|
+
|
|
980
|
+
CREATE TABLE IF NOT EXISTS hook_spool_state (
|
|
981
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
982
|
+
logical_bytes INTEGER NOT NULL,
|
|
983
|
+
evicted_records INTEGER NOT NULL,
|
|
984
|
+
evicted_bytes INTEGER NOT NULL,
|
|
985
|
+
evicted_pending_records INTEGER NOT NULL,
|
|
986
|
+
evicted_quarantined_records INTEGER NOT NULL,
|
|
987
|
+
last_evicted_at_ms INTEGER
|
|
988
|
+
);
|
|
989
|
+
INSERT OR IGNORE INTO hook_spool_state (
|
|
990
|
+
singleton,
|
|
991
|
+
logical_bytes,
|
|
992
|
+
evicted_records,
|
|
993
|
+
evicted_bytes,
|
|
994
|
+
evicted_pending_records,
|
|
995
|
+
evicted_quarantined_records,
|
|
996
|
+
last_evicted_at_ms
|
|
997
|
+
)
|
|
998
|
+
SELECT
|
|
999
|
+
1,
|
|
1000
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM hook_event_outbox) +
|
|
1001
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM hook_event_quarantine),
|
|
1002
|
+
0,
|
|
1003
|
+
0,
|
|
1004
|
+
0,
|
|
1005
|
+
0,
|
|
1006
|
+
NULL;
|
|
1007
|
+
`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
if (version < 7) {
|
|
1011
|
+
runMigration(
|
|
1012
|
+
7,
|
|
1013
|
+
`
|
|
1014
|
+
CREATE INDEX IF NOT EXISTS hook_event_outbox_destination_session
|
|
1015
|
+
ON hook_event_outbox(
|
|
1016
|
+
destination_identity,
|
|
1017
|
+
destination_url,
|
|
1018
|
+
session_id,
|
|
1019
|
+
id
|
|
1020
|
+
);
|
|
1021
|
+
`
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
if (version < 9) {
|
|
1025
|
+
runMigration(9, () => {
|
|
1026
|
+
database.exec(`
|
|
1027
|
+
CREATE INDEX IF NOT EXISTS hook_event_outbox_destination_session
|
|
1028
|
+
ON hook_event_outbox(
|
|
1029
|
+
destination_identity,
|
|
1030
|
+
destination_url,
|
|
1031
|
+
session_id,
|
|
1032
|
+
id
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
CREATE TABLE IF NOT EXISTS otlp_envelope_outbox (
|
|
1036
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1037
|
+
envelope_id TEXT NOT NULL UNIQUE,
|
|
1038
|
+
observer_instance_id TEXT NOT NULL,
|
|
1039
|
+
observer_version TEXT NOT NULL,
|
|
1040
|
+
signal TEXT NOT NULL CHECK (signal IN ('logs', 'metrics', 'traces')),
|
|
1041
|
+
content_type TEXT NOT NULL CHECK (
|
|
1042
|
+
content_type IN ('application/json', 'application/x-protobuf')
|
|
1043
|
+
),
|
|
1044
|
+
content_encoding TEXT NOT NULL CHECK (
|
|
1045
|
+
content_encoding IN ('identity', 'gzip')
|
|
1046
|
+
),
|
|
1047
|
+
body BLOB NOT NULL,
|
|
1048
|
+
sha256 TEXT NOT NULL,
|
|
1049
|
+
captured_at_ms INTEGER NOT NULL,
|
|
1050
|
+
destination_url TEXT,
|
|
1051
|
+
destination_identity TEXT,
|
|
1052
|
+
destination_organization_id TEXT,
|
|
1053
|
+
destination_organization_slug TEXT,
|
|
1054
|
+
CHECK (
|
|
1055
|
+
(
|
|
1056
|
+
destination_url IS NULL
|
|
1057
|
+
AND destination_identity IS NULL
|
|
1058
|
+
AND destination_organization_id IS NULL
|
|
1059
|
+
AND destination_organization_slug IS NULL
|
|
1060
|
+
)
|
|
1061
|
+
OR
|
|
1062
|
+
(
|
|
1063
|
+
destination_url IS NOT NULL
|
|
1064
|
+
AND destination_identity IS NOT NULL
|
|
1065
|
+
AND (
|
|
1066
|
+
(
|
|
1067
|
+
destination_organization_id IS NULL
|
|
1068
|
+
AND destination_organization_slug IS NULL
|
|
1069
|
+
)
|
|
1070
|
+
OR
|
|
1071
|
+
(
|
|
1072
|
+
destination_organization_id IS NOT NULL
|
|
1073
|
+
AND destination_organization_slug IS NOT NULL
|
|
1074
|
+
)
|
|
1075
|
+
)
|
|
1076
|
+
)
|
|
1077
|
+
)
|
|
1078
|
+
);
|
|
1079
|
+
CREATE INDEX IF NOT EXISTS otlp_envelope_outbox_destination
|
|
1080
|
+
ON otlp_envelope_outbox(
|
|
1081
|
+
destination_identity,
|
|
1082
|
+
destination_url,
|
|
1083
|
+
destination_organization_id,
|
|
1084
|
+
id
|
|
1085
|
+
);
|
|
1086
|
+
CREATE INDEX IF NOT EXISTS otlp_envelope_outbox_captured_at
|
|
1087
|
+
ON otlp_envelope_outbox(captured_at_ms, id);
|
|
1088
|
+
CREATE TABLE IF NOT EXISTS otlp_envelope_quarantine (
|
|
1089
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1090
|
+
outbox_id INTEGER NOT NULL,
|
|
1091
|
+
envelope_id TEXT NOT NULL UNIQUE,
|
|
1092
|
+
observer_instance_id TEXT NOT NULL,
|
|
1093
|
+
observer_version TEXT NOT NULL,
|
|
1094
|
+
signal TEXT NOT NULL CHECK (signal IN ('logs', 'metrics', 'traces')),
|
|
1095
|
+
content_type TEXT NOT NULL CHECK (
|
|
1096
|
+
content_type IN ('application/json', 'application/x-protobuf')
|
|
1097
|
+
),
|
|
1098
|
+
content_encoding TEXT NOT NULL CHECK (
|
|
1099
|
+
content_encoding IN ('identity', 'gzip')
|
|
1100
|
+
),
|
|
1101
|
+
body BLOB NOT NULL,
|
|
1102
|
+
sha256 TEXT NOT NULL,
|
|
1103
|
+
captured_at_ms INTEGER NOT NULL,
|
|
1104
|
+
destination_url TEXT,
|
|
1105
|
+
destination_identity TEXT,
|
|
1106
|
+
destination_organization_id TEXT,
|
|
1107
|
+
destination_organization_slug TEXT,
|
|
1108
|
+
rejection_reason TEXT NOT NULL,
|
|
1109
|
+
quarantined_at_ms INTEGER NOT NULL,
|
|
1110
|
+
CHECK (
|
|
1111
|
+
(
|
|
1112
|
+
destination_url IS NULL
|
|
1113
|
+
AND destination_identity IS NULL
|
|
1114
|
+
AND destination_organization_id IS NULL
|
|
1115
|
+
AND destination_organization_slug IS NULL
|
|
1116
|
+
)
|
|
1117
|
+
OR
|
|
1118
|
+
(
|
|
1119
|
+
destination_url IS NOT NULL
|
|
1120
|
+
AND destination_identity IS NOT NULL
|
|
1121
|
+
AND (
|
|
1122
|
+
(
|
|
1123
|
+
destination_organization_id IS NULL
|
|
1124
|
+
AND destination_organization_slug IS NULL
|
|
1125
|
+
)
|
|
1126
|
+
OR
|
|
1127
|
+
(
|
|
1128
|
+
destination_organization_id IS NOT NULL
|
|
1129
|
+
AND destination_organization_slug IS NOT NULL
|
|
1130
|
+
)
|
|
1131
|
+
)
|
|
1132
|
+
)
|
|
1133
|
+
)
|
|
1134
|
+
);
|
|
1135
|
+
CREATE INDEX IF NOT EXISTS otlp_envelope_quarantine_quarantined_at
|
|
1136
|
+
ON otlp_envelope_quarantine(quarantined_at_ms DESC, id DESC);
|
|
1137
|
+
`);
|
|
1138
|
+
const otlpOutboxColumns = new Set(
|
|
1139
|
+
database.prepare("PRAGMA table_info(otlp_envelope_outbox)").all().flatMap((row) => typeof row.name === "string" ? [row.name] : [])
|
|
1140
|
+
);
|
|
1141
|
+
const otlpQuarantineColumns = new Set(
|
|
1142
|
+
database.prepare("PRAGMA table_info(otlp_envelope_quarantine)").all().flatMap((row) => typeof row.name === "string" ? [row.name] : [])
|
|
1143
|
+
);
|
|
1144
|
+
return `
|
|
1145
|
+
${otlpOutboxColumns.has("retry_count") ? "" : `ALTER TABLE otlp_envelope_outbox
|
|
1146
|
+
ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0;`}
|
|
1147
|
+
${otlpOutboxColumns.has("next_attempt_at_ms") ? "" : `ALTER TABLE otlp_envelope_outbox
|
|
1148
|
+
ADD COLUMN next_attempt_at_ms INTEGER NOT NULL DEFAULT 0;`}
|
|
1149
|
+
${otlpOutboxColumns.has("logical_bytes") ? "" : `ALTER TABLE otlp_envelope_outbox
|
|
1150
|
+
ADD COLUMN logical_bytes INTEGER NOT NULL DEFAULT 0;`}
|
|
1151
|
+
UPDATE otlp_envelope_outbox
|
|
1152
|
+
SET logical_bytes = ${OTLP_LOGICAL_BYTES_SQL};
|
|
1153
|
+
CREATE INDEX IF NOT EXISTS otlp_envelope_outbox_attempt
|
|
1154
|
+
ON otlp_envelope_outbox(
|
|
1155
|
+
destination_identity,
|
|
1156
|
+
destination_url,
|
|
1157
|
+
destination_organization_id,
|
|
1158
|
+
next_attempt_at_ms,
|
|
1159
|
+
id
|
|
1160
|
+
);
|
|
1161
|
+
${otlpQuarantineColumns.has("logical_bytes") ? "" : `ALTER TABLE otlp_envelope_quarantine
|
|
1162
|
+
ADD COLUMN logical_bytes INTEGER NOT NULL DEFAULT 0;`}
|
|
1163
|
+
UPDATE otlp_envelope_quarantine
|
|
1164
|
+
SET logical_bytes = ${OTLP_LOGICAL_BYTES_SQL};
|
|
1165
|
+
|
|
1166
|
+
CREATE TABLE IF NOT EXISTS otlp_spool_state (
|
|
1167
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
1168
|
+
logical_bytes INTEGER NOT NULL CHECK (logical_bytes >= 0),
|
|
1169
|
+
body_bytes INTEGER NOT NULL CHECK (body_bytes >= 0),
|
|
1170
|
+
quarantined_ownerless_records INTEGER NOT NULL,
|
|
1171
|
+
quarantined_ownerless_logical_bytes INTEGER NOT NULL,
|
|
1172
|
+
quarantined_ownerless_body_bytes INTEGER NOT NULL,
|
|
1173
|
+
last_quarantined_ownerless_at_ms INTEGER
|
|
1174
|
+
);
|
|
1175
|
+
INSERT OR IGNORE INTO otlp_spool_state (
|
|
1176
|
+
singleton,
|
|
1177
|
+
logical_bytes,
|
|
1178
|
+
body_bytes,
|
|
1179
|
+
quarantined_ownerless_records,
|
|
1180
|
+
quarantined_ownerless_logical_bytes,
|
|
1181
|
+
quarantined_ownerless_body_bytes,
|
|
1182
|
+
last_quarantined_ownerless_at_ms
|
|
1183
|
+
) VALUES (
|
|
1184
|
+
1,
|
|
1185
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM otlp_envelope_outbox) +
|
|
1186
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM otlp_envelope_quarantine),
|
|
1187
|
+
(SELECT COALESCE(SUM(length(body)), 0) FROM otlp_envelope_outbox) +
|
|
1188
|
+
(SELECT COALESCE(SUM(length(body)), 0) FROM otlp_envelope_quarantine),
|
|
1189
|
+
0,
|
|
1190
|
+
0,
|
|
1191
|
+
0,
|
|
1192
|
+
NULL
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
DROP TRIGGER IF EXISTS otlp_outbox_body_bytes_insert;
|
|
1196
|
+
DROP TRIGGER IF EXISTS otlp_outbox_body_bytes_delete;
|
|
1197
|
+
DROP TRIGGER IF EXISTS otlp_quarantine_body_bytes_insert;
|
|
1198
|
+
DROP TRIGGER IF EXISTS otlp_quarantine_body_bytes_delete;
|
|
1199
|
+
|
|
1200
|
+
CREATE TRIGGER otlp_outbox_body_bytes_insert
|
|
1201
|
+
AFTER INSERT ON otlp_envelope_outbox
|
|
1202
|
+
BEGIN
|
|
1203
|
+
UPDATE otlp_spool_state
|
|
1204
|
+
SET
|
|
1205
|
+
logical_bytes = logical_bytes + NEW.logical_bytes,
|
|
1206
|
+
body_bytes = body_bytes + length(NEW.body)
|
|
1207
|
+
WHERE singleton = 1;
|
|
1208
|
+
END;
|
|
1209
|
+
CREATE TRIGGER otlp_outbox_body_bytes_delete
|
|
1210
|
+
AFTER DELETE ON otlp_envelope_outbox
|
|
1211
|
+
BEGIN
|
|
1212
|
+
UPDATE otlp_spool_state
|
|
1213
|
+
SET
|
|
1214
|
+
logical_bytes = logical_bytes - OLD.logical_bytes,
|
|
1215
|
+
body_bytes = body_bytes - length(OLD.body)
|
|
1216
|
+
WHERE singleton = 1;
|
|
1217
|
+
END;
|
|
1218
|
+
CREATE TRIGGER otlp_quarantine_body_bytes_insert
|
|
1219
|
+
AFTER INSERT ON otlp_envelope_quarantine
|
|
1220
|
+
BEGIN
|
|
1221
|
+
UPDATE otlp_spool_state
|
|
1222
|
+
SET
|
|
1223
|
+
logical_bytes = logical_bytes + NEW.logical_bytes,
|
|
1224
|
+
body_bytes = body_bytes + length(NEW.body)
|
|
1225
|
+
WHERE singleton = 1;
|
|
1226
|
+
END;
|
|
1227
|
+
CREATE TRIGGER otlp_quarantine_body_bytes_delete
|
|
1228
|
+
AFTER DELETE ON otlp_envelope_quarantine
|
|
1229
|
+
BEGIN
|
|
1230
|
+
UPDATE otlp_spool_state
|
|
1231
|
+
SET
|
|
1232
|
+
logical_bytes = logical_bytes - OLD.logical_bytes,
|
|
1233
|
+
body_bytes = body_bytes - length(OLD.body)
|
|
1234
|
+
WHERE singleton = 1;
|
|
1235
|
+
END;
|
|
1236
|
+
|
|
1237
|
+
`;
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
if (version < 10) {
|
|
1241
|
+
runMigration(
|
|
1242
|
+
10,
|
|
1243
|
+
`
|
|
1244
|
+
-- OTLP is operational telemetry, not a lossless artifact. Collapse the
|
|
1245
|
+
-- prototype quarantine into durable drop accounting and retain only a
|
|
1246
|
+
-- bounded pending spool.
|
|
1247
|
+
DROP TRIGGER IF EXISTS otlp_outbox_body_bytes_insert;
|
|
1248
|
+
DROP TRIGGER IF EXISTS otlp_outbox_body_bytes_delete;
|
|
1249
|
+
DROP TRIGGER IF EXISTS otlp_quarantine_body_bytes_insert;
|
|
1250
|
+
DROP TRIGGER IF EXISTS otlp_quarantine_body_bytes_delete;
|
|
1251
|
+
|
|
1252
|
+
UPDATE otlp_envelope_outbox
|
|
1253
|
+
SET logical_bytes = ${OTLP_LOGICAL_BYTES_SQL};
|
|
1254
|
+
|
|
1255
|
+
CREATE TABLE otlp_spool_state_v10 (
|
|
1256
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
1257
|
+
logical_bytes INTEGER NOT NULL CHECK (logical_bytes >= 0),
|
|
1258
|
+
body_bytes INTEGER NOT NULL CHECK (body_bytes >= 0),
|
|
1259
|
+
dropped_records INTEGER NOT NULL CHECK (dropped_records >= 0),
|
|
1260
|
+
dropped_logical_bytes INTEGER NOT NULL CHECK (dropped_logical_bytes >= 0),
|
|
1261
|
+
dropped_body_bytes INTEGER NOT NULL CHECK (dropped_body_bytes >= 0),
|
|
1262
|
+
last_dropped_at_ms INTEGER,
|
|
1263
|
+
last_drop_reason TEXT
|
|
1264
|
+
);
|
|
1265
|
+
INSERT INTO otlp_spool_state_v10 (
|
|
1266
|
+
singleton,
|
|
1267
|
+
logical_bytes,
|
|
1268
|
+
body_bytes,
|
|
1269
|
+
dropped_records,
|
|
1270
|
+
dropped_logical_bytes,
|
|
1271
|
+
dropped_body_bytes,
|
|
1272
|
+
last_dropped_at_ms,
|
|
1273
|
+
last_drop_reason
|
|
1274
|
+
)
|
|
1275
|
+
SELECT
|
|
1276
|
+
1,
|
|
1277
|
+
COALESCE((
|
|
1278
|
+
SELECT SUM(logical_bytes)
|
|
1279
|
+
FROM otlp_envelope_outbox
|
|
1280
|
+
WHERE destination_url IS NOT NULL
|
|
1281
|
+
), 0),
|
|
1282
|
+
COALESCE((
|
|
1283
|
+
SELECT SUM(length(body))
|
|
1284
|
+
FROM otlp_envelope_outbox
|
|
1285
|
+
WHERE destination_url IS NOT NULL
|
|
1286
|
+
), 0),
|
|
1287
|
+
quarantined_ownerless_records +
|
|
1288
|
+
(SELECT COUNT(*) FROM otlp_envelope_quarantine
|
|
1289
|
+
WHERE rejection_reason <> 'ownerless_ttl_expired') +
|
|
1290
|
+
(SELECT COUNT(*) FROM otlp_envelope_outbox
|
|
1291
|
+
WHERE destination_url IS NULL),
|
|
1292
|
+
quarantined_ownerless_logical_bytes +
|
|
1293
|
+
COALESCE((SELECT SUM(logical_bytes) FROM otlp_envelope_quarantine
|
|
1294
|
+
WHERE rejection_reason <> 'ownerless_ttl_expired'), 0) +
|
|
1295
|
+
COALESCE((SELECT SUM(logical_bytes) FROM otlp_envelope_outbox
|
|
1296
|
+
WHERE destination_url IS NULL), 0),
|
|
1297
|
+
quarantined_ownerless_body_bytes +
|
|
1298
|
+
COALESCE((SELECT SUM(length(body)) FROM otlp_envelope_quarantine
|
|
1299
|
+
WHERE rejection_reason <> 'ownerless_ttl_expired'), 0) +
|
|
1300
|
+
COALESCE((SELECT SUM(length(body)) FROM otlp_envelope_outbox
|
|
1301
|
+
WHERE destination_url IS NULL), 0),
|
|
1302
|
+
(
|
|
1303
|
+
SELECT MAX(timestamp_ms)
|
|
1304
|
+
FROM (
|
|
1305
|
+
SELECT last_quarantined_ownerless_at_ms AS timestamp_ms
|
|
1306
|
+
UNION ALL
|
|
1307
|
+
SELECT MAX(quarantined_at_ms) FROM otlp_envelope_quarantine
|
|
1308
|
+
UNION ALL
|
|
1309
|
+
SELECT MAX(captured_at_ms) FROM otlp_envelope_outbox
|
|
1310
|
+
WHERE destination_url IS NULL
|
|
1311
|
+
)
|
|
1312
|
+
),
|
|
1313
|
+
CASE
|
|
1314
|
+
WHEN quarantined_ownerless_records > 0
|
|
1315
|
+
OR EXISTS (SELECT 1 FROM otlp_envelope_quarantine)
|
|
1316
|
+
OR EXISTS (SELECT 1 FROM otlp_envelope_outbox
|
|
1317
|
+
WHERE destination_url IS NULL)
|
|
1318
|
+
THEN 'migration_cleanup'
|
|
1319
|
+
ELSE NULL
|
|
1320
|
+
END
|
|
1321
|
+
FROM otlp_spool_state
|
|
1322
|
+
WHERE singleton = 1;
|
|
1323
|
+
|
|
1324
|
+
DROP TABLE otlp_spool_state;
|
|
1325
|
+
ALTER TABLE otlp_spool_state_v10 RENAME TO otlp_spool_state;
|
|
1326
|
+
DELETE FROM otlp_envelope_outbox WHERE destination_url IS NULL;
|
|
1327
|
+
DROP TABLE otlp_envelope_quarantine;
|
|
1328
|
+
|
|
1329
|
+
CREATE TRIGGER otlp_outbox_body_bytes_insert
|
|
1330
|
+
AFTER INSERT ON otlp_envelope_outbox
|
|
1331
|
+
BEGIN
|
|
1332
|
+
UPDATE otlp_spool_state
|
|
1333
|
+
SET
|
|
1334
|
+
logical_bytes = logical_bytes + NEW.logical_bytes,
|
|
1335
|
+
body_bytes = body_bytes + length(NEW.body)
|
|
1336
|
+
WHERE singleton = 1;
|
|
1337
|
+
END;
|
|
1338
|
+
CREATE TRIGGER otlp_outbox_body_bytes_delete
|
|
1339
|
+
AFTER DELETE ON otlp_envelope_outbox
|
|
1340
|
+
BEGIN
|
|
1341
|
+
UPDATE otlp_spool_state
|
|
1342
|
+
SET
|
|
1343
|
+
logical_bytes = logical_bytes - OLD.logical_bytes,
|
|
1344
|
+
body_bytes = body_bytes - length(OLD.body)
|
|
1345
|
+
WHERE singleton = 1;
|
|
1346
|
+
END;
|
|
1347
|
+
`
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
database.exec(`
|
|
1351
|
+
BEGIN IMMEDIATE;
|
|
1352
|
+
${RECONCILE_HOOK_SPOOL_STATE_SQL};
|
|
1353
|
+
INSERT OR IGNORE INTO otlp_spool_state (
|
|
1354
|
+
singleton,
|
|
1355
|
+
logical_bytes,
|
|
1356
|
+
body_bytes,
|
|
1357
|
+
dropped_records,
|
|
1358
|
+
dropped_logical_bytes,
|
|
1359
|
+
dropped_body_bytes,
|
|
1360
|
+
last_dropped_at_ms,
|
|
1361
|
+
last_drop_reason
|
|
1362
|
+
) VALUES (1, 0, 0, 0, 0, 0, NULL, NULL);
|
|
1363
|
+
UPDATE otlp_spool_state
|
|
1364
|
+
SET
|
|
1365
|
+
logical_bytes =
|
|
1366
|
+
(SELECT COALESCE(SUM(logical_bytes), 0) FROM otlp_envelope_outbox),
|
|
1367
|
+
body_bytes =
|
|
1368
|
+
(SELECT COALESCE(SUM(length(body)), 0) FROM otlp_envelope_outbox)
|
|
1369
|
+
WHERE singleton = 1;
|
|
1370
|
+
COMMIT;
|
|
1371
|
+
`);
|
|
1372
|
+
const setRuntimeState = database.prepare(`
|
|
1373
|
+
INSERT INTO runtime_state (key, value_json, updated_at_ms)
|
|
1374
|
+
VALUES (?, ?, ?)
|
|
1375
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
1376
|
+
value_json = excluded.value_json,
|
|
1377
|
+
updated_at_ms = excluded.updated_at_ms
|
|
1378
|
+
`);
|
|
1379
|
+
const recordSyncRun = database.prepare(`
|
|
1380
|
+
INSERT INTO sync_runs (
|
|
1381
|
+
started_at_ms,
|
|
1382
|
+
completed_at_ms,
|
|
1383
|
+
environment,
|
|
1384
|
+
outcome,
|
|
1385
|
+
uploaded_bytes,
|
|
1386
|
+
failed,
|
|
1387
|
+
error
|
|
1388
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1389
|
+
`);
|
|
1390
|
+
const trimSyncRuns = database.prepare(`
|
|
1391
|
+
DELETE FROM sync_runs
|
|
1392
|
+
WHERE id <= (SELECT COALESCE(MAX(id), 0) - ? FROM sync_runs)
|
|
1393
|
+
`);
|
|
1394
|
+
const findPendingHookEvent = database.prepare(`
|
|
1395
|
+
SELECT 1 AS present
|
|
1396
|
+
FROM hook_event_outbox
|
|
1397
|
+
WHERE sync_id = ?
|
|
1398
|
+
`);
|
|
1399
|
+
const findQuarantinedHookEvent = database.prepare(`
|
|
1400
|
+
SELECT 1 AS present
|
|
1401
|
+
FROM hook_event_quarantine
|
|
1402
|
+
WHERE sync_id = ?
|
|
1403
|
+
`);
|
|
1404
|
+
const countPendingHookEvents = database.prepare(`
|
|
1405
|
+
SELECT COUNT(*) AS count
|
|
1406
|
+
FROM hook_event_outbox
|
|
1407
|
+
`);
|
|
1408
|
+
const countQuarantinedHookEvents = database.prepare(`
|
|
1409
|
+
SELECT COUNT(*) AS count
|
|
1410
|
+
FROM hook_event_quarantine
|
|
1411
|
+
`);
|
|
1412
|
+
const captureHookEvent = database.prepare(`
|
|
1413
|
+
INSERT INTO hook_event_outbox (
|
|
1414
|
+
sync_id,
|
|
1415
|
+
session_id,
|
|
1416
|
+
event_type,
|
|
1417
|
+
timestamp_ms,
|
|
1418
|
+
target,
|
|
1419
|
+
payload_json,
|
|
1420
|
+
cwd,
|
|
1421
|
+
repository,
|
|
1422
|
+
tool_name,
|
|
1423
|
+
user_prompt,
|
|
1424
|
+
file_path,
|
|
1425
|
+
command,
|
|
1426
|
+
plan,
|
|
1427
|
+
allowed_prompts,
|
|
1428
|
+
tool_result,
|
|
1429
|
+
captured_at_ms,
|
|
1430
|
+
destination_url,
|
|
1431
|
+
destination_identity,
|
|
1432
|
+
logical_bytes
|
|
1433
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1434
|
+
`);
|
|
1435
|
+
const listPendingHookEvents = database.prepare(`
|
|
1436
|
+
SELECT
|
|
1437
|
+
id,
|
|
1438
|
+
sync_id,
|
|
1439
|
+
session_id,
|
|
1440
|
+
event_type,
|
|
1441
|
+
timestamp_ms,
|
|
1442
|
+
target,
|
|
1443
|
+
payload_json,
|
|
1444
|
+
cwd,
|
|
1445
|
+
repository,
|
|
1446
|
+
tool_name,
|
|
1447
|
+
user_prompt,
|
|
1448
|
+
file_path,
|
|
1449
|
+
command,
|
|
1450
|
+
plan,
|
|
1451
|
+
allowed_prompts,
|
|
1452
|
+
tool_result,
|
|
1453
|
+
captured_at_ms,
|
|
1454
|
+
destination_url,
|
|
1455
|
+
destination_identity,
|
|
1456
|
+
retry_count,
|
|
1457
|
+
next_attempt_at_ms
|
|
1458
|
+
FROM hook_event_outbox
|
|
1459
|
+
ORDER BY id ASC
|
|
1460
|
+
LIMIT ?
|
|
1461
|
+
`);
|
|
1462
|
+
const claimUnboundHookEvents = database.prepare(`
|
|
1463
|
+
UPDATE hook_event_outbox
|
|
1464
|
+
SET
|
|
1465
|
+
destination_url = ?,
|
|
1466
|
+
destination_identity = ?,
|
|
1467
|
+
logical_bytes = logical_bytes + ?
|
|
1468
|
+
WHERE destination_url IS NULL
|
|
1469
|
+
AND destination_identity IS NULL
|
|
1470
|
+
`);
|
|
1471
|
+
const listDestinationPendingHookEvents = database.prepare(`
|
|
1472
|
+
SELECT
|
|
1473
|
+
id,
|
|
1474
|
+
sync_id,
|
|
1475
|
+
session_id,
|
|
1476
|
+
event_type,
|
|
1477
|
+
timestamp_ms,
|
|
1478
|
+
target,
|
|
1479
|
+
payload_json,
|
|
1480
|
+
cwd,
|
|
1481
|
+
repository,
|
|
1482
|
+
tool_name,
|
|
1483
|
+
user_prompt,
|
|
1484
|
+
file_path,
|
|
1485
|
+
command,
|
|
1486
|
+
plan,
|
|
1487
|
+
allowed_prompts,
|
|
1488
|
+
tool_result,
|
|
1489
|
+
captured_at_ms,
|
|
1490
|
+
destination_url,
|
|
1491
|
+
destination_identity,
|
|
1492
|
+
retry_count,
|
|
1493
|
+
next_attempt_at_ms
|
|
1494
|
+
FROM hook_event_outbox AS candidate
|
|
1495
|
+
WHERE candidate.destination_url = ?
|
|
1496
|
+
AND candidate.destination_identity = ?
|
|
1497
|
+
AND candidate.next_attempt_at_ms <= ?
|
|
1498
|
+
AND NOT EXISTS (
|
|
1499
|
+
SELECT 1
|
|
1500
|
+
FROM hook_event_outbox AS predecessor
|
|
1501
|
+
WHERE predecessor.destination_url = candidate.destination_url
|
|
1502
|
+
AND predecessor.destination_identity = candidate.destination_identity
|
|
1503
|
+
AND predecessor.session_id = candidate.session_id
|
|
1504
|
+
AND predecessor.id < candidate.id
|
|
1505
|
+
AND predecessor.next_attempt_at_ms > ?
|
|
1506
|
+
)
|
|
1507
|
+
-- Once a deferred row is eligible again, restore durable FIFO order so
|
|
1508
|
+
-- a steady stream of new captures cannot starve it indefinitely.
|
|
1509
|
+
ORDER BY id ASC
|
|
1510
|
+
LIMIT ?
|
|
1511
|
+
`);
|
|
1512
|
+
const nextDestinationHookAttempt = database.prepare(`
|
|
1513
|
+
SELECT MIN(candidate.next_attempt_at_ms) AS next_attempt_at_ms
|
|
1514
|
+
FROM hook_event_outbox AS candidate
|
|
1515
|
+
WHERE candidate.destination_url = ?
|
|
1516
|
+
AND candidate.destination_identity = ?
|
|
1517
|
+
AND NOT EXISTS (
|
|
1518
|
+
SELECT 1
|
|
1519
|
+
FROM hook_event_outbox AS predecessor
|
|
1520
|
+
WHERE predecessor.destination_url = candidate.destination_url
|
|
1521
|
+
AND predecessor.destination_identity = candidate.destination_identity
|
|
1522
|
+
AND predecessor.session_id = candidate.session_id
|
|
1523
|
+
AND predecessor.id < candidate.id
|
|
1524
|
+
)
|
|
1525
|
+
`);
|
|
1526
|
+
const quarantineHookEvent = database.prepare(`
|
|
1527
|
+
INSERT INTO hook_event_quarantine (
|
|
1528
|
+
outbox_id,
|
|
1529
|
+
sync_id,
|
|
1530
|
+
session_id,
|
|
1531
|
+
event_type,
|
|
1532
|
+
timestamp_ms,
|
|
1533
|
+
target,
|
|
1534
|
+
payload_json,
|
|
1535
|
+
cwd,
|
|
1536
|
+
repository,
|
|
1537
|
+
tool_name,
|
|
1538
|
+
user_prompt,
|
|
1539
|
+
file_path,
|
|
1540
|
+
command,
|
|
1541
|
+
plan,
|
|
1542
|
+
allowed_prompts,
|
|
1543
|
+
tool_result,
|
|
1544
|
+
captured_at_ms,
|
|
1545
|
+
destination_url,
|
|
1546
|
+
destination_identity,
|
|
1547
|
+
rejection_reason,
|
|
1548
|
+
quarantined_at_ms,
|
|
1549
|
+
logical_bytes
|
|
1550
|
+
)
|
|
1551
|
+
SELECT
|
|
1552
|
+
id,
|
|
1553
|
+
sync_id,
|
|
1554
|
+
session_id,
|
|
1555
|
+
event_type,
|
|
1556
|
+
timestamp_ms,
|
|
1557
|
+
target,
|
|
1558
|
+
payload_json,
|
|
1559
|
+
cwd,
|
|
1560
|
+
repository,
|
|
1561
|
+
tool_name,
|
|
1562
|
+
user_prompt,
|
|
1563
|
+
file_path,
|
|
1564
|
+
command,
|
|
1565
|
+
plan,
|
|
1566
|
+
allowed_prompts,
|
|
1567
|
+
tool_result,
|
|
1568
|
+
captured_at_ms,
|
|
1569
|
+
destination_url,
|
|
1570
|
+
destination_identity,
|
|
1571
|
+
?,
|
|
1572
|
+
?,
|
|
1573
|
+
logical_bytes + ?
|
|
1574
|
+
FROM hook_event_outbox
|
|
1575
|
+
WHERE destination_url = ?
|
|
1576
|
+
AND destination_identity = ?
|
|
1577
|
+
AND sync_id = ?
|
|
1578
|
+
ON CONFLICT(sync_id) DO NOTHING
|
|
1579
|
+
`);
|
|
1580
|
+
const hookSpoolState = database.prepare(`
|
|
1581
|
+
SELECT
|
|
1582
|
+
logical_bytes,
|
|
1583
|
+
evicted_records,
|
|
1584
|
+
evicted_bytes,
|
|
1585
|
+
evicted_pending_records,
|
|
1586
|
+
evicted_quarantined_records,
|
|
1587
|
+
last_evicted_at_ms
|
|
1588
|
+
FROM hook_spool_state
|
|
1589
|
+
WHERE singleton = 1
|
|
1590
|
+
`);
|
|
1591
|
+
const reconcileHookSpoolState = database.prepare(
|
|
1592
|
+
RECONCILE_HOOK_SPOOL_STATE_SQL
|
|
1593
|
+
);
|
|
1594
|
+
const adjustHookSpoolBytes = database.prepare(`
|
|
1595
|
+
UPDATE hook_spool_state
|
|
1596
|
+
SET logical_bytes = logical_bytes + ?
|
|
1597
|
+
WHERE singleton = 1
|
|
1598
|
+
`);
|
|
1599
|
+
const recordHookSpoolEviction = database.prepare(`
|
|
1600
|
+
UPDATE hook_spool_state
|
|
1601
|
+
SET
|
|
1602
|
+
logical_bytes = logical_bytes - ?,
|
|
1603
|
+
evicted_records = evicted_records + ?,
|
|
1604
|
+
evicted_bytes = evicted_bytes + ?,
|
|
1605
|
+
evicted_pending_records = evicted_pending_records + ?,
|
|
1606
|
+
evicted_quarantined_records = evicted_quarantined_records + ?,
|
|
1607
|
+
last_evicted_at_ms = ?
|
|
1608
|
+
WHERE singleton = 1
|
|
1609
|
+
`);
|
|
1610
|
+
const recordOversizedHookCapture = database.prepare(`
|
|
1611
|
+
UPDATE hook_spool_state
|
|
1612
|
+
SET
|
|
1613
|
+
evicted_records = evicted_records + 1,
|
|
1614
|
+
evicted_bytes = evicted_bytes + ?,
|
|
1615
|
+
evicted_pending_records = evicted_pending_records + 1,
|
|
1616
|
+
last_evicted_at_ms = ?
|
|
1617
|
+
WHERE singleton = 1
|
|
1618
|
+
`);
|
|
1619
|
+
const quarantineEvictionPlan = database.prepare(`
|
|
1620
|
+
WITH ordered AS (
|
|
1621
|
+
SELECT
|
|
1622
|
+
logical_bytes,
|
|
1623
|
+
SUM(logical_bytes) OVER (ORDER BY id ASC) - logical_bytes
|
|
1624
|
+
AS preceding_bytes
|
|
1625
|
+
FROM hook_event_quarantine
|
|
1626
|
+
)
|
|
1627
|
+
SELECT
|
|
1628
|
+
COUNT(*) AS record_count,
|
|
1629
|
+
COALESCE(SUM(logical_bytes), 0) AS logical_bytes
|
|
1630
|
+
FROM ordered
|
|
1631
|
+
WHERE preceding_bytes < ?
|
|
1632
|
+
`);
|
|
1633
|
+
const pendingEvictionPlan = database.prepare(`
|
|
1634
|
+
WITH ordered AS (
|
|
1635
|
+
SELECT
|
|
1636
|
+
logical_bytes,
|
|
1637
|
+
SUM(logical_bytes) OVER (ORDER BY id ASC) - logical_bytes
|
|
1638
|
+
AS preceding_bytes
|
|
1639
|
+
FROM hook_event_outbox
|
|
1640
|
+
)
|
|
1641
|
+
SELECT
|
|
1642
|
+
COUNT(*) AS record_count,
|
|
1643
|
+
COALESCE(SUM(logical_bytes), 0) AS logical_bytes
|
|
1644
|
+
FROM ordered
|
|
1645
|
+
WHERE preceding_bytes < ?
|
|
1646
|
+
`);
|
|
1647
|
+
const evictOldestQuarantinedHookEvents = database.prepare(`
|
|
1648
|
+
DELETE FROM hook_event_quarantine
|
|
1649
|
+
WHERE id IN (
|
|
1650
|
+
SELECT id FROM hook_event_quarantine ORDER BY id ASC LIMIT ?
|
|
1651
|
+
)
|
|
1652
|
+
`);
|
|
1653
|
+
const evictOldestPendingHookEvents = database.prepare(`
|
|
1654
|
+
DELETE FROM hook_event_outbox
|
|
1655
|
+
WHERE id IN (
|
|
1656
|
+
SELECT id FROM hook_event_outbox ORDER BY id ASC LIMIT ?
|
|
1657
|
+
)
|
|
1658
|
+
`);
|
|
1659
|
+
const expiredQuarantinePlan = database.prepare(`
|
|
1660
|
+
SELECT
|
|
1661
|
+
COUNT(*) AS record_count,
|
|
1662
|
+
COALESCE(SUM(logical_bytes), 0) AS logical_bytes
|
|
1663
|
+
FROM hook_event_quarantine
|
|
1664
|
+
WHERE quarantined_at_ms <= ?
|
|
1665
|
+
`);
|
|
1666
|
+
const pruneExpiredQuarantine = database.prepare(`
|
|
1667
|
+
DELETE FROM hook_event_quarantine
|
|
1668
|
+
WHERE quarantined_at_ms <= ?
|
|
1669
|
+
`);
|
|
1670
|
+
const deletePendingHookEvent = database.prepare(`
|
|
1671
|
+
DELETE FROM hook_event_outbox
|
|
1672
|
+
WHERE destination_url = ?
|
|
1673
|
+
AND destination_identity = ?
|
|
1674
|
+
AND sync_id = ?
|
|
1675
|
+
`);
|
|
1676
|
+
const findPendingOtlpEnvelope = database.prepare(`
|
|
1677
|
+
SELECT 1 AS present
|
|
1678
|
+
FROM otlp_envelope_outbox
|
|
1679
|
+
WHERE envelope_id = ?
|
|
1680
|
+
`);
|
|
1681
|
+
const countPendingOtlpEnvelopes = database.prepare(`
|
|
1682
|
+
SELECT COUNT(*) AS count
|
|
1683
|
+
FROM otlp_envelope_outbox
|
|
1684
|
+
`);
|
|
1685
|
+
const captureOtlpEnvelope = database.prepare(`
|
|
1686
|
+
INSERT INTO otlp_envelope_outbox (
|
|
1687
|
+
envelope_id,
|
|
1688
|
+
observer_instance_id,
|
|
1689
|
+
observer_version,
|
|
1690
|
+
signal,
|
|
1691
|
+
content_type,
|
|
1692
|
+
content_encoding,
|
|
1693
|
+
body,
|
|
1694
|
+
sha256,
|
|
1695
|
+
captured_at_ms,
|
|
1696
|
+
destination_url,
|
|
1697
|
+
destination_identity,
|
|
1698
|
+
destination_organization_id,
|
|
1699
|
+
destination_organization_slug,
|
|
1700
|
+
logical_bytes
|
|
1701
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1702
|
+
`);
|
|
1703
|
+
const listPendingOtlpEnvelopes = database.prepare(`
|
|
1704
|
+
SELECT
|
|
1705
|
+
id,
|
|
1706
|
+
envelope_id,
|
|
1707
|
+
observer_instance_id,
|
|
1708
|
+
observer_version,
|
|
1709
|
+
signal,
|
|
1710
|
+
content_type,
|
|
1711
|
+
content_encoding,
|
|
1712
|
+
body,
|
|
1713
|
+
sha256,
|
|
1714
|
+
captured_at_ms,
|
|
1715
|
+
destination_url,
|
|
1716
|
+
destination_identity,
|
|
1717
|
+
destination_organization_id,
|
|
1718
|
+
destination_organization_slug,
|
|
1719
|
+
retry_count,
|
|
1720
|
+
next_attempt_at_ms
|
|
1721
|
+
FROM otlp_envelope_outbox
|
|
1722
|
+
ORDER BY id ASC
|
|
1723
|
+
LIMIT ?
|
|
1724
|
+
`);
|
|
1725
|
+
const claimOrganizationForOtlpEnvelopes = database.prepare(`
|
|
1726
|
+
UPDATE otlp_envelope_outbox
|
|
1727
|
+
SET
|
|
1728
|
+
destination_organization_id = ?,
|
|
1729
|
+
destination_organization_slug = ?
|
|
1730
|
+
WHERE destination_url = ?
|
|
1731
|
+
AND destination_identity = ?
|
|
1732
|
+
AND destination_organization_id IS NULL
|
|
1733
|
+
AND destination_organization_slug IS NULL
|
|
1734
|
+
`);
|
|
1735
|
+
const listDestinationPendingOtlpEnvelopes = database.prepare(`
|
|
1736
|
+
SELECT
|
|
1737
|
+
id,
|
|
1738
|
+
envelope_id,
|
|
1739
|
+
observer_instance_id,
|
|
1740
|
+
observer_version,
|
|
1741
|
+
signal,
|
|
1742
|
+
content_type,
|
|
1743
|
+
content_encoding,
|
|
1744
|
+
body,
|
|
1745
|
+
sha256,
|
|
1746
|
+
captured_at_ms,
|
|
1747
|
+
destination_url,
|
|
1748
|
+
destination_identity,
|
|
1749
|
+
destination_organization_id,
|
|
1750
|
+
destination_organization_slug,
|
|
1751
|
+
retry_count,
|
|
1752
|
+
next_attempt_at_ms
|
|
1753
|
+
FROM otlp_envelope_outbox
|
|
1754
|
+
WHERE destination_url = ?
|
|
1755
|
+
AND destination_identity = ?
|
|
1756
|
+
AND destination_organization_id IS ?
|
|
1757
|
+
AND next_attempt_at_ms <= ?
|
|
1758
|
+
ORDER BY id ASC
|
|
1759
|
+
LIMIT ?
|
|
1760
|
+
`);
|
|
1761
|
+
const deferDestinationPendingOtlpEnvelope = database.prepare(`
|
|
1762
|
+
UPDATE otlp_envelope_outbox
|
|
1763
|
+
SET
|
|
1764
|
+
retry_count = retry_count + 1,
|
|
1765
|
+
next_attempt_at_ms = ?
|
|
1766
|
+
WHERE destination_url = ?
|
|
1767
|
+
AND destination_identity = ?
|
|
1768
|
+
AND destination_organization_id IS ?
|
|
1769
|
+
AND envelope_id = ?
|
|
1770
|
+
`);
|
|
1771
|
+
const nextDestinationPendingOtlpAttempt = database.prepare(`
|
|
1772
|
+
SELECT MIN(next_attempt_at_ms) AS next_attempt_at_ms
|
|
1773
|
+
FROM otlp_envelope_outbox
|
|
1774
|
+
WHERE destination_url = ?
|
|
1775
|
+
AND destination_identity = ?
|
|
1776
|
+
AND destination_organization_id IS ?
|
|
1777
|
+
`);
|
|
1778
|
+
const getOtlpSpoolState = database.prepare(`
|
|
1779
|
+
SELECT
|
|
1780
|
+
logical_bytes,
|
|
1781
|
+
body_bytes,
|
|
1782
|
+
dropped_records,
|
|
1783
|
+
dropped_logical_bytes,
|
|
1784
|
+
dropped_body_bytes,
|
|
1785
|
+
last_dropped_at_ms,
|
|
1786
|
+
last_drop_reason
|
|
1787
|
+
FROM otlp_spool_state
|
|
1788
|
+
WHERE singleton = 1
|
|
1789
|
+
`);
|
|
1790
|
+
const recordOtlpDrops = database.prepare(`
|
|
1791
|
+
UPDATE otlp_spool_state
|
|
1792
|
+
SET
|
|
1793
|
+
dropped_records = dropped_records + ?,
|
|
1794
|
+
dropped_logical_bytes = dropped_logical_bytes + ?,
|
|
1795
|
+
dropped_body_bytes = dropped_body_bytes + ?,
|
|
1796
|
+
last_dropped_at_ms = ?,
|
|
1797
|
+
last_drop_reason = ?
|
|
1798
|
+
WHERE singleton = 1
|
|
1799
|
+
`);
|
|
1800
|
+
const otlpCapacityEvictionPlan = database.prepare(`
|
|
1801
|
+
WITH ordered AS (
|
|
1802
|
+
SELECT
|
|
1803
|
+
logical_bytes,
|
|
1804
|
+
length(body) AS body_bytes,
|
|
1805
|
+
SUM(logical_bytes) OVER (ORDER BY id ASC) - logical_bytes
|
|
1806
|
+
AS preceding_bytes
|
|
1807
|
+
FROM otlp_envelope_outbox
|
|
1808
|
+
)
|
|
1809
|
+
SELECT
|
|
1810
|
+
COUNT(*) AS record_count,
|
|
1811
|
+
COALESCE(SUM(logical_bytes), 0) AS logical_bytes,
|
|
1812
|
+
COALESCE(SUM(body_bytes), 0) AS body_bytes
|
|
1813
|
+
FROM ordered
|
|
1814
|
+
WHERE preceding_bytes < ?
|
|
1815
|
+
`);
|
|
1816
|
+
const evictOldestPendingOtlpEnvelopes = database.prepare(`
|
|
1817
|
+
DELETE FROM otlp_envelope_outbox
|
|
1818
|
+
WHERE id IN (
|
|
1819
|
+
SELECT id FROM otlp_envelope_outbox ORDER BY id ASC LIMIT ?
|
|
1820
|
+
)
|
|
1821
|
+
`);
|
|
1822
|
+
const getDestinationPendingOtlpEnvelopeAccounting = database.prepare(`
|
|
1823
|
+
SELECT logical_bytes, length(body) AS body_bytes
|
|
1824
|
+
FROM otlp_envelope_outbox
|
|
1825
|
+
WHERE destination_url = ?
|
|
1826
|
+
AND destination_identity = ?
|
|
1827
|
+
AND destination_organization_id IS ?
|
|
1828
|
+
AND envelope_id = ?
|
|
1829
|
+
`);
|
|
1830
|
+
const deleteDestinationPendingOtlpEnvelope = database.prepare(`
|
|
1831
|
+
DELETE FROM otlp_envelope_outbox
|
|
1832
|
+
WHERE destination_url = ?
|
|
1833
|
+
AND destination_identity = ?
|
|
1834
|
+
AND destination_organization_id IS ?
|
|
1835
|
+
AND envelope_id = ?
|
|
1836
|
+
`);
|
|
1837
|
+
const runImmediateTransaction = (operation) => {
|
|
1838
|
+
database.exec("BEGIN IMMEDIATE");
|
|
1839
|
+
try {
|
|
1840
|
+
const result = operation();
|
|
1841
|
+
database.exec("COMMIT");
|
|
1842
|
+
return result;
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
try {
|
|
1845
|
+
database.exec("ROLLBACK");
|
|
1846
|
+
} catch {
|
|
1847
|
+
}
|
|
1848
|
+
throw error;
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
const requireSpoolInteger = (row, key) => {
|
|
1852
|
+
const value = row?.[key];
|
|
1853
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
1854
|
+
throw new ObserverHookSpoolStateInvariantError(
|
|
1855
|
+
`FML observer spool ${key} is invalid`
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
return value;
|
|
1859
|
+
};
|
|
1860
|
+
const recordOtlpDrop = (records, logicalBytes, bodyBytes, droppedAtMs, reason) => {
|
|
1861
|
+
if (records === 0) return;
|
|
1862
|
+
requireTimestamp(droppedAtMs, "OTLP drop timestamp");
|
|
1863
|
+
requireBoundedString(
|
|
1864
|
+
reason,
|
|
1865
|
+
"OTLP drop reason",
|
|
1866
|
+
MAX_OTLP_DROP_REASON_BYTES
|
|
1867
|
+
);
|
|
1868
|
+
const updated = recordOtlpDrops.run(
|
|
1869
|
+
records,
|
|
1870
|
+
logicalBytes,
|
|
1871
|
+
bodyBytes,
|
|
1872
|
+
droppedAtMs,
|
|
1873
|
+
reason
|
|
1874
|
+
).changes;
|
|
1875
|
+
if (updated !== 1) {
|
|
1876
|
+
throw new Error("FML observer OTLP drop accounting was incomplete");
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
const readHookSpoolState = () => {
|
|
1880
|
+
try {
|
|
1881
|
+
return hookSpoolState.get();
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
if (error instanceof RangeError) {
|
|
1884
|
+
throw new ObserverHookSpoolStateInvariantError(
|
|
1885
|
+
"FML observer hook spool contains an unsafe integer"
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
throw error;
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
const readHookSpoolLogicalBytes = () => {
|
|
1892
|
+
try {
|
|
1893
|
+
return requireSpoolInteger(readHookSpoolState(), "logical_bytes");
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
if (!(error instanceof ObserverHookSpoolStateInvariantError)) {
|
|
1896
|
+
throw error;
|
|
1897
|
+
}
|
|
1898
|
+
reconcileHookSpoolState.run();
|
|
1899
|
+
return requireSpoolInteger(readHookSpoolState(), "logical_bytes");
|
|
1900
|
+
}
|
|
1901
|
+
};
|
|
1902
|
+
const runImmediateWithSpoolRepair = (operation) => {
|
|
1903
|
+
try {
|
|
1904
|
+
return runImmediateTransaction(operation);
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
if (!(error instanceof ObserverHookSpoolStateInvariantError)) {
|
|
1907
|
+
throw error;
|
|
1908
|
+
}
|
|
1909
|
+
runImmediateTransaction(() => reconcileHookSpoolState.run());
|
|
1910
|
+
return runImmediateTransaction(operation);
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
const evictionPlan = (statement, bytesToFree) => {
|
|
1914
|
+
if (bytesToFree <= 0) return { records: 0, bytes: 0 };
|
|
1915
|
+
const row = statement.get(bytesToFree);
|
|
1916
|
+
return {
|
|
1917
|
+
records: requireSpoolInteger(row, "record_count"),
|
|
1918
|
+
bytes: requireSpoolInteger(row, "logical_bytes")
|
|
1919
|
+
};
|
|
1920
|
+
};
|
|
1921
|
+
let nextQuarantinePruneAtMs = 0;
|
|
1922
|
+
const enforceHookQuarantineAge = (nowMs, force = false) => {
|
|
1923
|
+
if (maxHookQuarantineAgeMs === void 0) return;
|
|
1924
|
+
requireTimestamp(nowMs, "Hook quarantine prune timestamp");
|
|
1925
|
+
if (!force && nowMs < nextQuarantinePruneAtMs) return;
|
|
1926
|
+
nextQuarantinePruneAtMs = Math.min(
|
|
1927
|
+
Number.MAX_SAFE_INTEGER,
|
|
1928
|
+
nowMs + Math.min(maxHookQuarantineAgeMs, QUARANTINE_PRUNE_INTERVAL_MS)
|
|
1929
|
+
);
|
|
1930
|
+
const cutoffMs = Math.max(0, nowMs - maxHookQuarantineAgeMs);
|
|
1931
|
+
const expiredRow = expiredQuarantinePlan.get(cutoffMs);
|
|
1932
|
+
const expired = {
|
|
1933
|
+
records: requireSpoolInteger(expiredRow, "record_count"),
|
|
1934
|
+
bytes: requireSpoolInteger(expiredRow, "logical_bytes")
|
|
1935
|
+
};
|
|
1936
|
+
if (expired.records === 0) return;
|
|
1937
|
+
const changes = pruneExpiredQuarantine.run(cutoffMs).changes;
|
|
1938
|
+
if (changes !== expired.records) {
|
|
1939
|
+
throw new Error("FML observer hook quarantine pruning was incomplete");
|
|
1940
|
+
}
|
|
1941
|
+
recordHookSpoolEviction.run(
|
|
1942
|
+
expired.bytes,
|
|
1943
|
+
expired.records,
|
|
1944
|
+
expired.bytes,
|
|
1945
|
+
0,
|
|
1946
|
+
expired.records,
|
|
1947
|
+
nowMs
|
|
1948
|
+
);
|
|
1949
|
+
};
|
|
1950
|
+
const enforceHookSpoolLimit = (evictedAtMs, additionalBytes = 0) => {
|
|
1951
|
+
if (maxHookSpoolBytes === void 0) return;
|
|
1952
|
+
requireTimestamp(evictedAtMs, "Hook spool eviction timestamp");
|
|
1953
|
+
if (!Number.isSafeInteger(additionalBytes) || additionalBytes < 0) {
|
|
1954
|
+
throw new Error("FML observer hook spool additional bytes are invalid");
|
|
1955
|
+
}
|
|
1956
|
+
const currentBytes = readHookSpoolLogicalBytes();
|
|
1957
|
+
let bytesToFree = currentBytes + additionalBytes - maxHookSpoolBytes;
|
|
1958
|
+
if (bytesToFree <= 0) return;
|
|
1959
|
+
const requiredBytes = bytesToFree;
|
|
1960
|
+
const quarantined = evictionPlan(quarantineEvictionPlan, bytesToFree);
|
|
1961
|
+
if (quarantined.records > 0) {
|
|
1962
|
+
const changes = evictOldestQuarantinedHookEvents.run(
|
|
1963
|
+
quarantined.records
|
|
1964
|
+
).changes;
|
|
1965
|
+
if (changes !== quarantined.records) {
|
|
1966
|
+
throw new Error(
|
|
1967
|
+
"FML observer hook quarantine eviction was incomplete"
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
bytesToFree -= quarantined.bytes;
|
|
1971
|
+
}
|
|
1972
|
+
const pending = evictionPlan(pendingEvictionPlan, bytesToFree);
|
|
1973
|
+
if (pending.records > 0) {
|
|
1974
|
+
const changes = evictOldestPendingHookEvents.run(
|
|
1975
|
+
pending.records
|
|
1976
|
+
).changes;
|
|
1977
|
+
if (changes !== pending.records) {
|
|
1978
|
+
throw new Error("FML observer hook outbox eviction was incomplete");
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
const evictedRecords = quarantined.records + pending.records;
|
|
1982
|
+
const evictedBytes = quarantined.bytes + pending.bytes;
|
|
1983
|
+
if (evictedRecords === 0 || evictedBytes === 0 || evictedBytes < requiredBytes) {
|
|
1984
|
+
throw new ObserverHookSpoolStateInvariantError(
|
|
1985
|
+
"FML observer hook spool accounting could not enforce its limit"
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
recordHookSpoolEviction.run(
|
|
1989
|
+
evictedBytes,
|
|
1990
|
+
evictedRecords,
|
|
1991
|
+
evictedBytes,
|
|
1992
|
+
pending.records,
|
|
1993
|
+
quarantined.records,
|
|
1994
|
+
evictedAtMs
|
|
1995
|
+
);
|
|
1996
|
+
};
|
|
1997
|
+
if (maxHookSpoolBytes !== void 0 || maxHookQuarantineAgeMs !== void 0) {
|
|
1998
|
+
runImmediateTransaction(() => {
|
|
1999
|
+
const nowMs = Date.now();
|
|
2000
|
+
enforceHookQuarantineAge(nowMs, true);
|
|
2001
|
+
enforceHookSpoolLimit(nowMs);
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
enforceOwnerOnlyMode(databaseFile, 384);
|
|
2005
|
+
enforceOwnerOnlyMode(`${databaseFile}-wal`, 384);
|
|
2006
|
+
enforceOwnerOnlyMode(`${databaseFile}-shm`, 384);
|
|
2007
|
+
return {
|
|
2008
|
+
setRuntimeState: (key, value, updatedAtMs = Date.now()) => {
|
|
2009
|
+
setRuntimeState.run(key, JSON.stringify(value), updatedAtMs);
|
|
2010
|
+
},
|
|
2011
|
+
recordSyncRun: (record) => {
|
|
2012
|
+
recordSyncRun.run(
|
|
2013
|
+
record.startedAtMs,
|
|
2014
|
+
record.completedAtMs,
|
|
2015
|
+
record.environment ?? null,
|
|
2016
|
+
record.outcome,
|
|
2017
|
+
record.uploadedBytes,
|
|
2018
|
+
record.failed,
|
|
2019
|
+
record.error ?? null
|
|
2020
|
+
);
|
|
2021
|
+
trimSyncRuns.run(MAX_SYNC_RUN_RECORDS);
|
|
2022
|
+
},
|
|
2023
|
+
captureHookEvent: (envelope) => {
|
|
2024
|
+
requireBoundedString(
|
|
2025
|
+
envelope.syncId,
|
|
2026
|
+
"Hook event sync ID",
|
|
2027
|
+
MAX_HOOK_SYNC_ID_BYTES
|
|
2028
|
+
);
|
|
2029
|
+
requireBoundedString(
|
|
2030
|
+
envelope.sessionId,
|
|
2031
|
+
"Hook event session ID",
|
|
2032
|
+
MAX_HOOK_SESSION_ID_BYTES
|
|
2033
|
+
);
|
|
2034
|
+
requireBoundedString(
|
|
2035
|
+
envelope.eventType,
|
|
2036
|
+
"Hook event type",
|
|
2037
|
+
MAX_HOOK_EVENT_TYPE_BYTES
|
|
2038
|
+
);
|
|
2039
|
+
requireBoundedString(
|
|
2040
|
+
envelope.target,
|
|
2041
|
+
"Hook event target",
|
|
2042
|
+
MAX_HOOK_TARGET_BYTES
|
|
2043
|
+
);
|
|
2044
|
+
requireTimestamp(envelope.timestampMs, "Hook event timestamp");
|
|
2045
|
+
const capturedAtMs = envelope.capturedAtMs ?? Date.now();
|
|
2046
|
+
requireTimestamp(capturedAtMs, "Hook event capture timestamp");
|
|
2047
|
+
const payloadJson = jsonStringify(
|
|
2048
|
+
envelope.payload,
|
|
2049
|
+
"Hook event payload"
|
|
2050
|
+
);
|
|
2051
|
+
if (Buffer.byteLength(payloadJson, "utf8") > MAX_HOOK_PAYLOAD_BYTES) {
|
|
2052
|
+
throw new Error(
|
|
2053
|
+
`Hook event payload exceeds ${MAX_HOOK_PAYLOAD_BYTES} bytes`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
validateOptionalHookField(envelope.cwd, "Hook event cwd");
|
|
2057
|
+
validateOptionalHookField(envelope.repository, "Hook event repository");
|
|
2058
|
+
validateOptionalHookField(envelope.toolName, "Hook event tool name");
|
|
2059
|
+
validateOptionalHookField(
|
|
2060
|
+
envelope.userPrompt,
|
|
2061
|
+
"Hook event user prompt"
|
|
2062
|
+
);
|
|
2063
|
+
validateOptionalHookField(envelope.filePath, "Hook event file path");
|
|
2064
|
+
validateOptionalHookField(envelope.command, "Hook event command");
|
|
2065
|
+
validateOptionalHookField(envelope.plan, "Hook event plan");
|
|
2066
|
+
validateOptionalHookField(
|
|
2067
|
+
envelope.allowedPrompts,
|
|
2068
|
+
"Hook event allowed prompts"
|
|
2069
|
+
);
|
|
2070
|
+
validateOptionalHookField(
|
|
2071
|
+
envelope.toolResult,
|
|
2072
|
+
"Hook event tool result"
|
|
2073
|
+
);
|
|
2074
|
+
if (envelope.destination) {
|
|
2075
|
+
validateHookDestination(envelope.destination);
|
|
2076
|
+
}
|
|
2077
|
+
const logicalBytes = hookOutboxLogicalBytes(envelope, payloadJson);
|
|
2078
|
+
const captureResult = runImmediateWithSpoolRepair(() => {
|
|
2079
|
+
enforceHookQuarantineAge(Date.now());
|
|
2080
|
+
if (findPendingHookEvent.get(envelope.syncId) || findQuarantinedHookEvent.get(envelope.syncId)) {
|
|
2081
|
+
return "duplicate";
|
|
2082
|
+
}
|
|
2083
|
+
const evictedAtMs = Date.now();
|
|
2084
|
+
if (maxHookSpoolBytes !== void 0 && logicalBytes > maxHookSpoolBytes) {
|
|
2085
|
+
recordOversizedHookCapture.run(logicalBytes, evictedAtMs);
|
|
2086
|
+
return "too_large";
|
|
2087
|
+
}
|
|
2088
|
+
enforceHookSpoolLimit(evictedAtMs, logicalBytes);
|
|
2089
|
+
captureHookEvent.run(
|
|
2090
|
+
envelope.syncId,
|
|
2091
|
+
envelope.sessionId,
|
|
2092
|
+
envelope.eventType,
|
|
2093
|
+
envelope.timestampMs,
|
|
2094
|
+
envelope.target,
|
|
2095
|
+
payloadJson,
|
|
2096
|
+
envelope.cwd ?? null,
|
|
2097
|
+
envelope.repository ?? null,
|
|
2098
|
+
envelope.toolName ?? null,
|
|
2099
|
+
envelope.userPrompt ?? null,
|
|
2100
|
+
envelope.filePath ?? null,
|
|
2101
|
+
envelope.command ?? null,
|
|
2102
|
+
envelope.plan ?? null,
|
|
2103
|
+
envelope.allowedPrompts ?? null,
|
|
2104
|
+
envelope.toolResult ?? null,
|
|
2105
|
+
capturedAtMs,
|
|
2106
|
+
envelope.destination?.targetUrl ?? null,
|
|
2107
|
+
envelope.destination?.destinationIdentity ?? null,
|
|
2108
|
+
logicalBytes
|
|
2109
|
+
);
|
|
2110
|
+
adjustHookSpoolBytes.run(logicalBytes);
|
|
2111
|
+
return "captured";
|
|
2112
|
+
});
|
|
2113
|
+
if (captureResult === "too_large" && maxHookSpoolBytes !== void 0) {
|
|
2114
|
+
throw new ObserverHookSpoolEntryTooLargeError(
|
|
2115
|
+
logicalBytes,
|
|
2116
|
+
maxHookSpoolBytes
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
return captureResult === "captured";
|
|
2120
|
+
},
|
|
2121
|
+
listPendingHookEvents: (limit = DEFAULT_HOOK_OUTBOX_BATCH_SIZE) => {
|
|
2122
|
+
validateHookBatchLimit(limit);
|
|
2123
|
+
return listPendingHookEvents.all(limit).map(hookOutboxRecordFromRow);
|
|
2124
|
+
},
|
|
2125
|
+
claimPendingHookEvents: (destination, limit = DEFAULT_HOOK_OUTBOX_BATCH_SIZE, eligibleAtMs = Date.now()) => {
|
|
2126
|
+
validateHookDestination(destination);
|
|
2127
|
+
validateHookBatchLimit(limit);
|
|
2128
|
+
requireTimestamp(eligibleAtMs, "Hook event eligibility timestamp");
|
|
2129
|
+
return runImmediateWithSpoolRepair(() => {
|
|
2130
|
+
const destinationBytes = utf8Bytes(destination.targetUrl) + utf8Bytes(destination.destinationIdentity);
|
|
2131
|
+
const claimed = claimUnboundHookEvents.run(
|
|
2132
|
+
destination.targetUrl,
|
|
2133
|
+
destination.destinationIdentity,
|
|
2134
|
+
destinationBytes
|
|
2135
|
+
).changes;
|
|
2136
|
+
if (claimed > 0) {
|
|
2137
|
+
adjustHookSpoolBytes.run(destinationBytes * claimed);
|
|
2138
|
+
enforceHookSpoolLimit(eligibleAtMs);
|
|
2139
|
+
}
|
|
2140
|
+
return listDestinationPendingHookEvents.all(
|
|
2141
|
+
destination.targetUrl,
|
|
2142
|
+
destination.destinationIdentity,
|
|
2143
|
+
eligibleAtMs,
|
|
2144
|
+
eligibleAtMs,
|
|
2145
|
+
limit
|
|
2146
|
+
).map(hookOutboxRecordFromRow);
|
|
2147
|
+
});
|
|
2148
|
+
},
|
|
2149
|
+
deferHookEvents: (destination, syncIds, nextAttemptAtMs) => {
|
|
2150
|
+
validateHookDestination(destination);
|
|
2151
|
+
requireTimestamp(nextAttemptAtMs, "Hook event next-attempt timestamp");
|
|
2152
|
+
const uniqueSyncIds = [...new Set(syncIds)];
|
|
2153
|
+
if (uniqueSyncIds.length === 0) return 0;
|
|
2154
|
+
if (uniqueSyncIds.length > MAX_HOOK_OUTBOX_BATCH_SIZE) {
|
|
2155
|
+
throw new Error(
|
|
2156
|
+
`Cannot defer more than ${MAX_HOOK_OUTBOX_BATCH_SIZE} hook events at once`
|
|
2157
|
+
);
|
|
2158
|
+
}
|
|
2159
|
+
for (const syncId of uniqueSyncIds) {
|
|
2160
|
+
requireBoundedString(
|
|
2161
|
+
syncId,
|
|
2162
|
+
"Hook event sync ID",
|
|
2163
|
+
MAX_HOOK_SYNC_ID_BYTES
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
const placeholders = uniqueSyncIds.map(() => "?").join(", ");
|
|
2167
|
+
const defer = database.prepare(`
|
|
2168
|
+
UPDATE hook_event_outbox
|
|
2169
|
+
SET
|
|
2170
|
+
retry_count = retry_count + 1,
|
|
2171
|
+
next_attempt_at_ms = ?
|
|
2172
|
+
WHERE destination_url = ?
|
|
2173
|
+
AND destination_identity = ?
|
|
2174
|
+
AND sync_id IN (${placeholders})
|
|
2175
|
+
`);
|
|
2176
|
+
return runImmediateTransaction(
|
|
2177
|
+
() => defer.run(
|
|
2178
|
+
nextAttemptAtMs,
|
|
2179
|
+
destination.targetUrl,
|
|
2180
|
+
destination.destinationIdentity,
|
|
2181
|
+
...uniqueSyncIds
|
|
2182
|
+
).changes
|
|
2183
|
+
);
|
|
2184
|
+
},
|
|
2185
|
+
nextPendingHookAttemptAtMs: (destination) => {
|
|
2186
|
+
validateHookDestination(destination);
|
|
2187
|
+
const value = nextDestinationHookAttempt.get(
|
|
2188
|
+
destination.targetUrl,
|
|
2189
|
+
destination.destinationIdentity
|
|
2190
|
+
)?.next_attempt_at_ms;
|
|
2191
|
+
if (value === null || value === void 0) return void 0;
|
|
2192
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
2193
|
+
throw new Error(
|
|
2194
|
+
"FML observer hook next-attempt timestamp is invalid"
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
return value;
|
|
2198
|
+
},
|
|
2199
|
+
countPendingHookEvents: () => {
|
|
2200
|
+
const count = countPendingHookEvents.get()?.count;
|
|
2201
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count)) {
|
|
2202
|
+
throw new Error("FML observer hook outbox count is invalid");
|
|
2203
|
+
}
|
|
2204
|
+
return count;
|
|
2205
|
+
},
|
|
2206
|
+
acknowledgeHookEvents: (destination, syncIds) => {
|
|
2207
|
+
validateHookDestination(destination);
|
|
2208
|
+
const uniqueSyncIds = [...new Set(syncIds)];
|
|
2209
|
+
if (uniqueSyncIds.length === 0) return 0;
|
|
2210
|
+
if (uniqueSyncIds.length > MAX_HOOK_OUTBOX_BATCH_SIZE) {
|
|
2211
|
+
throw new Error(
|
|
2212
|
+
`Cannot acknowledge more than ${MAX_HOOK_OUTBOX_BATCH_SIZE} hook events at once`
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
for (const syncId of uniqueSyncIds) {
|
|
2216
|
+
requireBoundedString(
|
|
2217
|
+
syncId,
|
|
2218
|
+
"Hook event sync ID",
|
|
2219
|
+
MAX_HOOK_SYNC_ID_BYTES
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
const placeholders = uniqueSyncIds.map(() => "?").join(", ");
|
|
2223
|
+
const acknowledge = database.prepare(`
|
|
2224
|
+
DELETE FROM hook_event_outbox
|
|
2225
|
+
WHERE destination_url = ?
|
|
2226
|
+
AND destination_identity = ?
|
|
2227
|
+
AND sync_id IN (${placeholders})
|
|
2228
|
+
`);
|
|
2229
|
+
const acknowledgedBytes = database.prepare(`
|
|
2230
|
+
SELECT COALESCE(SUM(logical_bytes), 0) AS logical_bytes
|
|
2231
|
+
FROM hook_event_outbox
|
|
2232
|
+
WHERE destination_url = ?
|
|
2233
|
+
AND destination_identity = ?
|
|
2234
|
+
AND sync_id IN (${placeholders})
|
|
2235
|
+
`);
|
|
2236
|
+
return runImmediateWithSpoolRepair(() => {
|
|
2237
|
+
const values = [
|
|
2238
|
+
destination.targetUrl,
|
|
2239
|
+
destination.destinationIdentity,
|
|
2240
|
+
...uniqueSyncIds
|
|
2241
|
+
];
|
|
2242
|
+
const logicalBytes = requireSpoolInteger(
|
|
2243
|
+
acknowledgedBytes.get(...values),
|
|
2244
|
+
"logical_bytes"
|
|
2245
|
+
);
|
|
2246
|
+
const changes = acknowledge.run(...values).changes;
|
|
2247
|
+
if (changes > 0) adjustHookSpoolBytes.run(-logicalBytes);
|
|
2248
|
+
return changes;
|
|
2249
|
+
});
|
|
2250
|
+
},
|
|
2251
|
+
quarantineHookEvents: (destination, rejections, quarantinedAtMs = Date.now()) => {
|
|
2252
|
+
validateHookDestination(destination);
|
|
2253
|
+
const uniqueRejections = [
|
|
2254
|
+
...new Map(
|
|
2255
|
+
rejections.map((rejection) => [rejection.syncId, rejection])
|
|
2256
|
+
).values()
|
|
2257
|
+
];
|
|
2258
|
+
if (uniqueRejections.length === 0) return 0;
|
|
2259
|
+
if (uniqueRejections.length > MAX_HOOK_OUTBOX_BATCH_SIZE) {
|
|
2260
|
+
throw new Error(
|
|
2261
|
+
`Cannot quarantine more than ${MAX_HOOK_OUTBOX_BATCH_SIZE} hook events at once`
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
requireTimestamp(quarantinedAtMs, "Hook event quarantine timestamp");
|
|
2265
|
+
for (const rejection of uniqueRejections) {
|
|
2266
|
+
requireBoundedString(
|
|
2267
|
+
rejection.syncId,
|
|
2268
|
+
"Hook event sync ID",
|
|
2269
|
+
MAX_HOOK_SYNC_ID_BYTES
|
|
2270
|
+
);
|
|
2271
|
+
requireBoundedString(
|
|
2272
|
+
rejection.reason,
|
|
2273
|
+
"Hook event rejection reason",
|
|
2274
|
+
MAX_HOOK_REJECTION_REASON_BYTES
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
return runImmediateWithSpoolRepair(() => {
|
|
2278
|
+
let quarantined = 0;
|
|
2279
|
+
for (const rejection of uniqueRejections) {
|
|
2280
|
+
const reasonBytes = utf8Bytes(rejection.reason);
|
|
2281
|
+
const inserted = quarantineHookEvent.run(
|
|
2282
|
+
rejection.reason,
|
|
2283
|
+
quarantinedAtMs,
|
|
2284
|
+
reasonBytes,
|
|
2285
|
+
destination.targetUrl,
|
|
2286
|
+
destination.destinationIdentity,
|
|
2287
|
+
rejection.syncId
|
|
2288
|
+
).changes;
|
|
2289
|
+
if (inserted === 0) continue;
|
|
2290
|
+
quarantined += deletePendingHookEvent.run(
|
|
2291
|
+
destination.targetUrl,
|
|
2292
|
+
destination.destinationIdentity,
|
|
2293
|
+
rejection.syncId
|
|
2294
|
+
).changes;
|
|
2295
|
+
adjustHookSpoolBytes.run(reasonBytes);
|
|
2296
|
+
}
|
|
2297
|
+
enforceHookQuarantineAge(Date.now(), true);
|
|
2298
|
+
enforceHookSpoolLimit(quarantinedAtMs);
|
|
2299
|
+
return quarantined;
|
|
2300
|
+
});
|
|
2301
|
+
},
|
|
2302
|
+
countQuarantinedHookEvents: () => {
|
|
2303
|
+
if (maxHookQuarantineAgeMs !== void 0) {
|
|
2304
|
+
runImmediateTransaction(() => enforceHookQuarantineAge(Date.now()));
|
|
2305
|
+
}
|
|
2306
|
+
const count = countQuarantinedHookEvents.get()?.count;
|
|
2307
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count)) {
|
|
2308
|
+
throw new Error("FML observer hook quarantine count is invalid");
|
|
2309
|
+
}
|
|
2310
|
+
return count;
|
|
2311
|
+
},
|
|
2312
|
+
getHookSpoolStatus: () => {
|
|
2313
|
+
const readStatus = () => {
|
|
2314
|
+
const row = readHookSpoolState();
|
|
2315
|
+
const lastEvictedAtMs = row?.last_evicted_at_ms;
|
|
2316
|
+
if (lastEvictedAtMs !== null && lastEvictedAtMs !== void 0 && (typeof lastEvictedAtMs !== "number" || !Number.isSafeInteger(lastEvictedAtMs) || lastEvictedAtMs < 0 || lastEvictedAtMs > MAX_TIMESTAMP_MS)) {
|
|
2317
|
+
throw new ObserverHookSpoolStateInvariantError(
|
|
2318
|
+
"FML observer hook spool eviction timestamp is invalid"
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
return {
|
|
2322
|
+
unit: "logical_bytes",
|
|
2323
|
+
maxBytes: maxHookSpoolBytes ?? null,
|
|
2324
|
+
quarantineMaxAgeMs: maxHookQuarantineAgeMs ?? null,
|
|
2325
|
+
logicalBytes: requireSpoolInteger(row, "logical_bytes"),
|
|
2326
|
+
evictedRecords: requireSpoolInteger(row, "evicted_records"),
|
|
2327
|
+
evictedBytes: requireSpoolInteger(row, "evicted_bytes"),
|
|
2328
|
+
evictedPendingRecords: requireSpoolInteger(
|
|
2329
|
+
row,
|
|
2330
|
+
"evicted_pending_records"
|
|
2331
|
+
),
|
|
2332
|
+
evictedQuarantinedRecords: requireSpoolInteger(
|
|
2333
|
+
row,
|
|
2334
|
+
"evicted_quarantined_records"
|
|
2335
|
+
),
|
|
2336
|
+
...typeof lastEvictedAtMs === "number" ? { lastEvictedAtMs } : {}
|
|
2337
|
+
};
|
|
2338
|
+
};
|
|
2339
|
+
try {
|
|
2340
|
+
return readStatus();
|
|
2341
|
+
} catch (error) {
|
|
2342
|
+
if (!(error instanceof ObserverHookSpoolStateInvariantError)) {
|
|
2343
|
+
throw error;
|
|
2344
|
+
}
|
|
2345
|
+
runImmediateTransaction(() => reconcileHookSpoolState.run());
|
|
2346
|
+
return readStatus();
|
|
2347
|
+
}
|
|
2348
|
+
},
|
|
2349
|
+
captureOtlpEnvelope: (envelope) => {
|
|
2350
|
+
if (envelope.envelopeId !== void 0) {
|
|
2351
|
+
requireBoundedString(
|
|
2352
|
+
envelope.envelopeId,
|
|
2353
|
+
"OTLP envelope ID",
|
|
2354
|
+
MAX_OTLP_ENVELOPE_ID_BYTES
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
requireBoundedString(
|
|
2358
|
+
envelope.observerInstanceId,
|
|
2359
|
+
"OTLP observer instance ID",
|
|
2360
|
+
MAX_OTLP_OBSERVER_INSTANCE_ID_BYTES
|
|
2361
|
+
);
|
|
2362
|
+
requireBoundedString(
|
|
2363
|
+
envelope.observerVersion,
|
|
2364
|
+
"OTLP observer version",
|
|
2365
|
+
MAX_OTLP_OBSERVER_VERSION_BYTES
|
|
2366
|
+
);
|
|
2367
|
+
const signal = requireOtlpSignal(envelope.signal);
|
|
2368
|
+
const contentType = requireOtlpContentType(envelope.contentType);
|
|
2369
|
+
const contentEncoding = requireOtlpContentEncoding(
|
|
2370
|
+
envelope.contentEncoding
|
|
2371
|
+
);
|
|
2372
|
+
if (!Buffer.isBuffer(envelope.body)) {
|
|
2373
|
+
throw new Error("OTLP envelope body must be a Buffer");
|
|
2374
|
+
}
|
|
2375
|
+
if (envelope.body.byteLength > MAX_OTLP_ENVELOPE_BYTES) {
|
|
2376
|
+
throw new Error(
|
|
2377
|
+
`OTLP envelope body exceeds ${MAX_OTLP_ENVELOPE_BYTES} bytes`
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
if (envelope.sha256 !== void 0 && !/^[0-9a-f]{64}$/.test(envelope.sha256)) {
|
|
2381
|
+
throw new Error(
|
|
2382
|
+
"OTLP envelope SHA-256 must be 64 lowercase hexadecimal characters"
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
const capturedAtMs = envelope.capturedAtMs ?? Date.now();
|
|
2386
|
+
requireTimestamp(capturedAtMs, "OTLP envelope capture timestamp");
|
|
2387
|
+
if (envelope.destination) {
|
|
2388
|
+
validateOtlpDestination(envelope.destination);
|
|
2389
|
+
}
|
|
2390
|
+
return runImmediateTransaction(() => {
|
|
2391
|
+
let envelopeId = envelope.envelopeId;
|
|
2392
|
+
if (envelopeId === void 0) {
|
|
2393
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
2394
|
+
const candidate = crypto2.randomUUID();
|
|
2395
|
+
if (!findPendingOtlpEnvelope.get(candidate)) {
|
|
2396
|
+
envelopeId = candidate;
|
|
2397
|
+
break;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
if (envelopeId === void 0) {
|
|
2401
|
+
throw new Error("Unable to allocate a unique OTLP envelope ID");
|
|
2402
|
+
}
|
|
2403
|
+
} else if (envelope.destination !== void 0 && findPendingOtlpEnvelope.get(envelopeId)) {
|
|
2404
|
+
return null;
|
|
2405
|
+
}
|
|
2406
|
+
const logicalBytes = otlpEnvelopeLogicalBytes(envelope, envelopeId);
|
|
2407
|
+
const actualSha256 = crypto2.createHash("sha256").update(envelope.body).digest("hex");
|
|
2408
|
+
if (envelope.sha256 !== void 0 && actualSha256 !== envelope.sha256) {
|
|
2409
|
+
throw new Error("OTLP envelope SHA-256 does not match its body");
|
|
2410
|
+
}
|
|
2411
|
+
if (envelope.destination === void 0) {
|
|
2412
|
+
recordOtlpDrop(
|
|
2413
|
+
1,
|
|
2414
|
+
logicalBytes,
|
|
2415
|
+
envelope.body.byteLength,
|
|
2416
|
+
capturedAtMs,
|
|
2417
|
+
OWNERLESS_OTLP_DROP_REASON
|
|
2418
|
+
);
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
if (logicalBytes > maxOtlpSpoolBytes) {
|
|
2422
|
+
recordOtlpDrop(
|
|
2423
|
+
1,
|
|
2424
|
+
logicalBytes,
|
|
2425
|
+
envelope.body.byteLength,
|
|
2426
|
+
capturedAtMs,
|
|
2427
|
+
OTLP_ENTRY_TOO_LARGE_DROP_REASON
|
|
2428
|
+
);
|
|
2429
|
+
return null;
|
|
2430
|
+
}
|
|
2431
|
+
const retainedBytes = requireSpoolInteger(
|
|
2432
|
+
getOtlpSpoolState.get(),
|
|
2433
|
+
"logical_bytes"
|
|
2434
|
+
);
|
|
2435
|
+
const bytesToFree = retainedBytes + logicalBytes - maxOtlpSpoolBytes;
|
|
2436
|
+
if (bytesToFree > 0) {
|
|
2437
|
+
const plan = otlpCapacityEvictionPlan.get(bytesToFree);
|
|
2438
|
+
const evictedRecords = requireSpoolInteger(plan, "record_count");
|
|
2439
|
+
const evictedLogicalBytes = requireSpoolInteger(
|
|
2440
|
+
plan,
|
|
2441
|
+
"logical_bytes"
|
|
2442
|
+
);
|
|
2443
|
+
const evictedBodyBytes = requireSpoolInteger(plan, "body_bytes");
|
|
2444
|
+
if (evictedRecords === 0 || evictedLogicalBytes < bytesToFree || evictOldestPendingOtlpEnvelopes.run(evictedRecords).changes !== evictedRecords) {
|
|
2445
|
+
throw new Error(
|
|
2446
|
+
"FML observer OTLP spool accounting could not enforce its limit"
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
recordOtlpDrop(
|
|
2450
|
+
evictedRecords,
|
|
2451
|
+
evictedLogicalBytes,
|
|
2452
|
+
evictedBodyBytes,
|
|
2453
|
+
capturedAtMs,
|
|
2454
|
+
OTLP_CAPACITY_DROP_REASON
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
captureOtlpEnvelope.run(
|
|
2458
|
+
envelopeId,
|
|
2459
|
+
envelope.observerInstanceId,
|
|
2460
|
+
envelope.observerVersion,
|
|
2461
|
+
signal,
|
|
2462
|
+
contentType,
|
|
2463
|
+
contentEncoding,
|
|
2464
|
+
envelope.body,
|
|
2465
|
+
actualSha256,
|
|
2466
|
+
capturedAtMs,
|
|
2467
|
+
envelope.destination.targetUrl,
|
|
2468
|
+
envelope.destination.destinationIdentity,
|
|
2469
|
+
envelope.destination.organizationId ?? null,
|
|
2470
|
+
envelope.destination.organizationSlug ?? null,
|
|
2471
|
+
logicalBytes
|
|
2472
|
+
);
|
|
2473
|
+
return envelopeId;
|
|
2474
|
+
});
|
|
2475
|
+
},
|
|
2476
|
+
listPendingOtlpEnvelopes: (limit = DEFAULT_OTLP_OUTBOX_BATCH_SIZE) => {
|
|
2477
|
+
validateOtlpBatchLimit(limit);
|
|
2478
|
+
return listPendingOtlpEnvelopes.all(limit).map(otlpOutboxRecordFromRow);
|
|
2479
|
+
},
|
|
2480
|
+
claimPendingOtlpEnvelopes: (destination, limit = DEFAULT_OTLP_OUTBOX_BATCH_SIZE, eligibleAtMs = Date.now()) => {
|
|
2481
|
+
validateOtlpDestination(destination);
|
|
2482
|
+
validateOtlpBatchLimit(limit);
|
|
2483
|
+
requireTimestamp(eligibleAtMs, "OTLP envelope eligibility timestamp");
|
|
2484
|
+
return runImmediateTransaction(() => {
|
|
2485
|
+
if (destination.organizationId !== void 0) {
|
|
2486
|
+
claimOrganizationForOtlpEnvelopes.run(
|
|
2487
|
+
destination.organizationId,
|
|
2488
|
+
destination.organizationSlug,
|
|
2489
|
+
destination.targetUrl,
|
|
2490
|
+
destination.destinationIdentity
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
return listDestinationPendingOtlpEnvelopes.all(
|
|
2494
|
+
destination.targetUrl,
|
|
2495
|
+
destination.destinationIdentity,
|
|
2496
|
+
destination.organizationId ?? null,
|
|
2497
|
+
eligibleAtMs,
|
|
2498
|
+
limit
|
|
2499
|
+
).map(otlpOutboxRecordFromRow);
|
|
2500
|
+
});
|
|
2501
|
+
},
|
|
2502
|
+
deferOtlpEnvelopes: (destination, envelopeIds, nextAttemptAtMs) => {
|
|
2503
|
+
validateOtlpDestination(destination);
|
|
2504
|
+
requireTimestamp(
|
|
2505
|
+
nextAttemptAtMs,
|
|
2506
|
+
"OTLP envelope next-attempt timestamp"
|
|
2507
|
+
);
|
|
2508
|
+
const uniqueEnvelopeIds = [...new Set(envelopeIds)];
|
|
2509
|
+
if (uniqueEnvelopeIds.length === 0) return 0;
|
|
2510
|
+
if (uniqueEnvelopeIds.length > MAX_OTLP_OUTBOX_BATCH_SIZE) {
|
|
2511
|
+
throw new Error(
|
|
2512
|
+
`Cannot defer more than ${MAX_OTLP_OUTBOX_BATCH_SIZE} OTLP envelopes at once`
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
for (const envelopeId of uniqueEnvelopeIds) {
|
|
2516
|
+
requireBoundedString(
|
|
2517
|
+
envelopeId,
|
|
2518
|
+
"OTLP envelope ID",
|
|
2519
|
+
MAX_OTLP_ENVELOPE_ID_BYTES
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
return runImmediateTransaction(() => {
|
|
2523
|
+
let deferred = 0;
|
|
2524
|
+
for (const envelopeId of uniqueEnvelopeIds) {
|
|
2525
|
+
deferred += deferDestinationPendingOtlpEnvelope.run(
|
|
2526
|
+
nextAttemptAtMs,
|
|
2527
|
+
destination.targetUrl,
|
|
2528
|
+
destination.destinationIdentity,
|
|
2529
|
+
destination.organizationId ?? null,
|
|
2530
|
+
envelopeId
|
|
2531
|
+
).changes;
|
|
2532
|
+
}
|
|
2533
|
+
return deferred;
|
|
2534
|
+
});
|
|
2535
|
+
},
|
|
2536
|
+
nextPendingOtlpAttemptAtMs: (destination) => {
|
|
2537
|
+
validateOtlpDestination(destination);
|
|
2538
|
+
const value = nextDestinationPendingOtlpAttempt.get(
|
|
2539
|
+
destination.targetUrl,
|
|
2540
|
+
destination.destinationIdentity,
|
|
2541
|
+
destination.organizationId ?? null
|
|
2542
|
+
)?.next_attempt_at_ms;
|
|
2543
|
+
if (value === null || value === void 0) return void 0;
|
|
2544
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
2545
|
+
throw new Error(
|
|
2546
|
+
"FML observer OTLP outbox next-attempt timestamp is invalid"
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
return value;
|
|
2550
|
+
},
|
|
2551
|
+
countPendingOtlpEnvelopes: () => {
|
|
2552
|
+
const count = countPendingOtlpEnvelopes.get()?.count;
|
|
2553
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count)) {
|
|
2554
|
+
throw new Error("FML observer OTLP outbox count is invalid");
|
|
2555
|
+
}
|
|
2556
|
+
return count;
|
|
2557
|
+
},
|
|
2558
|
+
acknowledgeOtlpEnvelopes: (destination, envelopeIds) => {
|
|
2559
|
+
validateOtlpDestination(destination);
|
|
2560
|
+
const uniqueEnvelopeIds = [...new Set(envelopeIds)];
|
|
2561
|
+
if (uniqueEnvelopeIds.length === 0) return 0;
|
|
2562
|
+
if (uniqueEnvelopeIds.length > MAX_OTLP_OUTBOX_BATCH_SIZE) {
|
|
2563
|
+
throw new Error(
|
|
2564
|
+
`Cannot acknowledge more than ${MAX_OTLP_OUTBOX_BATCH_SIZE} OTLP envelopes at once`
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
for (const envelopeId of uniqueEnvelopeIds) {
|
|
2568
|
+
requireBoundedString(
|
|
2569
|
+
envelopeId,
|
|
2570
|
+
"OTLP envelope ID",
|
|
2571
|
+
MAX_OTLP_ENVELOPE_ID_BYTES
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
const placeholders = uniqueEnvelopeIds.map(() => "?").join(", ");
|
|
2575
|
+
const acknowledge = database.prepare(`
|
|
2576
|
+
DELETE FROM otlp_envelope_outbox
|
|
2577
|
+
WHERE destination_url = ?
|
|
2578
|
+
AND destination_identity = ?
|
|
2579
|
+
AND destination_organization_id IS ?
|
|
2580
|
+
AND envelope_id IN (${placeholders})
|
|
2581
|
+
`);
|
|
2582
|
+
return runImmediateTransaction(
|
|
2583
|
+
() => acknowledge.run(
|
|
2584
|
+
destination.targetUrl,
|
|
2585
|
+
destination.destinationIdentity,
|
|
2586
|
+
destination.organizationId ?? null,
|
|
2587
|
+
...uniqueEnvelopeIds
|
|
2588
|
+
).changes
|
|
2589
|
+
);
|
|
2590
|
+
},
|
|
2591
|
+
dropOtlpEnvelopes: (destination, drops, droppedAtMs = Date.now()) => {
|
|
2592
|
+
validateOtlpDestination(destination);
|
|
2593
|
+
const uniqueDrops = [
|
|
2594
|
+
...new Map(drops.map((drop) => [drop.envelopeId, drop])).values()
|
|
2595
|
+
];
|
|
2596
|
+
if (uniqueDrops.length === 0) return 0;
|
|
2597
|
+
if (uniqueDrops.length > MAX_OTLP_OUTBOX_BATCH_SIZE) {
|
|
2598
|
+
throw new Error(
|
|
2599
|
+
`Cannot drop more than ${MAX_OTLP_OUTBOX_BATCH_SIZE} OTLP envelopes at once`
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
requireTimestamp(droppedAtMs, "OTLP drop timestamp");
|
|
2603
|
+
for (const drop of uniqueDrops) {
|
|
2604
|
+
requireBoundedString(
|
|
2605
|
+
drop.envelopeId,
|
|
2606
|
+
"OTLP envelope ID",
|
|
2607
|
+
MAX_OTLP_ENVELOPE_ID_BYTES
|
|
2608
|
+
);
|
|
2609
|
+
requireBoundedString(
|
|
2610
|
+
drop.reason,
|
|
2611
|
+
"OTLP drop reason",
|
|
2612
|
+
MAX_OTLP_DROP_REASON_BYTES
|
|
2613
|
+
);
|
|
2614
|
+
}
|
|
2615
|
+
return runImmediateTransaction(() => {
|
|
2616
|
+
let droppedRecords = 0;
|
|
2617
|
+
let droppedLogicalBytes = 0;
|
|
2618
|
+
let droppedBodyBytes = 0;
|
|
2619
|
+
let lastDropReason = uniqueDrops[0].reason;
|
|
2620
|
+
for (const drop of uniqueDrops) {
|
|
2621
|
+
const row = getDestinationPendingOtlpEnvelopeAccounting.get(
|
|
2622
|
+
destination.targetUrl,
|
|
2623
|
+
destination.destinationIdentity,
|
|
2624
|
+
destination.organizationId ?? null,
|
|
2625
|
+
drop.envelopeId
|
|
2626
|
+
);
|
|
2627
|
+
if (row === void 0) continue;
|
|
2628
|
+
const logicalBytes = requireSpoolInteger(row, "logical_bytes");
|
|
2629
|
+
const bodyBytes = requireSpoolInteger(row, "body_bytes");
|
|
2630
|
+
const deleted = deleteDestinationPendingOtlpEnvelope.run(
|
|
2631
|
+
destination.targetUrl,
|
|
2632
|
+
destination.destinationIdentity,
|
|
2633
|
+
destination.organizationId ?? null,
|
|
2634
|
+
drop.envelopeId
|
|
2635
|
+
).changes;
|
|
2636
|
+
if (deleted !== 1) {
|
|
2637
|
+
throw new Error("FML observer OTLP fenced drop was incomplete");
|
|
2638
|
+
}
|
|
2639
|
+
droppedRecords += 1;
|
|
2640
|
+
droppedLogicalBytes += logicalBytes;
|
|
2641
|
+
droppedBodyBytes += bodyBytes;
|
|
2642
|
+
lastDropReason = drop.reason;
|
|
2643
|
+
}
|
|
2644
|
+
recordOtlpDrop(
|
|
2645
|
+
droppedRecords,
|
|
2646
|
+
droppedLogicalBytes,
|
|
2647
|
+
droppedBodyBytes,
|
|
2648
|
+
droppedAtMs,
|
|
2649
|
+
lastDropReason
|
|
2650
|
+
);
|
|
2651
|
+
return droppedRecords;
|
|
2652
|
+
});
|
|
2653
|
+
},
|
|
2654
|
+
getOtlpSpoolStatus: () => {
|
|
2655
|
+
const row = getOtlpSpoolState.get();
|
|
2656
|
+
const lastDroppedAtMs = row?.last_dropped_at_ms;
|
|
2657
|
+
if (lastDroppedAtMs !== null && lastDroppedAtMs !== void 0 && (typeof lastDroppedAtMs !== "number" || !Number.isSafeInteger(lastDroppedAtMs) || lastDroppedAtMs < 0)) {
|
|
2658
|
+
throw new Error("FML observer OTLP drop timestamp is invalid");
|
|
2659
|
+
}
|
|
2660
|
+
const lastDropReason = row?.last_drop_reason;
|
|
2661
|
+
if (lastDropReason !== null && lastDropReason !== void 0 && typeof lastDropReason !== "string") {
|
|
2662
|
+
throw new Error("FML observer OTLP drop reason is invalid");
|
|
2663
|
+
}
|
|
2664
|
+
return {
|
|
2665
|
+
unit: "logical_bytes",
|
|
2666
|
+
maxBytes: maxOtlpSpoolBytes,
|
|
2667
|
+
logicalBytes: requireSpoolInteger(row, "logical_bytes"),
|
|
2668
|
+
bodyBytes: requireSpoolInteger(row, "body_bytes"),
|
|
2669
|
+
droppedRecords: requireSpoolInteger(row, "dropped_records"),
|
|
2670
|
+
droppedLogicalBytes: requireSpoolInteger(
|
|
2671
|
+
row,
|
|
2672
|
+
"dropped_logical_bytes"
|
|
2673
|
+
),
|
|
2674
|
+
droppedBodyBytes: requireSpoolInteger(row, "dropped_body_bytes"),
|
|
2675
|
+
...typeof lastDroppedAtMs === "number" ? { lastDroppedAtMs } : {},
|
|
2676
|
+
...typeof lastDropReason === "string" ? { lastDropReason } : {}
|
|
2677
|
+
};
|
|
2678
|
+
},
|
|
2679
|
+
close: () => database.close()
|
|
2680
|
+
};
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
try {
|
|
2683
|
+
database.exec("ROLLBACK");
|
|
2684
|
+
} catch {
|
|
2685
|
+
}
|
|
2686
|
+
database.close();
|
|
2687
|
+
throw error;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// ../../packages/panopticon/src/observer/protocol.ts
|
|
2692
|
+
var OBSERVER_PROTOCOL_VERSION = 1;
|
|
2693
|
+
var OBSERVER_SERVICE_NAME = "fml-observer";
|
|
2694
|
+
function isObserverHealth(value) {
|
|
2695
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
2696
|
+
const record = value;
|
|
2697
|
+
return record.status === "ok" && record.service === OBSERVER_SERVICE_NAME && record.protocolVersion === OBSERVER_PROTOCOL_VERSION && typeof record.instanceId === "string" && record.instanceId.length > 0 && typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0 && typeof record.port === "number" && Number.isInteger(record.port) && record.port > 0 && typeof record.version === "string" && (record.capabilities === void 0 || Array.isArray(record.capabilities) && record.capabilities.every(
|
|
2698
|
+
(capability2) => typeof capability2 === "string"
|
|
2699
|
+
));
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// ../../packages/panopticon/src/observer/server.ts
|
|
2703
|
+
import http from "http";
|
|
2704
|
+
var MAX_HOOK_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
2705
|
+
var LOOPBACK_HOST_HEADER_PATTERN = /^(?:127\.0\.0\.1|localhost|\[::1\])(?::(\d{1,5}))?$/iu;
|
|
2706
|
+
var HookRequestBodyError = class extends Error {
|
|
2707
|
+
constructor(status, code) {
|
|
2708
|
+
super(code);
|
|
2709
|
+
this.status = status;
|
|
2710
|
+
this.code = code;
|
|
2711
|
+
}
|
|
2712
|
+
};
|
|
2713
|
+
var OtlpRequestBodyError = class extends Error {
|
|
2714
|
+
constructor(status, rpcCode, message) {
|
|
2715
|
+
super(message);
|
|
2716
|
+
this.status = status;
|
|
2717
|
+
this.rpcCode = rpcCode;
|
|
2718
|
+
}
|
|
2719
|
+
};
|
|
2720
|
+
function isAllowedObserverHost(host) {
|
|
2721
|
+
if (host === void 0) return false;
|
|
2722
|
+
const match = LOOPBACK_HOST_HEADER_PATTERN.exec(host);
|
|
2723
|
+
if (!match) return false;
|
|
2724
|
+
const port = match[1];
|
|
2725
|
+
if (port === void 0) return true;
|
|
2726
|
+
const numericPort = Number(port);
|
|
2727
|
+
return numericPort > 0 && numericPort <= 65535;
|
|
2728
|
+
}
|
|
2729
|
+
function writeJson(res, status, body) {
|
|
2730
|
+
const payload = JSON.stringify(body);
|
|
2731
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2732
|
+
res.end(payload);
|
|
2733
|
+
}
|
|
2734
|
+
function readHookRequestBody(req) {
|
|
2735
|
+
const contentLengthHeader = req.headers["content-length"];
|
|
2736
|
+
if (contentLengthHeader !== void 0) {
|
|
2737
|
+
if (!/^\d+$/.test(contentLengthHeader)) {
|
|
2738
|
+
throw new HookRequestBodyError(400, "invalid_json");
|
|
2739
|
+
}
|
|
2740
|
+
if (Number(contentLengthHeader) > MAX_HOOK_REQUEST_BODY_BYTES) {
|
|
2741
|
+
req.resume();
|
|
2742
|
+
throw new HookRequestBodyError(413, "payload_too_large");
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
return new Promise((resolve, reject) => {
|
|
2746
|
+
const chunks = [];
|
|
2747
|
+
let byteCount = 0;
|
|
2748
|
+
let settled = false;
|
|
2749
|
+
const rejectOnce = (error) => {
|
|
2750
|
+
if (settled) return;
|
|
2751
|
+
settled = true;
|
|
2752
|
+
chunks.length = 0;
|
|
2753
|
+
reject(error);
|
|
2754
|
+
};
|
|
2755
|
+
req.on("data", (chunk) => {
|
|
2756
|
+
if (settled) return;
|
|
2757
|
+
byteCount += chunk.byteLength;
|
|
2758
|
+
if (byteCount > MAX_HOOK_REQUEST_BODY_BYTES) {
|
|
2759
|
+
rejectOnce(new HookRequestBodyError(413, "payload_too_large"));
|
|
2760
|
+
req.resume();
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
chunks.push(chunk);
|
|
2764
|
+
});
|
|
2765
|
+
req.on("end", () => {
|
|
2766
|
+
if (settled) return;
|
|
2767
|
+
settled = true;
|
|
2768
|
+
try {
|
|
2769
|
+
resolve(JSON.parse(Buffer.concat(chunks, byteCount).toString("utf8")));
|
|
2770
|
+
} catch {
|
|
2771
|
+
reject(new HookRequestBodyError(400, "invalid_json"));
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2774
|
+
req.on("error", (error) => {
|
|
2775
|
+
rejectOnce(error);
|
|
2776
|
+
});
|
|
2777
|
+
req.on("aborted", () => rejectOnce(new Error("hook request aborted")));
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
function getOtlpRoute(pathname) {
|
|
2781
|
+
const directSignal = pathname.startsWith("/v1/") ? pathname.slice("/v1/".length) : void 0;
|
|
2782
|
+
if (directSignal === "logs" || directSignal === "metrics" || directSignal === "traces") {
|
|
2783
|
+
return { signal: directSignal };
|
|
2784
|
+
}
|
|
2785
|
+
const segments = pathname.split("/");
|
|
2786
|
+
if (segments.length === 5 && segments[0] === "" && segments[1] === "otel" && segments[3] === "v1" && (segments[4] === "logs" || segments[4] === "metrics" || segments[4] === "traces")) {
|
|
2787
|
+
try {
|
|
2788
|
+
return {
|
|
2789
|
+
signal: segments[4],
|
|
2790
|
+
pathToken: decodeURIComponent(segments[2] ?? "")
|
|
2791
|
+
};
|
|
2792
|
+
} catch {
|
|
2793
|
+
return void 0;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return void 0;
|
|
2797
|
+
}
|
|
2798
|
+
function parseOtlpContentType(header) {
|
|
2799
|
+
const mediaType = header?.split(";", 1)[0]?.trim().toLowerCase();
|
|
2800
|
+
if (mediaType === "application/json" || mediaType === "application/x-protobuf") {
|
|
2801
|
+
return mediaType;
|
|
2802
|
+
}
|
|
2803
|
+
return void 0;
|
|
2804
|
+
}
|
|
2805
|
+
function parseOtlpContentEncoding(header) {
|
|
2806
|
+
if (header === void 0) return "identity";
|
|
2807
|
+
const encoding = header.trim().toLowerCase();
|
|
2808
|
+
if (encoding === "identity" || encoding === "gzip") return encoding;
|
|
2809
|
+
return void 0;
|
|
2810
|
+
}
|
|
2811
|
+
function encodeVarint(value) {
|
|
2812
|
+
const bytes = [];
|
|
2813
|
+
let remaining = value;
|
|
2814
|
+
do {
|
|
2815
|
+
let byte = remaining & 127;
|
|
2816
|
+
remaining >>>= 7;
|
|
2817
|
+
if (remaining > 0) byte |= 128;
|
|
2818
|
+
bytes.push(byte);
|
|
2819
|
+
} while (remaining > 0);
|
|
2820
|
+
return Buffer.from(bytes);
|
|
2821
|
+
}
|
|
2822
|
+
function encodeProtobufStatus(code, message) {
|
|
2823
|
+
const messageBytes = Buffer.from(message, "utf8");
|
|
2824
|
+
return Buffer.concat([
|
|
2825
|
+
Buffer.from([8]),
|
|
2826
|
+
encodeVarint(code),
|
|
2827
|
+
Buffer.from([18]),
|
|
2828
|
+
encodeVarint(messageBytes.byteLength),
|
|
2829
|
+
messageBytes
|
|
2830
|
+
]);
|
|
2831
|
+
}
|
|
2832
|
+
function writeOtlpError(res, status, contentType, rpcCode, message, extraHeaders = {}) {
|
|
2833
|
+
const payload = contentType === "application/x-protobuf" ? encodeProtobufStatus(rpcCode, message) : Buffer.from(JSON.stringify({ code: rpcCode, message }), "utf8");
|
|
2834
|
+
res.writeHead(status, {
|
|
2835
|
+
"Content-Type": contentType,
|
|
2836
|
+
"Content-Length": String(payload.byteLength),
|
|
2837
|
+
...extraHeaders
|
|
2838
|
+
});
|
|
2839
|
+
res.end(payload);
|
|
2840
|
+
}
|
|
2841
|
+
function writeOtlpSuccess(res, contentType) {
|
|
2842
|
+
const payload = contentType === "application/x-protobuf" ? Buffer.alloc(0) : Buffer.from("{}", "utf8");
|
|
2843
|
+
res.writeHead(200, {
|
|
2844
|
+
"Content-Type": contentType,
|
|
2845
|
+
"Content-Length": String(payload.byteLength)
|
|
2846
|
+
});
|
|
2847
|
+
res.end(payload);
|
|
2848
|
+
}
|
|
2849
|
+
function readOpaqueOtlpBody(req) {
|
|
2850
|
+
const contentLengthHeader = req.headers["content-length"];
|
|
2851
|
+
if (contentLengthHeader !== void 0) {
|
|
2852
|
+
if (!/^\d+$/.test(contentLengthHeader)) {
|
|
2853
|
+
req.resume();
|
|
2854
|
+
throw new OtlpRequestBodyError(400, 3, "invalid content-length");
|
|
2855
|
+
}
|
|
2856
|
+
if (Number(contentLengthHeader) > MAX_OTLP_ENVELOPE_BYTES) {
|
|
2857
|
+
req.resume();
|
|
2858
|
+
throw new OtlpRequestBodyError(413, 8, "OTLP request exceeds 16 MiB");
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
return new Promise((resolve, reject) => {
|
|
2862
|
+
const chunks = [];
|
|
2863
|
+
let byteCount = 0;
|
|
2864
|
+
let settled = false;
|
|
2865
|
+
const rejectOnce = (error) => {
|
|
2866
|
+
if (settled) return;
|
|
2867
|
+
settled = true;
|
|
2868
|
+
chunks.length = 0;
|
|
2869
|
+
reject(error);
|
|
2870
|
+
};
|
|
2871
|
+
req.on("data", (chunk) => {
|
|
2872
|
+
if (settled) return;
|
|
2873
|
+
byteCount += chunk.byteLength;
|
|
2874
|
+
if (byteCount > MAX_OTLP_ENVELOPE_BYTES) {
|
|
2875
|
+
req.resume();
|
|
2876
|
+
rejectOnce(
|
|
2877
|
+
new OtlpRequestBodyError(413, 8, "OTLP request exceeds 16 MiB")
|
|
2878
|
+
);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
chunks.push(chunk);
|
|
2882
|
+
});
|
|
2883
|
+
req.on("end", () => {
|
|
2884
|
+
if (settled) return;
|
|
2885
|
+
settled = true;
|
|
2886
|
+
resolve(Buffer.concat(chunks, byteCount));
|
|
2887
|
+
});
|
|
2888
|
+
req.on("aborted", () => rejectOnce(new Error("OTLP request aborted")));
|
|
2889
|
+
req.on("error", rejectOnce);
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
async function handleOtlpRequest(req, res, opts, route, method) {
|
|
2893
|
+
const responseContentType = parseOtlpContentType(req.headers["content-type"]) ?? "application/json";
|
|
2894
|
+
const authenticated = route.pathToken === void 0 ? hasObserverBearerToken(req, opts.authToken) : isObserverAuthToken(route.pathToken, opts.authToken);
|
|
2895
|
+
if (!authenticated) {
|
|
2896
|
+
req.resume();
|
|
2897
|
+
writeOtlpError(res, 401, responseContentType, 16, "unauthorized");
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
if (method !== "POST") {
|
|
2901
|
+
req.resume();
|
|
2902
|
+
writeOtlpError(res, 405, responseContentType, 12, "method not allowed", {
|
|
2903
|
+
Allow: "POST"
|
|
2904
|
+
});
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
const contentType = parseOtlpContentType(req.headers["content-type"]);
|
|
2908
|
+
if (contentType === void 0) {
|
|
2909
|
+
req.resume();
|
|
2910
|
+
writeOtlpError(
|
|
2911
|
+
res,
|
|
2912
|
+
415,
|
|
2913
|
+
"application/json",
|
|
2914
|
+
3,
|
|
2915
|
+
"content-type must be application/json or application/x-protobuf"
|
|
2916
|
+
);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const contentEncoding = parseOtlpContentEncoding(
|
|
2920
|
+
req.headers["content-encoding"]
|
|
2921
|
+
);
|
|
2922
|
+
if (contentEncoding === void 0) {
|
|
2923
|
+
req.resume();
|
|
2924
|
+
writeOtlpError(
|
|
2925
|
+
res,
|
|
2926
|
+
415,
|
|
2927
|
+
contentType,
|
|
2928
|
+
12,
|
|
2929
|
+
"content-encoding must be identity or gzip"
|
|
2930
|
+
);
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (opts.onOtlp === void 0) {
|
|
2934
|
+
req.resume();
|
|
2935
|
+
writeOtlpError(res, 503, contentType, 14, "OTLP capture is unavailable", {
|
|
2936
|
+
"Retry-After": "1"
|
|
2937
|
+
});
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
const capturedAtMs = Date.now();
|
|
2941
|
+
let body;
|
|
2942
|
+
try {
|
|
2943
|
+
body = await readOpaqueOtlpBody(req);
|
|
2944
|
+
} catch (error) {
|
|
2945
|
+
if (error instanceof OtlpRequestBodyError) {
|
|
2946
|
+
writeOtlpError(
|
|
2947
|
+
res,
|
|
2948
|
+
error.status,
|
|
2949
|
+
contentType,
|
|
2950
|
+
error.rpcCode,
|
|
2951
|
+
error.message
|
|
2952
|
+
);
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
writeOtlpError(res, 400, contentType, 3, "failed to read OTLP request");
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
try {
|
|
2959
|
+
const health = opts.getHealth();
|
|
2960
|
+
await opts.onOtlp({
|
|
2961
|
+
observerInstanceId: health.instanceId,
|
|
2962
|
+
observerVersion: health.version,
|
|
2963
|
+
signal: route.signal,
|
|
2964
|
+
contentType,
|
|
2965
|
+
contentEncoding,
|
|
2966
|
+
body,
|
|
2967
|
+
capturedAtMs
|
|
2968
|
+
});
|
|
2969
|
+
} catch (error) {
|
|
2970
|
+
console.error("FML observer OTLP capture failed", error);
|
|
2971
|
+
writeOtlpError(res, 503, contentType, 14, "OTLP capture failed", {
|
|
2972
|
+
"Retry-After": "1"
|
|
2973
|
+
});
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
writeOtlpSuccess(res, contentType);
|
|
2977
|
+
}
|
|
2978
|
+
async function handleObserverRequest(req, res, opts) {
|
|
2979
|
+
try {
|
|
2980
|
+
if (!isAllowedObserverHost(req.headers.host)) {
|
|
2981
|
+
writeJson(res, 403, { error: "invalid_host" });
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
2985
|
+
const method = req.method ?? "GET";
|
|
2986
|
+
if (pathname === "/health") {
|
|
2987
|
+
if (method !== "GET") {
|
|
2988
|
+
writeJson(res, 405, { error: "method_not_allowed" });
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
writeJson(res, 200, opts.getHealth());
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
const otlpRoute = getOtlpRoute(pathname);
|
|
2995
|
+
if (otlpRoute !== void 0) {
|
|
2996
|
+
await handleOtlpRequest(req, res, opts, otlpRoute, method);
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
const knownProtectedPath = pathname === "/status" || pathname === "/sync/wake" || pathname === "/hooks" || pathname === "/control/stop";
|
|
3000
|
+
if (!knownProtectedPath) {
|
|
3001
|
+
writeJson(res, 404, { error: "not_found" });
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
if (!requireObserverBearerToken(req, res, opts.authToken)) return;
|
|
3005
|
+
if (pathname === "/status") {
|
|
3006
|
+
if (method !== "GET") {
|
|
3007
|
+
writeJson(res, 405, { error: "method_not_allowed" });
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
writeJson(res, 200, await opts.getStatus());
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
if (pathname === "/sync/wake") {
|
|
3014
|
+
if (method !== "POST") {
|
|
3015
|
+
writeJson(res, 405, { error: "method_not_allowed" });
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
opts.onWake();
|
|
3019
|
+
writeJson(res, 202, { ok: true });
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
if (pathname === "/hooks") {
|
|
3023
|
+
if (method !== "POST") {
|
|
3024
|
+
writeJson(res, 405, { error: "method_not_allowed" });
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
let payload;
|
|
3028
|
+
try {
|
|
3029
|
+
payload = await readHookRequestBody(req);
|
|
3030
|
+
} catch (error) {
|
|
3031
|
+
if (error instanceof HookRequestBodyError) {
|
|
3032
|
+
writeJson(res, error.status, { error: error.code });
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
throw error;
|
|
3036
|
+
}
|
|
3037
|
+
await opts.onHook(payload);
|
|
3038
|
+
writeJson(res, 202, {});
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (method !== "POST") {
|
|
3042
|
+
writeJson(res, 405, { error: "method_not_allowed" });
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
writeJson(res, 202, { ok: true });
|
|
3046
|
+
setImmediate(() => {
|
|
3047
|
+
try {
|
|
3048
|
+
opts.onShutdown();
|
|
3049
|
+
} catch (error) {
|
|
3050
|
+
console.error("FML observer shutdown handler failed", error);
|
|
3051
|
+
}
|
|
3052
|
+
});
|
|
3053
|
+
} catch (error) {
|
|
3054
|
+
console.error("FML observer request handler failed", error);
|
|
3055
|
+
if (!res.headersSent) {
|
|
3056
|
+
writeJson(res, 500, { error: "internal_error" });
|
|
3057
|
+
} else {
|
|
3058
|
+
res.destroy();
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
function createObserverHttpServer(opts) {
|
|
3063
|
+
return http.createServer(async (req, res) => {
|
|
3064
|
+
await handleObserverRequest(req, res, opts);
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// src/hooks/inbox.ts
|
|
3069
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3070
|
+
import fs3 from "fs";
|
|
3071
|
+
import path3 from "path";
|
|
3072
|
+
|
|
3073
|
+
// src/hooks/inbox-config.ts
|
|
3074
|
+
var BYTE_SUFFIXES = {
|
|
3075
|
+
b: 1,
|
|
3076
|
+
kib: 1024,
|
|
3077
|
+
mib: 1024 * 1024,
|
|
3078
|
+
gib: 1024 * 1024 * 1024
|
|
3079
|
+
};
|
|
3080
|
+
var MAX_BYTES_PATTERN = /^([1-9]\d*)(b|kib|mib|gib)?$/i;
|
|
3081
|
+
var POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
|
|
3082
|
+
var DEFAULT_HOOK_INBOX_MAX_BYTES = 512 * 1024 * 1024;
|
|
3083
|
+
var DEFAULT_HOOK_INBOX_MAX_ENTRIES = 1e4;
|
|
3084
|
+
function parseHookInboxMaxBytes(configured) {
|
|
3085
|
+
if (configured === void 0) return DEFAULT_HOOK_INBOX_MAX_BYTES;
|
|
3086
|
+
const value = configured.trim();
|
|
3087
|
+
if (value.toLowerCase() === "unbounded") return void 0;
|
|
3088
|
+
const match = MAX_BYTES_PATTERN.exec(value);
|
|
3089
|
+
if (!match) {
|
|
3090
|
+
throw new Error(
|
|
3091
|
+
"FML_HOOK_INBOX_MAX_BYTES must be `unbounded` or a positive integer optionally followed by B, KiB, MiB, or GiB"
|
|
3092
|
+
);
|
|
3093
|
+
}
|
|
3094
|
+
const quantity = Number(match[1]);
|
|
3095
|
+
const suffix = match[2]?.toLowerCase() ?? "b";
|
|
3096
|
+
const bytes = quantity * BYTE_SUFFIXES[suffix];
|
|
3097
|
+
if (!Number.isSafeInteger(bytes)) {
|
|
3098
|
+
throw new Error("FML_HOOK_INBOX_MAX_BYTES exceeds the safe integer range");
|
|
3099
|
+
}
|
|
3100
|
+
return bytes;
|
|
3101
|
+
}
|
|
3102
|
+
function parseHookInboxMaxEntries(configured) {
|
|
3103
|
+
if (configured === void 0) return DEFAULT_HOOK_INBOX_MAX_ENTRIES;
|
|
3104
|
+
const value = configured.trim();
|
|
3105
|
+
if (value.toLowerCase() === "unbounded") return void 0;
|
|
3106
|
+
if (!POSITIVE_INTEGER_PATTERN.test(value)) {
|
|
3107
|
+
throw new Error(
|
|
3108
|
+
"FML_HOOK_INBOX_MAX_ENTRIES must be `unbounded` or a positive integer"
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
const entries = Number(value);
|
|
3112
|
+
if (!Number.isSafeInteger(entries)) {
|
|
3113
|
+
throw new Error(
|
|
3114
|
+
"FML_HOOK_INBOX_MAX_ENTRIES exceeds the safe integer range"
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
3117
|
+
return entries;
|
|
3118
|
+
}
|
|
3119
|
+
function hookInboxLimitsFromEnv(env = process.env) {
|
|
3120
|
+
const maxBytes = parseHookInboxMaxBytes(env.FML_HOOK_INBOX_MAX_BYTES);
|
|
3121
|
+
const maxEntries = parseHookInboxMaxEntries(env.FML_HOOK_INBOX_MAX_ENTRIES);
|
|
3122
|
+
return {
|
|
3123
|
+
...maxBytes === void 0 ? {} : { maxBytes },
|
|
3124
|
+
...maxEntries === void 0 ? {} : { maxEntries }
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// src/hooks/inbox.ts
|
|
3129
|
+
var HOOK_INBOX_SCHEMA_VERSION = 1;
|
|
3130
|
+
var MAX_HOOK_INBOX_ENTRY_BYTES = 16 * 1024 * 1024;
|
|
3131
|
+
var MAX_HOOK_INBOX_DRAIN_ENTRIES = 100;
|
|
3132
|
+
var MAX_HOOK_INBOX_DRAIN_CURSORS = 8;
|
|
3133
|
+
var INBOX_ID_PATTERN = /^\d{1,16}-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
3134
|
+
var INBOX_TEMP_PATTERN = /^\.(\d{1,16}-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/i;
|
|
3135
|
+
var SYNC_ID_MAX_BYTES = 1024;
|
|
3136
|
+
var STALE_INBOX_TEMP_AGE_MS = 5 * 60 * 1e3;
|
|
3137
|
+
var DRAIN_CURSOR_SAFETY_AGE_MS = STALE_INBOX_TEMP_AGE_MS;
|
|
3138
|
+
var INBOX_METADATA_KEY = "_fmlHookInbox";
|
|
3139
|
+
var RETIREMENT_STATE_FILE = ".retirement-state.json";
|
|
3140
|
+
var RETIREMENT_STATE_SCHEMA_VERSION = 1;
|
|
3141
|
+
var CAPACITY_STATE_FILE = ".capacity-state.json";
|
|
3142
|
+
var CAPACITY_LOCK_FILE = ".capacity.lock";
|
|
3143
|
+
var CAPACITY_STATE_SCHEMA_VERSION = 1;
|
|
3144
|
+
var CAPACITY_LOCK_WAIT_MS = 2e3;
|
|
3145
|
+
var CAPACITY_LOCK_POLL_MS = 5;
|
|
3146
|
+
var CAPACITY_LOCK_INITIALIZATION_GRACE_MS = 1e3;
|
|
3147
|
+
var CAPACITY_LOCK_STALE_MS = 5 * 60 * 1e3;
|
|
3148
|
+
var INBOX_STATE_TEMP_PATTERN = /^\.(?:capacity-state|retirement-state)\.json\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/iu;
|
|
3149
|
+
var ABANDONED_CAPACITY_LOCK_PATTERN = /^\.capacity\.lock\.stale\.(\d{1,16})\.[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
3150
|
+
var UNSUPPORTED_DIRECTORY_FSYNC_CODES = /* @__PURE__ */ new Set([
|
|
3151
|
+
"EBADF",
|
|
3152
|
+
"EINVAL",
|
|
3153
|
+
"EISDIR",
|
|
3154
|
+
"ENOTSUP",
|
|
3155
|
+
"EOPNOTSUPP",
|
|
3156
|
+
"EPERM"
|
|
3157
|
+
]);
|
|
3158
|
+
var HOOK_INBOX_DRAIN_CURSORS = /* @__PURE__ */ new Map();
|
|
3159
|
+
var HookInboxCapacityError = class extends Error {
|
|
3160
|
+
reason;
|
|
3161
|
+
constructor(reason) {
|
|
3162
|
+
super(
|
|
3163
|
+
reason === "max_bytes" ? "FML hook inbox byte limit reached" : "FML hook inbox entry limit reached"
|
|
3164
|
+
);
|
|
3165
|
+
this.name = "HookInboxCapacityError";
|
|
3166
|
+
this.reason = reason;
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
function isRecord2(value) {
|
|
3170
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3171
|
+
}
|
|
3172
|
+
function validTimestamp(value) {
|
|
3173
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 864e13;
|
|
3174
|
+
}
|
|
3175
|
+
function validSyncId(value) {
|
|
3176
|
+
return typeof value === "string" && value.length > 0 && !/[\u0000-\u001f\u007f]/u.test(value) && Buffer.byteLength(value, "utf8") <= SYNC_ID_MAX_BYTES;
|
|
3177
|
+
}
|
|
3178
|
+
function parseDestination(value) {
|
|
3179
|
+
if (value === void 0) return void 0;
|
|
3180
|
+
if (!isRecord2(value)) return null;
|
|
3181
|
+
const { targetUrl, destinationIdentity } = value;
|
|
3182
|
+
if (typeof targetUrl !== "string" || targetUrl.length === 0 || Buffer.byteLength(targetUrl, "utf8") > 4096 || typeof destinationIdentity !== "string" || destinationIdentity.length === 0 || Buffer.byteLength(destinationIdentity, "utf8") > 4096) {
|
|
3183
|
+
return null;
|
|
3184
|
+
}
|
|
3185
|
+
return { targetUrl, destinationIdentity };
|
|
3186
|
+
}
|
|
3187
|
+
function parseMetadata(value) {
|
|
3188
|
+
if (!isRecord2(value)) return null;
|
|
3189
|
+
if (typeof value.id !== "string" || !INBOX_ID_PATTERN.test(value.id) || !validSyncId(value.syncId) || !validTimestamp(value.capturedAtMs)) {
|
|
3190
|
+
return null;
|
|
3191
|
+
}
|
|
3192
|
+
const destination = parseDestination(value.destination);
|
|
3193
|
+
if (destination === null) return null;
|
|
3194
|
+
const repository = value.repository;
|
|
3195
|
+
if (repository !== void 0 && (typeof repository !== "string" || repository.length === 0 || Buffer.byteLength(repository, "utf8") > 4096)) {
|
|
3196
|
+
return null;
|
|
3197
|
+
}
|
|
3198
|
+
return {
|
|
3199
|
+
id: value.id,
|
|
3200
|
+
syncId: value.syncId,
|
|
3201
|
+
capturedAtMs: value.capturedAtMs,
|
|
3202
|
+
...repository === void 0 ? {} : { repository },
|
|
3203
|
+
...destination === void 0 ? {} : { destination }
|
|
3204
|
+
};
|
|
3205
|
+
}
|
|
3206
|
+
function entryFileName(id) {
|
|
3207
|
+
if (!INBOX_ID_PATTERN.test(id)) {
|
|
3208
|
+
throw new Error("Invalid FML hook inbox entry ID");
|
|
3209
|
+
}
|
|
3210
|
+
return `${id}.json`;
|
|
3211
|
+
}
|
|
3212
|
+
function entryPath(inboxDir, id) {
|
|
3213
|
+
return path3.join(inboxDir, entryFileName(id));
|
|
3214
|
+
}
|
|
3215
|
+
function enforceOwnerOnlyDirectory(inboxDir) {
|
|
3216
|
+
fs3.mkdirSync(inboxDir, { recursive: true, mode: 448 });
|
|
3217
|
+
try {
|
|
3218
|
+
fs3.chmodSync(inboxDir, 448);
|
|
3219
|
+
} catch {
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
function fsyncDirectory(inboxDir) {
|
|
3223
|
+
if (process.platform === "win32") return;
|
|
3224
|
+
let descriptor;
|
|
3225
|
+
try {
|
|
3226
|
+
descriptor = fs3.openSync(inboxDir, "r");
|
|
3227
|
+
fs3.fsyncSync(descriptor);
|
|
3228
|
+
} catch (error) {
|
|
3229
|
+
const code = error.code;
|
|
3230
|
+
if (code === void 0 || !UNSUPPORTED_DIRECTORY_FSYNC_CODES.has(code)) {
|
|
3231
|
+
throw error;
|
|
3232
|
+
}
|
|
3233
|
+
} finally {
|
|
3234
|
+
if (descriptor !== void 0) fs3.closeSync(descriptor);
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
async function fsyncDirectoryAsync(inboxDir) {
|
|
3238
|
+
if (process.platform === "win32") return;
|
|
3239
|
+
let descriptor;
|
|
3240
|
+
try {
|
|
3241
|
+
descriptor = await fs3.promises.open(inboxDir, "r");
|
|
3242
|
+
await descriptor.sync();
|
|
3243
|
+
} catch (error) {
|
|
3244
|
+
const code = error.code;
|
|
3245
|
+
if (code === void 0 || !UNSUPPORTED_DIRECTORY_FSYNC_CODES.has(code)) {
|
|
3246
|
+
throw error;
|
|
3247
|
+
}
|
|
3248
|
+
} finally {
|
|
3249
|
+
await descriptor?.close();
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
function defaultRetirementState() {
|
|
3253
|
+
return {
|
|
3254
|
+
schemaVersion: RETIREMENT_STATE_SCHEMA_VERSION,
|
|
3255
|
+
retiredEntries: 0,
|
|
3256
|
+
retiredBytes: 0
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
function nonNegativeInteger(value) {
|
|
3260
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
3261
|
+
}
|
|
3262
|
+
function saturatingAdd(left, right) {
|
|
3263
|
+
return Math.min(Number.MAX_SAFE_INTEGER, left + right);
|
|
3264
|
+
}
|
|
3265
|
+
function parseCapacityRejectionReason(value) {
|
|
3266
|
+
return value === "max_bytes" || value === "max_entries" ? value : void 0;
|
|
3267
|
+
}
|
|
3268
|
+
function parseNullablePositiveInteger(value) {
|
|
3269
|
+
if (value === null) return null;
|
|
3270
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : void 0;
|
|
3271
|
+
}
|
|
3272
|
+
function parsePendingCapacityMutation(value) {
|
|
3273
|
+
if (value === void 0) return void 0;
|
|
3274
|
+
if (!isRecord2(value)) return null;
|
|
3275
|
+
if (typeof value.fileName !== "string" || !value.fileName.endsWith(".json") || !INBOX_ID_PATTERN.test(value.fileName.slice(0, -5)) || !nonNegativeInteger(value.bytes) || !validTimestamp(value.startedAtMs)) {
|
|
3276
|
+
return null;
|
|
3277
|
+
}
|
|
3278
|
+
let temporaryFileName;
|
|
3279
|
+
if (value.temporaryFileName !== void 0) {
|
|
3280
|
+
if (typeof value.temporaryFileName !== "string") return null;
|
|
3281
|
+
const temporaryMatch = INBOX_TEMP_PATTERN.exec(value.temporaryFileName);
|
|
3282
|
+
if (!temporaryMatch || temporaryMatch[1]?.toLowerCase() !== value.fileName.slice(0, -5).toLowerCase()) {
|
|
3283
|
+
return null;
|
|
3284
|
+
}
|
|
3285
|
+
temporaryFileName = value.temporaryFileName;
|
|
3286
|
+
}
|
|
3287
|
+
return {
|
|
3288
|
+
fileName: value.fileName,
|
|
3289
|
+
...temporaryFileName === void 0 ? {} : { temporaryFileName },
|
|
3290
|
+
bytes: value.bytes,
|
|
3291
|
+
startedAtMs: value.startedAtMs
|
|
3292
|
+
};
|
|
3293
|
+
}
|
|
3294
|
+
function parseCapacityState(value) {
|
|
3295
|
+
if (!isRecord2(value) || value.schemaVersion !== CAPACITY_STATE_SCHEMA_VERSION || !nonNegativeInteger(value.pendingEntries) || !nonNegativeInteger(value.pendingBytes) || !nonNegativeInteger(value.rejectedEntries) || !nonNegativeInteger(value.rejectedBytes)) {
|
|
3296
|
+
return null;
|
|
3297
|
+
}
|
|
3298
|
+
const maxEntries = parseNullablePositiveInteger(value.maxEntries);
|
|
3299
|
+
const maxBytes = parseNullablePositiveInteger(value.maxBytes);
|
|
3300
|
+
const pendingPersist = parsePendingCapacityMutation(value.pendingPersist);
|
|
3301
|
+
const pendingRemoval = parsePendingCapacityMutation(value.pendingRemoval);
|
|
3302
|
+
if (maxEntries === void 0 || maxBytes === void 0 || pendingPersist === null || pendingRemoval === null || pendingPersist !== void 0 && pendingRemoval !== void 0) {
|
|
3303
|
+
return null;
|
|
3304
|
+
}
|
|
3305
|
+
const lastRejectionReason = parseCapacityRejectionReason(
|
|
3306
|
+
value.lastRejectionReason
|
|
3307
|
+
);
|
|
3308
|
+
return {
|
|
3309
|
+
schemaVersion: CAPACITY_STATE_SCHEMA_VERSION,
|
|
3310
|
+
pendingEntries: value.pendingEntries,
|
|
3311
|
+
pendingBytes: value.pendingBytes,
|
|
3312
|
+
rejectedEntries: value.rejectedEntries,
|
|
3313
|
+
rejectedBytes: value.rejectedBytes,
|
|
3314
|
+
maxEntries,
|
|
3315
|
+
maxBytes,
|
|
3316
|
+
...validTimestamp(value.lastRejectedAtMs) ? { lastRejectedAtMs: value.lastRejectedAtMs } : {},
|
|
3317
|
+
...lastRejectionReason === void 0 ? {} : { lastRejectionReason },
|
|
3318
|
+
...pendingPersist === void 0 ? {} : { pendingPersist },
|
|
3319
|
+
...pendingRemoval === void 0 ? {} : { pendingRemoval }
|
|
3320
|
+
};
|
|
3321
|
+
}
|
|
3322
|
+
function capacityStatePath(inboxDir) {
|
|
3323
|
+
return path3.join(inboxDir, CAPACITY_STATE_FILE);
|
|
3324
|
+
}
|
|
3325
|
+
function readCapacityStateFile(inboxDir) {
|
|
3326
|
+
try {
|
|
3327
|
+
return parseCapacityState(
|
|
3328
|
+
JSON.parse(
|
|
3329
|
+
fs3.readFileSync(capacityStatePath(inboxDir), "utf8")
|
|
3330
|
+
)
|
|
3331
|
+
);
|
|
3332
|
+
} catch {
|
|
3333
|
+
return null;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
function defaultCapacityState(limits, pendingEntries, pendingBytes) {
|
|
3337
|
+
return {
|
|
3338
|
+
schemaVersion: CAPACITY_STATE_SCHEMA_VERSION,
|
|
3339
|
+
pendingEntries,
|
|
3340
|
+
pendingBytes,
|
|
3341
|
+
rejectedEntries: 0,
|
|
3342
|
+
rejectedBytes: 0,
|
|
3343
|
+
maxEntries: limits.maxEntries ?? null,
|
|
3344
|
+
maxBytes: limits.maxBytes ?? null
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
function writeCapacityState(inboxDir, state, capacityLock) {
|
|
3348
|
+
enforceOwnerOnlyDirectory(inboxDir);
|
|
3349
|
+
capacityLock.assertOwned();
|
|
3350
|
+
const destination = capacityStatePath(inboxDir);
|
|
3351
|
+
const temporary = path3.join(
|
|
3352
|
+
inboxDir,
|
|
3353
|
+
`${CAPACITY_STATE_FILE}.${process.pid}.${randomUUID2()}.tmp`
|
|
3354
|
+
);
|
|
3355
|
+
let descriptor;
|
|
3356
|
+
try {
|
|
3357
|
+
descriptor = fs3.openSync(temporary, "wx", 384);
|
|
3358
|
+
fs3.writeFileSync(descriptor, `${JSON.stringify(state)}
|
|
3359
|
+
`, "utf8");
|
|
3360
|
+
fs3.fsyncSync(descriptor);
|
|
3361
|
+
capacityLock.assertOwned();
|
|
3362
|
+
fs3.closeSync(descriptor);
|
|
3363
|
+
descriptor = void 0;
|
|
3364
|
+
fs3.renameSync(temporary, destination);
|
|
3365
|
+
fsyncDirectory(inboxDir);
|
|
3366
|
+
capacityLock.assertOwned();
|
|
3367
|
+
} catch (error) {
|
|
3368
|
+
if (descriptor !== void 0) fs3.closeSync(descriptor);
|
|
3369
|
+
try {
|
|
3370
|
+
fs3.rmSync(temporary, { force: true });
|
|
3371
|
+
} catch {
|
|
3372
|
+
}
|
|
3373
|
+
throw error;
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
function capacityLockIsOwned(lockPath, descriptor, ownerToken) {
|
|
3377
|
+
try {
|
|
3378
|
+
const openStat = fs3.fstatSync(descriptor);
|
|
3379
|
+
const pathStat = fs3.statSync(lockPath);
|
|
3380
|
+
if (openStat.dev !== pathStat.dev || openStat.ino !== pathStat.ino) {
|
|
3381
|
+
return false;
|
|
3382
|
+
}
|
|
3383
|
+
const value = JSON.parse(fs3.readFileSync(lockPath, "utf8"));
|
|
3384
|
+
return isRecord2(value) && value.ownerToken === ownerToken;
|
|
3385
|
+
} catch {
|
|
3386
|
+
return false;
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
function parseCapacityLockOwner(value) {
|
|
3390
|
+
if (!isRecord2(value) || typeof value.ownerToken !== "string" || value.ownerToken.length === 0 || typeof value.pid !== "number" || !Number.isSafeInteger(value.pid) || value.pid <= 0 || !validTimestamp(value.createdAtMs)) {
|
|
3391
|
+
return null;
|
|
3392
|
+
}
|
|
3393
|
+
return {
|
|
3394
|
+
ownerToken: value.ownerToken,
|
|
3395
|
+
pid: value.pid,
|
|
3396
|
+
createdAtMs: value.createdAtMs
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
3399
|
+
function readCapacityLockOwner(lockPath) {
|
|
3400
|
+
try {
|
|
3401
|
+
return parseCapacityLockOwner(
|
|
3402
|
+
JSON.parse(fs3.readFileSync(lockPath, "utf8"))
|
|
3403
|
+
);
|
|
3404
|
+
} catch {
|
|
3405
|
+
return null;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
function capacityLockOwnerIsAlive(pid) {
|
|
3409
|
+
try {
|
|
3410
|
+
process.kill(pid, 0);
|
|
3411
|
+
return true;
|
|
3412
|
+
} catch (error) {
|
|
3413
|
+
const code = error.code;
|
|
3414
|
+
if (code === "ESRCH") return false;
|
|
3415
|
+
return true;
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
function restoreMovedCapacityLock(abandonedPath, lockPath) {
|
|
3419
|
+
try {
|
|
3420
|
+
fs3.renameSync(abandonedPath, lockPath);
|
|
3421
|
+
} catch (error) {
|
|
3422
|
+
if (!capacityLockRaceError(error)) throw error;
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
function capacityLockRaceError(error) {
|
|
3426
|
+
const code = error.code;
|
|
3427
|
+
return code === "ENOENT" || code === "EEXIST" || code === "EACCES" || code === "EPERM" || code === "EBUSY";
|
|
3428
|
+
}
|
|
3429
|
+
function recoverStaleCapacityLock(lockPath, nowMs) {
|
|
3430
|
+
let observed;
|
|
3431
|
+
try {
|
|
3432
|
+
observed = fs3.statSync(lockPath);
|
|
3433
|
+
} catch (error) {
|
|
3434
|
+
if (capacityLockRaceError(error)) return;
|
|
3435
|
+
throw error;
|
|
3436
|
+
}
|
|
3437
|
+
const ageMs = Math.max(0, nowMs - observed.mtimeMs);
|
|
3438
|
+
if (ageMs < CAPACITY_LOCK_INITIALIZATION_GRACE_MS) return;
|
|
3439
|
+
const observedOwner = readCapacityLockOwner(lockPath);
|
|
3440
|
+
const beyondStaleHorizon = ageMs >= CAPACITY_LOCK_STALE_MS;
|
|
3441
|
+
if (!beyondStaleHorizon && (!observedOwner || capacityLockOwnerIsAlive(observedOwner.pid))) {
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3444
|
+
const abandoned = `${lockPath}.stale.${nowMs}.${randomUUID2()}`;
|
|
3445
|
+
try {
|
|
3446
|
+
fs3.renameSync(lockPath, abandoned);
|
|
3447
|
+
const moved = fs3.statSync(abandoned);
|
|
3448
|
+
if (moved.dev !== observed.dev || moved.ino !== observed.ino) {
|
|
3449
|
+
restoreMovedCapacityLock(abandoned, lockPath);
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
const movedOwner = readCapacityLockOwner(abandoned);
|
|
3453
|
+
const ownerChanged = observedOwner ? !movedOwner || movedOwner.ownerToken !== observedOwner.ownerToken || movedOwner.pid !== observedOwner.pid : movedOwner !== null;
|
|
3454
|
+
const ownerRevivedBeforeStaleHorizon = !beyondStaleHorizon && movedOwner !== null && capacityLockOwnerIsAlive(movedOwner.pid);
|
|
3455
|
+
if (ownerChanged || ownerRevivedBeforeStaleHorizon) {
|
|
3456
|
+
restoreMovedCapacityLock(abandoned, lockPath);
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3459
|
+
fs3.rmSync(abandoned, { force: true });
|
|
3460
|
+
fsyncDirectory(path3.dirname(lockPath));
|
|
3461
|
+
} catch (error) {
|
|
3462
|
+
if (!capacityLockRaceError(error)) throw error;
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
function sleepSync(milliseconds) {
|
|
3466
|
+
const buffer = new SharedArrayBuffer(4);
|
|
3467
|
+
Atomics.wait(new Int32Array(buffer), 0, 0, milliseconds);
|
|
3468
|
+
}
|
|
3469
|
+
function acquireCapacityLock(inboxDir, waitMs = CAPACITY_LOCK_WAIT_MS) {
|
|
3470
|
+
enforceOwnerOnlyDirectory(inboxDir);
|
|
3471
|
+
const lockPath = path3.join(inboxDir, CAPACITY_LOCK_FILE);
|
|
3472
|
+
const deadlineMs = Date.now() + Math.max(0, waitMs);
|
|
3473
|
+
while (true) {
|
|
3474
|
+
let descriptor;
|
|
3475
|
+
let ownerToken;
|
|
3476
|
+
try {
|
|
3477
|
+
descriptor = fs3.openSync(lockPath, "wx+", 384);
|
|
3478
|
+
ownerToken = randomUUID2();
|
|
3479
|
+
fs3.writeFileSync(
|
|
3480
|
+
descriptor,
|
|
3481
|
+
`${JSON.stringify({ ownerToken, pid: process.pid, createdAtMs: Date.now() })}
|
|
3482
|
+
`,
|
|
3483
|
+
"utf8"
|
|
3484
|
+
);
|
|
3485
|
+
fs3.fsyncSync(descriptor);
|
|
3486
|
+
let released = false;
|
|
3487
|
+
const capacityLock = {
|
|
3488
|
+
assertOwned() {
|
|
3489
|
+
if (descriptor === void 0 || ownerToken === void 0 || !capacityLockIsOwned(lockPath, descriptor, ownerToken)) {
|
|
3490
|
+
throw new Error("FML hook inbox capacity lock ownership was lost");
|
|
3491
|
+
}
|
|
3492
|
+
},
|
|
3493
|
+
release() {
|
|
3494
|
+
if (released) return;
|
|
3495
|
+
released = true;
|
|
3496
|
+
let releaseError;
|
|
3497
|
+
try {
|
|
3498
|
+
if (capacityLockIsOwned(lockPath, descriptor, ownerToken)) {
|
|
3499
|
+
fs3.unlinkSync(lockPath);
|
|
3500
|
+
fsyncDirectory(inboxDir);
|
|
3501
|
+
}
|
|
3502
|
+
} catch (error) {
|
|
3503
|
+
releaseError = error;
|
|
3504
|
+
}
|
|
3505
|
+
try {
|
|
3506
|
+
fs3.closeSync(descriptor);
|
|
3507
|
+
} catch (error) {
|
|
3508
|
+
releaseError ??= error;
|
|
3509
|
+
}
|
|
3510
|
+
if (releaseError !== void 0) throw releaseError;
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
capacityLock.assertOwned();
|
|
3514
|
+
return capacityLock;
|
|
3515
|
+
} catch (error) {
|
|
3516
|
+
if (descriptor !== void 0) {
|
|
3517
|
+
const removeOwnedLock = ownerToken !== void 0 && capacityLockIsOwned(lockPath, descriptor, ownerToken);
|
|
3518
|
+
try {
|
|
3519
|
+
fs3.closeSync(descriptor);
|
|
3520
|
+
} catch {
|
|
3521
|
+
}
|
|
3522
|
+
if (removeOwnedLock) {
|
|
3523
|
+
try {
|
|
3524
|
+
fs3.rmSync(lockPath, { force: true });
|
|
3525
|
+
fsyncDirectory(inboxDir);
|
|
3526
|
+
} catch {
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
if (error.code !== "EEXIST") throw error;
|
|
3531
|
+
const nowMs = Date.now();
|
|
3532
|
+
recoverStaleCapacityLock(lockPath, nowMs);
|
|
3533
|
+
if (nowMs >= deadlineMs) return null;
|
|
3534
|
+
sleepSync(CAPACITY_LOCK_POLL_MS);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
function capacityMutationEvidence(inboxDir, pending, includeTemporary) {
|
|
3539
|
+
const { fileName } = pending;
|
|
3540
|
+
const id = fileName.slice(0, -5);
|
|
3541
|
+
const prefix = `.${id}.`;
|
|
3542
|
+
const candidates = [];
|
|
3543
|
+
if (includeTemporary && pending.temporaryFileName) {
|
|
3544
|
+
candidates.push(path3.join(inboxDir, pending.temporaryFileName));
|
|
3545
|
+
} else if (includeTemporary) {
|
|
3546
|
+
try {
|
|
3547
|
+
for (const name of fs3.readdirSync(inboxDir)) {
|
|
3548
|
+
if (name.startsWith(prefix) && name.endsWith(".tmp")) {
|
|
3549
|
+
candidates.push(path3.join(inboxDir, name));
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
} catch (error) {
|
|
3553
|
+
return error.code === "ENOENT" ? { kind: "missing" } : { kind: "unavailable" };
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
candidates.push(path3.join(inboxDir, fileName));
|
|
3557
|
+
let presentBytes;
|
|
3558
|
+
for (const candidate of candidates) {
|
|
3559
|
+
try {
|
|
3560
|
+
const stat = fs3.lstatSync(candidate);
|
|
3561
|
+
if (!stat.isFile() || presentBytes !== void 0) {
|
|
3562
|
+
return { kind: "unavailable" };
|
|
3563
|
+
}
|
|
3564
|
+
presentBytes = stat.size;
|
|
3565
|
+
} catch (error) {
|
|
3566
|
+
if (error.code !== "ENOENT") {
|
|
3567
|
+
return { kind: "unavailable" };
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
return presentBytes === void 0 ? { kind: "missing" } : { kind: "present", bytes: presentBytes };
|
|
3572
|
+
}
|
|
3573
|
+
function replaceAccountedBytes(totalBytes, reservedBytes, actualBytes) {
|
|
3574
|
+
const withoutReservation = Math.max(0, totalBytes - reservedBytes);
|
|
3575
|
+
return saturatingAdd(withoutReservation, actualBytes);
|
|
3576
|
+
}
|
|
3577
|
+
function settleCapacityState(inboxDir, state) {
|
|
3578
|
+
if (state.pendingPersist) {
|
|
3579
|
+
const pending = state.pendingPersist;
|
|
3580
|
+
const evidence = capacityMutationEvidence(inboxDir, pending, true);
|
|
3581
|
+
if (evidence.kind === "unavailable") {
|
|
3582
|
+
throw new Error(
|
|
3583
|
+
`FML hook inbox capacity evidence is unavailable for ${pending.fileName}`
|
|
3584
|
+
);
|
|
3585
|
+
}
|
|
3586
|
+
const survives = evidence.kind === "present";
|
|
3587
|
+
return {
|
|
3588
|
+
...state,
|
|
3589
|
+
pendingEntries: Math.max(0, state.pendingEntries - (survives ? 0 : 1)),
|
|
3590
|
+
pendingBytes: survives ? replaceAccountedBytes(
|
|
3591
|
+
state.pendingBytes,
|
|
3592
|
+
pending.bytes,
|
|
3593
|
+
evidence.bytes
|
|
3594
|
+
) : Math.max(0, state.pendingBytes - pending.bytes),
|
|
3595
|
+
pendingPersist: void 0
|
|
3596
|
+
};
|
|
3597
|
+
}
|
|
3598
|
+
if (state.pendingRemoval) {
|
|
3599
|
+
const pending = state.pendingRemoval;
|
|
3600
|
+
const evidence = capacityMutationEvidence(inboxDir, pending, false);
|
|
3601
|
+
if (evidence.kind === "unavailable") {
|
|
3602
|
+
throw new Error(
|
|
3603
|
+
`FML hook inbox capacity evidence is unavailable for ${pending.fileName}`
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
if (evidence.kind === "present") {
|
|
3607
|
+
return {
|
|
3608
|
+
...state,
|
|
3609
|
+
pendingEntries: state.pendingEntries + 1,
|
|
3610
|
+
pendingBytes: saturatingAdd(state.pendingBytes, evidence.bytes),
|
|
3611
|
+
pendingRemoval: void 0
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
return { ...state, pendingRemoval: void 0 };
|
|
3615
|
+
}
|
|
3616
|
+
return state;
|
|
3617
|
+
}
|
|
3618
|
+
function loadCapacityState(inboxDir, limits) {
|
|
3619
|
+
const existing = readCapacityStateFile(inboxDir);
|
|
3620
|
+
if (!existing) {
|
|
3621
|
+
const current = pendingInboxSize(inboxDir, true);
|
|
3622
|
+
return defaultCapacityState(
|
|
3623
|
+
limits,
|
|
3624
|
+
current.pendingEntries,
|
|
3625
|
+
current.pendingBytes
|
|
3626
|
+
);
|
|
3627
|
+
}
|
|
3628
|
+
const settled = settleCapacityState(inboxDir, existing);
|
|
3629
|
+
return {
|
|
3630
|
+
...settled,
|
|
3631
|
+
maxEntries: limits.maxEntries ?? null,
|
|
3632
|
+
maxBytes: limits.maxBytes ?? null
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
function maintenanceCapacityLimits(inboxDir) {
|
|
3636
|
+
const current = readCapacityStateFile(inboxDir);
|
|
3637
|
+
if (current) {
|
|
3638
|
+
return {
|
|
3639
|
+
...current.maxEntries === null ? {} : { maxEntries: current.maxEntries },
|
|
3640
|
+
...current.maxBytes === null ? {} : { maxBytes: current.maxBytes }
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
try {
|
|
3644
|
+
return hookInboxLimitsFromEnv();
|
|
3645
|
+
} catch {
|
|
3646
|
+
return {};
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
function removeCapacityTrackedEntry(filePath, inboxDir, expectedBytes, missingIsSuccess = false) {
|
|
3650
|
+
const capacityLock = acquireCapacityLock(inboxDir, 0);
|
|
3651
|
+
if (!capacityLock) return false;
|
|
3652
|
+
try {
|
|
3653
|
+
let state = loadCapacityState(
|
|
3654
|
+
inboxDir,
|
|
3655
|
+
maintenanceCapacityLimits(inboxDir)
|
|
3656
|
+
);
|
|
3657
|
+
let bytes = expectedBytes;
|
|
3658
|
+
try {
|
|
3659
|
+
const stat = fs3.lstatSync(filePath);
|
|
3660
|
+
if (!stat.isFile()) return false;
|
|
3661
|
+
bytes = stat.size;
|
|
3662
|
+
} catch (error) {
|
|
3663
|
+
if (error.code !== "ENOENT") return false;
|
|
3664
|
+
return missingIsSuccess;
|
|
3665
|
+
}
|
|
3666
|
+
if (bytes === void 0) return false;
|
|
3667
|
+
const pendingRemoval = {
|
|
3668
|
+
fileName: path3.basename(filePath),
|
|
3669
|
+
bytes,
|
|
3670
|
+
startedAtMs: Date.now()
|
|
3671
|
+
};
|
|
3672
|
+
state = {
|
|
3673
|
+
...state,
|
|
3674
|
+
pendingEntries: Math.max(0, state.pendingEntries - 1),
|
|
3675
|
+
pendingBytes: Math.max(0, state.pendingBytes - bytes),
|
|
3676
|
+
pendingRemoval
|
|
3677
|
+
};
|
|
3678
|
+
writeCapacityState(inboxDir, state, capacityLock);
|
|
3679
|
+
capacityLock.assertOwned();
|
|
3680
|
+
try {
|
|
3681
|
+
fs3.unlinkSync(filePath);
|
|
3682
|
+
} catch (error) {
|
|
3683
|
+
if (error.code !== "ENOENT") {
|
|
3684
|
+
try {
|
|
3685
|
+
writeCapacityState(
|
|
3686
|
+
inboxDir,
|
|
3687
|
+
{
|
|
3688
|
+
...state,
|
|
3689
|
+
pendingEntries: state.pendingEntries + 1,
|
|
3690
|
+
pendingBytes: state.pendingBytes + bytes,
|
|
3691
|
+
pendingRemoval: void 0
|
|
3692
|
+
},
|
|
3693
|
+
capacityLock
|
|
3694
|
+
);
|
|
3695
|
+
} catch {
|
|
3696
|
+
}
|
|
3697
|
+
return false;
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
try {
|
|
3701
|
+
capacityLock.assertOwned();
|
|
3702
|
+
} catch {
|
|
3703
|
+
return false;
|
|
3704
|
+
}
|
|
3705
|
+
return true;
|
|
3706
|
+
} catch {
|
|
3707
|
+
return false;
|
|
3708
|
+
} finally {
|
|
3709
|
+
capacityLock.release();
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
function parseRetirementReason(value) {
|
|
3713
|
+
return value === "malformed" || value === "terminal_rejected" ? value : void 0;
|
|
3714
|
+
}
|
|
3715
|
+
function parseRetirementState(value) {
|
|
3716
|
+
if (!isRecord2(value) || value.schemaVersion !== RETIREMENT_STATE_SCHEMA_VERSION || !nonNegativeInteger(value.retiredEntries) || !nonNegativeInteger(value.retiredBytes)) {
|
|
3717
|
+
return null;
|
|
3718
|
+
}
|
|
3719
|
+
let pending;
|
|
3720
|
+
if (value.pending !== void 0) {
|
|
3721
|
+
if (!isRecord2(value.pending)) return null;
|
|
3722
|
+
const reason = parseRetirementReason(value.pending.reason);
|
|
3723
|
+
if (typeof value.pending.fileName !== "string" || !value.pending.fileName.endsWith(".json") || !INBOX_ID_PATTERN.test(value.pending.fileName.slice(0, -5)) || !nonNegativeInteger(value.pending.bytes) || !validTimestamp(value.pending.retiredAtMs) || reason === void 0) {
|
|
3724
|
+
return null;
|
|
3725
|
+
}
|
|
3726
|
+
pending = {
|
|
3727
|
+
fileName: value.pending.fileName,
|
|
3728
|
+
bytes: value.pending.bytes,
|
|
3729
|
+
reason,
|
|
3730
|
+
retiredAtMs: value.pending.retiredAtMs
|
|
3731
|
+
};
|
|
3732
|
+
}
|
|
3733
|
+
const lastRetirementReason = parseRetirementReason(
|
|
3734
|
+
value.lastRetirementReason
|
|
3735
|
+
);
|
|
3736
|
+
return {
|
|
3737
|
+
schemaVersion: RETIREMENT_STATE_SCHEMA_VERSION,
|
|
3738
|
+
retiredEntries: value.retiredEntries,
|
|
3739
|
+
retiredBytes: value.retiredBytes,
|
|
3740
|
+
...validTimestamp(value.lastRetiredAtMs) ? { lastRetiredAtMs: value.lastRetiredAtMs } : {},
|
|
3741
|
+
...lastRetirementReason === void 0 ? {} : { lastRetirementReason },
|
|
3742
|
+
...pending === void 0 ? {} : { pending }
|
|
3743
|
+
};
|
|
3744
|
+
}
|
|
3745
|
+
function readRetirementState(inboxDir) {
|
|
3746
|
+
try {
|
|
3747
|
+
return parseRetirementState(
|
|
3748
|
+
JSON.parse(
|
|
3749
|
+
fs3.readFileSync(path3.join(inboxDir, RETIREMENT_STATE_FILE), "utf8")
|
|
3750
|
+
)
|
|
3751
|
+
) ?? defaultRetirementState();
|
|
3752
|
+
} catch {
|
|
3753
|
+
return defaultRetirementState();
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
function writeRetirementState(inboxDir, state) {
|
|
3757
|
+
enforceOwnerOnlyDirectory(inboxDir);
|
|
3758
|
+
const destination = path3.join(inboxDir, RETIREMENT_STATE_FILE);
|
|
3759
|
+
const temporary = path3.join(
|
|
3760
|
+
inboxDir,
|
|
3761
|
+
`${RETIREMENT_STATE_FILE}.${process.pid}.${randomUUID2()}.tmp`
|
|
3762
|
+
);
|
|
3763
|
+
let descriptor;
|
|
3764
|
+
try {
|
|
3765
|
+
descriptor = fs3.openSync(temporary, "wx", 384);
|
|
3766
|
+
fs3.writeFileSync(descriptor, `${JSON.stringify(state)}
|
|
3767
|
+
`, "utf8");
|
|
3768
|
+
fs3.fsyncSync(descriptor);
|
|
3769
|
+
fs3.closeSync(descriptor);
|
|
3770
|
+
descriptor = void 0;
|
|
3771
|
+
fs3.renameSync(temporary, destination);
|
|
3772
|
+
fsyncDirectory(inboxDir);
|
|
3773
|
+
} catch (error) {
|
|
3774
|
+
if (descriptor !== void 0) fs3.closeSync(descriptor);
|
|
3775
|
+
try {
|
|
3776
|
+
fs3.rmSync(temporary, { force: true });
|
|
3777
|
+
} catch {
|
|
3778
|
+
}
|
|
3779
|
+
throw error;
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
function completePendingRetirement(inboxDir, state) {
|
|
3783
|
+
const pending = state.pending;
|
|
3784
|
+
if (!pending) return state;
|
|
3785
|
+
if (!removeCapacityTrackedEntry(
|
|
3786
|
+
path3.join(inboxDir, pending.fileName),
|
|
3787
|
+
inboxDir,
|
|
3788
|
+
pending.bytes,
|
|
3789
|
+
true
|
|
3790
|
+
)) {
|
|
3791
|
+
return null;
|
|
3792
|
+
}
|
|
3793
|
+
const completed = {
|
|
3794
|
+
schemaVersion: RETIREMENT_STATE_SCHEMA_VERSION,
|
|
3795
|
+
retiredEntries: state.retiredEntries + 1,
|
|
3796
|
+
retiredBytes: state.retiredBytes + pending.bytes,
|
|
3797
|
+
lastRetiredAtMs: pending.retiredAtMs,
|
|
3798
|
+
lastRetirementReason: pending.reason
|
|
3799
|
+
};
|
|
3800
|
+
try {
|
|
3801
|
+
writeRetirementState(inboxDir, completed);
|
|
3802
|
+
return completed;
|
|
3803
|
+
} catch {
|
|
3804
|
+
return null;
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
function retireEntry(filePath, reason, inboxDir) {
|
|
3808
|
+
const current = completePendingRetirement(
|
|
3809
|
+
inboxDir,
|
|
3810
|
+
readRetirementState(inboxDir)
|
|
3811
|
+
);
|
|
3812
|
+
if (!current) return false;
|
|
3813
|
+
let bytes;
|
|
3814
|
+
try {
|
|
3815
|
+
bytes = fs3.lstatSync(filePath).size;
|
|
3816
|
+
} catch {
|
|
3817
|
+
return false;
|
|
3818
|
+
}
|
|
3819
|
+
const pending = {
|
|
3820
|
+
...current,
|
|
3821
|
+
pending: {
|
|
3822
|
+
fileName: path3.basename(filePath),
|
|
3823
|
+
bytes,
|
|
3824
|
+
reason,
|
|
3825
|
+
retiredAtMs: Date.now()
|
|
3826
|
+
}
|
|
3827
|
+
};
|
|
3828
|
+
try {
|
|
3829
|
+
writeRetirementState(inboxDir, pending);
|
|
3830
|
+
} catch {
|
|
3831
|
+
return false;
|
|
3832
|
+
}
|
|
3833
|
+
return completePendingRetirement(inboxDir, pending) !== null;
|
|
3834
|
+
}
|
|
3835
|
+
function createHookInboxEntry(request, event, destination, persistedAtMs = Date.now()) {
|
|
3836
|
+
const capturedAtMs = event.capturedAtMs ?? persistedAtMs;
|
|
3837
|
+
return {
|
|
3838
|
+
schemaVersion: HOOK_INBOX_SCHEMA_VERSION,
|
|
3839
|
+
// File ordering follows the local durable write, not an agent-supplied
|
|
3840
|
+
// event timestamp that may be delayed or backdated behind a drain cursor.
|
|
3841
|
+
id: `${persistedAtMs}-${randomUUID2()}`,
|
|
3842
|
+
syncId: event.syncId,
|
|
3843
|
+
capturedAtMs,
|
|
3844
|
+
...event.repository ? { repository: event.repository } : {},
|
|
3845
|
+
request,
|
|
3846
|
+
...destination === void 0 ? {} : {
|
|
3847
|
+
destination: {
|
|
3848
|
+
targetUrl: destination.targetUrl,
|
|
3849
|
+
destinationIdentity: destination.destinationIdentity
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
function persistHookInboxEntry(entry, inboxDir = FML_HOOK_INBOX_DIR, limits = hookInboxLimitsFromEnv(), capacityLockWaitMs = CAPACITY_LOCK_WAIT_MS) {
|
|
3855
|
+
const parsed = parseHookInboxEntry(entry);
|
|
3856
|
+
if (!parsed || parsed.id !== entry.id) {
|
|
3857
|
+
throw new Error("Invalid FML hook inbox entry");
|
|
3858
|
+
}
|
|
3859
|
+
const serialized = `${JSON.stringify(entry)}
|
|
3860
|
+
`;
|
|
3861
|
+
const serializedBytes = Buffer.byteLength(serialized, "utf8");
|
|
3862
|
+
if (serializedBytes > MAX_HOOK_INBOX_ENTRY_BYTES) {
|
|
3863
|
+
throw new Error(
|
|
3864
|
+
`FML hook inbox entry exceeds ${MAX_HOOK_INBOX_ENTRY_BYTES} bytes`
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
enforceOwnerOnlyDirectory(inboxDir);
|
|
3868
|
+
const capacityLock = acquireCapacityLock(inboxDir, capacityLockWaitMs);
|
|
3869
|
+
if (!capacityLock) {
|
|
3870
|
+
throw new Error("FML hook inbox admission is busy");
|
|
3871
|
+
}
|
|
3872
|
+
const destination = entryPath(inboxDir, entry.id);
|
|
3873
|
+
const temporary = path3.join(
|
|
3874
|
+
inboxDir,
|
|
3875
|
+
`.${entry.id}.${process.pid}.${randomUUID2()}.tmp`
|
|
3876
|
+
);
|
|
3877
|
+
let descriptor;
|
|
3878
|
+
let state;
|
|
3879
|
+
try {
|
|
3880
|
+
state = loadCapacityState(inboxDir, limits);
|
|
3881
|
+
} catch (error) {
|
|
3882
|
+
capacityLock.release();
|
|
3883
|
+
throw error;
|
|
3884
|
+
}
|
|
3885
|
+
const rejectionReason = limits.maxEntries !== void 0 && state.pendingEntries + 1 > limits.maxEntries ? "max_entries" : limits.maxBytes !== void 0 && state.pendingBytes + serializedBytes > limits.maxBytes ? "max_bytes" : void 0;
|
|
3886
|
+
if (rejectionReason) {
|
|
3887
|
+
state = {
|
|
3888
|
+
...state,
|
|
3889
|
+
rejectedEntries: saturatingAdd(state.rejectedEntries, 1),
|
|
3890
|
+
rejectedBytes: saturatingAdd(state.rejectedBytes, serializedBytes),
|
|
3891
|
+
lastRejectedAtMs: Date.now(),
|
|
3892
|
+
lastRejectionReason: rejectionReason
|
|
3893
|
+
};
|
|
3894
|
+
try {
|
|
3895
|
+
writeCapacityState(inboxDir, state, capacityLock);
|
|
3896
|
+
throw new HookInboxCapacityError(rejectionReason);
|
|
3897
|
+
} finally {
|
|
3898
|
+
capacityLock.release();
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
try {
|
|
3902
|
+
const pendingPersist = {
|
|
3903
|
+
fileName: path3.basename(destination),
|
|
3904
|
+
temporaryFileName: path3.basename(temporary),
|
|
3905
|
+
bytes: serializedBytes,
|
|
3906
|
+
startedAtMs: Date.now()
|
|
3907
|
+
};
|
|
3908
|
+
state = {
|
|
3909
|
+
...state,
|
|
3910
|
+
pendingEntries: state.pendingEntries + 1,
|
|
3911
|
+
pendingBytes: state.pendingBytes + serializedBytes,
|
|
3912
|
+
pendingPersist
|
|
3913
|
+
};
|
|
3914
|
+
writeCapacityState(inboxDir, state, capacityLock);
|
|
3915
|
+
capacityLock.assertOwned();
|
|
3916
|
+
descriptor = fs3.openSync(temporary, "wx", 384);
|
|
3917
|
+
fs3.writeFileSync(descriptor, serialized, "utf8");
|
|
3918
|
+
fs3.fsyncSync(descriptor);
|
|
3919
|
+
capacityLock.assertOwned();
|
|
3920
|
+
fs3.closeSync(descriptor);
|
|
3921
|
+
descriptor = void 0;
|
|
3922
|
+
fs3.renameSync(temporary, destination);
|
|
3923
|
+
try {
|
|
3924
|
+
fs3.chmodSync(destination, 384);
|
|
3925
|
+
} catch {
|
|
3926
|
+
}
|
|
3927
|
+
fsyncDirectory(inboxDir);
|
|
3928
|
+
capacityLock.assertOwned();
|
|
3929
|
+
} catch (error) {
|
|
3930
|
+
if (descriptor !== void 0) fs3.closeSync(descriptor);
|
|
3931
|
+
try {
|
|
3932
|
+
fs3.rmSync(temporary, { force: true });
|
|
3933
|
+
} catch {
|
|
3934
|
+
}
|
|
3935
|
+
if (state.pendingPersist) {
|
|
3936
|
+
try {
|
|
3937
|
+
const settled = settleCapacityState(inboxDir, state);
|
|
3938
|
+
writeCapacityState(inboxDir, settled, capacityLock);
|
|
3939
|
+
} catch {
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
throw error;
|
|
3943
|
+
} finally {
|
|
3944
|
+
capacityLock.release();
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
function hookInboxDeliveryPayload(entry) {
|
|
3948
|
+
return {
|
|
3949
|
+
...entry.request,
|
|
3950
|
+
[INBOX_METADATA_KEY]: {
|
|
3951
|
+
id: entry.id,
|
|
3952
|
+
syncId: entry.syncId,
|
|
3953
|
+
capturedAtMs: entry.capturedAtMs,
|
|
3954
|
+
...entry.repository === void 0 ? {} : { repository: entry.repository },
|
|
3955
|
+
...entry.destination === void 0 ? {} : { destination: entry.destination }
|
|
3956
|
+
}
|
|
3957
|
+
};
|
|
3958
|
+
}
|
|
3959
|
+
function parseHookInboxMetadata(value) {
|
|
3960
|
+
return isRecord2(value) ? parseMetadata(value[INBOX_METADATA_KEY]) : null;
|
|
3961
|
+
}
|
|
3962
|
+
function parseHookInboxEntry(value) {
|
|
3963
|
+
if (!isRecord2(value) || value.schemaVersion !== HOOK_INBOX_SCHEMA_VERSION) {
|
|
3964
|
+
return null;
|
|
3965
|
+
}
|
|
3966
|
+
const metadata = parseMetadata(value);
|
|
3967
|
+
const request = parseHookCaptureRequest(value.request);
|
|
3968
|
+
if (!metadata || !request) return null;
|
|
3969
|
+
return {
|
|
3970
|
+
schemaVersion: HOOK_INBOX_SCHEMA_VERSION,
|
|
3971
|
+
...metadata,
|
|
3972
|
+
request
|
|
3973
|
+
};
|
|
3974
|
+
}
|
|
3975
|
+
function sourceDeclaresForwardSchema(source) {
|
|
3976
|
+
const match = /^\s*\{\s*"schemaVersion"\s*:\s*(\d+)/u.exec(source);
|
|
3977
|
+
if (!match) return false;
|
|
3978
|
+
const schemaVersion = Number(match[1]);
|
|
3979
|
+
return Number.isSafeInteger(schemaVersion) && schemaVersion > HOOK_INBOX_SCHEMA_VERSION;
|
|
3980
|
+
}
|
|
3981
|
+
function readEntryResult(filePath) {
|
|
3982
|
+
try {
|
|
3983
|
+
const stat = fs3.lstatSync(filePath);
|
|
3984
|
+
if (!stat.isFile()) {
|
|
3985
|
+
return { kind: "malformed" };
|
|
3986
|
+
}
|
|
3987
|
+
if (stat.size > MAX_HOOK_INBOX_ENTRY_BYTES) {
|
|
3988
|
+
return { kind: "unsupported_schema" };
|
|
3989
|
+
}
|
|
3990
|
+
const source = fs3.readFileSync(filePath, "utf8");
|
|
3991
|
+
let value;
|
|
3992
|
+
try {
|
|
3993
|
+
value = JSON.parse(source);
|
|
3994
|
+
} catch {
|
|
3995
|
+
return sourceDeclaresForwardSchema(source) ? { kind: "unsupported_schema" } : { kind: "malformed" };
|
|
3996
|
+
}
|
|
3997
|
+
if (isRecord2(value) && typeof value.schemaVersion === "number" && Number.isSafeInteger(value.schemaVersion) && value.schemaVersion > HOOK_INBOX_SCHEMA_VERSION) {
|
|
3998
|
+
return { kind: "unsupported_schema" };
|
|
3999
|
+
}
|
|
4000
|
+
const entry = parseHookInboxEntry(value);
|
|
4001
|
+
return entry ? { kind: "valid", entry } : { kind: "malformed" };
|
|
4002
|
+
} catch (error) {
|
|
4003
|
+
if (error.code === "ENOENT") {
|
|
4004
|
+
return { kind: "missing" };
|
|
4005
|
+
}
|
|
4006
|
+
return { kind: "failed" };
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
function readEntry(filePath) {
|
|
4010
|
+
const result = readEntryResult(filePath);
|
|
4011
|
+
return result.kind === "valid" ? result.entry : null;
|
|
4012
|
+
}
|
|
4013
|
+
function acknowledgeHookInboxEntry(metadata, inboxDir = FML_HOOK_INBOX_DIR) {
|
|
4014
|
+
let filePath;
|
|
4015
|
+
try {
|
|
4016
|
+
filePath = entryPath(inboxDir, metadata.id);
|
|
4017
|
+
} catch {
|
|
4018
|
+
return false;
|
|
4019
|
+
}
|
|
4020
|
+
const entry = readEntry(filePath);
|
|
4021
|
+
if (!entry || entry.id !== metadata.id || entry.syncId !== metadata.syncId || entry.capturedAtMs !== metadata.capturedAtMs) {
|
|
4022
|
+
return false;
|
|
4023
|
+
}
|
|
4024
|
+
return removeCapacityTrackedEntry(filePath, inboxDir);
|
|
4025
|
+
}
|
|
4026
|
+
function isPendingEntryName(name) {
|
|
4027
|
+
return name.endsWith(".json") && INBOX_ID_PATTERN.test(name.slice(0, -5));
|
|
4028
|
+
}
|
|
4029
|
+
function insertOldestEntryName(names, selected, name, maximumEntries) {
|
|
4030
|
+
if (selected.has(name)) return;
|
|
4031
|
+
if (names.length === maximumEntries && name > names[names.length - 1]) {
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
let low = 0;
|
|
4035
|
+
let high = names.length;
|
|
4036
|
+
while (low < high) {
|
|
4037
|
+
const middle = low + high >>> 1;
|
|
4038
|
+
if (names[middle] < name) low = middle + 1;
|
|
4039
|
+
else high = middle;
|
|
4040
|
+
}
|
|
4041
|
+
names.splice(low, 0, name);
|
|
4042
|
+
selected.add(name);
|
|
4043
|
+
if (names.length > maximumEntries) {
|
|
4044
|
+
selected.delete(names.pop());
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
async function scanDrainEntryNames(inboxDir, maximumEntries, skipThrough, excludedName, nowMs = Date.now()) {
|
|
4048
|
+
const names = [];
|
|
4049
|
+
const selected = /* @__PURE__ */ new Set();
|
|
4050
|
+
let failed = 0;
|
|
4051
|
+
let resetCursor = false;
|
|
4052
|
+
let blockCursorAdvance = false;
|
|
4053
|
+
let removedOrphan = false;
|
|
4054
|
+
const cutoffMs = nowMs - STALE_INBOX_TEMP_AGE_MS;
|
|
4055
|
+
const directory = await fs3.promises.opendir(inboxDir);
|
|
4056
|
+
try {
|
|
4057
|
+
for (let dirent = await directory.read(); dirent !== null; dirent = await directory.read()) {
|
|
4058
|
+
const name = dirent.name;
|
|
4059
|
+
const abandonedLockMatch = ABANDONED_CAPACITY_LOCK_PATTERN.exec(name);
|
|
4060
|
+
if (INBOX_STATE_TEMP_PATTERN.test(name) || abandonedLockMatch !== null) {
|
|
4061
|
+
try {
|
|
4062
|
+
const orphanPath = path3.join(inboxDir, name);
|
|
4063
|
+
const stat = await fs3.promises.lstat(orphanPath);
|
|
4064
|
+
const abandonedAtMs = abandonedLockMatch ? Number(abandonedLockMatch[1]) : void 0;
|
|
4065
|
+
const isStale = abandonedLockMatch ? validTimestamp(abandonedAtMs) && abandonedAtMs <= cutoffMs : stat.mtimeMs <= cutoffMs;
|
|
4066
|
+
if (stat.isFile() && isStale) {
|
|
4067
|
+
await fs3.promises.unlink(orphanPath);
|
|
4068
|
+
removedOrphan = true;
|
|
4069
|
+
}
|
|
4070
|
+
} catch (error) {
|
|
4071
|
+
if (error.code !== "ENOENT") failed += 1;
|
|
4072
|
+
}
|
|
4073
|
+
continue;
|
|
4074
|
+
}
|
|
4075
|
+
if (isPendingEntryName(name)) {
|
|
4076
|
+
if (name !== excludedName && (!skipThrough || name > skipThrough)) {
|
|
4077
|
+
insertOldestEntryName(names, selected, name, maximumEntries);
|
|
4078
|
+
}
|
|
4079
|
+
continue;
|
|
4080
|
+
}
|
|
4081
|
+
const match = INBOX_TEMP_PATTERN.exec(name);
|
|
4082
|
+
const id = match?.[1];
|
|
4083
|
+
if (!id) continue;
|
|
4084
|
+
const destination = entryPath(inboxDir, id);
|
|
4085
|
+
const destinationName = path3.basename(destination);
|
|
4086
|
+
const temporary = path3.join(inboxDir, name);
|
|
4087
|
+
try {
|
|
4088
|
+
const stat = await fs3.promises.lstat(temporary);
|
|
4089
|
+
if (!stat.isFile()) continue;
|
|
4090
|
+
blockCursorAdvance = true;
|
|
4091
|
+
if (skipThrough && destinationName <= skipThrough) resetCursor = true;
|
|
4092
|
+
if (stat.mtimeMs > cutoffMs) continue;
|
|
4093
|
+
let destinationExists = false;
|
|
4094
|
+
try {
|
|
4095
|
+
await fs3.promises.lstat(destination);
|
|
4096
|
+
destinationExists = true;
|
|
4097
|
+
} catch (error) {
|
|
4098
|
+
if (error.code !== "ENOENT") throw error;
|
|
4099
|
+
}
|
|
4100
|
+
if (destinationExists) {
|
|
4101
|
+
await fs3.promises.unlink(temporary);
|
|
4102
|
+
} else {
|
|
4103
|
+
await fs3.promises.rename(temporary, destination);
|
|
4104
|
+
try {
|
|
4105
|
+
await fs3.promises.chmod(destination, 384);
|
|
4106
|
+
} catch {
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
const recoveredBehindCursor = !destinationExists && skipThrough !== void 0 && destinationName <= skipThrough;
|
|
4110
|
+
if (recoveredBehindCursor) resetCursor = true;
|
|
4111
|
+
if (destinationName !== excludedName && (recoveredBehindCursor || !skipThrough || destinationName > skipThrough)) {
|
|
4112
|
+
insertOldestEntryName(
|
|
4113
|
+
names,
|
|
4114
|
+
selected,
|
|
4115
|
+
destinationName,
|
|
4116
|
+
maximumEntries
|
|
4117
|
+
);
|
|
4118
|
+
}
|
|
4119
|
+
await fsyncDirectoryAsync(inboxDir);
|
|
4120
|
+
} catch (error) {
|
|
4121
|
+
blockCursorAdvance = true;
|
|
4122
|
+
if (skipThrough && destinationName <= skipThrough) resetCursor = true;
|
|
4123
|
+
if (error.code !== "ENOENT") failed += 1;
|
|
4124
|
+
}
|
|
4125
|
+
}
|
|
4126
|
+
} finally {
|
|
4127
|
+
await directory.close();
|
|
4128
|
+
}
|
|
4129
|
+
if (removedOrphan) {
|
|
4130
|
+
try {
|
|
4131
|
+
await fsyncDirectoryAsync(inboxDir);
|
|
4132
|
+
} catch {
|
|
4133
|
+
failed += 1;
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
return { names, failed, resetCursor, blockCursorAdvance };
|
|
4137
|
+
}
|
|
4138
|
+
function drainCursor(inboxDir) {
|
|
4139
|
+
const key = path3.resolve(inboxDir);
|
|
4140
|
+
const existing = HOOK_INBOX_DRAIN_CURSORS.get(key);
|
|
4141
|
+
if (existing) {
|
|
4142
|
+
HOOK_INBOX_DRAIN_CURSORS.delete(key);
|
|
4143
|
+
HOOK_INBOX_DRAIN_CURSORS.set(key, existing);
|
|
4144
|
+
return existing;
|
|
4145
|
+
}
|
|
4146
|
+
if (HOOK_INBOX_DRAIN_CURSORS.size >= MAX_HOOK_INBOX_DRAIN_CURSORS) {
|
|
4147
|
+
const oldestKey = HOOK_INBOX_DRAIN_CURSORS.keys().next().value;
|
|
4148
|
+
if (oldestKey !== void 0) HOOK_INBOX_DRAIN_CURSORS.delete(oldestKey);
|
|
4149
|
+
}
|
|
4150
|
+
const created = {};
|
|
4151
|
+
HOOK_INBOX_DRAIN_CURSORS.set(key, created);
|
|
4152
|
+
return created;
|
|
4153
|
+
}
|
|
4154
|
+
function inboxEntryTimestamp(fileName) {
|
|
4155
|
+
const separator = fileName.indexOf("-");
|
|
4156
|
+
if (separator <= 0) return null;
|
|
4157
|
+
const timestamp = Number(fileName.slice(0, separator));
|
|
4158
|
+
return validTimestamp(timestamp) ? timestamp : null;
|
|
4159
|
+
}
|
|
4160
|
+
function safeDrainCursor(cursor, nowMs) {
|
|
4161
|
+
if (cursor.skipThrough && cursor.advancedAtMs !== void 0 && nowMs >= cursor.advancedAtMs) {
|
|
4162
|
+
return cursor.skipThrough;
|
|
4163
|
+
}
|
|
4164
|
+
cursor.skipThrough = void 0;
|
|
4165
|
+
cursor.advancedAtMs = void 0;
|
|
4166
|
+
return void 0;
|
|
4167
|
+
}
|
|
4168
|
+
async function pendingInboxSizeAsync(inboxDir) {
|
|
4169
|
+
let pendingEntries = 0;
|
|
4170
|
+
let pendingBytes = 0;
|
|
4171
|
+
const directory = await fs3.promises.opendir(inboxDir);
|
|
4172
|
+
try {
|
|
4173
|
+
for (let dirent = await directory.read(); dirent !== null; dirent = await directory.read()) {
|
|
4174
|
+
if (!isPendingEntryName(dirent.name)) continue;
|
|
4175
|
+
try {
|
|
4176
|
+
const stat = await fs3.promises.lstat(path3.join(inboxDir, dirent.name));
|
|
4177
|
+
if (stat.isFile()) {
|
|
4178
|
+
pendingEntries += 1;
|
|
4179
|
+
pendingBytes += stat.size;
|
|
4180
|
+
}
|
|
4181
|
+
} catch (error) {
|
|
4182
|
+
if (error.code !== "ENOENT") throw error;
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
} finally {
|
|
4186
|
+
await directory.close();
|
|
4187
|
+
}
|
|
4188
|
+
return { pendingEntries, pendingBytes };
|
|
4189
|
+
}
|
|
4190
|
+
function pendingInboxSize(inboxDir, includeTemporary = false) {
|
|
4191
|
+
let pendingEntries = 0;
|
|
4192
|
+
let pendingBytes = 0;
|
|
4193
|
+
const directory = fs3.opendirSync(inboxDir);
|
|
4194
|
+
try {
|
|
4195
|
+
for (let dirent = directory.readSync(); dirent !== null; dirent = directory.readSync()) {
|
|
4196
|
+
if (!isPendingEntryName(dirent.name) && !(includeTemporary && INBOX_TEMP_PATTERN.test(dirent.name))) {
|
|
4197
|
+
continue;
|
|
4198
|
+
}
|
|
4199
|
+
try {
|
|
4200
|
+
const stat = fs3.lstatSync(path3.join(inboxDir, dirent.name));
|
|
4201
|
+
if (stat.isFile()) {
|
|
4202
|
+
pendingEntries += 1;
|
|
4203
|
+
pendingBytes += stat.size;
|
|
4204
|
+
}
|
|
4205
|
+
} catch (error) {
|
|
4206
|
+
if (error.code !== "ENOENT") throw error;
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
} finally {
|
|
4210
|
+
directory.closeSync();
|
|
4211
|
+
}
|
|
4212
|
+
return { pendingEntries, pendingBytes };
|
|
4213
|
+
}
|
|
4214
|
+
function retireHookInboxEntry(metadata, reason, inboxDir = FML_HOOK_INBOX_DIR) {
|
|
4215
|
+
let filePath;
|
|
4216
|
+
try {
|
|
4217
|
+
filePath = entryPath(inboxDir, metadata.id);
|
|
4218
|
+
} catch {
|
|
4219
|
+
return false;
|
|
4220
|
+
}
|
|
4221
|
+
const entry = readEntry(filePath);
|
|
4222
|
+
if (!entry || entry.syncId !== metadata.syncId || entry.capturedAtMs !== metadata.capturedAtMs) {
|
|
4223
|
+
return false;
|
|
4224
|
+
}
|
|
4225
|
+
return retireEntry(filePath, reason, inboxDir);
|
|
4226
|
+
}
|
|
4227
|
+
function emptyHookInboxStatus(limits, unreadable) {
|
|
4228
|
+
return {
|
|
4229
|
+
pendingEntries: 0,
|
|
4230
|
+
pendingBytes: 0,
|
|
4231
|
+
maxEntries: limits.maxEntries ?? null,
|
|
4232
|
+
maxBytes: limits.maxBytes ?? null,
|
|
4233
|
+
capacityRejectedEntries: 0,
|
|
4234
|
+
capacityRejectedBytes: 0,
|
|
4235
|
+
retiredEntries: 0,
|
|
4236
|
+
retiredBytes: 0,
|
|
4237
|
+
unreadable
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
function readHookInboxStatus(inboxDir = FML_HOOK_INBOX_DIR) {
|
|
4241
|
+
let limits;
|
|
4242
|
+
try {
|
|
4243
|
+
limits = hookInboxLimitsFromEnv();
|
|
4244
|
+
} catch {
|
|
4245
|
+
return emptyHookInboxStatus({}, true);
|
|
4246
|
+
}
|
|
4247
|
+
try {
|
|
4248
|
+
const retirement = readRetirementState(inboxDir);
|
|
4249
|
+
const { pendingBytes, pendingEntries } = pendingInboxSize(inboxDir);
|
|
4250
|
+
const capacity = readCapacityStateFile(inboxDir) ?? defaultCapacityState(limits, pendingEntries, pendingBytes);
|
|
4251
|
+
return {
|
|
4252
|
+
pendingEntries,
|
|
4253
|
+
pendingBytes,
|
|
4254
|
+
maxEntries: limits.maxEntries ?? null,
|
|
4255
|
+
maxBytes: limits.maxBytes ?? null,
|
|
4256
|
+
capacityRejectedEntries: capacity.rejectedEntries,
|
|
4257
|
+
capacityRejectedBytes: capacity.rejectedBytes,
|
|
4258
|
+
...capacity.lastRejectedAtMs === void 0 ? {} : { lastCapacityRejectedAtMs: capacity.lastRejectedAtMs },
|
|
4259
|
+
...capacity.lastRejectionReason === void 0 ? {} : { lastCapacityRejectionReason: capacity.lastRejectionReason },
|
|
4260
|
+
retiredEntries: retirement.retiredEntries,
|
|
4261
|
+
retiredBytes: retirement.retiredBytes,
|
|
4262
|
+
unreadable: false
|
|
4263
|
+
};
|
|
4264
|
+
} catch (error) {
|
|
4265
|
+
if (error.code === "ENOENT") {
|
|
4266
|
+
return emptyHookInboxStatus(limits, false);
|
|
4267
|
+
}
|
|
4268
|
+
return emptyHookInboxStatus(limits, true);
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
async function readHookInboxStatusAsync(inboxDir = FML_HOOK_INBOX_DIR) {
|
|
4272
|
+
let limits;
|
|
4273
|
+
try {
|
|
4274
|
+
limits = hookInboxLimitsFromEnv();
|
|
4275
|
+
} catch {
|
|
4276
|
+
return emptyHookInboxStatus({}, true);
|
|
4277
|
+
}
|
|
4278
|
+
try {
|
|
4279
|
+
const retirement = readRetirementState(inboxDir);
|
|
4280
|
+
const { pendingBytes, pendingEntries } = await pendingInboxSizeAsync(inboxDir);
|
|
4281
|
+
const capacity = readCapacityStateFile(inboxDir) ?? defaultCapacityState(limits, pendingEntries, pendingBytes);
|
|
4282
|
+
return {
|
|
4283
|
+
pendingEntries,
|
|
4284
|
+
pendingBytes,
|
|
4285
|
+
maxEntries: limits.maxEntries ?? null,
|
|
4286
|
+
maxBytes: limits.maxBytes ?? null,
|
|
4287
|
+
capacityRejectedEntries: capacity.rejectedEntries,
|
|
4288
|
+
capacityRejectedBytes: capacity.rejectedBytes,
|
|
4289
|
+
...capacity.lastRejectedAtMs === void 0 ? {} : { lastCapacityRejectedAtMs: capacity.lastRejectedAtMs },
|
|
4290
|
+
...capacity.lastRejectionReason === void 0 ? {} : { lastCapacityRejectionReason: capacity.lastRejectionReason },
|
|
4291
|
+
retiredEntries: retirement.retiredEntries,
|
|
4292
|
+
retiredBytes: retirement.retiredBytes,
|
|
4293
|
+
unreadable: false
|
|
4294
|
+
};
|
|
4295
|
+
} catch (error) {
|
|
4296
|
+
if (error.code === "ENOENT") {
|
|
4297
|
+
return emptyHookInboxStatus(limits, false);
|
|
4298
|
+
}
|
|
4299
|
+
return emptyHookInboxStatus(limits, true);
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
async function drainHookInbox(capture, inboxDir = FML_HOOK_INBOX_DIR) {
|
|
4303
|
+
const retirement = readRetirementState(inboxDir);
|
|
4304
|
+
const completedRetirement = completePendingRetirement(inboxDir, retirement);
|
|
4305
|
+
let failed = completedRetirement === null ? 1 : 0;
|
|
4306
|
+
const cursor = drainCursor(inboxDir);
|
|
4307
|
+
const drainStartedAtMs = Date.now();
|
|
4308
|
+
const skipThrough = safeDrainCursor(cursor, drainStartedAtMs);
|
|
4309
|
+
const pendingRetirementName = completedRetirement === null ? retirement.pending?.fileName : void 0;
|
|
4310
|
+
let drained = 0;
|
|
4311
|
+
let rejected = 0;
|
|
4312
|
+
let malformed = 0;
|
|
4313
|
+
let unsupported = 0;
|
|
4314
|
+
let names;
|
|
4315
|
+
let effectiveSkipThrough = skipThrough;
|
|
4316
|
+
let blockCursorAdvance = false;
|
|
4317
|
+
try {
|
|
4318
|
+
const scan = await scanDrainEntryNames(
|
|
4319
|
+
inboxDir,
|
|
4320
|
+
MAX_HOOK_INBOX_DRAIN_ENTRIES,
|
|
4321
|
+
skipThrough,
|
|
4322
|
+
pendingRetirementName
|
|
4323
|
+
);
|
|
4324
|
+
failed += scan.failed;
|
|
4325
|
+
names = scan.names;
|
|
4326
|
+
blockCursorAdvance = scan.blockCursorAdvance;
|
|
4327
|
+
if (scan.resetCursor) {
|
|
4328
|
+
cursor.skipThrough = void 0;
|
|
4329
|
+
cursor.advancedAtMs = void 0;
|
|
4330
|
+
effectiveSkipThrough = void 0;
|
|
4331
|
+
}
|
|
4332
|
+
} catch {
|
|
4333
|
+
return {
|
|
4334
|
+
drained,
|
|
4335
|
+
rejected,
|
|
4336
|
+
failed: failed + 1,
|
|
4337
|
+
malformed,
|
|
4338
|
+
unsupported
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
4341
|
+
let cursorBlocked = blockCursorAdvance;
|
|
4342
|
+
let safeThrough = effectiveSkipThrough;
|
|
4343
|
+
let sawUnsupported = effectiveSkipThrough !== void 0;
|
|
4344
|
+
const markResolved = (name, preservedUnsupported) => {
|
|
4345
|
+
if (preservedUnsupported) sawUnsupported = true;
|
|
4346
|
+
if (!sawUnsupported || cursorBlocked) return;
|
|
4347
|
+
const timestamp = inboxEntryTimestamp(name);
|
|
4348
|
+
if (timestamp === null || timestamp >= drainStartedAtMs - DRAIN_CURSOR_SAFETY_AGE_MS) {
|
|
4349
|
+
cursorBlocked = true;
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
safeThrough = name;
|
|
4353
|
+
};
|
|
4354
|
+
for (const name of names) {
|
|
4355
|
+
const filePath = path3.join(inboxDir, name);
|
|
4356
|
+
const readResult = readEntryResult(filePath);
|
|
4357
|
+
if (readResult.kind === "missing") {
|
|
4358
|
+
markResolved(name, false);
|
|
4359
|
+
continue;
|
|
4360
|
+
}
|
|
4361
|
+
if (readResult.kind === "unsupported_schema") {
|
|
4362
|
+
unsupported += 1;
|
|
4363
|
+
markResolved(name, true);
|
|
4364
|
+
continue;
|
|
4365
|
+
}
|
|
4366
|
+
if (readResult.kind === "failed") {
|
|
4367
|
+
failed += 1;
|
|
4368
|
+
cursorBlocked = true;
|
|
4369
|
+
continue;
|
|
4370
|
+
}
|
|
4371
|
+
if (readResult.kind === "malformed") {
|
|
4372
|
+
if (retireEntry(filePath, "malformed", inboxDir)) {
|
|
4373
|
+
malformed += 1;
|
|
4374
|
+
markResolved(name, false);
|
|
4375
|
+
} else {
|
|
4376
|
+
failed += 1;
|
|
4377
|
+
cursorBlocked = true;
|
|
4378
|
+
}
|
|
4379
|
+
continue;
|
|
4380
|
+
}
|
|
4381
|
+
const { entry } = readResult;
|
|
4382
|
+
try {
|
|
4383
|
+
const outcome = capture(entry);
|
|
4384
|
+
if (outcome === "retry") {
|
|
4385
|
+
failed += 1;
|
|
4386
|
+
cursorBlocked = true;
|
|
4387
|
+
continue;
|
|
4388
|
+
}
|
|
4389
|
+
if (outcome === "terminal_rejected") {
|
|
4390
|
+
if (retireEntry(filePath, "terminal_rejected", inboxDir)) {
|
|
4391
|
+
rejected += 1;
|
|
4392
|
+
markResolved(name, false);
|
|
4393
|
+
} else {
|
|
4394
|
+
failed += 1;
|
|
4395
|
+
cursorBlocked = true;
|
|
4396
|
+
}
|
|
4397
|
+
continue;
|
|
4398
|
+
}
|
|
4399
|
+
if (acknowledgeHookInboxEntry(entry, inboxDir)) {
|
|
4400
|
+
drained += 1;
|
|
4401
|
+
markResolved(name, false);
|
|
4402
|
+
} else {
|
|
4403
|
+
failed += 1;
|
|
4404
|
+
cursorBlocked = true;
|
|
4405
|
+
}
|
|
4406
|
+
} catch {
|
|
4407
|
+
failed += 1;
|
|
4408
|
+
cursorBlocked = true;
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
if (safeThrough !== effectiveSkipThrough) {
|
|
4412
|
+
cursor.skipThrough = safeThrough;
|
|
4413
|
+
cursor.advancedAtMs = drainStartedAtMs;
|
|
4414
|
+
}
|
|
4415
|
+
return { drained, rejected, failed, malformed, unsupported };
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4418
|
+
export {
|
|
4419
|
+
readObserverAuthToken,
|
|
4420
|
+
getOrCreateObserverAuthToken,
|
|
4421
|
+
HOOK_TARGETS,
|
|
4422
|
+
CODEX_HOOK_EVENTS,
|
|
4423
|
+
GEMINI_HOOK_EVENTS,
|
|
4424
|
+
GROK_HOOK_EVENTS,
|
|
4425
|
+
parseHookCaptureRequest,
|
|
4426
|
+
normalizeObserverHookCapture,
|
|
4427
|
+
ObserverHookSpoolEntryTooLargeError,
|
|
4428
|
+
openObserverDatabase,
|
|
4429
|
+
OBSERVER_PROTOCOL_VERSION,
|
|
4430
|
+
OBSERVER_SERVICE_NAME,
|
|
4431
|
+
isObserverHealth,
|
|
4432
|
+
createObserverHttpServer,
|
|
4433
|
+
createHookInboxEntry,
|
|
4434
|
+
persistHookInboxEntry,
|
|
4435
|
+
hookInboxDeliveryPayload,
|
|
4436
|
+
parseHookInboxMetadata,
|
|
4437
|
+
acknowledgeHookInboxEntry,
|
|
4438
|
+
retireHookInboxEntry,
|
|
4439
|
+
readHookInboxStatus,
|
|
4440
|
+
readHookInboxStatusAsync,
|
|
4441
|
+
drainHookInbox
|
|
4442
|
+
};
|