@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59
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 +105 -105
- package/dist/bin/cleanup-accounts.mjs +63 -63
- package/dist/bin/codex-remote.mjs +403 -403
- package/dist/bin/publish-android-release.mjs +63 -63
- package/dist/bin/release-upload-server.mjs +190 -190
- package/dist/bin/set-relay-admin-password.mjs +11 -0
- package/dist/build-platform.json +1 -1
- package/dist/get-shared-service-url.ps1 +14 -14
- package/dist/shared/code-browser.cjs +159 -159
- package/dist/shared/file-changes.cjs +31 -31
- package/dist/shared/file-citations.cjs +117 -117
- package/dist/shared/file-mentions.cjs +92 -92
- package/dist/shared/message-delivery.cjs +115 -115
- package/dist/shared/p2p-file-channel.cjs +87 -87
- package/dist/shared/release-version.cjs +31 -0
- package/dist/shared/server-requests.cjs +311 -311
- package/dist/shared/timeline-sync.cjs +173 -173
- package/dist/skills/code-relax/SKILL.md +22 -22
- package/dist/src/bounded-process-log.mjs +63 -63
- package/dist/src/celsius-client.mjs +107 -107
- package/dist/src/dots-approval-timeline.mjs +99 -99
- package/dist/src/dots-avatar.mjs +42 -42
- package/dist/src/dots-chat-projection.mjs +50 -50
- package/dist/src/dots-chat-server.mjs +483 -483
- package/dist/src/dots-message-images.mjs +319 -319
- package/dist/src/dots-profile.mjs +77 -77
- package/dist/src/dots-room-activity.mjs +172 -172
- package/dist/src/dots-room-client.mjs +137 -135
- package/dist/src/dots-room-projection.mjs +94 -94
- package/dist/src/dots-schedules.mjs +66 -66
- package/dist/src/dots-summary-tasks.mjs +32 -32
- package/dist/src/dots-task-catalog.mjs +430 -430
- package/dist/src/file-transfer-range.mjs +22 -22
- package/dist/src/file-upload-session.mjs +146 -130
- package/dist/src/hosted-history-app-server.mjs +103 -103
- package/dist/src/hosted-history-cache.mjs +113 -113
- package/dist/src/lan-socket-server.mjs +338 -266
- package/dist/src/managed-app-server.mjs +373 -373
- package/dist/src/managed-endpoint.mjs +25 -25
- package/dist/src/message-upload-files.mjs +109 -109
- package/dist/src/onboarding.mjs +12 -12
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/codex-protocol.mjs +46 -46
- package/dist/src/platform/desktop-project-store.mjs +219 -208
- package/dist/src/platform/linux/desktop-host.mjs +87 -87
- package/dist/src/platform/linux/process.mjs +53 -53
- package/dist/src/platform/linux/system.mjs +50 -50
- package/dist/src/platform/linux/user-environment.mjs +9 -9
- package/dist/src/platform/macos/codex-runtime.mjs +125 -125
- package/dist/src/platform/macos/desktop-host.mjs +86 -86
- package/dist/src/platform/macos/user-environment.mjs +9 -9
- package/dist/src/platform/native-package.mjs +58 -58
- package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
- package/dist/src/platform/windows/codex-runtime.mjs +215 -215
- package/dist/src/platform/windows/credential-store.mjs +27 -27
- package/dist/src/platform/windows/credential-store.ps1 +49 -49
- package/dist/src/platform/windows/desktop-host.mjs +65 -65
- package/dist/src/platform/windows/desktop-info.ps1 +38 -38
- package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
- package/dist/src/platform/windows/dots-profile.mjs +49 -49
- package/dist/src/platform/windows/launch-worker.mjs +41 -41
- package/dist/src/platform/windows/project-locations.mjs +16 -16
- package/dist/src/platform/windows/service-host.mjs +35 -35
- package/dist/src/platform/windows/user-environment.mjs +52 -52
- package/dist/src/project-management.mjs +58 -58
- package/dist/src/runtime-discovery.mjs +47 -47
- package/dist/src/self-relay/account-cleanup.mjs +30 -30
- package/dist/src/self-relay/admin-auth.mjs +59 -0
- package/dist/src/self-relay/admin-login-page.mjs +7 -0
- package/dist/src/self-relay/admin-page.mjs +87 -80
- package/dist/src/self-relay/admin-server.mjs +49 -18
- package/dist/src/self-relay/admin-style.mjs +58 -14
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
- package/dist/src/self-relay/admin-workstations.mjs +143 -0
- package/dist/src/self-relay/connection-reporting.mjs +38 -0
- package/dist/src/self-relay/connector.mjs +444 -439
- package/dist/src/self-relay/demo.mjs +31 -31
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +183 -183
- package/dist/src/self-relay/file-wire.mjs +13 -13
- package/dist/src/self-relay/lifecycle.mjs +11 -0
- package/dist/src/self-relay/p2p-probe.mjs +168 -168
- package/dist/src/self-relay/phone-connections.mjs +55 -0
- package/dist/src/self-relay/privacy-page.mjs +26 -26
- package/dist/src/self-relay/public-releases.mjs +67 -67
- package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
- package/dist/src/self-relay/server.mjs +63 -3
- package/dist/src/self-relay/usage-meter.mjs +141 -141
- package/dist/src/server.mjs +23 -9
- package/dist/src/service-doctor.mjs +148 -148
- package/dist/src/service-install.mjs +97 -97
- package/dist/src/service-lifecycle.mjs +139 -139
- package/dist/src/service-update.mjs +6 -3
- package/dist/src/shared-app-server.mjs +683 -666
- package/dist/src/shared-catalog.mjs +132 -132
- package/dist/src/shared-thread-stream.mjs +165 -165
- package/dist/src/usage-reset.mjs +17 -17
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
- package/tools/postinstall.mjs +186 -186
|
@@ -1,373 +1,373 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { executableKey } from './platform/path-identity.mjs';
|
|
3
|
-
import net from 'node:net';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { EventEmitter } from 'node:events';
|
|
7
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
-
import { readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
9
|
-
import { SharedAppServer } from './shared-app-server.mjs';
|
|
10
|
-
import { pruneRetainedDesktopRuntimes, resolveCodexRuntime, runtimeFilesAvailable } from './platform/codex-runtime.mjs';
|
|
11
|
-
|
|
12
|
-
import { defaultManagedPort, managedProcessUrl } from './managed-endpoint.mjs';
|
|
13
|
-
import { configureManagedEnvironment } from './platform/user-environment.mjs';
|
|
14
|
-
export const managedAppServerLauncherRevision = 3;
|
|
15
|
-
const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
|
|
16
|
-
|
|
17
|
-
export function managedPortAvailable(port = defaultManagedPort) {
|
|
18
|
-
return new Promise(resolve => {
|
|
19
|
-
const server = net.createServer();
|
|
20
|
-
server.once('error', () => resolve(false));
|
|
21
|
-
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
export function availableManagedPort() {
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
const server = net.createServer();
|
|
27
|
-
server.once('error', reject);
|
|
28
|
-
server.listen(0, '127.0.0.1', () => {
|
|
29
|
-
const port = server.address().port;
|
|
30
|
-
server.close(error => error ? reject(error) : resolve(port));
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class ManagedAppServer extends EventEmitter {
|
|
36
|
-
constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
|
|
37
|
-
wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(`ws://127.0.0.1:${defaultManagedPort}`),
|
|
38
|
-
selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime }) {
|
|
39
|
-
super();
|
|
40
|
-
this.stateRoot = stateRoot;
|
|
41
|
-
this.host = host;
|
|
42
|
-
this.environment = environment;
|
|
43
|
-
this.available = available;
|
|
44
|
-
this.selectPort = selectPort;
|
|
45
|
-
this.configureEnvironment = configureEnvironment;
|
|
46
|
-
this.resolveRuntime = resolveRuntime;
|
|
47
|
-
this.wait = wait;
|
|
48
|
-
this.timeout = timeout;
|
|
49
|
-
this.now = now;
|
|
50
|
-
this.recordPath = path.join(stateRoot, 'managed-app-server.json');
|
|
51
|
-
this.pendingPath = path.join(stateRoot, 'managed-app-server.pending.json');
|
|
52
|
-
this.lockPath = path.join(stateRoot, 'managed-app-server.lock');
|
|
53
|
-
this.client = client;
|
|
54
|
-
this.state = { state: 'absent', ready: false, error: null };
|
|
55
|
-
this.client.diagnosticIdentity = () => ({ kernelPid: this.state.pid ?? null, kernelCreated: this.state.created ?? null });
|
|
56
|
-
this.restartStatus = null;
|
|
57
|
-
this.stopped = true;
|
|
58
|
-
this.restarting = false;
|
|
59
|
-
this.retryAttempt = 0;
|
|
60
|
-
this.retryAfter = 0;
|
|
61
|
-
this.client.on('disconnected', () => {
|
|
62
|
-
if (!this.stopped && !this.restarting && !this.work) this.failed('Codex 内核连接断开。');
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
snapshot() {
|
|
67
|
-
return { ...this.state,
|
|
68
|
-
version: this.state.ready && this.client.ready ? this.client.serverVersion || null : null,
|
|
69
|
-
...(this.restartStatus ? { restart: { ...this.restartStatus } } : {}) };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async start() {
|
|
73
|
-
this.stopped = false;
|
|
74
|
-
return this.ensure();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async ensure() {
|
|
78
|
-
if (this.stopped) return false;
|
|
79
|
-
if (this.restarting) return false;
|
|
80
|
-
if (this.work) return this.work;
|
|
81
|
-
if (this.now() < this.retryAfter) return false;
|
|
82
|
-
this.work = this.ensureNow().finally(() => { this.work = null; });
|
|
83
|
-
return this.work;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async ensureNow(lockHeld = false) {
|
|
87
|
-
// Keep an already verified instance available while the periodic identity
|
|
88
|
-
// check runs; publishing "starting" every three seconds makes health flicker.
|
|
89
|
-
if (!this.state.ready) this.publish({ state: 'starting', ready: false, error: null });
|
|
90
|
-
try {
|
|
91
|
-
fs.mkdirSync(this.stateRoot, { recursive: true });
|
|
92
|
-
const release = lockHeld ? () => {} : await this.acquireLock();
|
|
93
|
-
if (!release) return false;
|
|
94
|
-
try {
|
|
95
|
-
const pending = readRecord(this.pendingPath);
|
|
96
|
-
if (pending?.phase === 'spawning') throw new Error('上次内核启动的进程交接未完成,已停止自动启动以避免重复内核;请检查内核启动日志与 managed-app-server.pending.json。');
|
|
97
|
-
let record = pending || readRecord(this.recordPath);
|
|
98
|
-
let actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
99
|
-
if (actual && !sameManagedProcess(record, actual)) {
|
|
100
|
-
// Stale PID now belongs to someone else: leave it untouched and select
|
|
101
|
-
// another available endpoint rather than adopting or stopping it.
|
|
102
|
-
actual = null;
|
|
103
|
-
record = null;
|
|
104
|
-
}
|
|
105
|
-
const startedHere = !actual;
|
|
106
|
-
const savedRuntime = actual ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) : null;
|
|
107
|
-
const runtime = actual && savedRuntime?.executable && runtimeFilesAvailable(savedRuntime.executable)
|
|
108
|
-
? savedRuntime
|
|
109
|
-
: await this.resolveRuntime(this.stateRoot, { refresh: !actual, environment: this.environment });
|
|
110
|
-
if (actual && !runtimeFilesAvailable(actual.executable)) {
|
|
111
|
-
throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
|
|
112
|
-
}
|
|
113
|
-
if (!actual) {
|
|
114
|
-
fs.rmSync(this.pendingPath, { force: true });
|
|
115
|
-
const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
|
|
116
|
-
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
|
|
117
|
-
for (const key of Object.keys(childEnvironment)) {
|
|
118
|
-
if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
|
|
119
|
-
}
|
|
120
|
-
// A probe cannot reserve a port for another process. Retry only when our
|
|
121
|
-
// previous child has definitely exited, never on a live unready kernel.
|
|
122
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
123
|
-
const preferred = attempt === 0 ? Number(managedProcessUrl(record)?.split(':').at(-1)) || defaultManagedPort : 0;
|
|
124
|
-
const port = preferred && await this.available(preferred) ? preferred : await this.selectPort();
|
|
125
|
-
const url = `ws://127.0.0.1:${port}`;
|
|
126
|
-
// Commit intent before spawning: if the launcher dies before it can
|
|
127
|
-
// record the child PID, a successor must fail closed, not duplicate it.
|
|
128
|
-
writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
|
|
129
|
-
const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
|
|
130
|
-
[path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
|
|
131
|
-
const started = await this.host.process(pid);
|
|
132
|
-
if (!started) {
|
|
133
|
-
fs.rmSync(this.pendingPath, { force: true });
|
|
134
|
-
if (attempt < 4) continue;
|
|
135
|
-
throw new Error('Codex 内核启动后退出,动态端口尝试已用完。');
|
|
136
|
-
}
|
|
137
|
-
record = { ...started, url, launcherRevision: managedAppServerLauncherRevision };
|
|
138
|
-
if (!sameManagedProcess(record, started) || executableKey(started.executable) !== executableKey(runtime.executable)) {
|
|
139
|
-
throw new Error('Codex 内核启动后进程身份无法确认,未启动第二个实例。');
|
|
140
|
-
}
|
|
141
|
-
// Private ownership journal is not a published endpoint. It prevents
|
|
142
|
-
// duplicate children even if the launcher crashes before readiness.
|
|
143
|
-
writeRecord(this.pendingPath, record);
|
|
144
|
-
actual = started;
|
|
145
|
-
try { await this.connectReady(record); break; }
|
|
146
|
-
catch (error) {
|
|
147
|
-
const remaining = await this.host.process(pid);
|
|
148
|
-
if (remaining || attempt === 4) throw error;
|
|
149
|
-
fs.rmSync(this.pendingPath, { force: true });
|
|
150
|
-
actual = null;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
} else await this.connectReady(record);
|
|
154
|
-
const url = managedProcessUrl(record);
|
|
155
|
-
// Do not publish a guessed port or update Desktop before ownership,
|
|
156
|
-
// readiness and protocol initialization have all succeeded.
|
|
157
|
-
if (this.publishedUrl !== url) await this.configureEnvironment(this.stateRoot, url);
|
|
158
|
-
this.environment.CODEX_APP_SERVER_WS_URL = url;
|
|
159
|
-
process.env.CODEX_APP_SERVER_WS_URL = url;
|
|
160
|
-
if (this.publishedUrl !== url || readRecord(this.pendingPath)) writeRecord(this.recordPath, { ...record, url });
|
|
161
|
-
this.publishedUrl = url;
|
|
162
|
-
fs.rmSync(this.pendingPath, { force: true });
|
|
163
|
-
if (startedHere) {
|
|
164
|
-
try { await pruneRetainedDesktopRuntimes(this.stateRoot, actual.executable); }
|
|
165
|
-
catch (error) { console.error('旧版 Codex 内核文件清理失败:', error); }
|
|
166
|
-
}
|
|
167
|
-
const preferredExecutable = path.resolve(runtime.executable);
|
|
168
|
-
const updatePending = executableKey(actual.executable) !== executableKey(preferredExecutable);
|
|
169
|
-
const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
|
|
170
|
-
this.retryAttempt = 0;
|
|
171
|
-
this.retryAfter = 0;
|
|
172
|
-
this.publish({ state: 'ready', ready: true, error: null, pid: actual.pid,
|
|
173
|
-
created: actual.created, executable: actual.executable, preferredExecutable,
|
|
174
|
-
updatePending, launcherRevision, selectedVersion: runtime.version, source: runtime.source,
|
|
175
|
-
launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
|
|
176
|
-
url });
|
|
177
|
-
return true;
|
|
178
|
-
} finally { release(); }
|
|
179
|
-
} catch (error) {
|
|
180
|
-
this.failed(error instanceof Error ? error.message : String(error));
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async acquireLock() {
|
|
186
|
-
const token = randomUUID();
|
|
187
|
-
const temporary = `${this.lockPath}.${token}.tmp`;
|
|
188
|
-
const owner = await this.host.process(process.pid);
|
|
189
|
-
if (!owner) throw new Error('启动器进程身份无法确认。');
|
|
190
|
-
try {
|
|
191
|
-
// Publish a fully written record atomically; a crash cannot leave an
|
|
192
|
-
// empty lock that has no identifiable owner.
|
|
193
|
-
fs.writeFileSync(temporary, JSON.stringify({ ...owner, token }), { flag: 'wx', mode: 0o600 });
|
|
194
|
-
try { fs.linkSync(temporary, this.lockPath); }
|
|
195
|
-
catch (error) {
|
|
196
|
-
if (error.code !== 'EEXIST') throw error;
|
|
197
|
-
const previous = readRecord(this.lockPath);
|
|
198
|
-
if (!previous?.token) throw new Error('内核启动锁无法识别,请检查 managed-app-server.lock。');
|
|
199
|
-
const actual = await this.host.process(previous.pid);
|
|
200
|
-
if (!actual || actual.created !== previous.created || executableKey(actual.executable) !== executableKey(previous.executable)) {
|
|
201
|
-
// One permanent tombstone per abandoned generation means two stale
|
|
202
|
-
// observers cannot delete a replacement owner's lock (ABA race).
|
|
203
|
-
if (await this.claimRecovery(previous, this.lockPath, temporary)) {
|
|
204
|
-
if (readRecord(this.lockPath)?.token === previous.token) fs.rmSync(this.lockPath, { force: true });
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
return () => { if (readRecord(this.lockPath)?.token === token) fs.rmSync(this.lockPath, { force: true }); };
|
|
210
|
-
} finally { fs.rmSync(temporary, { force: true }); }
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async claimRecovery(previous, target, ownerFile, depth = 0) {
|
|
214
|
-
if (depth > 16) throw new Error('内核启动锁多次异常中断,请检查启动器日志。');
|
|
215
|
-
const recovered = `${this.lockPath}.${previous.token}.recovered`;
|
|
216
|
-
try { fs.linkSync(ownerFile, recovered); return true; }
|
|
217
|
-
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
218
|
-
const claimant = readRecord(recovered);
|
|
219
|
-
if (!claimant?.token) throw new Error('内核启动锁回收记录无法识别。');
|
|
220
|
-
const actual = await this.host.process(claimant.pid);
|
|
221
|
-
if (actual && actual.created === claimant.created && executableKey(actual.executable) === executableKey(claimant.executable)) return false;
|
|
222
|
-
// A reclaimer can itself crash. Keep an append-only ownership chain so its
|
|
223
|
-
// successor can complete cleanup without allowing two live reclaimers.
|
|
224
|
-
return this.claimRecovery(claimant, recovered, ownerFile, depth + 1);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async connectReady(expected) {
|
|
228
|
-
const url = managedProcessUrl(expected);
|
|
229
|
-
if (!url) throw new Error('Codex 内核地址无效。');
|
|
230
|
-
await this.waitUntilReady(expected);
|
|
231
|
-
if (this.client.url !== url) {
|
|
232
|
-
await this.disconnectClient();
|
|
233
|
-
this.client.url = url;
|
|
234
|
-
}
|
|
235
|
-
await this.client.connect();
|
|
236
|
-
if (!this.client.ready || !sameManagedProcess(expected, await this.host.process(expected.pid))
|
|
237
|
-
|| !await this.host.ownsListener(expected, url)) {
|
|
238
|
-
this.client.close();
|
|
239
|
-
throw new Error('Codex 内核协议初始化或监听进程验证未完成。');
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async disconnectClient() {
|
|
244
|
-
const socket = this.client.socket;
|
|
245
|
-
this.client.close();
|
|
246
|
-
if (socket && socket.readyState !== 3) {
|
|
247
|
-
await new Promise(resolve => {
|
|
248
|
-
const timer = setTimeout(() => { socket.terminate(); }, 1000);
|
|
249
|
-
socket.once('close', () => { clearTimeout(timer); resolve(); });
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
this.client.ready = false;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async waitUntilReady(expected) {
|
|
256
|
-
const url = managedProcessUrl(expected);
|
|
257
|
-
const deadline = this.now() + this.timeout;
|
|
258
|
-
while (true) {
|
|
259
|
-
const actual = await this.host.process(expected.pid);
|
|
260
|
-
if (!sameManagedProcess(expected, actual)) throw new Error('Codex 内核在就绪前退出。');
|
|
261
|
-
if (await this.host.ownsListener(expected, url) && await this.host.ready(url.replace('ws:', 'http:') + '/readyz')) return;
|
|
262
|
-
if (this.now() >= deadline) throw new Error('Codex 内核未在 30 秒内就绪;保留已启动实例,不会另起重复内核。');
|
|
263
|
-
await this.wait(250);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
restart(operationId) {
|
|
268
|
-
if (this.stopped) throw new Error('工作站服务正在停止,不能重启 Codex 内核。');
|
|
269
|
-
if (this.restartWork) {
|
|
270
|
-
if (this.restartStatus?.operationId === operationId) return this.snapshot();
|
|
271
|
-
const error = new Error('Codex 内核已经在重启,请等待当前操作完成。');
|
|
272
|
-
error.code = 'managed_app_server_restarting';
|
|
273
|
-
throw error;
|
|
274
|
-
}
|
|
275
|
-
this.restartStatus = { operationId, status: 'running', stage: 'checking', error: null,
|
|
276
|
-
startedAt: new Date(this.now()).toISOString() };
|
|
277
|
-
this.emit('state', this.snapshot());
|
|
278
|
-
this.restartWork = this.restartNow().finally(() => { this.restartWork = null; });
|
|
279
|
-
return this.snapshot();
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
async restartNow() {
|
|
283
|
-
this.restarting = true;
|
|
284
|
-
await this.work;
|
|
285
|
-
const previous = this.state;
|
|
286
|
-
let release;
|
|
287
|
-
this.publish({ ...previous, state: 'restarting', ready: false, error: null });
|
|
288
|
-
try {
|
|
289
|
-
release = await this.acquireLock();
|
|
290
|
-
if (!release) throw new Error('另一个内核启动操作正在进行,请稍后重试。');
|
|
291
|
-
const pending = readRecord(this.pendingPath);
|
|
292
|
-
if (pending?.phase === 'spawning') throw new Error('上次内核启动交接未完成,无法安全重启未知进程;请检查内核启动日志。');
|
|
293
|
-
const record = pending || readRecord(this.recordPath);
|
|
294
|
-
const actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
295
|
-
if (actual && !sameManagedProcess(record, actual)) {
|
|
296
|
-
throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
|
|
297
|
-
}
|
|
298
|
-
this.restartStage('stopping');
|
|
299
|
-
// Resolve the replacement before stopping the current working process.
|
|
300
|
-
const runtime = await this.resolveRuntime(this.stateRoot, { refresh: true, environment: this.environment });
|
|
301
|
-
const desktop = await this.host.prepareKernelRestart?.();
|
|
302
|
-
await this.disconnectClient();
|
|
303
|
-
if (actual) {
|
|
304
|
-
await this.host.stop(actual.pid, actual);
|
|
305
|
-
await this.waitUntilStopped(actual);
|
|
306
|
-
}
|
|
307
|
-
fs.rmSync(this.pendingPath, { force: true });
|
|
308
|
-
this.publishedUrl = null;
|
|
309
|
-
this.retryAttempt = 0;
|
|
310
|
-
this.retryAfter = 0;
|
|
311
|
-
this.restartStage('starting');
|
|
312
|
-
const ready = await this.ensureNow(true);
|
|
313
|
-
if (!ready) throw new Error(this.state.error || 'Codex 内核未能重新启动。');
|
|
314
|
-
const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: this.state.url };
|
|
315
|
-
delete desktopEnvironment.CODEX_APP_SERVER_FORCE_CLI;
|
|
316
|
-
delete desktopEnvironment.CODEX_APP_TOOLS_PIPE_PATH;
|
|
317
|
-
await this.host.resumeAfterKernelRestart?.(desktop, desktopEnvironment);
|
|
318
|
-
this.restartStatus = { ...this.restartStatus, status: 'completed', stage: 'ready',
|
|
319
|
-
completedAt: new Date(this.now()).toISOString() };
|
|
320
|
-
this.emit('state', this.snapshot());
|
|
321
|
-
} catch (error) {
|
|
322
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
-
if (this.state.state === 'restarting') this.failed(message);
|
|
324
|
-
this.restartStatus = { ...this.restartStatus, status: 'failed', stage: 'failed', error: message,
|
|
325
|
-
completedAt: new Date(this.now()).toISOString() };
|
|
326
|
-
this.emit('state', this.snapshot());
|
|
327
|
-
} finally {
|
|
328
|
-
release?.();
|
|
329
|
-
this.restarting = false;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
restartStage(stage) {
|
|
334
|
-
this.restartStatus = { ...this.restartStatus, stage };
|
|
335
|
-
this.emit('state', this.snapshot());
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async waitUntilStopped(expected) {
|
|
339
|
-
const deadline = this.now() + 10_000;
|
|
340
|
-
while (true) {
|
|
341
|
-
const actual = await this.host.process(expected.pid);
|
|
342
|
-
if (!sameManagedProcess(expected, actual)) return;
|
|
343
|
-
if (this.now() >= deadline) throw new Error('Codex 内核未在 10 秒内退出;不会启动第二个实例。');
|
|
344
|
-
await this.wait(100);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
failed(message) {
|
|
349
|
-
const retryMs = retryDelays[Math.min(this.retryAttempt++, retryDelays.length - 1)];
|
|
350
|
-
this.retryAfter = this.now() + retryMs;
|
|
351
|
-
this.publish({ state: 'failed', ready: false, error: message, retryAt: new Date(this.retryAfter).toISOString() });
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
publish(value) {
|
|
355
|
-
const changed = JSON.stringify(value) !== JSON.stringify(this.state);
|
|
356
|
-
this.state = value;
|
|
357
|
-
if (changed) this.emit('state', this.snapshot());
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
close() {
|
|
361
|
-
this.stopped = true;
|
|
362
|
-
this.client.close();
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
export function sameManagedProcess(record, actual) {
|
|
367
|
-
if (!record || !actual || record.pid !== actual.pid || record.created !== actual.created) return false;
|
|
368
|
-
if (!record.executable || !actual.executable || executableKey(record.executable) !== executableKey(actual.executable)) return false;
|
|
369
|
-
const url = managedProcessUrl(record);
|
|
370
|
-
if (!url || managedProcessUrl(actual) !== url) return false;
|
|
371
|
-
return Array.isArray(actual.args) ? JSON.stringify(record.args) === JSON.stringify(actual.args)
|
|
372
|
-
: record.command === actual.command;
|
|
373
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { executableKey } from './platform/path-identity.mjs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
9
|
+
import { SharedAppServer } from './shared-app-server.mjs';
|
|
10
|
+
import { pruneRetainedDesktopRuntimes, resolveCodexRuntime, runtimeFilesAvailable } from './platform/codex-runtime.mjs';
|
|
11
|
+
|
|
12
|
+
import { defaultManagedPort, managedProcessUrl } from './managed-endpoint.mjs';
|
|
13
|
+
import { configureManagedEnvironment } from './platform/user-environment.mjs';
|
|
14
|
+
export const managedAppServerLauncherRevision = 3;
|
|
15
|
+
const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
|
|
16
|
+
|
|
17
|
+
export function managedPortAvailable(port = defaultManagedPort) {
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
const server = net.createServer();
|
|
20
|
+
server.once('error', () => resolve(false));
|
|
21
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function availableManagedPort() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const server = net.createServer();
|
|
27
|
+
server.once('error', reject);
|
|
28
|
+
server.listen(0, '127.0.0.1', () => {
|
|
29
|
+
const port = server.address().port;
|
|
30
|
+
server.close(error => error ? reject(error) : resolve(port));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ManagedAppServer extends EventEmitter {
|
|
36
|
+
constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
|
|
37
|
+
wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(`ws://127.0.0.1:${defaultManagedPort}`),
|
|
38
|
+
selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime }) {
|
|
39
|
+
super();
|
|
40
|
+
this.stateRoot = stateRoot;
|
|
41
|
+
this.host = host;
|
|
42
|
+
this.environment = environment;
|
|
43
|
+
this.available = available;
|
|
44
|
+
this.selectPort = selectPort;
|
|
45
|
+
this.configureEnvironment = configureEnvironment;
|
|
46
|
+
this.resolveRuntime = resolveRuntime;
|
|
47
|
+
this.wait = wait;
|
|
48
|
+
this.timeout = timeout;
|
|
49
|
+
this.now = now;
|
|
50
|
+
this.recordPath = path.join(stateRoot, 'managed-app-server.json');
|
|
51
|
+
this.pendingPath = path.join(stateRoot, 'managed-app-server.pending.json');
|
|
52
|
+
this.lockPath = path.join(stateRoot, 'managed-app-server.lock');
|
|
53
|
+
this.client = client;
|
|
54
|
+
this.state = { state: 'absent', ready: false, error: null };
|
|
55
|
+
this.client.diagnosticIdentity = () => ({ kernelPid: this.state.pid ?? null, kernelCreated: this.state.created ?? null });
|
|
56
|
+
this.restartStatus = null;
|
|
57
|
+
this.stopped = true;
|
|
58
|
+
this.restarting = false;
|
|
59
|
+
this.retryAttempt = 0;
|
|
60
|
+
this.retryAfter = 0;
|
|
61
|
+
this.client.on('disconnected', () => {
|
|
62
|
+
if (!this.stopped && !this.restarting && !this.work) this.failed('Codex 内核连接断开。');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
snapshot() {
|
|
67
|
+
return { ...this.state,
|
|
68
|
+
version: this.state.ready && this.client.ready ? this.client.serverVersion || null : null,
|
|
69
|
+
...(this.restartStatus ? { restart: { ...this.restartStatus } } : {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async start() {
|
|
73
|
+
this.stopped = false;
|
|
74
|
+
return this.ensure();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async ensure() {
|
|
78
|
+
if (this.stopped) return false;
|
|
79
|
+
if (this.restarting) return false;
|
|
80
|
+
if (this.work) return this.work;
|
|
81
|
+
if (this.now() < this.retryAfter) return false;
|
|
82
|
+
this.work = this.ensureNow().finally(() => { this.work = null; });
|
|
83
|
+
return this.work;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async ensureNow(lockHeld = false) {
|
|
87
|
+
// Keep an already verified instance available while the periodic identity
|
|
88
|
+
// check runs; publishing "starting" every three seconds makes health flicker.
|
|
89
|
+
if (!this.state.ready) this.publish({ state: 'starting', ready: false, error: null });
|
|
90
|
+
try {
|
|
91
|
+
fs.mkdirSync(this.stateRoot, { recursive: true });
|
|
92
|
+
const release = lockHeld ? () => {} : await this.acquireLock();
|
|
93
|
+
if (!release) return false;
|
|
94
|
+
try {
|
|
95
|
+
const pending = readRecord(this.pendingPath);
|
|
96
|
+
if (pending?.phase === 'spawning') throw new Error('上次内核启动的进程交接未完成,已停止自动启动以避免重复内核;请检查内核启动日志与 managed-app-server.pending.json。');
|
|
97
|
+
let record = pending || readRecord(this.recordPath);
|
|
98
|
+
let actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
99
|
+
if (actual && !sameManagedProcess(record, actual)) {
|
|
100
|
+
// Stale PID now belongs to someone else: leave it untouched and select
|
|
101
|
+
// another available endpoint rather than adopting or stopping it.
|
|
102
|
+
actual = null;
|
|
103
|
+
record = null;
|
|
104
|
+
}
|
|
105
|
+
const startedHere = !actual;
|
|
106
|
+
const savedRuntime = actual ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) : null;
|
|
107
|
+
const runtime = actual && savedRuntime?.executable && runtimeFilesAvailable(savedRuntime.executable)
|
|
108
|
+
? savedRuntime
|
|
109
|
+
: await this.resolveRuntime(this.stateRoot, { refresh: !actual, environment: this.environment });
|
|
110
|
+
if (actual && !runtimeFilesAvailable(actual.executable)) {
|
|
111
|
+
throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
|
|
112
|
+
}
|
|
113
|
+
if (!actual) {
|
|
114
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
115
|
+
const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
|
|
116
|
+
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
|
|
117
|
+
for (const key of Object.keys(childEnvironment)) {
|
|
118
|
+
if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
|
|
119
|
+
}
|
|
120
|
+
// A probe cannot reserve a port for another process. Retry only when our
|
|
121
|
+
// previous child has definitely exited, never on a live unready kernel.
|
|
122
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
123
|
+
const preferred = attempt === 0 ? Number(managedProcessUrl(record)?.split(':').at(-1)) || defaultManagedPort : 0;
|
|
124
|
+
const port = preferred && await this.available(preferred) ? preferred : await this.selectPort();
|
|
125
|
+
const url = `ws://127.0.0.1:${port}`;
|
|
126
|
+
// Commit intent before spawning: if the launcher dies before it can
|
|
127
|
+
// record the child PID, a successor must fail closed, not duplicate it.
|
|
128
|
+
writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
|
|
129
|
+
const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
|
|
130
|
+
[path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
|
|
131
|
+
const started = await this.host.process(pid);
|
|
132
|
+
if (!started) {
|
|
133
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
134
|
+
if (attempt < 4) continue;
|
|
135
|
+
throw new Error('Codex 内核启动后退出,动态端口尝试已用完。');
|
|
136
|
+
}
|
|
137
|
+
record = { ...started, url, launcherRevision: managedAppServerLauncherRevision };
|
|
138
|
+
if (!sameManagedProcess(record, started) || executableKey(started.executable) !== executableKey(runtime.executable)) {
|
|
139
|
+
throw new Error('Codex 内核启动后进程身份无法确认,未启动第二个实例。');
|
|
140
|
+
}
|
|
141
|
+
// Private ownership journal is not a published endpoint. It prevents
|
|
142
|
+
// duplicate children even if the launcher crashes before readiness.
|
|
143
|
+
writeRecord(this.pendingPath, record);
|
|
144
|
+
actual = started;
|
|
145
|
+
try { await this.connectReady(record); break; }
|
|
146
|
+
catch (error) {
|
|
147
|
+
const remaining = await this.host.process(pid);
|
|
148
|
+
if (remaining || attempt === 4) throw error;
|
|
149
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
150
|
+
actual = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} else await this.connectReady(record);
|
|
154
|
+
const url = managedProcessUrl(record);
|
|
155
|
+
// Do not publish a guessed port or update Desktop before ownership,
|
|
156
|
+
// readiness and protocol initialization have all succeeded.
|
|
157
|
+
if (this.publishedUrl !== url) await this.configureEnvironment(this.stateRoot, url);
|
|
158
|
+
this.environment.CODEX_APP_SERVER_WS_URL = url;
|
|
159
|
+
process.env.CODEX_APP_SERVER_WS_URL = url;
|
|
160
|
+
if (this.publishedUrl !== url || readRecord(this.pendingPath)) writeRecord(this.recordPath, { ...record, url });
|
|
161
|
+
this.publishedUrl = url;
|
|
162
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
163
|
+
if (startedHere) {
|
|
164
|
+
try { await pruneRetainedDesktopRuntimes(this.stateRoot, actual.executable); }
|
|
165
|
+
catch (error) { console.error('旧版 Codex 内核文件清理失败:', error); }
|
|
166
|
+
}
|
|
167
|
+
const preferredExecutable = path.resolve(runtime.executable);
|
|
168
|
+
const updatePending = executableKey(actual.executable) !== executableKey(preferredExecutable);
|
|
169
|
+
const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
|
|
170
|
+
this.retryAttempt = 0;
|
|
171
|
+
this.retryAfter = 0;
|
|
172
|
+
this.publish({ state: 'ready', ready: true, error: null, pid: actual.pid,
|
|
173
|
+
created: actual.created, executable: actual.executable, preferredExecutable,
|
|
174
|
+
updatePending, launcherRevision, selectedVersion: runtime.version, source: runtime.source,
|
|
175
|
+
launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
|
|
176
|
+
url });
|
|
177
|
+
return true;
|
|
178
|
+
} finally { release(); }
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.failed(error instanceof Error ? error.message : String(error));
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async acquireLock() {
|
|
186
|
+
const token = randomUUID();
|
|
187
|
+
const temporary = `${this.lockPath}.${token}.tmp`;
|
|
188
|
+
const owner = await this.host.process(process.pid);
|
|
189
|
+
if (!owner) throw new Error('启动器进程身份无法确认。');
|
|
190
|
+
try {
|
|
191
|
+
// Publish a fully written record atomically; a crash cannot leave an
|
|
192
|
+
// empty lock that has no identifiable owner.
|
|
193
|
+
fs.writeFileSync(temporary, JSON.stringify({ ...owner, token }), { flag: 'wx', mode: 0o600 });
|
|
194
|
+
try { fs.linkSync(temporary, this.lockPath); }
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code !== 'EEXIST') throw error;
|
|
197
|
+
const previous = readRecord(this.lockPath);
|
|
198
|
+
if (!previous?.token) throw new Error('内核启动锁无法识别,请检查 managed-app-server.lock。');
|
|
199
|
+
const actual = await this.host.process(previous.pid);
|
|
200
|
+
if (!actual || actual.created !== previous.created || executableKey(actual.executable) !== executableKey(previous.executable)) {
|
|
201
|
+
// One permanent tombstone per abandoned generation means two stale
|
|
202
|
+
// observers cannot delete a replacement owner's lock (ABA race).
|
|
203
|
+
if (await this.claimRecovery(previous, this.lockPath, temporary)) {
|
|
204
|
+
if (readRecord(this.lockPath)?.token === previous.token) fs.rmSync(this.lockPath, { force: true });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
return () => { if (readRecord(this.lockPath)?.token === token) fs.rmSync(this.lockPath, { force: true }); };
|
|
210
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async claimRecovery(previous, target, ownerFile, depth = 0) {
|
|
214
|
+
if (depth > 16) throw new Error('内核启动锁多次异常中断,请检查启动器日志。');
|
|
215
|
+
const recovered = `${this.lockPath}.${previous.token}.recovered`;
|
|
216
|
+
try { fs.linkSync(ownerFile, recovered); return true; }
|
|
217
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
218
|
+
const claimant = readRecord(recovered);
|
|
219
|
+
if (!claimant?.token) throw new Error('内核启动锁回收记录无法识别。');
|
|
220
|
+
const actual = await this.host.process(claimant.pid);
|
|
221
|
+
if (actual && actual.created === claimant.created && executableKey(actual.executable) === executableKey(claimant.executable)) return false;
|
|
222
|
+
// A reclaimer can itself crash. Keep an append-only ownership chain so its
|
|
223
|
+
// successor can complete cleanup without allowing two live reclaimers.
|
|
224
|
+
return this.claimRecovery(claimant, recovered, ownerFile, depth + 1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async connectReady(expected) {
|
|
228
|
+
const url = managedProcessUrl(expected);
|
|
229
|
+
if (!url) throw new Error('Codex 内核地址无效。');
|
|
230
|
+
await this.waitUntilReady(expected);
|
|
231
|
+
if (this.client.url !== url) {
|
|
232
|
+
await this.disconnectClient();
|
|
233
|
+
this.client.url = url;
|
|
234
|
+
}
|
|
235
|
+
await this.client.connect();
|
|
236
|
+
if (!this.client.ready || !sameManagedProcess(expected, await this.host.process(expected.pid))
|
|
237
|
+
|| !await this.host.ownsListener(expected, url)) {
|
|
238
|
+
this.client.close();
|
|
239
|
+
throw new Error('Codex 内核协议初始化或监听进程验证未完成。');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async disconnectClient() {
|
|
244
|
+
const socket = this.client.socket;
|
|
245
|
+
this.client.close();
|
|
246
|
+
if (socket && socket.readyState !== 3) {
|
|
247
|
+
await new Promise(resolve => {
|
|
248
|
+
const timer = setTimeout(() => { socket.terminate(); }, 1000);
|
|
249
|
+
socket.once('close', () => { clearTimeout(timer); resolve(); });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
this.client.ready = false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async waitUntilReady(expected) {
|
|
256
|
+
const url = managedProcessUrl(expected);
|
|
257
|
+
const deadline = this.now() + this.timeout;
|
|
258
|
+
while (true) {
|
|
259
|
+
const actual = await this.host.process(expected.pid);
|
|
260
|
+
if (!sameManagedProcess(expected, actual)) throw new Error('Codex 内核在就绪前退出。');
|
|
261
|
+
if (await this.host.ownsListener(expected, url) && await this.host.ready(url.replace('ws:', 'http:') + '/readyz')) return;
|
|
262
|
+
if (this.now() >= deadline) throw new Error('Codex 内核未在 30 秒内就绪;保留已启动实例,不会另起重复内核。');
|
|
263
|
+
await this.wait(250);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
restart(operationId) {
|
|
268
|
+
if (this.stopped) throw new Error('工作站服务正在停止,不能重启 Codex 内核。');
|
|
269
|
+
if (this.restartWork) {
|
|
270
|
+
if (this.restartStatus?.operationId === operationId) return this.snapshot();
|
|
271
|
+
const error = new Error('Codex 内核已经在重启,请等待当前操作完成。');
|
|
272
|
+
error.code = 'managed_app_server_restarting';
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
this.restartStatus = { operationId, status: 'running', stage: 'checking', error: null,
|
|
276
|
+
startedAt: new Date(this.now()).toISOString() };
|
|
277
|
+
this.emit('state', this.snapshot());
|
|
278
|
+
this.restartWork = this.restartNow().finally(() => { this.restartWork = null; });
|
|
279
|
+
return this.snapshot();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async restartNow() {
|
|
283
|
+
this.restarting = true;
|
|
284
|
+
await this.work;
|
|
285
|
+
const previous = this.state;
|
|
286
|
+
let release;
|
|
287
|
+
this.publish({ ...previous, state: 'restarting', ready: false, error: null });
|
|
288
|
+
try {
|
|
289
|
+
release = await this.acquireLock();
|
|
290
|
+
if (!release) throw new Error('另一个内核启动操作正在进行,请稍后重试。');
|
|
291
|
+
const pending = readRecord(this.pendingPath);
|
|
292
|
+
if (pending?.phase === 'spawning') throw new Error('上次内核启动交接未完成,无法安全重启未知进程;请检查内核启动日志。');
|
|
293
|
+
const record = pending || readRecord(this.recordPath);
|
|
294
|
+
const actual = record?.pid ? await this.host.process(record.pid) : null;
|
|
295
|
+
if (actual && !sameManagedProcess(record, actual)) {
|
|
296
|
+
throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
|
|
297
|
+
}
|
|
298
|
+
this.restartStage('stopping');
|
|
299
|
+
// Resolve the replacement before stopping the current working process.
|
|
300
|
+
const runtime = await this.resolveRuntime(this.stateRoot, { refresh: true, environment: this.environment });
|
|
301
|
+
const desktop = await this.host.prepareKernelRestart?.();
|
|
302
|
+
await this.disconnectClient();
|
|
303
|
+
if (actual) {
|
|
304
|
+
await this.host.stop(actual.pid, actual);
|
|
305
|
+
await this.waitUntilStopped(actual);
|
|
306
|
+
}
|
|
307
|
+
fs.rmSync(this.pendingPath, { force: true });
|
|
308
|
+
this.publishedUrl = null;
|
|
309
|
+
this.retryAttempt = 0;
|
|
310
|
+
this.retryAfter = 0;
|
|
311
|
+
this.restartStage('starting');
|
|
312
|
+
const ready = await this.ensureNow(true);
|
|
313
|
+
if (!ready) throw new Error(this.state.error || 'Codex 内核未能重新启动。');
|
|
314
|
+
const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: this.state.url };
|
|
315
|
+
delete desktopEnvironment.CODEX_APP_SERVER_FORCE_CLI;
|
|
316
|
+
delete desktopEnvironment.CODEX_APP_TOOLS_PIPE_PATH;
|
|
317
|
+
await this.host.resumeAfterKernelRestart?.(desktop, desktopEnvironment);
|
|
318
|
+
this.restartStatus = { ...this.restartStatus, status: 'completed', stage: 'ready',
|
|
319
|
+
completedAt: new Date(this.now()).toISOString() };
|
|
320
|
+
this.emit('state', this.snapshot());
|
|
321
|
+
} catch (error) {
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
if (this.state.state === 'restarting') this.failed(message);
|
|
324
|
+
this.restartStatus = { ...this.restartStatus, status: 'failed', stage: 'failed', error: message,
|
|
325
|
+
completedAt: new Date(this.now()).toISOString() };
|
|
326
|
+
this.emit('state', this.snapshot());
|
|
327
|
+
} finally {
|
|
328
|
+
release?.();
|
|
329
|
+
this.restarting = false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
restartStage(stage) {
|
|
334
|
+
this.restartStatus = { ...this.restartStatus, stage };
|
|
335
|
+
this.emit('state', this.snapshot());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async waitUntilStopped(expected) {
|
|
339
|
+
const deadline = this.now() + 10_000;
|
|
340
|
+
while (true) {
|
|
341
|
+
const actual = await this.host.process(expected.pid);
|
|
342
|
+
if (!sameManagedProcess(expected, actual)) return;
|
|
343
|
+
if (this.now() >= deadline) throw new Error('Codex 内核未在 10 秒内退出;不会启动第二个实例。');
|
|
344
|
+
await this.wait(100);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
failed(message) {
|
|
349
|
+
const retryMs = retryDelays[Math.min(this.retryAttempt++, retryDelays.length - 1)];
|
|
350
|
+
this.retryAfter = this.now() + retryMs;
|
|
351
|
+
this.publish({ state: 'failed', ready: false, error: message, retryAt: new Date(this.retryAfter).toISOString() });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
publish(value) {
|
|
355
|
+
const changed = JSON.stringify(value) !== JSON.stringify(this.state);
|
|
356
|
+
this.state = value;
|
|
357
|
+
if (changed) this.emit('state', this.snapshot());
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
close() {
|
|
361
|
+
this.stopped = true;
|
|
362
|
+
this.client.close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function sameManagedProcess(record, actual) {
|
|
367
|
+
if (!record || !actual || record.pid !== actual.pid || record.created !== actual.created) return false;
|
|
368
|
+
if (!record.executable || !actual.executable || executableKey(record.executable) !== executableKey(actual.executable)) return false;
|
|
369
|
+
const url = managedProcessUrl(record);
|
|
370
|
+
if (!url || managedProcessUrl(actual) !== url) return false;
|
|
371
|
+
return Array.isArray(actual.args) ? JSON.stringify(record.args) === JSON.stringify(actual.args)
|
|
372
|
+
: record.command === actual.command;
|
|
373
|
+
}
|