iteration-cli 0.4.1 → 0.4.3

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/cli.js CHANGED
@@ -8,7 +8,7 @@ import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
8
8
  import { importCodexHistory } from "./history-import.js";
9
9
  import { runCollector } from "./service.js";
10
10
  import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
11
- import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
11
+ import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
12
12
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
13
13
  function argumentsForCli() {
14
14
  const parsed = parseArgs({
@@ -22,6 +22,7 @@ function argumentsForCli() {
22
22
  "organization-id": { type: "string" },
23
23
  "codex-home": { type: "string" },
24
24
  "data-directory": { type: "string" },
25
+ "include-history": { type: "boolean" },
25
26
  yes: { type: "boolean" },
26
27
  },
27
28
  });
@@ -156,7 +157,7 @@ async function importHistory(values) {
156
157
  return;
157
158
  }
158
159
  const result = await importCodexHistory(config, paths, true);
159
- process.stdout.write(`History scan scheduled for ${result.size.files} local session files (${result.size.bytes} bytes). Scanning and uploads run in bounded batches and may take time. Older sessions appear under the date Iteration receives them. iteration status codex shows events waiting to upload, not total scan progress.${result.backgroundRunning ? "" : " The collector was not running; run iteration start codex to begin scanning."}\n`);
160
+ process.stdout.write(`History scan scheduled for ${result.size.files} local session files (${result.size.bytes} bytes). Scanning and uploads run in bounded batches and may take time. Older sessions appear under the date Iteration receives them. Use iteration status to see local scan progress and pending uploads.${result.backgroundRunning ? "" : " The collector was not running; run iteration start codex to begin scanning."}\n`);
160
161
  }
