iteration-cli 0.4.3 → 0.4.4

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 CHANGED
@@ -7,7 +7,7 @@ npm install --global iteration-cli
7
7
  iteration
8
8
  ```
9
9
 
10
- The Codex collector currently runs on macOS with Node.js 22 or later. Connecting imports existing local Codex sessions and collects new activity. Run `iteration status` to check uploads, or `iteration help` for other commands.
10
+ The Codex collector currently runs on macOS with Node.js 22 or later. Connecting imports existing local Codex sessions and collects new activity. Run `iteration` to see whether collection and uploads are healthy, `iteration status` for details, or `iteration help` for other commands.
11
11
 
12
12
  If you previously installed the Codex-specific package, remove it with the package manager you used:
13
13
 
package/dist/cli.js CHANGED
@@ -7,8 +7,9 @@ import * as z from "zod";
7
7
  import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
8
8
  import { importCodexHistory } from "./history-import.js";
9
9
  import { runCollector } from "./service.js";
10
+ import { renderDetailedStatus, renderHome, } from "./status-view.js";
10
11
  import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
11
- import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
12
+ import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
12
13
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
13
14
  function argumentsForCli() {
14
15
  const parsed = parseArgs({
@@ -66,34 +67,34 @@ function usage(command) {
66
67
  "Iteration CLI",
67
68
  "",
68
69
  "Get started:",
69
- " iteration Connect Codex, or show connection status",
70
+ " iteration Connect Codex, or see its current activity",
70
71
  "",
71
72
  "Commands:",
72
73
  " iteration connect codex Connect Codex directly",
73
- " iteration status Check uploads and connection",
74
+ " iteration status See scan, upload, and error details",
74
75
  " iteration disconnect codex Stop collecting",
75
76
  "",
76
77
  "Run iteration connect codex --help for advanced setup options.",
77
78
  ].join("\n") + "\n");
78
79
  }
79
- async function configured(paths) {
80
+ async function fileExists(filename) {
80
81
  try {
81
- await fs.access(paths.config);
82
+ await fs.access(filename);
82
83
  return true;
83
84
  }
84
85
  catch (error) {
85
86
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
86
87
  return false;
87
88
  }
88
- throw new Error(`Could not inspect collector config at ${paths.config}.`, {
89
+ throw new Error(`Could not inspect collector file at ${filename}.`, {
89
90
  cause: error,
90
91
  });
91
92
  }
92
93
  }
93
94
  async function defaultCommand() {
94
- const paths = collectorPaths(dataDirectory({}));
95
- if (await configured(paths)) {
96
- await status({});
95
+ const snapshot = await statusSnapshot({});
96
+ if (snapshot.kind !== "not_connected") {
97
+ process.stdout.write(`${renderHome(snapshot)}\n`);
97
98
  return;
98
99
  }
99
100
  if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
@@ -127,6 +128,7 @@ async function initialize(values) {
127
128
  token: enrollment.token,
128
129
  codexHome,
129
130
  dataDirectory: directory,
131
+ organizationName: enrollment.organizationName,
130
132
  };
131
133
  await saveConfig(config, paths);
132
134
  try {
@@ -141,7 +143,7 @@ async function initialize(values) {
141
143
  catch (error) {
142
144
  throw new Error("Collector access was approved, but background startup failed. Run iteration start codex to retry it.", { cause: error });
143
145
  }
144
- process.stdout.write(`Codex connected to ${enrollment.organizationName}. Existing local sessions and new activity will upload in the background. Run iteration status to check uploads.\n`);
146
+ process.stdout.write(`Codex is connected to ${enrollment.organizationName}. Existing sessions are importing and new activity is collected automatically. Run iteration to check progress.\n`);
145
147
  }
146
148
  async function importHistory(values) {
147
149
  if (process.platform !== "darwin") {
@@ -168,33 +170,46 @@ async function start(values) {
168
170
  const config = await loadConfig(paths);
169
171
  const repairedState = await ensureStartableState(config, paths);
170
172
  await installLaunchAgent(directory);
171
- process.stdout.write(`Collector started. Existing local sessions and new activity will upload in the background. Run iteration status to check uploads.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
173
+ process.stdout.write(`Codex is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
172
174
  }
173
- async function status(values) {
175
+ async function statusSnapshot(values) {
174
176
  const paths = collectorPaths(dataDirectory(values));
175
- if (!(await configured(paths))) {
176
- process.stdout.write("Installed: false\nRunning: false\nPending events: 0\n");
177
- return;
177
+ if (!(await fileExists(paths.config))) {
178
+ const [stateExists, pendingBytes] = await Promise.all([
179
+ fileExists(paths.state),
180
+ outboxSize(paths),
181
+ ]);
182
+ if (stateExists || pendingBytes > 0) {
183
+ return { kind: "needs_repair", pendingBytes, configPath: paths.config };
184
+ }
185
+ return { kind: "not_connected" };
178
186
  }
179
187
  const config = await loadConfig(paths);
180
- const running = await launchAgentStatus();
181
- const [size, ids, scan] = await Promise.all([
188
+ const [running, size, ids, scan, localHealth] = await Promise.all([
189
+ launchAgentStatus(),
182
190
  outboxSize(paths),
183
191
  pendingEventIds(paths),
184
192
  historyScanProgress(config.codexHome, paths),
193
+ healthSnapshot(paths),
185
194
  ]);
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`;
191
- process.stdout.write([
192
- "Installed: true",
193
- `Running: ${running}`,
194
- scanLabel,
195
- `Pending events: ${ids.size}`,
196
- `Pending bytes: ${size}`,
197
- ].join("\n") + "\n");
195
+ const pendingEvents = size === 0 ? 0 : ids.size;
196
+ const pendingBytes = ids.size === 0 ? 0 : size;
197
+ return {
198
+ kind: "connected",
199
+ workspaceName: config.organizationName ?? null,
200
+ running,
201
+ scan,
202
+ pendingEvents,
203
+ pendingBytes,
204
+ health: localHealth.health,
205
+ healthAvailable: localHealth.kind === "available",
206
+ tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
207
+ logPath: path.join(os.homedir(), "Library", "Logs", "iteration-codex-collector.error.log"),
208
+ };
209
+ }
210
+ async function status(values) {
211
+ const snapshot = await statusSnapshot(values);
212
+ process.stdout.write(`${renderDetailedStatus(snapshot)}\n`);
198
213
  }
