@neta-art/cohub-cli 6.11.2 → 7.0.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.
@@ -0,0 +1,353 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdir, open, readFile, readdir, rename, rm, stat, link } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { RUNTIME_ARCHIVE_SEGMENT_BYTES, harnessArchiveIndexSchema, validateArchiveBoundary, } from "@neta-art/cohub";
6
+ const missing = (error) => error?.code === "ENOENT";
7
+ const hash = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
8
+ export async function checksumNativeFile(path) {
9
+ const digest = createHash("sha256");
10
+ for await (const bytes of createReadStream(path))
11
+ digest.update(bytes);
12
+ return digest.digest("hex");
13
+ }
14
+ export async function atomicRuntimeJson(path, value) {
15
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
16
+ const temporary = `${path}.${randomUUID()}.tmp`;
17
+ try {
18
+ const file = await open(temporary, "wx", 0o600);
19
+ try {
20
+ await file.writeFile(JSON.stringify(value));
21
+ await file.sync();
22
+ }
23
+ finally {
24
+ await file.close();
25
+ }
26
+ await rename(temporary, path);
27
+ if (process.platform !== "win32") {
28
+ const directory = await open(dirname(path), "r");
29
+ try {
30
+ await directory.sync();
31
+ }
32
+ finally {
33
+ await directory.close();
34
+ }
35
+ }
36
+ }
37
+ finally {
38
+ await rm(temporary, { force: true });
39
+ }
40
+ }
41
+ /** Immutable local segments are the outbox. Model acknowledgements never remove them. */
42
+ export class RuntimeArchiveStore {
43
+ root;
44
+ transport;
45
+ flushing = null;
46
+ capturing = new Map();
47
+ constructor(root, transport) {
48
+ this.root = root;
49
+ this.transport = transport;
50
+ }
51
+ async pendingCount() {
52
+ const pending = new Set();
53
+ for (const directory of ["pending", "captures"]) {
54
+ const names = await readdir(join(this.root, directory)).catch((error) => { if (missing(error))
55
+ return []; throw error; });
56
+ for (const name of names)
57
+ if (name.endsWith(".json"))
58
+ pending.add(name);
59
+ }
60
+ return pending.size;
61
+ }
62
+ async failedCaptureCount() {
63
+ const names = await readdir(join(this.root, "failed", "captures")).catch((error) => { if (missing(error))
64
+ return []; throw error; });
65
+ return names.filter((name) => name.endsWith(".json")).length;
66
+ }
67
+ async hasCapture(turnId) { return Boolean(await this.readIndex(this.version(turnId))); }
68
+ version(turnId) { return join(this.root, "versions", `${turnId}.json`); }
69
+ blob(sha) { return join(this.root, "objects", sha); }
70
+ async saveBlob(sha, bytes) {
71
+ await mkdir(join(this.root, "objects"), { recursive: true, mode: 0o700 });
72
+ const target = this.blob(sha), temporary = `${target}.${randomUUID()}.tmp`;
73
+ try {
74
+ const output = await open(temporary, "wx", 0o600);
75
+ try {
76
+ await output.writeFile(bytes);
77
+ await output.sync();
78
+ }
79
+ finally {
80
+ await output.close();
81
+ }
82
+ await link(temporary, target).catch((error) => { if (error.code !== "EEXIST")
83
+ throw error; });
84
+ }
85
+ finally {
86
+ await rm(temporary, { force: true });
87
+ }
88
+ }
89
+ async readIndex(path) {
90
+ try {
91
+ return harnessArchiveIndexSchema.parse(JSON.parse(await readFile(path, "utf8")));
92
+ }
93
+ catch (error) {
94
+ if (missing(error))
95
+ return null;
96
+ throw error;
97
+ }
98
+ }
99
+ stage(state, turnId) {
100
+ const existing = this.capturing.get(turnId);
101
+ if (existing)
102
+ return existing;
103
+ const task = this.capture(state, turnId).finally(() => this.capturing.delete(turnId));
104
+ this.capturing.set(turnId, task);
105
+ return task;
106
+ }
107
+ async capture(state, turnId) {
108
+ const identity = { sessionId: state.sessionId, turnId, harness: state.harness };
109
+ const headPath = join(this.root, "heads", `${state.sessionId}.${state.harness}.json`);
110
+ const saved = await this.readIndex(this.version(turnId));
111
+ if (saved) {
112
+ if (saved.sessionId !== state.sessionId || saved.harness !== state.harness)
113
+ throw new Error("Archive identity mismatch / 归档身份不匹配");
114
+ const committed = await stat(join(this.root, "ready", `${turnId}.json`)).catch((error) => { if (missing(error))
115
+ return null; throw error; });
116
+ if (!committed)
117
+ await atomicRuntimeJson(join(this.root, "pending", `${turnId}.json`), saved);
118
+ if (!await this.readIndex(headPath))
119
+ await atomicRuntimeJson(headPath, saved);
120
+ return identity;
121
+ }
122
+ const previous = await this.readIndex(headPath);
123
+ const file = await open(state.path, "r");
124
+ let index;
125
+ try {
126
+ const before = await file.stat();
127
+ if (!before.isFile() || !before.size)
128
+ throw new Error("Native archive is empty / 原生归档为空");
129
+ const buffer = Buffer.alloc(RUNTIME_ARCHIVE_SEGMENT_BYTES);
130
+ let offset = 0;
131
+ let digest = createHash("sha256");
132
+ let parent = null;
133
+ // Hash the old prefix, not just its size: equal-size and growing rewrites are valid.
134
+ if (previous && previous.nativeSessionId === state.nativeSessionId && previous.sizeBytes <= before.size) {
135
+ while (offset < previous.sizeBytes) {
136
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, previous.sizeBytes - offset), offset);
137
+ if (!bytesRead)
138
+ throw new Error("Native file changed during capture / 归档时文件发生变化");
139
+ digest.update(buffer.subarray(0, bytesRead));
140
+ offset += bytesRead;
141
+ }
142
+ if (digest.copy().digest("hex") === previous.sha256)
143
+ parent = previous;
144
+ }
145
+ if (!parent) {
146
+ offset = 0;
147
+ digest = createHash("sha256");
148
+ }
149
+ const segments = [];
150
+ await mkdir(join(this.root, "objects"), { recursive: true, mode: 0o700 });
151
+ while (offset < before.size) {
152
+ const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, before.size - offset), offset);
153
+ if (!bytesRead)
154
+ throw new Error("Native file changed during capture / 归档时文件发生变化");
155
+ const bytes = buffer.subarray(0, bytesRead);
156
+ digest.update(bytes);
157
+ const segment = { offset, sizeBytes: bytesRead, sha256: hash(bytes), md5: hash(bytes, "md5") };
158
+ await this.saveBlob(segment.sha256, bytes);
159
+ segments.push(segment);
160
+ offset += bytesRead;
161
+ }
162
+ const after = await stat(state.path);
163
+ if (before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
164
+ throw new Error("Native file changed during capture / 归档时文件发生变化");
165
+ if (process.platform !== "win32") {
166
+ const directory = await open(join(this.root, "objects"), "r");
167
+ try {
168
+ await directory.sync();
169
+ }
170
+ finally {
171
+ await directory.close();
172
+ }
173
+ }
174
+ index = harnessArchiveIndexSchema.parse({ ...identity, version: 1, nativeSessionId: state.nativeSessionId,
175
+ nativeFormat: state.harness === "pi" ? "pi.jsonl" : "codex.rollout", parentTurnId: parent?.turnId ?? null,
176
+ sizeBytes: before.size, sha256: digest.digest("hex"), segments });
177
+ validateArchiveBoundary(index, parent);
178
+ }
179
+ finally {
180
+ await file.close();
181
+ }
182
+ // Publish the outbox before advancing the local head. Neither points at mutable files.
183
+ await atomicRuntimeJson(join(this.root, "pending", `${turnId}.json`), index);
184
+ await atomicRuntimeJson(this.version(turnId), index);
185
+ await atomicRuntimeJson(headPath, index);
186
+ return identity;
187
+ }
188
+ async flush(signal) {
189
+ if (!this.transport)
190
+ return;
191
+ if (this.flushing)
192
+ return this.flushing;
193
+ this.flushing = this.drain(signal).finally(() => { this.flushing = null; });
194
+ return this.flushing;
195
+ }
196
+ async drain(signal) {
197
+ const transport = this.transport;
198
+ if (!transport)
199
+ return;
200
+ const names = await readdir(join(this.root, "pending")).catch((error) => { if (missing(error))
201
+ return []; throw error; });
202
+ const pending = new Map();
203
+ for (const name of names) {
204
+ if (!name.endsWith(".json"))
205
+ continue;
206
+ const index = await this.readIndex(join(this.root, "pending", name));
207
+ if (index)
208
+ pending.set(index.turnId, index);
209
+ }
210
+ const children = new Map();
211
+ const queue = [];
212
+ for (const index of pending.values()) {
213
+ if (index.parentTurnId && pending.has(index.parentTurnId)) {
214
+ const siblings = children.get(index.parentTurnId) ?? [];
215
+ siblings.push(index);
216
+ children.set(index.parentTurnId, siblings);
217
+ }
218
+ else
219
+ queue.push(index);
220
+ }
221
+ if (pending.size && !queue.length)
222
+ throw new Error("Cyclic archive outbox / 归档队列引用循环");
223
+ for (let cursor = 0; cursor < queue.length; cursor++) {
224
+ signal.throwIfAborted();
225
+ const index = queue[cursor];
226
+ if (!index)
227
+ continue;
228
+ try {
229
+ const { uploads } = await transport.prepareRuntimeArchive(index, { signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) });
230
+ if (uploads.length > index.segments.length)
231
+ throw new Error("Upload plan mismatch / 上传计划不匹配");
232
+ const expected = new Set(index.segments.map((segment) => JSON.stringify(segment)));
233
+ for (const { segment, uploadUrl, headers } of uploads) {
234
+ if (!expected.has(JSON.stringify(segment)))
235
+ throw new Error("Upload segment mismatch / 上传分段不匹配");
236
+ signal.throwIfAborted();
237
+ const bytes = await readFile(this.blob(segment.sha256));
238
+ if (bytes.length !== segment.sizeBytes || hash(bytes) !== segment.sha256)
239
+ throw new Error("Local archive segment is corrupt / 本地归档分段已损坏");
240
+ const response = await (transport.fetchObject ?? fetch)(uploadUrl, { method: "PUT", headers, body: bytes, redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]) });
241
+ // An immutable segment may already exist after a lost acknowledgement. Commit verifies it.
242
+ if (!response.ok && ![409, 412].includes(response.status))
243
+ throw new Error(`Archive upload failed / 归档上传失败: ${response.status}`);
244
+ }
245
+ await transport.commitRuntimeArchive(index, { signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]) });
246
+ await atomicRuntimeJson(join(this.root, "ready", `${index.turnId}.json`), { turnId: index.turnId, sha256: index.sha256 });
247
+ await rm(join(this.root, "pending", `${index.turnId}.json`), { force: true });
248
+ queue.push(...children.get(index.turnId) ?? []);
249
+ }
250
+ catch (error) {
251
+ if (!signal.aborted)
252
+ console.error("Archive pending; native segments retained / 归档待重试,原始分段已保留:", error);
253
+ }
254
+ }
255
+ }
256
+ async restore(reference, target, signal) {
257
+ if (!this.transport)
258
+ throw new Error("Archive transport unavailable / 归档传输不可用");
259
+ const timeout = (ms) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
260
+ const pages = [];
261
+ const visited = new Set();
262
+ let turnId = reference.turnId;
263
+ while (turnId) {
264
+ signal?.throwIfAborted();
265
+ if (visited.has(turnId))
266
+ throw new Error("Cyclic archive / 归档引用循环");
267
+ visited.add(turnId);
268
+ const page = await this.transport.getRuntimeArchive(reference.sessionId, turnId, { signal: timeout(30_000) });
269
+ const index = harnessArchiveIndexSchema.parse(page.index);
270
+ if (index.turnId !== turnId || index.sessionId !== reference.sessionId || index.harness !== reference.harness)
271
+ throw new Error("Archive identity mismatch / 归档身份不匹配");
272
+ pages.push({ ...page, index });
273
+ turnId = index.parentTurnId;
274
+ }
275
+ const head = pages[0]?.index;
276
+ if (!head)
277
+ throw new Error("Archive missing / 归档不存在");
278
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
279
+ const temporary = `${target}.${randomUUID()}.restoring`;
280
+ const file = await open(temporary, "wx", 0o600);
281
+ try {
282
+ let parent = null;
283
+ const digest = createHash("sha256");
284
+ for (const page of pages.reverse()) {
285
+ validateArchiveBoundary(page.index, parent);
286
+ if (page.segments.length !== page.index.segments.length)
287
+ throw new Error("Missing archive segments / 归档分段缺失");
288
+ for (const [ordinal, expected] of page.index.segments.entries()) {
289
+ signal?.throwIfAborted();
290
+ const cached = await readFile(this.blob(expected.sha256)).catch((error) => { if (missing(error))
291
+ return null; throw error; });
292
+ if (cached) {
293
+ if (cached.length !== expected.sizeBytes || hash(cached) !== expected.sha256)
294
+ throw new Error("Cached archive segment is corrupt / 缓存归档分段已损坏");
295
+ digest.update(cached);
296
+ await file.writeFile(cached);
297
+ continue;
298
+ }
299
+ let link = page.segments[ordinal];
300
+ if (!link || JSON.stringify(link.segment) !== JSON.stringify(expected))
301
+ throw new Error("Archive segment identity mismatch / 归档分段标识不匹配");
302
+ let response = await (this.transport.fetchObject ?? fetch)(link.downloadUrl, { redirect: "error", signal: timeout(60_000) });
303
+ if ([401, 403].includes(response.status)) {
304
+ const refreshed = await this.transport.getRuntimeArchive(reference.sessionId, page.index.turnId, { signal: timeout(30_000) });
305
+ link = refreshed.segments[ordinal];
306
+ if (!link || JSON.stringify(link.segment) !== JSON.stringify(expected))
307
+ throw new Error("Archive segment missing / 归档分段缺失");
308
+ response = await (this.transport.fetchObject ?? fetch)(link.downloadUrl, { redirect: "error", signal: timeout(60_000) });
309
+ }
310
+ if (!response.ok || !response.body)
311
+ throw new Error(`Archive download failed / 归档下载失败: ${response.status}`);
312
+ const segmentHash = createHash("sha256");
313
+ let size = 0;
314
+ const chunks = [];
315
+ for await (const chunk of response.body) {
316
+ size += chunk.length;
317
+ if (size > expected.sizeBytes)
318
+ throw new Error("Archive size mismatch / 归档大小不匹配");
319
+ chunks.push(chunk);
320
+ segmentHash.update(chunk);
321
+ digest.update(chunk);
322
+ await file.writeFile(chunk);
323
+ }
324
+ if (size !== expected.sizeBytes || segmentHash.digest("hex") !== expected.sha256)
325
+ throw new Error("Archive checksum mismatch / 归档校验失败");
326
+ await this.saveBlob(expected.sha256, Buffer.concat(chunks));
327
+ }
328
+ if (digest.copy().digest("hex") !== page.index.sha256)
329
+ throw new Error("Archive version checksum mismatch / 归档版本校验失败");
330
+ parent = page.index;
331
+ }
332
+ if ((await file.stat()).size !== head.sizeBytes)
333
+ throw new Error("Archive length mismatch / 归档长度不匹配");
334
+ await file.sync();
335
+ await file.close();
336
+ await link(temporary, target);
337
+ if (process.platform !== "win32") {
338
+ const directory = await open(dirname(target), "r");
339
+ try {
340
+ await directory.sync();
341
+ }
342
+ finally {
343
+ await directory.close();
344
+ }
345
+ }
346
+ return head;
347
+ }
348
+ finally {
349
+ await file.close();
350
+ await rm(temporary, { force: true });
351
+ }
352
+ }
353
+ }
@@ -0,0 +1,9 @@
1
+ import type { RuntimeMessage } from "@neta-art/cohub";
2
+ declare const fields: readonly ["inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteInputTokens", "totalTokens"];
3
+ export type CodexTokenTotals = Record<typeof fields[number], number>;
4
+ export declare const codexTokenTotals: (value: unknown) => CodexTokenTotals;
5
+ export declare const subtractCodexTokens: (total: CodexTokenTotals, base: CodexTokenTotals) => CodexTokenTotals;
6
+ export declare function codexUsage(total: CodexTokenTotals): NonNullable<RuntimeMessage["usage"]>;
7
+ /** Seed portable imports from the original native counters, not a prior turn's `last`. */
8
+ export declare function codexArchiveTotals(records: Record<string, unknown>[]): CodexTokenTotals | undefined;
9
+ export {};
@@ -0,0 +1,23 @@
1
+ import { record } from "./json-rpc.js";
2
+ const fields = ["inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteInputTokens", "totalTokens"];
3
+ const tokens = (value) => typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
4
+ export const codexTokenTotals = (value) => {
5
+ const input = record(value);
6
+ return Object.fromEntries(fields.map((key) => [key, tokens(input[key])]));
7
+ };
8
+ export const subtractCodexTokens = (total, base) => Object.fromEntries(fields.map((key) => [key, Math.max(0, total[key] - base[key])]));
9
+ export function codexUsage(total) {
10
+ return { input: Math.max(0, total.inputTokens - total.cachedInputTokens - total.cacheWriteInputTokens), output: total.outputTokens, cacheRead: total.cachedInputTokens, cacheWrite: total.cacheWriteInputTokens, totalTokens: total.totalTokens };
11
+ }
12
+ /** Seed portable imports from the original native counters, not a prior turn's `last`. */
13
+ export function codexArchiveTotals(records) {
14
+ for (let i = records.length - 1; i >= 0; i--) {
15
+ const entry = records[i];
16
+ if (entry?.type !== "token_usage_record")
17
+ continue;
18
+ const value = record(record(entry.payload).thread_token_usage);
19
+ if (typeof value.total_tokens !== "number")
20
+ continue;
21
+ return codexTokenTotals({ inputTokens: value.input_tokens, outputTokens: value.output_tokens, cachedInputTokens: value.cached_input_tokens, cacheWriteInputTokens: value.cache_write_input_tokens, totalTokens: value.total_tokens });
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ import { type RuntimeCapabilities } from "@neta-art/cohub";
2
+ import { type HarnessOptions } from "./harness.js";
3
+ import { type RuntimeSessionStore } from "./session-store.js";
4
+ export type RuntimeConnectionOptions = {
5
+ spaceId: string;
6
+ cwd: string;
7
+ url: string;
8
+ capabilities: RuntimeCapabilities;
9
+ harnesses: HarnessOptions;
10
+ token: () => Promise<string>;
11
+ signal: AbortSignal;
12
+ store: RuntimeSessionStore;
13
+ onReady: () => void;
14
+ leaseConflictTimeoutMs?: number;
15
+ };
16
+ export declare function serveRuntime(options: RuntimeConnectionOptions): Promise<void>;