@neta-art/cohub-cli 7.1.2 → 8.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 +40 -2
- package/dist/auth.js +38 -5
- package/dist/client.js +5 -2
- package/dist/commands/runtime.d.ts +1 -2
- package/dist/commands/runtime.js +178 -302
- package/dist/commands/sandboxd-binary.d.ts +1 -1
- package/dist/commands/sandboxd-binary.js +7 -5
- package/dist/runtime/archive-store.d.ts +2 -0
- package/dist/runtime/archive-store.js +20 -6
- package/dist/runtime/connection.d.ts +4 -2
- package/dist/runtime/connection.js +80 -23
- package/dist/runtime/diagnostics.d.ts +3 -0
- package/dist/runtime/diagnostics.js +3 -0
- package/dist/runtime/harness.d.ts +3 -0
- package/dist/runtime/harness.js +34 -1
- package/dist/runtime/instance.d.ts +5 -0
- package/dist/runtime/instance.js +159 -0
- package/dist/runtime/launch.d.ts +20 -0
- package/dist/runtime/launch.js +176 -0
- package/dist/runtime/native-codex-hook.d.ts +1 -0
- package/dist/runtime/native-codex-hook.js +28 -0
- package/dist/runtime/native-install.d.ts +21 -0
- package/dist/runtime/native-install.js +130 -0
- package/dist/runtime/native-ipc.d.ts +26 -0
- package/dist/runtime/native-ipc.js +101 -0
- package/dist/runtime/native-pi-extension.d.ts +20 -0
- package/dist/runtime/native-pi-extension.js +47 -0
- package/dist/runtime/native-sync-store.d.ts +97 -0
- package/dist/runtime/native-sync-store.js +365 -0
- package/dist/runtime/native-sync.d.ts +25 -0
- package/dist/runtime/native-sync.js +128 -0
- package/dist/runtime/native-transcript.d.ts +27 -0
- package/dist/runtime/native-transcript.js +281 -0
- package/dist/runtime/presentation.d.ts +21 -0
- package/dist/runtime/presentation.js +76 -0
- package/dist/runtime/session-store.d.ts +2 -0
- package/dist/runtime/session-store.js +40 -5
- package/dist/runtime/space-binding.d.ts +3 -0
- package/dist/runtime/space-binding.js +43 -6
- package/dist/runtime/supervisor.d.ts +16 -0
- package/dist/runtime/supervisor.js +277 -0
- package/dist/runtime/worker.d.ts +1 -0
- package/dist/runtime/worker.js +20 -0
- package/package.json +3 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, stat, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { nativeTurnCompleteSchema, nativeTurnStartSchema, nativeTurnProgressSchema, harnessArchiveIndexSchema } from "@neta-art/cohub";
|
|
6
|
+
import { RuntimeArchiveStore, atomicRuntimeJson } from "./archive-store.js";
|
|
7
|
+
import { findRuntimeNativeSession } from "./session-store.js";
|
|
8
|
+
import { withRuntimeSpaceBindingsLock } from "./space-binding.js";
|
|
9
|
+
const missing = (error) => error.code === "ENOENT";
|
|
10
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
11
|
+
export const nativeIdentityHash = (identity) => hash(identity);
|
|
12
|
+
export function nativeStableId(value) {
|
|
13
|
+
const hex = hash(value);
|
|
14
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
15
|
+
}
|
|
16
|
+
async function readJson(path) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (missing(error))
|
|
22
|
+
return null;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Turn receipts are append-only. Capture never waits for the network; network ACKs live in separate files. */
|
|
27
|
+
export class NativeSyncStore {
|
|
28
|
+
options;
|
|
29
|
+
root;
|
|
30
|
+
archives;
|
|
31
|
+
archiveFailure;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
const source = options.instanceKey ? JSON.stringify([options.nativeSessionId, options.instanceKey]) : options.nativeSessionId;
|
|
35
|
+
this.root = join(options.runtimeRoot, "native", nativeIdentityHash(options.identity), `${options.harness}-${hash(source)}`);
|
|
36
|
+
const transport = options.transport;
|
|
37
|
+
this.archives = new RuntimeArchiveStore(join(this.root, "archives"), transport ? {
|
|
38
|
+
prepareRuntimeArchive: async (index, request) => transport.prepareRuntimeArchive(await this.cloudArchive(index), request),
|
|
39
|
+
commitRuntimeArchive: async (index, request) => transport.commitRuntimeArchive(await this.cloudArchive(index), request),
|
|
40
|
+
getRuntimeArchive: (...args) => transport.getRuntimeArchive(...args),
|
|
41
|
+
fetchObject: transport.fetchObject,
|
|
42
|
+
} : undefined);
|
|
43
|
+
this.archives.setErrorReporter((error) => { this.archiveFailure = error; });
|
|
44
|
+
}
|
|
45
|
+
bindingPath() { return join(this.root, "binding.json"); }
|
|
46
|
+
turnId(key) {
|
|
47
|
+
return nativeStableId(JSON.stringify([this.options.identity, this.options.spaceId, this.options.harness, this.options.nativeSessionId, this.options.instanceKey ?? null, key]));
|
|
48
|
+
}
|
|
49
|
+
receiptPath(id) { return join(this.root, "turns", `${id}.json`); }
|
|
50
|
+
acknowledgementPath(id) { return join(this.root, "acknowledged", `${id}.json`); }
|
|
51
|
+
pendingPath(id) { return join(this.root, "pending", `${id}.json`); }
|
|
52
|
+
requestPath(id) { return join(this.root, "requests", `${id}.json`); }
|
|
53
|
+
cloudBindingPath(id) { return join(this.root, "bindings", `${id}.json`); }
|
|
54
|
+
async binding() {
|
|
55
|
+
const value = await readJson(this.bindingPath());
|
|
56
|
+
if (value?.version !== 1 || value.identity !== this.options.identity || value.spaceId !== this.options.spaceId || value.nativeSessionId !== this.options.nativeSessionId || value.instanceKey !== this.options.instanceKey || value.harness !== this.options.harness)
|
|
57
|
+
throw new Error("Native binding mismatch / 原生关联不匹配");
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
async initialize(path, transcript) {
|
|
61
|
+
const existing = await readJson(this.bindingPath());
|
|
62
|
+
if (existing) {
|
|
63
|
+
const binding = await this.binding();
|
|
64
|
+
if (binding.path !== path)
|
|
65
|
+
throw new Error("Native path changed; original binding retained / 原生路径已变化,原关联已保留");
|
|
66
|
+
return binding;
|
|
67
|
+
}
|
|
68
|
+
const managed = await findRuntimeNativeSession(this.options.runtimeRoot, this.options.harness, transcript.nativeSessionId, path);
|
|
69
|
+
let throughBytes = 0;
|
|
70
|
+
const anchors = [];
|
|
71
|
+
if (managed) {
|
|
72
|
+
if (managed.pendingTurnId)
|
|
73
|
+
throw new Error("Reconcile the managed Turn before native continuation / 请先确认 Runtime 中尚未确认的 Turn");
|
|
74
|
+
if (managed.throughTurnId) {
|
|
75
|
+
const index = await readJson(join(this.options.runtimeRoot, "archives", "versions", `${managed.throughTurnId}.json`));
|
|
76
|
+
if (index) {
|
|
77
|
+
const parsed = harnessArchiveIndexSchema.parse(index);
|
|
78
|
+
const checksum = createHash("sha256");
|
|
79
|
+
for await (const bytes of createReadStream(path, { end: parsed.sizeBytes - 1 }))
|
|
80
|
+
checksum.update(bytes);
|
|
81
|
+
if (checksum.digest("hex") !== parsed.sha256)
|
|
82
|
+
throw new Error("Runtime history prefix changed / Runtime 历史前缀已变化");
|
|
83
|
+
throughBytes = parsed.sizeBytes;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const checksum = createHash("sha256");
|
|
87
|
+
for await (const bytes of createReadStream(path))
|
|
88
|
+
checksum.update(bytes);
|
|
89
|
+
if (checksum.digest("hex") !== managed.checksum)
|
|
90
|
+
throw new Error("Cannot identify the last complete Runtime Turn / 无法识别 Runtime 最后一个完整 Turn");
|
|
91
|
+
throughBytes = (await stat(path)).size;
|
|
92
|
+
anchors.push({ turnId: managed.throughTurnId, sizeBytes: throughBytes, sha256: managed.checksum });
|
|
93
|
+
}
|
|
94
|
+
const versions = join(this.options.runtimeRoot, "archives", "versions");
|
|
95
|
+
const names = await readdir(versions).catch((error) => { if (missing(error))
|
|
96
|
+
return []; throw error; });
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
if (!name.endsWith(".json"))
|
|
99
|
+
continue;
|
|
100
|
+
const version = harnessArchiveIndexSchema.parse(await readJson(join(versions, name)));
|
|
101
|
+
if (version.sessionId === managed.sessionId && version.harness === managed.harness && version.nativeSessionId === managed.nativeSessionId && version.sizeBytes <= throughBytes) {
|
|
102
|
+
anchors.push({ turnId: version.turnId, sizeBytes: version.sizeBytes, sha256: version.sha256 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const binding = { version: 1, identity: this.options.identity, spaceId: this.options.spaceId, harness: this.options.harness,
|
|
108
|
+
nativeSessionId: transcript.nativeSessionId, instanceKey: this.options.instanceKey, path, originSessionId: nativeStableId(`${this.root}:archive`),
|
|
109
|
+
sessionId: managed?.sessionId ?? transcript.cloudSessionId ?? null, throughTurnId: managed?.throughTurnId ?? null, throughBytes, anchors };
|
|
110
|
+
// A managed Runtime must rebuild a separate projection rather than write into an interactive client's file.
|
|
111
|
+
await atomicRuntimeJson(join(this.options.runtimeRoot, "native-owners", `${hash(path)}.json`), { path, nativeSessionId: binding.nativeSessionId });
|
|
112
|
+
await atomicRuntimeJson(this.bindingPath(), binding);
|
|
113
|
+
return binding;
|
|
114
|
+
}
|
|
115
|
+
async capture(path, transcript) {
|
|
116
|
+
if (transcript.nativeSessionId !== this.options.nativeSessionId)
|
|
117
|
+
throw new Error("Native session identity mismatch / 原生会话身份不匹配");
|
|
118
|
+
await withRuntimeSpaceBindingsLock(async () => {
|
|
119
|
+
const binding = await this.initialize(path, transcript);
|
|
120
|
+
if (binding.throughBytes > 0) {
|
|
121
|
+
const anchor = binding.anchors.find((entry) => entry.sizeBytes === binding.throughBytes);
|
|
122
|
+
if (!anchor || transcript.prefixes.get(binding.throughBytes) !== anchor.sha256)
|
|
123
|
+
throw new Error("Runtime history prefix changed; original binding retained / Runtime 历史前缀已变化,原关联已保留");
|
|
124
|
+
}
|
|
125
|
+
let parentKey = null;
|
|
126
|
+
let parentCloudTurnId = binding.throughTurnId;
|
|
127
|
+
let knownBoundary = binding.throughBytes === 0;
|
|
128
|
+
for (const turn of transcript.turns) {
|
|
129
|
+
if (turn.startBytes < binding.throughBytes) {
|
|
130
|
+
// Native offsets only validate whole-Turn archive checkpoints; they never become cloud fork anchors.
|
|
131
|
+
const matches = (binding.anchors ?? []).filter((anchor) => anchor.sizeBytes >= turn.contentEndBytes && turn.boundaries[anchor.sizeBytes] === anchor.sha256);
|
|
132
|
+
if (new Set(matches.map((anchor) => anchor.turnId)).size > 1)
|
|
133
|
+
throw new Error("Ambiguous Runtime Turn boundary / Runtime Turn 边界不明确");
|
|
134
|
+
parentCloudTurnId = matches[0]?.turnId ?? null;
|
|
135
|
+
knownBoundary = matches.length > 0;
|
|
136
|
+
parentKey = null;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (turn.cloudTurnId) {
|
|
140
|
+
parentCloudTurnId = turn.cloudTurnId;
|
|
141
|
+
knownBoundary = binding.harness === "codex" ? turn.result !== null : !["toolUse", "pending"].includes(turn.messages.at(-1)?.stopReason ?? "pending");
|
|
142
|
+
parentKey = null;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!parentKey && !knownBoundary)
|
|
146
|
+
throw new Error("Native continuation is not at a complete Runtime Turn boundary / 原生续聊不在完整 Runtime Turn 边界上");
|
|
147
|
+
const turnId = this.turnId(turn.key);
|
|
148
|
+
const old = await readJson(this.receiptPath(turnId));
|
|
149
|
+
if (old?.result) {
|
|
150
|
+
if (turn.contentEndBytes < (old.contentEndBytes ?? old.endBytes) || JSON.stringify(turn.userContent) !== JSON.stringify(old.userContent) || turn.result && JSON.stringify(nativeTurnCompleteSchema.parse(turn.result)) !== JSON.stringify(old.result)) {
|
|
151
|
+
throw new Error("Native branch is inside a settled Turn; only whole-Turn forks are supported / 原生分支位于已结束的 Turn 内,仅支持完整 Turn 分支");
|
|
152
|
+
}
|
|
153
|
+
parentKey = turn.key;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const result = turn.result ? nativeTurnCompleteSchema.parse(turn.result) : null;
|
|
157
|
+
const receipt = { version: 1, turnId, key: turn.key, parentKey, parentCloudTurnId: parentKey ? null : parentCloudTurnId,
|
|
158
|
+
userContent: turn.userContent, startedAt: turn.startedAt, endBytes: turn.endBytes, contentEndBytes: turn.contentEndBytes, result,
|
|
159
|
+
...(!result ? { progress: nativeTurnProgressSchema.parse({ revision: turn.endBytes, messages: turn.messages }) } : {}) };
|
|
160
|
+
if (old && (JSON.stringify(old.userContent) !== JSON.stringify(receipt.userContent) || old.parentKey !== receipt.parentKey))
|
|
161
|
+
throw new Error("Native Turn changed; original receipt retained / 原生 Turn 已变化,原回执已保留");
|
|
162
|
+
// Capture immutable native bytes before publishing the completed receipt. Subsequent Turns may change the source.
|
|
163
|
+
if (result)
|
|
164
|
+
await this.archives.stage({ sessionId: binding.originSessionId, harness: binding.harness, nativeSessionId: binding.nativeSessionId, path, sizeBytes: turn.endBytes, expectedChecksum: turn.sha256 }, turnId);
|
|
165
|
+
if (JSON.stringify(old) !== JSON.stringify(receipt)) {
|
|
166
|
+
// The pending index precedes the receipt, so a crash cannot silently strand an unacknowledged Turn.
|
|
167
|
+
await atomicRuntimeJson(this.pendingPath(turnId), { turnId });
|
|
168
|
+
await atomicRuntimeJson(this.receiptPath(turnId), receipt);
|
|
169
|
+
}
|
|
170
|
+
parentKey = turn.key;
|
|
171
|
+
}
|
|
172
|
+
}, { lockPath: join(this.root, "capture.lock") });
|
|
173
|
+
}
|
|
174
|
+
async receipts(pendingOnly = false) {
|
|
175
|
+
const names = await readdir(join(this.root, pendingOnly ? "pending" : "turns")).catch((error) => { if (missing(error))
|
|
176
|
+
return []; throw error; });
|
|
177
|
+
const receipts = [];
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
if (!name.endsWith(".json"))
|
|
180
|
+
continue;
|
|
181
|
+
const receipt = await readJson(join(this.root, "turns", name));
|
|
182
|
+
if (pendingOnly && !receipt) {
|
|
183
|
+
// Crash window: the pending index was written but the receipt itself never landed.
|
|
184
|
+
// The pointer carries no data; drop it — the next capture rebuilds the receipt from the transcript.
|
|
185
|
+
await rm(join(this.root, "pending", name), { force: true });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (receipt?.version !== 1 || receipt.turnId !== this.turnId(receipt.key))
|
|
189
|
+
throw new Error("Native receipt is corrupt; original retained / 原生回执损坏,原件已保留");
|
|
190
|
+
receipts.push(receipt);
|
|
191
|
+
}
|
|
192
|
+
return receipts.sort((a, b) => a.endBytes - b.endBytes || a.turnId.localeCompare(b.turnId));
|
|
193
|
+
}
|
|
194
|
+
async status() {
|
|
195
|
+
const binding = await this.binding();
|
|
196
|
+
const receipts = await this.receipts();
|
|
197
|
+
let pendingTurns = 0;
|
|
198
|
+
let sessionId = binding.sessionId;
|
|
199
|
+
for (const receipt of receipts) {
|
|
200
|
+
const remote = await readJson(this.cloudBindingPath(receipt.turnId));
|
|
201
|
+
if (remote)
|
|
202
|
+
sessionId = remote.sessionId;
|
|
203
|
+
if (!await readJson(this.acknowledgementPath(receipt.turnId)))
|
|
204
|
+
pendingTurns++;
|
|
205
|
+
}
|
|
206
|
+
return { harness: binding.harness, nativeSessionId: binding.nativeSessionId, sessionId, pendingTurns, pendingArchives: await this.archives.pendingCount() };
|
|
207
|
+
}
|
|
208
|
+
async flush(signal, onAbort) {
|
|
209
|
+
const transport = this.options.transport;
|
|
210
|
+
if (!transport)
|
|
211
|
+
return;
|
|
212
|
+
await withRuntimeSpaceBindingsLock(async () => {
|
|
213
|
+
const binding = await this.binding();
|
|
214
|
+
const pending = new Map((await this.receipts(true)).map((receipt) => [receipt.key, receipt]));
|
|
215
|
+
const processed = new Set();
|
|
216
|
+
const visit = async (receipt) => {
|
|
217
|
+
signal.throwIfAborted();
|
|
218
|
+
if (await readJson(this.acknowledgementPath(receipt.turnId))) {
|
|
219
|
+
await rm(this.pendingPath(receipt.turnId), { force: true });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (processed.has(receipt.key))
|
|
223
|
+
return false;
|
|
224
|
+
processed.add(receipt.key);
|
|
225
|
+
let parent = null;
|
|
226
|
+
if (receipt.parentKey) {
|
|
227
|
+
const parentId = this.turnId(receipt.parentKey);
|
|
228
|
+
parent = await readJson(this.acknowledgementPath(parentId));
|
|
229
|
+
if (!parent) {
|
|
230
|
+
const predecessor = pending.get(receipt.parentKey);
|
|
231
|
+
if (!predecessor || !await visit(predecessor))
|
|
232
|
+
return false;
|
|
233
|
+
parent = await readJson(this.acknowledgementPath(parentId));
|
|
234
|
+
}
|
|
235
|
+
if (!parent)
|
|
236
|
+
throw new Error("Parent binding is missing / 父 Turn 关联缺失");
|
|
237
|
+
}
|
|
238
|
+
let request = await readJson(this.requestPath(receipt.turnId));
|
|
239
|
+
if (!request) {
|
|
240
|
+
request = nativeTurnStartSchema.parse({ turnId: receipt.turnId, sessionId: parent?.sessionId ?? binding.sessionId, parentTurnId: parent?.turnId ?? receipt.parentCloudTurnId,
|
|
241
|
+
branchSessionId: nativeStableId(`${receipt.turnId}:branch`), harness: binding.harness, nativeSessionId: binding.nativeSessionId, userContent: receipt.userContent, startedAt: receipt.startedAt });
|
|
242
|
+
await atomicRuntimeJson(this.requestPath(receipt.turnId), request);
|
|
243
|
+
}
|
|
244
|
+
let remote = await readJson(this.cloudBindingPath(receipt.turnId));
|
|
245
|
+
if (!remote) {
|
|
246
|
+
if (!transport.startNativeTurn)
|
|
247
|
+
throw new Error("Native Runtime WS is unavailable / 原生 Runtime WS 不可用");
|
|
248
|
+
remote = await transport.startNativeTurn(request, { signal });
|
|
249
|
+
if (remote.turnId !== receipt.turnId)
|
|
250
|
+
throw new Error("Server Turn identity mismatch / 服务端 Turn 身份不匹配");
|
|
251
|
+
await atomicRuntimeJson(this.cloudBindingPath(receipt.turnId), remote);
|
|
252
|
+
}
|
|
253
|
+
if (!receipt.result) {
|
|
254
|
+
if (receipt.progress?.messages.length && transport.updateNativeTurn) {
|
|
255
|
+
const progressPath = join(this.root, "progress", `${receipt.turnId}.json`);
|
|
256
|
+
const sent = await readJson(progressPath);
|
|
257
|
+
if (!sent || sent.revision < receipt.progress.revision) {
|
|
258
|
+
await transport.updateNativeTurn(remote.sessionId, remote.turnId, receipt.progress, { signal });
|
|
259
|
+
await atomicRuntimeJson(progressPath, { revision: receipt.progress.revision });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (onAbort && transport.heartbeatNativeTurn) {
|
|
263
|
+
const status = await transport.heartbeatNativeTurn(remote.sessionId, remote.turnId, { signal });
|
|
264
|
+
if (status.abortRequested)
|
|
265
|
+
onAbort?.();
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (!transport.completeNativeTurn)
|
|
270
|
+
throw new Error("Native Runtime WS is unavailable / 原生 Runtime WS 不可用");
|
|
271
|
+
// Artifact retries back off: the terminal state is durable, so hammering the completion
|
|
272
|
+
// endpoint every flush cycle (5s) while object storage is down only adds load.
|
|
273
|
+
const backoffPath = join(this.root, "backoff", `${receipt.turnId}.json`);
|
|
274
|
+
const backoff = await readJson(backoffPath);
|
|
275
|
+
if (backoff && Date.now() - backoff.at < 30_000)
|
|
276
|
+
return false;
|
|
277
|
+
const completion = await transport.completeNativeTurn(remote.sessionId, remote.turnId, receipt.result, { signal });
|
|
278
|
+
if (completion.artifactsPending) {
|
|
279
|
+
// Terminal state is durable; only the artifact snapshot is missing. Keep the receipt pending
|
|
280
|
+
// and retry on the next flush cycle (>= 30s) until artifacts persist.
|
|
281
|
+
await atomicRuntimeJson(backoffPath, { at: Date.now() });
|
|
282
|
+
throw new Error("Native artifacts are pending; completion replays later / 原生产物待生成,稍后重放完成请求");
|
|
283
|
+
}
|
|
284
|
+
await rm(backoffPath, { force: true });
|
|
285
|
+
await atomicRuntimeJson(this.acknowledgementPath(receipt.turnId), remote);
|
|
286
|
+
await rm(this.pendingPath(receipt.turnId), { force: true });
|
|
287
|
+
return true;
|
|
288
|
+
};
|
|
289
|
+
const failures = [];
|
|
290
|
+
for (const receipt of pending.values()) {
|
|
291
|
+
if (!processed.has(receipt.key)) {
|
|
292
|
+
try {
|
|
293
|
+
await visit(receipt);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
failures.push(error);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Resolve Session identities before the archive dependency walk. A fork baseline must not
|
|
301
|
+
// wait for an unrelated parent Session's failed upload. Immutable local versions stay untouched.
|
|
302
|
+
const archivePending = join(this.archives.root, "pending");
|
|
303
|
+
const indexes = await readdir(archivePending).catch((error) => { if (missing(error))
|
|
304
|
+
return []; throw error; });
|
|
305
|
+
for (const name of indexes) {
|
|
306
|
+
if (!name.endsWith(".json"))
|
|
307
|
+
continue;
|
|
308
|
+
try {
|
|
309
|
+
const path = join(archivePending, name);
|
|
310
|
+
const index = harnessArchiveIndexSchema.parse(await readJson(path));
|
|
311
|
+
const resolved = await this.cloudArchive(index);
|
|
312
|
+
if (JSON.stringify(index) !== JSON.stringify(resolved))
|
|
313
|
+
await atomicRuntimeJson(path, resolved);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
failures.push(error);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.archiveFailure = undefined;
|
|
320
|
+
await this.archives.flush(signal);
|
|
321
|
+
if (failures.length)
|
|
322
|
+
throw failures[0];
|
|
323
|
+
if (this.archiveFailure)
|
|
324
|
+
throw this.archiveFailure;
|
|
325
|
+
}, { lockPath: join(this.root, "flush.lock") });
|
|
326
|
+
}
|
|
327
|
+
async cloudArchive(index) {
|
|
328
|
+
const binding = await readJson(this.acknowledgementPath(index.turnId));
|
|
329
|
+
if (!binding)
|
|
330
|
+
throw new Error("Native Turn result is not confirmed / 原生 Turn 结果尚未确认");
|
|
331
|
+
if (index.parentTurnId) {
|
|
332
|
+
const parent = await readJson(this.acknowledgementPath(index.parentTurnId));
|
|
333
|
+
if (parent?.sessionId === binding.sessionId)
|
|
334
|
+
return { ...index, sessionId: binding.sessionId };
|
|
335
|
+
// Cross-Session archive parents are forbidden. Materialize a baseline using existing immutable segments.
|
|
336
|
+
const segments = [...index.segments];
|
|
337
|
+
const visited = new Set([index.turnId]);
|
|
338
|
+
let parentId = index.parentTurnId;
|
|
339
|
+
while (parentId) {
|
|
340
|
+
if (visited.has(parentId))
|
|
341
|
+
throw new Error("Cyclic native archive / 原生归档存在循环");
|
|
342
|
+
visited.add(parentId);
|
|
343
|
+
const previous = harnessArchiveIndexSchema.parse(await readJson(join(this.archives.root, "versions", `${parentId}.json`)));
|
|
344
|
+
segments.unshift(...previous.segments);
|
|
345
|
+
parentId = previous.parentTurnId;
|
|
346
|
+
}
|
|
347
|
+
return harnessArchiveIndexSchema.parse({ ...index, sessionId: binding.sessionId, parentTurnId: null, segments });
|
|
348
|
+
}
|
|
349
|
+
return { ...index, sessionId: binding.sessionId };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
export async function listNativeSyncStores(runtimeRoot, spaceId, identity, transport) {
|
|
353
|
+
const root = join(runtimeRoot, "native", nativeIdentityHash(identity));
|
|
354
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
355
|
+
const stores = [];
|
|
356
|
+
for (const name of await readdir(root)) {
|
|
357
|
+
if (!/^(pi|codex)-[a-f0-9]{64}$/.test(name))
|
|
358
|
+
continue;
|
|
359
|
+
const binding = await readJson(join(root, name, "binding.json"));
|
|
360
|
+
if (!binding || binding.identity !== identity || binding.spaceId !== spaceId)
|
|
361
|
+
continue;
|
|
362
|
+
stores.push(new NativeSyncStore({ runtimeRoot, spaceId, identity, harness: binding.harness, nativeSessionId: binding.nativeSessionId, instanceKey: binding.instanceKey, transport }));
|
|
363
|
+
}
|
|
364
|
+
return stores;
|
|
365
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NativeSyncStore, type NativeSyncTransport } from "./native-sync-store.js";
|
|
2
|
+
import type { NativeRuntimeEvent } from "@neta-art/cohub";
|
|
3
|
+
export type NativeSyncConfig = {
|
|
4
|
+
version: 1;
|
|
5
|
+
identity: string;
|
|
6
|
+
spaceId: string;
|
|
7
|
+
root: string;
|
|
8
|
+
harnesses: ("pi" | "codex")[];
|
|
9
|
+
};
|
|
10
|
+
export declare const nativeRuntimeRoot: (spaceId: string) => string;
|
|
11
|
+
export declare const nativeSyncConfigPath: (runtimeRoot: string, identity: string) => string;
|
|
12
|
+
export declare function nativeArchiveTransport(spaceId: string, identity: string): Pick<NativeSyncTransport, "prepareRuntimeArchive" | "commitRuntimeArchive" | "getRuntimeArchive">;
|
|
13
|
+
export declare function readNativeSyncConfig(runtimeRoot: string, identity: string): Promise<NativeSyncConfig | null>;
|
|
14
|
+
/** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
|
|
15
|
+
export declare function captureNativeSession(input: {
|
|
16
|
+
harness: "pi" | "codex";
|
|
17
|
+
cwd: string;
|
|
18
|
+
path: string;
|
|
19
|
+
nativeSessionId?: string;
|
|
20
|
+
settled?: boolean;
|
|
21
|
+
leafId?: string | null;
|
|
22
|
+
}): Promise<NativeSyncStore | null>;
|
|
23
|
+
/** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
|
|
24
|
+
export declare function nativeWebSocketTransport(spaceId: string, identity: string, send: (event: NativeRuntimeEvent) => Promise<unknown>): NativeSyncTransport;
|
|
25
|
+
export declare function flushNativeSessions(spaceId: string, identity: string, signal: AbortSignal, report: (error: unknown) => void, transportOverride?: NativeSyncTransport): Promise<void>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createClient } from "../client.js";
|
|
5
|
+
import { currentIdentityKey } from "../space.js";
|
|
6
|
+
import { canonicalRuntimeRoot, getRuntimeSpaceBinding } from "./space-binding.js";
|
|
7
|
+
import { readNativeTranscript } from "./native-transcript.js";
|
|
8
|
+
import { findRuntimeNativeSession } from "./session-store.js";
|
|
9
|
+
import { listNativeSyncStores, nativeIdentityHash, NativeSyncStore } from "./native-sync-store.js";
|
|
10
|
+
export const nativeRuntimeRoot = (spaceId) => join(homedir(), ".local", "state", "cohub", "runtime", spaceId);
|
|
11
|
+
export const nativeSyncConfigPath = (runtimeRoot, identity) => join(runtimeRoot, "native", nativeIdentityHash(identity), "config.json");
|
|
12
|
+
export function nativeArchiveTransport(spaceId, identity) {
|
|
13
|
+
const client = createClient().space(spaceId);
|
|
14
|
+
const guard = async (task) => {
|
|
15
|
+
if (currentIdentityKey() !== identity)
|
|
16
|
+
throw new Error("Native sync account changed / 原生同步账号已变化");
|
|
17
|
+
const result = await task();
|
|
18
|
+
if (currentIdentityKey() !== identity)
|
|
19
|
+
throw new Error("Native sync account changed / 原生同步账号已变化");
|
|
20
|
+
return result;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
prepareRuntimeArchive: (...args) => guard(() => client.prepareRuntimeArchive(...args)),
|
|
24
|
+
commitRuntimeArchive: (...args) => guard(() => client.commitRuntimeArchive(...args)),
|
|
25
|
+
getRuntimeArchive: (...args) => guard(() => client.getRuntimeArchive(...args)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function readNativeSyncConfig(runtimeRoot, identity) {
|
|
29
|
+
try {
|
|
30
|
+
const config = JSON.parse(await readFile(nativeSyncConfigPath(runtimeRoot, identity), "utf8"));
|
|
31
|
+
if (config.version !== 1 || config.identity !== identity || !Array.isArray(config.harnesses))
|
|
32
|
+
throw new Error("Invalid native sync configuration / 原生同步配置无效");
|
|
33
|
+
return config;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === "ENOENT")
|
|
37
|
+
return null;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const nativeStores = new Map();
|
|
42
|
+
/** Local capture only. Neither Pi callbacks nor Codex hooks wait for Cohub's network. */
|
|
43
|
+
export async function captureNativeSession(input) {
|
|
44
|
+
if (process.env.COHUB_TURN_ID || process.env.COHUB_EXECUTION_TOKEN)
|
|
45
|
+
return null;
|
|
46
|
+
const identity = currentIdentityKey();
|
|
47
|
+
if (!identity)
|
|
48
|
+
return null;
|
|
49
|
+
const root = await canonicalRuntimeRoot(input.cwd);
|
|
50
|
+
const space = await getRuntimeSpaceBinding(root, identity);
|
|
51
|
+
if (!space)
|
|
52
|
+
return null;
|
|
53
|
+
const runtimeRoot = nativeRuntimeRoot(space.spaceId);
|
|
54
|
+
const config = await readNativeSyncConfig(runtimeRoot, identity);
|
|
55
|
+
if (!config || config.root !== root || config.spaceId !== space.spaceId || !config.harnesses.includes(input.harness))
|
|
56
|
+
return null;
|
|
57
|
+
const path = await canonicalRuntimeRoot(input.path);
|
|
58
|
+
const transcript = await readNativeTranscript(path, input.harness, input);
|
|
59
|
+
if (await canonicalRuntimeRoot(transcript.cwd) !== root || input.nativeSessionId && transcript.nativeSessionId !== input.nativeSessionId)
|
|
60
|
+
throw new Error("Native transcript belongs to another project or Session / 原生记录属于其他项目或会话");
|
|
61
|
+
const key = JSON.stringify([identity, space.spaceId, input.harness, transcript.nativeSessionId, path]);
|
|
62
|
+
let store = nativeStores.get(key);
|
|
63
|
+
if (!store) {
|
|
64
|
+
const transport = nativeArchiveTransport(space.spaceId, identity);
|
|
65
|
+
const candidates = (await listNativeSyncStores(runtimeRoot, space.spaceId, identity, transport))
|
|
66
|
+
.filter((candidate) => candidate.options.harness === input.harness && candidate.options.nativeSessionId === transcript.nativeSessionId);
|
|
67
|
+
for (const candidate of candidates)
|
|
68
|
+
if ((await candidate.binding()).path === path) {
|
|
69
|
+
store = candidate;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
if (!store) {
|
|
73
|
+
const managed = await findRuntimeNativeSession(runtimeRoot, input.harness, transcript.nativeSessionId, path);
|
|
74
|
+
const managedPath = managed ? await canonicalRuntimeRoot(managed.path).catch((error) => { if (error.code === "ENOENT")
|
|
75
|
+
return null; throw error; }) : null;
|
|
76
|
+
if (candidates.length && managedPath !== path)
|
|
77
|
+
throw new Error("Native path changed; original bindings retained / 原生路径已变化,原关联已保留");
|
|
78
|
+
// Restored Pi working copies can share a native ID. Existing Cohub sidecars disambiguate them.
|
|
79
|
+
store = new NativeSyncStore({ runtimeRoot, spaceId: space.spaceId, identity, harness: input.harness, nativeSessionId: transcript.nativeSessionId,
|
|
80
|
+
instanceKey: managedPath === path ? path : undefined, transport });
|
|
81
|
+
}
|
|
82
|
+
if (nativeStores.size >= 256)
|
|
83
|
+
nativeStores.delete(nativeStores.keys().next().value ?? "");
|
|
84
|
+
nativeStores.set(key, store);
|
|
85
|
+
}
|
|
86
|
+
await store.capture(path, transcript);
|
|
87
|
+
return store;
|
|
88
|
+
}
|
|
89
|
+
/** The existing Runtime supervisor retries receipts even after the original terminal has exited. */
|
|
90
|
+
export function nativeWebSocketTransport(spaceId, identity, send) {
|
|
91
|
+
const archive = nativeArchiveTransport(spaceId, identity);
|
|
92
|
+
return {
|
|
93
|
+
...archive,
|
|
94
|
+
startNativeTurn: async (input) => await send({ type: "start", input }),
|
|
95
|
+
completeNativeTurn: async (sessionId, turnId, result) => await send({ type: "complete", sessionId, turnId, result }),
|
|
96
|
+
updateNativeTurn: async (sessionId, turnId, progress) => await send({ type: "progress", sessionId, turnId, progress }),
|
|
97
|
+
heartbeatNativeTurn: async (sessionId, turnId) => await send({ type: "heartbeat", sessionId, turnId }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export async function flushNativeSessions(spaceId, identity, signal, report, transportOverride) {
|
|
101
|
+
if (currentIdentityKey() !== identity)
|
|
102
|
+
return;
|
|
103
|
+
const runtimeRoot = nativeRuntimeRoot(spaceId);
|
|
104
|
+
const config = await readNativeSyncConfig(runtimeRoot, identity);
|
|
105
|
+
if (!config || config.spaceId !== spaceId)
|
|
106
|
+
return;
|
|
107
|
+
const transport = transportOverride;
|
|
108
|
+
if (!transport)
|
|
109
|
+
return;
|
|
110
|
+
const stores = await listNativeSyncStores(runtimeRoot, spaceId, identity, transport);
|
|
111
|
+
for (const store of stores) {
|
|
112
|
+
signal.throwIfAborted();
|
|
113
|
+
const binding = await store.binding();
|
|
114
|
+
if (!config.harnesses.includes(binding.harness))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
// Codex hooks only push capture requests while its terminal lives; the Daemon keeps re-reading
|
|
118
|
+
// the transcript between hooks so Stop-flushed Turns are picked up even if a hook is missed.
|
|
119
|
+
if (binding.harness === "codex")
|
|
120
|
+
await store.capture(binding.path, await readNativeTranscript(binding.path, "codex"));
|
|
121
|
+
await store.flush(AbortSignal.any([signal, AbortSignal.timeout(30_000)]));
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!signal.aborted)
|
|
125
|
+
report(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ContentBlock, NativeTurnComplete, NativeTurnMessage } from "@neta-art/cohub";
|
|
2
|
+
export type NativeTranscriptTurn = {
|
|
3
|
+
key: string;
|
|
4
|
+
parentKey: string | null;
|
|
5
|
+
cloudTurnId?: string;
|
|
6
|
+
userContent: ContentBlock[];
|
|
7
|
+
messages: NativeTurnMessage[];
|
|
8
|
+
startedAt: string;
|
|
9
|
+
startBytes: number;
|
|
10
|
+
endBytes: number;
|
|
11
|
+
contentEndBytes: number;
|
|
12
|
+
boundaries: Record<number, string>;
|
|
13
|
+
sha256: string;
|
|
14
|
+
result: NativeTurnComplete | null;
|
|
15
|
+
};
|
|
16
|
+
export type NativeTranscript = {
|
|
17
|
+
nativeSessionId: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
cloudSessionId?: string;
|
|
20
|
+
turns: NativeTranscriptTurn[];
|
|
21
|
+
prefixes: ReadonlyMap<number, string>;
|
|
22
|
+
};
|
|
23
|
+
/** Partial trailing records are retried, never parsed or acknowledged as complete. */
|
|
24
|
+
export declare function readNativeTranscript(path: string, harness: "pi" | "codex", options?: {
|
|
25
|
+
settled?: boolean;
|
|
26
|
+
leafId?: string | null;
|
|
27
|
+
}): Promise<NativeTranscript>;
|