iteration-cli 0.9.0 → 0.10.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/README.md +4 -0
- package/dist/cli.js +18207 -429
- package/dist/cursor-hook-entry.js +11667 -139
- package/dist/launcher.js +6731 -0
- package/package.json +5 -3
- package/dist/auto-connect.d.ts +0 -32
- package/dist/auto-connect.js +0 -235
- package/dist/background-service.d.ts +0 -7
- package/dist/background-service.js +0 -37
- package/dist/claude-transcript.d.ts +0 -6
- package/dist/claude-transcript.js +0 -256
- package/dist/clerk-device-auth.d.ts +0 -7
- package/dist/clerk-device-auth.js +0 -279
- package/dist/cli.d.ts +0 -2
- package/dist/collector-fetch.d.ts +0 -9
- package/dist/collector-fetch.js +0 -57
- package/dist/collector.d.ts +0 -17
- package/dist/collector.js +0 -256
- package/dist/cursor-hook-entry.d.ts +0 -1
- package/dist/cursor-hooks.d.ts +0 -7
- package/dist/cursor-hooks.js +0 -163
- package/dist/cursor-transcript.d.ts +0 -8
- package/dist/cursor-transcript.js +0 -294
- package/dist/history-import.d.ts +0 -17
- package/dist/history-import.js +0 -63
- package/dist/launch-agent.d.ts +0 -13
- package/dist/launch-agent.js +0 -183
- package/dist/redact-trace.d.ts +0 -2
- package/dist/redact-trace.js +0 -182
- package/dist/rollout.d.ts +0 -11
- package/dist/rollout.js +0 -259
- package/dist/service.d.ts +0 -5
- package/dist/service.js +0 -249
- package/dist/status-view.d.ts +0 -33
- package/dist/status-view.js +0 -257
- package/dist/storage.d.ts +0 -94
- package/dist/storage.js +0 -809
- package/dist/systemd-user.d.ts +0 -7
- package/dist/systemd-user.js +0 -137
- package/dist/turn-summary.d.ts +0 -23
- package/dist/turn-summary.js +0 -365
package/dist/storage.js
DELETED
|
@@ -1,809 +0,0 @@
|
|
|
1
|
-
import { once } from "node:events";
|
|
2
|
-
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { createInterface } from "node:readline";
|
|
6
|
-
import { MAX_TRACE_EVENTS_PER_BATCH, parseTraceEventBatch, } from "iteration-trace-contracts";
|
|
7
|
-
import * as z from "zod";
|
|
8
|
-
import { redactTraceEvent } from "./redact-trace.js";
|
|
9
|
-
const MAX_OUTBOX_BYTES = 64 * 1024 * 1024;
|
|
10
|
-
export function collectorSource(config) {
|
|
11
|
-
return "cursorHome" in config
|
|
12
|
-
? "cursor"
|
|
13
|
-
: "claudeHome" in config
|
|
14
|
-
? "claude"
|
|
15
|
-
: "codex";
|
|
16
|
-
}
|
|
17
|
-
export function collectorSourceDirectory(config) {
|
|
18
|
-
return "cursorHome" in config
|
|
19
|
-
? config.cursorHome
|
|
20
|
-
: "claudeHome" in config
|
|
21
|
-
? config.claudeHome
|
|
22
|
-
: config.codexHome;
|
|
23
|
-
}
|
|
24
|
-
const connectionSchema = {
|
|
25
|
-
endpoint: z.string(),
|
|
26
|
-
token: z.string(),
|
|
27
|
-
dataDirectory: z.string(),
|
|
28
|
-
organizationId: z.uuid().optional(),
|
|
29
|
-
organizationName: z.string().min(1).max(160).optional(),
|
|
30
|
-
};
|
|
31
|
-
const configSchema = z.union([
|
|
32
|
-
z.object({ ...connectionSchema, codexHome: z.string() }).strict(),
|
|
33
|
-
z
|
|
34
|
-
.object({
|
|
35
|
-
...connectionSchema,
|
|
36
|
-
source: z.literal("claude"),
|
|
37
|
-
claudeHome: z.string(),
|
|
38
|
-
})
|
|
39
|
-
.strict(),
|
|
40
|
-
z
|
|
41
|
-
.object({
|
|
42
|
-
...connectionSchema,
|
|
43
|
-
source: z.literal("cursor"),
|
|
44
|
-
cursorHome: z.string(),
|
|
45
|
-
})
|
|
46
|
-
.strict(),
|
|
47
|
-
]);
|
|
48
|
-
const stateSchema = z
|
|
49
|
-
.object({
|
|
50
|
-
cursors: z.record(z.string(), z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)),
|
|
51
|
-
skipping: z.array(z.string()),
|
|
52
|
-
sourceDirectory: z.string().nullable(),
|
|
53
|
-
initialized: z.boolean(),
|
|
54
|
-
})
|
|
55
|
-
.strict();
|
|
56
|
-
const healthSchema = z
|
|
57
|
-
.object({
|
|
58
|
-
lastUploadAtMs: z.number().int().min(0).nullable(),
|
|
59
|
-
historyCaughtUpAtMs: z.number().int().min(0).nullable(),
|
|
60
|
-
lastFailure: z
|
|
61
|
-
.object({
|
|
62
|
-
atMs: z.number().int().min(0),
|
|
63
|
-
kind: z.enum(["http", "network", "response", "collector"]),
|
|
64
|
-
status: z.number().int().min(100).max(599).nullable(),
|
|
65
|
-
})
|
|
66
|
-
.strict()
|
|
67
|
-
.nullable(),
|
|
68
|
-
})
|
|
69
|
-
.strict();
|
|
70
|
-
const redactionMarkerSchema = z.object({ version: z.literal(1) }).strict();
|
|
71
|
-
function parseOutboxEvent(line) {
|
|
72
|
-
let decoded;
|
|
73
|
-
try {
|
|
74
|
-
decoded = JSON.parse(line);
|
|
75
|
-
}
|
|
76
|
-
catch (error) {
|
|
77
|
-
throw new Error("Trace outbox contains a malformed record.", {
|
|
78
|
-
cause: error,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
const parsed = parseTraceEventBatch({ events: [decoded] });
|
|
82
|
-
if (!parsed.success)
|
|
83
|
-
throw new Error(`Trace outbox event is invalid: ${parsed.error}`);
|
|
84
|
-
const [event] = parsed.value.events;
|
|
85
|
-
if (event === undefined)
|
|
86
|
-
throw new Error("Trace outbox event is missing.");
|
|
87
|
-
return event;
|
|
88
|
-
}
|
|
89
|
-
export function defaultCollectorDirectory(source) {
|
|
90
|
-
return path.join(os.homedir(), ".config", "iteration", `${source}-collector`);
|
|
91
|
-
}
|
|
92
|
-
export function validateCollectorEndpoint(endpoint) {
|
|
93
|
-
let parsed;
|
|
94
|
-
try {
|
|
95
|
-
parsed = new URL(endpoint);
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
throw new Error("Collector endpoint must be a valid URL.", {
|
|
99
|
-
cause: error,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
const localHost = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
|
103
|
-
if (parsed.username !== "" ||
|
|
104
|
-
parsed.password !== "" ||
|
|
105
|
-
(parsed.protocol !== "https:" &&
|
|
106
|
-
!(parsed.protocol === "http:" && localHost))) {
|
|
107
|
-
throw new Error("Collector endpoint must use HTTPS, except for localhost.");
|
|
108
|
-
}
|
|
109
|
-
if (parsed.pathname !== "/api/traces/ingest" ||
|
|
110
|
-
parsed.search !== "" ||
|
|
111
|
-
parsed.hash !== "") {
|
|
112
|
-
throw new Error("Collector endpoint must be the trace ingest route without query parameters.");
|
|
113
|
-
}
|
|
114
|
-
return parsed;
|
|
115
|
-
}
|
|
116
|
-
export function collectorPaths(directory = defaultCollectorDirectory("codex")) {
|
|
117
|
-
return {
|
|
118
|
-
config: path.join(directory, "config.json"),
|
|
119
|
-
state: path.join(directory, "state.json"),
|
|
120
|
-
outbox: path.join(directory, "outbox.jsonl"),
|
|
121
|
-
redactionMarker: path.join(directory, "redaction.json"),
|
|
122
|
-
lock: path.join(directory, "collector.lock"),
|
|
123
|
-
historyBackfill: path.join(directory, "history-backfill.json"),
|
|
124
|
-
health: path.join(directory, "health.json"),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
export async function assertFreshCollectorDirectory(paths, source = "codex") {
|
|
128
|
-
const state = await loadState(paths);
|
|
129
|
-
const configExists = await fs
|
|
130
|
-
.access(paths.config)
|
|
131
|
-
.then(() => true)
|
|
132
|
-
.catch((error) => {
|
|
133
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
134
|
-
return false;
|
|
135
|
-
throw error;
|
|
136
|
-
});
|
|
137
|
-
if (configExists) {
|
|
138
|
-
throw new Error(`This collector data directory is already configured. Run iteration status ${source} or iteration start ${source} to repair startup. Use a different --data-directory for another workspace, or iteration import-history ${source} for earlier sessions.`);
|
|
139
|
-
}
|
|
140
|
-
if (state.initialized || (await outboxSize(paths)) > 0) {
|
|
141
|
-
throw new Error("Collector state or pending events exist without a config. Use a different --data-directory; this data cannot be safely assigned to a new workspace.");
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
export async function ensurePrivateDirectory(directory) {
|
|
145
|
-
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
146
|
-
await fs.chmod(directory, 0o700);
|
|
147
|
-
}
|
|
148
|
-
async function writeJsonAtomic(filename, value) {
|
|
149
|
-
const temporary = `${filename}.${process.pid}.tmp`;
|
|
150
|
-
const handle = await fs.open(temporary, "w", 0o600);
|
|
151
|
-
try {
|
|
152
|
-
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
|
153
|
-
await handle.sync();
|
|
154
|
-
}
|
|
155
|
-
finally {
|
|
156
|
-
await handle.close();
|
|
157
|
-
}
|
|
158
|
-
await fs.rename(temporary, filename);
|
|
159
|
-
}
|
|
160
|
-
export async function saveConfig(config, paths) {
|
|
161
|
-
await ensurePrivateDirectory(path.dirname(paths.config));
|
|
162
|
-
await writeJsonAtomic(paths.config, config);
|
|
163
|
-
}
|
|
164
|
-
export async function loadHealth(paths) {
|
|
165
|
-
let contents;
|
|
166
|
-
try {
|
|
167
|
-
contents = await fs.readFile(paths.health, "utf8");
|
|
168
|
-
}
|
|
169
|
-
catch (error) {
|
|
170
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
171
|
-
return {
|
|
172
|
-
lastUploadAtMs: null,
|
|
173
|
-
historyCaughtUpAtMs: null,
|
|
174
|
-
lastFailure: null,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
throw new Error(`Could not read collector health at ${paths.health}.`, {
|
|
178
|
-
cause: error,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
let decoded;
|
|
182
|
-
try {
|
|
183
|
-
decoded = JSON.parse(contents);
|
|
184
|
-
}
|
|
185
|
-
catch (error) {
|
|
186
|
-
throw new Error("Collector health is not valid JSON.", { cause: error });
|
|
187
|
-
}
|
|
188
|
-
const parsed = healthSchema.safeParse(decoded);
|
|
189
|
-
if (!parsed.success) {
|
|
190
|
-
throw new Error("Collector health has an invalid shape.", {
|
|
191
|
-
cause: parsed.error,
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
return parsed.data;
|
|
195
|
-
}
|
|
196
|
-
export async function healthSnapshot(paths) {
|
|
197
|
-
try {
|
|
198
|
-
return { kind: "available", health: await loadHealth(paths) };
|
|
199
|
-
}
|
|
200
|
-
catch {
|
|
201
|
-
return {
|
|
202
|
-
kind: "unavailable",
|
|
203
|
-
health: {
|
|
204
|
-
lastUploadAtMs: null,
|
|
205
|
-
historyCaughtUpAtMs: null,
|
|
206
|
-
lastFailure: null,
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
export async function saveHealth(health, paths) {
|
|
212
|
-
await ensurePrivateDirectory(path.dirname(paths.health));
|
|
213
|
-
await writeJsonAtomic(paths.health, health);
|
|
214
|
-
}
|
|
215
|
-
export async function loadConfig(paths) {
|
|
216
|
-
let contents;
|
|
217
|
-
try {
|
|
218
|
-
contents = await fs.readFile(paths.config, "utf8");
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
throw new Error(`Could not read collector config at ${paths.config}.`, {
|
|
222
|
-
cause: error,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
let decoded;
|
|
226
|
-
try {
|
|
227
|
-
decoded = JSON.parse(contents);
|
|
228
|
-
}
|
|
229
|
-
catch (error) {
|
|
230
|
-
throw new Error("Collector config is not valid JSON.", { cause: error });
|
|
231
|
-
}
|
|
232
|
-
const parsed = configSchema.safeParse(decoded);
|
|
233
|
-
if (!parsed.success)
|
|
234
|
-
throw new Error("Collector config has an invalid shape.", {
|
|
235
|
-
cause: parsed.error,
|
|
236
|
-
});
|
|
237
|
-
validateCollectorEndpoint(parsed.data.endpoint);
|
|
238
|
-
const { token } = parsed.data;
|
|
239
|
-
if (token.length !== 43 || !/^[A-Za-z0-9_-]+$/.test(token)) {
|
|
240
|
-
throw new Error("Collector token has an invalid format.");
|
|
241
|
-
}
|
|
242
|
-
return parsed.data;
|
|
243
|
-
}
|
|
244
|
-
export async function loadState(paths) {
|
|
245
|
-
let contents;
|
|
246
|
-
try {
|
|
247
|
-
contents = await fs.readFile(paths.state, "utf8");
|
|
248
|
-
}
|
|
249
|
-
catch (error) {
|
|
250
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
251
|
-
return {
|
|
252
|
-
cursors: {},
|
|
253
|
-
skipping: [],
|
|
254
|
-
sourceDirectory: null,
|
|
255
|
-
initialized: false,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
throw new Error(`Could not read collector checkpoint at ${paths.state}.`, {
|
|
259
|
-
cause: error,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
let decoded;
|
|
263
|
-
try {
|
|
264
|
-
decoded = JSON.parse(contents);
|
|
265
|
-
}
|
|
266
|
-
catch (error) {
|
|
267
|
-
throw new Error("Collector checkpoint is not valid JSON.", {
|
|
268
|
-
cause: error,
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
const parsed = stateSchema.safeParse(decoded);
|
|
272
|
-
if (!parsed.success)
|
|
273
|
-
throw new Error("Collector checkpoint has an invalid shape.", {
|
|
274
|
-
cause: parsed.error,
|
|
275
|
-
});
|
|
276
|
-
return parsed.data;
|
|
277
|
-
}
|
|
278
|
-
export async function saveState(state, paths) {
|
|
279
|
-
await ensurePrivateDirectory(path.dirname(paths.state));
|
|
280
|
-
await writeJsonAtomic(paths.state, state);
|
|
281
|
-
}
|
|
282
|
-
export async function outboxSize(paths) {
|
|
283
|
-
try {
|
|
284
|
-
return (await fs.stat(paths.outbox)).size;
|
|
285
|
-
}
|
|
286
|
-
catch (error) {
|
|
287
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
288
|
-
return 0;
|
|
289
|
-
throw new Error("Could not inspect the local trace outbox.", {
|
|
290
|
-
cause: error,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
export async function pendingEventIds(paths) {
|
|
295
|
-
const ids = new Set();
|
|
296
|
-
const size = await outboxSize(paths);
|
|
297
|
-
if (size > MAX_OUTBOX_BYTES)
|
|
298
|
-
throw new Error("Trace outbox exceeds its configured capacity.");
|
|
299
|
-
try {
|
|
300
|
-
const input = await fs.open(paths.outbox, "r");
|
|
301
|
-
const stream = input.createReadStream({
|
|
302
|
-
autoClose: true,
|
|
303
|
-
encoding: "utf8",
|
|
304
|
-
});
|
|
305
|
-
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
306
|
-
for await (const line of lines) {
|
|
307
|
-
ids.add(parseOutboxEvent(line).eventId);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
catch (error) {
|
|
311
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
312
|
-
return ids;
|
|
313
|
-
throw error;
|
|
314
|
-
}
|
|
315
|
-
return ids;
|
|
316
|
-
}
|
|
317
|
-
export async function appendEvents(events, paths) {
|
|
318
|
-
if (events.length === 0)
|
|
319
|
-
return;
|
|
320
|
-
const contents = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
|
|
321
|
-
const newSize = Buffer.byteLength(contents, "utf8");
|
|
322
|
-
const currentSize = await outboxSize(paths);
|
|
323
|
-
if (currentSize + newSize > MAX_OUTBOX_BYTES) {
|
|
324
|
-
throw new Error("Trace outbox is full; upload pending data before scanning more sessions.");
|
|
325
|
-
}
|
|
326
|
-
await ensurePrivateDirectory(path.dirname(paths.outbox));
|
|
327
|
-
const handle = await fs.open(paths.outbox, "a", 0o600);
|
|
328
|
-
try {
|
|
329
|
-
await handle.writeFile(contents, "utf8");
|
|
330
|
-
await handle.sync();
|
|
331
|
-
}
|
|
332
|
-
finally {
|
|
333
|
-
await handle.close();
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
export async function redactPendingOutbox(paths) {
|
|
337
|
-
let marker;
|
|
338
|
-
try {
|
|
339
|
-
marker = await fs.readFile(paths.redactionMarker, "utf8");
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
343
|
-
marker = null;
|
|
344
|
-
else
|
|
345
|
-
throw error;
|
|
346
|
-
}
|
|
347
|
-
if (marker !== null) {
|
|
348
|
-
let decoded;
|
|
349
|
-
try {
|
|
350
|
-
decoded = JSON.parse(marker);
|
|
351
|
-
}
|
|
352
|
-
catch {
|
|
353
|
-
throw new Error("Collector redaction marker is not valid JSON.");
|
|
354
|
-
}
|
|
355
|
-
if (!redactionMarkerSchema.safeParse(decoded).success)
|
|
356
|
-
throw new Error("Collector redaction marker has an invalid shape.");
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
const size = await outboxSize(paths);
|
|
360
|
-
if (size > MAX_OUTBOX_BYTES)
|
|
361
|
-
throw new Error("Trace outbox exceeds its configured capacity.");
|
|
362
|
-
if (size > 0) {
|
|
363
|
-
const temporary = `${paths.outbox}.${process.pid}.redacting`;
|
|
364
|
-
const output = await fs.open(temporary, "w", 0o600);
|
|
365
|
-
try {
|
|
366
|
-
const lines = createInterface({
|
|
367
|
-
input: createReadStream(paths.outbox, { encoding: "utf8" }),
|
|
368
|
-
crlfDelay: Infinity,
|
|
369
|
-
});
|
|
370
|
-
for await (const line of lines) {
|
|
371
|
-
const event = await redactTraceEvent(parseOutboxEvent(line));
|
|
372
|
-
await output.writeFile(`${JSON.stringify(event)}\n`, "utf8");
|
|
373
|
-
}
|
|
374
|
-
await output.sync();
|
|
375
|
-
}
|
|
376
|
-
catch (error) {
|
|
377
|
-
await output.close();
|
|
378
|
-
await fs.rm(temporary, { force: true });
|
|
379
|
-
throw error;
|
|
380
|
-
}
|
|
381
|
-
await output.close();
|
|
382
|
-
try {
|
|
383
|
-
if ((await fs.stat(temporary)).size > MAX_OUTBOX_BYTES)
|
|
384
|
-
throw new Error("Redacted trace outbox exceeds its configured capacity.");
|
|
385
|
-
await fs.rename(temporary, paths.outbox);
|
|
386
|
-
const directory = await fs.open(path.dirname(paths.outbox), "r");
|
|
387
|
-
try {
|
|
388
|
-
await directory.sync();
|
|
389
|
-
}
|
|
390
|
-
finally {
|
|
391
|
-
await directory.close();
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
catch (error) {
|
|
395
|
-
await fs.rm(temporary, { force: true });
|
|
396
|
-
throw error;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
await ensurePrivateDirectory(path.dirname(paths.redactionMarker));
|
|
400
|
-
await writeJsonAtomic(paths.redactionMarker, { version: 1 });
|
|
401
|
-
}
|
|
402
|
-
export async function nextBatch(paths, maxBytes) {
|
|
403
|
-
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
404
|
-
throw new Error("Maximum upload batch size must be a positive integer.");
|
|
405
|
-
}
|
|
406
|
-
const size = await outboxSize(paths);
|
|
407
|
-
if (size === 0)
|
|
408
|
-
return [];
|
|
409
|
-
if (size > MAX_OUTBOX_BYTES)
|
|
410
|
-
throw new Error("Trace outbox exceeds its configured capacity.");
|
|
411
|
-
const input = await fs.open(paths.outbox, "r");
|
|
412
|
-
const stream = input.createReadStream({ autoClose: true, encoding: "utf8" });
|
|
413
|
-
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
414
|
-
const events = [];
|
|
415
|
-
let bytes = 0;
|
|
416
|
-
for await (const line of lines) {
|
|
417
|
-
const lineBytes = Buffer.byteLength(line, "utf8") + 1;
|
|
418
|
-
if (events.length >= MAX_TRACE_EVENTS_PER_BATCH ||
|
|
419
|
-
(events.length > 0 && bytes + lineBytes > maxBytes)) {
|
|
420
|
-
lines.close();
|
|
421
|
-
stream.destroy();
|
|
422
|
-
break;
|
|
423
|
-
}
|
|
424
|
-
if (lineBytes > maxBytes) {
|
|
425
|
-
lines.close();
|
|
426
|
-
stream.destroy();
|
|
427
|
-
throw new Error("A queued event exceeds the maximum upload batch size.");
|
|
428
|
-
}
|
|
429
|
-
events.push(parseOutboxEvent(line));
|
|
430
|
-
bytes += lineBytes;
|
|
431
|
-
}
|
|
432
|
-
return events;
|
|
433
|
-
}
|
|
434
|
-
export async function acknowledgeEvents(acknowledgedIds, paths) {
|
|
435
|
-
const temporary = `${paths.outbox}.${process.pid}.tmp`;
|
|
436
|
-
const input = await fs.open(paths.outbox, "r").catch((error) => {
|
|
437
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
438
|
-
return null;
|
|
439
|
-
throw error;
|
|
440
|
-
});
|
|
441
|
-
if (input === null)
|
|
442
|
-
return;
|
|
443
|
-
const output = createWriteStream(temporary, { flags: "w", mode: 0o600 });
|
|
444
|
-
try {
|
|
445
|
-
const lines = createInterface({
|
|
446
|
-
input: input.createReadStream({ autoClose: true, encoding: "utf8" }),
|
|
447
|
-
crlfDelay: Infinity,
|
|
448
|
-
});
|
|
449
|
-
for await (const line of lines) {
|
|
450
|
-
const event = parseOutboxEvent(line);
|
|
451
|
-
if (!acknowledgedIds.has(event.eventId)) {
|
|
452
|
-
if (!output.write(`${line}\n`))
|
|
453
|
-
await once(output, "drain");
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
output.end();
|
|
457
|
-
await once(output, "close");
|
|
458
|
-
const durable = await fs.open(temporary, "r+");
|
|
459
|
-
try {
|
|
460
|
-
await durable.sync();
|
|
461
|
-
}
|
|
462
|
-
finally {
|
|
463
|
-
await durable.close();
|
|
464
|
-
}
|
|
465
|
-
await fs.rename(temporary, paths.outbox);
|
|
466
|
-
}
|
|
467
|
-
catch (error) {
|
|
468
|
-
output.destroy();
|
|
469
|
-
await fs.rm(temporary, { force: true });
|
|
470
|
-
throw error;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
export async function initializeState(source, home, paths, includeHistory) {
|
|
474
|
-
const state = await loadState(paths);
|
|
475
|
-
const sourceDirectory = path.resolve(home);
|
|
476
|
-
if (state.initialized && state.sourceDirectory !== sourceDirectory) {
|
|
477
|
-
throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
|
|
478
|
-
}
|
|
479
|
-
if (state.initialized && state.sourceDirectory === sourceDirectory) {
|
|
480
|
-
if (includeHistory) {
|
|
481
|
-
throw new Error(`${source} is already connected. Run iteration import-history ${source} to import existing sessions.`);
|
|
482
|
-
}
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
await saveState(await prepareSourceInitialState(source, home, includeHistory), paths);
|
|
486
|
-
}
|
|
487
|
-
export async function prepareSourceInitialState(source, home, includeHistory) {
|
|
488
|
-
const sourceDirectory = path.resolve(home);
|
|
489
|
-
const files = await sourceFiles(source, sourceDirectory);
|
|
490
|
-
const cursors = {};
|
|
491
|
-
if (!includeHistory) {
|
|
492
|
-
for (const filename of files) {
|
|
493
|
-
cursors[filename] = await lastCompleteRecordOffset(filename, (await fs.stat(filename)).size);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
return {
|
|
497
|
-
cursors,
|
|
498
|
-
skipping: [],
|
|
499
|
-
sourceDirectory,
|
|
500
|
-
initialized: true,
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
export async function ensureStartableState(config, paths) {
|
|
504
|
-
const source = collectorSource(config);
|
|
505
|
-
const directory = collectorSourceDirectory(config);
|
|
506
|
-
const state = await loadState(paths);
|
|
507
|
-
if (state.initialized) {
|
|
508
|
-
if (state.sourceDirectory !== path.resolve(directory)) {
|
|
509
|
-
throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
|
|
510
|
-
}
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
if ((await outboxSize(paths)) > 0) {
|
|
514
|
-
throw new Error("Pending events exist without an initialized checkpoint; repair the collector state before starting uploads.");
|
|
515
|
-
}
|
|
516
|
-
await saveState(await prepareSourceInitialState(source, directory, true), paths);
|
|
517
|
-
return true;
|
|
518
|
-
}
|
|
519
|
-
const HISTORY_BACKFILL_VERSION = 4;
|
|
520
|
-
const historyBackfillMarkerSchema = z
|
|
521
|
-
.object({ version: z.number().int().min(1).max(HISTORY_BACKFILL_VERSION) })
|
|
522
|
-
.strict();
|
|
523
|
-
async function readHistoryBackfillVersion(paths) {
|
|
524
|
-
let marker;
|
|
525
|
-
try {
|
|
526
|
-
marker = await fs.readFile(paths.historyBackfill, "utf8");
|
|
527
|
-
}
|
|
528
|
-
catch (error) {
|
|
529
|
-
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
530
|
-
throw error;
|
|
531
|
-
return null;
|
|
532
|
-
}
|
|
533
|
-
let decoded;
|
|
534
|
-
try {
|
|
535
|
-
decoded = JSON.parse(marker);
|
|
536
|
-
}
|
|
537
|
-
catch (error) {
|
|
538
|
-
throw new Error("Collector history backfill marker is not valid JSON.", {
|
|
539
|
-
cause: error,
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
const parsed = historyBackfillMarkerSchema.safeParse(decoded);
|
|
543
|
-
if (!parsed.success) {
|
|
544
|
-
throw new Error("Collector history backfill marker has an invalid shape.", {
|
|
545
|
-
cause: parsed.error,
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
return parsed.data.version;
|
|
549
|
-
}
|
|
550
|
-
export async function ensureHistoryBackfill(codexHome, paths) {
|
|
551
|
-
const version = await readHistoryBackfillVersion(paths);
|
|
552
|
-
if (version !== null && version >= HISTORY_BACKFILL_VERSION)
|
|
553
|
-
return false;
|
|
554
|
-
const state = await loadState(paths);
|
|
555
|
-
if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
|
|
556
|
-
throw new Error("Collector state does not match CODEX_HOME; reconnect Codex before uploading.");
|
|
557
|
-
}
|
|
558
|
-
await rolloutFiles(path.join(codexHome, "sessions"));
|
|
559
|
-
await saveState({ ...state, cursors: {}, skipping: [] }, paths);
|
|
560
|
-
await writeJsonAtomic(paths.historyBackfill, {
|
|
561
|
-
version: HISTORY_BACKFILL_VERSION,
|
|
562
|
-
});
|
|
563
|
-
return true;
|
|
564
|
-
}
|
|
565
|
-
// Version 2 re-reads Claude history so stored sessions gain each response's
|
|
566
|
-
// final usage report and the PR links Claude records.
|
|
567
|
-
const CLAUDE_HISTORY_BACKFILL_VERSION = 2;
|
|
568
|
-
export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
|
|
569
|
-
const version = await readHistoryBackfillVersion(paths);
|
|
570
|
-
if (version !== null && version >= CLAUDE_HISTORY_BACKFILL_VERSION)
|
|
571
|
-
return false;
|
|
572
|
-
const state = await loadState(paths);
|
|
573
|
-
if (!state.initialized ||
|
|
574
|
-
state.sourceDirectory !== path.resolve(claudeHome)) {
|
|
575
|
-
throw new Error("Collector state does not match the Claude directory; reconnect Claude before uploading.");
|
|
576
|
-
}
|
|
577
|
-
await claudeFiles(path.join(claudeHome, "projects"));
|
|
578
|
-
await saveState({ ...state, cursors: {}, skipping: [] }, paths);
|
|
579
|
-
await writeJsonAtomic(paths.historyBackfill, {
|
|
580
|
-
version: CLAUDE_HISTORY_BACKFILL_VERSION,
|
|
581
|
-
});
|
|
582
|
-
return true;
|
|
583
|
-
}
|
|
584
|
-
export async function ensureCursorHistoryBackfill(cursorHome, paths) {
|
|
585
|
-
const version = await readHistoryBackfillVersion(paths);
|
|
586
|
-
if (version !== null && version >= 1)
|
|
587
|
-
return false;
|
|
588
|
-
const state = await loadState(paths);
|
|
589
|
-
if (!state.initialized || state.sourceDirectory !== path.resolve(cursorHome))
|
|
590
|
-
throw new Error("Collector state does not match the Cursor directory; reconnect Cursor before uploading.");
|
|
591
|
-
await cursorFiles(path.join(cursorHome, "projects"));
|
|
592
|
-
await saveState({ ...state, cursors: {}, skipping: [] }, paths);
|
|
593
|
-
await writeJsonAtomic(paths.historyBackfill, { version: 1 });
|
|
594
|
-
return true;
|
|
595
|
-
}
|
|
596
|
-
export async function historyScanProgress(home, paths, source = "codex") {
|
|
597
|
-
if ((await readHistoryBackfillVersion(paths)) === null)
|
|
598
|
-
return { kind: "waiting" };
|
|
599
|
-
const state = await loadState(paths);
|
|
600
|
-
if (!state.initialized || state.sourceDirectory !== path.resolve(home)) {
|
|
601
|
-
throw new Error(`Collector state does not match the ${source} directory.`);
|
|
602
|
-
}
|
|
603
|
-
const files = await sourceFiles(source, home);
|
|
604
|
-
let scannedBytes = 0;
|
|
605
|
-
let totalBytes = 0;
|
|
606
|
-
for (let start = 0; start < files.length; start += 64) {
|
|
607
|
-
const batch = files.slice(start, start + 64);
|
|
608
|
-
const sizes = await Promise.all(batch.map(async (filename) => {
|
|
609
|
-
try {
|
|
610
|
-
return (await fs.stat(filename)).size;
|
|
611
|
-
}
|
|
612
|
-
catch (error) {
|
|
613
|
-
if (error instanceof Error &&
|
|
614
|
-
"code" in error &&
|
|
615
|
-
error.code === "ENOENT")
|
|
616
|
-
return null;
|
|
617
|
-
throw error;
|
|
618
|
-
}
|
|
619
|
-
}));
|
|
620
|
-
for (const [index, size] of sizes.entries()) {
|
|
621
|
-
if (size === null)
|
|
622
|
-
continue;
|
|
623
|
-
const filename = batch[index];
|
|
624
|
-
if (filename === undefined)
|
|
625
|
-
continue;
|
|
626
|
-
totalBytes += size;
|
|
627
|
-
const cursor = state.cursors[filename] ?? 0;
|
|
628
|
-
scannedBytes += cursor <= size ? cursor : 0;
|
|
629
|
-
if (!Number.isSafeInteger(totalBytes) ||
|
|
630
|
-
!Number.isSafeInteger(scannedBytes)) {
|
|
631
|
-
throw new Error(`${source} history size exceeds the supported limit.`);
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
return { kind: "scanning", scannedBytes, totalBytes };
|
|
636
|
-
}
|
|
637
|
-
export async function resetHistoryCursors(home, paths, source = "codex") {
|
|
638
|
-
const state = await loadState(paths);
|
|
639
|
-
const sourceDirectory = path.resolve(home);
|
|
640
|
-
if (!state.initialized || state.sourceDirectory !== sourceDirectory) {
|
|
641
|
-
throw new Error(`Connect the ${source} source before importing its history.`);
|
|
642
|
-
}
|
|
643
|
-
const files = await sourceFiles(source, sourceDirectory);
|
|
644
|
-
const cursors = {};
|
|
645
|
-
let bytes = 0;
|
|
646
|
-
for (const filename of files) {
|
|
647
|
-
cursors[filename] = 0;
|
|
648
|
-
bytes += (await fs.stat(filename)).size;
|
|
649
|
-
if (!Number.isSafeInteger(bytes)) {
|
|
650
|
-
throw new Error(`${source} history size exceeds the supported limit.`);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
const localHealth = await healthSnapshot(paths);
|
|
654
|
-
if (localHealth.kind === "unavailable" ||
|
|
655
|
-
localHealth.health.historyCaughtUpAtMs !== null) {
|
|
656
|
-
await saveHealth({ ...localHealth.health, historyCaughtUpAtMs: null }, paths);
|
|
657
|
-
}
|
|
658
|
-
await saveState({
|
|
659
|
-
cursors,
|
|
660
|
-
skipping: [],
|
|
661
|
-
sourceDirectory,
|
|
662
|
-
initialized: true,
|
|
663
|
-
}, paths);
|
|
664
|
-
return { files: files.length, bytes };
|
|
665
|
-
}
|
|
666
|
-
export async function historyImportSize(home, source = "codex") {
|
|
667
|
-
const files = await sourceFiles(source, home);
|
|
668
|
-
let bytes = 0;
|
|
669
|
-
for (const filename of files) {
|
|
670
|
-
bytes += (await fs.stat(filename)).size;
|
|
671
|
-
if (!Number.isSafeInteger(bytes)) {
|
|
672
|
-
throw new Error(`${source} history size exceeds the supported limit.`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
return { files: files.length, bytes };
|
|
676
|
-
}
|
|
677
|
-
async function lastCompleteRecordOffset(filename, size) {
|
|
678
|
-
if (size === 0)
|
|
679
|
-
return 0;
|
|
680
|
-
const handle = await fs.open(filename, "r");
|
|
681
|
-
try {
|
|
682
|
-
const finalByte = Buffer.alloc(1);
|
|
683
|
-
await handle.read(finalByte, 0, 1, size - 1);
|
|
684
|
-
if (finalByte[0] === 10)
|
|
685
|
-
return size;
|
|
686
|
-
let end = size;
|
|
687
|
-
const minimum = Math.max(0, size - 32 * 1024 * 1024);
|
|
688
|
-
while (end > minimum) {
|
|
689
|
-
const start = Math.max(minimum, end - 64 * 1024);
|
|
690
|
-
const buffer = Buffer.alloc(end - start);
|
|
691
|
-
const result = await handle.read(buffer, 0, buffer.length, start);
|
|
692
|
-
const newline = buffer.subarray(0, result.bytesRead).lastIndexOf(10);
|
|
693
|
-
if (newline >= 0)
|
|
694
|
-
return start + newline + 1;
|
|
695
|
-
end = start;
|
|
696
|
-
}
|
|
697
|
-
if (size > 32 * 1024 * 1024) {
|
|
698
|
-
throw new Error(`Could not find a safe rollout boundary in ${filename}.`);
|
|
699
|
-
}
|
|
700
|
-
return 0;
|
|
701
|
-
}
|
|
702
|
-
finally {
|
|
703
|
-
await handle.close();
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
async function rolloutFiles(root) {
|
|
707
|
-
const files = [];
|
|
708
|
-
const pending = [root];
|
|
709
|
-
while (pending.length > 0) {
|
|
710
|
-
const directory = pending.pop();
|
|
711
|
-
if (directory === undefined)
|
|
712
|
-
continue;
|
|
713
|
-
let entries;
|
|
714
|
-
try {
|
|
715
|
-
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
716
|
-
}
|
|
717
|
-
catch (error) {
|
|
718
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
719
|
-
continue;
|
|
720
|
-
throw new Error(`Could not scan Codex session directory ${directory}.`, {
|
|
721
|
-
cause: error,
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
for (const entry of entries) {
|
|
725
|
-
const filename = path.join(directory, entry.name);
|
|
726
|
-
if (entry.isDirectory())
|
|
727
|
-
pending.push(filename);
|
|
728
|
-
else if (entry.isFile() && /^rollout-.+\.jsonl$/.test(entry.name))
|
|
729
|
-
files.push(filename);
|
|
730
|
-
if (files.length > 10_000)
|
|
731
|
-
throw new Error("Codex session file count exceeds the scan limit.");
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
return files.sort();
|
|
735
|
-
}
|
|
736
|
-
async function directoryEntries(directory) {
|
|
737
|
-
try {
|
|
738
|
-
return await fs.readdir(directory, { withFileTypes: true });
|
|
739
|
-
}
|
|
740
|
-
catch (error) {
|
|
741
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
742
|
-
return [];
|
|
743
|
-
throw new Error(`Could not scan Claude session directory ${directory}.`, {
|
|
744
|
-
cause: error,
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
export async function claudeFiles(root) {
|
|
749
|
-
const files = [];
|
|
750
|
-
for (const project of await directoryEntries(root)) {
|
|
751
|
-
if (!project.isDirectory())
|
|
752
|
-
continue;
|
|
753
|
-
const projectDirectory = path.join(root, project.name);
|
|
754
|
-
for (const entry of await directoryEntries(projectDirectory)) {
|
|
755
|
-
const filename = path.join(projectDirectory, entry.name);
|
|
756
|
-
if (entry.isFile() &&
|
|
757
|
-
/^[^/]+\.jsonl$/.test(entry.name) &&
|
|
758
|
-
!entry.name.includes(".orphaned-") &&
|
|
759
|
-
!entry.name.includes(".superseded-")) {
|
|
760
|
-
files.push(filename);
|
|
761
|
-
}
|
|
762
|
-
else if (entry.isDirectory()) {
|
|
763
|
-
const subagents = path.join(filename, "subagents");
|
|
764
|
-
for (const agent of await directoryEntries(subagents)) {
|
|
765
|
-
if (agent.isFile() && /^agent-.+\.jsonl$/.test(agent.name))
|
|
766
|
-
files.push(path.join(subagents, agent.name));
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
if (files.length > 10_000)
|
|
770
|
-
throw new Error("Claude session file count exceeds the scan limit.");
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
return files.sort();
|
|
774
|
-
}
|
|
775
|
-
export function sourceFiles(source, home) {
|
|
776
|
-
const directory = path.resolve(home);
|
|
777
|
-
if (source === "codex")
|
|
778
|
-
return rolloutFiles(path.join(directory, "sessions"));
|
|
779
|
-
if (source === "claude")
|
|
780
|
-
return claudeFiles(path.join(directory, "projects"));
|
|
781
|
-
if (source === "cursor")
|
|
782
|
-
return cursorFiles(path.join(directory, "projects"));
|
|
783
|
-
const unhandled = source;
|
|
784
|
-
throw new Error(`Unsupported collector source: ${unhandled}`);
|
|
785
|
-
}
|
|
786
|
-
export async function cursorFiles(root) {
|
|
787
|
-
const files = [];
|
|
788
|
-
for (const project of await directoryEntries(root)) {
|
|
789
|
-
if (!project.isDirectory())
|
|
790
|
-
continue;
|
|
791
|
-
const transcripts = path.join(root, project.name, "agent-transcripts");
|
|
792
|
-
for (const conversation of await directoryEntries(transcripts)) {
|
|
793
|
-
if (!conversation.isDirectory())
|
|
794
|
-
continue;
|
|
795
|
-
const filename = path.join(transcripts, conversation.name, `${conversation.name}.jsonl`);
|
|
796
|
-
if ((await fs.stat(filename).catch((error) => {
|
|
797
|
-
if (error instanceof Error &&
|
|
798
|
-
"code" in error &&
|
|
799
|
-
error.code === "ENOENT")
|
|
800
|
-
return null;
|
|
801
|
-
throw error;
|
|
802
|
-
}))?.isFile())
|
|
803
|
-
files.push(filename);
|
|
804
|
-
if (files.length > 10_000)
|
|
805
|
-
throw new Error("Cursor session file count exceeds the scan limit.");
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
return files.sort();
|
|
809
|
-
}
|