iteration-cli 0.7.0 → 0.7.1

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.
@@ -1,4 +1,5 @@
1
1
  import { type CollectorSource } from "iteration-trace-contracts";
2
+ import { type CollectorFetcher } from "./collector-fetch.js";
2
3
  import { type CollectorConfig, type CollectorState } from "./storage.js";
3
4
  export type MachineConsent = Readonly<{
4
5
  version: 1;
@@ -24,7 +25,7 @@ export declare function readCollectorScope(anchor: CollectorConfig): Promise<Rea
24
25
  organizationId: string;
25
26
  organizationName: string;
26
27
  }>>;
27
- export declare function expandSource(anchor: CollectorConfig, source: CollectorSource, organizationId: string | null, request?: typeof fetch): Promise<SourceEnrollment>;
28
+ export declare function expandSource(anchor: CollectorConfig, source: CollectorSource, organizationId: string | null, request?: CollectorFetcher): Promise<SourceEnrollment>;
28
29
  export declare function activateSource(source: CollectorSource, home: string, endpoint: string, enrollment: SourceEnrollment, state: CollectorState): Promise<CollectorConfig>;
29
30
  export declare function connectNewDetectedSources(anchor: CollectorConfig, consent: MachineConsent): Promise<CollectorSource[]>;
30
31
  export declare function connectedConfig(source: CollectorSource): Promise<CollectorConfig | null>;
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import * as z from "zod";
6
6
  import { COLLECTOR_SOURCES, parseCollectorEnrollmentResponse, } from "iteration-trace-contracts";
7
7
  import { installBackgroundService } from "./background-service.js";
8
+ import { collectorFetch } from "./collector-fetch.js";
8
9
  import { installTurnSummary } from "./turn-summary.js";
