@neta-art/cohub-cli 8.1.1 → 8.2.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/dist/commands/runtime.js +34 -9
- package/dist/runtime/archive-store.d.ts +4 -0
- package/dist/runtime/archive-store.js +10 -6
- package/dist/runtime/native-archive.d.ts +4 -0
- package/dist/runtime/native-archive.js +8 -0
- package/dist/runtime/native-sync-store.d.ts +14 -0
- package/dist/runtime/native-sync-store.js +151 -24
- package/dist/runtime/native-sync.d.ts +15 -0
- package/dist/runtime/native-sync.js +126 -21
- package/dist/runtime/native-transcript.d.ts +31 -3
- package/dist/runtime/native-transcript.js +289 -31
- package/dist/runtime/session-store.js +24 -10
- package/dist/runtime/supervisor.js +35 -11
- package/package.json +1 -1
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { createClient } from "../client.js";
|
|
5
5
|
import { currentIdentityKey } from "../space.js";
|
|
6
6
|
import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
|
|
7
|
-
import { readNativeTranscript, readNativeTranscriptHeader } from "./native-transcript.js";
|
|
7
|
+
import { readNativeTranscript, readNativeTranscriptHeader, codexSessionsDirectory, codexArchivedSessionsDirectory, codexRolloutIdOf } from "./native-transcript.js";
|
|
8
8
|
import { findRuntimeNativeSession } from "./session-store.js";
|
|
9
9
|
import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
|
|
10
|
+
import { serializeDiagnosticError } from "./diagnostics.js";
|
|
10
11
|
export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
|
|
11
12
|
export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
|
|
12
13
|
const piSessionDirectory = (cwd) => {
|
|
@@ -17,10 +18,11 @@ const piSessionDirectory = (cwd) => {
|
|
|
17
18
|
const safePath = `--${cwd.replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`;
|
|
18
19
|
return join(agentDir, "sessions", safePath);
|
|
19
20
|
};
|
|
20
|
-
const codexSessionDirectory = () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
|
|
21
21
|
const LARGE_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
22
22
|
async function nativeTranscriptPaths(root, harness, signal) {
|
|
23
|
-
|
|
23
|
+
// Codex keeps rollouts active or archived, plain or `.zst`-compressed; Pi writes plain JSONL.
|
|
24
|
+
const pending = harness === "pi" ? [piSessionDirectory(root)] : [codexSessionsDirectory(), codexArchivedSessionsDirectory()];
|
|
25
|
+
const accepts = harness === "pi" ? (name) => name.endsWith(".jsonl") : (name) => name.endsWith(".jsonl") || name.endsWith(".jsonl.zst");
|
|
24
26
|
const paths = [];
|
|
25
27
|
const errors = [];
|
|
26
28
|
while (pending.length) {
|
|
@@ -38,7 +40,7 @@ async function nativeTranscriptPaths(root, harness, signal) {
|
|
|
38
40
|
const path = join(directory, entry.name);
|
|
39
41
|
if (entry.isDirectory())
|
|
40
42
|
pending.push(path);
|
|
41
|
-
else if (entry.isFile() && entry.name
|
|
43
|
+
else if (entry.isFile() && accepts(entry.name))
|
|
42
44
|
paths.push(path);
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -48,10 +50,68 @@ async function nativeTranscriptPaths(root, harness, signal) {
|
|
|
48
50
|
export async function discoverNativeImportCandidates(root, harnesses, options = {}) {
|
|
49
51
|
const candidates = [];
|
|
50
52
|
const errors = [];
|
|
53
|
+
/** Same-thread rollouts left out of import; they may carry Turns no candidate contains. */
|
|
54
|
+
const superseded = [];
|
|
51
55
|
for (const harness of harnesses) {
|
|
52
56
|
options.signal?.throwIfAborted();
|
|
53
57
|
const scanned = await nativeTranscriptPaths(root, harness, options.signal);
|
|
54
58
|
errors.push(...scanned.errors);
|
|
59
|
+
// One rollout chain per thread: every rollout referenced by a same-thread file is
|
|
60
|
+
// superseded by it (rollover / revert); a parent referenced only from a different thread
|
|
61
|
+
// (fork) still owns its own Turns and stays importable. Headers are read once and reused.
|
|
62
|
+
const headers = new Map();
|
|
63
|
+
const absorbedRollouts = new Set();
|
|
64
|
+
if (harness === "codex") {
|
|
65
|
+
// Bounded concurrency: one descriptor per open file across the global Codex sessions tree.
|
|
66
|
+
let next = 0;
|
|
67
|
+
const readHeaders = async () => {
|
|
68
|
+
for (;;) {
|
|
69
|
+
const path = scanned.paths[next++];
|
|
70
|
+
if (!path)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
headers.set(path, await readNativeTranscriptHeader(path, harness, options.signal));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (options.signal?.aborted)
|
|
77
|
+
throw error;
|
|
78
|
+
errors.push({ harness, path, message: error instanceof Error ? error.message : String(error) });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await Promise.all(Array.from({ length: Math.min(16, scanned.paths.length) }, readHeaders));
|
|
83
|
+
const threadByRollout = new Map();
|
|
84
|
+
for (const header of headers.values())
|
|
85
|
+
if (header.rolloutId)
|
|
86
|
+
threadByRollout.set(header.rolloutId, header.nativeSessionId);
|
|
87
|
+
for (const header of headers.values()) {
|
|
88
|
+
const base = header.historyBase;
|
|
89
|
+
if (base && threadByRollout.get(base.rolloutId) === header.nativeSessionId)
|
|
90
|
+
absorbedRollouts.add(base.rolloutId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// One candidate per thread: several same-thread rollouts can survive absorption (two
|
|
94
|
+
// siblings reverting from the same parent). The newest by rollout filename timestamp wins;
|
|
95
|
+
// the losers are reported, never imported twice under one nativeSessionId.
|
|
96
|
+
const supersededSiblings = new Set();
|
|
97
|
+
if (harness === "codex") {
|
|
98
|
+
const byThread = new Map();
|
|
99
|
+
for (const [path, header] of headers) {
|
|
100
|
+
if (absorbedRollouts.has(header.rolloutId ?? ""))
|
|
101
|
+
continue;
|
|
102
|
+
const list = byThread.get(header.nativeSessionId) ?? [];
|
|
103
|
+
list.push({ path, rolloutId: header.rolloutId ?? "", sortKey: basename(path) });
|
|
104
|
+
byThread.set(header.nativeSessionId, list);
|
|
105
|
+
}
|
|
106
|
+
for (const list of byThread.values()) {
|
|
107
|
+
if (list.length < 2)
|
|
108
|
+
continue;
|
|
109
|
+
const newest = list.reduce((a, b) => (a.sortKey > b.sortKey ? a : b));
|
|
110
|
+
for (const sibling of list)
|
|
111
|
+
if (sibling !== newest)
|
|
112
|
+
supersededSiblings.add(sibling.path);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
55
115
|
let start = 0;
|
|
56
116
|
while (start < scanned.paths.length) {
|
|
57
117
|
options.signal?.throwIfAborted();
|
|
@@ -60,9 +120,16 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
|
|
|
60
120
|
const concurrency = sizes.some((size) => size > LARGE_TRANSCRIPT_BYTES) ? 1 : 2;
|
|
61
121
|
const batch = await Promise.all(paths.slice(0, concurrency).map(async (path) => {
|
|
62
122
|
try {
|
|
63
|
-
|
|
123
|
+
// Pre-scan failures are already reported; do not read the header again.
|
|
124
|
+
const header = headers.get(path) ?? (harness === "pi" ? await readNativeTranscriptHeader(path, harness, options.signal) : null);
|
|
125
|
+
if (!header)
|
|
126
|
+
return null;
|
|
64
127
|
if (await canonicalRuntimeRoot(header.cwd) !== root)
|
|
65
128
|
return null;
|
|
129
|
+
if (header.rolloutId && absorbedRollouts.has(header.rolloutId))
|
|
130
|
+
return null;
|
|
131
|
+
if (supersededSiblings.has(path))
|
|
132
|
+
return { superseded: { path, nativeSessionId: header.nativeSessionId, message: "Superseded by a newer rollout of the same conversation; its Turns are not imported" } };
|
|
66
133
|
const transcript = await readNativeTranscript(path, harness, { settled: true, signal: options.signal });
|
|
67
134
|
if (transcript.turns.length === 0)
|
|
68
135
|
return { error: { harness, path, message: "Transcript contains no importable Turns" } };
|
|
@@ -77,10 +144,10 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
|
|
|
77
144
|
options.signal?.throwIfAborted();
|
|
78
145
|
if (!result)
|
|
79
146
|
continue;
|
|
80
|
-
if ("
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
147
|
+
if ("superseded" in result)
|
|
148
|
+
superseded.push(result.superseded);
|
|
149
|
+
else if ("error" in result)
|
|
150
|
+
errors.push(result.error);
|
|
84
151
|
else
|
|
85
152
|
candidates.push(result);
|
|
86
153
|
options.onProgress?.({ harness, scanned: start + batch.length, candidates: candidates.length });
|
|
@@ -88,7 +155,7 @@ export async function discoverNativeImportCandidates(root, harnesses, options =
|
|
|
88
155
|
start += concurrency;
|
|
89
156
|
}
|
|
90
157
|
}
|
|
91
|
-
return { candidates, errors };
|
|
158
|
+
return { candidates, errors, superseded };
|
|
92
159
|
}
|
|
93
160
|
export function nativeArchiveTransport(spaceId, identity) {
|
|
94
161
|
const client = createClient().space(spaceId);
|
|
@@ -149,21 +216,30 @@ export async function captureNativeSession(input) {
|
|
|
149
216
|
const transcript = await readNativeTranscript(path, input.harness, input);
|
|
150
217
|
if (await canonicalPathOrNull(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
|
|
151
218
|
throw new Error("Native transcript belongs to another project or Session");
|
|
219
|
+
// The path stays in the cache key: restored Pi working copies share a native ID but not a file.
|
|
152
220
|
const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
|
|
153
221
|
let store = nativeStores.get(key);
|
|
154
222
|
if (!store) {
|
|
155
223
|
const transport = nativeArchiveTransport(space.spaceId, identity);
|
|
224
|
+
const lineage = input.harness === "codex" ? transcript.lineageRolloutIds ?? [] : [];
|
|
156
225
|
const candidates = (await listNativeSyncStores(runtimeRoot, space.spaceId, identity, transport))
|
|
157
226
|
.filter((candidate) => candidate.options.harness === input.harness && candidate.options.nativeSessionId === transcript.nativeSessionId);
|
|
158
|
-
|
|
159
|
-
|
|
227
|
+
// A Codex rollover moves appends to a fresh rollout that references the old one: a store
|
|
228
|
+
// bound anywhere in the new leaf's lineage is the same conversation, so keep using it —
|
|
229
|
+
// the binding migrates inside capture. Exact-path matches win; lineage matches follow.
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
const binding = await candidate.binding();
|
|
232
|
+
if (binding.path === path) {
|
|
160
233
|
store = candidate;
|
|
161
234
|
break;
|
|
162
235
|
}
|
|
236
|
+
if (!store && lineage.includes(codexRolloutIdOf(basename(binding.path)) ?? ""))
|
|
237
|
+
store = candidate;
|
|
238
|
+
}
|
|
163
239
|
if (!store) {
|
|
164
240
|
const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
|
|
165
241
|
const managedPath = managed ? await canonicalPathOrNull(managed.path) : null;
|
|
166
|
-
if (candidates.length && managedPath !== path)
|
|
242
|
+
if (candidates.length && managedPath !== path && !(managedPath && lineage.includes(codexRolloutIdOf(basename(managedPath)) ?? "")))
|
|
167
243
|
throw new Error("Native path changed; original bindings retained");
|
|
168
244
|
// Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
|
|
169
245
|
store = new NativeSyncStore({ runtimeRoot, spaceId: space.spaceId, identity, harness: input.harness, nativeSessionId: transcript.nativeSessionId,
|
|
@@ -173,7 +249,7 @@ export async function captureNativeSession(input) {
|
|
|
173
249
|
nativeStores.delete(nativeStores.keys().next().value ?? "");
|
|
174
250
|
nativeStores.set(key, store);
|
|
175
251
|
}
|
|
176
|
-
await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin });
|
|
252
|
+
await store.capture(path, transcript, { sessionStartedAt: input.sessionStartedAt, origin: input.origin, signal: input.signal });
|
|
177
253
|
return store;
|
|
178
254
|
}
|
|
179
255
|
/** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
|
|
@@ -187,6 +263,35 @@ export function nativeWebSocketTransport(spaceId, identity, send) {
|
|
|
187
263
|
heartbeatNativeTurn: async (sessionId, turnId) => await send({ type: "heartbeat", sessionId, turnId }),
|
|
188
264
|
};
|
|
189
265
|
}
|
|
266
|
+
export const NATIVE_SYNC_SESSION_CONCURRENCY = 4;
|
|
267
|
+
export async function runNativeSyncPool(items, concurrency, signal, processItem) {
|
|
268
|
+
let next = 0;
|
|
269
|
+
const worker = async () => {
|
|
270
|
+
while (!signal.aborted) {
|
|
271
|
+
const index = next++;
|
|
272
|
+
if (index >= items.length)
|
|
273
|
+
return;
|
|
274
|
+
await processItem(items[index]);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(items.length, Math.max(1, concurrency)) }, worker));
|
|
278
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
279
|
+
if (failure)
|
|
280
|
+
throw failure.reason;
|
|
281
|
+
}
|
|
282
|
+
export function summarizeNativeSyncErrors(errors) {
|
|
283
|
+
const groups = new Map();
|
|
284
|
+
for (const error of errors) {
|
|
285
|
+
const value = serializeDiagnosticError(error);
|
|
286
|
+
const key = JSON.stringify([value.name, value.code, value.status, value.causeCode, value.message]);
|
|
287
|
+
const group = groups.get(key);
|
|
288
|
+
if (group)
|
|
289
|
+
group.count += 1;
|
|
290
|
+
else
|
|
291
|
+
groups.set(key, { name: value.name ?? "Error", message: value.message, ...(value.code ? { code: value.code } : {}), ...(value.status ? { status: value.status } : {}), count: 1 });
|
|
292
|
+
}
|
|
293
|
+
return [...groups.values()];
|
|
294
|
+
}
|
|
190
295
|
export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
|
|
191
296
|
if (currentIdentityKey() !== identity)
|
|
192
297
|
return false;
|
|
@@ -199,12 +304,12 @@ export async function flushNativeSessions(spaceId, identity, signal, report, tra
|
|
|
199
304
|
return false;
|
|
200
305
|
const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
|
|
201
306
|
let failed = false;
|
|
202
|
-
|
|
203
|
-
signal.throwIfAborted();
|
|
204
|
-
const binding = await store.binding();
|
|
205
|
-
if (!config.harnesses.includes(binding.harness))
|
|
206
|
-
continue;
|
|
307
|
+
await runNativeSyncPool(stores, NATIVE_SYNC_SESSION_CONCURRENCY, signal, async (store) => {
|
|
207
308
|
try {
|
|
309
|
+
signal.throwIfAborted();
|
|
310
|
+
const binding = await store.binding();
|
|
311
|
+
if (!config.harnesses.includes(binding.harness))
|
|
312
|
+
return;
|
|
208
313
|
// Codex hooks trigger captures; re-reading here also closes gaps after reconnects or missed hooks.
|
|
209
314
|
if (binding.harness === "codex")
|
|
210
315
|
await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
|
|
@@ -216,6 +321,6 @@ export async function flushNativeSessions(spaceId, identity, signal, report, tra
|
|
|
216
321
|
report(error);
|
|
217
322
|
}
|
|
218
323
|
}
|
|
219
|
-
}
|
|
324
|
+
});
|
|
220
325
|
return failed;
|
|
221
326
|
}
|
|
@@ -12,6 +12,12 @@ export type NativeTranscriptTurn = {
|
|
|
12
12
|
boundaries: Record<number, string>;
|
|
13
13
|
sha256: string;
|
|
14
14
|
result: NativeTurnComplete | null;
|
|
15
|
+
/** Codex lineage: the rollout file this Turn was read from. */
|
|
16
|
+
path?: string;
|
|
17
|
+
/** Codex lineage: the rollout id of the file this Turn was read from. */
|
|
18
|
+
rolloutId?: string;
|
|
19
|
+
/** Codex lineage: merged order across rollout files. */
|
|
20
|
+
sequence?: number;
|
|
15
21
|
};
|
|
16
22
|
export type NativeTranscript = {
|
|
17
23
|
nativeSessionId: string;
|
|
@@ -19,14 +25,36 @@ export type NativeTranscript = {
|
|
|
19
25
|
cloudSessionId?: string;
|
|
20
26
|
turns: NativeTranscriptTurn[];
|
|
21
27
|
prefixes: ReadonlyMap<number, string>;
|
|
28
|
+
/** Codex lineage: every rollout file oldest-to-leaf, including the leaf itself. */
|
|
29
|
+
lineagePaths?: string[];
|
|
30
|
+
/** Codex lineage: rollout ids matching `lineagePaths` order. */
|
|
31
|
+
lineageRolloutIds?: string[];
|
|
22
32
|
};
|
|
23
33
|
export declare const NATIVE_MAX_RECORD_BYTES: number;
|
|
24
34
|
export declare const NATIVE_MAX_TRANSCRIPT_BYTES: number;
|
|
25
|
-
|
|
26
|
-
export declare
|
|
35
|
+
export declare const codexSessionsDirectory: () => string;
|
|
36
|
+
export declare const codexArchivedSessionsDirectory: () => string;
|
|
37
|
+
/** Ordinary rollout names encode one id; reverted threads append `_rollout-id`. */
|
|
38
|
+
export declare function codexRolloutIdOf(fileName: string): string | null;
|
|
39
|
+
export type CodexHistoryBase = {
|
|
40
|
+
rolloutId: string;
|
|
41
|
+
endOrdinal: number;
|
|
42
|
+
endBytes: number;
|
|
43
|
+
};
|
|
44
|
+
export type NativeTranscriptHeader = {
|
|
27
45
|
nativeSessionId: string;
|
|
28
46
|
cwd: string;
|
|
29
|
-
|
|
47
|
+
rolloutId?: string;
|
|
48
|
+
historyBase?: CodexHistoryBase;
|
|
49
|
+
};
|
|
50
|
+
/** Read only the session header before deciding whether a global Codex file belongs to this project. */
|
|
51
|
+
export declare function readNativeTranscriptHeader(path: string, harness: "pi" | "codex", signal?: AbortSignal): Promise<NativeTranscriptHeader>;
|
|
52
|
+
/** Raw JSONL bytes of a rollout, transparently decompressing `.zst` and honoring a lineage cutoff. */
|
|
53
|
+
export declare function codexRolloutBytes(path: string, endBytes: number | null, signal?: AbortSignal): AsyncGenerator<Buffer>;
|
|
54
|
+
/** Cache location for a rollout's decompressed copy; shared by capture and archival. */
|
|
55
|
+
export declare function codexRolloutCachePath(source: string, cacheDirectory: string): string;
|
|
56
|
+
/** Archives need plain JSONL bytes; decompress a cold `.zst` rollout into a cache once. */
|
|
57
|
+
export declare function ensurePlainCodexRollout(path: string, cacheDirectory: string, signal?: AbortSignal, maxBytes?: number): Promise<string>;
|
|
30
58
|
/** Partial trailing records are retried, never parsed or acknowledged as complete. */
|
|
31
59
|
export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
|
|
32
60
|
settled?: boolean;
|