@fml-inc/fml 0.7.0 → 0.8.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -4
- package/README.md +312 -79
- package/agent-assets/fml/SKILL.md +180 -0
- package/bin/fml +4 -18
- package/bin/hook-handler +7 -0
- package/bin/mcp-server +4 -18
- package/bin/observer +6 -0
- package/dist/chunk-7DR5X7XI.js +1522 -0
- package/dist/chunk-CK5MEL5J.js +140 -0
- package/dist/chunk-DEMVOVVY.js +4442 -0
- package/dist/chunk-F66IBM3Z.js +1037 -0
- package/dist/chunk-HUR2HCUG.js +953 -0
- package/dist/chunk-I4GZPTBM.js +109 -0
- package/dist/chunk-JXSSPQ5B.js +3331 -0
- package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
- package/dist/{chunk-PTUZMKSN.js → chunk-P3QLARXE.js} +34 -23
- package/dist/chunk-T5YVLSUC.js +138 -0
- package/dist/cli.js +7523 -1005
- package/dist/{esm-2EUDFZWP.js → esm-4YKLKFRX.js} +546 -741
- package/dist/hook-handler.js +190 -0
- package/dist/mcp/server.js +112 -98
- package/dist/observer-server.js +1566 -0
- package/dist/sync-cycle.js +31 -0
- package/dist/{token-store-3IQSLOTQ.js → token-store-R7WXEPLI.js} +10 -3
- package/hooks/hooks.json +290 -0
- package/package.json +24 -27
- package/dist/chunk-PTUZMKSN.js.map +0 -1
- package/dist/chunk-PZ5AY32C.js.map +0 -1
- package/dist/chunk-S4TJNLEN.js +0 -61
- package/dist/chunk-S4TJNLEN.js.map +0 -1
- package/dist/chunk-VV4JHH4H.js +0 -437
- package/dist/chunk-VV4JHH4H.js.map +0 -1
- package/dist/chunk-YH5ROR7G.js +0 -69
- package/dist/chunk-YH5ROR7G.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/daemon-MSLDHLMH.js +0 -21
- package/dist/daemon-MSLDHLMH.js.map +0 -1
- package/dist/daemon-utils-OODBIYB7.js +0 -10
- package/dist/daemon-utils-OODBIYB7.js.map +0 -1
- package/dist/esm-2EUDFZWP.js.map +0 -1
- package/dist/mcp/server.js.map +0 -1
- package/dist/postinstall.js +0 -16
- package/dist/postinstall.js.map +0 -1
- package/dist/token-store-3IQSLOTQ.js.map +0 -1
- package/scripts/postinstall.cjs +0 -25
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveRepoFromCwdAsync
|
|
3
|
+
} from "./chunk-T5YVLSUC.js";
|
|
4
|
+
import {
|
|
5
|
+
getActiveEnv,
|
|
6
|
+
getSiteUrlForConvexUrl,
|
|
7
|
+
getSyncDestinationIdentity,
|
|
8
|
+
getValidToken
|
|
9
|
+
} from "./chunk-7DR5X7XI.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
|
+
};
|