199
214
  async function main() {
200
215
  const { command, source, values } = argumentsForCli();
@@ -1,7 +1,16 @@
1
1
  import { type CollectorConfig, type CollectorPaths } from "./storage.js";
2
- export declare function scanRolloutsOnce(config: CollectorConfig, paths: CollectorPaths, signal?: AbortSignal): Promise<number>;
3
- export declare function collectorTick(config: CollectorConfig, paths: CollectorPaths, fetcher?: typeof fetch, signal?: AbortSignal): Promise<Readonly<{
2
+ export declare class TraceUploadError extends Error {
3
+ readonly kind: "http" | "network" | "response";
4
+ readonly status: number | null;
5
+ constructor(kind: "http" | "network" | "response", status: number | null, message: string, options?: ErrorOptions);
6
+ }
7
+ export declare function scanRolloutsOnce(config: CollectorConfig, paths: CollectorPaths, signal?: AbortSignal): Promise<Readonly<{
8
+ discovered: number;
9
+ caughtUp: boolean;
10
+ }>>;
11
+ export declare function collectorTick(config: CollectorConfig, paths: CollectorPaths, fetcher?: typeof fetch, signal?: AbortSignal, onAcknowledged?: () => void): Promise<Readonly<{
4
12
  discovered: number;
5
13
  uploaded: number;
6
14
  outboxBytes: number;
15
+ historyCaughtUp: boolean;
7
16
  }>>;
package/dist/collector.js CHANGED
@@ -11,6 +11,16 @@ const MAX_EVENT_BYTES = MAX_UPLOAD_BYTES - 1024;
11
11
  const MAX_UPLOADS_PER_TICK = 5;
12
12
  const MAX_REQUEST_BYTES = 4 * 1024 * 1024;
13
13
  const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
14
+ export class TraceUploadError extends Error {
15
+ kind;
16
+ status;
17
+ constructor(kind, status, message, options) {
18
+ super(message, options);
19
+ this.name = "TraceUploadError";
20
+ this.kind = kind;
21
+ this.status = status;
22
+ }
23
+ }
14
24
  function logGap(filename, offset, reason) {
15
25
  console.error(JSON.stringify({
16
26
  level: "capture_gap",
@@ -32,10 +42,13 @@ export async function scanRolloutsOnce(config, paths, signal) {
32
42
  const queuedIds = await pendingEventIds(paths);
33
43
  const events = [];
34
44
  let remainingBudget = MAX_SCAN_BYTES_PER_TICK;
45
+ let caughtUp = true;
35
46
  for (const filename of files) {
36
47
  signal?.throwIfAborted();
37
- if (remainingBudget === 0)
48
+ if (remainingBudget === 0) {
49
+ caughtUp = false;
38
50
  break;
51
+ }
39
52
  const stats = await fs.stat(filename);
40
53
  let offset = cursors[filename] ?? 0;
41
54
  if (offset > stats.size) {
@@ -50,15 +63,19 @@ export async function scanRolloutsOnce(config, paths, signal) {
50
63
  }
51
64
  const handle = await fs.open(filename, "r");
52
65
  let buffer;
66
+ let readEnd = offset;
53
67
  try {
54
68
  buffer = Buffer.alloc(bytesToRead);
55
69
  const result = await handle.read(buffer, 0, bytesToRead, offset);
56
70
  buffer = buffer.subarray(0, result.bytesRead);
71
+ readEnd = offset + result.bytesRead;
57
72
  remainingBudget -= result.bytesRead;
58
73
  }
59
74
  finally {
60
75
  await handle.close();
61
76
  }
77
+ if (readEnd < stats.size)
78
+ caughtUp = false;
62
79
  if (skipping.has(filename)) {
63
80
  const newline = buffer.indexOf(10);
64
81
  if (newline < 0) {
@@ -120,9 +137,9 @@ export async function scanRolloutsOnce(config, paths, signal) {
120
137
  sourceDirectory: state.sourceDirectory,
121
138
  initialized: true,
122
139
  }, paths);
123
- return events.length;
140
+ return { discovered: events.length, caughtUp };
124
141
  }
125
- async function uploadPendingOnce(config, paths, fetcher = fetch, signal) {
142
+ async function uploadPendingOnce(config, paths, fetcher = fetch, signal, onAcknowledged) {
126
143
  let uploaded = 0;
127
144
  for (let attempt = 0; attempt < MAX_UPLOADS_PER_TICK; attempt += 1) {
128
145
  signal?.throwIfAborted();
@@ -133,41 +150,54 @@ async function uploadPendingOnce(config, paths, fetcher = fetch, signal) {
133
150
  if (Buffer.byteLength(body, "utf8") > MAX_REQUEST_BYTES) {
134
151
  throw new Error("Queued trace batch exceeds the upload request limit.");
135
152
  }
136
- const response = await fetcher(config.endpoint, {
137
- method: "POST",
138
- headers: {
139
- authorization: `Bearer ${config.token}`,
140
- "content-type": "application/json",
141
- },
142
- body,
143
- signal: signal
144
- ? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
145
- : AbortSignal.timeout(15_000),
146
- });
153
+ let response;
154
+ try {
155
+ response = await fetcher(config.endpoint, {
156
+ method: "POST",
157
+ headers: {
158
+ authorization: `Bearer ${config.token}`,
159
+ "content-type": "application/json",
160
+ },
161
+ body,
162
+ signal: signal
163
+ ? AbortSignal.any([signal, AbortSignal.timeout(15_000)])
164
+ : AbortSignal.timeout(15_000),
165
+ });
166
+ }
167
+ catch (error) {
168
+ if (signal?.aborted)
169
+ throw error;
170
+ throw new TraceUploadError("network", null, "Trace upload could not reach Iteration.", { cause: error });
171
+ }
147
172
  if (response.status !== 202) {
148
- throw new Error(`Trace upload failed with HTTP ${response.status}.`);
173
+ throw new TraceUploadError("http", response.status, `Trace upload failed with HTTP ${response.status}.`);
149
174
  }
150
175
  let acknowledgement;
151
176
  try {
152
177
  acknowledgement = await response.json();
153
178
  }
154
179
  catch (error) {
155
- throw new Error("Trace upload returned an unreadable acknowledgement.", {
156
- cause: error,
157
- });
180
+ throw new TraceUploadError("response", null, "Trace upload returned an unreadable acknowledgement.", { cause: error });
158
181
  }
159
182
  const expectedIds = batch.map((event) => event.eventId);
160
183
  const parsed = parseTraceEventAcknowledgement(acknowledgement, expectedIds);
161
- if (!parsed.success)
162
- throw new Error(parsed.error);
184
+ if (!parsed.success) {
185
+ throw new TraceUploadError("response", null, parsed.error);
186
+ }
163
187
  await acknowledgeEvents(new Set(parsed.value.acceptedEventIds), paths);
188
+ onAcknowledged?.();
164
189
  uploaded += batch.length;
165
190
  }
166
191
  return uploaded;
167
192
  }
168
- export async function collectorTick(config, paths, fetcher = fetch, signal) {
169
- let uploaded = await uploadPendingOnce(config, paths, fetcher, signal);
170
- const discovered = await scanRolloutsOnce(config, paths, signal);
171
- uploaded += await uploadPendingOnce(config, paths, fetcher, signal);
172
- return { discovered, uploaded, outboxBytes: await outboxSize(paths) };
193
+ export async function collectorTick(config, paths, fetcher = fetch, signal, onAcknowledged) {
194
+ let uploaded = await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
195
+ const scan = await scanRolloutsOnce(config, paths, signal);
196
+ uploaded += await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
197
+ return {
198
+ discovered: scan.discovered,
199
+ uploaded,
200
+ outboxBytes: await outboxSize(paths),
201
+ historyCaughtUp: scan.caughtUp,
202
+ };
173
203
  }
package/dist/service.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
- import { collectorTick } from "./collector.js";
5
- import { collectorPaths, ensureHistoryBackfill, ensurePrivateDirectory, loadConfig, } from "./storage.js";
4
+ import { collectorTick, TraceUploadError } from "./collector.js";
5
+ import { collectorPaths, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
6
6
  function errorCode(error) {
7
7
  if (error instanceof Error && "code" in error)
8
8
  return String(error.code);
@@ -10,6 +10,25 @@ function errorCode(error) {
10
10
  }
11
11
  class CollectorLockBusyError extends Error {
12
12
  }
13
+ function collectorFailure(error) {
14
+ return {
15
+ atMs: Date.now(),
16
+ kind: error instanceof TraceUploadError ? error.kind : "collector",
17
+ status: error instanceof TraceUploadError ? error.status : null,
18
+ };
19
+ }
20
+ async function persistHealth(health, paths) {
21
+ try {
22
+ await saveHealth(health, paths);
23
+ }
24
+ catch {
25
+ console.error(JSON.stringify({
26
+ level: "warning",
27
+ operation: "save_collector_health",
28
+ message: "Local collector status could not be saved; collection continues.",
29
+ }));
30
+ }
31
+ }
13
32
  export async function acquireCollectorLock(paths) {
14
33
  await ensurePrivateDirectory(path.dirname(paths.lock));
15
34
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -75,10 +94,33 @@ export async function runCollector(directory) {
75
94
  let retryDelayMs = 5000;
76
95
  try {
77
96
  const config = await loadConfig(paths);
97
+ const loadedHealth = await healthSnapshot(paths);
98
+ let health = loadedHealth.health;
99
+ if (loadedHealth.kind === "unavailable") {
100
+ console.error(JSON.stringify({
101
+ level: "warning",
102
+ operation: "load_collector_health",
103
+ message: "Local collector status could not be read; collection continues.",
104
+ }));
105
+ }
78
106
  await ensureHistoryBackfill(config.codexHome, paths);
79
107
  while (!controller.signal.aborted) {
108
+ let acknowledgedAtMs = null;
80
109
  try {
81
- const result = await collectorTick(config, paths, fetch, controller.signal);
110
+ const result = await collectorTick(config, paths, fetch, controller.signal, () => {
111
+ acknowledgedAtMs = Date.now();
112
+ });
113
+ if (acknowledgedAtMs !== null ||
114
+ health.lastFailure !== null ||
115
+ (result.historyCaughtUp && health.historyCaughtUpAtMs === null)) {
116
+ health = {
117
+ lastUploadAtMs: acknowledgedAtMs ?? health.lastUploadAtMs,
118
+ historyCaughtUpAtMs: health.historyCaughtUpAtMs ??
119
+ (result.historyCaughtUp ? Date.now() : null),
120
+ lastFailure: null,
121
+ };
122
+ await persistHealth(health, paths);
123
+ }
82
124
  if (result.discovered > 0 || result.uploaded > 0) {
83
125
  console.info(JSON.stringify({
84
126
  level: "info",
@@ -92,6 +134,11 @@ export async function runCollector(directory) {
92
134
  catch (error) {
93
135
  if (controller.signal.aborted)
94
136
  break;
137
+ health = {
138
+ lastUploadAtMs: acknowledgedAtMs ?? health.lastUploadAtMs,
139
+ historyCaughtUpAtMs: health.historyCaughtUpAtMs,
140
+ lastFailure: collectorFailure(error),
141
+ };
95
142
  console.error(JSON.stringify({
96
143
  level: "error",
97
144
  operation: "collector_tick",
@@ -100,6 +147,7 @@ export async function runCollector(directory) {
100
147
  ? error.message
101
148
  : "Unknown collector failure.",
102
149
  }));
150
+ await persistHealth(health, paths);
103
151
  retryDelayMs = Math.min(retryDelayMs * 2, 60_000);
104
152
  }
105
153
  try {
@@ -0,0 +1,29 @@
1
+ import type { CollectorHealth } from "./storage.js";
2
+ type ScanProgress = Readonly<{
3
+ kind: "waiting";
4
+ } | {
5
+ kind: "scanning";
6
+ scannedBytes: number;
7
+ totalBytes: number;
8
+ }>;
9
+ export type CliStatus = Readonly<{
10
+ kind: "not_connected";
11
+ }> | Readonly<{
12
+ kind: "needs_repair";
13
+ pendingBytes: number;
14
+ configPath: string;
15
+ }> | Readonly<{
16
+ kind: "connected";
17
+ workspaceName: string | null;
18
+ running: boolean;
19
+ scan: ScanProgress;
20
+ pendingEvents: number;
21
+ pendingBytes: number;
22
+ health: CollectorHealth;
23
+ healthAvailable: boolean;
24
+ tracesUrl: string;
25
+ logPath: string;
26
+ }>;
27
+ export declare function renderHome(status: CliStatus, nowMs?: number): string;
28
+ export declare function renderDetailedStatus(status: CliStatus, nowMs?: number): string;
29
+ export {};
@@ -0,0 +1,239 @@
1
+ function safeWorkspaceName(value) {
2
+ const cleaned = value
3
+ ?.split("")
4
+ .map((character) => {
5
+ const code = character.charCodeAt(0);
6
+ return code < 32 || (code >= 127 && code <= 159) ? " " : character;
7
+ })
8
+ .join("")
9
+ .replace(/\s+/g, " ")
10
+ .trim();
11
+ return cleaned === undefined || cleaned === "" ? null : cleaned;
12
+ }
13
+ function readableBytes(bytes) {
14
+ if (bytes < 1_000)
15
+ return `${bytes} B`;
16
+ const units = ["KB", "MB", "GB", "TB"];
17
+ let amount = bytes;
18
+ let index = -1;
19
+ do {
20
+ amount /= 1_000;
21
+ index += 1;
22
+ } while (amount >= 1_000 && index < units.length - 1);
23
+ const precision = amount < 10 ? 1 : 0;
24
+ return `${amount.toFixed(precision).replace(/\.0$/, "")} ${units[index]}`;
25
+ }
26
+ function relativeTime(atMs, nowMs) {
27
+ const elapsedSeconds = Math.max(0, Math.floor((nowMs - atMs) / 1_000));
28
+ if (elapsedSeconds < 5)
29
+ return "just now";
30
+ if (elapsedSeconds < 60)
31
+ return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ago`;
32
+ const elapsedMinutes = Math.floor(elapsedSeconds / 60);
33
+ if (elapsedMinutes < 60)
34
+ return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ago`;
35
+ const elapsedHours = Math.floor(elapsedMinutes / 60);
36
+ if (elapsedHours < 24)
37
+ return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ago`;
38
+ const elapsedDays = Math.floor(elapsedHours / 24);
39
+ return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`;
40
+ }
41
+ function pendingDescription(status) {
42
+ return `${status.pendingEvents} event${status.pendingEvents === 1 ? "" : "s"} (${readableBytes(status.pendingBytes)}) waiting on this Mac`;
43
+ }
44
+ function progressDescription(scan) {
45
+ if (scan.kind === "waiting")
46
+ return "Local history is preparing to scan.";
47
+ if (scan.totalBytes === 0)
48
+ return "No local Codex sessions found yet.";
49
+ if (scan.scannedBytes >= scan.totalBytes)
50
+ return `Local history scanned (${readableBytes(scan.totalBytes)}).`;
51
+ const remainingBytes = scan.totalBytes - scan.scannedBytes;
52
+ if (remainingBytes < 1_000_000)
53
+ return "Local scan is nearly caught up (less than 1 MB remains).";
54
+ const percentage = Math.min(99, Math.floor((scan.scannedBytes / scan.totalBytes) * 100));
55
+ return `About ${percentage}% of local history scanned (${readableBytes(scan.scannedBytes)} of ${readableBytes(scan.totalBytes)}).`;
56
+ }
57
+ function historyDescription(status) {
58
+ if (status.health.historyCaughtUpAtMs === null) {
59
+ return progressDescription(status.scan);
60
+ }
61
+ if (status.scan.kind === "waiting")
62
+ return "History is preparing to scan.";
63
+ const remainingBytes = status.scan.totalBytes - status.scan.scannedBytes;
64
+ if (remainingBytes <= 0)
65
+ return "History imported; local files are caught up.";
66
+ if (remainingBytes < 1_000_000)
67
+ return "History imported; scanning current activity.";
68
+ return `History imported; ${readableBytes(remainingBytes)} of new activity waiting to scan.`;
69
+ }
70
+ function failureDescription(status) {
71
+ const failure = status.health.lastFailure;
72
+ if (failure === null) {
73
+ throw new Error("A collector failure is required to describe it.");
74
+ }
75
+ if (failure.kind === "http") {
76
+ const code = failure.status === null ? "HTTP error" : `HTTP ${failure.status}`;
77
+ if (failure.status === 401 || failure.status === 403) {
78
+ return {
79
+ heading: "Codex upload access needs attention",
80
+ explanation: `Iteration denied this collector (${code}). Queued data stays on this Mac. Contact Iteration support to reconnect it safely.`,
81
+ };
82
+ }
83
+ if (failure.status !== null &&
84
+ failure.status < 500 &&
85
+ failure.status !== 429) {
86
+ return {
87
+ heading: "Codex uploads need attention",
88
+ explanation: `Iteration rejected an event batch (${code}). Check the collector log for details.`,
89
+ };
90
+ }
91
+ return {
92
+ heading: "Codex uploads are delayed",
93
+ explanation: `Iteration could not accept an upload (${code}). Retrying automatically.`,
94
+ };
95
+ }
96
+ if (failure.kind === "network") {
97
+ return {
98
+ heading: "Codex uploads are delayed",
99
+ explanation: "Could not reach Iteration. Retrying automatically.",
100
+ };
101
+ }
102
+ if (failure.kind === "response") {
103
+ return {
104
+ heading: "Codex uploads need attention",
105
+ explanation: "Iteration returned an unexpected upload response. Retrying automatically.",
106
+ };
107
+ }
108
+ return {
109
+ heading: "Codex collector needs attention",
110
+ explanation: "A local error interrupted collection. See iteration status codex for details.",
111
+ };
112
+ }
113
+ export function renderHome(status, nowMs = Date.now()) {
114
+ if (status.kind === "not_connected") {
115
+ return [
116
+ "Iteration",
117
+ "",
118
+ "Codex is not connected.",
119
+ "Run iteration connect codex to import your existing sessions.",
120
+ ].join("\n");
121
+ }
122
+ if (status.kind === "needs_repair") {
123
+ return [
124
+ "Iteration",
125
+ "",
126
+ "Codex connection needs repair",
127
+ "Local collector data exists, but its connection details are missing.",
128
+ status.pendingBytes > 0
129
+ ? `${readableBytes(status.pendingBytes)} of queued data remains on this Mac.`
130
+ : "Existing local progress remains on this Mac.",
131
+ "Run iteration status codex for recovery details.",
132
+ ].join("\n");
133
+ }
134
+ const workspaceName = safeWorkspaceName(status.workspaceName);
135
+ const lines = [
136
+ workspaceName === null ? "Iteration" : `Iteration · ${workspaceName}`,
137
+ "",
138
+ ];
139
+ if (!status.running) {
140
+ lines.push("Codex is stopped", "New sessions are not being collected.");
141
+ if (status.pendingEvents > 0)
142
+ lines.push(`${pendingDescription(status)}.`);
143
+ lines.push("Resume with iteration start codex.");
144
+ }
145
+ else if (!status.healthAvailable) {
146
+ lines.push("Codex is running; local status needs attention", "Upload history could not be read. Collection continues in the background.");
147
+ if (status.pendingEvents > 0)
148
+ lines.push(`${pendingDescription(status)}.`);
149
+ lines.push("Details: iteration status codex");
150
+ }
151
+ else if (status.health.lastFailure !== null) {
152
+ const failure = failureDescription(status);
153
+ lines.push(failure.heading, failure.explanation);
154
+ if (status.pendingEvents > 0)
155
+ lines.push(`${pendingDescription(status)}.`);
156
+ if (status.health.lastUploadAtMs !== null) {
157
+ lines.push(`Last successful upload ${relativeTime(status.health.lastUploadAtMs, nowMs)}.`);
158
+ }
159
+ lines.push("Details: iteration status codex");
160
+ }
161
+ else if (status.scan.kind === "waiting") {
162
+ lines.push("Codex is preparing your history", progressDescription(status.scan));
163
+ }
164
+ else if (status.scan.kind === "scanning" &&
165
+ status.scan.totalBytes > 0 &&
166
+ status.scan.scannedBytes < status.scan.totalBytes &&
167
+ status.health.historyCaughtUpAtMs === null) {
168
+ lines.push("Codex is importing existing sessions", historyDescription(status), "New activity is collected automatically.");
169
+ }
170
+ else if (status.scan.kind === "scanning" &&
171
+ status.scan.totalBytes - status.scan.scannedBytes >= 1_000_000) {
172
+ lines.push("Codex is catching up with new activity", historyDescription(status));
173
+ if (status.pendingEvents > 0)
174
+ lines.push(`${pendingDescription(status)}.`);
175
+ }
176
+ else if (status.pendingEvents > 0) {
177
+ lines.push("Codex is uploading captured sessions", `${pendingDescription(status)}.`);
178
+ }
179
+ else if (status.scan.totalBytes === 0) {
180
+ lines.push("Codex is connected", "Waiting for Codex sessions on this Mac.");
181
+ }
182
+ else if (status.scan.kind === "scanning" &&
183
+ status.scan.scannedBytes < status.scan.totalBytes) {
184
+ lines.push("Codex is collecting new activity", "Local history is imported; current activity is being scanned.");
185
+ }
186
+ else {
187
+ lines.push("Codex is collecting", "Local scan is caught up and no uploads are waiting.", "New activity is collected automatically.");
188
+ }
189
+ if (status.running &&
190
+ status.health.lastFailure === null &&
191
+ status.health.lastUploadAtMs !== null) {
192
+ lines.push(`Last successful upload ${relativeTime(status.health.lastUploadAtMs, nowMs)}.`);
193
+ }
194
+ lines.push("", `Explore traces ${status.tracesUrl}`);
195
+ return lines.join("\n");
196
+ }
197
+ export function renderDetailedStatus(status, nowMs = Date.now()) {
198
+ if (status.kind === "not_connected") {
199
+ return "Codex is not connected. Run iteration connect codex to connect.";
200
+ }
201
+ if (status.kind === "needs_repair") {
202
+ return [
203
+ "Codex connection needs repair",
204
+ "Local collector data exists without its connection details.",
205
+ `Queued data: ${readableBytes(status.pendingBytes)}`,
206
+ `Restore the missing config from backup: ${status.configPath}`,
207
+ "If you do not have it, contact Iteration support before reconnecting; existing data has been kept.",
208
+ ].join("\n");
209
+ }
210
+ const workspaceName = safeWorkspaceName(status.workspaceName);
211
+ const lines = [
212
+ "Codex collector",
213
+ ...(workspaceName === null ? [] : [`Workspace: ${workspaceName}`]),
214
+ `Background process: ${status.running ? "Running" : "Stopped"}`,
215
+ `History: ${historyDescription(status).replace(/^History /, "")}`,
216
+ `Uploads: ${status.pendingEvents === 0 ? "None waiting" : pendingDescription(status)}`,
217
+ ];
218
+ if (!status.healthAvailable) {
219
+ lines.push("Local status: unavailable; check collector file permissions", `Collector log: ${status.logPath}`);
220
+ }
221
+ if (status.health.lastUploadAtMs !== null) {
222
+ lines.push(`Last successful upload: ${relativeTime(status.health.lastUploadAtMs, nowMs)}`);
223
+ }
224
+ if (status.health.lastFailure !== null) {
225
+ const failure = status.health.lastFailure;
226
+ const label = failure.kind === "http" && failure.status !== null
227
+ ? `HTTP ${failure.status}`
228
+ : failure.kind === "network"
229
+ ? "Network unavailable"
230
+ : failure.kind === "response"
231
+ ? "Unexpected upload response"
232
+ : "Local collector error";
233
+ lines.push(`Last issue: ${label} (${relativeTime(failure.atMs, nowMs)})`, `Collector log: ${status.logPath}`);
234
+ }
235
+ if (!status.running)
236
+ lines.push("Resume: iteration start codex");
237
+ lines.push(`Explore traces: ${status.tracesUrl}`);
238
+ return lines.join("\n");
239
+ }
package/dist/storage.d.ts CHANGED
@@ -4,6 +4,7 @@ export type CollectorConfig = Readonly<{
4
4
  token: string;
5
5
  codexHome: string;
6
6
  dataDirectory: string;
7
+ organizationName?: string;
7
8
  }>;
8
9
  export type CollectorState = Readonly<{
9
10
  cursors: Readonly<Record<string, number>>;
@@ -11,18 +12,39 @@ export type CollectorState = Readonly<{
11
12
  sourceDirectory: string | null;
12
13
  initialized: boolean;
13
14
  }>;
15
+ export type CollectorFailure = Readonly<{
16
+ atMs: number;
17
+ kind: "http" | "network" | "response" | "collector";
18
+ status: number | null;
19
+ }>;
20
+ export type CollectorHealth = Readonly<{
21
+ lastUploadAtMs: number | null;
22
+ historyCaughtUpAtMs: number | null;
23
+ lastFailure: CollectorFailure | null;
24
+ }>;
25
+ export type HealthSnapshot = Readonly<{
26
+ kind: "available";
27
+ health: CollectorHealth;
28
+ } | {
29
+ kind: "unavailable";
30
+ health: CollectorHealth;
31
+ }>;
14
32
  export type CollectorPaths = Readonly<{
15
33
  config: string;
16
34
  state: string;
17
35
  outbox: string;
18
36
  lock: string;
19
37
  historyBackfill: string;
38
+ health: string;
20
39
  }>;
21
40
  export declare function validateCollectorEndpoint(endpoint: string): URL;
22
41
  export declare function collectorPaths(directory?: string): CollectorPaths;
23
42
  export declare function assertFreshCollectorDirectory(paths: CollectorPaths): Promise<void>;
24
43
  export declare function ensurePrivateDirectory(directory: string): Promise<void>;
25
44
  export declare function saveConfig(config: CollectorConfig, paths: CollectorPaths): Promise<void>;
45
+ export declare function loadHealth(paths: CollectorPaths): Promise<CollectorHealth>;
46
+ export declare function healthSnapshot(paths: CollectorPaths): Promise<HealthSnapshot>;
47
+ export declare function saveHealth(health: CollectorHealth, paths: CollectorPaths): Promise<void>;
26
48
  export declare function loadConfig(paths: CollectorPaths): Promise<CollectorConfig>;
27
49
  export declare function loadState(paths: CollectorPaths): Promise<CollectorState>;
28
50
  export declare function saveState(state: CollectorState, paths: CollectorPaths): Promise<void>;
package/dist/storage.js CHANGED
@@ -12,6 +12,7 @@ const configSchema = z
12
12
  token: z.string(),
13
13
  codexHome: z.string(),
14
14
  dataDirectory: z.string(),
15
+ organizationName: z.string().min(1).max(160).optional(),
15
16
  })
16
17
  .strict();
17
18
  const stateSchema = z
@@ -22,6 +23,20 @@ const stateSchema = z
22
23
  initialized: z.boolean(),
23
24
  })
24
25
  .strict();
26
+ const healthSchema = z
27
+ .object({
28
+ lastUploadAtMs: z.number().int().min(0).nullable(),
29
+ historyCaughtUpAtMs: z.number().int().min(0).nullable(),
30
+ lastFailure: z
31
+ .object({
32
+ atMs: z.number().int().min(0),
33
+ kind: z.enum(["http", "network", "response", "collector"]),
34
+ status: z.number().int().min(100).max(599).nullable(),
35
+ })
36
+ .strict()
37
+ .nullable(),
38
+ })
39
+ .strict();
25
40
  function parseOutboxEvent(line) {
26
41
  let decoded;
27
42
  try {
@@ -74,6 +89,7 @@ export function collectorPaths(directory = defaultDirectory()) {
74
89
  outbox: path.join(directory, "outbox.jsonl"),
75
90
  lock: path.join(directory, "collector.lock"),
76
91
  historyBackfill: path.join(directory, "history-backfill.json"),
92
+ health: path.join(directory, "health.json"),
77
93
  };
78
94
  }
79
95
  export async function assertFreshCollectorDirectory(paths) {
@@ -113,6 +129,57 @@ export async function saveConfig(config, paths) {
113
129
  await ensurePrivateDirectory(path.dirname(paths.config));
114
130
  await writeJsonAtomic(paths.config, config);
115
131
  }
132
+ export async function loadHealth(paths) {
133
+ let contents;
134
+ try {
135
+ contents = await fs.readFile(paths.health, "utf8");
136
+ }
137
+ catch (error) {
138
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
139
+ return {
140
+ lastUploadAtMs: null,
141
+ historyCaughtUpAtMs: null,
142
+ lastFailure: null,
143
+ };
144
+ }
145
+ throw new Error(`Could not read collector health at ${paths.health}.`, {
146
+ cause: error,
147
+ });
148
+ }
149
+ let decoded;
150
+ try {
151
+ decoded = JSON.parse(contents);
152
+ }
153
+ catch (error) {
154
+ throw new Error("Collector health is not valid JSON.", { cause: error });
155
+ }
156
+ const parsed = healthSchema.safeParse(decoded);
157
+ if (!parsed.success) {
158
+ throw new Error("Collector health has an invalid shape.", {
159
+ cause: parsed.error,
160
+ });
161
+ }
162
+ return parsed.data;
163
+ }
164
+ export async function healthSnapshot(paths) {
165
+ try {
166
+ return { kind: "available", health: await loadHealth(paths) };
167
+ }
168
+ catch {
169
+ return {
170
+ kind: "unavailable",
171
+ health: {
172
+ lastUploadAtMs: null,
173
+ historyCaughtUpAtMs: null,
174
+ lastFailure: null,
175
+ },
176
+ };
177
+ }
178
+ }
179
+ export async function saveHealth(health, paths) {
180
+ await ensurePrivateDirectory(path.dirname(paths.health));
181
+ await writeJsonAtomic(paths.health, health);
182
+ }
116
183
  export async function loadConfig(paths) {
117
184
  let contents;
118
185
  try {
@@ -135,12 +202,12 @@ export async function loadConfig(paths) {
135
202
  throw new Error("Collector config has an invalid shape.", {
136
203
  cause: parsed.error,
137
204
  });
138
- const { endpoint, token, codexHome, dataDirectory } = parsed.data;
205
+ const { endpoint, token, codexHome, dataDirectory, organizationName } = parsed.data;
139
206
  validateCollectorEndpoint(endpoint);
140
207
  if (token.length !== 43 || !/^[A-Za-z0-9_-]+$/.test(token)) {
141
208
  throw new Error("Collector token has an invalid format.");
142
209
  }
143
- return { endpoint, token, codexHome, dataDirectory };
210
+ return { endpoint, token, codexHome, dataDirectory, organizationName };
144
211
  }
145
212
  export async function loadState(paths) {
146
213
  let contents;
@@ -437,6 +504,11 @@ export async function resetHistoryCursors(codexHome, paths) {
437
504
  throw new Error("Codex history size exceeds the supported limit.");
438
505
  }
439
506
  }
507
+ const localHealth = await healthSnapshot(paths);
508
+ if (localHealth.kind === "unavailable" ||
509
+ localHealth.health.historyCaughtUpAtMs !== null) {
510
+ await saveHealth({ ...localHealth.health, historyCaughtUpAtMs: null }, paths);
511
+ }
440
512
  await saveState({
441
513
  cursors,
442
514
  skipping: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",