iteration-cli 0.4.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.
@@ -0,0 +1,445 @@
1
+ import { createWriteStream, promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { once } from "node:events";
5
+ import { createInterface } from "node:readline";
6
+ import * as z from "zod";
7
+ import { MAX_TRACE_EVENTS_PER_BATCH, parseTraceEventBatch, } from "iteration-trace-contracts";
8
+ const MAX_OUTBOX_BYTES = 64 * 1024 * 1024;
9
+ const configSchema = z
10
+ .object({
11
+ endpoint: z.string(),
12
+ token: z.string(),
13
+ codexHome: z.string(),
14
+ dataDirectory: z.string(),
15
+ })
16
+ .strict();
17
+ const stateSchema = z
18
+ .object({
19
+ cursors: z.record(z.string(), z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)),
20
+ skipping: z.array(z.string()),
21
+ sourceDirectory: z.string().nullable(),
22
+ initialized: z.boolean(),
23
+ })
24
+ .strict();
25
+ function parseOutboxEvent(line) {
26
+ let decoded;
27
+ try {
28
+ decoded = JSON.parse(line);
29
+ }
30
+ catch (error) {
31
+ throw new Error("Trace outbox contains a malformed record.", {
32
+ cause: error,
33
+ });
34
+ }
35
+ const parsed = parseTraceEventBatch({ events: [decoded] });
36
+ if (!parsed.success)
37
+ throw new Error(`Trace outbox event is invalid: ${parsed.error}`);
38
+ const [event] = parsed.value.events;
39
+ if (event === undefined)
40
+ throw new Error("Trace outbox event is missing.");
41
+ return event;
42
+ }
43
+ function defaultDirectory() {
44
+ return path.join(os.homedir(), ".config", "iteration", "codex-collector");
45
+ }
46
+ export function validateCollectorEndpoint(endpoint) {
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(endpoint);
50
+ }
51
+ catch (error) {
52
+ throw new Error("Collector endpoint must be a valid URL.", {
53
+ cause: error,
54
+ });
55
+ }
56
+ const localHost = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
57
+ if (parsed.username !== "" ||
58
+ parsed.password !== "" ||
59
+ (parsed.protocol !== "https:" &&
60
+ !(parsed.protocol === "http:" && localHost))) {
61
+ throw new Error("Collector endpoint must use HTTPS, except for localhost.");
62
+ }
63
+ if (parsed.pathname !== "/api/traces/ingest" ||
64
+ parsed.search !== "" ||
65
+ parsed.hash !== "") {
66
+ throw new Error("Collector endpoint must be the trace ingest route without query parameters.");
67
+ }
68
+ return parsed;
69
+ }
70
+ export function collectorPaths(directory = defaultDirectory()) {
71
+ return {
72
+ config: path.join(directory, "config.json"),
73
+ state: path.join(directory, "state.json"),
74
+ outbox: path.join(directory, "outbox.jsonl"),
75
+ lock: path.join(directory, "collector.lock"),
76
+ };
77
+ }
78
+ export async function assertFreshCollectorDirectory(paths) {
79
+ const state = await loadState(paths);
80
+ const configExists = await fs
81
+ .access(paths.config)
82
+ .then(() => true)
83
+ .catch((error) => {
84
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
85
+ return false;
86
+ throw error;
87
+ });
88
+ if (configExists) {
89
+ throw new Error("This collector data directory is already configured. Run iteration status codex or iteration start codex to repair startup. Use a different --data-directory for another workspace, or iteration import-history codex for earlier sessions.");
90
+ }
91
+ if (state.initialized || (await outboxSize(paths)) > 0) {
92
+ 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.");
93
+ }
94
+ }
95
+ export async function ensurePrivateDirectory(directory) {
96
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
97
+ await fs.chmod(directory, 0o700);
98
+ }
99
+ async function writeJsonAtomic(filename, value) {
100
+ const temporary = `${filename}.${process.pid}.tmp`;
101
+ const handle = await fs.open(temporary, "w", 0o600);
102
+ try {
103
+ await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
104
+ await handle.sync();
105
+ }
106
+ finally {
107
+ await handle.close();
108
+ }
109
+ await fs.rename(temporary, filename);
110
+ }
111
+ export async function saveConfig(config, paths) {
112
+ await ensurePrivateDirectory(path.dirname(paths.config));
113
+ await writeJsonAtomic(paths.config, config);
114
+ }
115
+ export async function loadConfig(paths) {
116
+ let contents;
117
+ try {
118
+ contents = await fs.readFile(paths.config, "utf8");
119
+ }
120
+ catch (error) {
121
+ throw new Error(`Could not read collector config at ${paths.config}.`, {
122
+ cause: error,
123
+ });
124
+ }
125
+ let decoded;
126
+ try {
127
+ decoded = JSON.parse(contents);
128
+ }
129
+ catch (error) {
130
+ throw new Error("Collector config is not valid JSON.", { cause: error });
131
+ }
132
+ const parsed = configSchema.safeParse(decoded);
133
+ if (!parsed.success)
134
+ throw new Error("Collector config has an invalid shape.", {
135
+ cause: parsed.error,
136
+ });
137
+ const { endpoint, token, codexHome, dataDirectory } = parsed.data;
138
+ validateCollectorEndpoint(endpoint);
139
+ if (token.length !== 43 || !/^[A-Za-z0-9_-]+$/.test(token)) {
140
+ throw new Error("Collector token has an invalid format.");
141
+ }
142
+ return { endpoint, token, codexHome, dataDirectory };
143
+ }
144
+ export async function loadState(paths) {
145
+ let contents;
146
+ try {
147
+ contents = await fs.readFile(paths.state, "utf8");
148
+ }
149
+ catch (error) {
150
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
151
+ return {
152
+ cursors: {},
153
+ skipping: [],
154
+ sourceDirectory: null,
155
+ initialized: false,
156
+ };
157
+ }
158
+ throw new Error(`Could not read collector checkpoint at ${paths.state}.`, {
159
+ cause: error,
160
+ });
161
+ }
162
+ let decoded;
163
+ try {
164
+ decoded = JSON.parse(contents);
165
+ }
166
+ catch (error) {
167
+ throw new Error("Collector checkpoint is not valid JSON.", {
168
+ cause: error,
169
+ });
170
+ }
171
+ const parsed = stateSchema.safeParse(decoded);
172
+ if (!parsed.success)
173
+ throw new Error("Collector checkpoint has an invalid shape.", {
174
+ cause: parsed.error,
175
+ });
176
+ return parsed.data;
177
+ }
178
+ export async function saveState(state, paths) {
179
+ await ensurePrivateDirectory(path.dirname(paths.state));
180
+ await writeJsonAtomic(paths.state, state);
181
+ }
182
+ export async function outboxSize(paths) {
183
+ try {
184
+ return (await fs.stat(paths.outbox)).size;
185
+ }
186
+ catch (error) {
187
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
188
+ return 0;
189
+ throw new Error("Could not inspect the local trace outbox.", {
190
+ cause: error,
191
+ });
192
+ }
193
+ }
194
+ export async function pendingEventIds(paths) {
195
+ const ids = new Set();
196
+ const size = await outboxSize(paths);
197
+ if (size > MAX_OUTBOX_BYTES)
198
+ throw new Error("Trace outbox exceeds its configured capacity.");
199
+ try {
200
+ const input = await fs.open(paths.outbox, "r");
201
+ const stream = input.createReadStream({
202
+ autoClose: true,
203
+ encoding: "utf8",
204
+ });
205
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
206
+ for await (const line of lines) {
207
+ ids.add(parseOutboxEvent(line).eventId);
208
+ }
209
+ }
210
+ catch (error) {
211
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
212
+ return ids;
213
+ throw error;
214
+ }
215
+ return ids;
216
+ }
217
+ export async function appendEvents(events, paths) {
218
+ if (events.length === 0)
219
+ return;
220
+ const contents = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
221
+ const newSize = Buffer.byteLength(contents, "utf8");
222
+ const currentSize = await outboxSize(paths);
223
+ if (currentSize + newSize > MAX_OUTBOX_BYTES) {
224
+ throw new Error("Trace outbox is full; upload pending data before scanning more sessions.");
225
+ }
226
+ await ensurePrivateDirectory(path.dirname(paths.outbox));
227
+ const handle = await fs.open(paths.outbox, "a", 0o600);
228
+ try {
229
+ await handle.writeFile(contents, "utf8");
230
+ await handle.sync();
231
+ }
232
+ finally {
233
+ await handle.close();
234
+ }
235
+ }
236
+ export async function nextBatch(paths, maxBytes) {
237
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
238
+ throw new Error("Maximum upload batch size must be a positive integer.");
239
+ }
240
+ const size = await outboxSize(paths);
241
+ if (size === 0)
242
+ return [];
243
+ if (size > MAX_OUTBOX_BYTES)
244
+ throw new Error("Trace outbox exceeds its configured capacity.");
245
+ const input = await fs.open(paths.outbox, "r");
246
+ const stream = input.createReadStream({ autoClose: true, encoding: "utf8" });
247
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
248
+ const events = [];
249
+ let bytes = 0;
250
+ for await (const line of lines) {
251
+ const lineBytes = Buffer.byteLength(line, "utf8") + 1;
252
+ if (events.length >= MAX_TRACE_EVENTS_PER_BATCH ||
253
+ (events.length > 0 && bytes + lineBytes > maxBytes)) {
254
+ lines.close();
255
+ stream.destroy();
256
+ break;
257
+ }
258
+ if (lineBytes > maxBytes) {
259
+ lines.close();
260
+ stream.destroy();
261
+ throw new Error("A queued event exceeds the maximum upload batch size.");
262
+ }
263
+ events.push(parseOutboxEvent(line));
264
+ bytes += lineBytes;
265
+ }
266
+ return events;
267
+ }
268
+ export async function acknowledgeEvents(acknowledgedIds, paths) {
269
+ const temporary = `${paths.outbox}.${process.pid}.tmp`;
270
+ const input = await fs.open(paths.outbox, "r").catch((error) => {
271
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
272
+ return null;
273
+ throw error;
274
+ });
275
+ if (input === null)
276
+ return;
277
+ const output = createWriteStream(temporary, { flags: "w", mode: 0o600 });
278
+ try {
279
+ const lines = createInterface({
280
+ input: input.createReadStream({ autoClose: true, encoding: "utf8" }),
281
+ crlfDelay: Infinity,
282
+ });
283
+ for await (const line of lines) {
284
+ const event = parseOutboxEvent(line);
285
+ if (!acknowledgedIds.has(event.eventId)) {
286
+ if (!output.write(`${line}\n`))
287
+ await once(output, "drain");
288
+ }
289
+ }
290
+ output.end();
291
+ await once(output, "close");
292
+ const durable = await fs.open(temporary, "r+");
293
+ try {
294
+ await durable.sync();
295
+ }
296
+ finally {
297
+ await durable.close();
298
+ }
299
+ await fs.rename(temporary, paths.outbox);
300
+ }
301
+ catch (error) {
302
+ output.destroy();
303
+ await fs.rm(temporary, { force: true });
304
+ throw error;
305
+ }
306
+ }
307
+ export async function initializeState(codexHome, paths, includeHistory) {
308
+ const state = await loadState(paths);
309
+ const sourceDirectory = path.resolve(codexHome);
310
+ if (state.initialized && state.sourceDirectory !== sourceDirectory) {
311
+ throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
312
+ }
313
+ if (state.initialized && state.sourceDirectory === sourceDirectory) {
314
+ if (includeHistory) {
315
+ throw new Error("Codex is already connected. Run iteration import-history codex to import existing sessions.");
316
+ }
317
+ return;
318
+ }
319
+ await saveState(await prepareInitialState(codexHome, includeHistory), paths);
320
+ }
321
+ export async function prepareInitialState(codexHome, includeHistory) {
322
+ const sourceDirectory = path.resolve(codexHome);
323
+ const sessionsRoot = path.join(sourceDirectory, "sessions");
324
+ const files = await rolloutFiles(sessionsRoot);
325
+ const cursors = {};
326
+ for (const filename of files) {
327
+ cursors[filename] = includeHistory
328
+ ? 0
329
+ : await lastCompleteRecordOffset(filename, (await fs.stat(filename)).size);
330
+ }
331
+ return {
332
+ cursors,
333
+ skipping: [],
334
+ sourceDirectory,
335
+ initialized: true,
336
+ };
337
+ }
338
+ export async function ensureStartableState(config, paths) {
339
+ const state = await loadState(paths);
340
+ if (state.initialized) {
341
+ if (state.sourceDirectory !== path.resolve(config.codexHome)) {
342
+ throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
343
+ }
344
+ return false;
345
+ }
346
+ if ((await outboxSize(paths)) > 0) {
347
+ throw new Error("Pending events exist without an initialized checkpoint; repair the collector state before starting uploads.");
348
+ }
349
+ await initializeState(config.codexHome, paths, false);
350
+ return true;
351
+ }
352
+ export async function resetHistoryCursors(codexHome, paths) {
353
+ const state = await loadState(paths);
354
+ const sourceDirectory = path.resolve(codexHome);
355
+ if (!state.initialized || state.sourceDirectory !== sourceDirectory) {
356
+ throw new Error("Connect the Codex source before importing its history.");
357
+ }
358
+ const files = await rolloutFiles(path.join(sourceDirectory, "sessions"));
359
+ const cursors = {};
360
+ let bytes = 0;
361
+ for (const filename of files) {
362
+ cursors[filename] = 0;
363
+ bytes += (await fs.stat(filename)).size;
364
+ if (!Number.isSafeInteger(bytes)) {
365
+ throw new Error("Codex history size exceeds the supported limit.");
366
+ }
367
+ }
368
+ await saveState({
369
+ cursors,
370
+ skipping: [],
371
+ sourceDirectory,
372
+ initialized: true,
373
+ }, paths);
374
+ return { files: files.length, bytes };
375
+ }
376
+ export async function historyImportSize(codexHome) {
377
+ const files = await rolloutFiles(path.join(path.resolve(codexHome), "sessions"));
378
+ let bytes = 0;
379
+ for (const filename of files) {
380
+ bytes += (await fs.stat(filename)).size;
381
+ if (!Number.isSafeInteger(bytes)) {
382
+ throw new Error("Codex history size exceeds the supported limit.");
383
+ }
384
+ }
385
+ return { files: files.length, bytes };
386
+ }
387
+ async function lastCompleteRecordOffset(filename, size) {
388
+ if (size === 0)
389
+ return 0;
390
+ const handle = await fs.open(filename, "r");
391
+ try {
392
+ const finalByte = Buffer.alloc(1);
393
+ await handle.read(finalByte, 0, 1, size - 1);
394
+ if (finalByte[0] === 10)
395
+ return size;
396
+ let end = size;
397
+ const minimum = Math.max(0, size - 32 * 1024 * 1024);
398
+ while (end > minimum) {
399
+ const start = Math.max(minimum, end - 64 * 1024);
400
+ const buffer = Buffer.alloc(end - start);
401
+ const result = await handle.read(buffer, 0, buffer.length, start);
402
+ const newline = buffer.subarray(0, result.bytesRead).lastIndexOf(10);
403
+ if (newline >= 0)
404
+ return start + newline + 1;
405
+ end = start;
406
+ }
407
+ if (size > 32 * 1024 * 1024) {
408
+ throw new Error(`Could not find a safe rollout boundary in ${filename}.`);
409
+ }
410
+ return 0;
411
+ }
412
+ finally {
413
+ await handle.close();
414
+ }
415
+ }
416
+ export async function rolloutFiles(root) {
417
+ const files = [];
418
+ const pending = [root];
419
+ while (pending.length > 0) {
420
+ const directory = pending.pop();
421
+ if (directory === undefined)
422
+ continue;
423
+ let entries;
424
+ try {
425
+ entries = await fs.readdir(directory, { withFileTypes: true });
426
+ }
427
+ catch (error) {
428
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
429
+ continue;
430
+ throw new Error(`Could not scan Codex session directory ${directory}.`, {
431
+ cause: error,
432
+ });
433
+ }
434
+ for (const entry of entries) {
435
+ const filename = path.join(directory, entry.name);
436
+ if (entry.isDirectory())
437
+ pending.push(filename);
438
+ else if (entry.isFile() && /^rollout-.+\.jsonl$/.test(entry.name))
439
+ files.push(filename);
440
+ if (files.length > 10_000)
441
+ throw new Error("Codex session file count exceeds the scan limit.");
442
+ }
443
+ }
444
+ return files.sort();
445
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "iteration-cli",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Thytu/Iteration.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "bin": {
17
+ "iteration": "./dist/cli.js"
18
+ },
19
+ "dependencies": {
20
+ "zod": "4.6.4",
21
+ "iteration-trace-contracts": "0.3.0"
22
+ },
23
+ "scripts": {
24
+ "build": "pnpm --filter iteration-trace-contracts build && tsc -p tsconfig.json"
25
+ }
26
+ }