161
162
  async function start(values) {
162
163
  if (process.platform !== "darwin") {
@@ -175,15 +176,22 @@ async function status(values) {
175
176
  process.stdout.write("Installed: false\nRunning: false\nPending events: 0\n");
176
177
  return;
177
178
  }
178
- await loadConfig(paths);
179
+ const config = await loadConfig(paths);
179
180
  const running = await launchAgentStatus();
180
- const [size, ids] = await Promise.all([
181
+ const [size, ids, scan] = await Promise.all([
181
182
  outboxSize(paths),
182
183
  pendingEventIds(paths),
184
+ historyScanProgress(config.codexHome, paths),
183
185
  ]);
186
+ const scanLabel = scan.kind === "waiting"
187
+ ? "Local history: waiting for collector start"
188
+ : scan.totalBytes === 0
189
+ ? "Local scan: no session data found"
190
+ : `Local scan: ${scan.scannedBytes} of ${scan.totalBytes} bytes processed`;
184
191
  process.stdout.write([
185
192
  "Installed: true",
186
193
  `Running: ${running}`,
194
+ scanLabel,
187
195
  `Pending events: ${ids.size}`,
188
196
  `Pending bytes: ${size}`,
189
197
  ].join("\n") + "\n");
@@ -193,6 +201,10 @@ async function main() {
193
201
  if (values.help === true || command === "help") {
194
202
  return usage(command === "help" ? source : command);
195
203
  }
204
+ if (values["include-history"] === true &&
205
+ !(command === "connect" && source === "codex")) {
206
+ throw new Error("Existing sessions are included automatically. Run iteration to connect.");
207
+ }
196
208
  if (command === "" || (command === "connect" && source === undefined)) {
197
209
  if (Object.keys(values).length > 0) {
198
210
  throw new Error("Options require a source. Run iteration connect codex --help for details.");
@@ -1,4 +1,12 @@
1
+ export type LaunchAgentRestart = {
2
+ isLoaded(timeoutMs: number): Promise<boolean>;
3
+ bootstrap(timeoutMs: number): Promise<void>;
4
+ wait(delayMs: number): Promise<void>;
5
+ now(): number;
6
+ };
1
7
  export declare function launchAgentPlist(cliPath?: string, nodePath?: string, logDirectory?: string, dataDirectory?: string): string;
8
+ export declare function bootstrapAfterBootout(wasLoaded: boolean, control: LaunchAgentRestart): Promise<void>;
2
9
  export declare function installLaunchAgent(dataDirectory?: string): Promise<string>;
3
10
  export declare function uninstallLaunchAgent(): Promise<void>;
4
11
  export declare function launchAgentStatus(): Promise<boolean>;
12
+ export declare function launchAgentRunning(output: string): boolean;
@@ -3,9 +3,13 @@ import { execFile as execFileCallback } from "node:child_process";
3
3
  import { promises as fs } from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
6
7
  import { fileURLToPath } from "node:url";
7
8
  const execFile = promisify(execFileCallback);
8
9
  const LABEL = "com.iteration.codex-collector";
10
+ const LAUNCHCTL_TIMEOUT_MS = 5_000;
11
+ const RESTART_TIMEOUT_MS = 10_000;
12
+ const RESTART_POLL_MS = 200;
9
13
  function xml(value) {
10
14
  return value
11
15
  .replaceAll("&", "&amp;")
@@ -52,18 +56,73 @@ function launchctlTarget() {
52
56
  throw new Error("Could not determine the current macOS user id.");
53
57
  return `gui/${userId}`;
54
58
  }
55
- async function bootoutIfLoaded(target) {
59
+ function serviceMissing(error) {
60
+ const details = error;
61
+ return (String(details.code) === "113" ||
62
+ details.stderr?.includes("Could not find service") === true);
63
+ }
64
+ async function serviceLoaded(target, timeoutMs = LAUNCHCTL_TIMEOUT_MS) {
56
65
  try {
57
- await execFile("launchctl", ["print", `${target}/${LABEL}`]);
66
+ await execFile("launchctl", ["print", `${target}/${LABEL}`], {
67
+ timeout: timeoutMs,
68
+ });
69
+ return true;
58
70
  }
59
71
  catch (error) {
60
- const details = error;
61
- if (String(details.code) === "113" ||
62
- details.stderr?.includes("Could not find service"))
63
- return;
72
+ if (serviceMissing(error))
73
+ return false;
64
74
  throw error;
65
75
  }
66
- await execFile("launchctl", ["bootout", `${target}/${LABEL}`]);
76
+ }
77
+ async function bootoutIfLoaded(target) {
78
+ if (!(await serviceLoaded(target)))
79
+ return false;
80
+ await execFile("launchctl", ["bootout", `${target}/${LABEL}`], {
81
+ timeout: LAUNCHCTL_TIMEOUT_MS,
82
+ });
83
+ return true;
84
+ }
85
+ function transientBootstrapFailure(error) {
86
+ const details = error;
87
+ return (String(details.code) === "5" ||
88
+ /^Bootstrap failed:\s*5\b/m.test(details.stderr ?? ""));
89
+ }
90
+ function restartTimeoutError(lastBootstrapFailure) {
91
+ return new Error(lastBootstrapFailure === undefined
92
+ ? "The previous Codex collector did not stop within 10 seconds."
93
+ : "launchctl bootstrap still failed with error 5 after 10 seconds.", { cause: lastBootstrapFailure });
94
+ }
95
+ export async function bootstrapAfterBootout(wasLoaded, control) {
96
+ if (!wasLoaded) {
97
+ await control.bootstrap(LAUNCHCTL_TIMEOUT_MS);
98
+ return;
99
+ }
100
+ const deadline = control.now() + RESTART_TIMEOUT_MS;
101
+ let lastBootstrapFailure;
102
+ while (true) {
103
+ let remaining = deadline - control.now();
104
+ if (remaining <= 0) {
105
+ throw restartTimeoutError(lastBootstrapFailure);
106
+ }
107
+ if (!(await control.isLoaded(Math.min(remaining, LAUNCHCTL_TIMEOUT_MS)))) {
108
+ remaining = deadline - control.now();
109
+ if (remaining <= 0)
110
+ continue;
111
+ try {
112
+ await control.bootstrap(Math.min(remaining, LAUNCHCTL_TIMEOUT_MS));
113
+ return;
114
+ }
115
+ catch (error) {
116
+ if (!transientBootstrapFailure(error))
117
+ throw error;
118
+ lastBootstrapFailure = error;
119
+ }
120
+ }
121
+ remaining = deadline - control.now();
122
+ if (remaining > 0) {
123
+ await control.wait(Math.min(remaining, RESTART_POLL_MS));
124
+ }
125
+ }
67
126
  }
68
127
  export async function installLaunchAgent(dataDirectory) {
69
128
  if (process.platform !== "darwin") {
@@ -77,8 +136,20 @@ export async function installLaunchAgent(dataDirectory) {
77
136
  await fs.writeFile(file, launchAgentPlist(undefined, undefined, undefined, dataDirectory ??
78
137
  path.join(os.homedir(), ".config", "iteration", "codex-collector")), { encoding: "utf8", mode: 0o644 });
79
138
  const target = launchctlTarget();
80
- await bootoutIfLoaded(target);
81
- await execFile("launchctl", ["bootstrap", target, file]);
139
+ const wasLoaded = await bootoutIfLoaded(target);
140
+ await bootstrapAfterBootout(wasLoaded, {
141
+ isLoaded: (timeoutMs) => serviceLoaded(target, timeoutMs),
142
+ bootstrap: async (timeoutMs) => {
143
+ await execFile("launchctl", ["bootstrap", target, file], {
144
+ timeout: timeoutMs,
145
+ });
146
+ },
147
+ wait: sleep,
148
+ now: Date.now,
149
+ });
150
+ await execFile("launchctl", ["kickstart", "-p", `${target}/${LABEL}`], {
151
+ timeout: LAUNCHCTL_TIMEOUT_MS,
152
+ });
82
153
  return file;
83
154
  }
84
155
  export async function uninstallLaunchAgent() {
@@ -93,10 +164,17 @@ export async function launchAgentStatus() {
93
164
  if (process.platform !== "darwin")
94
165
  return false;
95
166
  try {
96
- await execFile("launchctl", ["print", `${launchctlTarget()}/${LABEL}`]);
97
- return true;
167
+ const { stdout } = await execFile("launchctl", [
168
+ "print",
169
+ `${launchctlTarget()}/${LABEL}`,
170
+ ]);
171
+ return launchAgentRunning(stdout);
98
172
  }
99
173
  catch {
100
174
  return false;
101
175
  }
102
176
  }
177
+ export function launchAgentRunning(output) {
178
+ const state = output.match(/^\s*state = ([^\r\n]+)$/m);
179
+ return state?.[1]?.trim() === "running";
180
+ }
package/dist/storage.d.ts CHANGED
@@ -35,6 +35,13 @@ export declare function initializeState(codexHome: string, paths: CollectorPaths
35
35
  export declare function prepareInitialState(codexHome: string, includeHistory: boolean): Promise<CollectorState>;
36
36
  export declare function ensureStartableState(config: CollectorConfig, paths: CollectorPaths): Promise<boolean>;
37
37
  export declare function ensureHistoryBackfill(codexHome: string, paths: CollectorPaths): Promise<boolean>;
38
+ export declare function historyScanProgress(codexHome: string, paths: CollectorPaths): Promise<Readonly<{
39
+ kind: "waiting";
40
+ } | {
41
+ kind: "scanning";
42
+ scannedBytes: number;
43
+ totalBytes: number;
44
+ }>>;
38
45
  export declare function resetHistoryCursors(codexHome: string, paths: CollectorPaths): Promise<Readonly<{
39
46
  files: number;
40
47
  bytes: number;
package/dist/storage.js CHANGED
@@ -322,9 +322,10 @@ export async function initializeState(codexHome, paths, includeHistory) {
322
322
  export async function prepareInitialState(codexHome, includeHistory) {
323
323
  const sourceDirectory = path.resolve(codexHome);
324
324
  const sessionsRoot = path.join(sourceDirectory, "sessions");
325
+ const files = await rolloutFiles(sessionsRoot);
325
326
  const cursors = {};
326
327
  if (!includeHistory) {
327
- for (const filename of await rolloutFiles(sessionsRoot)) {
328
+ for (const filename of files) {
328
329
  cursors[filename] = await lastCompleteRecordOffset(filename, (await fs.stat(filename)).size);
329
330
  }
330
331
  }
@@ -349,7 +350,7 @@ export async function ensureStartableState(config, paths) {
349
350
  await initializeState(config.codexHome, paths, true);
350
351
  return true;
351
352
  }
352
- export async function ensureHistoryBackfill(codexHome, paths) {
353
+ async function hasHistoryBackfillMarker(paths) {
353
354
  let marker;
354
355
  try {
355
356
  marker = await fs.readFile(paths.historyBackfill, "utf8");
@@ -363,16 +364,63 @@ export async function ensureHistoryBackfill(codexHome, paths) {
363
364
  if (marker !== '{"version":1}\n') {
364
365
  throw new Error("Collector history backfill marker has an invalid shape.");
365
366
  }
366
- return false;
367
+ return true;
367
368
  }
369
+ return false;
370
+ }
371
+ export async function ensureHistoryBackfill(codexHome, paths) {
372
+ if (await hasHistoryBackfillMarker(paths))
373
+ return false;
368
374
  const state = await loadState(paths);
369
375
  if (!state.initialized || state.sourceDirectory !== path.resolve(codexHome)) {
370
376
  throw new Error("Collector state does not match CODEX_HOME; reconnect Codex before uploading.");
371
377
  }
378
+ await rolloutFiles(path.join(codexHome, "sessions"));
372
379
  await saveState({ ...state, cursors: {}, skipping: [] }, paths);
373
380
  await writeJsonAtomic(paths.historyBackfill, { version: 1 });
374
381
  return true;
375
382
  }
383
+ export async function historyScanProgress(codexHome, paths) {
384
+ if (!(await hasHistoryBackfillMarker(paths)))
385
+ return { kind: "waiting" };
386
+ 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.");
389
+ }
390
+ const files = await rolloutFiles(path.join(codexHome, "sessions"));
391
+ let scannedBytes = 0;
392
+ let totalBytes = 0;
393
+ for (let start = 0; start < files.length; start += 64) {
394
+ const batch = files.slice(start, start + 64);
395
+ const sizes = await Promise.all(batch.map(async (filename) => {
396
+ try {
397
+ return (await fs.stat(filename)).size;
398
+ }
399
+ catch (error) {
400
+ if (error instanceof Error &&
401
+ "code" in error &&
402
+ error.code === "ENOENT")
403
+ return null;
404
+ throw error;
405
+ }
406
+ }));
407
+ for (const [index, size] of sizes.entries()) {
408
+ if (size === null)
409
+ continue;
410
+ const filename = batch[index];
411
+ if (filename === undefined)
412
+ continue;
413
+ totalBytes += size;
414
+ const cursor = state.cursors[filename] ?? 0;
415
+ scannedBytes += cursor <= size ? cursor : 0;
416
+ if (!Number.isSafeInteger(totalBytes) ||
417
+ !Number.isSafeInteger(scannedBytes)) {
418
+ throw new Error("Codex history size exceeds the supported limit.");
419
+ }
420
+ }
421
+ }
422
+ return { kind: "scanning", scannedBytes, totalBytes };
423
+ }
376
424
  export async function resetHistoryCursors(codexHome, paths) {
377
425
  const state = await loadState(paths);
378
426
  const sourceDirectory = path.resolve(codexHome);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",