@codemem/opencode-plugin 0.45.0 → 0.46.0-alpha.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/.opencode/lib/compat.js +4 -1
- package/.opencode/lib/opencode-v2-adapter.js +1 -1
- package/.opencode/lib/raw-event-delivery.js +451 -0
- package/.opencode/lib/raw-event-spool.js +42 -7
- package/.opencode/lib/recall-lifecycle.js +324 -0
- package/.opencode/lib/runtime.js +115 -699
- package/package.json +1 -1
package/.opencode/lib/compat.js
CHANGED
|
@@ -190,9 +190,12 @@ export const resolveAutoUpdatePlan = ({
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
if (normalizedRunner === "uvx") {
|
|
193
|
+
let reason = "custom-source";
|
|
194
|
+
if (!source) reason = "missing-source";
|
|
195
|
+
else if (isPinnedGitSource(source)) reason = "pinned-source";
|
|
193
196
|
return {
|
|
194
197
|
allowed: false,
|
|
195
|
-
reason
|
|
198
|
+
reason,
|
|
196
199
|
command: null,
|
|
197
200
|
commandText: null,
|
|
198
201
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
V2_ADAPTER_DIAGNOSTICS,
|
|
3
2
|
createRuntimeHost,
|
|
4
3
|
createRuntimeLocation,
|
|
4
|
+
V2_ADAPTER_DIAGNOSTICS,
|
|
5
5
|
} from "./host-contract.js";
|
|
6
6
|
import { createCodememRuntime } from "./runtime.js";
|
|
7
7
|
import { registerV2NotificationBridge } from "./v2-notification-bridge.js";
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_DRAIN_LIMIT,
|
|
4
|
+
DEFAULT_MAX_ENTRIES,
|
|
5
|
+
loadRawEventSpoolEntries,
|
|
6
|
+
RAW_EVENT_SPOOL_FULL_CODE,
|
|
7
|
+
removeRawEventSpoolEntry,
|
|
8
|
+
resolveSpoolDirectory,
|
|
9
|
+
writeRawEventSpoolEntry,
|
|
10
|
+
} from "./raw-event-spool.js";
|
|
11
|
+
|
|
12
|
+
const rawEventSpoolDrainsInFlight = new Map();
|
|
13
|
+
|
|
14
|
+
export const classifyRawEventTransportCause = (error) => {
|
|
15
|
+
const diagnostic = [
|
|
16
|
+
error?.name,
|
|
17
|
+
error?.code,
|
|
18
|
+
error?.cause?.code,
|
|
19
|
+
error?.message,
|
|
20
|
+
].filter(Boolean).join(" ");
|
|
21
|
+
return /AbortError|TimeoutError|timeout|timed out|ETIMEDOUT/i.test(diagnostic)
|
|
22
|
+
? "timeout"
|
|
23
|
+
: "connection";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const describeRawEventViewerFailure = (failure) => {
|
|
27
|
+
if (!failure) return "viewer delivery failed";
|
|
28
|
+
if (failure.stage === "backoff") {
|
|
29
|
+
return `viewer transport remained in backoff after ${failure.triggerStage || "request"} ${failure.cause}`;
|
|
30
|
+
}
|
|
31
|
+
if (failure.cause === "http_status") {
|
|
32
|
+
return `viewer ${failure.stage} returned ${failure.status}`;
|
|
33
|
+
}
|
|
34
|
+
if (failure.cause === "ingest_unavailable") {
|
|
35
|
+
return "viewer status reported ingest unavailable";
|
|
36
|
+
}
|
|
37
|
+
return `viewer ${failure.stage} ${failure.cause}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const createDeliveryState = (options) => ({
|
|
41
|
+
abortController: new AbortController(),
|
|
42
|
+
captureContexts: new WeakMap(),
|
|
43
|
+
envelopes: new WeakMap(),
|
|
44
|
+
lastStatusAvailable: true,
|
|
45
|
+
lastStatusCheckAt: 0,
|
|
46
|
+
latestViewerFailure: null,
|
|
47
|
+
lastToastAtBySession: new Map(),
|
|
48
|
+
options,
|
|
49
|
+
spoolDirectory: resolveSpoolDirectory(options.spoolHome),
|
|
50
|
+
spoolCorruptionNoted: false,
|
|
51
|
+
spoolLoadFailureNoted: false,
|
|
52
|
+
spoolPersistenceFailureNoted: null,
|
|
53
|
+
startedAts: new WeakMap(),
|
|
54
|
+
streamUnavailableUntil: 0,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const shouldToast = (state, sessionID, category = "general") => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const key = `${sessionID || "unknown"}:${category}`;
|
|
60
|
+
const last = state.lastToastAtBySession.get(key) || 0;
|
|
61
|
+
if (now - last < 60000) return false;
|
|
62
|
+
state.lastToastAtBySession.set(key, now);
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const bestEffortHostLog = async (state, input) => {
|
|
67
|
+
try {
|
|
68
|
+
await state.options.hostLog(input);
|
|
69
|
+
} catch {
|
|
70
|
+
// Best-effort app logging only.
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const bestEffortNotify = async (state, input) => {
|
|
75
|
+
try {
|
|
76
|
+
await state.options.hostNotify(input);
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort toast only.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const warnSpoolPersistenceFailure = async (state, sessionID, reason) => {
|
|
83
|
+
const message = reason === "spool_full"
|
|
84
|
+
? "codemem raw-event retry spool is full; repair Codemem, then archive retained entries"
|
|
85
|
+
: "codemem could not save a raw event for retry; it remains queued in memory";
|
|
86
|
+
await bestEffortHostLog(state, {
|
|
87
|
+
service: "codemem",
|
|
88
|
+
level: "error",
|
|
89
|
+
message,
|
|
90
|
+
extra: { category: "persistence", reason },
|
|
91
|
+
});
|
|
92
|
+
if (state.spoolPersistenceFailureNoted === reason) return;
|
|
93
|
+
state.spoolPersistenceFailureNoted = reason;
|
|
94
|
+
if (!state.options.hostNotify || !shouldToast(state, sessionID, `persistence:${reason}`)) return;
|
|
95
|
+
await bestEffortNotify(state, { message: `codemem: ${message}`, variant: "error" });
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const persistForRetry = async (state, { body, serialized, payload, sessionID }) => {
|
|
99
|
+
try {
|
|
100
|
+
await writeRawEventSpoolEntry({
|
|
101
|
+
envelope: body,
|
|
102
|
+
serialized,
|
|
103
|
+
homeDir: state.options.spoolHome,
|
|
104
|
+
maxEntries: state.options.spoolMaxEntries,
|
|
105
|
+
});
|
|
106
|
+
state.spoolPersistenceFailureNoted = null;
|
|
107
|
+
if (payload && typeof payload === "object") payload._raw_spooled = true;
|
|
108
|
+
return true;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const reason = error?.code === RAW_EVENT_SPOOL_FULL_CODE ? "spool_full" : "write_failed";
|
|
111
|
+
await state.options.logLine(`raw_events.spool.${reason} category=persistence`);
|
|
112
|
+
await warnSpoolPersistenceFailure(state, sessionID, reason);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const removeFromSpool = async (state, { eventId, payload }) => {
|
|
118
|
+
try {
|
|
119
|
+
await removeRawEventSpoolEntry({ eventId, homeDir: state.options.spoolHome });
|
|
120
|
+
if (payload && typeof payload === "object") payload._raw_spooled = false;
|
|
121
|
+
} catch {
|
|
122
|
+
await state.options.logLine("raw_events.spool.cleanup_failed category=persistence");
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const deliveryKind = ({ delivered, durable }) => {
|
|
127
|
+
if (delivered) return "cli";
|
|
128
|
+
if (durable) return "spool";
|
|
129
|
+
return "memory";
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const deliveryOutcome = ({ delivered, durable }) => {
|
|
133
|
+
if (delivered) return "was queued via CLI";
|
|
134
|
+
if (!durable) return "was not saved for retry; still queued in memory";
|
|
135
|
+
return "was saved for retry";
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const deliveryExtra = ({ category, delivery, viewerFailure, cliDiagnostics }) => ({
|
|
139
|
+
category,
|
|
140
|
+
delivery,
|
|
141
|
+
viewer_stage: viewerFailure?.stage || "unknown",
|
|
142
|
+
viewer_cause: viewerFailure?.cause || "unknown",
|
|
143
|
+
...(Number.isInteger(viewerFailure?.status) ? { viewer_status: viewerFailure.status } : {}),
|
|
144
|
+
...(viewerFailure?.triggerStage ? { viewer_trigger_stage: viewerFailure.triggerStage } : {}),
|
|
145
|
+
viewer_elapsed_ms: Math.max(0, viewerFailure?.elapsedMs || 0),
|
|
146
|
+
cli_attempts: Math.max(0, cliDiagnostics?.attempts || 0),
|
|
147
|
+
cli_elapsed_ms: Math.max(0, cliDiagnostics?.elapsedMs || 0),
|
|
148
|
+
...(cliDiagnostics?.cause ? { cli_cause: cliDiagnostics.cause } : {}),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const viewerFailureLogFields = (failure) =>
|
|
152
|
+
`viewer_stage=${failure.stage} viewer_cause=${failure.cause}`
|
|
153
|
+
+ `${Number.isInteger(failure.status) ? ` viewer_status=${failure.status}` : ""}`
|
|
154
|
+
+ ` viewer_elapsed_ms=${failure.elapsedMs}`;
|
|
155
|
+
|
|
156
|
+
const backoffFailure = (latestFailure) => ({
|
|
157
|
+
...(latestFailure || { cause: "active" }),
|
|
158
|
+
stage: "backoff",
|
|
159
|
+
...(latestFailure?.stage ? { triggerStage: latestFailure.stage } : {}),
|
|
160
|
+
elapsedMs: 0,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const failureFromError = ({ error, stage, startedAt }) => ({
|
|
164
|
+
...(error?.rawEventFailure || {
|
|
165
|
+
stage,
|
|
166
|
+
cause: classifyRawEventTransportCause(error),
|
|
167
|
+
}),
|
|
168
|
+
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const notifyDelivery = async (state, input) => {
|
|
172
|
+
const { category, cliDiagnostics, delivered, sessionID, viewerFailure } = input;
|
|
173
|
+
const action = state.options.failureActions[category] || state.options.failureActions.connection;
|
|
174
|
+
const message = `codemem raw event ${deliveryOutcome(input)} after ${describeRawEventViewerFailure(viewerFailure)}; ${action}`;
|
|
175
|
+
const delivery = deliveryKind(input);
|
|
176
|
+
const durable = input.durable === true;
|
|
177
|
+
await bestEffortHostLog(state, {
|
|
178
|
+
service: "codemem",
|
|
179
|
+
level: delivered || durable ? "warn" : "error",
|
|
180
|
+
message,
|
|
181
|
+
extra: deliveryExtra({ category, delivery, viewerFailure, cliDiagnostics }),
|
|
182
|
+
});
|
|
183
|
+
if (durable) return;
|
|
184
|
+
if (!state.options.hostNotify || !shouldToast(state, sessionID, `${category}:${delivery}`)) return;
|
|
185
|
+
await bestEffortNotify(state, {
|
|
186
|
+
message: `codemem: ${message}`,
|
|
187
|
+
variant: delivered ? "warning" : "error",
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const notifySpoolLoadFailure = async (state) => {
|
|
192
|
+
await state.options.logLine("raw_events.spool.load_failed category=persistence");
|
|
193
|
+
const message = "codemem could not read saved raw events; spool entries were left untouched";
|
|
194
|
+
await bestEffortHostLog(state, {
|
|
195
|
+
service: "codemem",
|
|
196
|
+
level: "error",
|
|
197
|
+
message,
|
|
198
|
+
extra: { category: "persistence", delivery: "load" },
|
|
199
|
+
});
|
|
200
|
+
if (state.spoolLoadFailureNoted) return;
|
|
201
|
+
state.spoolLoadFailureNoted = true;
|
|
202
|
+
if (!state.options.hostNotify || !shouldToast(state, null, "persistence:load")) return;
|
|
203
|
+
await bestEffortNotify(state, { message: `codemem: ${message}`, variant: "error" });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const notifySpoolCorruption = async (state, count) => {
|
|
207
|
+
const message = `codemem raw-event retry spool contains ${count} corrupt retained ${count === 1 ? "entry" : "entries"}; repair or archive the retained entries`;
|
|
208
|
+
await bestEffortHostLog(state, {
|
|
209
|
+
service: "codemem",
|
|
210
|
+
level: "error",
|
|
211
|
+
message,
|
|
212
|
+
extra: { category: "persistence", delivery: "corrupt", count },
|
|
213
|
+
});
|
|
214
|
+
if (state.spoolCorruptionNoted) return;
|
|
215
|
+
state.spoolCorruptionNoted = true;
|
|
216
|
+
if (!state.options.hostNotify || !shouldToast(state, null, "persistence:corrupt")) return;
|
|
217
|
+
await bestEffortNotify(state, { message: `codemem: ${message}`, variant: "error" });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const loadSpool = async (state) => {
|
|
221
|
+
try {
|
|
222
|
+
const loaded = await loadRawEventSpoolEntries({
|
|
223
|
+
homeDir: state.options.spoolHome,
|
|
224
|
+
limit: state.options.spoolDrainLimit,
|
|
225
|
+
});
|
|
226
|
+
state.spoolLoadFailureNoted = false;
|
|
227
|
+
return loaded;
|
|
228
|
+
} catch {
|
|
229
|
+
await notifySpoolLoadFailure(state);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const armViewerBackoff = (state, error, timing) => {
|
|
235
|
+
const viewerFailure = failureFromError({ error, ...timing });
|
|
236
|
+
state.latestViewerFailure = viewerFailure;
|
|
237
|
+
state.streamUnavailableUntil = Date.now() + Math.max(1000, state.options.backoffMs);
|
|
238
|
+
return viewerFailure;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const drainLoadedEntries = async (state, loaded) => {
|
|
242
|
+
if (loaded.corruptCount > 0) {
|
|
243
|
+
await state.options.logLine(`raw_events.spool.corrupt_retained count=${loaded.corruptCount}`);
|
|
244
|
+
await notifySpoolCorruption(state, loaded.corruptCount);
|
|
245
|
+
} else {
|
|
246
|
+
state.spoolCorruptionNoted = false;
|
|
247
|
+
}
|
|
248
|
+
for (const entry of loaded.entries) {
|
|
249
|
+
const viewerStartedAt = Date.now();
|
|
250
|
+
try {
|
|
251
|
+
await postToViewer(state, entry.envelope);
|
|
252
|
+
await removeFromSpool(state, { eventId: entry.eventId });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
await state.options.logLine("raw_events.spool.drain_deferred category=fallback");
|
|
255
|
+
const status = error?.rawEventFailure?.status;
|
|
256
|
+
if (!Number.isInteger(status) || status >= 500 || status === 409) {
|
|
257
|
+
armViewerBackoff(state, error, { stage: "post", startedAt: viewerStartedAt });
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const runSpoolDrain = async (state) => {
|
|
265
|
+
const loaded = await loadSpool(state);
|
|
266
|
+
if (loaded) await drainLoadedEntries(state, loaded);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const drainSpool = (state) => {
|
|
270
|
+
if (!state.options.enabled) return Promise.resolve();
|
|
271
|
+
if (Date.now() < state.streamUnavailableUntil) return Promise.resolve();
|
|
272
|
+
const existingDrain = rawEventSpoolDrainsInFlight.get(state.spoolDirectory);
|
|
273
|
+
if (existingDrain) return existingDrain;
|
|
274
|
+
const drainPromise = runSpoolDrain(state);
|
|
275
|
+
const trackedDrain = drainPromise
|
|
276
|
+
.catch(async () => state.options.logLine("raw_events.spool.drain_failed category=persistence"))
|
|
277
|
+
.finally(() => {
|
|
278
|
+
if (rawEventSpoolDrainsInFlight.get(state.spoolDirectory) === trackedDrain) {
|
|
279
|
+
rawEventSpoolDrainsInFlight.delete(state.spoolDirectory);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
rawEventSpoolDrainsInFlight.set(state.spoolDirectory, trackedDrain);
|
|
283
|
+
return trackedDrain;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const cachedEnvelope = (state, sessionID, type, payload, now) => {
|
|
287
|
+
const cached = payload && typeof payload === "object" ? state.envelopes.get(payload) : null;
|
|
288
|
+
if (cached) return cached;
|
|
289
|
+
const options = state.options;
|
|
290
|
+
const envelope = options.buildEnvelope({
|
|
291
|
+
sessionID,
|
|
292
|
+
type,
|
|
293
|
+
payload,
|
|
294
|
+
cwd: options.cwd,
|
|
295
|
+
project: options.projectName,
|
|
296
|
+
startedAt: state.startedAts.has(payload) ? state.startedAts.get(payload) : options.sessionStartedAt(),
|
|
297
|
+
nowMs: now,
|
|
298
|
+
nowMono: typeof performance !== "undefined" && performance.now ? performance.now() : null,
|
|
299
|
+
nextEventId: options.nextEventId,
|
|
300
|
+
captureContext: state.captureContexts.get(payload),
|
|
301
|
+
});
|
|
302
|
+
const serialized = JSON.stringify(envelope);
|
|
303
|
+
const value = { body: JSON.parse(serialized), serialized };
|
|
304
|
+
if (payload && typeof payload === "object") state.envelopes.set(payload, value);
|
|
305
|
+
return value;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const deliverDuringBackoff = async (state, input) => {
|
|
309
|
+
const viewerFailure = backoffFailure(state.latestViewerFailure);
|
|
310
|
+
const durable = await persistForRetry(state, input);
|
|
311
|
+
if (durable && input.payload && typeof input.payload === "object") {
|
|
312
|
+
input.payload._raw_spooled = true;
|
|
313
|
+
}
|
|
314
|
+
await notifyDelivery(state, {
|
|
315
|
+
category: "connection",
|
|
316
|
+
cliDiagnostics: null,
|
|
317
|
+
delivered: false,
|
|
318
|
+
durable,
|
|
319
|
+
sessionID: input.sessionID,
|
|
320
|
+
viewerFailure,
|
|
321
|
+
});
|
|
322
|
+
return durable;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const ingestUnavailableError = () => {
|
|
326
|
+
const error = new Error("raw-events ingest unavailable");
|
|
327
|
+
error.rawEventFailure = { stage: "status", cause: "ingest_unavailable" };
|
|
328
|
+
return error;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const checkViewerAvailability = async (state, now) => {
|
|
332
|
+
if (now - state.lastStatusCheckAt < Math.max(1000, state.options.statusCheckMs)) {
|
|
333
|
+
if (!state.lastStatusAvailable) throw ingestUnavailableError();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const response = await state.options.fetchRawEventsStatus(state.options.rawEventsStatusUrl);
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
state.options.discardResponseBody(response);
|
|
339
|
+
const error = new Error(`raw-events status failed (${response.status})`);
|
|
340
|
+
error.rawEventFailure = {
|
|
341
|
+
stage: "status",
|
|
342
|
+
cause: "http_status",
|
|
343
|
+
status: response.status,
|
|
344
|
+
};
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
const body = await response.json();
|
|
348
|
+
state.lastStatusAvailable = body?.ingest?.available !== false;
|
|
349
|
+
state.lastStatusCheckAt = now;
|
|
350
|
+
if (!state.lastStatusAvailable) throw ingestUnavailableError();
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const postToViewer = async (state, body) => {
|
|
354
|
+
const options = state.options;
|
|
355
|
+
const response = await fetch(options.rawEventsUrl, {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "Content-Type": "application/json" },
|
|
358
|
+
signal: AbortSignal.any([
|
|
359
|
+
state.abortController.signal,
|
|
360
|
+
AbortSignal.timeout(options.rawEventsStatusTimeoutMs),
|
|
361
|
+
]),
|
|
362
|
+
body: JSON.stringify({ ...body, db_path: options.promptPackDbPath, identity_target: options.identityTarget }),
|
|
363
|
+
});
|
|
364
|
+
if (response.ok) return response;
|
|
365
|
+
let responseBody = null;
|
|
366
|
+
try {
|
|
367
|
+
responseBody = await response.json();
|
|
368
|
+
} catch {
|
|
369
|
+
// Generic connection guidance remains the safe fallback.
|
|
370
|
+
}
|
|
371
|
+
const error = new Error(`raw-events post failed (${response.status})`);
|
|
372
|
+
error.rawEventFailureCategory = options.classifyViewerFailure(responseBody);
|
|
373
|
+
error.rawEventFailure = {
|
|
374
|
+
stage: "post",
|
|
375
|
+
cause: "http_status",
|
|
376
|
+
status: response.status,
|
|
377
|
+
};
|
|
378
|
+
throw error;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const handleViewerFailure = async (state, input, error, timing) => {
|
|
382
|
+
if (!state.options.isActive()) return persistForRetry(state, input);
|
|
383
|
+
const category = error?.rawEventFailureCategory || "connection";
|
|
384
|
+
const viewerFailure = armViewerBackoff(state, error, timing);
|
|
385
|
+
await state.options.logLine(
|
|
386
|
+
`raw_events.error category=${category} ${viewerFailureLogFields(viewerFailure)}`
|
|
387
|
+
);
|
|
388
|
+
const durable = await persistForRetry(state, input);
|
|
389
|
+
if (durable && input.payload && typeof input.payload === "object") {
|
|
390
|
+
input.payload._raw_spooled = true;
|
|
391
|
+
}
|
|
392
|
+
await notifyDelivery(state, {
|
|
393
|
+
category,
|
|
394
|
+
cliDiagnostics: null,
|
|
395
|
+
delivered: false,
|
|
396
|
+
durable,
|
|
397
|
+
sessionID: input.sessionID,
|
|
398
|
+
viewerFailure,
|
|
399
|
+
});
|
|
400
|
+
return durable;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const deliver = async (state, { sessionID, type, payload }) => {
|
|
404
|
+
if (!state.options.enabled) return true;
|
|
405
|
+
if (!sessionID || !type) return false;
|
|
406
|
+
if (payload?._raw_enqueued) return true;
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
const envelope = cachedEnvelope(state, sessionID, type, payload, now);
|
|
409
|
+
const input = { ...envelope, payload, sessionID };
|
|
410
|
+
if (!state.options.isActive()) return persistForRetry(state, input);
|
|
411
|
+
if (now < state.streamUnavailableUntil) return deliverDuringBackoff(state, input);
|
|
412
|
+
const viewerStartedAt = Date.now();
|
|
413
|
+
let viewerStage = "status";
|
|
414
|
+
try {
|
|
415
|
+
await checkViewerAvailability(state, now);
|
|
416
|
+
viewerStage = "post";
|
|
417
|
+
await postToViewer(state, envelope.body);
|
|
418
|
+
state.streamUnavailableUntil = 0;
|
|
419
|
+
state.latestViewerFailure = null;
|
|
420
|
+
state.lastStatusAvailable = true;
|
|
421
|
+
await removeFromSpool(state, { eventId: envelope.body.event_id, payload });
|
|
422
|
+
if (payload && typeof payload === "object") payload._raw_enqueued = true;
|
|
423
|
+
return true;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return handleViewerFailure(state, input, error, {
|
|
426
|
+
stage: viewerStage,
|
|
427
|
+
startedAt: viewerStartedAt,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
export const createRawEventDelivery = (options) => {
|
|
433
|
+
const state = createDeliveryState({
|
|
434
|
+
...options,
|
|
435
|
+
spoolDrainLimit: options.spoolDrainLimit ?? DEFAULT_DRAIN_LIMIT,
|
|
436
|
+
spoolHome: options.spoolHome ?? homedir(),
|
|
437
|
+
spoolMaxEntries: options.spoolMaxEntries ?? DEFAULT_MAX_ENTRIES,
|
|
438
|
+
});
|
|
439
|
+
return {
|
|
440
|
+
abort: () => state.abortController.abort(),
|
|
441
|
+
clearSession: (sessionID) => {
|
|
442
|
+
for (const key of state.lastToastAtBySession.keys()) {
|
|
443
|
+
if (key.startsWith(`${sessionID}:`)) state.lastToastAtBySession.delete(key);
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
deliver: (input) => deliver(state, input),
|
|
447
|
+
drainSpool: () => drainSpool(state),
|
|
448
|
+
setCaptureContext: (payload, context) => state.captureContexts.set(payload, context),
|
|
449
|
+
setStartedAt: (payload, startedAt) => state.startedAts.set(payload, startedAt),
|
|
450
|
+
};
|
|
451
|
+
};
|
|
@@ -3,14 +3,14 @@ import {
|
|
|
3
3
|
chmod,
|
|
4
4
|
link,
|
|
5
5
|
mkdir,
|
|
6
|
-
|
|
6
|
+
open,
|
|
7
7
|
readdir,
|
|
8
|
+
readFile,
|
|
8
9
|
rm,
|
|
9
10
|
stat,
|
|
10
|
-
writeFile,
|
|
11
11
|
} from "node:fs/promises";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
14
|
|
|
15
15
|
const DEFAULT_DRAIN_LIMIT = 20;
|
|
16
16
|
const DEFAULT_MAX_ENTRIES = 2000;
|
|
@@ -37,8 +37,42 @@ const requireEventId = (envelope) => {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
const ensurePrivateDirectory = async (directory) => {
|
|
40
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
40
|
+
const firstCreated = await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
41
41
|
await chmod(directory, 0o700);
|
|
42
|
+
if (!firstCreated) return;
|
|
43
|
+
const boundary = dirname(firstCreated);
|
|
44
|
+
let current = directory;
|
|
45
|
+
while (true) {
|
|
46
|
+
await syncDirectory(current);
|
|
47
|
+
if (current === boundary) return;
|
|
48
|
+
current = dirname(current);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const writeDurableFile = async (path, contents) => {
|
|
53
|
+
const handle = await open(path, "wx", 0o600);
|
|
54
|
+
try {
|
|
55
|
+
await handle.writeFile(contents, { encoding: "utf8" });
|
|
56
|
+
await handle.sync();
|
|
57
|
+
} finally {
|
|
58
|
+
await handle.close();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const syncDirectory = async (path) => {
|
|
63
|
+
let handle;
|
|
64
|
+
try {
|
|
65
|
+
handle = await open(path, "r");
|
|
66
|
+
await handle.sync();
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (
|
|
69
|
+
process.platform === "win32"
|
|
70
|
+
&& ["EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)
|
|
71
|
+
) return;
|
|
72
|
+
throw error;
|
|
73
|
+
} finally {
|
|
74
|
+
await handle?.close();
|
|
75
|
+
}
|
|
42
76
|
};
|
|
43
77
|
|
|
44
78
|
const writeRawEventSpoolEntryUnlocked = async ({
|
|
@@ -66,6 +100,7 @@ const writeRawEventSpoolEntryUnlocked = async ({
|
|
|
66
100
|
throw new Error("raw event spool entry conflicts with existing event_id");
|
|
67
101
|
}
|
|
68
102
|
await chmod(destination, 0o600);
|
|
103
|
+
await syncDirectory(directory);
|
|
69
104
|
return { eventId, serialized: existing };
|
|
70
105
|
} catch (error) {
|
|
71
106
|
if (error?.code !== "ENOENT") {
|
|
@@ -84,7 +119,7 @@ const writeRawEventSpoolEntryUnlocked = async ({
|
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
try {
|
|
87
|
-
await
|
|
122
|
+
await writeDurableFile(temporary, bytes);
|
|
88
123
|
try {
|
|
89
124
|
await link(temporary, destination);
|
|
90
125
|
} catch (error) {
|
|
@@ -96,12 +131,12 @@ const writeRawEventSpoolEntryUnlocked = async ({
|
|
|
96
131
|
throw new Error("raw event spool entry conflicts with existing event_id");
|
|
97
132
|
}
|
|
98
133
|
await chmod(destination, 0o600);
|
|
99
|
-
return { eventId, serialized: existing };
|
|
100
134
|
}
|
|
101
135
|
await chmod(destination, 0o600);
|
|
102
136
|
} finally {
|
|
103
137
|
await rm(temporary, { force: true }).catch(() => {});
|
|
104
138
|
}
|
|
139
|
+
await syncDirectory(directory);
|
|
105
140
|
|
|
106
141
|
return { eventId, serialized: bytes };
|
|
107
142
|
};
|
|
@@ -189,8 +224,8 @@ const loadRawEventSpoolEntries = async ({
|
|
|
189
224
|
export {
|
|
190
225
|
DEFAULT_DRAIN_LIMIT,
|
|
191
226
|
DEFAULT_MAX_ENTRIES,
|
|
192
|
-
RAW_EVENT_SPOOL_FULL_CODE,
|
|
193
227
|
loadRawEventSpoolEntries,
|
|
228
|
+
RAW_EVENT_SPOOL_FULL_CODE,
|
|
194
229
|
removeRawEventSpoolEntry,
|
|
195
230
|
resolveSpoolDirectory,
|
|
196
231
|
writeRawEventSpoolEntry,
|