9
10
  import { assertFreshCollectorDirectory, collectorPaths, defaultCollectorDirectory, ensurePrivateDirectory, loadConfig, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
10
11
  const consentSchema = z
@@ -92,7 +93,7 @@ export async function saveMachineConsent(consent) {
92
93
  }
93
94
  export async function readCollectorScope(anchor) {
94
95
  const endpoint = validateCollectorEndpoint(anchor.endpoint);
95
- const response = await fetch(new URL("./collector-auth/expand", endpoint), {
96
+ const response = await collectorFetch(new URL("./collector-auth/expand", endpoint), {
96
97
  headers: { Authorization: `Bearer ${anchor.token}` },
97
98
  signal: AbortSignal.timeout(10_000),
98
99
  });
@@ -109,7 +110,7 @@ export async function readCollectorScope(anchor) {
109
110
  throw new Error("Iteration returned an invalid collector workspace.");
110
111
  return parsed.data;
111
112
  }
112
- export async function expandSource(anchor, source, organizationId, request = fetch) {
113
+ export async function expandSource(anchor, source, organizationId, request = collectorFetch) {
113
114
  const endpoint = validateCollectorEndpoint(anchor.endpoint);
114
115
  const token = createHmac("sha256", anchor.token)
115
116
  .update(`iteration-source:${source}`, "utf8")
@@ -0,0 +1,9 @@
1
+ type CollectorRequest = Readonly<{
2
+ method?: "GET" | "POST";
3
+ headers?: Readonly<Record<string, string>>;
4
+ body?: string;
5
+ signal?: AbortSignal;
6
+ }>;
7
+ export type CollectorFetcher = (url: string | URL, init?: CollectorRequest) => Promise<Response>;
8
+ export declare const collectorFetch: CollectorFetcher;
9
+ export {};
@@ -0,0 +1,58 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ const MAX_RESPONSE_BYTES = 1024 * 1024;
4
+ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
5
+ // Node's fetch can negotiate HTTP/2 and, after a timed-out request breaks the
6
+ // TLS connection, keep handing every later request the destroyed session until
7
+ // the process restarts. The collector runs for days, so it uses HTTP/1.1
8
+ // connections that are discarded when they fail.
9
+ const agents = {
10
+ "http:": new http.Agent({ keepAlive: true, maxSockets: 2 }),
11
+ "https:": new https.Agent({ keepAlive: true, maxSockets: 2 }),
12
+ };
13
+ export const collectorFetch = (input, init = {}) => {
14
+ const url = new URL(input);
15
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
16
+ throw new TypeError(`Unsupported collector request protocol ${url.protocol}`);
17
+ }
18
+ const body = init.body ?? null;
19
+ const headers = { ...init.headers };
20
+ if (body !== null) {
21
+ headers["content-length"] = String(Buffer.byteLength(body, "utf8"));
22
+ }
23
+ const transport = url.protocol === "https:" ? https : http;
24
+ return new Promise((resolve, reject) => {
25
+ const request = transport.request(url, {
26
+ method: init.method ?? "GET",
27
+ headers,
28
+ agent: agents[url.protocol],
29
+ signal: init.signal,
30
+ }, (response) => {
31
+ const chunks = [];
32
+ let size = 0;
33
+ response.on("data", (chunk) => {
34
+ size += chunk.byteLength;
35
+ if (size > MAX_RESPONSE_BYTES) {
36
+ response.destroy(new Error("Iteration returned an oversized response."));
37
+ return;
38
+ }
39
+ chunks.push(chunk);
40
+ });
41
+ response.on("error", reject);
42
+ response.on("end", () => {
43
+ const status = response.statusCode ?? 0;
44
+ const responseHeaders = new Headers();
45
+ for (const [name, value] of Object.entries(response.headers)) {
46
+ if (value === undefined)
47
+ continue;
48
+ for (const item of Array.isArray(value) ? value : [value]) {
49
+ responseHeaders.append(name, item);
50
+ }
51
+ }
52
+ resolve(new Response(NULL_BODY_STATUSES.has(status) ? null : Buffer.concat(chunks), { status, headers: responseHeaders }));
53
+ });
54
+ });
55
+ request.on("error", reject);
56
+ request.end(body ?? undefined);
57
+ });
58
+ };
@@ -1,3 +1,4 @@
1
+ import { type CollectorFetcher } from "./collector-fetch.js";
1
2
  import { type CollectorConfig, type CollectorPaths } from "./storage.js";
2
3
  export declare class TraceUploadError extends Error {
3
4
  readonly kind: "http" | "network" | "response";
@@ -8,7 +9,7 @@ export declare function scanSourceOnce(config: CollectorConfig, paths: Collector
8
9
  discovered: number;
9
10
  caughtUp: boolean;
10
11
  }>>;
11
- export declare function collectorTick(config: CollectorConfig, paths: CollectorPaths, fetcher?: typeof fetch, signal?: AbortSignal, onAcknowledged?: () => void): Promise<Readonly<{
12
+ export declare function collectorTick(config: CollectorConfig, paths: CollectorPaths, fetcher?: CollectorFetcher, signal?: AbortSignal, onAcknowledged?: () => void): Promise<Readonly<{
12
13
  discovered: number;
13
14
  uploaded: number;
14
15
  outboxBytes: number;
package/dist/collector.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseTraceEventAcknowledgement, } from "iteration-trace-contracts";
4
+ import { collectorFetch } from "./collector-fetch.js";
4
5
  import { parseClaudeLine } from "./claude-transcript.js";
5
6
  import { parseRolloutLine } from "./rollout.js";
6
7
  import { acknowledgeEvents, appendEvents, collectorSource, collectorSourceDirectory, loadState, nextBatch, outboxSize, pendingEventIds, sourceFiles, saveState, } from "./storage.js";
@@ -11,6 +12,9 @@ const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
11
12
  const MAX_EVENT_BYTES = MAX_UPLOAD_BYTES - 1024;
12
13
  const MAX_UPLOADS_PER_TICK = 5;
13
14
  const MAX_REQUEST_BYTES = 4 * 1024 * 1024;
15
+ // A tick can queue up to MAX_SCAN_BYTES_PER_TICK of new events, so scanning
16
+ // pauses well below the outbox capacity and resumes once uploads drain it.
17
+ const SCAN_PAUSE_OUTBOX_BYTES = 32 * 1024 * 1024;
14
18
  const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
15
19
  export class TraceUploadError extends Error {
16
20
  kind;
@@ -157,7 +161,7 @@ export async function scanSourceOnce(config, paths, signal) {
157
161
  }, paths);
158
162
  return { discovered: events.length, caughtUp };
159
163
  }
160
- async function uploadPendingOnce(config, paths, fetcher = fetch, signal, onAcknowledged) {
164
+ async function uploadPendingOnce(config, paths, fetcher = collectorFetch, signal, onAcknowledged) {
161
165
  let uploaded = 0;
162
166
  for (let attempt = 0; attempt < MAX_UPLOADS_PER_TICK; attempt += 1) {
163
167
  signal?.throwIfAborted();
@@ -208,9 +212,11 @@ async function uploadPendingOnce(config, paths, fetcher = fetch, signal, onAckno
208
212
  }
209
213
  return uploaded;
210
214
  }
211
- export async function collectorTick(config, paths, fetcher = fetch, signal, onAcknowledged) {
215
+ export async function collectorTick(config, paths, fetcher = collectorFetch, signal, onAcknowledged) {
212
216
  let uploaded = await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
213
- const scan = await scanSourceOnce(config, paths, signal);
217
+ const scan = (await outboxSize(paths)) >= SCAN_PAUSE_OUTBOX_BYTES
218
+ ? { discovered: 0, caughtUp: false }
219
+ : await scanSourceOnce(config, paths, signal);
214
220
  uploaded += await uploadPendingOnce(config, paths, fetcher, signal, onAcknowledged);
215
221
  return {
216
222
  discovered: scan.discovered,
package/dist/service.js CHANGED
@@ -2,6 +2,7 @@ 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
4
  import { connectNewDetectedSources, loadMachineConsent, } from "./auto-connect.js";
5
+ import { collectorFetch } from "./collector-fetch.js";
5
6
  import { collectorTick, TraceUploadError } from "./collector.js";
6
7
  import { refreshTurnSummaries } from "./turn-summary.js";
7
8
  import { collectorSource, collectorPaths, ensureClaudeHistoryBackfill, ensureHistoryBackfill, ensurePrivateDirectory, healthSnapshot, loadConfig, saveHealth, } from "./storage.js";
@@ -12,6 +13,21 @@ function errorCode(error) {
12
13
  }
13
14
  class CollectorLockBusyError extends Error {
14
15
  }
16
+ // Fetch reports network failures as a generic TypeError; the actionable
17
+ // reason (DNS, TLS, reset, timeout) is only in its cause chain.
18
+ function errorCauses(error) {
19
+ const causes = [];
20
+ let current = error instanceof Error ? error.cause : undefined;
21
+ while (current instanceof Error && causes.length < 4) {
22
+ causes.push({
23
+ name: current.name,
24
+ code: errorCode(current),
25
+ message: current.message.slice(0, 300),
26
+ });
27
+ current = current.cause;
28
+ }
29
+ return causes;
30
+ }
15
31
  function collectorFailure(error) {
16
32
  return {
17
33
  atMs: Date.now(),
@@ -143,7 +159,7 @@ export async function runCollector(directory, expectedSource) {
143
159
  }
144
160
  let acknowledgedAtMs = null;
145
161
  try {
146
- const result = await collectorTick(config, paths, fetch, controller.signal, () => {
162
+ const result = await collectorTick(config, paths, collectorFetch, controller.signal, () => {
147
163
  acknowledgedAtMs = Date.now();
148
164
  });
149
165
  if (acknowledgedAtMs !== null ||
@@ -160,6 +176,7 @@ export async function runCollector(directory, expectedSource) {
160
176
  if (result.discovered > 0 || result.uploaded > 0) {
161
177
  console.info(JSON.stringify({
162
178
  level: "info",
179
+ at: new Date().toISOString(),
163
180
  source,
164
181
  discovered: result.discovered,
165
182
  uploaded: result.uploaded,
@@ -178,9 +195,11 @@ export async function runCollector(directory, expectedSource) {
178
195
  };
179
196
  console.error(JSON.stringify({
180
197
  level: "error",
198
+ at: new Date().toISOString(),
181
199
  source,
182
200
  operation: "collector_tick",
183
201
  errorName: error instanceof Error ? error.name : "UnknownError",
202
+ causes: errorCauses(error),
184
203
  message: error instanceof Error
185
204
  ? error.message
186
205
  : "Unknown collector failure.",
@@ -189,16 +208,18 @@ export async function runCollector(directory, expectedSource) {
189
208
  retryDelayMs = Math.min(retryDelayMs * 2, 60_000);
190
209
  }
191
210
  try {
192
- await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), fetch, controller.signal);
211
+ await refreshTurnSummaries(config, summariesRefreshedAt, Date.now(), collectorFetch, controller.signal);
193
212
  }
194
213
  catch (error) {
195
214
  if (controller.signal.aborted)
196
215
  break;
197
216
  console.error(JSON.stringify({
198
217
  level: "warning",
218
+ at: new Date().toISOString(),
199
219
  source,
200
220
  operation: "refresh_turn_summaries",
201
221
  errorName: error instanceof Error ? error.name : "UnknownError",
222
+ causes: errorCauses(error),
202
223
  message: error instanceof Error
203
224
  ? error.message
204
225
  : "Unknown turn summary failure.",
@@ -1,4 +1,5 @@
1
1
  import { type CollectorFrictionSummary } from "iteration-trace-contracts";
2
+ import { type CollectorFetcher } from "./collector-fetch.js";
2
3
  import { type CollectorConfig } from "./storage.js";
3
4
  export type TurnSummaryPaths = Readonly<{
4
5
  directory: string;
@@ -17,6 +18,6 @@ export declare class TurnSummaryRefreshError extends Error {
17
18
  export declare function refreshTurnSummaries(config: CollectorConfig, refreshedAt: Map<string, Readonly<{
18
19
  directory: string;
19
20
  atMs: number;
20
- }>>, nowMs: number, fetcher?: typeof fetch, signal?: AbortSignal): Promise<number>;
21
+ }>>, nowMs: number, fetcher?: CollectorFetcher, signal?: AbortSignal): Promise<number>;
21
22
  export type TurnSummaryApproval = "approved" | "needs_approval" | "not_installed";
22
23
  export declare function codexTurnSummaryApproval(config: CollectorConfig): Promise<TurnSummaryApproval>;
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { isLaunchDirectory, parseCollectorFrictionSummary, } from "iteration-trace-contracts";
5
5
  import * as z from "zod";
6
+ import { collectorFetch } from "./collector-fetch.js";
6
7
  import { ensurePrivateDirectory } from "./storage.js";
7
8
  const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
8
9
  const REQUEST_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
@@ -249,7 +250,7 @@ async function forget(paths, key) {
249
250
  // Refreshes the summary line for each repository the hook asked about. The
250
251
  // hook reads the result on a later turn, so a slow or failed request never
251
252
  // delays the agent.
252
- export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher = fetch, signal) {
253
+ export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher = collectorFetch, signal) {
253
254
  const paths = turnSummaryPaths(config.dataDirectory);
254
255
  let keys;
255
256
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",