iteration-cli 0.4.2 → 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 +1 -1
- package/dist/cli.js +44 -29
- package/dist/collector.d.ts +11 -2
- package/dist/collector.js +55 -25
- package/dist/launch-agent.d.ts +7 -0
- package/dist/launch-agent.js +80 -10
- package/dist/service.js +51 -3
- package/dist/status-view.d.ts +29 -0
- package/dist/status-view.js +239 -0
- package/dist/storage.d.ts +22 -0
- package/dist/storage.js +74 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
80
|
+
async function fileExists(filename) {
|
|
80
81
|
try {
|
|
81
|
-
await fs.access(
|
|
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
|
|
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
|
|
95
|
-
if (
|
|
96
|
-
|
|
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
|
|
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(`
|
|
173
|
+
process.stdout.write(`Codex is collecting again. Run iteration to check progress.${repairedState ? " Incomplete setup was repaired." : ""}\n`);
|
|
172
174
|
}
|
|
173
|
-
async function
|
|
175
|
+
async function statusSnapshot(values) {
|
|
174
176
|
const paths = collectorPaths(dataDirectory(values));
|
|
175
|
-
if (!(await
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
181
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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();
|
package/dist/collector.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { type CollectorConfig, type CollectorPaths } from "./storage.js";
|
|
2
|
-
export declare
|
|
3
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
171
|
-
uploaded += await uploadPendingOnce(config, paths, fetcher, signal);
|
|
172
|
-
return {
|
|
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/launch-agent.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
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>;
|
package/dist/launch-agent.js
CHANGED
|
@@ -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("&", "&")
|
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
details.stderr?.includes("Could not find service"))
|
|
63
|
-
return;
|
|
72
|
+
if (serviceMissing(error))
|
|
73
|
+
return false;
|
|
64
74
|
throw error;
|
|
65
75
|
}
|
|
66
|
-
|
|
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,9 +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
|
|
82
|
-
|
|
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
|
+
});
|
|
83
153
|
return file;
|
|
84
154
|
}
|
|
85
155
|
export async function uninstallLaunchAgent() {
|
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: [],
|