@mnemonik/shared 7.153.0 → 7.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gateOutbox.d.ts +65 -0
- package/dist/gateOutbox.d.ts.map +1 -0
- package/dist/gateOutbox.js +156 -0
- package/dist/gateOutbox.js.map +1 -0
- package/dist/hookConversation.d.ts +9 -0
- package/dist/hookConversation.d.ts.map +1 -1
- package/dist/hookConversation.js +35 -1
- package/dist/hookConversation.js.map +1 -1
- package/dist/hookRuntime.d.ts +1 -0
- package/dist/hookRuntime.d.ts.map +1 -1
- package/dist/hookRuntime.js +1 -0
- package/dist/hookRuntime.js.map +1 -1
- package/dist/hookTimeouts.d.ts +80 -42
- package/dist/hookTimeouts.d.ts.map +1 -1
- package/dist/hookTimeouts.js +245 -59
- package/dist/hookTimeouts.js.map +1 -1
- package/package.json +2 -2
- package/src/gateOutbox.ts +181 -0
- package/src/hookConversation.ts +32 -1
- package/src/hookRuntime.ts +1 -0
- package/src/hookTimeouts.ts +252 -61
- package/tests/gateOutbox.test.ts +121 -0
package/src/hookTimeouts.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import diagnosticsChannel from 'node:diagnostics_channel';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Hook dispatcher HTTP timeout budgets + AbortSignal helper.
|
|
3
5
|
*
|
|
@@ -108,51 +110,63 @@ export function withHookTimeout(ms: number): { signal: AbortSignal; cleanup: ()
|
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/**
|
|
111
|
-
*
|
|
113
|
+
* The main hook call (POST /api/v1/hooks/injections) is hedged: when the first
|
|
114
|
+
* request has not answered by HOOK_HEDGE_SHARE of the budget, a second request
|
|
115
|
+
* starts beside it on a fresh connection, and whichever answers first is used.
|
|
116
|
+
*
|
|
117
|
+
* Why a second request at all: on 24 October 2026 about a third of one
|
|
118
|
+
* machine's hook calls were lost for 50 minutes to a stall between Cloudflare
|
|
119
|
+
* and one load-balancer node. The server had accepted those requests and was
|
|
120
|
+
* waiting for a body that never finished arriving. A second request opens a new
|
|
121
|
+
* connection and normally lands on a healthy path.
|
|
112
122
|
*
|
|
113
|
-
* Why it
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
123
|
+
* Why it is a hedge and not a retry: v7.149.0 abandoned the first request at
|
|
124
|
+
* 65% of the budget and then retried. Every hook process opens its own
|
|
125
|
+
* connection (DNS, TCP and TLS from the user's machine to Cloudflare), and the
|
|
126
|
+
* credential is read before the request leaves, so on 25 October that first
|
|
127
|
+
* request had often been on the wire for only a few hundred milliseconds when
|
|
128
|
+
* it was abandoned. Answers that were merely slow were thrown away, half the
|
|
129
|
+
* calls on the owner's machine needed two requests, about 8% lost both, and the
|
|
130
|
+
* load balancer counted each abandoned request as a client close (ELB 460). A
|
|
131
|
+
* hedge never abandons an answer that is on its way: the first request keeps
|
|
132
|
+
* running to the end of the budget.
|
|
119
133
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
134
|
+
* Both requests share one deadline, `budgetMs` from the call, so the budget
|
|
135
|
+
* every hook event is checked against (tests/HookEventBudgets.test.ts) and the
|
|
136
|
+
* host's kill limit do not move. A first request that fails fast (a connection
|
|
137
|
+
* error or a gateway status) starts the second at once instead of at the hedge
|
|
138
|
+
* point.
|
|
125
139
|
*
|
|
126
|
-
* What
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
140
|
+
* What counts as an answer: any response other than a gateway status (408, 502,
|
|
141
|
+
* 503, 504, Cloudflare's 52x) and other than the server's "the first request is
|
|
142
|
+
* still working" reply (HOOK_REPLAY_HEADER: pending). A 4xx or a 500 is an
|
|
143
|
+
* answer (null to the caller): repeating it would repeat the failure.
|
|
130
144
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
145
|
+
* The server de-duplicates the two requests by HOOK_REQUEST_ID_HEADER
|
|
146
|
+
* (src/server/hookReplay.ts): the second request is handed the first one's
|
|
147
|
+
* answer, or waits for it, and nothing is counted twice. Nothing is queued or
|
|
148
|
+
* stored on the client; a call with no answer by the deadline is dropped.
|
|
134
149
|
*/
|
|
135
|
-
export const
|
|
150
|
+
export const HOOK_HEDGE_SHARE = 0.65;
|
|
136
151
|
|
|
137
|
-
/** Below this much budget left, a second
|
|
152
|
+
/** Below this much budget left, a second request cannot complete a round trip. */
|
|
138
153
|
export const HOOK_SECOND_ATTEMPT_MIN_MS = 300;
|
|
139
154
|
|
|
140
|
-
/** Sent on every
|
|
155
|
+
/** Sent on every request: 1 for the first, 2 for the hedge. */
|
|
141
156
|
export const HOOK_ATTEMPT_HEADER = 'x-mnemonik-hook-attempt';
|
|
142
157
|
|
|
143
158
|
/**
|
|
144
|
-
* Sent on both
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
159
|
+
* Sent on both requests with the same value. The server keeps the first
|
|
160
|
+
* request's answer for a few seconds under this id and hands it to the second,
|
|
161
|
+
* so an answer decided by one request is delivered through either, and nothing
|
|
162
|
+
* is counted twice (src/server/hookReplay.ts).
|
|
148
163
|
*/
|
|
149
164
|
export const HOOK_REQUEST_ID_HEADER = 'x-mnemonik-hook-request-id';
|
|
150
165
|
|
|
151
166
|
/**
|
|
152
|
-
* Sent on every
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* has already given up.
|
|
167
|
+
* Sent on every request: how many milliseconds the caller will still wait. The
|
|
168
|
+
* server fits its own work inside this less the return trip, so it never
|
|
169
|
+
* decides an answer the client will not be there to receive.
|
|
156
170
|
*/
|
|
157
171
|
export const HOOK_BUDGET_HEADER = 'x-mnemonik-hook-budget-ms';
|
|
158
172
|
|
|
@@ -171,11 +185,75 @@ export function withHookVersion<T extends { headers?: unknown }>(
|
|
|
171
185
|
runtime: string,
|
|
172
186
|
version: string
|
|
173
187
|
): T {
|
|
174
|
-
const
|
|
188
|
+
const raw = init.headers;
|
|
189
|
+
if (raw && typeof raw === 'object' && !(raw instanceof Headers) && !Array.isArray(raw)) {
|
|
190
|
+
// A plain record is copied with its property descriptors, not its values:
|
|
191
|
+
// the hedged hook client's budget header is a getter read when the
|
|
192
|
+
// request is sent, after the credential is read (hedgedHookRequest).
|
|
193
|
+
const headers = Object.defineProperties({}, Object.getOwnPropertyDescriptors(raw)) as Record<
|
|
194
|
+
string,
|
|
195
|
+
string
|
|
196
|
+
>;
|
|
197
|
+
for (const name of Object.keys(headers)) {
|
|
198
|
+
if (name.toLowerCase() === HOOK_VERSION_HEADER) delete headers[name];
|
|
199
|
+
}
|
|
200
|
+
headers[HOOK_VERSION_HEADER] = `${runtime}/${version}`;
|
|
201
|
+
return { ...init, headers };
|
|
202
|
+
}
|
|
203
|
+
const headers = new Headers(raw as ConstructorParameters<typeof Headers>[0]);
|
|
175
204
|
headers.set(HOOK_VERSION_HEADER, `${runtime}/${version}`);
|
|
176
205
|
return { ...init, headers: Object.fromEntries(headers.entries()) };
|
|
177
206
|
}
|
|
178
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Sent on every request by a hedging client (hedgedHookRequest). Its budget
|
|
210
|
+
* header is what the whole call still has; clients v7.149 to v7.151 sent the
|
|
211
|
+
* first attempt's own 1.3 s cut-off instead, so a server that sees no marker
|
|
212
|
+
* assumes the whole-call 2 s budget rather than trusting that header.
|
|
213
|
+
*/
|
|
214
|
+
export const HOOK_HEDGED_HEADER = 'x-mnemonik-hook-hedged';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Sent on the second request only: what had become of the first when the second
|
|
218
|
+
* left. `pending; sent=812` (still waiting, its body finished sending 812 ms
|
|
219
|
+
* earlier), `pending; sent=none` (it had not finished sending, so the client
|
|
220
|
+
* itself was slow), `gateway; sent=<ms>` (the edge answered 502/503/504/52x) or
|
|
221
|
+
* `transport; sent=<ms>` (the connection failed). The server uses it to count
|
|
222
|
+
* requests the network lost on the way in, which is what the edge alarm reads
|
|
223
|
+
* (src/server/hookReplay.ts, hookEdgeMetrics.ts).
|
|
224
|
+
*/
|
|
225
|
+
export const HOOK_FIRST_ATTEMPT_HEADER = 'x-mnemonik-hook-first-attempt';
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Set by the server on a reply that is not an answer: the request found the
|
|
229
|
+
* other request of the same call still working on it and could not wait any
|
|
230
|
+
* longer. The client keeps waiting for the other request.
|
|
231
|
+
*/
|
|
232
|
+
export const HOOK_REPLAY_HEADER = 'x-mnemonik-hook-replay';
|
|
233
|
+
export const HOOK_REPLAY_PENDING = 'pending';
|
|
234
|
+
|
|
235
|
+
export type HookFirstAttemptState = 'pending' | 'gateway' | 'transport';
|
|
236
|
+
|
|
237
|
+
export interface HookFirstAttempt {
|
|
238
|
+
state: HookFirstAttemptState;
|
|
239
|
+
/** Milliseconds since the first request finished sending its body; null when it had not. */
|
|
240
|
+
sentAgoMs: number | null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function formatHookFirstAttempt(first: HookFirstAttempt): string {
|
|
244
|
+
return `${first.state}; sent=${first.sentAgoMs === null ? 'none' : Math.max(0, Math.round(first.sentAgoMs))}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function parseHookFirstAttempt(value: unknown): HookFirstAttempt | null {
|
|
248
|
+
if (typeof value !== 'string') return null;
|
|
249
|
+
const match = /^(pending|gateway|transport); sent=(none|\d{1,6})$/.exec(value.trim());
|
|
250
|
+
if (!match) return null;
|
|
251
|
+
return {
|
|
252
|
+
state: match[1] as HookFirstAttemptState,
|
|
253
|
+
sentAgoMs: match[2] === 'none' ? null : Number(match[2]),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
179
257
|
/** Statuses that mean the request did not reach, or was not served by, the application. */
|
|
180
258
|
export function isRetryableHookStatus(status: number): boolean {
|
|
181
259
|
return (
|
|
@@ -200,44 +278,157 @@ export function isTransientHookFetchError(error: unknown): boolean {
|
|
|
200
278
|
);
|
|
201
279
|
}
|
|
202
280
|
|
|
203
|
-
|
|
281
|
+
/**
|
|
282
|
+
* When the first request finished sending its body, read from Node's fetch
|
|
283
|
+
* (undici) diagnostics channels. Matched by the request id header, so other
|
|
284
|
+
* requests the process makes at the same time are ignored. Reports null when
|
|
285
|
+
* the channels are unavailable or the body never finished sending; nothing
|
|
286
|
+
* depends on it except the HOOK_FIRST_ATTEMPT_HEADER the server counts.
|
|
287
|
+
*/
|
|
288
|
+
function watchFirstRequestSent(
|
|
289
|
+
requestId: string,
|
|
290
|
+
clock: () => number
|
|
291
|
+
): { sentAt(): number | null; stop(): void } {
|
|
292
|
+
let sentAt: number | null = null;
|
|
293
|
+
let request: object | null = null;
|
|
294
|
+
const onCreate = (message: unknown): void => {
|
|
295
|
+
const candidate = (message as { request?: { headers?: unknown } } | null)?.request;
|
|
296
|
+
if (request || !candidate) return;
|
|
297
|
+
if (String(candidate.headers ?? '').includes(requestId)) request = candidate;
|
|
298
|
+
};
|
|
299
|
+
const onBodySent = (message: unknown): void => {
|
|
300
|
+
if (sentAt === null && request && (message as { request?: object })?.request === request) {
|
|
301
|
+
sentAt = clock();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
diagnosticsChannel.subscribe('undici:request:create', onCreate);
|
|
306
|
+
diagnosticsChannel.subscribe('undici:request:bodySent', onBodySent);
|
|
307
|
+
} catch {
|
|
308
|
+
return { sentAt: () => null, stop: () => undefined };
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
sentAt: () => sentAt,
|
|
312
|
+
stop: () => {
|
|
313
|
+
try {
|
|
314
|
+
diagnosticsChannel.unsubscribe('undici:request:create', onCreate);
|
|
315
|
+
diagnosticsChannel.unsubscribe('undici:request:bodySent', onBodySent);
|
|
316
|
+
} catch {
|
|
317
|
+
// Nothing to undo.
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
type HookAttemptOutcome<T> =
|
|
324
|
+
{ final: true; value: T | null } | { final: false; kind: 'gateway' | 'transport' | 'pending' };
|
|
204
325
|
|
|
205
326
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
327
|
+
* Send one hook call as up to two hedged requests inside `budgetMs` (see
|
|
328
|
+
* HOOK_HEDGE_SHARE). `send` performs one request with the headers and signal it
|
|
329
|
+
* is given; `parse` reads an answer. Returns the first answer, null when no
|
|
330
|
+
* request answered in time or the answer was an error status, and rejects only
|
|
331
|
+
* when `send` or `parse` fails in a way that is not a transport failure.
|
|
211
332
|
*/
|
|
212
|
-
export
|
|
333
|
+
export function hedgedHookRequest<T>(
|
|
213
334
|
budgetMs: number,
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
attemptNumber: 1 | 2,
|
|
217
|
-
requestId: string,
|
|
218
|
-
budgetMs: number
|
|
219
|
-
) => Promise<HookAttemptResult<T>>,
|
|
335
|
+
send: (headers: Record<string, string>, signal: AbortSignal) => Promise<Response>,
|
|
336
|
+
parse: (response: Response) => Promise<T>,
|
|
220
337
|
clock: () => number = Date.now
|
|
221
338
|
): Promise<T | null> {
|
|
222
339
|
const startedAt = clock();
|
|
340
|
+
const deadline = startedAt + budgetMs;
|
|
223
341
|
const requestId = globalThis.crypto.randomUUID();
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
342
|
+
const sent = watchFirstRequestSent(requestId, clock);
|
|
343
|
+
const controllers: AbortController[] = [];
|
|
344
|
+
|
|
345
|
+
return new Promise<T | null>((resolve, reject) => {
|
|
346
|
+
let settled = false;
|
|
347
|
+
let running = 0;
|
|
348
|
+
let secondStarted = false;
|
|
349
|
+
let firstFailure: 'gateway' | 'transport' | null = null;
|
|
350
|
+
let hedgeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
351
|
+
|
|
352
|
+
const finish = (value: T | null, error?: unknown, winner?: AbortController): void => {
|
|
353
|
+
if (settled) return;
|
|
354
|
+
settled = true;
|
|
355
|
+
clearTimeout(deadlineTimer);
|
|
356
|
+
clearTimeout(hedgeTimer);
|
|
357
|
+
sent.stop();
|
|
358
|
+
// Cancel whatever is still in flight; the request that answered is done.
|
|
359
|
+
for (const controller of controllers) if (controller !== winner) controller.abort();
|
|
360
|
+
if (error !== undefined) reject(error);
|
|
361
|
+
else resolve(value);
|
|
362
|
+
};
|
|
363
|
+
const deadlineTimer = setTimeout(() => finish(null), budgetMs);
|
|
364
|
+
|
|
365
|
+
const startSecond = (): boolean => {
|
|
366
|
+
if (settled || secondStarted) return secondStarted;
|
|
367
|
+
if (deadline - clock() < HOOK_SECOND_ATTEMPT_MIN_MS) return false;
|
|
368
|
+
secondStarted = true;
|
|
369
|
+
clearTimeout(hedgeTimer);
|
|
370
|
+
launch(2);
|
|
371
|
+
return true;
|
|
372
|
+
};
|
|
235
373
|
|
|
236
|
-
|
|
237
|
-
|
|
374
|
+
const launch = (attemptNumber: 1 | 2): void => {
|
|
375
|
+
const controller = new AbortController();
|
|
376
|
+
controllers.push(controller);
|
|
377
|
+
const now = clock();
|
|
378
|
+
const headers: Record<string, string> = {
|
|
379
|
+
[HOOK_ATTEMPT_HEADER]: String(attemptNumber),
|
|
380
|
+
[HOOK_REQUEST_ID_HEADER]: requestId,
|
|
381
|
+
[HOOK_HEDGED_HEADER]: '1',
|
|
382
|
+
};
|
|
383
|
+
// Read when the request is actually built (after the credential read or
|
|
384
|
+
// rotation and the conversation lock), not when the attempt starts, so
|
|
385
|
+
// the server's deadline does not include time already spent here.
|
|
386
|
+
Object.defineProperty(headers, HOOK_BUDGET_HEADER, {
|
|
387
|
+
enumerable: true,
|
|
388
|
+
get: () => String(Math.max(0, Math.floor(deadline - clock()))),
|
|
389
|
+
});
|
|
390
|
+
if (attemptNumber === 2) {
|
|
391
|
+
const sentAt = sent.sentAt();
|
|
392
|
+
headers[HOOK_FIRST_ATTEMPT_HEADER] = formatHookFirstAttempt({
|
|
393
|
+
state: firstFailure ?? 'pending',
|
|
394
|
+
sentAgoMs: sentAt === null ? null : now - sentAt,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
running += 1;
|
|
398
|
+
void (async () => {
|
|
399
|
+
let outcome: HookAttemptOutcome<T>;
|
|
400
|
+
try {
|
|
401
|
+
const response = await send(headers, controller.signal);
|
|
402
|
+
if (isRetryableHookStatus(response.status)) outcome = { final: false, kind: 'gateway' };
|
|
403
|
+
else if (response.headers.get(HOOK_REPLAY_HEADER) === HOOK_REPLAY_PENDING)
|
|
404
|
+
outcome = { final: false, kind: 'pending' };
|
|
405
|
+
else if (!response.ok) outcome = { final: true, value: null };
|
|
406
|
+
else outcome = { final: true, value: await parse(response) };
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (!settled && !isTransientHookFetchError(error)) {
|
|
409
|
+
running -= 1;
|
|
410
|
+
finish(null, error);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
outcome = { final: false, kind: 'transport' };
|
|
414
|
+
}
|
|
415
|
+
running -= 1;
|
|
416
|
+
if (settled) return;
|
|
417
|
+
if (outcome.final) {
|
|
418
|
+
finish(outcome.value, undefined, controller);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Not an answer. The first request's failure starts the second at once;
|
|
422
|
+
// with no request left running there is no answer to wait for.
|
|
423
|
+
if (attemptNumber === 1) {
|
|
424
|
+
if (outcome.kind !== 'pending') firstFailure = outcome.kind;
|
|
425
|
+
startSecond();
|
|
426
|
+
}
|
|
427
|
+
if (running === 0) finish(null);
|
|
428
|
+
})();
|
|
429
|
+
};
|
|
238
430
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return second.done ? second.value : null;
|
|
431
|
+
launch(1);
|
|
432
|
+
hedgeTimer = setTimeout(startSecond, Math.floor(budgetMs * HOOK_HEDGE_SHARE));
|
|
433
|
+
});
|
|
243
434
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { after, test } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
GATE_OUTBOX_CAP,
|
|
9
|
+
GATE_OUTBOX_MAX_AGE_MS,
|
|
10
|
+
GATE_OUTBOX_MAX_EVENTS,
|
|
11
|
+
GATE_OUTBOX_STRAY_MS,
|
|
12
|
+
queueGateEvent,
|
|
13
|
+
returnGateEvents,
|
|
14
|
+
takeGateEvents,
|
|
15
|
+
} from '../src/gateOutbox.js';
|
|
16
|
+
|
|
17
|
+
// Gate telemetry rides the session's next injections request instead of an
|
|
18
|
+
// awaited POST of its own (about 30% of a PreToolUse hook's process time on
|
|
19
|
+
// 26 October 2026). These pin the queue: claimed once, bounded, re-sendable,
|
|
20
|
+
// never throws.
|
|
21
|
+
|
|
22
|
+
const roots: string[] = [];
|
|
23
|
+
after(async () => {
|
|
24
|
+
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
|
|
25
|
+
});
|
|
26
|
+
async function outbox(): Promise<string> {
|
|
27
|
+
const root = await mkdtemp(join(tmpdir(), 'mnemonik-gate-outbox-'));
|
|
28
|
+
roots.push(root);
|
|
29
|
+
return join(root, 'session', 'gate-outbox');
|
|
30
|
+
}
|
|
31
|
+
const event = (outcome: string, at = Date.now()) => ({
|
|
32
|
+
tool: 'Bash',
|
|
33
|
+
mode: 'advisory',
|
|
34
|
+
outcome,
|
|
35
|
+
at,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('queued events are taken once, oldest first, each with its own id', async () => {
|
|
39
|
+
const folder = await outbox();
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
assert.equal(queueGateEvent(folder, event('b', now)), true);
|
|
42
|
+
queueGateEvent(folder, event('a', now - 10));
|
|
43
|
+
const taken = takeGateEvents(folder);
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
taken.map((e) => e.outcome),
|
|
46
|
+
['a', 'b']
|
|
47
|
+
);
|
|
48
|
+
assert.equal(new Set(taken.map((e) => e.id)).size, 2);
|
|
49
|
+
// A second request of the same session finds nothing to send twice.
|
|
50
|
+
assert.deepEqual(takeGateEvents(folder), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('events a request could not deliver go back, with the same ids', async () => {
|
|
54
|
+
const folder = await outbox();
|
|
55
|
+
queueGateEvent(folder, event('x'));
|
|
56
|
+
const taken = takeGateEvents(folder);
|
|
57
|
+
returnGateEvents(folder, taken);
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
takeGateEvents(folder).map((e) => e.id),
|
|
60
|
+
taken.map((e) => e.id)
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an empty or missing outbox yields nothing', async () => {
|
|
65
|
+
assert.deepEqual(takeGateEvents(await outbox()), []);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('stale and malformed events are dropped; a request carries at most the cap', async () => {
|
|
69
|
+
const folder = await outbox();
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
queueGateEvent(folder, event('stale', now - GATE_OUTBOX_MAX_AGE_MS - 1));
|
|
72
|
+
for (let i = 0; i < GATE_OUTBOX_MAX_EVENTS + 5; i += 1)
|
|
73
|
+
queueGateEvent(folder, event(`e${i}`, now));
|
|
74
|
+
await writeFile(join(folder, '999999999999999-torn.json'), '{"tool":"Bash"');
|
|
75
|
+
const first = takeGateEvents(folder, now);
|
|
76
|
+
assert.equal(first.length, GATE_OUTBOX_MAX_EVENTS);
|
|
77
|
+
assert.ok(first.every((e) => e.outcome !== 'stale'));
|
|
78
|
+
const rest = takeGateEvents(folder, now);
|
|
79
|
+
assert.equal(first.length + rest.length, GATE_OUTBOX_MAX_EVENTS + 5);
|
|
80
|
+
assert.ok(GATE_OUTBOX_MAX_AGE_MS >= 24 * 60 * 60 * 1000);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a queue that cannot be written reports false instead of throwing', async () => {
|
|
84
|
+
// A regular file where the session folder should be: no folder can be made.
|
|
85
|
+
const folder = await outbox();
|
|
86
|
+
queueGateEvent(folder, event('x'));
|
|
87
|
+
const file = join(folder, 'not-a-folder');
|
|
88
|
+
await writeFile(file, 'x');
|
|
89
|
+
assert.equal(queueGateEvent(join(file, 'gate-outbox'), event('y')), false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the outbox holds at most the cap, dropping the oldest', async () => {
|
|
93
|
+
// A server that stays unreachable: every request returns its events.
|
|
94
|
+
const folder = await outbox();
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
for (let i = 0; i < GATE_OUTBOX_CAP + 20; i += 1) queueGateEvent(folder, event(`e${i}`, now + i));
|
|
97
|
+
const queued = readdirSync(folder).filter((name) => name.endsWith('.json'));
|
|
98
|
+
assert.equal(queued.length, GATE_OUTBOX_CAP);
|
|
99
|
+
const taken = takeGateEvents(folder, now + GATE_OUTBOX_CAP + 20);
|
|
100
|
+
assert.equal(taken[0]?.outcome, 'e20');
|
|
101
|
+
returnGateEvents(folder, taken);
|
|
102
|
+
queueGateEvent(folder, event('newest', now + GATE_OUTBOX_CAP + 21));
|
|
103
|
+
assert.equal(
|
|
104
|
+
readdirSync(folder).filter((name) => name.endsWith('.json')).length,
|
|
105
|
+
GATE_OUTBOX_CAP
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('temporary and claimed files a killed hook left are swept after ten minutes', async () => {
|
|
110
|
+
const folder = await outbox();
|
|
111
|
+
queueGateEvent(folder, event('x'));
|
|
112
|
+
const strayTmp = join(folder, '.000000000000001-a.json.tmp');
|
|
113
|
+
const strayClaim = join(folder, '.000000000000002-b.json.123.claimed');
|
|
114
|
+
const freshTmp = join(folder, '.000000000000003-c.json.tmp');
|
|
115
|
+
for (const path of [strayTmp, strayClaim, freshTmp]) await writeFile(path, '{}');
|
|
116
|
+
const old = new Date(Date.now() - GATE_OUTBOX_STRAY_MS - 60_000);
|
|
117
|
+
await utimes(strayTmp, old, old);
|
|
118
|
+
await utimes(strayClaim, old, old);
|
|
119
|
+
assert.equal(takeGateEvents(folder).length, 1);
|
|
120
|
+
assert.deepEqual(readdirSync(folder).sort(), ['.000000000000003-c.json.tmp']);
|
|
121
|
+
});
|