@fml-inc/fml 0.7.0 → 0.8.0-rc.2

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