@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,281 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { piContent } from "./harness.js";
|
|
4
|
+
import { codexTokenTotals, codexUsage } from "./codex-usage.js";
|
|
5
|
+
import { record } from "./json-rpc.js";
|
|
6
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
7
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
8
|
+
const iso = (value) => {
|
|
9
|
+
const date = new Date(typeof value === "number" || typeof value === "string" ? value : 0);
|
|
10
|
+
if (!Number.isFinite(date.getTime()))
|
|
11
|
+
throw new Error("Invalid native timestamp / 原生时间无效");
|
|
12
|
+
return date.toISOString();
|
|
13
|
+
};
|
|
14
|
+
/** Partial trailing records are retried, never parsed or acknowledged as complete. */
|
|
15
|
+
export async function readNativeTranscript(path, harness, options = {}) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
const prefixes = new Map();
|
|
18
|
+
let fragments = [], pendingBytes = 0, offset = 0;
|
|
19
|
+
const checksum = createHash("sha256");
|
|
20
|
+
const append = (bytes) => {
|
|
21
|
+
if (!bytes.length)
|
|
22
|
+
return;
|
|
23
|
+
fragments.push(bytes);
|
|
24
|
+
pendingBytes += bytes.length;
|
|
25
|
+
checksum.update(bytes);
|
|
26
|
+
if (pendingBytes > 32 * 1024 * 1024)
|
|
27
|
+
throw new Error("Native record is too large / 原生记录过大");
|
|
28
|
+
if (offset + pendingBytes > 128 * 1024 * 1024)
|
|
29
|
+
throw new Error("Native transcript exceeds the capture limit; original retained / 原生记录超出采集上限,原件已保留");
|
|
30
|
+
};
|
|
31
|
+
for await (const chunk of createReadStream(path)) {
|
|
32
|
+
let start = 0;
|
|
33
|
+
for (let end = chunk.indexOf(10); end >= 0; end = chunk.indexOf(10, start)) {
|
|
34
|
+
append(chunk.subarray(start, end + 1));
|
|
35
|
+
// Concatenate once per record, not once per read chunk (large base64 images stay linear).
|
|
36
|
+
const bytes = fragments.length === 1 ? fragments[0] : Buffer.concat(fragments, pendingBytes);
|
|
37
|
+
const line = bytes.subarray(0, bytes.length - 1);
|
|
38
|
+
const startBytes = offset;
|
|
39
|
+
offset += pendingBytes;
|
|
40
|
+
fragments = [];
|
|
41
|
+
pendingBytes = 0;
|
|
42
|
+
const sha256 = checksum.copy().digest("hex");
|
|
43
|
+
prefixes.set(offset, sha256);
|
|
44
|
+
if (line.length) {
|
|
45
|
+
let value;
|
|
46
|
+
try {
|
|
47
|
+
value = record(JSON.parse(line.toString("utf8")));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error("Invalid native JSON record; original retained / 原生 JSON 记录无效,原件已保留");
|
|
51
|
+
}
|
|
52
|
+
lines.push({ value, startBytes, endBytes: offset, sha256 });
|
|
53
|
+
}
|
|
54
|
+
start = end + 1;
|
|
55
|
+
}
|
|
56
|
+
append(chunk.subarray(start));
|
|
57
|
+
}
|
|
58
|
+
if (!lines.length)
|
|
59
|
+
throw new Error("Native transcript is empty / 原生记录为空");
|
|
60
|
+
return { ...(harness === "pi" ? parsePiTranscript(lines, options) : parseCodexTranscript(lines)), prefixes };
|
|
61
|
+
}
|
|
62
|
+
function parsePiTranscript(lines, options) {
|
|
63
|
+
const header = lines[0]?.value ?? {};
|
|
64
|
+
if (header.type !== "session" || !text(header.id))
|
|
65
|
+
throw new Error("Invalid Pi session / Pi 会话无效");
|
|
66
|
+
const entries = new Map(lines.slice(1).filter((line) => text(line.value.id)).map((line) => [text(line.value.id), line]));
|
|
67
|
+
const branch = [];
|
|
68
|
+
let leaf = options.leafId ?? (lines.length > 1 ? text(lines.at(-1)?.value.id) : "");
|
|
69
|
+
const visited = new Set();
|
|
70
|
+
while (leaf) {
|
|
71
|
+
if (visited.has(leaf))
|
|
72
|
+
throw new Error("Cyclic Pi history / Pi 历史存在循环");
|
|
73
|
+
visited.add(leaf);
|
|
74
|
+
const entry = entries.get(leaf);
|
|
75
|
+
if (!entry)
|
|
76
|
+
throw new Error("Pi parent history is missing / Pi 父历史缺失");
|
|
77
|
+
branch.push(entry);
|
|
78
|
+
leaf = text(entry.value.parentId);
|
|
79
|
+
}
|
|
80
|
+
branch.reverse();
|
|
81
|
+
const turns = [];
|
|
82
|
+
let cloudSessionId = text(record(header.cohub).sessionId) || text(record(header.affinity).sessionId);
|
|
83
|
+
let current = null;
|
|
84
|
+
let messages = [];
|
|
85
|
+
let completedAt = iso(header.timestamp);
|
|
86
|
+
const finish = (settled) => {
|
|
87
|
+
if (!current)
|
|
88
|
+
return;
|
|
89
|
+
const last = messages.at(-1);
|
|
90
|
+
if (settled)
|
|
91
|
+
current.result = { messages, completedAt, status: !last || ["aborted", "pending", "toolUse"].includes(last.stopReason ?? "") ? "interrupted" : last?.errorMessage || last?.stopReason === "error" ? "failed" : "completed" };
|
|
92
|
+
turns.push(current);
|
|
93
|
+
};
|
|
94
|
+
for (const line of branch) {
|
|
95
|
+
const entry = line.value;
|
|
96
|
+
if (entry.type !== "message") {
|
|
97
|
+
if (current) {
|
|
98
|
+
current.endBytes = line.endBytes;
|
|
99
|
+
current.sha256 = line.sha256;
|
|
100
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const message = record(entry.message);
|
|
105
|
+
if (!["user", "assistant", "toolResult"].includes(text(message.role))) {
|
|
106
|
+
// Native-only UI / shell records remain in raw archives; they do not rewrite a settled agent Turn.
|
|
107
|
+
if (current) {
|
|
108
|
+
current.endBytes = line.endBytes;
|
|
109
|
+
current.sha256 = line.sha256;
|
|
110
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const timestamp = iso(entry.timestamp ?? message.timestamp);
|
|
115
|
+
if (message.role === "user") {
|
|
116
|
+
finish(true);
|
|
117
|
+
completedAt = timestamp;
|
|
118
|
+
const cloudTurnId = text(record(message.meta).turnId);
|
|
119
|
+
if (cloudTurnId && !text(record(header.cohub).sessionId) && !text(record(header.affinity).sessionId))
|
|
120
|
+
cloudSessionId = text(record(message.meta).sourceSessionId) || cloudSessionId;
|
|
121
|
+
current = { key: text(entry.id), parentKey: turns.at(-1)?.key ?? null, ...(cloudTurnId ? { cloudTurnId } : {}), userContent: typeof message.content === "string" ? [{ type: "text", text: message.content }] : piContent(message.content), messages: [], startedAt: completedAt, startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null };
|
|
122
|
+
messages = current.messages;
|
|
123
|
+
}
|
|
124
|
+
else if (current && message.role === "assistant") {
|
|
125
|
+
messages.push({ content: piContent(message.content), provider: text(message.provider) || null, model: text(message.model) || null,
|
|
126
|
+
usage: record(message.usage), stopReason: text(message.stopReason) || null, errorMessage: text(message.errorMessage) || null });
|
|
127
|
+
}
|
|
128
|
+
else if (current && message.role === "toolResult") {
|
|
129
|
+
const assistant = messages.at(-1);
|
|
130
|
+
if (!assistant)
|
|
131
|
+
throw new Error("Pi tool result has no assistant Turn / Pi 工具结果缺少所属 Turn");
|
|
132
|
+
assistant.content.push({ type: "tool_result", tool_use_id: text(message.toolCallId), content: typeof message.content === "string" ? message.content : piContent(message.content), is_error: Boolean(message.isError) });
|
|
133
|
+
}
|
|
134
|
+
if (current) {
|
|
135
|
+
current.endBytes = line.endBytes;
|
|
136
|
+
current.contentEndBytes = line.endBytes;
|
|
137
|
+
current.sha256 = line.sha256;
|
|
138
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
139
|
+
}
|
|
140
|
+
completedAt = timestamp;
|
|
141
|
+
}
|
|
142
|
+
finish(Boolean(options.settled));
|
|
143
|
+
return { nativeSessionId: text(header.id), cwd: text(header.cwd), cloudSessionId: cloudSessionId || undefined, turns };
|
|
144
|
+
}
|
|
145
|
+
function codexContent(value) {
|
|
146
|
+
return list(value).flatMap((item) => {
|
|
147
|
+
const block = record(item);
|
|
148
|
+
if (["input_text", "output_text", "text"].includes(text(block.type)))
|
|
149
|
+
return [{ type: "text", text: text(block.text) }];
|
|
150
|
+
if (block.type === "input_image" && text(block.image_url)) {
|
|
151
|
+
const data = /^data:([^;]+);base64,(.*)$/s.exec(text(block.image_url));
|
|
152
|
+
return [{ type: "image", source: data ? { type: "base64", media_type: data[1] ?? "image/png", data: data[2] ?? "" } : { type: "url", url: text(block.image_url) } }];
|
|
153
|
+
}
|
|
154
|
+
return [];
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function parseCodexTranscript(lines) {
|
|
158
|
+
const header = lines[0]?.value ?? {};
|
|
159
|
+
const metadata = record(header.payload);
|
|
160
|
+
if (header.type !== "session_meta" || !text(metadata.id))
|
|
161
|
+
throw new Error("Invalid Codex session / Codex 会话无效");
|
|
162
|
+
if (metadata.history_base || metadata.fork_source)
|
|
163
|
+
throw new Error("Codex history references another rollout; retain the original and materialize its full history first / Codex 历史引用其他记录,请保留原件并先导出完整历史");
|
|
164
|
+
const turns = [];
|
|
165
|
+
let current = null;
|
|
166
|
+
let messages = [];
|
|
167
|
+
let model = null;
|
|
168
|
+
let cloudSessionId = text(record(metadata.cohub).sessionId);
|
|
169
|
+
let provider = text(metadata.model_provider) || null;
|
|
170
|
+
let userFromResponse = false;
|
|
171
|
+
const usageByTurn = new Map();
|
|
172
|
+
const assistant = () => {
|
|
173
|
+
let last = messages.at(-1);
|
|
174
|
+
if (!last) {
|
|
175
|
+
last = { content: [], model, provider };
|
|
176
|
+
messages.push(last);
|
|
177
|
+
}
|
|
178
|
+
return last;
|
|
179
|
+
};
|
|
180
|
+
for (const line of lines.slice(1)) {
|
|
181
|
+
const entry = line.value, payload = record(entry.payload);
|
|
182
|
+
if (entry.type === "turn_context") {
|
|
183
|
+
model = text(payload.model) || model;
|
|
184
|
+
provider = text(payload.model_provider) || provider;
|
|
185
|
+
}
|
|
186
|
+
if (entry.type === "event_msg" && ["turn_started", "task_started"].includes(text(payload.type))) {
|
|
187
|
+
if (current)
|
|
188
|
+
turns.push(current);
|
|
189
|
+
current = { key: text(payload.turn_id), parentKey: turns.at(-1)?.key ?? null, userContent: [], messages: [], startedAt: iso(entry.timestamp), startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null };
|
|
190
|
+
if (!current.key)
|
|
191
|
+
throw new Error("Codex Turn identity is missing / Codex Turn 身份缺失");
|
|
192
|
+
messages = current.messages;
|
|
193
|
+
userFromResponse = false;
|
|
194
|
+
}
|
|
195
|
+
if (entry.type === "token_usage_record") {
|
|
196
|
+
const usage = record(payload.turn_token_usage);
|
|
197
|
+
if (typeof usage.total_tokens === "number")
|
|
198
|
+
usageByTurn.set(text(payload.turn_id), codexUsage(codexTokenTotals({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cachedInputTokens: usage.cached_input_tokens, cacheWriteInputTokens: usage.cache_write_input_tokens, totalTokens: usage.total_tokens })));
|
|
199
|
+
}
|
|
200
|
+
if (!current) {
|
|
201
|
+
const previous = turns.at(-1);
|
|
202
|
+
if (previous) {
|
|
203
|
+
previous.endBytes = line.endBytes;
|
|
204
|
+
previous.sha256 = line.sha256;
|
|
205
|
+
previous.boundaries[line.endBytes] = line.sha256;
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
current.endBytes = line.endBytes;
|
|
210
|
+
current.sha256 = line.sha256;
|
|
211
|
+
current.boundaries[line.endBytes] = line.sha256;
|
|
212
|
+
current.contentEndBytes = line.endBytes;
|
|
213
|
+
if (entry.type === "response_item") {
|
|
214
|
+
const cohub = record(record(entry.metadata).cohub);
|
|
215
|
+
if (typeof cohub.turnId === "string") {
|
|
216
|
+
current.cloudTurnId = cohub.turnId;
|
|
217
|
+
if (!text(record(metadata.cohub).sessionId))
|
|
218
|
+
cloudSessionId = text(cohub.sessionId) || cloudSessionId;
|
|
219
|
+
}
|
|
220
|
+
if (payload.type === "message" && payload.role === "user") {
|
|
221
|
+
if (!userFromResponse)
|
|
222
|
+
current.userContent = [];
|
|
223
|
+
current.userContent.push(...codexContent(payload.content));
|
|
224
|
+
userFromResponse = true;
|
|
225
|
+
}
|
|
226
|
+
else if (payload.type === "message" && payload.role === "assistant") {
|
|
227
|
+
const content = codexContent(payload.content);
|
|
228
|
+
if (!messages.length || messages.at(-1)?.content.length)
|
|
229
|
+
messages.push({ content, model, provider });
|
|
230
|
+
else
|
|
231
|
+
assistant().content.push(...content);
|
|
232
|
+
}
|
|
233
|
+
else if (payload.type === "reasoning") {
|
|
234
|
+
const thinking = list(payload.summary).map((item) => text(record(item).text)).join("\n");
|
|
235
|
+
if (thinking)
|
|
236
|
+
assistant().content.push({ type: "thinking", thinking });
|
|
237
|
+
}
|
|
238
|
+
else if (["function_call", "custom_tool_call"].includes(text(payload.type))) {
|
|
239
|
+
let input;
|
|
240
|
+
try {
|
|
241
|
+
input = payload.type === "custom_tool_call" ? { input: payload.input } : record(JSON.parse(text(payload.arguments)));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
input = { raw: payload.arguments };
|
|
245
|
+
}
|
|
246
|
+
assistant().content.push({ type: "tool_use", id: text(payload.call_id), name: text(payload.name), input });
|
|
247
|
+
}
|
|
248
|
+
else if (["function_call_output", "custom_tool_call_output"].includes(text(payload.type))) {
|
|
249
|
+
assistant().content.push({ type: "tool_result", tool_use_id: text(payload.call_id), content: typeof payload.output === "string" ? payload.output : JSON.stringify(payload.output ?? null) });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (entry.type === "event_msg" && payload.type === "user_message" && !userFromResponse) {
|
|
253
|
+
current.userContent = [{ type: "text", text: text(payload.message) }];
|
|
254
|
+
userFromResponse = true;
|
|
255
|
+
}
|
|
256
|
+
if (entry.type === "event_msg" && ["turn_complete", "task_complete", "turn_aborted"].includes(text(payload.type))) {
|
|
257
|
+
if (payload.turn_id && payload.turn_id !== current.key)
|
|
258
|
+
throw new Error("Codex Turn boundary mismatch / Codex Turn 边界不匹配");
|
|
259
|
+
if (!messages.length && text(payload.last_agent_message))
|
|
260
|
+
messages.push({ content: [{ type: "text", text: text(payload.last_agent_message) }], model, provider });
|
|
261
|
+
const errorMessage = text(record(payload.error).message);
|
|
262
|
+
const status = payload.type === "turn_aborted" ? "interrupted" : errorMessage ? "failed" : "completed";
|
|
263
|
+
const final = assistant();
|
|
264
|
+
final.stopReason = status === "interrupted" ? "aborted" : status === "failed" ? "error" : "stop";
|
|
265
|
+
if (errorMessage)
|
|
266
|
+
final.errorMessage = errorMessage;
|
|
267
|
+
current.result = { messages, completedAt: iso(entry.timestamp), status };
|
|
268
|
+
turns.push(current);
|
|
269
|
+
current = null;
|
|
270
|
+
messages = [];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (current && userFromResponse)
|
|
274
|
+
turns.push(current);
|
|
275
|
+
for (const turn of turns) {
|
|
276
|
+
const last = turn.result?.messages.at(-1);
|
|
277
|
+
if (last && usageByTurn.has(turn.key))
|
|
278
|
+
last.usage = usageByTurn.get(turn.key);
|
|
279
|
+
}
|
|
280
|
+
return { nativeSessionId: text(metadata.id), cwd: text(metadata.cwd), cloudSessionId: cloudSessionId || undefined, turns };
|
|
281
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RuntimeDiagnostic, RuntimeDiagnosticLevel } from "./diagnostics.js";
|
|
2
|
+
export declare const diagnosticLevels: RuntimeDiagnosticLevel[];
|
|
3
|
+
export declare const atLeastLevel: (level: RuntimeDiagnosticLevel, minimum: RuntimeDiagnosticLevel) => boolean;
|
|
4
|
+
export declare const runtimeWebUrl: (spaceId: string) => string;
|
|
5
|
+
export declare function formatDiagnostic(event: RuntimeDiagnostic, verbose?: boolean): string;
|
|
6
|
+
/** File logs retain every event; repeated terminal warnings are coalesced. */
|
|
7
|
+
export declare function createDiagnosticConsole(verbose?: boolean, write?: (line: string) => boolean): (event: RuntimeDiagnostic) => void;
|
|
8
|
+
export type RuntimeSummary = {
|
|
9
|
+
spaceId: string;
|
|
10
|
+
runtimeId: string;
|
|
11
|
+
root: string;
|
|
12
|
+
harnesses: string[];
|
|
13
|
+
pid: number;
|
|
14
|
+
state: "starting" | "ready" | "reconnecting" | "attention" | "stopping";
|
|
15
|
+
harnessConnected: boolean;
|
|
16
|
+
workspaceConnected: boolean;
|
|
17
|
+
diagnosticsPath: string;
|
|
18
|
+
background: boolean;
|
|
19
|
+
nativeSync?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function printRuntimeSummary(summary: RuntimeSummary, json?: boolean, reused?: boolean): void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { resolveCohubEnvironment } from "@neta-art/cohub";
|
|
2
|
+
export const diagnosticLevels = ["debug", "info", "warn", "error"];
|
|
3
|
+
export const atLeastLevel = (level, minimum) => diagnosticLevels.indexOf(level) >= diagnosticLevels.indexOf(minimum);
|
|
4
|
+
export const runtimeWebUrl = (spaceId) => `https://${resolveCohubEnvironment() === "prod" ? "" : "dev."}cohub.live/spaces/${spaceId}`;
|
|
5
|
+
const messages = {
|
|
6
|
+
"runtime.ready": "Harness connected / Harness 已连接",
|
|
7
|
+
"runtime.available": "Runtime ready / Runtime 已就绪",
|
|
8
|
+
"runtime.websocket.closed": "Connection lost; reconnecting / 连接中断,正在重连",
|
|
9
|
+
"runtime.heartbeat_timeout": "Connection timed out; reconnecting / 连接超时,正在重连",
|
|
10
|
+
"runtime.auth_token_failed": "Cannot obtain credentials; retrying / 暂时无法获取凭证,正在重试",
|
|
11
|
+
"runtime.auth_required": "Sign in with cohub auth login / 请运行 cohub auth login 登录",
|
|
12
|
+
"runtime.stopped": "Runtime stopped / Runtime 已停止",
|
|
13
|
+
"runtime.failed": "Runtime needs attention / Runtime 需要处理",
|
|
14
|
+
"runtime.turn_failed": "Turn failed; local files retained / 执行失败,本地文件已保留",
|
|
15
|
+
"runtime.connection_failed": "Connection attempt failed; retrying / 连接失败,将继续重试",
|
|
16
|
+
"runtime.execution_transport_lost": "Execution disconnected; outcome needs reconciliation / 执行连接中断,结果待确认",
|
|
17
|
+
"archive.upload_pending": "Archive upload pending; local data retained / 归档待上传,本地数据已保留",
|
|
18
|
+
"native.sync_pending": "Native sync pending; local records retained / 原生同步待处理,本地记录已保留",
|
|
19
|
+
"archive.capture_pending": "Archive capture pending / 归档待处理",
|
|
20
|
+
"archive.capture_unavailable": "Archive unavailable; original receipt retained / 归档不可用,原始回执已保留",
|
|
21
|
+
"archive.restore_failed": "Native restore unavailable; using saved history / 原生恢复不可用,使用已保存历史",
|
|
22
|
+
"sandboxd.process_exit": "File bridge stopped; restarting / 文件桥接已退出,正在重启",
|
|
23
|
+
"sandboxd.download": "Preparing file bridge / 正在准备文件桥接",
|
|
24
|
+
"sandboxd.connected": "File bridge connected / 文件桥接已连接",
|
|
25
|
+
"sandboxd.disconnected": "File bridge disconnected; reconnecting / 文件桥接已断开,正在重连",
|
|
26
|
+
};
|
|
27
|
+
export function formatDiagnostic(event, verbose = false) {
|
|
28
|
+
const text = messages[event.event] ?? (typeof event.data?.message === "string" ? event.data.message : event.event);
|
|
29
|
+
const detail = event.error?.message ? ` — ${event.error.message}` : "";
|
|
30
|
+
const context = verbose ? ` ${JSON.stringify({ ...event.data, runtimeId: event.runtimeId, sessionId: event.sessionId, turnId: event.turnId })}` : "";
|
|
31
|
+
return `${event.timestamp.slice(11, 19)} ${event.level.toUpperCase().padEnd(5)} ${text}${detail}${context}\n`;
|
|
32
|
+
}
|
|
33
|
+
/** File logs retain every event; repeated terminal warnings are coalesced. */
|
|
34
|
+
export function createDiagnosticConsole(verbose = false, write = (line) => process.stderr.write(line)) {
|
|
35
|
+
const last = new Map();
|
|
36
|
+
return (event) => {
|
|
37
|
+
if (!verbose && (event.level === "debug" || !atLeastLevel(event.level, "warn") && !messages[event.event]))
|
|
38
|
+
return;
|
|
39
|
+
const key = `${event.component}:${event.event}:${event.level}:${event.error?.message ?? event.data?.message ?? ""}`;
|
|
40
|
+
const previous = last.get(key);
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
if (!verbose && previous && now - previous.at < 30_000 && atLeastLevel(event.level, "warn")) {
|
|
43
|
+
previous.suppressed++;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (last.size >= 256)
|
|
47
|
+
last.delete(last.keys().next().value ?? "");
|
|
48
|
+
last.set(key, { at: now, suppressed: 0 });
|
|
49
|
+
const repeated = previous?.suppressed ? ` (+${previous.suppressed} repeated / 重复)` : "";
|
|
50
|
+
write(`${formatDiagnostic(event, verbose).trimEnd()}${repeated}\n`);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function printRuntimeSummary(summary, json = false, reused = false) {
|
|
54
|
+
const value = { ...summary, url: runtimeWebUrl(summary.spaceId), reused };
|
|
55
|
+
if (json) {
|
|
56
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const label = summary.state === "ready" ? "Runtime ready / Runtime 已就绪" : `Runtime ${summary.state} / Runtime 尚未就绪`;
|
|
60
|
+
process.stdout.write(`\n${label}${reused ? " · reused / 已复用" : ""}\n\n`);
|
|
61
|
+
const rows = [
|
|
62
|
+
["Space / 空间", summary.spaceId],
|
|
63
|
+
["URL / 链接", value.url],
|
|
64
|
+
["Directory / 目录", summary.root],
|
|
65
|
+
["Harness", summary.harnesses.join(" · ")],
|
|
66
|
+
["Mode / 模式", summary.background ? "Background / 后台" : "Foreground / 前台"],
|
|
67
|
+
["PID", String(summary.pid)],
|
|
68
|
+
["Logs / 日志", summary.diagnosticsPath],
|
|
69
|
+
];
|
|
70
|
+
for (const [name, text] of rows)
|
|
71
|
+
process.stdout.write(` ${name} ${text}\n`);
|
|
72
|
+
process.stdout.write(`\n cohub runtime logs --space ${summary.spaceId} --follow\n cohub runtime down --space ${summary.spaceId}\n`);
|
|
73
|
+
if (!summary.background && !reused)
|
|
74
|
+
process.stdout.write(" Ctrl+C to stop / 按 Ctrl+C 停止\n");
|
|
75
|
+
process.stdout.write("\n");
|
|
76
|
+
}
|
|
@@ -30,6 +30,8 @@ export type NativeSession = {
|
|
|
30
30
|
sourceFingerprint?: string | null;
|
|
31
31
|
nativeLeafId?: string | null;
|
|
32
32
|
};
|
|
33
|
+
/** Reverse lookup for native clients; the existing Runtime state remains authoritative. */
|
|
34
|
+
export declare function findRuntimeNativeSession(root: string, harness: "pi" | "codex", nativeSessionId: string, path?: string): Promise<NativeSession | null>;
|
|
33
35
|
/** Local references are hints validated against actual native files, never cloud existence claims. */
|
|
34
36
|
export declare class RuntimeSessionStore {
|
|
35
37
|
readonly root: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { RUNTIME_RECOVERY_BATCH_SIZE, runtimeEventSchema, serializeProjectionRecords } from "@neta-art/cohub";
|
|
@@ -9,6 +9,35 @@ import { serializeDiagnosticError } from "./diagnostics.js";
|
|
|
9
9
|
import { ProjectionStore, rebindProjectionNativeSession } from "./projection-store.js";
|
|
10
10
|
export class ContextRequiredError extends Error {
|
|
11
11
|
}
|
|
12
|
+
/** Reverse lookup for native clients; the existing Runtime state remains authoritative. */
|
|
13
|
+
export async function findRuntimeNativeSession(root, harness, nativeSessionId, path) {
|
|
14
|
+
const directory = join(root, harness);
|
|
15
|
+
const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
|
|
16
|
+
return []; throw error; });
|
|
17
|
+
const matches = [];
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
if (!name.endsWith(".json"))
|
|
20
|
+
continue;
|
|
21
|
+
const state = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
22
|
+
if (state.version === 1 && state.harness === harness && state.nativeSessionId === nativeSessionId)
|
|
23
|
+
matches.push(state);
|
|
24
|
+
}
|
|
25
|
+
if (path && matches.length) {
|
|
26
|
+
const canonical = await realpath(path);
|
|
27
|
+
const exact = [];
|
|
28
|
+
for (const state of matches) {
|
|
29
|
+
const candidate = await realpath(state.path).catch((error) => { if (error.code === "ENOENT")
|
|
30
|
+
return null; throw error; });
|
|
31
|
+
if (candidate === canonical)
|
|
32
|
+
exact.push(state);
|
|
33
|
+
}
|
|
34
|
+
if (exact.length === 1)
|
|
35
|
+
return exact[0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
if (matches.length > 1)
|
|
38
|
+
throw new Error("Native Session has ambiguous Runtime bindings / 原生会话存在多个 Runtime 关联");
|
|
39
|
+
return matches[0] ?? null;
|
|
40
|
+
}
|
|
12
41
|
const checksum = (data) => createHash("sha256").update(data).digest("hex");
|
|
13
42
|
const missing = (error) => error?.code === "ENOENT";
|
|
14
43
|
class CaptureUnavailableError extends Error {
|
|
@@ -240,7 +269,6 @@ export class RuntimeSessionStore {
|
|
|
240
269
|
}
|
|
241
270
|
catch (error) {
|
|
242
271
|
this.diagnostics?.log("error", "runtime.session_state_unreadable", { path: join(directory, name), error: serializeDiagnosticError(error) });
|
|
243
|
-
console.error(`Runtime session state unreadable: ${join(directory, name)}`, error);
|
|
244
272
|
}
|
|
245
273
|
}
|
|
246
274
|
}
|
|
@@ -295,11 +323,9 @@ export class RuntimeSessionStore {
|
|
|
295
323
|
});
|
|
296
324
|
await rm(join(captures, name), { force: true });
|
|
297
325
|
this.diagnostics?.log("error", "archive.capture_unavailable", { reason: error.message, receipt: true }, { component: "archive" });
|
|
298
|
-
console.error("Archive capture unavailable; receipt retained:", error.message);
|
|
299
326
|
}
|
|
300
327
|
else {
|
|
301
328
|
this.diagnostics?.log("warn", "archive.capture_pending", { error: serializeDiagnosticError(error) }, { component: "archive" });
|
|
302
|
-
console.error("Archive capture pending:", error);
|
|
303
329
|
}
|
|
304
330
|
}
|
|
305
331
|
}
|
|
@@ -368,6 +394,16 @@ export class RuntimeSessionStore {
|
|
|
368
394
|
}
|
|
369
395
|
}
|
|
370
396
|
}
|
|
397
|
+
if (previous) {
|
|
398
|
+
const previousPath = previous.path;
|
|
399
|
+
const canonical = await realpath(previousPath).catch((error) => { if (missing(error))
|
|
400
|
+
return previousPath; throw error; });
|
|
401
|
+
const externallyOwned = await readFile(join(this.root, "native-owners", `${checksum(canonical)}.json`), "utf8").then(() => true).catch((error) => { if (missing(error))
|
|
402
|
+
return false; throw error; });
|
|
403
|
+
// Interactive clients own their native files, including symlink aliases. Use an independent projection.
|
|
404
|
+
if (externallyOwned)
|
|
405
|
+
previous = null;
|
|
406
|
+
}
|
|
371
407
|
if (previous) {
|
|
372
408
|
try {
|
|
373
409
|
const nativeChecksum = await checksumNativeFile(previous.path);
|
|
@@ -408,7 +444,6 @@ export class RuntimeSessionStore {
|
|
|
408
444
|
catch (error) {
|
|
409
445
|
signal?.throwIfAborted();
|
|
410
446
|
this.diagnostics?.log("warn", "archive.restore_failed", { error: serializeDiagnosticError(error) }, { component: "archive" });
|
|
411
|
-
console.error("Native archive unavailable; rebuilding from durable history:", error);
|
|
412
447
|
}
|
|
413
448
|
}
|
|
414
449
|
return await this.syncNativeProjection(input, cwd, state, signal);
|
|
@@ -39,6 +39,9 @@ export declare function resolveRuntimeSpace(input: {
|
|
|
39
39
|
root: string;
|
|
40
40
|
identityKey: string | null | undefined;
|
|
41
41
|
explicitSpaceId?: string | null;
|
|
42
|
+
/** Explicit new selection; compare the binding observed before prompting. */
|
|
43
|
+
newSpace?: boolean;
|
|
44
|
+
expectedSpaceId?: string | null;
|
|
42
45
|
createSpace: () => Promise<string>;
|
|
43
46
|
validateSpace?: (spaceId: string) => Promise<void>;
|
|
44
47
|
path?: string;
|
|
@@ -99,7 +99,7 @@ function upsertRuntimeSpaceBinding(file, binding) {
|
|
|
99
99
|
bindings[index] = nextBinding;
|
|
100
100
|
return { file: { version: 1, bindings }, changed: true };
|
|
101
101
|
}
|
|
102
|
-
async function
|
|
102
|
+
async function writeRuntimeJson(path, file) {
|
|
103
103
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
104
104
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
105
105
|
try {
|
|
@@ -248,7 +248,7 @@ async function persistRuntimeSpaceBinding(path, binding) {
|
|
|
248
248
|
const file = await readRuntimeSpaceBindings(path);
|
|
249
249
|
const result = upsertRuntimeSpaceBinding(file, binding);
|
|
250
250
|
if (result.changed)
|
|
251
|
-
await
|
|
251
|
+
await writeRuntimeJson(path, result.file);
|
|
252
252
|
}, { path, lockPath: `${path}${GLOBAL_LOCK_SUFFIX}` });
|
|
253
253
|
}
|
|
254
254
|
export async function getRuntimeSpaceBinding(root, key, path = runtimeSpaceBindingsPath()) {
|
|
@@ -281,25 +281,62 @@ export async function resolveRuntimeSpace(input) {
|
|
|
281
281
|
const lockPath = bindingLockPath(path, root, bindingKey);
|
|
282
282
|
return withRuntimeSpaceBindingsLock(async () => {
|
|
283
283
|
const file = await readRuntimeSpaceBindings(path);
|
|
284
|
+
const receiptPath = `${lockPath}.creation.json`;
|
|
284
285
|
if (explicitSpaceId) {
|
|
285
286
|
await input.validateSpace?.(explicitSpaceId);
|
|
286
287
|
await persistRuntimeSpaceBinding(path, { root, key: bindingKey, spaceId: explicitSpaceId });
|
|
288
|
+
// Preserve an ambiguous creation receipt for diagnosis, rather than deleting it.
|
|
289
|
+
if (await statIfPresent(receiptPath))
|
|
290
|
+
await rename(receiptPath, `${receiptPath}.${randomUUID()}.resolved`);
|
|
287
291
|
return { spaceId: explicitSpaceId, source: "explicit" };
|
|
288
292
|
}
|
|
289
293
|
const existing = findRuntimeSpaceBinding(file, { root, key: bindingKey });
|
|
290
|
-
if (existing) {
|
|
294
|
+
if (input.newSpace && (existing?.spaceId ?? null) !== (input.expectedSpaceId ?? null)) {
|
|
295
|
+
throw new RuntimeSpaceBindingsError("Directory binding changed; run up again / 目录绑定已变化,请重新运行 up");
|
|
296
|
+
}
|
|
297
|
+
if (existing && !input.newSpace) {
|
|
291
298
|
await input.validateSpace?.(existing.spaceId);
|
|
292
299
|
return { spaceId: existing.spaceId, source: "binding" };
|
|
293
300
|
}
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
301
|
+
// Fail closed across the remote-create/local-commit crash window. A saved ID
|
|
302
|
+
// resumes binding; an ambiguous request must be resolved with --space, never replayed.
|
|
303
|
+
let receipt = null;
|
|
304
|
+
try {
|
|
305
|
+
receipt = JSON.parse(await readFile(receiptPath, "utf8"));
|
|
306
|
+
if (!receipt || !nonEmptyString(receipt.operationId) || receipt.spaceId !== undefined && !nonEmptyString(receipt.spaceId)) {
|
|
307
|
+
throw new RuntimeSpaceBindingsError(`Invalid creation receipt; original retained / 创建回执无效,原始信息已保留: ${receiptPath}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (!missing(error))
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
if (receipt && !receipt.spaceId)
|
|
315
|
+
throw new RuntimeSpaceBindingsError(`A previous Space creation has an unknown outcome. Check your Spaces and use --space <id> / 上次创建结果未知,请检查 Space 后使用 --space <id>。${receiptPath}`);
|
|
316
|
+
if (!receipt) {
|
|
317
|
+
receipt = { operationId: randomUUID() };
|
|
318
|
+
await writeRuntimeJson(receiptPath, receipt);
|
|
319
|
+
}
|
|
320
|
+
let createdSpaceId = receipt.spaceId;
|
|
321
|
+
if (!createdSpaceId) {
|
|
322
|
+
try {
|
|
323
|
+
createdSpaceId = await input.createSpace();
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
const status = error?.status;
|
|
327
|
+
if (status && status >= 400 && status < 500 && status !== 408)
|
|
328
|
+
await rm(receiptPath);
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
await writeRuntimeJson(receiptPath, { ...receipt, spaceId: createdSpaceId });
|
|
332
|
+
}
|
|
297
333
|
if (!nonEmptyString(createdSpaceId)) {
|
|
298
334
|
throw new RuntimeSpaceBindingsError("Local Runtime Space creation returned no Space ID");
|
|
299
335
|
}
|
|
300
336
|
const spaceId = createdSpaceId.trim();
|
|
301
337
|
await input.validateSpace?.(spaceId);
|
|
302
338
|
await persistRuntimeSpaceBinding(path, { root, key: bindingKey, spaceId });
|
|
339
|
+
await rm(receiptPath);
|
|
303
340
|
return { spaceId, source: "created" };
|
|
304
341
|
}, { path, lockPath });
|
|
305
342
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type RuntimeCapabilities } from "@neta-art/cohub";
|
|
2
|
+
import { type HarnessOptions } from "./harness.js";
|
|
3
|
+
import { type RuntimeDiagnostic, type RuntimeDiagnosticLevel } from "./diagnostics.js";
|
|
4
|
+
import { type RuntimeSummary } from "./presentation.js";
|
|
5
|
+
export type RuntimeLaunch = {
|
|
6
|
+
spaceId: string;
|
|
7
|
+
root: string;
|
|
8
|
+
identity: string;
|
|
9
|
+
harnesses: ("pi" | "codex")[];
|
|
10
|
+
executables: HarnessOptions;
|
|
11
|
+
capabilities?: RuntimeCapabilities;
|
|
12
|
+
background: boolean;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function sandboxOutputLevel(value: unknown, stream: "stdout" | "stderr"): RuntimeDiagnosticLevel;
|
|
16
|
+
export declare function runRuntime(config: RuntimeLaunch, onState: (status: RuntimeSummary) => void, externalSignal?: AbortSignal, onDiagnostic?: (event: RuntimeDiagnostic) => void): Promise<void>;
|