@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,1037 @@
1
+ import {
2
+ resolveRepoFromCwdAsync
3
+ } from "./chunk-7V7G7NVF.js";
4
+ import {
5
+ getActiveEnv,
6
+ getSiteUrlForConvexUrl,
7
+ getSyncDestinationIdentity,
8
+ getValidToken
9
+ } from "./chunk-A6HAEVJX.js";
10
+
11
+ // src/hooks/sync-loop.ts
12
+ import { createHash } from "crypto";
13
+ var IDLE_INTERVAL_MS = 3e4;
14
+ var AUTH_INTERVAL_MS = 6e4;
15
+ var MIN_RETRY_INTERVAL_MS = 2e3;
16
+ var MAX_RETRY_INTERVAL_MS = 5 * 6e4;
17
+ var MAX_RETRY_AFTER_MS = 24 * 60 * 6e4;
18
+ var HOOK_UPLOAD_TIMEOUT_MS = 3e4;
19
+ var MAX_HOOK_EVENT_ROW_JSON_BYTES = 2 * 1024 * 1024;
20
+ var MAX_SESSION_ROW_JSON_BYTES = 2 * 1024 * 1024;
21
+ var MAX_BATCH_JSON_BYTES = 3 * 1024 * 1024;
22
+ var MAX_SESSION_RELATIONSHIPS_PER_ROW = 100;
23
+ var MAX_EXTRACTED_TEXT_BYTES = 64 * 1024;
24
+ var MAX_CONTEXT_TEXT_BYTES = 16 * 1024;
25
+ var MAX_REJECTION_REASON_BYTES = 3 * 1024;
26
+ function retryAtMs(response, now) {
27
+ const value = response.headers.get("Retry-After");
28
+ if (!value) return void 0;
29
+ const seconds = Number(value);
30
+ const candidate = Number.isFinite(seconds) ? now + Math.max(0, seconds) * 1e3 : Date.parse(value);
31
+ if (!Number.isFinite(candidate)) return void 0;
32
+ return Math.min(now + MAX_RETRY_AFTER_MS, Math.max(now, candidate));
33
+ }
34
+ function errorText(status, body) {
35
+ if (body && typeof body === "object" && !Array.isArray(body)) {
36
+ const error = body.error;
37
+ if (typeof error === "string" && error.length > 0) {
38
+ return error.length > 1e3 ? `${error.slice(0, 997)}...` : error;
39
+ }
40
+ }
41
+ return `Sync request failed with HTTP ${status}`;
42
+ }
43
+ function utf8Bytes(value) {
44
+ return Buffer.byteLength(value, "utf8");
45
+ }
46
+ function sha256(value) {
47
+ return createHash("sha256").update(value).digest("hex");
48
+ }
49
+ function utf8Prefix(value, maxBytes) {
50
+ let low = 0;
51
+ let high = value.length;
52
+ while (low < high) {
53
+ const middle = Math.ceil((low + high) / 2);
54
+ if (utf8Bytes(value.slice(0, middle)) <= maxBytes) low = middle;
55
+ else high = middle - 1;
56
+ }
57
+ let end = low;
58
+ if (end > 0 && end < value.length && value.charCodeAt(end - 1) >= 55296 && value.charCodeAt(end - 1) <= 56319 && value.charCodeAt(end) >= 56320 && value.charCodeAt(end) <= 57343) {
59
+ end -= 1;
60
+ }
61
+ return value.slice(0, end);
62
+ }
63
+ function codeUnitPrefix(value, requestedEnd) {
64
+ let end = Math.min(value.length, Math.max(0, requestedEnd));
65
+ if (end > 0 && end < value.length && value.charCodeAt(end - 1) >= 55296 && value.charCodeAt(end - 1) <= 56319 && value.charCodeAt(end) >= 56320 && value.charCodeAt(end) <= 57343) {
66
+ end -= 1;
67
+ }
68
+ return value.slice(0, end);
69
+ }
70
+ function boundedText(value, maxBytes, field) {
71
+ const originalBytes = utf8Bytes(value);
72
+ if (originalBytes <= maxBytes) return value;
73
+ const marker = `
74
+ [fml:${field}:truncated originalBytes=${originalBytes} sha256=${sha256(value)}]`;
75
+ const markerBytes = utf8Bytes(marker);
76
+ if (markerBytes >= maxBytes) {
77
+ throw new Error(`${field} cannot fit its truncation marker`);
78
+ }
79
+ return `${utf8Prefix(value, maxBytes - markerBytes)}${marker}`;
80
+ }
81
+ function requireUploadString(value, field, maxBytes) {
82
+ if (value.length === 0) throw new Error(`${field} must not be empty`);
83
+ if (utf8Bytes(value) > maxBytes) {
84
+ throw new Error(`${field} exceeds ${maxBytes} UTF-8 bytes`);
85
+ }
86
+ }
87
+ function optionalBoundedText(value, maxBytes, field) {
88
+ return value === void 0 ? void 0 : boundedText(value, maxBytes, field);
89
+ }
90
+ function serializedJson(value, description) {
91
+ let result;
92
+ try {
93
+ result = JSON.stringify(value);
94
+ } catch (error) {
95
+ throw new Error(`${description} is not JSON-serializable`, {
96
+ cause: error
97
+ });
98
+ }
99
+ if (result === void 0) {
100
+ throw new Error(`${description} is not JSON-serializable`);
101
+ }
102
+ return result;
103
+ }
104
+ function rejectionReason(prefix, error) {
105
+ const detail = error instanceof Error ? error.message : String(error);
106
+ return boundedText(
107
+ `${prefix}: ${detail}`,
108
+ MAX_REJECTION_REASON_BYTES,
109
+ "rejection_reason"
110
+ );
111
+ }
112
+ function terminalHttpStatus(status) {
113
+ return status === 400 || status === 413 || status === 422;
114
+ }
115
+ function perGroupServerFailure(status) {
116
+ return status >= 500 && status <= 599;
117
+ }
118
+ function abortReason(signal) {
119
+ return signal.reason ?? new Error("Hook sync aborted");
120
+ }
121
+ async function abortable(operation, signal) {
122
+ if (!signal) return await operation;
123
+ if (signal.aborted) {
124
+ void operation.catch(() => {
125
+ });
126
+ throw abortReason(signal);
127
+ }
128
+ return await new Promise((resolve, reject) => {
129
+ const onAbort = () => reject(abortReason(signal));
130
+ signal.addEventListener("abort", onAbort, { once: true });
131
+ void operation.then(
132
+ (value) => {
133
+ signal.removeEventListener("abort", onAbort);
134
+ resolve(value);
135
+ },
136
+ (error) => {
137
+ signal.removeEventListener("abort", onAbort);
138
+ reject(error);
139
+ }
140
+ );
141
+ if (signal.aborted) onAbort();
142
+ });
143
+ }
144
+ function sessionAcceptance(body, sessionId) {
145
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
146
+ return {
147
+ ok: false,
148
+ error: "Invalid v2 session response: expected a JSON object"
149
+ };
150
+ }
151
+ const accepted = body.accepted;
152
+ if (!Array.isArray(accepted)) {
153
+ return {
154
+ ok: false,
155
+ error: "Invalid v2 session response: expected an accepted session ID array"
156
+ };
157
+ }
158
+ if (accepted.some(
159
+ (value) => typeof value !== "string" || value !== sessionId
160
+ ) || accepted.length > 1) {
161
+ return {
162
+ ok: false,
163
+ error: "Invalid v2 session response: accepted contains an unrequested or duplicate session ID"
164
+ };
165
+ }
166
+ return { ok: true, accepted: accepted.length === 1 };
167
+ }
168
+ function hookAcceptance(body, records) {
169
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
170
+ return {
171
+ ok: false,
172
+ error: "Invalid v2 hook response: expected a JSON object"
173
+ };
174
+ }
175
+ const accepted = body.accepted;
176
+ if (!Array.isArray(accepted)) {
177
+ return {
178
+ ok: false,
179
+ error: "Invalid v2 hook response: expected an accepted sync ID array"
180
+ };
181
+ }
182
+ const requestedSyncIds = new Set(records.map((record) => record.syncId));
183
+ const acceptedSyncIds = /* @__PURE__ */ new Set();
184
+ for (const value of accepted) {
185
+ if (typeof value !== "string" || !requestedSyncIds.has(value)) {
186
+ return {
187
+ ok: false,
188
+ error: "Invalid v2 hook response: accepted contains an unrequested sync ID"
189
+ };
190
+ }
191
+ if (acceptedSyncIds.has(value)) {
192
+ return {
193
+ ok: false,
194
+ error: "Invalid v2 hook response: accepted contains a duplicate sync ID"
195
+ };
196
+ }
197
+ acceptedSyncIds.add(value);
198
+ }
199
+ return {
200
+ ok: true,
201
+ acceptedSyncIds: [...acceptedSyncIds],
202
+ omittedSyncIds: records.map((record) => record.syncId).filter((syncId) => !acceptedSyncIds.has(syncId))
203
+ };
204
+ }
205
+ function omittedHookReceiptError(omittedCount) {
206
+ return `Server did not accept ${omittedCount} hook event${omittedCount === 1 ? "" : "s"}; retrying after authorization refresh`;
207
+ }
208
+ function retryDelayForRecords(records) {
209
+ const retryCount = records.reduce(
210
+ (highest, record) => Math.max(highest, record.retryCount),
211
+ 0
212
+ );
213
+ return Math.min(
214
+ MAX_RETRY_INTERVAL_MS,
215
+ MIN_RETRY_INTERVAL_MS * 2 ** Math.min(retryCount, 8)
216
+ );
217
+ }
218
+ async function postSyncRows(destination, table, rows, opts) {
219
+ const headers = {
220
+ Authorization: destination.authorization,
221
+ "Content-Type": "application/json",
222
+ "User-Agent": `fml/${opts.version}`,
223
+ "X-Panopticon-Version": opts.version
224
+ };
225
+ const request = opts.fetch ?? fetch;
226
+ const timeoutSignal = AbortSignal.timeout(HOOK_UPLOAD_TIMEOUT_MS);
227
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
228
+ let response;
229
+ try {
230
+ response = await request(`${destination.targetUrl}/v2/sync`, {
231
+ method: "POST",
232
+ headers,
233
+ body: JSON.stringify({ producer: "hook", table, rows }),
234
+ signal
235
+ });
236
+ } catch (error) {
237
+ if (opts.signal?.aborted) throw opts.signal.reason ?? error;
238
+ const detail = error instanceof Error ? error.message : String(error);
239
+ return {
240
+ ok: false,
241
+ status: 0,
242
+ body: null,
243
+ error: detail.length > 1e3 ? `Hook sync request failed: ${detail.slice(0, 970)}...` : `Hook sync request failed: ${detail}`
244
+ };
245
+ }
246
+ let body;
247
+ try {
248
+ body = await response.json();
249
+ } catch {
250
+ body = null;
251
+ }
252
+ return {
253
+ ok: response.ok,
254
+ status: response.status,
255
+ body,
256
+ ...response.ok ? {} : { error: errorText(response.status, body) },
257
+ ...response.status === 429 || response.status === 503 ? { retryAtMs: retryAtMs(response, opts.nowMs()) } : {}
258
+ };
259
+ }
260
+ function boundedPayloadRow(baseRow, payload) {
261
+ const initialJson = serializedJson(baseRow, "Hook event row");
262
+ const initialBytes = utf8Bytes(initialJson);
263
+ if (initialBytes <= MAX_HOOK_EVENT_ROW_JSON_BYTES) {
264
+ return { row: baseRow, jsonBytes: initialBytes };
265
+ }
266
+ const payloadJson = serializedJson(payload, "Hook event payload");
267
+ const metadata = {
268
+ truncated: true,
269
+ format: "json-prefix-v1",
270
+ originalBytes: utf8Bytes(payloadJson),
271
+ sha256: sha256(payloadJson)
272
+ };
273
+ let low = 0;
274
+ let high = payloadJson.length;
275
+ let bestRow = null;
276
+ let bestBytes = 0;
277
+ while (low <= high) {
278
+ const middle = Math.floor((low + high) / 2);
279
+ const candidate = {
280
+ ...baseRow,
281
+ payload: {
282
+ _fmlTruncated: metadata,
283
+ preview: codeUnitPrefix(payloadJson, middle)
284
+ }
285
+ };
286
+ const candidateBytes = utf8Bytes(
287
+ serializedJson(candidate, "Truncated hook event row")
288
+ );
289
+ if (candidateBytes <= MAX_HOOK_EVENT_ROW_JSON_BYTES) {
290
+ bestRow = candidate;
291
+ bestBytes = candidateBytes;
292
+ low = middle + 1;
293
+ } else {
294
+ high = middle - 1;
295
+ }
296
+ }
297
+ if (!bestRow) {
298
+ throw new Error(
299
+ `Hook event row cannot fit within ${MAX_HOOK_EVENT_ROW_JSON_BYTES} UTF-8 bytes`
300
+ );
301
+ }
302
+ return { row: bestRow, jsonBytes: bestBytes };
303
+ }
304
+ async function prepareHookEvent(record, signal) {
305
+ requireUploadString(record.syncId, "Hook event sync ID", 1024);
306
+ requireUploadString(record.sessionId, "Hook event session ID", 4096);
307
+ requireUploadString(record.eventType, "Hook event type", 1024);
308
+ requireUploadString(record.target, "Hook event target", 256);
309
+ if (!Number.isSafeInteger(record.timestampMs) || record.timestampMs < 0) {
310
+ throw new Error("Hook event timestamp must be a non-negative safe integer");
311
+ }
312
+ const repositoryInfo = await repositoryForRecord(record, signal);
313
+ const repository = repositoryInfo?.repo;
314
+ const cwd = optionalBoundedText(record.cwd, MAX_CONTEXT_TEXT_BYTES, "cwd");
315
+ const boundedRepository = optionalBoundedText(
316
+ repository,
317
+ MAX_CONTEXT_TEXT_BYTES,
318
+ "repository"
319
+ );
320
+ const toolName = optionalBoundedText(
321
+ record.toolName,
322
+ MAX_CONTEXT_TEXT_BYTES,
323
+ "tool_name"
324
+ );
325
+ const userPrompt = optionalBoundedText(
326
+ record.userPrompt,
327
+ MAX_EXTRACTED_TEXT_BYTES,
328
+ "user_prompt"
329
+ );
330
+ const filePath = optionalBoundedText(
331
+ record.filePath,
332
+ MAX_CONTEXT_TEXT_BYTES,
333
+ "file_path"
334
+ );
335
+ const command = optionalBoundedText(
336
+ record.command,
337
+ MAX_EXTRACTED_TEXT_BYTES,
338
+ "command"
339
+ );
340
+ const plan = optionalBoundedText(
341
+ record.plan,
342
+ MAX_EXTRACTED_TEXT_BYTES,
343
+ "plan"
344
+ );
345
+ const allowedPrompts = optionalBoundedText(
346
+ record.allowedPrompts,
347
+ MAX_EXTRACTED_TEXT_BYTES,
348
+ "allowed_prompts"
349
+ );
350
+ const toolResult = optionalBoundedText(
351
+ record.toolResult,
352
+ MAX_EXTRACTED_TEXT_BYTES,
353
+ "tool_result"
354
+ );
355
+ const baseRow = {
356
+ sessionId: record.sessionId,
357
+ syncId: record.syncId,
358
+ eventType: record.eventType,
359
+ timestampMs: record.timestampMs,
360
+ target: record.target,
361
+ payload: record.payload,
362
+ ...cwd ? { cwd } : {},
363
+ ...boundedRepository ? { repository: boundedRepository } : {},
364
+ ...toolName ? { toolName } : {},
365
+ ...userPrompt ? { userPrompt } : {},
366
+ ...filePath ? { filePath } : {},
367
+ ...command ? { command } : {},
368
+ ...plan ? { plan } : {},
369
+ ...allowedPrompts ? { allowedPrompts } : {},
370
+ ...toolResult ? { toolResult } : {}
371
+ };
372
+ const bounded = boundedPayloadRow(baseRow, record.payload);
373
+ return { record, repositoryInfo, ...bounded };
374
+ }
375
+ async function repositoryForRecord(record, signal) {
376
+ if (record.repository) return { repo: record.repository, branch: null };
377
+ return record.cwd ? await abortable(resolveRepoFromCwdAsync(record.cwd, signal), signal) : null;
378
+ }
379
+ function hookSessionRow(events) {
380
+ const records = events.map((event) => event.record);
381
+ const first = records[0];
382
+ if (!first)
383
+ throw new Error("cannot build a session from an empty hook batch");
384
+ const startedAtMs = Math.min(...records.map((record) => record.timestampMs));
385
+ const firstPromptValue = records.find(
386
+ (record) => record.userPrompt
387
+ )?.userPrompt;
388
+ const firstPrompt = optionalBoundedText(
389
+ firstPromptValue,
390
+ MAX_EXTRACTED_TEXT_BYTES,
391
+ "session_first_prompt"
392
+ );
393
+ const repositoriesByName = /* @__PURE__ */ new Map();
394
+ const cwdsByPath = /* @__PURE__ */ new Map();
395
+ for (const [order, event] of events.entries()) {
396
+ const { record, repositoryInfo } = event;
397
+ if (repositoryInfo) {
398
+ const repository = boundedText(
399
+ repositoryInfo.repo,
400
+ MAX_CONTEXT_TEXT_BYTES,
401
+ "session_repository"
402
+ );
403
+ const branch = repositoryInfo.branch ? boundedText(
404
+ repositoryInfo.branch,
405
+ MAX_CONTEXT_TEXT_BYTES,
406
+ "session_branch"
407
+ ) : void 0;
408
+ const existing = repositoriesByName.get(repository);
409
+ if (!existing) {
410
+ repositoriesByName.set(repository, {
411
+ repository,
412
+ firstSeenMs: record.timestampMs,
413
+ ...branch ? { branch } : {},
414
+ order
415
+ });
416
+ } else {
417
+ if (record.timestampMs < existing.firstSeenMs) {
418
+ existing.firstSeenMs = record.timestampMs;
419
+ existing.branch = branch;
420
+ } else if (existing.branch === void 0 && branch !== void 0) {
421
+ existing.branch = branch;
422
+ }
423
+ }
424
+ }
425
+ const cwd = optionalBoundedText(
426
+ record.cwd,
427
+ MAX_CONTEXT_TEXT_BYTES,
428
+ "session_cwd"
429
+ );
430
+ if (cwd) {
431
+ const existing = cwdsByPath.get(cwd);
432
+ if (!existing) {
433
+ cwdsByPath.set(cwd, {
434
+ cwd,
435
+ firstSeenMs: record.timestampMs,
436
+ order
437
+ });
438
+ } else {
439
+ existing.firstSeenMs = Math.min(
440
+ existing.firstSeenMs,
441
+ record.timestampMs
442
+ );
443
+ }
444
+ }
445
+ }
446
+ const repositories = [...repositoriesByName.values()].sort(
447
+ (left, right) => left.firstSeenMs - right.firstSeenMs || left.order - right.order
448
+ ).map(({ order: _order, ...relationship }) => relationship);
449
+ const cwds = [...cwdsByPath.values()].sort(
450
+ (left, right) => left.firstSeenMs - right.firstSeenMs || left.order - right.order
451
+ ).map(({ order: _order, ...relationship }) => relationship);
452
+ return {
453
+ sessionId: first.sessionId,
454
+ target: first.target,
455
+ startedAtMs,
456
+ createdAt: startedAtMs,
457
+ machine: "local",
458
+ ...firstPrompt ? { firstPrompt } : {},
459
+ ...repositories.length > 0 ? { repositories } : {},
460
+ ...cwds.length > 0 ? { cwds } : {}
461
+ };
462
+ }
463
+ function batchesForSession(events) {
464
+ const batches = [];
465
+ let batch = [];
466
+ let batchBytes = 0;
467
+ let repositories = /* @__PURE__ */ new Set();
468
+ let cwds = /* @__PURE__ */ new Set();
469
+ for (const event of events) {
470
+ const repository = typeof event.row.repository === "string" ? event.row.repository : void 0;
471
+ const cwd = typeof event.row.cwd === "string" ? event.row.cwd : void 0;
472
+ const nextRelationshipCount = repositories.size + cwds.size + (repository && !repositories.has(repository) ? 1 : 0) + (cwd && !cwds.has(cwd) ? 1 : 0);
473
+ const exceedsSessionRowLimit = batch.length > 0 && utf8Bytes(
474
+ serializedJson(hookSessionRow([...batch, event]), "Hook session row")
475
+ ) > MAX_SESSION_ROW_JSON_BYTES;
476
+ if (batch.length > 0 && (batch.length >= 100 || batchBytes + event.jsonBytes > MAX_BATCH_JSON_BYTES || nextRelationshipCount > MAX_SESSION_RELATIONSHIPS_PER_ROW || exceedsSessionRowLimit)) {
477
+ batches.push(batch);
478
+ batch = [];
479
+ batchBytes = 0;
480
+ repositories = /* @__PURE__ */ new Set();
481
+ cwds = /* @__PURE__ */ new Set();
482
+ }
483
+ batch.push(event);
484
+ batchBytes += event.jsonBytes;
485
+ if (repository) repositories.add(repository);
486
+ if (cwd) cwds.add(cwd);
487
+ }
488
+ if (batch.length > 0) batches.push(batch);
489
+ return batches;
490
+ }
491
+ function groupPendingEvents(events) {
492
+ const bySession = /* @__PURE__ */ new Map();
493
+ for (const event of events) {
494
+ const session = bySession.get(event.record.sessionId) ?? [];
495
+ session.push(event);
496
+ bySession.set(event.record.sessionId, session);
497
+ }
498
+ return [...bySession.values()].flatMap(batchesForSession);
499
+ }
500
+ function activeHookDestinationSnapshot() {
501
+ const environment = getActiveEnv();
502
+ const destinationIdentity = getSyncDestinationIdentity(environment.name);
503
+ if (!destinationIdentity) {
504
+ return { kind: "unauthenticated", environment: environment.name };
505
+ }
506
+ return {
507
+ kind: "ready",
508
+ binding: {
509
+ environment: environment.name,
510
+ targetUrl: getSiteUrlForConvexUrl(environment.convexUrl),
511
+ destinationIdentity
512
+ }
513
+ };
514
+ }
515
+ function getActiveHookDestinationBinding() {
516
+ const snapshot = activeHookDestinationSnapshot();
517
+ return snapshot.kind === "ready" ? snapshot.binding : null;
518
+ }
519
+ function sameDestinationBinding(left, right) {
520
+ return left.environment === right.environment && left.targetUrl === right.targetUrl && left.destinationIdentity === right.destinationIdentity;
521
+ }
522
+ async function resolveDestination() {
523
+ const initial = activeHookDestinationSnapshot();
524
+ if (initial.kind !== "ready") return initial;
525
+ const destination = initial.binding;
526
+ const token = await getValidToken({ env: destination.environment });
527
+ if (!token) {
528
+ return {
529
+ kind: "unauthenticated",
530
+ environment: destination.environment
531
+ };
532
+ }
533
+ const verified = activeHookDestinationSnapshot();
534
+ if (verified.kind !== "ready" || !sameDestinationBinding(destination, verified.binding)) {
535
+ return verified.kind === "ready" ? { kind: "unauthenticated", environment: verified.binding.environment } : verified;
536
+ }
537
+ return {
538
+ kind: "ready",
539
+ destination: {
540
+ ...destination,
541
+ authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}`
542
+ }
543
+ };
544
+ }
545
+ async function runHookSyncCycle(opts) {
546
+ const nowMs = opts.nowMs ?? Date.now;
547
+ const totalPending = opts.database.countPendingHookEvents();
548
+ if (totalPending === 0) {
549
+ return { kind: "drained", pending: 0, uploaded: 0 };
550
+ }
551
+ if (opts.signal?.aborted) throw abortReason(opts.signal);
552
+ const destinationResolution = await abortable(
553
+ resolveDestination(),
554
+ opts.signal
555
+ );
556
+ if (destinationResolution.kind !== "ready") {
557
+ return {
558
+ kind: destinationResolution.kind,
559
+ environment: destinationResolution.environment,
560
+ pending: opts.database.countPendingHookEvents(),
561
+ uploaded: 0
562
+ };
563
+ }
564
+ const destination = destinationResolution.destination;
565
+ const claimedAtMs = nowMs();
566
+ const pendingRecords = opts.database.claimPendingHookEvents(
567
+ destination,
568
+ 100,
569
+ claimedAtMs
570
+ );
571
+ if (pendingRecords.length === 0) {
572
+ const retryAtMs2 = opts.database.nextPendingHookAttemptAtMs(destination);
573
+ if (retryAtMs2 !== void 0 && retryAtMs2 > claimedAtMs) {
574
+ return {
575
+ kind: "completed",
576
+ environment: destination.environment,
577
+ pending: opts.database.countPendingHookEvents(),
578
+ uploaded: 0,
579
+ error: "Hook events are waiting for their authorization retry",
580
+ retryAtMs: retryAtMs2
581
+ };
582
+ }
583
+ return {
584
+ kind: "destination_drained",
585
+ environment: destination.environment,
586
+ pending: opts.database.countPendingHookEvents(),
587
+ uploaded: 0
588
+ };
589
+ }
590
+ let uploaded = 0;
591
+ let quarantined = 0;
592
+ let lastError;
593
+ let transportRetryAtMs;
594
+ let blockedByTransportFailure = false;
595
+ const deferredSessionRetryAtMs = /* @__PURE__ */ new Map();
596
+ const deferRetryableRecords = (records, error, notBeforeMs) => {
597
+ if (records.length === 0) return void 0;
598
+ lastError = error;
599
+ const retryAtMs2 = Math.max(
600
+ nowMs() + retryDelayForRecords(records),
601
+ notBeforeMs ?? 0
602
+ );
603
+ const deferred = opts.database.deferHookEvents(
604
+ destination,
605
+ records.map((record) => record.syncId),
606
+ retryAtMs2
607
+ );
608
+ return deferred === 0 ? void 0 : retryAtMs2;
609
+ };
610
+ const deferUnattemptedSessionTail = (events, attemptedIndex, retryAtMs2) => {
611
+ const syncIds = events.slice(attemptedIndex + 1).map((event) => event.record.syncId);
612
+ if (syncIds.length === 0) return;
613
+ opts.database.deferHookEvents(destination, syncIds, retryAtMs2);
614
+ };
615
+ const preparedEvents = [];
616
+ for (const record of pendingRecords) {
617
+ if (opts.signal?.aborted) throw abortReason(opts.signal);
618
+ try {
619
+ preparedEvents.push(await prepareHookEvent(record, opts.signal));
620
+ } catch (error) {
621
+ if (opts.signal?.aborted) throw abortReason(opts.signal);
622
+ quarantined += opts.database.quarantineHookEvents(
623
+ destination,
624
+ [
625
+ {
626
+ syncId: record.syncId,
627
+ reason: rejectionReason("local_serialization_error", error)
628
+ }
629
+ ],
630
+ nowMs()
631
+ );
632
+ }
633
+ }
634
+ eventGroups: for (const events of groupPendingEvents(preparedEvents)) {
635
+ if (opts.signal?.aborted) throw abortReason(opts.signal);
636
+ const records = events.map((event) => event.record);
637
+ const sessionId = records[0].sessionId;
638
+ const deferredSessionRetry = deferredSessionRetryAtMs.get(sessionId);
639
+ if (deferredSessionRetry !== void 0) {
640
+ opts.database.deferHookEvents(
641
+ destination,
642
+ records.map((record) => record.syncId),
643
+ deferredSessionRetry
644
+ );
645
+ continue;
646
+ }
647
+ const requestOptions = {
648
+ version: opts.version,
649
+ ...opts.signal ? { signal: opts.signal } : {},
650
+ ...opts.fetch ? { fetch: opts.fetch } : {},
651
+ nowMs
652
+ };
653
+ let sessionRow;
654
+ try {
655
+ sessionRow = hookSessionRow(events);
656
+ const sessionRowBytes = utf8Bytes(
657
+ serializedJson(sessionRow, "Hook session row")
658
+ );
659
+ if (sessionRowBytes > MAX_SESSION_ROW_JSON_BYTES) {
660
+ throw new Error(
661
+ `Hook session row exceeds ${MAX_SESSION_ROW_JSON_BYTES} UTF-8 bytes`
662
+ );
663
+ }
664
+ } catch (error) {
665
+ quarantined += opts.database.quarantineHookEvents(
666
+ destination,
667
+ records.map((record) => ({
668
+ syncId: record.syncId,
669
+ reason: rejectionReason("session_serialization_error", error)
670
+ })),
671
+ nowMs()
672
+ );
673
+ continue;
674
+ }
675
+ const sessionResponse = await postSyncRows(
676
+ destination,
677
+ "sessions",
678
+ [sessionRow],
679
+ requestOptions
680
+ );
681
+ if (sessionResponse.status === 401) {
682
+ return {
683
+ kind: "unauthenticated",
684
+ environment: destination.environment,
685
+ pending: opts.database.countPendingHookEvents(),
686
+ uploaded
687
+ };
688
+ }
689
+ if (!sessionResponse.ok) {
690
+ if (terminalHttpStatus(sessionResponse.status)) {
691
+ const retryAtMs2 = deferRetryableRecords(
692
+ records,
693
+ rejectionReason(
694
+ `session_upload_rejected_http_${sessionResponse.status}`,
695
+ sessionResponse.error ?? "request rejected"
696
+ )
697
+ );
698
+ if (retryAtMs2 !== void 0) {
699
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
700
+ }
701
+ continue;
702
+ }
703
+ const error = sessionResponse.error ?? "Session sync request failed";
704
+ transportRetryAtMs = deferRetryableRecords(
705
+ records,
706
+ error,
707
+ sessionResponse.retryAtMs
708
+ );
709
+ if (perGroupServerFailure(sessionResponse.status)) {
710
+ if (transportRetryAtMs !== void 0) {
711
+ deferredSessionRetryAtMs.set(sessionId, transportRetryAtMs);
712
+ }
713
+ continue;
714
+ }
715
+ blockedByTransportFailure = true;
716
+ break;
717
+ }
718
+ const sessionReceipt = sessionAcceptance(
719
+ sessionResponse.body,
720
+ records[0].sessionId
721
+ );
722
+ if (!sessionReceipt.ok) {
723
+ const retryAtMs2 = deferRetryableRecords(records, sessionReceipt.error);
724
+ if (retryAtMs2 !== void 0) {
725
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
726
+ }
727
+ continue;
728
+ }
729
+ if (!sessionReceipt.accepted) {
730
+ const retryAtMs2 = deferRetryableRecords(
731
+ records,
732
+ `Server has not accepted session ${records[0].sessionId}; retrying after authorization refresh`
733
+ );
734
+ if (retryAtMs2 !== void 0) {
735
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
736
+ }
737
+ continue;
738
+ }
739
+ const hookResponse = await postSyncRows(
740
+ destination,
741
+ "hook_events",
742
+ events.map((event) => event.row),
743
+ requestOptions
744
+ );
745
+ if (hookResponse.status === 401) {
746
+ return {
747
+ kind: "unauthenticated",
748
+ environment: destination.environment,
749
+ pending: opts.database.countPendingHookEvents(),
750
+ uploaded
751
+ };
752
+ }
753
+ if (!hookResponse.ok) {
754
+ if (terminalHttpStatus(hookResponse.status)) {
755
+ if (events.length > 1) {
756
+ for (const [eventIndex, event] of events.entries()) {
757
+ const isolatedResponse = await postSyncRows(
758
+ destination,
759
+ "hook_events",
760
+ [event.row],
761
+ requestOptions
762
+ );
763
+ if (isolatedResponse.status === 401) {
764
+ return {
765
+ kind: "unauthenticated",
766
+ environment: destination.environment,
767
+ pending: opts.database.countPendingHookEvents(),
768
+ uploaded
769
+ };
770
+ }
771
+ if (isolatedResponse.ok) {
772
+ const acceptance2 = hookAcceptance(isolatedResponse.body, [
773
+ event.record
774
+ ]);
775
+ if (!acceptance2.ok) {
776
+ const retryAtMs2 = deferRetryableRecords(
777
+ [event.record],
778
+ acceptance2.error
779
+ );
780
+ if (retryAtMs2 !== void 0) {
781
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
782
+ deferUnattemptedSessionTail(events, eventIndex, retryAtMs2);
783
+ }
784
+ continue eventGroups;
785
+ }
786
+ if (acceptance2.acceptedSyncIds.length > 0) {
787
+ opts.database.acknowledgeHookEvents(
788
+ destination,
789
+ acceptance2.acceptedSyncIds
790
+ );
791
+ uploaded += acceptance2.acceptedSyncIds.length;
792
+ }
793
+ if (acceptance2.omittedSyncIds.length > 0) {
794
+ const retryAtMs2 = deferRetryableRecords(
795
+ [event.record],
796
+ omittedHookReceiptError(acceptance2.omittedSyncIds.length)
797
+ );
798
+ if (retryAtMs2 !== void 0) {
799
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
800
+ deferUnattemptedSessionTail(events, eventIndex, retryAtMs2);
801
+ }
802
+ continue eventGroups;
803
+ }
804
+ continue;
805
+ }
806
+ if (terminalHttpStatus(isolatedResponse.status)) {
807
+ quarantined += opts.database.quarantineHookEvents(
808
+ destination,
809
+ [
810
+ {
811
+ syncId: event.record.syncId,
812
+ reason: rejectionReason(
813
+ `hook_upload_rejected_http_${isolatedResponse.status}`,
814
+ isolatedResponse.error ?? "request rejected"
815
+ )
816
+ }
817
+ ],
818
+ nowMs()
819
+ );
820
+ continue;
821
+ }
822
+ const error2 = isolatedResponse.error ?? "Hook sync request failed";
823
+ transportRetryAtMs = deferRetryableRecords(
824
+ [event.record],
825
+ error2,
826
+ isolatedResponse.retryAtMs
827
+ );
828
+ if (perGroupServerFailure(isolatedResponse.status)) {
829
+ if (transportRetryAtMs !== void 0) {
830
+ deferredSessionRetryAtMs.set(sessionId, transportRetryAtMs);
831
+ deferUnattemptedSessionTail(
832
+ events,
833
+ eventIndex,
834
+ transportRetryAtMs
835
+ );
836
+ }
837
+ continue eventGroups;
838
+ }
839
+ blockedByTransportFailure = true;
840
+ break eventGroups;
841
+ }
842
+ continue;
843
+ }
844
+ quarantined += opts.database.quarantineHookEvents(
845
+ destination,
846
+ records.map((record) => ({
847
+ syncId: record.syncId,
848
+ reason: rejectionReason(
849
+ `hook_upload_rejected_http_${hookResponse.status}`,
850
+ hookResponse.error ?? "request rejected"
851
+ )
852
+ })),
853
+ nowMs()
854
+ );
855
+ continue;
856
+ }
857
+ const error = hookResponse.error ?? "Hook sync request failed";
858
+ transportRetryAtMs = deferRetryableRecords(
859
+ records,
860
+ error,
861
+ hookResponse.retryAtMs
862
+ );
863
+ if (perGroupServerFailure(hookResponse.status)) {
864
+ if (transportRetryAtMs !== void 0) {
865
+ deferredSessionRetryAtMs.set(sessionId, transportRetryAtMs);
866
+ }
867
+ continue;
868
+ }
869
+ blockedByTransportFailure = true;
870
+ break;
871
+ }
872
+ const acceptance = hookAcceptance(hookResponse.body, records);
873
+ if (!acceptance.ok) {
874
+ const retryAtMs2 = deferRetryableRecords(records, acceptance.error);
875
+ if (retryAtMs2 !== void 0) {
876
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
877
+ }
878
+ continue;
879
+ }
880
+ if (acceptance.acceptedSyncIds.length > 0) {
881
+ opts.database.acknowledgeHookEvents(
882
+ destination,
883
+ acceptance.acceptedSyncIds
884
+ );
885
+ uploaded += acceptance.acceptedSyncIds.length;
886
+ }
887
+ if (acceptance.omittedSyncIds.length > 0) {
888
+ const omittedSyncIds = new Set(acceptance.omittedSyncIds);
889
+ const retryAtMs2 = deferRetryableRecords(
890
+ records.filter((record) => omittedSyncIds.has(record.syncId)),
891
+ omittedHookReceiptError(acceptance.omittedSyncIds.length)
892
+ );
893
+ if (retryAtMs2 !== void 0) {
894
+ deferredSessionRetryAtMs.set(sessionId, retryAtMs2);
895
+ }
896
+ }
897
+ }
898
+ const nextPendingAttemptAtMs = opts.database.nextPendingHookAttemptAtMs(destination);
899
+ const nextRetryAtMs = blockedByTransportFailure ? transportRetryAtMs : nextPendingAttemptAtMs === void 0 ? void 0 : Math.max(nextPendingAttemptAtMs, nowMs());
900
+ return {
901
+ kind: "completed",
902
+ environment: destination.environment,
903
+ pending: opts.database.countPendingHookEvents(),
904
+ uploaded,
905
+ ...quarantined > 0 ? { quarantined } : {},
906
+ ...lastError ? { error: lastError } : {},
907
+ ...nextRetryAtMs === void 0 ? {} : { retryAtMs: nextRetryAtMs },
908
+ ...blockedByTransportFailure ? { wakeOnCapture: false } : {}
909
+ };
910
+ }
911
+ function createHookSyncLoop(opts) {
912
+ const nowMs = opts.nowMs ?? Date.now;
913
+ const runOnce = opts.runOnce ?? runHookSyncCycle;
914
+ let stopping = false;
915
+ let wakePending = false;
916
+ let forceWakePending = false;
917
+ let wakeWaiter = null;
918
+ let activeAbort = null;
919
+ let failures = 0;
920
+ let status = {
921
+ phase: "idle",
922
+ pending: opts.database.countPendingHookEvents()
923
+ };
924
+ const wake = (force = false) => {
925
+ wakePending = true;
926
+ forceWakePending ||= force;
927
+ wakeWaiter?.(stopping || force);
928
+ };
929
+ const wait = async (delayMs, wakeOnCapture = true) => {
930
+ if (stopping || forceWakePending || wakePending && wakeOnCapture) {
931
+ wakePending = false;
932
+ forceWakePending = false;
933
+ return;
934
+ }
935
+ if (!wakeOnCapture) wakePending = false;
936
+ await new Promise((resolve) => {
937
+ const finish = () => {
938
+ clearTimeout(timer);
939
+ wakeWaiter = null;
940
+ wakePending = false;
941
+ forceWakePending = false;
942
+ resolve();
943
+ };
944
+ const timer = setTimeout(() => {
945
+ finish();
946
+ }, delayMs);
947
+ wakeWaiter = (force) => {
948
+ if (!force && !wakeOnCapture) return;
949
+ finish();
950
+ };
951
+ });
952
+ };
953
+ const done = (async () => {
954
+ while (!stopping) {
955
+ const attemptedAtMs = nowMs();
956
+ status = {
957
+ ...status,
958
+ phase: "syncing",
959
+ pending: opts.database.countPendingHookEvents(),
960
+ lastAttemptAtMs: attemptedAtMs,
961
+ lastError: void 0,
962
+ nextAttemptAtMs: void 0
963
+ };
964
+ activeAbort = new AbortController();
965
+ let result;
966
+ try {
967
+ result = await runOnce({
968
+ database: opts.database,
969
+ version: opts.version,
970
+ signal: activeAbort.signal,
971
+ nowMs
972
+ });
973
+ } catch (error) {
974
+ activeAbort = null;
975
+ if (stopping) break;
976
+ failures += 1;
977
+ const delayMs2 = Math.min(
978
+ MAX_RETRY_INTERVAL_MS,
979
+ MIN_RETRY_INTERVAL_MS * 2 ** Math.min(failures - 1, 8)
980
+ );
981
+ const nextAttemptAtMs = nowMs() + delayMs2;
982
+ status = {
983
+ phase: "backing_off",
984
+ pending: opts.database.countPendingHookEvents(),
985
+ lastAttemptAtMs: attemptedAtMs,
986
+ lastError: error instanceof Error ? error.message : String(error),
987
+ nextAttemptAtMs
988
+ };
989
+ await wait(delayMs2, false);
990
+ continue;
991
+ }
992
+ activeAbort = null;
993
+ if (stopping) break;
994
+ const completedAtMs = nowMs();
995
+ const succeeded = result.kind === "drained" || result.kind === "destination_drained" || result.uploaded > 0;
996
+ failures = result.kind === "completed" && result.error ? failures + 1 : 0;
997
+ const failureDelay = Math.min(
998
+ MAX_RETRY_INTERVAL_MS,
999
+ MIN_RETRY_INTERVAL_MS * 2 ** Math.min(Math.max(0, failures - 1), 8)
1000
+ );
1001
+ const delayMs = result.kind === "unauthenticated" ? AUTH_INTERVAL_MS : result.kind === "completed" && result.pending > 0 ? result.retryAtMs !== void 0 ? Math.max(
1002
+ MIN_RETRY_INTERVAL_MS,
1003
+ result.retryAtMs - completedAtMs
1004
+ ) : result.error ? failureDelay : MIN_RETRY_INTERVAL_MS : IDLE_INTERVAL_MS;
1005
+ status = {
1006
+ phase: result.kind === "unauthenticated" ? "waiting_for_auth" : result.kind === "completed" && result.error ? "backing_off" : "idle",
1007
+ pending: result.pending,
1008
+ lastAttemptAtMs: attemptedAtMs,
1009
+ ...succeeded ? { lastSuccessAtMs: completedAtMs } : {},
1010
+ ...result.kind === "completed" && result.error ? { lastError: result.error } : result.kind === "unauthenticated" ? {
1011
+ lastError: "FML login required"
1012
+ } : {},
1013
+ nextAttemptAtMs: completedAtMs + delayMs
1014
+ };
1015
+ await wait(
1016
+ delayMs,
1017
+ result.kind !== "unauthenticated" && (result.kind !== "completed" || result.wakeOnCapture !== false)
1018
+ );
1019
+ }
1020
+ })();
1021
+ return {
1022
+ done,
1023
+ wake,
1024
+ stop: () => {
1025
+ if (stopping) return;
1026
+ stopping = true;
1027
+ activeAbort?.abort();
1028
+ wake(true);
1029
+ },
1030
+ status: () => status
1031
+ };
1032
+ }
1033
+
1034
+ export {
1035
+ getActiveHookDestinationBinding,
1036
+ createHookSyncLoop
1037
+ };