iteration-cli 0.4.3 → 0.5.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/storage.js CHANGED
@@ -1,19 +1,33 @@
1
+ import { once } from "node:events";
1
2
  import { createWriteStream, promises as fs } from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
- import { once } from "node:events";
5
5
  import { createInterface } from "node:readline";
6
- import * as z from "zod";
7
6
  import { MAX_TRACE_EVENTS_PER_BATCH, parseTraceEventBatch, } from "iteration-trace-contracts";
7
+ import * as z from "zod";
8
8
  const MAX_OUTBOX_BYTES = 64 * 1024 * 1024;
9
- const configSchema = z
10
- .object({
9
+ export function collectorSource(config) {
10
+ return "claudeHome" in config ? "claude" : "codex";
11
+ }
12
+ export function collectorSourceDirectory(config) {
13
+ return "claudeHome" in config ? config.claudeHome : config.codexHome;
14
+ }
15
+ const connectionSchema = {
11
16
  endpoint: z.string(),
12
17
  token: z.string(),
13
- codexHome: z.string(),
14
18
  dataDirectory: z.string(),
15
- })
16
- .strict();
19
+ organizationName: z.string().min(1).max(160).optional(),
20
+ };
21
+ const configSchema = z.union([
22
+ z.object({ ...connectionSchema, codexHome: z.string() }).strict(),
23
+ z
24
+ .object({
25
+ ...connectionSchema,
26
+ source: z.literal("claude"),
27
+ claudeHome: z.string(),
28
+ })
29
+ .strict(),
30
+ ]);
17
31
  const stateSchema = z
18
32
  .object({
19
33
  cursors: z.record(z.string(), z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)),
@@ -22,6 +36,20 @@ const stateSchema = z
22
36
  initialized: z.boolean(),
23
37
  })
24
38
  .strict();
