@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.
- package/README.md +21 -0
- package/dist/commands/apps.js +13 -1
- package/dist/commands/runtime.d.ts +4 -0
- package/dist/commands/runtime.js +117 -0
- package/dist/commands/sandboxd-binary.js +2 -2
- package/dist/commands/spaces.js +3 -0
- package/dist/index.js +3 -3
- package/dist/runtime/archive-store.d.ts +44 -0
- package/dist/runtime/archive-store.js +353 -0
- package/dist/runtime/codex-usage.d.ts +9 -0
- package/dist/runtime/codex-usage.js +23 -0
- package/dist/runtime/connection.d.ts +16 -0
- package/dist/runtime/connection.js +318 -0
- package/dist/runtime/harness.d.ts +19 -0
- package/dist/runtime/harness.js +403 -0
- package/dist/runtime/json-rpc.d.ts +37 -0
- package/dist/runtime/json-rpc.js +165 -0
- package/dist/runtime/model-catalog.d.ts +6 -0
- package/dist/runtime/model-catalog.js +19 -0
- package/dist/runtime/native-archive.d.ts +16 -0
- package/dist/runtime/native-archive.js +88 -0
- package/dist/runtime/process-group.d.ts +3 -0
- package/dist/runtime/process-group.js +68 -0
- package/dist/runtime/session-store.d.ts +46 -0
- package/dist/runtime/session-store.js +273 -0
- package/package.json +11 -4
- package/dist/commands/sandbox.d.ts +0 -3
- package/dist/commands/sandbox.js +0 -175
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { link, mkdir, open, rm } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { RUNTIME_MAX_FRAME_BYTES } from "@neta-art/cohub";
|
|
6
|
+
import { JsonLineDecoder } from "./json-rpc.js";
|
|
7
|
+
import { codexArchiveTotals } from "./codex-usage.js";
|
|
8
|
+
export async function readCodexArchiveTotals(path) {
|
|
9
|
+
let totals;
|
|
10
|
+
const decoder = new JsonLineDecoder((row) => { totals = codexArchiveTotals([row]) ?? totals; });
|
|
11
|
+
for await (const bytes of createReadStream(path))
|
|
12
|
+
decoder.push(bytes);
|
|
13
|
+
decoder.end();
|
|
14
|
+
return totals;
|
|
15
|
+
}
|
|
16
|
+
/** Change only the header of a working copy. Raw archive bytes remain untouched. */
|
|
17
|
+
export async function importNativeArchive(input) {
|
|
18
|
+
await mkdir(dirname(input.target), { recursive: true, mode: 0o700 });
|
|
19
|
+
const temporary = `${input.target}.${randomUUID()}.importing`;
|
|
20
|
+
const file = await open(temporary, "wx", 0o600);
|
|
21
|
+
const digest = createHash("sha256");
|
|
22
|
+
let headerReady = false, headerBytes = 0;
|
|
23
|
+
const fragments = [];
|
|
24
|
+
let totals;
|
|
25
|
+
const decoder = input.harness === "codex" ? new JsonLineDecoder((row) => { totals = codexArchiveTotals([row]) ?? totals; }) : null;
|
|
26
|
+
const write = async (bytes) => { digest.update(bytes); await file.writeFile(bytes); };
|
|
27
|
+
const writeHeader = async () => {
|
|
28
|
+
const header = JSON.parse(Buffer.concat(fragments).toString("utf8"));
|
|
29
|
+
if (input.harness === "pi") {
|
|
30
|
+
if (header?.type !== "session" || header.id !== input.nativeSessionId)
|
|
31
|
+
throw new Error("Pi archive identity mismatch / Pi 归档身份不匹配");
|
|
32
|
+
header.cwd = input.cwd;
|
|
33
|
+
delete header.parentSession;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (header?.type !== "session_meta" || header.payload?.id !== input.nativeSessionId)
|
|
37
|
+
throw new Error("Codex archive identity mismatch / Codex 归档身份不匹配");
|
|
38
|
+
header.payload.id = input.id;
|
|
39
|
+
if (header.payload.session_id != null)
|
|
40
|
+
header.payload.session_id = input.id;
|
|
41
|
+
header.payload.history_mode = "legacy";
|
|
42
|
+
header.payload.cwd = input.cwd;
|
|
43
|
+
}
|
|
44
|
+
await write(Buffer.from(`${JSON.stringify(header)}\n`));
|
|
45
|
+
fragments.length = 0;
|
|
46
|
+
headerReady = true;
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
for await (const bytes of createReadStream(input.source, { signal: input.signal })) {
|
|
50
|
+
decoder?.push(bytes);
|
|
51
|
+
if (headerReady) {
|
|
52
|
+
await write(bytes);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const newline = bytes.indexOf(10);
|
|
56
|
+
const prefix = newline < 0 ? bytes : bytes.subarray(0, newline);
|
|
57
|
+
headerBytes += prefix.length;
|
|
58
|
+
if (headerBytes > RUNTIME_MAX_FRAME_BYTES)
|
|
59
|
+
throw new Error("Native header is too large / 原生文件头过大");
|
|
60
|
+
fragments.push(prefix);
|
|
61
|
+
if (newline >= 0) {
|
|
62
|
+
await writeHeader();
|
|
63
|
+
await write(bytes.subarray(newline + 1));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!headerReady)
|
|
67
|
+
await writeHeader();
|
|
68
|
+
decoder?.end();
|
|
69
|
+
input.signal?.throwIfAborted();
|
|
70
|
+
await file.sync();
|
|
71
|
+
await file.close();
|
|
72
|
+
await link(temporary, input.target);
|
|
73
|
+
if (process.platform !== "win32") {
|
|
74
|
+
const directory = await open(dirname(input.target), "r");
|
|
75
|
+
try {
|
|
76
|
+
await directory.sync();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await directory.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { checksum: digest.digest("hex"), nativeSessionId: input.harness === "pi" ? input.nativeSessionId : input.id, codexTokenTotals: totals };
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await file.close();
|
|
86
|
+
await rm(temporary, { force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
export class ProcessCleanupUncertainError extends Error {
|
|
5
|
+
}
|
|
6
|
+
async function groupAlive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(-pid, 0);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === "ESRCH")
|
|
12
|
+
return false;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
if (process.platform !== "linux")
|
|
16
|
+
return true;
|
|
17
|
+
// Linux can retain reparented zombies: they cannot execute, but kill(0) still sees them.
|
|
18
|
+
for (const entry of await readdir("/proc")) {
|
|
19
|
+
if (!/^\d+$/.test(entry))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const stat = await readFile(`/proc/${entry}/stat`, "utf8");
|
|
23
|
+
// After removing `pid (comm)`: state, ppid, pgrp, session, ...
|
|
24
|
+
const [state, _parentPid, processGroupId] = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
25
|
+
if (Number(processGroupId) === pid && state !== "Z" && state !== "X")
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== "ENOENT" && error.code !== "ESRCH")
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export async function stopProcessGroup(pid) {
|
|
36
|
+
try {
|
|
37
|
+
if (process.platform === "win32") {
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
const task = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", timeout: 5000 });
|
|
40
|
+
task.once("error", reject);
|
|
41
|
+
task.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`taskkill exited ${code}`)));
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const signal = (value) => {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(-pid, value);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "ESRCH")
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
signal("SIGTERM");
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
while (await groupAlive(pid)) {
|
|
57
|
+
const elapsed = Date.now() - started;
|
|
58
|
+
if (elapsed >= 5000)
|
|
59
|
+
throw new Error("Process group is still running");
|
|
60
|
+
if (elapsed >= 2000)
|
|
61
|
+
signal("SIGKILL");
|
|
62
|
+
await delay(25);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (cause) {
|
|
66
|
+
throw new ProcessCleanupUncertainError("Tool process cleanup could not be confirmed; execution remains unresolved / 无法确认工具进程已停止,执行结果保持未确认", { cause });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type RuntimeExecutionEvent, type HarnessArchive, type RuntimeTurnInput } from "@neta-art/cohub";
|
|
2
|
+
import { RuntimeArchiveStore, type ArchiveTransport } from "./archive-store.js";
|
|
3
|
+
import type { CodexTokenTotals } from "./codex-usage.js";
|
|
4
|
+
export declare class ContextRequiredError extends Error {
|
|
5
|
+
readonly historyOnly: boolean;
|
|
6
|
+
constructor(message: string, historyOnly?: boolean);
|
|
7
|
+
}
|
|
8
|
+
export type NativeSession = {
|
|
9
|
+
version: 1;
|
|
10
|
+
sessionId: string;
|
|
11
|
+
harness: "pi" | "codex";
|
|
12
|
+
nativeSessionId: string;
|
|
13
|
+
path: string;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
throughTurnId: string | null;
|
|
16
|
+
revision: string;
|
|
17
|
+
checksum: string;
|
|
18
|
+
pendingTurnId: string | null;
|
|
19
|
+
resultChecksum?: string;
|
|
20
|
+
archivePendingTurnId?: string;
|
|
21
|
+
codexTokenTotals?: CodexTokenTotals;
|
|
22
|
+
};
|
|
23
|
+
/** Local references are hints validated against actual native files, never cloud existence claims. */
|
|
24
|
+
export declare class RuntimeSessionStore {
|
|
25
|
+
readonly root: string;
|
|
26
|
+
readonly archives: RuntimeArchiveStore;
|
|
27
|
+
private archiveFlush;
|
|
28
|
+
constructor(spaceId: string, stateRoot?: string, transport?: ArchiveTransport);
|
|
29
|
+
private statePath;
|
|
30
|
+
flushArchives(signal: AbortSignal): Promise<void>;
|
|
31
|
+
private flushArchiveOutbox;
|
|
32
|
+
pendingTurnIds(sessionId: string): Promise<string[]>;
|
|
33
|
+
prepare(input: RuntimeTurnInput, cwd: string, signal?: AbortSignal): Promise<{
|
|
34
|
+
state: NativeSession;
|
|
35
|
+
resume: "native" | "restored" | "handoff" | "new";
|
|
36
|
+
}>;
|
|
37
|
+
started(state: NativeSession, turnId: string): Promise<void>;
|
|
38
|
+
private resultPath;
|
|
39
|
+
recordResult(state: NativeSession, requestId: string, events: RuntimeExecutionEvent[]): Promise<void>;
|
|
40
|
+
recoverResult(input: Pick<RuntimeTurnInput, "sessionId" | "harness" | "turnId">, requestId?: string): Promise<{
|
|
41
|
+
state: NativeSession;
|
|
42
|
+
events: RuntimeExecutionEvent[];
|
|
43
|
+
} | null>;
|
|
44
|
+
archive(state: NativeSession, turnId: string): Promise<HarnessArchive | null>;
|
|
45
|
+
acknowledge(state: NativeSession, turnId: string, revision: string): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rm } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { contextToPiMessages, selectRuntimeContextMessages, runtimeEventSchema } from "@neta-art/cohub";
|
|
6
|
+
import { RuntimeArchiveStore, checksumNativeFile, atomicRuntimeJson as atomicJson } from "./archive-store.js";
|
|
7
|
+
import { importNativeArchive, readCodexArchiveTotals } from "./native-archive.js";
|
|
8
|
+
export class ContextRequiredError extends Error {
|
|
9
|
+
historyOnly;
|
|
10
|
+
constructor(message, historyOnly = false) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.historyOnly = historyOnly;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const checksum = (data) => createHash("sha256").update(data).digest("hex");
|
|
16
|
+
const missing = (error) => error?.code === "ENOENT";
|
|
17
|
+
class CaptureUnavailableError extends Error {
|
|
18
|
+
}
|
|
19
|
+
/** Local references are hints validated against actual native files, never cloud existence claims. */
|
|
20
|
+
export class RuntimeSessionStore {
|
|
21
|
+
root;
|
|
22
|
+
archives;
|
|
23
|
+
archiveFlush = null;
|
|
24
|
+
constructor(spaceId, stateRoot = join(homedir(), ".local", "state", "cohub", "runtime"), transport) {
|
|
25
|
+
this.root = join(stateRoot, spaceId);
|
|
26
|
+
this.archives = new RuntimeArchiveStore(join(this.root, "archives"), transport);
|
|
27
|
+
}
|
|
28
|
+
statePath(input) { return join(this.root, input.harness, `${input.sessionId}.json`); }
|
|
29
|
+
async flushArchives(signal) {
|
|
30
|
+
this.archiveFlush ??= this.flushArchiveOutbox(signal).finally(() => { this.archiveFlush = null; });
|
|
31
|
+
return this.archiveFlush;
|
|
32
|
+
}
|
|
33
|
+
async flushArchiveOutbox(signal) {
|
|
34
|
+
const captures = join(this.archives.root, "captures");
|
|
35
|
+
const names = await readdir(captures).catch((error) => { if (missing(error))
|
|
36
|
+
return []; throw error; });
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
if (!name.endsWith(".json"))
|
|
40
|
+
continue;
|
|
41
|
+
let receipt;
|
|
42
|
+
try {
|
|
43
|
+
receipt = await readFile(join(captures, name), "utf8");
|
|
44
|
+
let state;
|
|
45
|
+
try {
|
|
46
|
+
state = JSON.parse(receipt);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new CaptureUnavailableError("Invalid capture receipt / 归档捕获记录无效");
|
|
50
|
+
}
|
|
51
|
+
const turnId = state?.archivePendingTurnId;
|
|
52
|
+
if (typeof turnId !== "string" || typeof state?.path !== "string" || typeof state.resultChecksum !== "string") {
|
|
53
|
+
throw new CaptureUnavailableError("Invalid capture receipt / 归档捕获记录无效");
|
|
54
|
+
}
|
|
55
|
+
if (!await this.archives.hasCapture(turnId)) {
|
|
56
|
+
const digest = await checksumNativeFile(state.path).catch((error) => {
|
|
57
|
+
if (missing(error))
|
|
58
|
+
throw new CaptureUnavailableError("Native session missing / 原生会话文件不存在");
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
if (digest !== state.resultChecksum)
|
|
62
|
+
throw new CaptureUnavailableError("Native session changed; original files retained / 原生会话已变化,原文件已保留");
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
await this.archives.stage(state, turnId);
|
|
65
|
+
}
|
|
66
|
+
await rm(join(captures, name), { force: true });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
signal.throwIfAborted();
|
|
70
|
+
if (error instanceof CaptureUnavailableError && receipt !== undefined) {
|
|
71
|
+
// Preserve the exact receipt before retiring it from the retry queue. Never alter native files.
|
|
72
|
+
await atomicJson(join(this.archives.root, "failed", "captures", `${name}.${checksum(receipt)}.json`), {
|
|
73
|
+
receipt, reason: error.message, failedAt: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
await rm(join(captures, name), { force: true });
|
|
76
|
+
console.error("Archive capture unavailable; receipt retained / 归档捕获不可恢复,记录已保留:", error.message);
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
console.error("Archive capture pending / 归档捕获待重试:", error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await this.archives.flush(signal);
|
|
83
|
+
}
|
|
84
|
+
async pendingTurnIds(sessionId) {
|
|
85
|
+
const ids = [];
|
|
86
|
+
for (const harness of ["pi", "codex"]) {
|
|
87
|
+
try {
|
|
88
|
+
const state = JSON.parse(await readFile(this.statePath({ sessionId, harness }), "utf8"));
|
|
89
|
+
if (state.sessionId !== sessionId || state.harness !== harness)
|
|
90
|
+
throw new Error("Local session identity mismatch");
|
|
91
|
+
if (state.pendingTurnId)
|
|
92
|
+
ids.push(state.pendingTurnId);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!missing(error))
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ids;
|
|
100
|
+
}
|
|
101
|
+
async prepare(input, cwd, signal) {
|
|
102
|
+
let previous = null;
|
|
103
|
+
try {
|
|
104
|
+
previous = JSON.parse(await readFile(this.statePath(input), "utf8"));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!missing(error))
|
|
108
|
+
throw new Error("Local session state is unreadable; original files were preserved", { cause: error });
|
|
109
|
+
}
|
|
110
|
+
if (previous) {
|
|
111
|
+
if (previous.sessionId !== input.sessionId || previous.harness !== input.harness)
|
|
112
|
+
throw new Error("Local session identity mismatch");
|
|
113
|
+
const pending = previous.pendingTurnId;
|
|
114
|
+
if (pending) {
|
|
115
|
+
const resolved = input.context.resolvedTurnIds?.includes(pending) === true;
|
|
116
|
+
const settled = input.context.settledTurnIds?.includes(pending) === true;
|
|
117
|
+
const lostAcknowledgement = Boolean(previous.resultChecksum) && pending === input.context.throughTurnId && pending !== input.turnId;
|
|
118
|
+
// A human-confirmed stop is authoritative: rebuild from durable history instead of
|
|
119
|
+
// resuming a native projection whose outcome the server never recorded.
|
|
120
|
+
const retire = resolved || (settled && !lostAcknowledgement);
|
|
121
|
+
const nativeChecksum = await checksumNativeFile(previous.path).catch((error) => { if (missing(error))
|
|
122
|
+
return null; throw error; });
|
|
123
|
+
if (previous.resultChecksum && (settled || resolved) && nativeChecksum && nativeChecksum !== previous.resultChecksum) {
|
|
124
|
+
throw new Error("Native session changed outside Cohub; original data was preserved");
|
|
125
|
+
}
|
|
126
|
+
if (retire) {
|
|
127
|
+
// The server reached a terminal state for this turn. Archive the local projection and
|
|
128
|
+
// rebuild from durable context; native files are never deleted, so nothing is lost.
|
|
129
|
+
const receipt = await readFile(this.resultPath(previous), "utf8").catch((error) => { if (missing(error))
|
|
130
|
+
return null; throw error; });
|
|
131
|
+
const retired = { state: previous, receipt };
|
|
132
|
+
await atomicJson(join(this.root, "retired", `${pending}.${checksum(JSON.stringify(retired))}.json`), retired);
|
|
133
|
+
previous = null;
|
|
134
|
+
}
|
|
135
|
+
else if (lostAcknowledgement) {
|
|
136
|
+
// The server already persisted this turn; only our acknowledgement was lost.
|
|
137
|
+
await this.acknowledge(previous, pending, input.context.revision);
|
|
138
|
+
}
|
|
139
|
+
else if (input.context.complete === false) {
|
|
140
|
+
throw new ContextRequiredError("Server resolution is required for the pending native execution");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
throw new Error(`Local turn ${pending} has unconfirmed results; reconcile it before continuing`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (previous) {
|
|
148
|
+
try {
|
|
149
|
+
if (await checksumNativeFile(previous.path) !== previous.checksum)
|
|
150
|
+
throw new Error("Native session changed outside Cohub; original data was preserved");
|
|
151
|
+
if (previous.archivePendingTurnId) {
|
|
152
|
+
await this.archives.stage(previous, previous.archivePendingTurnId);
|
|
153
|
+
previous.archivePendingTurnId = undefined;
|
|
154
|
+
await atomicJson(this.statePath(previous), previous);
|
|
155
|
+
}
|
|
156
|
+
if (input.harness === "codex" && !previous.codexTokenTotals)
|
|
157
|
+
previous.codexTokenTotals = await readCodexArchiveTotals(previous.path);
|
|
158
|
+
if (previous.cwd === cwd && previous.throughTurnId === input.context.throughTurnId && previous.revision === input.context.revision)
|
|
159
|
+
return { state: previous, resume: "native" };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (!missing(error))
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (input.context.complete === false && !input.context.archive)
|
|
167
|
+
throw new ContextRequiredError("Full context is required to materialize this session");
|
|
168
|
+
const id = randomUUID();
|
|
169
|
+
const path = join(this.root, input.harness, `${id}.jsonl`);
|
|
170
|
+
const archive = input.context.archive;
|
|
171
|
+
const restore = archive?.harness === input.harness && archive.sessionId === input.sessionId && archive.turnId === input.context.throughTurnId;
|
|
172
|
+
const rawPath = join(this.root, "archives", "restored", `${id}.jsonl`);
|
|
173
|
+
const state = {
|
|
174
|
+
version: 1, harness: input.harness, sessionId: input.sessionId, nativeSessionId: id,
|
|
175
|
+
path, cwd, throughTurnId: input.context.throughTurnId, revision: input.context.revision, checksum: "", pendingTurnId: null,
|
|
176
|
+
};
|
|
177
|
+
if (restore) {
|
|
178
|
+
try {
|
|
179
|
+
const restored = await this.archives.restore(archive, rawPath, signal);
|
|
180
|
+
Object.assign(state, await importNativeArchive({ source: rawPath, target: path, harness: input.harness, nativeSessionId: restored.nativeSessionId, id, cwd, signal }));
|
|
181
|
+
return { state, resume: "restored" };
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
signal?.throwIfAborted();
|
|
185
|
+
console.error("Native archive unavailable; rebuilding from durable history / 原生归档不可用,将从持久历史重建:", error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (input.context.complete === false)
|
|
189
|
+
throw new ContextRequiredError("Database history is required after archive recovery failed", true);
|
|
190
|
+
let data = null;
|
|
191
|
+
if (input.harness === "pi") {
|
|
192
|
+
const entries = [{ type: "session", version: 3, id, cwd, timestamp: new Date().toISOString() }];
|
|
193
|
+
let parentId = null;
|
|
194
|
+
const history = selectRuntimeContextMessages(input.context.messages);
|
|
195
|
+
const summary = history[0]?.role === "system" ? history[0].content.find((block) => block.type === "system_note" && block.note_type === "compacted") : undefined;
|
|
196
|
+
let compaction;
|
|
197
|
+
if (summary?.type === "system_note") {
|
|
198
|
+
parentId = randomUUID().slice(0, 8);
|
|
199
|
+
compaction = { type: "compaction", id: parentId, parentId: null, timestamp: new Date().toISOString(), summary: summary.text,
|
|
200
|
+
firstKeptEntryId: "", tokensBefore: history[0]?.meta?.compaction?.tokensBefore ?? 0 };
|
|
201
|
+
entries.push(compaction);
|
|
202
|
+
}
|
|
203
|
+
for (const message of contextToPiMessages(history)) {
|
|
204
|
+
const entryId = randomUUID().slice(0, 8);
|
|
205
|
+
if (compaction && !compaction.firstKeptEntryId)
|
|
206
|
+
compaction.firstKeptEntryId = entryId;
|
|
207
|
+
entries.push({ type: "message", id: entryId, parentId, timestamp: new Date().toISOString(), message });
|
|
208
|
+
parentId = entryId;
|
|
209
|
+
}
|
|
210
|
+
data = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
211
|
+
}
|
|
212
|
+
if (data != null) {
|
|
213
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
214
|
+
const file = await open(path, "wx", 0o600);
|
|
215
|
+
try {
|
|
216
|
+
await file.writeFile(data);
|
|
217
|
+
await file.sync();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await file.close();
|
|
221
|
+
}
|
|
222
|
+
state.checksum = checksum(data);
|
|
223
|
+
}
|
|
224
|
+
return { state, resume: input.context.messages.length ? "handoff" : "new" };
|
|
225
|
+
}
|
|
226
|
+
async started(state, turnId) { state.pendingTurnId = turnId; state.resultChecksum = undefined; await atomicJson(this.statePath(state), state); }
|
|
227
|
+
resultPath(state) { return join(this.root, "results", `${state.sessionId}.${state.harness}.json`); }
|
|
228
|
+
async recordResult(state, requestId, events) {
|
|
229
|
+
if (!state.pendingTurnId)
|
|
230
|
+
throw new Error("Cannot record a result for an idle native session");
|
|
231
|
+
state.resultChecksum = await checksumNativeFile(state.path);
|
|
232
|
+
if (state.archivePendingTurnId)
|
|
233
|
+
await atomicJson(join(this.archives.root, "captures", `${state.archivePendingTurnId}.json`), state);
|
|
234
|
+
await atomicJson(this.resultPath(state), { requestId, state, events });
|
|
235
|
+
await atomicJson(this.statePath(state), state);
|
|
236
|
+
}
|
|
237
|
+
async recoverResult(input, requestId) {
|
|
238
|
+
try {
|
|
239
|
+
const saved = JSON.parse(await readFile(this.resultPath(input), "utf8"));
|
|
240
|
+
if (saved.state.sessionId !== input.sessionId || saved.state.harness !== input.harness)
|
|
241
|
+
throw new Error("Runtime result identity mismatch");
|
|
242
|
+
if (saved.state.pendingTurnId !== input.turnId)
|
|
243
|
+
return null;
|
|
244
|
+
if (requestId && saved.requestId !== requestId)
|
|
245
|
+
throw new Error("Runtime result execution identity mismatch");
|
|
246
|
+
return { state: saved.state, events: saved.events.map((event) => runtimeEventSchema.parse(event)) };
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (missing(error))
|
|
250
|
+
return null;
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async archive(state, turnId) {
|
|
255
|
+
if (!state.path)
|
|
256
|
+
return null;
|
|
257
|
+
state.archivePendingTurnId = turnId;
|
|
258
|
+
const reference = await this.archives.stage(state, turnId);
|
|
259
|
+
state.archivePendingTurnId = undefined;
|
|
260
|
+
return reference;
|
|
261
|
+
}
|
|
262
|
+
async acknowledge(state, turnId, revision) {
|
|
263
|
+
if (state.pendingTurnId !== turnId)
|
|
264
|
+
throw new Error("Runtime acknowledgement identity mismatch");
|
|
265
|
+
const currentChecksum = await checksumNativeFile(state.path).catch((error) => { if (missing(error))
|
|
266
|
+
return state.resultChecksum ?? state.checksum; throw error; });
|
|
267
|
+
if (state.resultChecksum && currentChecksum !== state.resultChecksum)
|
|
268
|
+
throw new Error("Native data changed before acknowledgement; files preserved");
|
|
269
|
+
const acknowledged = { ...state, checksum: currentChecksum, pendingTurnId: null, resultChecksum: undefined, throughTurnId: turnId, revision };
|
|
270
|
+
await atomicJson(this.statePath(state), acknowledged);
|
|
271
|
+
Object.assign(state, acknowledged);
|
|
272
|
+
}
|
|
273
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neta-art/cohub-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "CLI for Cohub — spaces, sessions, and agent collaboration.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24"
|
|
8
|
+
},
|
|
6
9
|
"license": "Apache-2.0",
|
|
7
10
|
"bin": {
|
|
8
11
|
"cohub": "./bin/cohub.js"
|
|
@@ -15,18 +18,20 @@
|
|
|
15
18
|
"NOTICE"
|
|
16
19
|
],
|
|
17
20
|
"dependencies": {
|
|
18
|
-
"@neta-art/generation": "^0.1.
|
|
21
|
+
"@neta-art/generation": "^0.1.30",
|
|
19
22
|
"commander": "^15.0.0",
|
|
20
23
|
"pixi.js": "^8.20.1",
|
|
21
24
|
"sharp": "^0.35.4",
|
|
22
|
-
"@neta-art/cohub": "8.
|
|
25
|
+
"@neta-art/cohub": "8.18.0"
|
|
23
26
|
},
|
|
24
27
|
"publishConfig": {
|
|
25
28
|
"access": "public"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
28
31
|
"@types/node": "^26.4.0",
|
|
29
|
-
"
|
|
32
|
+
"@types/ws": "^8.18.1",
|
|
33
|
+
"typescript": "^7.0.2",
|
|
34
|
+
"ws": "^8.21.3"
|
|
30
35
|
},
|
|
31
36
|
"optionalDependencies": {
|
|
32
37
|
"@napi-rs/canvas": "^1.0.8"
|
|
@@ -34,6 +39,8 @@
|
|
|
34
39
|
"scripts": {
|
|
35
40
|
"build": "tsc -p tsconfig.build.json",
|
|
36
41
|
"test": "node ../../scripts/test/run.mjs 'tests/**/*.test.ts'",
|
|
42
|
+
"test:runtime": "node --import tsx --test tests/runtime-*.integration.ts",
|
|
43
|
+
"test:runtime:native": "node --import tsx tests/runtime-native.smoke.ts",
|
|
37
44
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
38
45
|
}
|
|
39
46
|
}
|