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.
- package/README.md +21 -0
- package/dist/clerk-device-auth.d.ts +5 -0
- package/dist/clerk-device-auth.js +276 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +182 -0
- package/dist/collector.d.ts +7 -0
- package/dist/collector.js +173 -0
- package/dist/history-import.d.ts +17 -0
- package/dist/history-import.js +60 -0
- package/dist/launch-agent.d.ts +4 -0
- package/dist/launch-agent.js +102 -0
- package/dist/rollout.d.ts +11 -0
- package/dist/rollout.js +272 -0
- package/dist/service.d.ts +4 -0
- package/dist/service.js +119 -0
- package/dist/storage.d.ts +44 -0
- package/dist/storage.js +445 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Iteration CLI
|
|
2
|
+
|
|
3
|
+
Install one CLI for Iteration's supported coding-agent sources:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install --global iteration-cli
|
|
7
|
+
iteration connect codex
|
|
8
|
+
iteration status codex
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The Codex collector currently runs on macOS with Node.js 22 or later. `iteration connect codex --include-history` also imports earlier local sessions after you approve access. Run `iteration help` for the available commands.
|
|
12
|
+
|
|
13
|
+
If you previously installed the Codex-specific package, remove it with the package manager you used:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm remove --global iteration-codex-collector
|
|
17
|
+
# Or, if you installed it with npm:
|
|
18
|
+
npm uninstall --global iteration-codex-collector
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then install `iteration-cli` and run `iteration start codex`. This points the existing background collector at the new CLI. Its local configuration and pending events remain in place.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { parseCollectorEnrollmentResponse, } from "iteration-trace-contracts";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const serverAuthConfigSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
issuerUrl: z.url(),
|
|
10
|
+
clientId: z.string().min(1),
|
|
11
|
+
})
|
|
12
|
+
.strict();
|
|
13
|
+
const oauthMetadataSchema = z.object({
|
|
14
|
+
device_authorization_endpoint: z.url(),
|
|
15
|
+
token_endpoint: z.url(),
|
|
16
|
+
issuer: z.url().optional(),
|
|
17
|
+
});
|
|
18
|
+
const deviceCodeSchema = z.object({
|
|
19
|
+
device_code: z.string().min(1),
|
|
20
|
+
user_code: z.string().min(1),
|
|
21
|
+
verification_uri: z.url(),
|
|
22
|
+
verification_uri_complete: z.url().optional(),
|
|
23
|
+
expires_in: z.number().int().min(1).max(86_400),
|
|
24
|
+
interval: z.number().int().min(1).max(3_600),
|
|
25
|
+
});
|
|
26
|
+
const oauthTokenSchema = z.object({
|
|
27
|
+
access_token: z.string().min(1),
|
|
28
|
+
});
|
|
29
|
+
const oauthErrorSchema = z.object({
|
|
30
|
+
error: z.string().min(1),
|
|
31
|
+
error_description: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
function apiRoute(endpoint, relativePath) {
|
|
34
|
+
return new URL(relativePath, endpoint);
|
|
35
|
+
}
|
|
36
|
+
async function responseJson(response) {
|
|
37
|
+
try {
|
|
38
|
+
return await response.json();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw new Error("The authentication server returned invalid JSON.", {
|
|
42
|
+
cause: error,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function loadOAuthConfiguration(endpoint) {
|
|
47
|
+
const response = await fetch(apiRoute(endpoint, "./collector-auth/config"), {
|
|
48
|
+
signal: AbortSignal.timeout(10_000),
|
|
49
|
+
});
|
|
50
|
+
const body = await responseJson(response);
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(response.status === 503
|
|
53
|
+
? "Collector sign-in is not configured on the Iteration server."
|
|
54
|
+
: "Could not load collector sign-in configuration.");
|
|
55
|
+
}
|
|
56
|
+
const parsed = serverAuthConfigSchema.safeParse(body);
|
|
57
|
+
if (!parsed.success) {
|
|
58
|
+
throw new Error("Collector sign-in configuration has an invalid shape.");
|
|
59
|
+
}
|
|
60
|
+
const issuer = new URL(parsed.data.issuerUrl);
|
|
61
|
+
if (issuer.protocol !== "https:" ||
|
|
62
|
+
issuer.username !== "" ||
|
|
63
|
+
issuer.password !== "" ||
|
|
64
|
+
issuer.pathname !== "/" ||
|
|
65
|
+
issuer.search !== "" ||
|
|
66
|
+
issuer.hash !== "") {
|
|
67
|
+
throw new Error("The configured Clerk issuer URL is invalid.");
|
|
68
|
+
}
|
|
69
|
+
return { issuerUrl: issuer.origin, clientId: parsed.data.clientId };
|
|
70
|
+
}
|
|
71
|
+
async function discoverDeviceEndpoints(issuerUrl) {
|
|
72
|
+
const issuer = new URL(issuerUrl);
|
|
73
|
+
const response = await fetch(new URL(".well-known/oauth-authorization-server", issuer), { signal: AbortSignal.timeout(10_000) });
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
throw new Error("Clerk OAuth device authorization is unavailable.");
|
|
76
|
+
}
|
|
77
|
+
const parsed = oauthMetadataSchema.safeParse(await responseJson(response));
|
|
78
|
+
if (!parsed.success) {
|
|
79
|
+
throw new Error("Clerk OAuth metadata does not advertise device authorization.");
|
|
80
|
+
}
|
|
81
|
+
const deviceAuthorizationUrl = new URL(parsed.data.device_authorization_endpoint);
|
|
82
|
+
const tokenUrl = new URL(parsed.data.token_endpoint);
|
|
83
|
+
if (deviceAuthorizationUrl.protocol !== "https:" ||
|
|
84
|
+
tokenUrl.protocol !== "https:" ||
|
|
85
|
+
deviceAuthorizationUrl.origin !== issuer.origin ||
|
|
86
|
+
tokenUrl.origin !== issuer.origin) {
|
|
87
|
+
throw new Error("Clerk OAuth metadata contains an unexpected endpoint.");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
deviceAuthorizationUrl,
|
|
91
|
+
tokenUrl,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function openBrowser(url) {
|
|
95
|
+
try {
|
|
96
|
+
await execFileAsync("open", [url], { timeout: 5000 });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
process.stdout.write("Open the verification link in your browser.\n");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function delay(milliseconds) {
|
|
103
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
104
|
+
}
|
|
105
|
+
async function requestDeviceCode(url, clientId) {
|
|
106
|
+
const body = new URLSearchParams({
|
|
107
|
+
client_id: clientId,
|
|
108
|
+
scope: "openid profile email",
|
|
109
|
+
});
|
|
110
|
+
const response = await fetch(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
113
|
+
body,
|
|
114
|
+
signal: AbortSignal.timeout(10_000),
|
|
115
|
+
});
|
|
116
|
+
const payload = await responseJson(response);
|
|
117
|
+
const parsed = deviceCodeSchema.safeParse(payload);
|
|
118
|
+
if (!response.ok || !parsed.success) {
|
|
119
|
+
const providerError = oauthErrorSchema.safeParse(payload);
|
|
120
|
+
throw new Error(providerError.success
|
|
121
|
+
? "Clerk device sign-in could not start: " +
|
|
122
|
+
(providerError.data.error_description ?? providerError.data.error)
|
|
123
|
+
: "Clerk returned an invalid device authorization response.");
|
|
124
|
+
}
|
|
125
|
+
return parsed.data;
|
|
126
|
+
}
|
|
127
|
+
async function pollForAccessToken(tokenUrl, clientId, deviceCode, expiresIn, initialInterval) {
|
|
128
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
129
|
+
let interval = initialInterval;
|
|
130
|
+
while (Date.now() < expiresAt) {
|
|
131
|
+
await delay(interval * 1000);
|
|
132
|
+
if (Date.now() >= expiresAt)
|
|
133
|
+
break;
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await fetch(tokenUrl, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
139
|
+
body: new URLSearchParams({
|
|
140
|
+
client_id: clientId,
|
|
141
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
142
|
+
device_code: deviceCode,
|
|
143
|
+
}),
|
|
144
|
+
signal: AbortSignal.timeout(10_000),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const payload = await responseJson(response);
|
|
151
|
+
const token = oauthTokenSchema.safeParse(payload);
|
|
152
|
+
if (response.ok && token.success)
|
|
153
|
+
return token.data.access_token;
|
|
154
|
+
const providerError = oauthErrorSchema.safeParse(payload);
|
|
155
|
+
if (!providerError.success) {
|
|
156
|
+
throw new Error("Clerk returned an invalid device token response.");
|
|
157
|
+
}
|
|
158
|
+
switch (providerError.data.error) {
|
|
159
|
+
case "authorization_pending":
|
|
160
|
+
continue;
|
|
161
|
+
case "slow_down":
|
|
162
|
+
interval += 5;
|
|
163
|
+
continue;
|
|
164
|
+
case "access_denied":
|
|
165
|
+
throw new Error("You denied Iteration collector access.");
|
|
166
|
+
case "expired_token":
|
|
167
|
+
throw new Error("The sign-in code expired. Run init again.");
|
|
168
|
+
default:
|
|
169
|
+
throw new Error("Clerk device sign-in failed: " +
|
|
170
|
+
(providerError.data.error_description ?? providerError.data.error));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new Error("The sign-in code expired. Run init again.");
|
|
174
|
+
}
|
|
175
|
+
async function clerkDeviceSignIn(configuration) {
|
|
176
|
+
const endpoints = await discoverDeviceEndpoints(configuration.issuerUrl);
|
|
177
|
+
const challenge = await requestDeviceCode(endpoints.deviceAuthorizationUrl, configuration.clientId);
|
|
178
|
+
const verificationUrl = challenge.verification_uri_complete ?? challenge.verification_uri;
|
|
179
|
+
process.stdout.write("Sign in to Iteration to authorize this collector.\n" +
|
|
180
|
+
"Code: " +
|
|
181
|
+
challenge.user_code +
|
|
182
|
+
"\n" +
|
|
183
|
+
"Link: " +
|
|
184
|
+
verificationUrl +
|
|
185
|
+
"\n");
|
|
186
|
+
await openBrowser(verificationUrl);
|
|
187
|
+
return pollForAccessToken(endpoints.tokenUrl, configuration.clientId, challenge.device_code, challenge.expires_in, challenge.interval);
|
|
188
|
+
}
|
|
189
|
+
async function enrollDevice(endpoint, accessToken, deviceName, organizationId) {
|
|
190
|
+
const body = { deviceName };
|
|
191
|
+
if (organizationId !== null)
|
|
192
|
+
body.organizationId = organizationId;
|
|
193
|
+
const response = await fetch(apiRoute(endpoint, "./collector-auth/enroll"), {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: {
|
|
196
|
+
Authorization: "Bearer " + accessToken,
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify(body),
|
|
200
|
+
signal: AbortSignal.timeout(15_000),
|
|
201
|
+
});
|
|
202
|
+
const payload = await responseJson(response);
|
|
203
|
+
const parsed = parseCollectorEnrollmentResponse(payload);
|
|
204
|
+
if (response.ok && parsed.success)
|
|
205
|
+
return parsed.value;
|
|
206
|
+
const error = z.object({ error: z.string().min(1) }).safeParse(payload);
|
|
207
|
+
if (error.success) {
|
|
208
|
+
throw new Error(error.data.error);
|
|
209
|
+
}
|
|
210
|
+
throw new Error("The Iteration server returned an invalid enrollment response.");
|
|
211
|
+
}
|
|
212
|
+
async function chooseOrganization(organizations, hasMore) {
|
|
213
|
+
process.stdout.write("Choose an Iteration workspace for this collector:\n");
|
|
214
|
+
for (const [index, organization] of organizations.entries()) {
|
|
215
|
+
process.stdout.write(index + 1 + ". " + organization.name + " (" + organization.id + ")\n");
|
|
216
|
+
}
|
|
217
|
+
if (hasMore) {
|
|
218
|
+
process.stdout.write("More workspaces exist; enter an organization ID to choose one not listed.\n");
|
|
219
|
+
}
|
|
220
|
+
if (process.stdin.isTTY !== true) {
|
|
221
|
+
throw new Error("Run init interactively or pass --organization-id.");
|
|
222
|
+
}
|
|
223
|
+
const prompt = createInterface({
|
|
224
|
+
input: process.stdin,
|
|
225
|
+
output: process.stdout,
|
|
226
|
+
});
|
|
227
|
+
try {
|
|
228
|
+
const answer = await prompt.question("Workspace number or organization ID: ");
|
|
229
|
+
const id = z.uuid().safeParse(answer.trim());
|
|
230
|
+
if (id.success)
|
|
231
|
+
return id.data;
|
|
232
|
+
const selection = z.coerce
|
|
233
|
+
.number()
|
|
234
|
+
.int()
|
|
235
|
+
.min(1)
|
|
236
|
+
.max(organizations.length)
|
|
237
|
+
.safeParse(answer.trim());
|
|
238
|
+
if (!selection.success) {
|
|
239
|
+
throw new Error("Choose a listed number or enter a valid organization ID.");
|
|
240
|
+
}
|
|
241
|
+
const organization = organizations[selection.data - 1];
|
|
242
|
+
if (organization === undefined) {
|
|
243
|
+
throw new Error("The selected workspace is not available.");
|
|
244
|
+
}
|
|
245
|
+
return organization.id;
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
prompt.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export async function authenticateAndEnrollCollector(endpoint, deviceNameValue, organizationId) {
|
|
252
|
+
const deviceName = z
|
|
253
|
+
.string()
|
|
254
|
+
.trim()
|
|
255
|
+
.min(1)
|
|
256
|
+
.max(160)
|
|
257
|
+
.safeParse(deviceNameValue);
|
|
258
|
+
if (!deviceName.success) {
|
|
259
|
+
throw new Error("--device-name must contain 1 to 160 characters.");
|
|
260
|
+
}
|
|
261
|
+
const configuration = await loadOAuthConfiguration(endpoint);
|
|
262
|
+
const clerkAccessToken = await clerkDeviceSignIn(configuration);
|
|
263
|
+
let result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, organizationId);
|
|
264
|
+
if (result.status === "choose_organization") {
|
|
265
|
+
const selectedOrganizationId = await chooseOrganization(result.organizations, result.hasMore);
|
|
266
|
+
result = await enrollDevice(endpoint, clerkAccessToken, deviceName.data, selectedOrganizationId);
|
|
267
|
+
}
|
|
268
|
+
if (result.status !== "enrolled") {
|
|
269
|
+
throw new Error("The collector could not be assigned to that workspace.");
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
token: result.token,
|
|
273
|
+
deviceId: result.deviceId,
|
|
274
|
+
organizationName: result.organizationName,
|
|
275
|
+
};
|
|
276
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promises as fs } from "node:fs";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { authenticateAndEnrollCollector } from "./clerk-device-auth.js";
|
|
8
|
+
import { importCodexHistory } from "./history-import.js";
|
|
9
|
+
import { runCollector } from "./service.js";
|
|
10
|
+
import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
|
|
11
|
+
import { assertFreshCollectorDirectory, collectorPaths, ensureStartableState, loadConfig, outboxSize, pendingEventIds, prepareInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
|
|
12
|
+
const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
|
|
13
|
+
function argumentsForCli() {
|
|
14
|
+
const parsed = parseArgs({
|
|
15
|
+
args: process.argv.slice(2),
|
|
16
|
+
allowPositionals: true,
|
|
17
|
+
strict: true,
|
|
18
|
+
options: {
|
|
19
|
+
help: { type: "boolean" },
|
|
20
|
+
endpoint: { type: "string" },
|
|
21
|
+
"device-name": { type: "string" },
|
|
22
|
+
"organization-id": { type: "string" },
|
|
23
|
+
"codex-home": { type: "string" },
|
|
24
|
+
"data-directory": { type: "string" },
|
|
25
|
+
"include-history": { type: "boolean" },
|
|
26
|
+
yes: { type: "boolean" },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (parsed.positionals.length > 2) {
|
|
30
|
+
throw new Error("Too many command arguments. Run iteration help for usage.");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
command: parsed.positionals[0] ?? "help",
|
|
34
|
+
source: parsed.positionals[1],
|
|
35
|
+
values: parsed.values,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function optionalString(values, name) {
|
|
39
|
+
const value = values[name];
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
const parsed = z.string().min(1).safeParse(value);
|
|
43
|
+
if (!parsed.success) {
|
|
44
|
+
throw new Error(`--${name} must be a non-empty string.`);
|
|
45
|
+
}
|
|
46
|
+
return parsed.data;
|
|
47
|
+
}
|
|
48
|
+
function usage() {
|
|
49
|
+
process.stdout.write([
|
|
50
|
+
"Iteration CLI",
|
|
51
|
+
" iteration connect codex [--endpoint URL] [--device-name NAME] [--organization-id UUID] [--include-history] [--codex-home DIR] [--data-directory DIR]",
|
|
52
|
+
" iteration status codex [--data-directory DIR]",
|
|
53
|
+
" iteration import-history codex [--data-directory DIR] [--yes]",
|
|
54
|
+
" iteration start codex [--data-directory DIR]",
|
|
55
|
+
" iteration disconnect codex",
|
|
56
|
+
].join("\n") + "\n");
|
|
57
|
+
}
|
|
58
|
+
function dataDirectory(values) {
|
|
59
|
+
return path.resolve(optionalString(values, "data-directory") ??
|
|
60
|
+
path.join(os.homedir(), ".config", "iteration", "codex-collector"));
|
|
61
|
+
}
|
|
62
|
+
async function initialize(values) {
|
|
63
|
+
if (process.platform !== "darwin") {
|
|
64
|
+
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
65
|
+
}
|
|
66
|
+
const endpoint = optionalString(values, "endpoint") ?? DEFAULT_INGEST_ENDPOINT;
|
|
67
|
+
const endpointUrl = validateCollectorEndpoint(endpoint);
|
|
68
|
+
const deviceName = optionalString(values, "device-name") ?? os.hostname();
|
|
69
|
+
const organizationId = optionalString(values, "organization-id");
|
|
70
|
+
const directory = dataDirectory(values);
|
|
71
|
+
const paths = collectorPaths(directory);
|
|
72
|
+
const includeHistory = values["include-history"] === true;
|
|
73
|
+
const codexHome = path.resolve(optionalString(values, "codex-home") ??
|
|
74
|
+
process.env.CODEX_HOME ??
|
|
75
|
+
path.join(os.homedir(), ".codex"));
|
|
76
|
+
await assertFreshCollectorDirectory(paths);
|
|
77
|
+
const preparedState = await prepareInitialState(codexHome, includeHistory);
|
|
78
|
+
const enrollment = await authenticateAndEnrollCollector(endpointUrl, deviceName, organizationId);
|
|
79
|
+
const config = {
|
|
80
|
+
endpoint: endpointUrl.toString(),
|
|
81
|
+
token: enrollment.token,
|
|
82
|
+
codexHome,
|
|
83
|
+
dataDirectory: directory,
|
|
84
|
+
};
|
|
85
|
+
await saveConfig(config, paths);
|
|
86
|
+
try {
|
|
87
|
+
await saveState(preparedState, paths);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new Error("Collector access was approved, but local setup did not finish. Run iteration start codex to repair it.", { cause: error });
|
|
91
|
+
}
|
|
92
|
+
let agentFile;
|
|
93
|
+
try {
|
|
94
|
+
agentFile = await installLaunchAgent(directory);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new Error("Collector access was approved, but background startup failed. Run iteration start codex to retry it.", { cause: error });
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(`Collector installed for ${enrollment.organizationName} and set to start at login. ${includeHistory ? "Existing sessions will be imported." : "Existing sessions are skipped; new activity will be collected."}\nLaunch agent: ${agentFile}\n`);
|
|
100
|
+
}
|
|
101
|
+
async function importHistory(values) {
|
|
102
|
+
if (process.platform !== "darwin") {
|
|
103
|
+
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
104
|
+
}
|
|
105
|
+
const directory = dataDirectory(values);
|
|
106
|
+
const paths = collectorPaths(directory);
|
|
107
|
+
const config = await loadConfig(paths);
|
|
108
|
+
const estimate = (await importCodexHistory(config, paths, false)).size;
|
|
109
|
+
process.stdout.write(`Found ${estimate.files} local Codex session files (${estimate.bytes} bytes). History may include prompts, code, and tool output that workspace members can read.\n`);
|
|
110
|
+
if (values.yes !== true) {
|
|
111
|
+
process.stdout.write("No changes made. Run iteration import-history codex --yes to confirm this upload.\n");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = await importCodexHistory(config, paths, true);
|
|
115
|
+
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`);
|
|
116
|
+
}
|
|
117
|
+
async function start(values) {
|
|
118
|
+
if (process.platform !== "darwin") {
|
|
119
|
+
throw new Error("Automatic startup is currently supported on macOS only.");
|
|
120
|
+
}
|
|
121
|
+
const directory = dataDirectory(values);
|
|
122
|
+
const paths = collectorPaths(directory);
|
|
123
|
+
const config = await loadConfig(paths);
|
|
124
|
+
const repairedState = await ensureStartableState(config, paths);
|
|
125
|
+
await installLaunchAgent(directory);
|
|
126
|
+
process.stdout.write(`Collector started. Run iteration status codex to check uploads.${repairedState ? " Incomplete setup was repaired with new activity only; use iteration import-history codex if you want to include earlier local sessions." : ""}\n`);
|
|
127
|
+
}
|
|
128
|
+
async function status(values) {
|
|
129
|
+
const paths = collectorPaths(dataDirectory(values));
|
|
130
|
+
try {
|
|
131
|
+
await fs.access(paths.config);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
135
|
+
process.stdout.write("Installed: false\nRunning: false\nPending events: 0\n");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Could not inspect collector config at ${paths.config}.`, {
|
|
139
|
+
cause: error,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
await loadConfig(paths);
|
|
143
|
+
const running = await launchAgentStatus();
|
|
144
|
+
const [size, ids] = await Promise.all([
|
|
145
|
+
outboxSize(paths),
|
|
146
|
+
pendingEventIds(paths),
|
|
147
|
+
]);
|
|
148
|
+
process.stdout.write([
|
|
149
|
+
"Installed: true",
|
|
150
|
+
`Running: ${running}`,
|
|
151
|
+
`Pending events: ${ids.size}`,
|
|
152
|
+
`Pending bytes: ${size}`,
|
|
153
|
+
].join("\n") + "\n");
|
|
154
|
+
}
|
|
155
|
+
async function main() {
|
|
156
|
+
const { command, source, values } = argumentsForCli();
|
|
157
|
+
if (values.help === true || command === "help")
|
|
158
|
+
return usage();
|
|
159
|
+
if (command === "connect" && source === "codex")
|
|
160
|
+
return initialize(values);
|
|
161
|
+
if (command === "run") {
|
|
162
|
+
if (source !== undefined)
|
|
163
|
+
throw new Error("The run command takes no source.");
|
|
164
|
+
return runCollector(optionalString(values, "data-directory") ?? undefined);
|
|
165
|
+
}
|
|
166
|
+
if (command === "status" && source === "codex")
|
|
167
|
+
return status(values);
|
|
168
|
+
if (command === "import-history" && source === "codex")
|
|
169
|
+
return importHistory(values);
|
|
170
|
+
if (command === "start" && source === "codex")
|
|
171
|
+
return start(values);
|
|
172
|
+
if (command === "disconnect" && source === "codex") {
|
|
173
|
+
await uninstallLaunchAgent();
|
|
174
|
+
process.stdout.write("Collector startup removed. Local pending data was retained.\n");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`Unknown Iteration command: ${[command, source].filter(Boolean).join(" ")}. Run iteration help for usage.`);
|
|
178
|
+
}
|
|
179
|
+
main().catch((error) => {
|
|
180
|
+
process.stderr.write(`${error instanceof Error ? error.message : "Unknown collector error."}\n`);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
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<{
|
|
4
|
+
discovered: number;
|
|
5
|
+
uploaded: number;
|
|
6
|
+
outboxBytes: number;
|
|
7
|
+
}>>;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseTraceEventAcknowledgement, } from "iteration-trace-contracts";
|
|
4
|
+
import { parseRolloutLine } from "./rollout.js";
|
|
5
|
+
import { acknowledgeEvents, appendEvents, loadState, nextBatch, outboxSize, pendingEventIds, rolloutFiles, saveState, } from "./storage.js";
|
|
6
|
+
const MAX_SCAN_BYTES_PER_FILE = 16 * 1024 * 1024;
|
|
7
|
+
const MAX_SCAN_BYTES_PER_TICK = 16 * 1024 * 1024;
|
|
8
|
+
const MAX_RECORD_BYTES = 3 * 1024 * 1024;
|
|
9
|
+
const MAX_UPLOAD_BYTES = 3 * 1024 * 1024;
|
|
10
|
+
const MAX_EVENT_BYTES = MAX_UPLOAD_BYTES - 1024;
|
|
11
|
+
const MAX_UPLOADS_PER_TICK = 5;
|
|
12
|
+
const MAX_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
14
|
+
function logGap(filename, offset, reason) {
|
|
15
|
+
console.error(JSON.stringify({
|
|
16
|
+
level: "capture_gap",
|
|
17
|
+
filename,
|
|
18
|
+
offset,
|
|
19
|
+
reason,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
export async function scanRolloutsOnce(config, paths, signal) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
const state = await loadState(paths);
|
|
25
|
+
if (!state.initialized ||
|
|
26
|
+
state.sourceDirectory !== path.resolve(config.codexHome)) {
|
|
27
|
+
throw new Error("Collector state does not match CODEX_HOME; run iteration connect codex again.");
|
|
28
|
+
}
|
|
29
|
+
const cursors = { ...state.cursors };
|
|
30
|
+
const skipping = new Set(state.skipping);
|
|
31
|
+
const files = await rolloutFiles(path.join(config.codexHome, "sessions"));
|
|
32
|
+
const queuedIds = await pendingEventIds(paths);
|
|
33
|
+
const events = [];
|
|
34
|
+
let remainingBudget = MAX_SCAN_BYTES_PER_TICK;
|
|
35
|
+
for (const filename of files) {
|
|
36
|
+
signal?.throwIfAborted();
|
|
37
|
+
if (remainingBudget === 0)
|
|
38
|
+
break;
|
|
39
|
+
const stats = await fs.stat(filename);
|
|
40
|
+
let offset = cursors[filename] ?? 0;
|
|
41
|
+
if (offset > stats.size) {
|
|
42
|
+
logGap(filename, offset, "rollout_file_shrank_resetting_cursor");
|
|
43
|
+
offset = 0;
|
|
44
|
+
skipping.delete(filename);
|
|
45
|
+
}
|
|
46
|
+
const bytesToRead = Math.min(stats.size - offset, MAX_SCAN_BYTES_PER_FILE, remainingBudget);
|
|
47
|
+
if (bytesToRead === 0) {
|
|
48
|
+
cursors[filename] = offset;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const handle = await fs.open(filename, "r");
|
|
52
|
+
let buffer;
|
|
53
|
+
try {
|
|
54
|
+
buffer = Buffer.alloc(bytesToRead);
|
|
55
|
+
const result = await handle.read(buffer, 0, bytesToRead, offset);
|
|
56
|
+
buffer = buffer.subarray(0, result.bytesRead);
|
|
57
|
+
remainingBudget -= result.bytesRead;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
if (skipping.has(filename)) {
|
|
63
|
+
const newline = buffer.indexOf(10);
|
|
64
|
+
if (newline < 0) {
|
|
65
|
+
cursors[filename] = offset + buffer.length;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
offset += newline + 1;
|
|
69
|
+
buffer = buffer.subarray(newline + 1);
|
|
70
|
+
skipping.delete(filename);
|
|
71
|
+
}
|
|
72
|
+
let lineStart = 0;
|
|
73
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
74
|
+
if (buffer[index] !== 10)
|
|
75
|
+
continue;
|
|
76
|
+
let lineBytes = buffer.subarray(lineStart, index);
|
|
77
|
+
if (lineBytes.at(-1) === 13)
|
|
78
|
+
lineBytes = lineBytes.subarray(0, -1);
|
|
79
|
+
const byteOffset = offset + lineStart;
|
|
80
|
+
if (lineBytes.length > MAX_RECORD_BYTES) {
|
|
81
|
+
logGap(filename, byteOffset, "rollout_record_exceeds_scan_limit");
|
|
82
|
+
lineStart = index + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let line;
|
|
86
|
+
try {
|
|
87
|
+
line = UTF8_DECODER.decode(lineBytes);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
logGap(filename, byteOffset, "rollout_record_is_not_utf8");
|
|
91
|
+
lineStart = index + 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const parsed = parseRolloutLine(line, filename, byteOffset);
|
|
95
|
+
if (parsed.kind === "gap")
|
|
96
|
+
logGap(filename, byteOffset, parsed.reason);
|
|
97
|
+
if (parsed.kind === "event" && !queuedIds.has(parsed.event.eventId)) {
|
|
98
|
+
const eventBytes = Buffer.byteLength(JSON.stringify(parsed.event), "utf8");
|
|
99
|
+
if (eventBytes > MAX_EVENT_BYTES) {
|
|
100
|
+
logGap(filename, byteOffset, "normalized_event_exceeds_upload_limit");
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
events.push(parsed.event);
|
|
104
|
+
queuedIds.add(parsed.event.eventId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
lineStart = index + 1;
|
|
108
|
+
}
|
|
109
|
+
if (buffer.length - lineStart > MAX_RECORD_BYTES) {
|
|
110
|
+
logGap(filename, offset + lineStart, "incomplete_rollout_record_exceeds_scan_limit");
|
|
111
|
+
skipping.add(filename);
|
|
112
|
+
lineStart = buffer.length;
|
|
113
|
+
}
|
|
114
|
+
cursors[filename] = offset + lineStart;
|
|
115
|
+
}
|
|
116
|
+
await appendEvents(events, paths);
|
|
117
|
+
await saveState({
|
|
118
|
+
cursors,
|
|
119
|
+
skipping: [...skipping],
|
|
120
|
+
sourceDirectory: state.sourceDirectory,
|
|
121
|
+
initialized: true,
|
|
122
|
+
}, paths);
|
|
123
|
+
return events.length;
|
|
124
|
+
}
|
|
125
|
+
async function uploadPendingOnce(config, paths, fetcher = fetch, signal) {
|
|
126
|
+
let uploaded = 0;
|
|
127
|
+
for (let attempt = 0; attempt < MAX_UPLOADS_PER_TICK; attempt += 1) {
|
|
128
|
+
signal?.throwIfAborted();
|
|
129
|
+
const batch = await nextBatch(paths, MAX_UPLOAD_BYTES);
|
|
130
|
+
if (batch.length === 0)
|
|
131
|
+
return uploaded;
|
|
132
|
+
const body = JSON.stringify({ events: batch });
|
|
133
|
+
if (Buffer.byteLength(body, "utf8") > MAX_REQUEST_BYTES) {
|
|
134
|
+
throw new Error("Queued trace batch exceeds the upload request limit.");
|
|
135
|
+
}
|
|
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
|
+
});
|
|
147
|
+
if (response.status !== 202) {
|
|
148
|
+
throw new Error(`Trace upload failed with HTTP ${response.status}.`);
|
|
149
|
+
}
|
|
150
|
+
let acknowledgement;
|
|
151
|
+
try {
|
|
152
|
+
acknowledgement = await response.json();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
throw new Error("Trace upload returned an unreadable acknowledgement.", {
|
|
156
|
+
cause: error,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const expectedIds = batch.map((event) => event.eventId);
|
|
160
|
+
const parsed = parseTraceEventAcknowledgement(acknowledgement, expectedIds);
|
|
161
|
+
if (!parsed.success)
|
|
162
|
+
throw new Error(parsed.error);
|
|
163
|
+
await acknowledgeEvents(new Set(parsed.value.acceptedEventIds), paths);
|
|
164
|
+
uploaded += batch.length;
|
|
165
|
+
}
|
|
166
|
+
return uploaded;
|
|
167
|
+
}
|
|
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) };
|
|
173
|
+
}
|