39
+ const healthSchema = z
40
+ .object({
41
+ lastUploadAtMs: z.number().int().min(0).nullable(),
42
+ historyCaughtUpAtMs: z.number().int().min(0).nullable(),
43
+ lastFailure: z
44
+ .object({
45
+ atMs: z.number().int().min(0),
46
+ kind: z.enum(["http", "network", "response", "collector"]),
47
+ status: z.number().int().min(100).max(599).nullable(),
48
+ })
49
+ .strict()
50
+ .nullable(),
51
+ })
52
+ .strict();
25
53
  function parseOutboxEvent(line) {
26
54
  let decoded;
27
55
  try {
@@ -40,8 +68,8 @@ function parseOutboxEvent(line) {
40
68
  throw new Error("Trace outbox event is missing.");
41
69
  return event;
42
70
  }
43
- function defaultDirectory() {
44
- return path.join(os.homedir(), ".config", "iteration", "codex-collector");
71
+ export function defaultCollectorDirectory(source) {
72
+ return path.join(os.homedir(), ".config", "iteration", `${source}-collector`);
45
73
  }
46
74
  export function validateCollectorEndpoint(endpoint) {
47
75
  let parsed;
@@ -67,16 +95,17 @@ export function validateCollectorEndpoint(endpoint) {
67
95
  }
68
96
  return parsed;
69
97
  }
70
- export function collectorPaths(directory = defaultDirectory()) {
98
+ export function collectorPaths(directory = defaultCollectorDirectory("codex")) {
71
99
  return {
72
100
  config: path.join(directory, "config.json"),
73
101
  state: path.join(directory, "state.json"),
74
102
  outbox: path.join(directory, "outbox.jsonl"),
75
103
  lock: path.join(directory, "collector.lock"),
76
104
  historyBackfill: path.join(directory, "history-backfill.json"),
105
+ health: path.join(directory, "health.json"),
77
106
  };
78
107
  }
79
- export async function assertFreshCollectorDirectory(paths) {
108
+ export async function assertFreshCollectorDirectory(paths, source = "codex") {
80
109
  const state = await loadState(paths);
81
110
  const configExists = await fs
82
111
  .access(paths.config)
@@ -87,7 +116,7 @@ export async function assertFreshCollectorDirectory(paths) {
87
116
  throw error;
88
117
  });
89
118
  if (configExists) {
90
- 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.");
119
+ 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.`);
91
120
  }
92
121
  if (state.initialized || (await outboxSize(paths)) > 0) {
93
122
  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.");
@@ -113,6 +142,57 @@ export async function saveConfig(config, paths) {
113
142
  await ensurePrivateDirectory(path.dirname(paths.config));
114
143
  await writeJsonAtomic(paths.config, config);
115
144
  }
145
+ export async function loadHealth(paths) {
146
+ let contents;
147
+ try {
148
+ contents = await fs.readFile(paths.health, "utf8");
149
+ }
150
+ catch (error) {
151
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
152
+ return {
153
+ lastUploadAtMs: null,
154
+ historyCaughtUpAtMs: null,
155
+ lastFailure: null,
156
+ };
157
+ }
158
+ throw new Error(`Could not read collector health at ${paths.health}.`, {
159
+ cause: error,
160
+ });
161
+ }
162
+ let decoded;
163
+ try {
164
+ decoded = JSON.parse(contents);
165
+ }
166
+ catch (error) {
167
+ throw new Error("Collector health is not valid JSON.", { cause: error });
168
+ }
169
+ const parsed = healthSchema.safeParse(decoded);
170
+ if (!parsed.success) {
171
+ throw new Error("Collector health has an invalid shape.", {
172
+ cause: parsed.error,
173
+ });
174
+ }
175
+ return parsed.data;
176
+ }
177
+ export async function healthSnapshot(paths) {
178
+ try {
179
+ return { kind: "available", health: await loadHealth(paths) };
180
+ }
181
+ catch {
182
+ return {
183
+ kind: "unavailable",
184
+ health: {
185
+ lastUploadAtMs: null,
186
+ historyCaughtUpAtMs: null,
187
+ lastFailure: null,
188
+ },
189
+ };
190
+ }
191
+ }
192
+ export async function saveHealth(health, paths) {
193
+ await ensurePrivateDirectory(path.dirname(paths.health));
194
+ await writeJsonAtomic(paths.health, health);
195
+ }
116
196
  export async function loadConfig(paths) {
117
197
  let contents;
118
198
  try {
@@ -135,12 +215,12 @@ export async function loadConfig(paths) {
135
215
  throw new Error("Collector config has an invalid shape.", {
136
216
  cause: parsed.error,
137
217
  });
138
- const { endpoint, token, codexHome, dataDirectory } = parsed.data;
139
- validateCollectorEndpoint(endpoint);
218
+ validateCollectorEndpoint(parsed.data.endpoint);
219
+ const { token } = parsed.data;
140
220
  if (token.length !== 43 || !/^[A-Za-z0-9_-]+$/.test(token)) {
141
221
  throw new Error("Collector token has an invalid format.");
142
222
  }
143
- return { endpoint, token, codexHome, dataDirectory };
223
+ return parsed.data;
144
224
  }
145
225
  export async function loadState(paths) {
146
226
  let contents;
@@ -305,24 +385,23 @@ export async function acknowledgeEvents(acknowledgedIds, paths) {
305
385
  throw error;
306
386
  }
307
387
  }
308
- export async function initializeState(codexHome, paths, includeHistory) {
388
+ export async function initializeState(source, home, paths, includeHistory) {
309
389
  const state = await loadState(paths);
310
- const sourceDirectory = path.resolve(codexHome);
390
+ const sourceDirectory = path.resolve(home);
311
391
  if (state.initialized && state.sourceDirectory !== sourceDirectory) {
312
- throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
392
+ throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
313
393
  }
314
394
  if (state.initialized && state.sourceDirectory === sourceDirectory) {
315
395
  if (includeHistory) {
316
- throw new Error("Codex is already connected. Run iteration import-history codex to import existing sessions.");
396
+ throw new Error(`${source} is already connected. Run iteration import-history ${source} to import existing sessions.`);
317
397
  }
318
398
  return;
319
399
  }
320
- await saveState(await prepareInitialState(codexHome, includeHistory), paths);
400
+ await saveState(await prepareSourceInitialState(source, home, includeHistory), paths);
321
401
  }
322
- export async function prepareInitialState(codexHome, includeHistory) {
323
- const sourceDirectory = path.resolve(codexHome);
324
- const sessionsRoot = path.join(sourceDirectory, "sessions");
325
- const files = await rolloutFiles(sessionsRoot);
402
+ export async function prepareSourceInitialState(source, home, includeHistory) {
403
+ const sourceDirectory = path.resolve(home);
404
+ const files = await sourceFiles(source, sourceDirectory);
326
405
  const cursors = {};
327
406
  if (!includeHistory) {
328
407
  for (const filename of files) {
@@ -337,20 +416,26 @@ export async function prepareInitialState(codexHome, includeHistory) {
337
416
  };
338
417
  }
339
418
  export async function ensureStartableState(config, paths) {
419
+ const source = collectorSource(config);
420
+ const directory = collectorSourceDirectory(config);
340
421
  const state = await loadState(paths);
341
422
  if (state.initialized) {
342
- if (state.sourceDirectory !== path.resolve(config.codexHome)) {
343
- throw new Error("Collector state belongs to another Codex directory; use a separate --data-directory.");
423
+ if (state.sourceDirectory !== path.resolve(directory)) {
424
+ throw new Error(`Collector state belongs to another ${source} directory; use a separate --data-directory.`);
344
425
  }
345
426
  return false;
346
427
  }
347
428
  if ((await outboxSize(paths)) > 0) {
348
429
  throw new Error("Pending events exist without an initialized checkpoint; repair the collector state before starting uploads.");
349
430
  }
350
- await initializeState(config.codexHome, paths, true);
431
+ await saveState(await prepareSourceInitialState(source, directory, true), paths);
351
432
  return true;
352
433
  }
353
- async function hasHistoryBackfillMarker(paths) {
434
+ const HISTORY_BACKFILL_VERSION = 4;
435
+ const historyBackfillMarkerSchema = z
436
+ .object({ version: z.number().int().min(1).max(HISTORY_BACKFILL_VERSION) })
437
+ .strict();
438
+ async function readHistoryBackfillVersion(paths) {
354
439
  let marker;
355
440
  try {
356
441
  marker = await fs.readFile(paths.historyBackfill, "utf8");
@@ -358,18 +443,28 @@ async function hasHistoryBackfillMarker(paths) {
358
443
  catch (error) {
359
444
  if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
360
445
  throw error;
361
- marker = null;
446
+ return null;
362
447
  }
363
- if (marker !== null) {
364
- if (marker !== '{"version":1}\n') {
365
- throw new Error("Collector history backfill marker has an invalid shape.");
366
- }
367
- return true;
448
+ let decoded;
449
+ try {
450
+ decoded = JSON.parse(marker);
451
+ }
452
+ catch (error) {
453
+ throw new Error("Collector history backfill marker is not valid JSON.", {
454
+ cause: error,
455
+ });
456
+ }
457
+ const parsed = historyBackfillMarkerSchema.safeParse(decoded);
458
+ if (!parsed.success) {
459
+ throw new Error("Collector history backfill marker has an invalid shape.", {
460
+ cause: parsed.error,
461
+ });
368
462
  }
369
- return false;
463
+ return parsed.data.version;
370
464
  }
371
465
  export async function ensureHistoryBackfill(codexHome, paths) {
372
- if (await hasHistoryBackfillMarker(paths))
466
+ const version = await readHistoryBackfillVersion(paths);
467
+ if (version !== null && version >= HISTORY_BACKFILL_VERSION)
373
468
  return false;
374
469
  const state = await loadState(paths);
375
470
  if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
@@ -377,17 +472,33 @@ export async function ensureHistoryBackfill(codexHome, paths) {
377
472
  }
378
473
  await rolloutFiles(path.join(codexHome, "sessions"));
379
474
  await saveState({ ...state, cursors: {}, skipping: [] }, paths);
475
+ await writeJsonAtomic(paths.historyBackfill, {
476
+ version: HISTORY_BACKFILL_VERSION,
477
+ });
478
+ return true;
479
+ }
480
+ export async function ensureClaudeHistoryBackfill(claudeHome, paths) {
481
+ const version = await readHistoryBackfillVersion(paths);
482
+ if (version !== null && version >= 1)
483
+ return false;
484
+ const state = await loadState(paths);
485
+ if (!state.initialized ||
486
+ state.sourceDirectory !== path.resolve(claudeHome)) {
487
+ throw new Error("Collector state does not match the Claude directory; reconnect Claude before uploading.");
488
+ }
489
+ await claudeFiles(path.join(claudeHome, "projects"));
490
+ await saveState({ ...state, cursors: {}, skipping: [] }, paths);
380
491
  await writeJsonAtomic(paths.historyBackfill, { version: 1 });
381
492
  return true;
382
493
  }
383
- export async function historyScanProgress(codexHome, paths) {
384
- if (!(await hasHistoryBackfillMarker(paths)))
494
+ export async function historyScanProgress(home, paths, source = "codex") {
495
+ if ((await readHistoryBackfillVersion(paths)) === null)
385
496
  return { kind: "waiting" };
386
497
  const state = await loadState(paths);
387
- if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
388
- throw new Error("Collector state does not match CODEX_HOME.");
498
+ if (!state.initialized || state.sourceDirectory !== path.resolve(home)) {
499
+ throw new Error(`Collector state does not match the ${source} directory.`);
389
500
  }
390
- const files = await rolloutFiles(path.join(codexHome, "sessions"));
501
+ const files = await sourceFiles(source, home);
391
502
  let scannedBytes = 0;
392
503
  let totalBytes = 0;
393
504
  for (let start = 0; start < files.length; start += 64) {
@@ -415,28 +526,33 @@ export async function historyScanProgress(codexHome, paths) {
415
526
  scannedBytes += cursor <= size ? cursor : 0;
416
527
  if (!Number.isSafeInteger(totalBytes) ||
417
528
  !Number.isSafeInteger(scannedBytes)) {
418
- throw new Error("Codex history size exceeds the supported limit.");
529
+ throw new Error(`${source} history size exceeds the supported limit.`);
419
530
  }
420
531
  }
421
532
  }
422
533
  return { kind: "scanning", scannedBytes, totalBytes };
423
534
  }
424
- export async function resetHistoryCursors(codexHome, paths) {
535
+ export async function resetHistoryCursors(home, paths, source = "codex") {
425
536
  const state = await loadState(paths);
426
- const sourceDirectory = path.resolve(codexHome);
537
+ const sourceDirectory = path.resolve(home);
427
538
  if (!state.initialized || state.sourceDirectory !== sourceDirectory) {
428
- throw new Error("Connect the Codex source before importing its history.");
539
+ throw new Error(`Connect the ${source} source before importing its history.`);
429
540
  }
430
- const files = await rolloutFiles(path.join(sourceDirectory, "sessions"));
541
+ const files = await sourceFiles(source, sourceDirectory);
431
542
  const cursors = {};
432
543
  let bytes = 0;
433
544
  for (const filename of files) {
434
545
  cursors[filename] = 0;
435
546
  bytes += (await fs.stat(filename)).size;
436
547
  if (!Number.isSafeInteger(bytes)) {
437
- throw new Error("Codex history size exceeds the supported limit.");
548
+ throw new Error(`${source} history size exceeds the supported limit.`);
438
549
  }
439
550
  }
551
+ const localHealth = await healthSnapshot(paths);
552
+ if (localHealth.kind === "unavailable" ||
553
+ localHealth.health.historyCaughtUpAtMs !== null) {
554
+ await saveHealth({ ...localHealth.health, historyCaughtUpAtMs: null }, paths);
555
+ }
440
556
  await saveState({
441
557
  cursors,
442
558
  skipping: [],
@@ -445,13 +561,13 @@ export async function resetHistoryCursors(codexHome, paths) {
445
561
  }, paths);
446
562
  return { files: files.length, bytes };
447
563
  }
448
- export async function historyImportSize(codexHome) {
449
- const files = await rolloutFiles(path.join(path.resolve(codexHome), "sessions"));
564
+ export async function historyImportSize(home, source = "codex") {
565
+ const files = await sourceFiles(source, home);
450
566
  let bytes = 0;
451
567
  for (const filename of files) {
452
568
  bytes += (await fs.stat(filename)).size;
453
569
  if (!Number.isSafeInteger(bytes)) {
454
- throw new Error("Codex history size exceeds the supported limit.");
570
+ throw new Error(`${source} history size exceeds the supported limit.`);
455
571
  }
456
572
  }
457
573
  return { files: files.length, bytes };
@@ -485,7 +601,7 @@ async function lastCompleteRecordOffset(filename, size) {
485
601
  await handle.close();
486
602
  }
487
603
  }
488
- export async function rolloutFiles(root) {
604
+ async function rolloutFiles(root) {
489
605
  const files = [];
490
606
  const pending = [root];
491
607
  while (pending.length > 0) {
@@ -515,3 +631,51 @@ export async function rolloutFiles(root) {
515
631
  }
516
632
  return files.sort();
517
633
  }
634
+ async function directoryEntries(directory) {
635
+ try {
636
+ return await fs.readdir(directory, { withFileTypes: true });
637
+ }
638
+ catch (error) {
639
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
640
+ return [];
641
+ throw new Error(`Could not scan Claude session directory ${directory}.`, {
642
+ cause: error,
643
+ });
644
+ }
645
+ }
646
+ export async function claudeFiles(root) {
647
+ const files = [];
648
+ for (const project of await directoryEntries(root)) {
649
+ if (!project.isDirectory())
650
+ continue;
651
+ const projectDirectory = path.join(root, project.name);
652
+ for (const entry of await directoryEntries(projectDirectory)) {
653
+ const filename = path.join(projectDirectory, entry.name);
654
+ if (entry.isFile() &&
655
+ /^[^/]+\.jsonl$/.test(entry.name) &&
656
+ !entry.name.includes(".orphaned-") &&
657
+ !entry.name.includes(".superseded-")) {
658
+ files.push(filename);
659
+ }
660
+ else if (entry.isDirectory()) {
661
+ const subagents = path.join(filename, "subagents");
662
+ for (const agent of await directoryEntries(subagents)) {
663
+ if (agent.isFile() && /^agent-.+\.jsonl$/.test(agent.name))
664
+ files.push(path.join(subagents, agent.name));
665
+ }
666
+ }
667
+ if (files.length > 10_000)
668
+ throw new Error("Claude session file count exceeds the scan limit.");
669
+ }
670
+ }
671
+ return files.sort();
672
+ }
673
+ export function sourceFiles(source, home) {
674
+ const directory = path.resolve(home);
675
+ if (source === "codex")
676
+ return rolloutFiles(path.join(directory, "sessions"));
677
+ if (source === "claude")
678
+ return claudeFiles(path.join(directory, "projects"));
679
+ const unhandled = source;
680
+ throw new Error(`Unsupported collector source: ${unhandled}`);
681
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "zod": "4.6.4",
21
- "iteration-trace-contracts": "0.3.0"
21
+ "iteration-trace-contracts": "0.5.0"
22
22
  },
23
23
  "scripts": {
24
24
  "build": "pnpm --filter iteration-trace-contracts build && tsc -p tsconfig.json"