@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type RuntimeCapabilities } from "@neta-art/cohub";
|
|
1
|
+
import { type RuntimeCapabilities, type NativeRuntimeEvent } from "@neta-art/cohub";
|
|
2
2
|
import { type HarnessOptions } from "./harness.js";
|
|
3
3
|
import { type RuntimeSessionStore } from "./session-store.js";
|
|
4
4
|
import { type RuntimeDiagnostics } from "./diagnostics.js";
|
|
@@ -8,12 +8,14 @@ export type RuntimeConnectionOptions = {
|
|
|
8
8
|
url: string;
|
|
9
9
|
capabilities: RuntimeCapabilities;
|
|
10
10
|
harnesses: HarnessOptions;
|
|
11
|
-
token: () => Promise<string>;
|
|
11
|
+
token: (forceRefresh?: boolean) => Promise<string>;
|
|
12
12
|
signal: AbortSignal;
|
|
13
13
|
store: RuntimeSessionStore;
|
|
14
14
|
onReady: () => void;
|
|
15
|
+
onDisconnected?: () => void;
|
|
15
16
|
runtimeId?: string;
|
|
16
17
|
diagnostics?: RuntimeDiagnostics;
|
|
17
18
|
leaseConflictTimeoutMs?: number;
|
|
19
|
+
onNativeChannel?: (send: (event: NativeRuntimeEvent) => Promise<unknown>) => void;
|
|
18
20
|
};
|
|
19
21
|
export declare function serveRuntime(options: RuntimeConnectionOptions): Promise<void>;
|
|
@@ -16,7 +16,7 @@ export async function serveRuntime(options) {
|
|
|
16
16
|
const flush = () => options.store.flushArchives(uploadSignal).catch((error) => {
|
|
17
17
|
if (!uploadSignal.aborted) {
|
|
18
18
|
log("warn", "archive.flush_failed", { error: serializeDiagnosticError(error) });
|
|
19
|
-
|
|
19
|
+
// The diagnostic sink handles terminal presentation and throttling.
|
|
20
20
|
}
|
|
21
21
|
});
|
|
22
22
|
const timer = setInterval(() => {
|
|
@@ -31,22 +31,36 @@ export async function serveRuntime(options) {
|
|
|
31
31
|
try {
|
|
32
32
|
while (!options.signal.aborted) {
|
|
33
33
|
attempt += 1;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
attempt
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
let readyAt = 0;
|
|
35
|
+
let outcome;
|
|
36
|
+
try {
|
|
37
|
+
outcome = await connect({
|
|
38
|
+
...options,
|
|
39
|
+
runtimeId,
|
|
40
|
+
attempt,
|
|
41
|
+
onReady: () => {
|
|
42
|
+
readyAt = Date.now();
|
|
43
|
+
conflictSince = null;
|
|
44
|
+
options.onReady();
|
|
45
|
+
void flush();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (options.signal.aborted)
|
|
51
|
+
return;
|
|
52
|
+
log("warn", "runtime.connection_failed", { error: serializeDiagnosticError(error) });
|
|
53
|
+
outcome = "retry";
|
|
54
|
+
}
|
|
55
|
+
options.onDisconnected?.();
|
|
46
56
|
if (options.signal.aborted)
|
|
47
57
|
return;
|
|
48
58
|
if (outcome === "fatal")
|
|
49
|
-
throw new Error("Runtime connection rejected");
|
|
59
|
+
throw new Error("Runtime connection rejected / Runtime 连接被拒绝,请检查权限或升级 CLI");
|
|
60
|
+
if (readyAt && Date.now() - readyAt >= 60_000) {
|
|
61
|
+
backoff = 500;
|
|
62
|
+
attempt = 0;
|
|
63
|
+
}
|
|
50
64
|
if (outcome === "conflict") {
|
|
51
65
|
conflictSince ??= Date.now();
|
|
52
66
|
if (Date.now() - conflictSince >= (options.leaseConflictTimeoutMs ?? 90_000)) {
|
|
@@ -58,8 +72,8 @@ export async function serveRuntime(options) {
|
|
|
58
72
|
delayMs: backoff,
|
|
59
73
|
outcome,
|
|
60
74
|
});
|
|
61
|
-
await delay(backoff, undefined, { signal: options.signal }).catch(() => undefined);
|
|
62
|
-
backoff = Math.min(
|
|
75
|
+
await delay(backoff / 2 + Math.random() * backoff / 2, undefined, { signal: options.signal }).catch(() => undefined);
|
|
76
|
+
backoff = Math.min(30_000, backoff * 2);
|
|
63
77
|
}
|
|
64
78
|
}
|
|
65
79
|
finally {
|
|
@@ -91,7 +105,7 @@ async function connect(options) {
|
|
|
91
105
|
currentToken = await options.token();
|
|
92
106
|
}
|
|
93
107
|
catch (error) {
|
|
94
|
-
log("
|
|
108
|
+
log("warn", error instanceof Error && error.name === "AuthRequiredError" ? "runtime.auth_required" : "runtime.auth_token_failed", { error: serializeDiagnosticError(error) });
|
|
95
109
|
throw error;
|
|
96
110
|
}
|
|
97
111
|
const runtimeUrl = new URL(options.url);
|
|
@@ -105,7 +119,10 @@ async function connect(options) {
|
|
|
105
119
|
let connectionId = null;
|
|
106
120
|
let fatal = false;
|
|
107
121
|
let conflict = false;
|
|
122
|
+
let unauthorized = false;
|
|
108
123
|
let readyTimer;
|
|
124
|
+
const nativePending = new Map();
|
|
125
|
+
let nativeChannelReady = false;
|
|
109
126
|
const send = (frame) => {
|
|
110
127
|
if (socket.readyState !== WebSocket.OPEN || socket.bufferedAmount > RUNTIME_MAX_FRAME_BYTES) {
|
|
111
128
|
log("warn", "runtime.frame_send_unavailable", {
|
|
@@ -127,6 +144,23 @@ async function connect(options) {
|
|
|
127
144
|
execution.controller.abort();
|
|
128
145
|
socket.close();
|
|
129
146
|
};
|
|
147
|
+
const sendNative = (event) => new Promise((resolve, reject) => {
|
|
148
|
+
if (!nativeChannelReady || !connectionId) {
|
|
149
|
+
reject(new Error("Runtime native channel is unavailable"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const requestId = randomUUID();
|
|
153
|
+
const timer = setTimeout(() => { nativePending.delete(requestId); reject(new Error("Native Runtime event timed out")); }, 30_000);
|
|
154
|
+
nativePending.set(requestId, { resolve, reject, timer });
|
|
155
|
+
try {
|
|
156
|
+
send({ type: "runtime.native", requestId, event });
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
nativePending.delete(requestId);
|
|
161
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
130
164
|
options.signal.addEventListener("abort", stop, { once: true });
|
|
131
165
|
const heartbeat = setInterval(() => {
|
|
132
166
|
const heartbeatAgeMs = Date.now() - lastHeartbeat;
|
|
@@ -174,10 +208,11 @@ async function connect(options) {
|
|
|
174
208
|
}, 10_000);
|
|
175
209
|
const closed = new Promise((resolve) => {
|
|
176
210
|
socket.addEventListener("close", (event) => {
|
|
177
|
-
|
|
211
|
+
unauthorized = event.code === 4401;
|
|
212
|
+
fatal = [4400, 4403].includes(event.code);
|
|
178
213
|
conflict = event.code === 4409;
|
|
179
214
|
disconnected.abort();
|
|
180
|
-
log(fatal || conflict ? "error" : "warn", "runtime.websocket.closed", {
|
|
215
|
+
log(options.signal.aborted ? "debug" : fatal || conflict ? "error" : "warn", "runtime.websocket.closed", {
|
|
181
216
|
code: event.code,
|
|
182
217
|
reason: event.reason,
|
|
183
218
|
durationMs: Date.now() - connectedAt,
|
|
@@ -185,8 +220,13 @@ async function connect(options) {
|
|
|
185
220
|
fatal,
|
|
186
221
|
conflict,
|
|
187
222
|
}, { connectionId });
|
|
188
|
-
|
|
189
|
-
|
|
223
|
+
nativeChannelReady = false;
|
|
224
|
+
for (const pending of nativePending.values()) {
|
|
225
|
+
clearTimeout(pending.timer);
|
|
226
|
+
pending.reject(new Error("Runtime native channel disconnected"));
|
|
227
|
+
}
|
|
228
|
+
nativePending.clear();
|
|
229
|
+
options.onDisconnected?.();
|
|
190
230
|
for (const execution of active.values()) {
|
|
191
231
|
log("error", "runtime.execution_transport_lost", {
|
|
192
232
|
code: event.code,
|
|
@@ -246,6 +286,8 @@ async function connect(options) {
|
|
|
246
286
|
connectionId,
|
|
247
287
|
durationMs: Date.now() - connectedAt,
|
|
248
288
|
}, { connectionId });
|
|
289
|
+
nativeChannelReady = true;
|
|
290
|
+
options.onNativeChannel?.(sendNative);
|
|
249
291
|
options.onReady();
|
|
250
292
|
let recoveryBatch = 0;
|
|
251
293
|
for await (const executions of options.store.pendingExecutionBatches()) {
|
|
@@ -264,6 +306,19 @@ async function connect(options) {
|
|
|
264
306
|
lastHeartbeat = Date.now();
|
|
265
307
|
return;
|
|
266
308
|
}
|
|
309
|
+
if (raw.type === "runtime.native.result") {
|
|
310
|
+
const result = raw;
|
|
311
|
+
const pending = result.requestId ? nativePending.get(result.requestId) : null;
|
|
312
|
+
if (!pending)
|
|
313
|
+
return;
|
|
314
|
+
clearTimeout(pending.timer);
|
|
315
|
+
nativePending.delete(result.requestId ?? "");
|
|
316
|
+
if (result.error)
|
|
317
|
+
pending.reject(new Error(result.error));
|
|
318
|
+
else
|
|
319
|
+
pending.resolve(result.result);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
267
322
|
const frame = runtimeCommandSchema.parse(raw);
|
|
268
323
|
if (frame.type === "session.context") {
|
|
269
324
|
log("debug", "runtime.context_received", {
|
|
@@ -294,7 +349,7 @@ async function connect(options) {
|
|
|
294
349
|
revision: frame.revision,
|
|
295
350
|
error: serializeDiagnosticError(error),
|
|
296
351
|
}, context);
|
|
297
|
-
|
|
352
|
+
// Keep the receipt; the next reconciliation can retry acknowledgement.
|
|
298
353
|
active.delete(frame.requestId);
|
|
299
354
|
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", message: "Local acknowledgement failed; result retained" } });
|
|
300
355
|
return;
|
|
@@ -353,7 +408,7 @@ async function connect(options) {
|
|
|
353
408
|
catch (error) {
|
|
354
409
|
if (!recovery.controller.signal.aborted) {
|
|
355
410
|
log("error", "runtime.recovery_failed", { error: serializeDiagnosticError(error) }, context);
|
|
356
|
-
|
|
411
|
+
// Original files and receipts remain available for reconciliation.
|
|
357
412
|
try {
|
|
358
413
|
send({ type: "runtime.event", requestId: frame.requestId, event: { type: "turn.error", uncertain: true, message: "Result unavailable; files retained" } });
|
|
359
414
|
}
|
|
@@ -523,5 +578,7 @@ async function connect(options) {
|
|
|
523
578
|
clearInterval(heartbeat);
|
|
524
579
|
options.signal.removeEventListener("abort", stop);
|
|
525
580
|
}
|
|
581
|
+
if (unauthorized && !options.signal.aborted)
|
|
582
|
+
await options.token(true);
|
|
526
583
|
return fatal ? "fatal" : conflict ? "conflict" : "retry";
|
|
527
584
|
}
|
|
@@ -50,6 +50,8 @@ export type RuntimeDiagnosticsOptions = {
|
|
|
50
50
|
logFlushIntervalMs?: number;
|
|
51
51
|
maxLogFileBytes?: number;
|
|
52
52
|
maxTotalLogBytes?: number;
|
|
53
|
+
/** Receives only the redacted event, before asynchronous disk I/O. */
|
|
54
|
+
onEvent?: (event: RuntimeDiagnostic) => void;
|
|
53
55
|
};
|
|
54
56
|
export type ReadRuntimeDiagnosticsOptions = {
|
|
55
57
|
limit?: number;
|
|
@@ -64,6 +66,7 @@ export declare class RuntimeDiagnostics {
|
|
|
64
66
|
readonly logPath: string;
|
|
65
67
|
private readonly spaceId;
|
|
66
68
|
private readonly component;
|
|
69
|
+
private readonly onEvent?;
|
|
67
70
|
private readonly logFlushIntervalMs;
|
|
68
71
|
private readonly maxLogFileBytes;
|
|
69
72
|
private readonly maxTotalLogBytes;
|
|
@@ -134,6 +134,7 @@ export class RuntimeDiagnostics {
|
|
|
134
134
|
logPath;
|
|
135
135
|
spaceId;
|
|
136
136
|
component;
|
|
137
|
+
onEvent;
|
|
137
138
|
logFlushIntervalMs;
|
|
138
139
|
maxLogFileBytes;
|
|
139
140
|
maxTotalLogBytes;
|
|
@@ -153,6 +154,7 @@ export class RuntimeDiagnostics {
|
|
|
153
154
|
this.runtimeId = options.runtimeId ?? randomUUID();
|
|
154
155
|
this.spaceId = options.spaceId;
|
|
155
156
|
this.component = options.component ?? "runtime";
|
|
157
|
+
this.onEvent = options.onEvent;
|
|
156
158
|
this.logFlushIntervalMs = options.logFlushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
|
|
157
159
|
this.maxLogFileBytes = options.maxLogFileBytes ?? DEFAULT_MAX_LOG_FILE_BYTES;
|
|
158
160
|
this.maxTotalLogBytes = options.maxTotalLogBytes ?? DEFAULT_MAX_TOTAL_LOG_BYTES;
|
|
@@ -193,6 +195,7 @@ export class RuntimeDiagnostics {
|
|
|
193
195
|
...(diagnosticError ? { error: diagnosticError } : {}),
|
|
194
196
|
});
|
|
195
197
|
this.enqueueWrite(value);
|
|
198
|
+
this.onEvent?.(value);
|
|
196
199
|
}
|
|
197
200
|
async close() {
|
|
198
201
|
this.closed = true;
|
|
@@ -6,6 +6,9 @@ export type HarnessOptions = {
|
|
|
6
6
|
pi?: string;
|
|
7
7
|
codex?: string;
|
|
8
8
|
};
|
|
9
|
+
export declare function harnessExecutableCandidates(binary: string, cwd: string, path: string, platform?: NodeJS.Platform, pathExt?: string | undefined): string[];
|
|
10
|
+
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
11
|
+
export declare function installedHarnesses(cwd: string, options: HarnessOptions, path?: string): Promise<("pi" | "codex")[]>;
|
|
9
12
|
export type HarnessResult = {
|
|
10
13
|
state: NativeSession;
|
|
11
14
|
event: Extract<RuntimeExecutionEvent, {
|
package/dist/runtime/harness.js
CHANGED
|
@@ -1,8 +1,40 @@
|
|
|
1
|
+
import { access, stat } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { delimiter, join, resolve, win32 } from "node:path";
|
|
1
4
|
import { JsonRpcProcess, record } from "./json-rpc.js";
|
|
2
5
|
import { codexModelCatalog } from "./model-catalog.js";
|
|
3
6
|
import { codexTokenTotals, codexUsage, subtractCodexTokens } from "./codex-usage.js";
|
|
4
7
|
import { downloadPublicImage } from "../safe-remote-image.js";
|
|
5
8
|
import { serializeDiagnosticError } from "./diagnostics.js";
|
|
9
|
+
export function harnessExecutableCandidates(binary, cwd, path, platform = process.platform, pathExt = process.env.PATHEXT) {
|
|
10
|
+
const windows = platform === "win32";
|
|
11
|
+
const paths = windows ? win32 : { delimiter, join, resolve };
|
|
12
|
+
const extensions = windows && !win32.extname(binary)
|
|
13
|
+
? ["", ...(pathExt || ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => /^\.[a-z0-9]+$/i.test(ext))]
|
|
14
|
+
: [""];
|
|
15
|
+
const roots = binary.includes("/") || binary.includes("\\")
|
|
16
|
+
? [paths.resolve(cwd, binary)]
|
|
17
|
+
: path.split(paths.delimiter).map((directory) => directory.replace(/^"(.*)"$/, "$1")).filter(Boolean).map((directory) => paths.resolve(cwd, directory, binary));
|
|
18
|
+
return roots.flatMap((root) => extensions.map((ext) => `${root}${ext}`));
|
|
19
|
+
}
|
|
20
|
+
/** Discover installed executables only; authentication/capability errors stay explicit. */
|
|
21
|
+
export async function installedHarnesses(cwd, options, path = process.env.PATH ?? "") {
|
|
22
|
+
const names = options.pi || options.codex ? ["pi", "codex"].filter((name) => options[name]) : ["pi", "codex"];
|
|
23
|
+
const found = await Promise.all(names.map(async (name) => {
|
|
24
|
+
const binary = options[name] || name;
|
|
25
|
+
const candidates = harnessExecutableCandidates(binary, cwd, path);
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
try {
|
|
28
|
+
await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
29
|
+
if ((await stat(candidate)).isFile())
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
catch { /* Try the next PATH entry. */ }
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}));
|
|
36
|
+
return found.filter((name) => name !== null);
|
|
37
|
+
}
|
|
6
38
|
const runtimeEnvironment = (input) => ({ COHUB_SPACE_ID: input.spaceId, COHUB_SESSION_ID: input.sessionId, COHUB_TURN_ID: input.turnId });
|
|
7
39
|
const array = (value) => Array.isArray(value) ? value : [];
|
|
8
40
|
const text = (value) => typeof value === "string" ? value : "";
|
|
@@ -49,7 +81,8 @@ function createAbortEscalation(rpc, signal, interrupt) {
|
|
|
49
81
|
}
|
|
50
82
|
/** Native files stay authoritative; archival failure only degrades cross-host resume. */
|
|
51
83
|
async function finishHarnessTurn(store, state, message, resume, turnId, diagnosticContext) {
|
|
52
|
-
|
|
84
|
+
// The store records a redacted diagnostic; native files remain authoritative.
|
|
85
|
+
const archive = await store.archive(state, turnId, diagnosticContext).catch(() => null);
|
|
53
86
|
return { state, event: { type: "turn.end", message, resume, archive } };
|
|
54
87
|
}
|
|
55
88
|
export async function discoverHarnesses(harnesses, options, cwd) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuntimeSummary } from "./presentation.js";
|
|
2
|
+
export declare function runtimeInstanceDirectory(identity: string, spaceId: string): string;
|
|
3
|
+
export declare function requestRuntimeInstance(directory: string, action?: "status" | "stop", force?: boolean): Promise<RuntimeSummary | null>;
|
|
4
|
+
/** Private local IPC is both the single-instance guard and the control surface. */
|
|
5
|
+
export declare function ownRuntimeInstance(directory: string, status: () => RuntimeSummary, stop: (force: boolean) => Promise<void>): Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createConnection, createServer } from "node:net";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { withRuntimeSpaceBindingsLock } from "./space-binding.js";
|
|
7
|
+
export function runtimeInstanceDirectory(identity, spaceId) {
|
|
8
|
+
const key = createHash("sha256").update(`${identity}\0${spaceId}`).digest("hex").slice(0, 24);
|
|
9
|
+
return join(homedir(), ".local", "state", "cohub", "instances", key);
|
|
10
|
+
}
|
|
11
|
+
function alive(pid) {
|
|
12
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
13
|
+
return true;
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return error.code !== "ESRCH";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readRecord(directory) {
|
|
23
|
+
try {
|
|
24
|
+
const value = JSON.parse(await readFile(join(directory, "owner.json"), "utf8"));
|
|
25
|
+
if (!Number.isSafeInteger(value.pid) || typeof value.nonce !== "string" || typeof value.socket !== "string")
|
|
26
|
+
throw new Error("Invalid Runtime instance record / Runtime 实例记录无效");
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code === "ENOENT")
|
|
31
|
+
return null;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function request(record, action, force = false) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const socket = createConnection(record.socket);
|
|
38
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error("Runtime control timed out / Runtime 控制连接超时")); }, 3000);
|
|
39
|
+
let buffer = "";
|
|
40
|
+
let settled = false;
|
|
41
|
+
const finish = (error, value) => {
|
|
42
|
+
if (settled)
|
|
43
|
+
return;
|
|
44
|
+
settled = true;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
socket.destroy();
|
|
47
|
+
if (error)
|
|
48
|
+
reject(error);
|
|
49
|
+
else if (value)
|
|
50
|
+
resolve(value);
|
|
51
|
+
};
|
|
52
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({ nonce: record.nonce, action, force })}\n`));
|
|
53
|
+
socket.on("error", (error) => finish(error));
|
|
54
|
+
socket.on("close", () => finish(new Error("Runtime control closed / Runtime 控制连接已关闭")));
|
|
55
|
+
socket.on("data", (chunk) => {
|
|
56
|
+
buffer += chunk.toString();
|
|
57
|
+
if (buffer.length > 64 * 1024) {
|
|
58
|
+
finish(new Error("Runtime response too large"));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!buffer.includes("\n"))
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const response = JSON.parse(buffer.slice(0, buffer.indexOf("\n")));
|
|
65
|
+
if (response.error)
|
|
66
|
+
finish(new Error(response.error));
|
|
67
|
+
else if (response.nonce !== record.nonce || response.status?.pid !== record.pid)
|
|
68
|
+
finish(new Error("Runtime identity changed / Runtime 身份已变化"));
|
|
69
|
+
else
|
|
70
|
+
finish(undefined, response.status);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export async function requestRuntimeInstance(directory, action = "status", force = false) {
|
|
79
|
+
const record = await readRecord(directory);
|
|
80
|
+
if (!record || !alive(record.pid))
|
|
81
|
+
return null;
|
|
82
|
+
try {
|
|
83
|
+
return await request(record, action, force);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// A reused PID is not proof of ownership. Missing/refused endpoints cannot
|
|
87
|
+
// belong to a serving Runtime; timeouts and permission errors remain uncertain.
|
|
88
|
+
if (["ENOENT", "ECONNREFUSED"].includes(error.code ?? ""))
|
|
89
|
+
return null;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Private local IPC is both the single-instance guard and the control surface. */
|
|
94
|
+
export async function ownRuntimeInstance(directory, status, stop) {
|
|
95
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
96
|
+
return withRuntimeSpaceBindingsLock(async () => {
|
|
97
|
+
if (await requestRuntimeInstance(directory))
|
|
98
|
+
throw new Error("Runtime already running; use status / Runtime 已在运行,请使用 status");
|
|
99
|
+
const socketPath = process.platform === "win32"
|
|
100
|
+
? `\\\\.\\pipe\\cohub-${createHash("sha256").update(directory).digest("hex").slice(0, 24)}`
|
|
101
|
+
: join(directory, "control.sock");
|
|
102
|
+
if (process.platform !== "win32")
|
|
103
|
+
await rm(socketPath, { force: true });
|
|
104
|
+
const record = { pid: process.pid, nonce: randomUUID(), socket: socketPath };
|
|
105
|
+
const clients = new Set();
|
|
106
|
+
const server = createServer((socket) => {
|
|
107
|
+
clients.add(socket);
|
|
108
|
+
socket.setTimeout(3000, () => socket.destroy());
|
|
109
|
+
socket.on("error", () => socket.destroy());
|
|
110
|
+
socket.on("close", () => clients.delete(socket));
|
|
111
|
+
let input = "";
|
|
112
|
+
socket.on("data", (chunk) => {
|
|
113
|
+
input += chunk.toString();
|
|
114
|
+
if (input.length > 4096) {
|
|
115
|
+
socket.destroy();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!input.includes("\n"))
|
|
119
|
+
return;
|
|
120
|
+
socket.removeAllListeners("data");
|
|
121
|
+
void (async () => {
|
|
122
|
+
const message = JSON.parse(input.slice(0, input.indexOf("\n")));
|
|
123
|
+
if (message.nonce !== record.nonce) {
|
|
124
|
+
socket.destroy();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (message.action === "stop")
|
|
128
|
+
await stop(message.force === true);
|
|
129
|
+
else if (message.action !== "status")
|
|
130
|
+
throw new Error("Unknown Runtime control request");
|
|
131
|
+
socket.end(`${JSON.stringify({ nonce: record.nonce, status: status() })}\n`);
|
|
132
|
+
})().catch((error) => socket.end(`${JSON.stringify({ error: error instanceof Error ? error.message : String(error) })}\n`));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
await new Promise((resolve, reject) => {
|
|
136
|
+
server.once("error", reject);
|
|
137
|
+
server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); });
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
if (process.platform !== "win32")
|
|
141
|
+
await chmod(socketPath, 0o600);
|
|
142
|
+
const temporary = join(directory, `${record.nonce}.tmp`);
|
|
143
|
+
await writeFile(temporary, JSON.stringify(record), { mode: 0o600 });
|
|
144
|
+
await rename(temporary, join(directory, "owner.json"));
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
server.close();
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
return async () => {
|
|
151
|
+
for (const client of clients)
|
|
152
|
+
client.destroy();
|
|
153
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
154
|
+
// Never remove a replacement owner's files.
|
|
155
|
+
if ((await readRecord(directory))?.nonce === record.nonce)
|
|
156
|
+
await rm(join(directory, "owner.json"));
|
|
157
|
+
};
|
|
158
|
+
}, { path: join(directory, "owner.json") });
|
|
159
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type RuntimeSummary } from "./presentation.js";
|
|
3
|
+
import { type RuntimeLaunch } from "./supervisor.js";
|
|
4
|
+
export type RuntimeUpOptions = {
|
|
5
|
+
space?: string;
|
|
6
|
+
new?: boolean;
|
|
7
|
+
name?: string;
|
|
8
|
+
harness: string[];
|
|
9
|
+
pi?: string;
|
|
10
|
+
codex?: string;
|
|
11
|
+
yes?: boolean;
|
|
12
|
+
json?: boolean;
|
|
13
|
+
detach?: boolean;
|
|
14
|
+
verbose?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare const resolveLocalSpaceName: (root: string, name?: string) => string;
|
|
17
|
+
export declare function parseRuntimeHarnesses(values: string[]): ("pi" | "codex")[];
|
|
18
|
+
export declare function resolveRuntimeTarget(program: Command, target?: string): Promise<string>;
|
|
19
|
+
export declare function startBackgroundRuntime(config: RuntimeLaunch): Promise<RuntimeSummary>;
|
|
20
|
+
export declare function runtimeUp(program: Command, dir: string | undefined, options: RuntimeUpOptions): Promise<void>;
|