@walkhi/code-relax 0.1.0-beta.45 → 0.1.0-beta.50
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/dist/bin/codex-remote.mjs +0 -1
- package/dist/bin/release-upload-server.mjs +17 -1
- package/dist/build-platform.json +1 -1
- package/dist/shared/app-server-events.cjs +29 -19
- package/dist/shared/message-delivery.cjs +15 -0
- package/dist/shared/server-requests.cjs +94 -2
- package/dist/src/desktop-launcher.mjs +28 -12
- package/dist/src/dots-chat-server.mjs +1 -1
- package/dist/src/dots-room-activity.mjs +2 -0
- package/dist/src/platform/windows/desktop-info.ps1 +1 -1
- package/dist/src/runtime-discovery.mjs +1 -1
- package/dist/src/self-relay/public-releases.mjs +19 -4
- package/dist/src/self-relay/server.mjs +1 -1
- package/dist/src/server.mjs +33 -7
- package/dist/src/service-doctor.mjs +24 -3
- package/dist/src/service-restart.mjs +8 -8
- package/dist/src/service-update-worker.mjs +2 -2
- package/dist/src/service-update.mjs +7 -7
- package/dist/src/shared-recovery.mjs +12 -7
- package/dist/src/shared-thread-stream.mjs +5 -4
- package/package.json +4 -4
|
@@ -290,7 +290,6 @@ async function main() {
|
|
|
290
290
|
let stoppedForUpdate = false;
|
|
291
291
|
const installed = await installService(packageRoot(path.dirname(fileURLToPath(import.meta.url))), serviceRoot, executable, physicalStateRoot, {
|
|
292
292
|
beforeActivate: async () => {
|
|
293
|
-
if (desktopNeedsRestart) await desktopHost.closeDesktop(desktopBeforeSetup.desktop);
|
|
294
293
|
stoppedForUpdate = resumeAfterUpdate;
|
|
295
294
|
if (previousRelay.running && legacyRelaySession) {
|
|
296
295
|
await controlRequest(legacyRelaySession, 'POST');
|
|
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
12
|
import { verifyReleaseIdentity, createUploadSigner } from '../relay-admin/release-oidc.mjs';
|
|
13
13
|
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
14
14
|
import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
15
|
+
import { updatePublicWorkstations } from '../src/self-relay/public-releases.mjs';
|
|
15
16
|
|
|
16
17
|
import { readHarmonyConfig, validateHarmonyMetadata, checkHarmonyVersion, verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
|
|
17
18
|
|
|
@@ -56,8 +57,23 @@ let busy = false;
|
|
|
56
57
|
const server = http.createServer(async (req, res) => {
|
|
57
58
|
const reply = (code, data) => { if (!res.destroyed) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); } };
|
|
58
59
|
let claims;
|
|
59
|
-
|
|
60
|
+
const npmStatus = req.url === '/code-relax/publish/npm/status';
|
|
61
|
+
try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, ''), npmStatus ? 'npm' : 'mobile'); }
|
|
60
62
|
catch { reply(401, { ok: false, error: 'OIDC_REJECTED' }); return; }
|
|
63
|
+
if (npmStatus) {
|
|
64
|
+
try {
|
|
65
|
+
if (req.method !== 'POST') { reply(405, { ok: false, error: 'METHOD_NOT_ALLOWED' }); return; }
|
|
66
|
+
const { version } = await readJson(req);
|
|
67
|
+
const response = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax', { signal: AbortSignal.timeout(15000) });
|
|
68
|
+
if (!response.ok) throw Error('Registry unavailable');
|
|
69
|
+
const metadata = await response.json();
|
|
70
|
+
if (metadata['dist-tags']?.latest !== version || metadata['dist-tags']?.beta !== version
|
|
71
|
+
|| !metadata.versions?.[version]) { reply(409, { ok: false, error: 'NPM_VERSION_MISMATCH' }); return; }
|
|
72
|
+
updatePublicWorkstations(version);
|
|
73
|
+
reply(200, { ok: true, version });
|
|
74
|
+
} catch { reply(500, { ok: false, error: 'NPM_STATUS_SYNC_FAILED' }); }
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
61
77
|
const target = /^\/code-relax\/publish\/(android|hap)\/intents(?:\/|$)/.exec(req.url || '')?.[1];
|
|
62
78
|
if (!target) { reply(404, { ok: false, error: 'NOT_FOUND' }); return; }
|
|
63
79
|
const prefix = `/code-relax/publish/${target}/intents`;
|
package/dist/build-platform.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"layout":"npm-platform-packages","version":"0.1.0-beta.
|
|
1
|
+
{"layout":"npm-platform-packages","version":"0.1.0-beta.50"}
|
|
@@ -335,27 +335,37 @@
|
|
|
335
335
|
}
|
|
336
336
|
return optionsCache.context(threadId, contexts.get(threadId));
|
|
337
337
|
}
|
|
338
|
-
async function createThread(request, target, input, settings, additionalContext) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
...(additionalContext ? { additionalContext: { ...additionalContext,
|
|
345
|
-
...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput).additionalContext } } : {}),
|
|
346
|
-
...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
|
|
347
|
-
...(settings.model ? { model:settings.model } : {}),
|
|
348
|
-
...(settings.thinking ? { effort:settings.thinking } : {}),
|
|
349
|
-
...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
|
|
350
|
-
return result(started);
|
|
351
|
-
} catch (error) {
|
|
352
|
-
// The known thread and the late first turn together form one creation receipt.
|
|
353
|
-
if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(result, lateError => {
|
|
354
|
-
// The thread already exists even if its first turn was rejected.
|
|
355
|
-
throw Object.assign(new Error(lateError.message || '新对话首条消息未确认'), { cause: lateError, deliveryUnknown: true });
|
|
356
|
-
});
|
|
338
|
+
async function createThread(request, target, input, settings, additionalContext, onThreadCreated = () => {}) {
|
|
339
|
+
let response;
|
|
340
|
+
try { response = await request('thread/start', target); }
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(result => startFirstTurn(result.thread))
|
|
343
|
+
.catch(lateError => { if (lateError.deliveryResult) return lateError.deliveryResult; throw lateError; });
|
|
357
344
|
throw error;
|
|
358
345
|
}
|
|
346
|
+
return startFirstTurn(response.thread);
|
|
347
|
+
async function startFirstTurn(thread) {
|
|
348
|
+
onThreadCreated(thread.id);
|
|
349
|
+
const result = started => ({ threadId:thread.id, turnId:started?.turn?.id || null });
|
|
350
|
+
try {
|
|
351
|
+
const started = await request('turn/start', { threadId:thread.id, input,
|
|
352
|
+
...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput),
|
|
353
|
+
...(additionalContext ? { additionalContext: { ...additionalContext,
|
|
354
|
+
...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput).additionalContext } } : {}),
|
|
355
|
+
...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
|
|
356
|
+
...(settings.model ? { model:settings.model } : {}),
|
|
357
|
+
...(settings.thinking ? { effort:settings.thinking } : {}),
|
|
358
|
+
...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
|
|
359
|
+
return result(started);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
// The known thread and the late first turn together form one creation receipt.
|
|
362
|
+
if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(result, lateError => {
|
|
363
|
+
// The thread already exists even if its first turn was rejected.
|
|
364
|
+
throw Object.assign(new Error(lateError.message || '新对话首条消息未确认'), { cause: lateError, deliveryUnknown: true });
|
|
365
|
+
});
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
359
369
|
}
|
|
360
370
|
function projectThreadTarget(project) {
|
|
361
371
|
const roots = project.roots.map(root => root.path);
|
|
@@ -21,9 +21,24 @@
|
|
|
21
21
|
class Store {
|
|
22
22
|
constructor() { this.entries = new Map(); }
|
|
23
23
|
key(scope, clientId) { return JSON.stringify([scope, clientId]); }
|
|
24
|
+
beginCreation(scope, clientId) {
|
|
25
|
+
const entry = this.entries.get(this.key(scope, clientId));
|
|
26
|
+
let finish;
|
|
27
|
+
entry.creationReady = new Promise(resolve => { finish = resolve; });
|
|
28
|
+
return threadId => {
|
|
29
|
+
if (threadId) entry.createdThreadId = threadId;
|
|
30
|
+
finish();
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async creationBindings() {
|
|
34
|
+
await Promise.all([...this.entries.values()].map(entry => entry.creationReady));
|
|
35
|
+
return new Map([...this.entries].filter(([, entry]) => entry.createdThreadId)
|
|
36
|
+
.map(([key, entry]) => [entry.createdThreadId, JSON.parse(key)[1]]));
|
|
37
|
+
}
|
|
24
38
|
status(scope, clientId) {
|
|
25
39
|
const entry = this.entries.get(this.key(scope, clientId));
|
|
26
40
|
return entry ? { state: entry.state, ...(entry.result ? { result: entry.result } : {}),
|
|
41
|
+
...(entry.createdThreadId ? { createdThreadId: entry.createdThreadId } : {}),
|
|
27
42
|
...(entry.error ? { error: entry.error.message } : {}) } : { state: 'unknown' };
|
|
28
43
|
}
|
|
29
44
|
async reconcile(scope, clientId, inspect) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const file = 'item/fileChange/requestApproval';
|
|
7
7
|
const input = 'item/tool/requestUserInput';
|
|
8
8
|
const elicitation = 'mcpServer/elicitation/request';
|
|
9
|
+
const permissions = 'item/permissions/requestApproval';
|
|
9
10
|
const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
10
11
|
const approvalId = request => 'approval:' + (request.params.turnId || '') + ':' + typeof request.id + ':' + request.id;
|
|
11
12
|
function confirmation(request) {
|
|
@@ -98,10 +99,86 @@
|
|
|
98
99
|
_meta: decision === 'allowAlways' ? { persist: 'always' }
|
|
99
100
|
: decision === 'allowSession' ? { persist: 'session' } : null };
|
|
100
101
|
}
|
|
102
|
+
function externalAction(request) {
|
|
103
|
+
const p = request.params || {};
|
|
104
|
+
if (request.method !== elicitation || p.mode !== 'url') return null;
|
|
105
|
+
try {
|
|
106
|
+
const url = new URL(p.url);
|
|
107
|
+
if (url.protocol !== 'https:' || url.username || url.password) return null;
|
|
108
|
+
if (p.serverName !== 'codex_apps') return { url: url.href, connector: null };
|
|
109
|
+
const connector = p._meta?._codex_apps?.connector_auth_failure;
|
|
110
|
+
const trusted = host => ['chatgpt.com', 'chatgpt-staging.com'].some(domain => host === domain || host.endsWith('.' + domain));
|
|
111
|
+
if (!trusted(url.hostname) || connector?.is_auth_failure !== true
|
|
112
|
+
|| typeof connector.connector_id !== 'string' || typeof connector.connector_name !== 'string'
|
|
113
|
+
|| typeof connector.install_url !== 'string' || connector.connection_consent != null) return null;
|
|
114
|
+
return { url: url.href, connector };
|
|
115
|
+
} catch { return null; }
|
|
116
|
+
}
|
|
117
|
+
function elicitationResult(action, content = null) { return { action, content: action === 'accept' ? content : null, _meta: null }; }
|
|
118
|
+
function fieldOptions(field) {
|
|
119
|
+
const schema = field.type === 'array' ? field.items : field;
|
|
120
|
+
if (Array.isArray(schema?.enum)) return schema.enum.map((value, index) => ({ value, label: schema.enumNames?.[index] || value }));
|
|
121
|
+
const options = schema?.oneOf || schema?.anyOf;
|
|
122
|
+
return Array.isArray(options) ? options.map(option => ({ value: option.const, label: option.title || option.const })) : null;
|
|
123
|
+
}
|
|
124
|
+
function formSchema(request) {
|
|
125
|
+
const p = request.params || {}, meta = p._meta || {}, schema = p.requestedSchema;
|
|
126
|
+
// Specialized approvals, credentials and OpenAI forms have their own response contracts.
|
|
127
|
+
if (request.method !== elicitation || p.mode !== 'form' || meta.codex_approval_kind
|
|
128
|
+
|| meta['openai/elicitation'] || meta['openai/confirmation'] || schema?.type !== 'object'
|
|
129
|
+
|| ['browser', 'browser-use'].includes(p.serverName)
|
|
130
|
+
|| !schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties)
|
|
131
|
+
|| Object.keys(schema).some(key => !['$schema', 'type', 'properties', 'required'].includes(key))) return null;
|
|
132
|
+
const known = ['type', 'title', 'description', 'default', 'minLength', 'maxLength', 'format', 'minimum', 'maximum', 'enum', 'enumNames', 'oneOf', 'items', 'minItems', 'maxItems'];
|
|
133
|
+
for (const field of Object.values(schema.properties)) {
|
|
134
|
+
if (!field || Object.keys(field).some(key => !known.includes(key))
|
|
135
|
+
|| !['string', 'number', 'integer', 'boolean', 'array'].includes(field.type)) return null;
|
|
136
|
+
const options = fieldOptions(field);
|
|
137
|
+
if ((field.type === 'array' || field.enum != null || field.oneOf != null) && !options
|
|
138
|
+
|| options && (!options.length || options.some(option => typeof option.value !== 'string' || typeof option.label !== 'string'))) return null;
|
|
139
|
+
if (field.format && !['email', 'uri', 'date', 'date-time'].includes(field.format)) return null;
|
|
140
|
+
}
|
|
141
|
+
if (schema.required != null && (!Array.isArray(schema.required) || schema.required.some(key => !Object.hasOwn(schema.properties, key)))) return null;
|
|
142
|
+
return schema;
|
|
143
|
+
}
|
|
144
|
+
function validateForm(schema, values) {
|
|
145
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)
|
|
146
|
+
|| Object.keys(values).some(key => !Object.hasOwn(schema.properties, key))) throw new Error('表单内容无效');
|
|
147
|
+
for (const [key, field] of Object.entries(schema.properties)) {
|
|
148
|
+
const value = values[key], label = field.title || key;
|
|
149
|
+
if (value === undefined) { if (schema.required?.includes(key)) throw new Error(`请填写${label}`); continue; }
|
|
150
|
+
let valid = field.type === 'array' ? Array.isArray(value) : typeof value === (field.type === 'integer' ? 'number' : field.type);
|
|
151
|
+
if (typeof value === 'number') valid &&= Number.isFinite(value) && (field.type !== 'integer' || Number.isInteger(value))
|
|
152
|
+
&& (field.minimum == null || value >= field.minimum) && (field.maximum == null || value <= field.maximum);
|
|
153
|
+
if (typeof value === 'string') {
|
|
154
|
+
valid &&= (field.minLength == null || [...value].length >= field.minLength) && (field.maxLength == null || [...value].length <= field.maxLength);
|
|
155
|
+
if (field.format === 'email') valid &&= /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
156
|
+
if (field.format === 'uri') { try { new URL(value); } catch { valid = false; } }
|
|
157
|
+
if (field.format === 'date') valid &&= /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value));
|
|
158
|
+
if (field.format === 'date-time') valid &&= /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
|
|
159
|
+
}
|
|
160
|
+
const options = fieldOptions(field);
|
|
161
|
+
if (Array.isArray(value)) valid &&= new Set(value).size === value.length && (field.minItems == null || value.length >= field.minItems)
|
|
162
|
+
&& (field.maxItems == null || value.length <= field.maxItems) && value.every(item => options?.some(option => option.value === item));
|
|
163
|
+
else if (options) valid &&= options.some(option => option.value === value);
|
|
164
|
+
if (!valid) throw new Error(`${label}的内容不符合请求要求`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function permissionResult(request, decision) {
|
|
168
|
+
return { permissions: decision === 'decline' ? {} : request.params.permissions, scope: decision === 'acceptForSession' ? 'session' : 'turn' };
|
|
169
|
+
}
|
|
170
|
+
function blocksComposer(request) {
|
|
171
|
+
if (externalAction(request)) return false;
|
|
172
|
+
return request.method === input || Boolean(formSchema(request))
|
|
173
|
+
|| request.method !== elicitation && decisions(request).length > 0
|
|
174
|
+
|| request.method === elicitation && decisions(request).some(value => value !== 'cancel');
|
|
175
|
+
}
|
|
101
176
|
function decisions(request) {
|
|
102
177
|
if (confirmation(request)) return ['decline', 'accept'];
|
|
103
178
|
if (request.method === command) return request.params.availableDecisions ?? ['accept', 'acceptForSession', 'decline', 'cancel'];
|
|
104
179
|
if (request.method === file) return ['accept', 'acceptForSession', 'decline', 'cancel'];
|
|
180
|
+
if (request.method === permissions && request.params.permissions) return ['accept', 'acceptForSession', 'decline'];
|
|
181
|
+
if (externalAction(request)) return ['decline', 'accept'];
|
|
105
182
|
const action = browserAction(request);
|
|
106
183
|
if (action) return ['allowOnce', ...(action.session ? ['allowSession'] : []), ...(action.persistent ? ['allowAlways'] : []), 'cancel'];
|
|
107
184
|
const app = computerAppAccess(request);
|
|
@@ -110,9 +187,24 @@
|
|
|
110
187
|
if (browser) return browser.persistent ? ['allowOnce', 'allowSite', 'allowAllSites', 'decline'] : ['allowOnce', 'decline'];
|
|
111
188
|
const tool = mcpToolApproval(request);
|
|
112
189
|
if (tool) return ['decline', 'allowOnce', ...(tool.session ? ['allowSession'] : []), ...(tool.persistent ? ['allowAlways'] : [])];
|
|
113
|
-
return [];
|
|
190
|
+
return request.method === elicitation ? ['cancel'] : [];
|
|
114
191
|
}
|
|
115
192
|
function validate(request, result) {
|
|
193
|
+
if (request.method === elicitation && equal(result, elicitationResult('cancel'))) return;
|
|
194
|
+
if (externalAction(request)) {
|
|
195
|
+
if (!['accept', 'decline'].some(action => equal(result, elicitationResult(action)))) throw new Error('外部操作结果无效');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const schema = formSchema(request);
|
|
199
|
+
if (schema) {
|
|
200
|
+
if (equal(result, elicitationResult('decline'))) return;
|
|
201
|
+
if (result?.action !== 'accept' || result._meta !== null || Object.keys(result).length !== 3) throw new Error('表单响应格式无效');
|
|
202
|
+
validateForm(schema, result.content); return;
|
|
203
|
+
}
|
|
204
|
+
if (request.method === permissions) {
|
|
205
|
+
if (!decisions(request).some(decision => equal(result, permissionResult(request, decision)))) throw new Error('权限决定不属于当前请求');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
116
208
|
if (confirmation(request)) {
|
|
117
209
|
if (!decisions(request).some(decision => equal(confirmationResult(request, decision), result))) throw new Error('确认结果不属于当前请求');
|
|
118
210
|
return;
|
|
@@ -215,5 +307,5 @@
|
|
|
215
307
|
return { submitted: true };
|
|
216
308
|
}
|
|
217
309
|
}
|
|
218
|
-
return { Store, decisions, validate, command, file, input, elicitation, browserAccess, browserAction, browserActionResult, computerAppAccess, computerAppResult, mcpToolApproval, mcpToolResult, confirmation, confirmationResult };
|
|
310
|
+
return { Store, decisions, validate, command, file, input, elicitation, permissions, externalAction, elicitationResult, formSchema, fieldOptions, validateForm, permissionResult, blocksComposer, browserAccess, browserAction, browserActionResult, computerAppAccess, computerAppResult, mcpToolApproval, mcpToolResult, confirmation, confirmationResult };
|
|
219
311
|
});
|
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
-
import { readRecord } from './service-lifecycle.mjs';
|
|
5
|
-
import
|
|
4
|
+
import { readRecord, lifecycle } from './service-lifecycle.mjs';
|
|
5
|
+
import * as serviceHost from './platform/service-host.mjs';
|
|
6
|
+
import { managedPortAvailable as portAvailable } from './managed-app-server.mjs';
|
|
7
|
+
import { readManagedEndpoint } from './managed-endpoint.mjs';
|
|
6
8
|
export { portAvailable };
|
|
7
|
-
|
|
9
|
+
function desktopEnvironment(stateRoot, environment) {
|
|
10
|
+
const childEnv = { ...environment };
|
|
11
|
+
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
|
|
12
|
+
const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
|
|
13
|
+
if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
|
|
14
|
+
childEnv.CODEX_APP_SERVER_WS_URL = readManagedEndpoint(stateRoot);
|
|
15
|
+
return childEnv;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Opening the optional UI does not start, restart or supervise the kernel.
|
|
19
|
+
export async function startDesktop({ stateRoot, host, environment = process.env, restart = false }) {
|
|
20
|
+
const info = await host.inspect();
|
|
21
|
+
if (!info.desktop) throw new Error('未安装 Codex 桌面端。');
|
|
22
|
+
if (info.running && !restart) return { status: 'running', message: 'Codex 桌面端已启动。' };
|
|
23
|
+
if (info.running) await host.closeDesktop(info.desktop);
|
|
24
|
+
await host.startDesktop(info.desktop, desktopEnvironment(stateRoot, environment));
|
|
25
|
+
return { status: 'accepted', message: restart ? '已提交 Codex 桌面端重启请求。' : '已提交 Codex 桌面端启动请求。' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function launchDesktop({ stateRoot, host, environment = process.env, wait = delay, timeout = 30000, recoveryId, progress = () => {}, service = lifecycle({ stateRoot, host: serviceHost }) }) {
|
|
8
29
|
fs.mkdirSync(stateRoot, { recursive: true });
|
|
9
30
|
const recovery = readRecord(path.join(stateRoot, 'shared-recovery.lock'));
|
|
10
31
|
if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请用 relax doctor 查看恢复状态。');
|
|
11
32
|
const lock = path.join(stateRoot, 'desktop-launch.lock');
|
|
12
33
|
let descriptor;
|
|
13
|
-
let managed;
|
|
14
34
|
try { descriptor = fs.openSync(lock, 'wx'); }
|
|
15
35
|
catch (error) {
|
|
16
36
|
if (error.code === 'EEXIST') throw new Error('另一个 Desktop 启动操作正在进行;若上次异常退出,请确认没有启动器运行后移除 desktop-launch.lock。');
|
|
@@ -24,14 +44,10 @@ export async function launchDesktop({ stateRoot, host, environment = process.env
|
|
|
24
44
|
if (connected) return { reused: true };
|
|
25
45
|
throw new Error('请先保存任务并完全退出 Codex Desktop,再接入 Codex 内核;不会自动结束正在运行的任务。');
|
|
26
46
|
}
|
|
27
|
-
const childEnv = { ...environment };
|
|
28
|
-
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
|
|
29
47
|
progress('starting-app-server');
|
|
30
|
-
|
|
31
|
-
if (!
|
|
32
|
-
const
|
|
33
|
-
if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
|
|
34
|
-
childEnv.CODEX_APP_SERVER_WS_URL = managed.snapshot().url;
|
|
48
|
+
const health = await service.status();
|
|
49
|
+
if (!health.running || !health.managedAppServer?.ready) throw new Error(health.managedAppServer?.error || '工作站 Codex 内核未就绪。');
|
|
50
|
+
const childEnv = desktopEnvironment(stateRoot, environment);
|
|
35
51
|
progress('starting-desktop');
|
|
36
52
|
await host.startDesktop(info.desktop, childEnv);
|
|
37
53
|
const deadline = Date.now() + timeout;
|
|
@@ -42,5 +58,5 @@ export async function launchDesktop({ stateRoot, host, environment = process.env
|
|
|
42
58
|
await wait(500);
|
|
43
59
|
}
|
|
44
60
|
return { reused: false };
|
|
45
|
-
} finally {
|
|
61
|
+
} finally { fs.closeSync(descriptor); fs.rmSync(lock, { force: true }); }
|
|
46
62
|
}
|
|
@@ -426,7 +426,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
426
426
|
const current = await this.rooms.request(`${route}?inspect=true`), request = current.elicitation;
|
|
427
427
|
if (epoch !== this.epoch || this.bindings.get(threadId) !== room || request?.request_id !== body.id
|
|
428
428
|
|| request.params?.threadId !== request.thread_id || typeof current.request_fingerprint !== 'string') throw new Error('确认请求已变化,请重新核对。');
|
|
429
|
-
if (!
|
|
429
|
+
if (!request.params || typeof request.params.mode !== 'string') throw new Error('此房间请求格式无效');
|
|
430
430
|
const key = JSON.stringify([room.id, body.roomMessageId, body.id]);
|
|
431
431
|
return { requestId: request.request_id, params: request.params, fingerprint: current.request_fingerprint,
|
|
432
432
|
status: current.runtime_status, canRespond: current.can_respond === true && current.runtime_status === 'pending',
|
|
@@ -137,6 +137,8 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
137
137
|
await this.server.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/live`, {
|
|
138
138
|
method: 'POST', signal: controller.signal, onEvent: value => {
|
|
139
139
|
if (!current()) return;
|
|
140
|
+
// The live SSE sends {} keepalives after ready; they carry no identity.
|
|
141
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) return;
|
|
140
142
|
if (value?.type !== 'calpico-room-live-ready' || value.version !== 1 || value.room_id !== room.id
|
|
141
143
|
|| typeof value.subscription_id !== 'string' || watch.subscriptionId && watch.subscriptionId !== value.subscription_id) {
|
|
142
144
|
throw new Error('点点活动订阅身份无效。');
|
|
@@ -35,4 +35,4 @@ $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
|
|
|
35
35
|
if (-not $package) { @{ desktop=$null; running=$false } | ConvertTo-Json -Compress; exit }
|
|
36
36
|
$desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
|
|
37
37
|
$running = @(Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $desktop })
|
|
38
|
-
@{ desktop=$desktop; running=($running.Count -gt 0); hasWindow=(@($running | Where-Object { $_.MainWindowHandle -ne 0 }).Count -gt 0) } | ConvertTo-Json -Compress
|
|
38
|
+
@{ desktop=$desktop; version=$package.Version.ToString(); running=($running.Count -gt 0); hasWindow=(@($running | Where-Object { $_.MainWindowHandle -ne 0 }).Count -gt 0) } | ConvertTo-Json -Compress
|
|
@@ -31,7 +31,7 @@ export class RuntimeDiscovery {
|
|
|
31
31
|
const desktop = await this.host.inspect();
|
|
32
32
|
const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
|
|
33
33
|
this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
|
|
34
|
-
installed: Boolean(desktop.desktop), attachedToManagedServer: Boolean(attached) },
|
|
34
|
+
installed: Boolean(desktop.desktop), version: desktop.version || '', attachedToManagedServer: Boolean(attached) },
|
|
35
35
|
desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
|
|
36
36
|
} catch (error) {
|
|
37
37
|
errors.push(error.message);
|
|
@@ -26,10 +26,7 @@ export function updatePublicHarmony(release, file = publicManifestFile) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function updateMobileManifest(platform, release, file) {
|
|
29
|
-
|
|
30
|
-
const handle = fs.openSync(lock, 'wx', 0o600);
|
|
31
|
-
try {
|
|
32
|
-
const manifest = readPublicManifest(file);
|
|
29
|
+
return updatePublicManifest(manifest => {
|
|
33
30
|
if (manifest[platform].versionCode > release.versionCode) throw new Error('Public Android version would go backwards');
|
|
34
31
|
manifest[platform] = { status: 'published', statusText: { zh: '', en: '' }, version: release.versionName,
|
|
35
32
|
versionCode: release.versionCode, size: release.size, sha256: release.sha256,
|
|
@@ -39,6 +36,24 @@ function updateMobileManifest(platform, release, file) {
|
|
|
39
36
|
manifest.harmonyos.url = 'https://www.coderelax.cn/code-relax/install';
|
|
40
37
|
manifest.harmonyos.manifestUrl = release.manifestUrl;
|
|
41
38
|
}
|
|
39
|
+
}, file);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function updatePublicWorkstations(version, file = publicManifestFile) {
|
|
43
|
+
if (!/^\d+\.\d+\.\d+-beta\.\d+$/.test(version)) throw Error('Invalid npm version');
|
|
44
|
+
return updatePublicManifest(manifest => {
|
|
45
|
+
for (const platform of ['windows', 'macos', 'linux']) {
|
|
46
|
+
if (manifest[platform].status === 'published') manifest[platform].version = version;
|
|
47
|
+
}
|
|
48
|
+
}, file);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function updatePublicManifest(update, file = publicManifestFile) {
|
|
52
|
+
const lock = file + '.lock', temporary = file + '.new';
|
|
53
|
+
const handle = fs.openSync(lock, 'wx', 0o600);
|
|
54
|
+
try {
|
|
55
|
+
const manifest = readPublicManifest(file);
|
|
56
|
+
update(manifest);
|
|
42
57
|
const owner = fs.statSync(file);
|
|
43
58
|
fs.writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', { mode: owner.mode & 0o777 });
|
|
44
59
|
fs.chmodSync(temporary, owner.mode & 0o777);
|
|
@@ -989,7 +989,7 @@ function controlPayload(value) {
|
|
|
989
989
|
const message = JSON.parse(value);
|
|
990
990
|
if (message.type === 'unsubscribe') return true;
|
|
991
991
|
return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
|
|
992
|
-
(message.path === '/api/health' ||
|
|
992
|
+
(message.path === '/api/health' || ['/api/desktop/start', '/api/desktop/restart'].includes(message.path) || message.path.startsWith('/api/shared-recovery'));
|
|
993
993
|
} catch { return false; }
|
|
994
994
|
}
|
|
995
995
|
|
package/dist/src/server.mjs
CHANGED
|
@@ -38,6 +38,7 @@ import { DotsMessageImages } from './dots-message-images.mjs';
|
|
|
38
38
|
import { desktopDotsProfiles } from './platform/windows/dots-profile.mjs';
|
|
39
39
|
import { readPlanPricing } from './plan-pricing.mjs';
|
|
40
40
|
import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
|
|
41
|
+
import { startDesktop } from './desktop-launcher.mjs';
|
|
41
42
|
import { requestServiceRestart } from './service-restart.mjs';
|
|
42
43
|
import { checkServiceUpdate, requestServiceUpdate, serviceUpdateStatus } from './service-update.mjs';
|
|
43
44
|
import { TurnModelHistory } from './turn-model-history.mjs';
|
|
@@ -261,10 +262,17 @@ async function handleRequest(request, response) {
|
|
|
261
262
|
if (request.method === 'GET' && url.pathname === '/api/shared-recovery') {
|
|
262
263
|
sendJson(response, 200, await recoveryStatus(stateRoot)); return;
|
|
263
264
|
}
|
|
264
|
-
if (request.method === 'POST' && ['/api/
|
|
265
|
+
if (request.method === 'POST' && ['/api/desktop/start', '/api/desktop/restart'].includes(url.pathname)) {
|
|
266
|
+
const body = await readJsonBody(request);
|
|
267
|
+
if (body.confirmed !== true) throw new RequestError(400, '请确认启动桌面端。');
|
|
268
|
+
const result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' });
|
|
269
|
+
await runtimeDiscovery.sample();
|
|
270
|
+
sendJson(response, 202, result); return;
|
|
271
|
+
}
|
|
272
|
+
if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
|
|
265
273
|
const body = await readJsonBody(request);
|
|
266
274
|
if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
|
|
267
|
-
sendJson(response, 202, await requestRecovery(stateRoot,
|
|
275
|
+
sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
|
|
268
276
|
}
|
|
269
277
|
if (url.pathname === '/api/service/update' && request.method === 'GET') {
|
|
270
278
|
sendJson(response, 200, await checkServiceUpdate(stateRoot)); return;
|
|
@@ -494,7 +502,13 @@ async function handleRequest(request, response) {
|
|
|
494
502
|
const archived = url.searchParams.get('archived') === 'true';
|
|
495
503
|
const scope = url.searchParams.get('scope') || 'all';
|
|
496
504
|
if (!['all', 'local', 'durable'].includes(scope)) { sendJson(response, 400, { error: '无效的会话列表范围' }); return; }
|
|
497
|
-
|
|
505
|
+
const catalog = await sharedCatalog.threads(limit, archived, scope, url.searchParams.get('refresh') === '1');
|
|
506
|
+
const creations = await messageReceipts.creationBindings();
|
|
507
|
+
for (const thread of [...(catalog.threads || []), ...(catalog.pinnedThreads || [])]) {
|
|
508
|
+
const clientId = creations.get(thread.id);
|
|
509
|
+
if (clientId && (thread.hostId || 'local') === 'local') thread.creationClientId = clientId;
|
|
510
|
+
}
|
|
511
|
+
sendJson(response, 200, catalog);
|
|
498
512
|
return;
|
|
499
513
|
}
|
|
500
514
|
|
|
@@ -708,13 +722,23 @@ async function handleRequest(request, response) {
|
|
|
708
722
|
|
|
709
723
|
const createStatusMatch = /^\/api\/projects\/([^/]+)\/threads\/([a-f0-9]{32})\/status$/.exec(url.pathname);
|
|
710
724
|
if (request.method === 'GET' && createStatusMatch) {
|
|
711
|
-
|
|
725
|
+
const creationScope = ['project', decodeURIComponent(createStatusMatch[1])], clientId = createStatusMatch[2];
|
|
726
|
+
let status = messageReceipts.status(creationScope, clientId);
|
|
727
|
+
if (status.state === 'unknown' && status.createdThreadId) {
|
|
728
|
+
const threadId = status.createdThreadId;
|
|
729
|
+
status = await messageReceipts.reconcile(creationScope, clientId, async () => {
|
|
730
|
+
const found = await sharedServer.findSentMessage(threadId, clientId);
|
|
731
|
+
return found ? { threadId, hostId: 'local', turnId: found.data?.turnId } : null;
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
sendJson(response, 200, status); return;
|
|
712
735
|
}
|
|
713
736
|
const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
|
|
714
737
|
if (request.method === 'POST' && createThreadMatch) {
|
|
715
738
|
const projectId = decodeURIComponent(createThreadMatch[1]);
|
|
716
739
|
const body = await readJsonBody(request);
|
|
717
740
|
const result = await messageReceipts.submit(['project', projectId], body.clientUserMessageId, async () => {
|
|
741
|
+
const bindCreation = messageReceipts.beginCreation(['project', projectId], body.clientUserMessageId);
|
|
718
742
|
try {
|
|
719
743
|
await requireExecutionWritable();
|
|
720
744
|
if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
|
|
@@ -735,7 +759,7 @@ async function handleRequest(request, response) {
|
|
|
735
759
|
}
|
|
736
760
|
const { input } = appServerTurnArguments('', body);
|
|
737
761
|
messageSubmissionStarted = true;
|
|
738
|
-
const created = await appServerEvents.createThread(rpc, target, input, body);
|
|
762
|
+
const created = await appServerEvents.createThread(rpc, target, input, body, undefined, bindCreation);
|
|
739
763
|
const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
740
764
|
void generateAndSetThreadTitle({
|
|
741
765
|
request: rpc,
|
|
@@ -749,8 +773,10 @@ async function handleRequest(request, response) {
|
|
|
749
773
|
}).catch(error => console.error(`生成对话标题失败:${error.message}`));
|
|
750
774
|
return { ...created, hostId:'local' };
|
|
751
775
|
} catch (error) {
|
|
752
|
-
error.deliveryUnknown =
|
|
753
|
-
|
|
776
|
+
error.deliveryUnknown = Boolean(messageReceipts.status(['project', projectId], body.clientUserMessageId).createdThreadId)
|
|
777
|
+
|| messageSubmissionStarted && !messageDelivery.rejectedBeforeSubmission(error);
|
|
778
|
+
if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(created => ({ ...created, hostId: 'local' })).finally(() => bindCreation());
|
|
779
|
+
else bindCreation();
|
|
754
780
|
throw error;
|
|
755
781
|
}
|
|
756
782
|
});
|
|
@@ -59,7 +59,18 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
|
|
|
59
59
|
: `已安装版本 ${installedVersion},但无法确认运行服务版本;请保留诊断结果核查,不能据此认定旧进程或自动重启。`);
|
|
60
60
|
if (state.running) add('execution', state.execution?.ready === true && state.execution?.canWrite === true ? 'ok' : 'error',
|
|
61
61
|
state.execution?.ready === true && state.execution?.canWrite === true
|
|
62
|
-
? 'Codex
|
|
62
|
+
? 'Codex 完整控制通道已就绪;单个对话的写入权限需在打开该对话时确认。' : 'Codex 完整控制尚未确认就绪且可写;请查看内核和连接诊断,不能仅凭服务在线判定健康。');
|
|
63
|
+
if (state.running && state.desktop) {
|
|
64
|
+
const descriptions = {
|
|
65
|
+
managed: '桌面端已接入 Code Relax 内核。',
|
|
66
|
+
ordinary: '桌面端正在独立运行,未接入 Code Relax 内核;若同一对话报写入冲突,请核对桌面端占用。',
|
|
67
|
+
stopped: '桌面端未启动;手机可独立使用已就绪的内核。',
|
|
68
|
+
unsupported: '当前平台未提供桌面端接入。',
|
|
69
|
+
};
|
|
70
|
+
add('desktop', 'ok', state.desktop.installed === false && state.desktop.state !== 'unsupported'
|
|
71
|
+
? '未安装桌面端;手机可独立使用已就绪的内核。'
|
|
72
|
+
: descriptions[state.desktop.state] || '桌面端接入状态暂未确认。');
|
|
73
|
+
}
|
|
63
74
|
const currentVersion = state.managedAppServer?.version || null;
|
|
64
75
|
const selectedVersion = state.managedAppServer?.selectedVersion || null;
|
|
65
76
|
if (state.managedAppServer) kernelUpdate = { pending: state.managedAppServer.updatePending === true,
|
|
@@ -91,13 +102,20 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
|
|
|
91
102
|
collectedAt: new Date().toISOString(), platform: process.platform, arch: process.arch, osRelease: os.release(),
|
|
92
103
|
nodeVersion: process.version, nodeExecutable: process.execPath, serviceRoot: paths.serviceRoot,
|
|
93
104
|
servicePid: serviceState?.pid || null, port: serviceState?.port || null, serviceVersion: serviceState?.version || null,
|
|
105
|
+
kernel: serviceState?.managedAppServer ? {
|
|
106
|
+
version: serviceState.managedAppServer.version || null,
|
|
107
|
+
executable: serviceState.managedAppServer.executable || null,
|
|
108
|
+
selectedVersion: serviceState.managedAppServer.selectedVersion || null,
|
|
109
|
+
preferredExecutable: serviceState.managedAppServer.preferredExecutable || null,
|
|
110
|
+
selectedSource: serviceState.managedAppServer.source || null,
|
|
111
|
+
} : null,
|
|
94
112
|
} } : {}) };
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
export function formatDoctorOutput(result, detail = false) {
|
|
98
116
|
const labels = { 'keep-awake': '保持唤醒', installation: '安装文件', 'bridge-runtime': '运行环境', network: '局域网',
|
|
99
117
|
autostart: '登录自启动', service: '工作站服务', 'managed-app-server': 'Codex 内核',
|
|
100
|
-
execution: '完整控制', 'package-version': '安装包版本', 'service-version': '服务版本', 'managed-app-server-update': 'Codex 更新',
|
|
118
|
+
execution: '完整控制', desktop: 'Codex 桌面端', 'package-version': '安装包版本', 'service-version': '服务版本', 'managed-app-server-update': 'Codex 更新',
|
|
101
119
|
'managed-app-server-launcher': '工作站更新', skill: 'Codex Skill' };
|
|
102
120
|
const byId = id => result.checks.find(item => item.id === id);
|
|
103
121
|
const summary = (id, success) => {
|
|
@@ -106,7 +124,7 @@ export function formatDoctorOutput(result, detail = false) {
|
|
|
106
124
|
};
|
|
107
125
|
const issues = result.checks.filter(item => item.status !== 'ok');
|
|
108
126
|
const lines = [result.ready ? '工作站检查通过。' : '工作站检查发现问题。',
|
|
109
|
-
summary('installation', '完整'), summary('package-version', 'CLI 与固定服务一致'), summary('service', '正在运行'), summary('managed-app-server', '已就绪'), summary('execution', '
|
|
127
|
+
summary('installation', '完整'), summary('package-version', 'CLI 与固定服务一致'), summary('service', '正在运行'), summary('managed-app-server', '已就绪'), summary('execution', '通道已就绪(不代表每个对话均可写)')];
|
|
110
128
|
if (issues.length) lines.push('', '需要处理或留意:', ...issues.map(item => `- ${labels[item.id] || item.id}:${item.message}`));
|
|
111
129
|
if (detail) {
|
|
112
130
|
const info = result.diagnostics || {};
|
|
@@ -115,6 +133,9 @@ export function formatDoctorOutput(result, detail = false) {
|
|
|
115
133
|
`Node:${info.nodeVersion || process.version};${info.nodeExecutable || process.execPath}`,
|
|
116
134
|
`服务目录:${info.serviceRoot || '未知'}`, `服务进程:${info.servicePid || '未运行'};端口:${info.port || '未知'}`, '');
|
|
117
135
|
lines.push('', '检查详情:', ...result.checks.filter(item => item.status === 'ok').map(item => `- ${labels[item.id] || item.id}:${item.message}`));
|
|
136
|
+
if (info.kernel) lines.push('',
|
|
137
|
+
`运行内核:${info.kernel.version || '未知'};${info.kernel.executable || '路径未知'}`,
|
|
138
|
+
`候选内核:${info.kernel.selectedVersion || '未知'};来源 ${info.kernel.selectedSource || '未知'};${info.kernel.preferredExecutable || '路径未知'}`);
|
|
118
139
|
const recovery = result.desktopRecovery;
|
|
119
140
|
if (recovery && !['none', 'unsupported'].includes(recovery.status)) {
|
|
120
141
|
const status = { failed: '未完成', completed: '已完成', succeeded: '已完成', running: '进行中', unknown: '无法读取' }[recovery.status] || recovery.status;
|
|
@@ -20,12 +20,12 @@ export async function requestServiceRestart(stateRoot) {
|
|
|
20
20
|
const lock = path.join(stateRoot, 'service-restart.lock');
|
|
21
21
|
const operationId = randomUUID();
|
|
22
22
|
const owner = await processIdentity(process.pid);
|
|
23
|
-
if (!owner) throw new Error('
|
|
23
|
+
if (!owner) throw new Error('无法确认桌面服务重启请求进程。');
|
|
24
24
|
const previous = readRecord(lock);
|
|
25
25
|
if (previous) {
|
|
26
26
|
const actual = previous.owner?.pid ? await processIdentity(previous.owner.pid) : null;
|
|
27
27
|
if (sameProcess(previous.owner, actual) || readRecord(lock)?.operationId !== previous.operationId) {
|
|
28
|
-
throw new Error('
|
|
28
|
+
throw new Error('桌面服务已在重启中。');
|
|
29
29
|
}
|
|
30
30
|
fs.rmSync(lock);
|
|
31
31
|
}
|
|
@@ -36,18 +36,18 @@ export async function requestServiceRestart(stateRoot) {
|
|
|
36
36
|
// Publish a complete record exclusively; a crash cannot leave a half-written lock.
|
|
37
37
|
fs.linkSync(temporary, lock);
|
|
38
38
|
} catch (error) {
|
|
39
|
-
if (error.code === 'EEXIST') throw new Error('
|
|
39
|
+
if (error.code === 'EEXIST') throw new Error('桌面服务已在重启中。');
|
|
40
40
|
throw error;
|
|
41
41
|
} finally { fs.rmSync(temporary, { force: true }); }
|
|
42
42
|
try {
|
|
43
43
|
const log = path.join(stateRoot, 'service-restart.log');
|
|
44
44
|
const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, operationId], process.env, [log, log]);
|
|
45
45
|
const worker = await processIdentity(pid);
|
|
46
|
-
if (!worker) throw new Error('
|
|
46
|
+
if (!worker) throw new Error('桌面服务重启进程未启动。');
|
|
47
47
|
const current = readRecord(lock);
|
|
48
|
-
if (current?.operationId !== operationId) throw new Error('
|
|
48
|
+
if (current?.operationId !== operationId) throw new Error('桌面服务重启实例已失效。');
|
|
49
49
|
writeRecord(lock, { ...current, owner: worker });
|
|
50
|
-
return { operationId, status: 'scheduled', message: '
|
|
50
|
+
return { operationId, status: 'scheduled', message: '桌面服务重启已安排。' };
|
|
51
51
|
} catch (error) {
|
|
52
52
|
const current = readRecord(lock);
|
|
53
53
|
if (current?.operationId === operationId && sameProcess(current.owner, owner)) fs.rmSync(lock, { force: true });
|
|
@@ -61,9 +61,9 @@ async function run(stateRoot, operationId) {
|
|
|
61
61
|
const deadline = Date.now() + 30000;
|
|
62
62
|
while (true) {
|
|
63
63
|
const record = readRecord(lock);
|
|
64
|
-
if (record?.operationId !== operationId) throw new Error('
|
|
64
|
+
if (record?.operationId !== operationId) throw new Error('桌面服务重启实例已失效。');
|
|
65
65
|
if (record.owner?.pid === process.pid) break;
|
|
66
|
-
if (Date.now() >= deadline) throw new Error('
|
|
66
|
+
if (Date.now() >= deadline) throw new Error('桌面服务重启进程交接超时。');
|
|
67
67
|
await delay(100);
|
|
68
68
|
}
|
|
69
69
|
const service = lifecycle({ stateRoot, host });
|
|
@@ -31,9 +31,9 @@ try {
|
|
|
31
31
|
const code = await new Promise((resolve, reject) => {
|
|
32
32
|
child.once('error', reject); child.once('exit', (code, signal) => resolve(code ?? `signal ${signal}`));
|
|
33
33
|
});
|
|
34
|
-
if (code !== 0) throw new Error(`npm 安装失败(${code}
|
|
34
|
+
if (code !== 0) throw new Error(`npm 安装失败(${code}),请查看桌面服务更新日志。`);
|
|
35
35
|
const installed = JSON.parse(fs.readFileSync(path.join(job.serviceRoot, 'installation.json'), 'utf8'));
|
|
36
|
-
if (installed.version !== job.targetVersion) throw new Error('
|
|
36
|
+
if (installed.version !== job.targetVersion) throw new Error('安装结束,但桌面服务版本与目标版本不一致。');
|
|
37
37
|
update({ status: 'completed', finishedAt: new Date().toISOString(), installedVersion: installed.version });
|
|
38
38
|
} catch (error) {
|
|
39
39
|
console.error(error.stack || error.message);
|
|
@@ -13,7 +13,7 @@ export function publicUpdateOperation(record, installedVersion) {
|
|
|
13
13
|
if (!record) return null;
|
|
14
14
|
return { operationId: record.operationId, targetVersion: record.targetVersion,
|
|
15
15
|
status: record.status, targetInstalled: Boolean(installedVersion && installedVersion === record.targetVersion),
|
|
16
|
-
error: record.status === 'failed' ? '
|
|
16
|
+
error: record.status === 'failed' ? '上次更新未完整完成,请查看桌面服务更新日志。' : null,
|
|
17
17
|
installedVersion: record.installedVersion || null };
|
|
18
18
|
}
|
|
19
19
|
export function serviceUpdateStatus(root) {
|
|
@@ -25,7 +25,7 @@ export function serviceUpdateStatus(root) {
|
|
|
25
25
|
}
|
|
26
26
|
export async function checkServiceUpdate(root) {
|
|
27
27
|
const result = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax/latest', { signal: AbortSignal.timeout(12000) });
|
|
28
|
-
if (!result.ok) throw new Error(
|
|
28
|
+
if (!result.ok) throw new Error(`查询桌面服务版本失败:HTTP ${result.status}`);
|
|
29
29
|
const latest = await result.json();
|
|
30
30
|
if (!versionPattern.test(latest.version || '')) throw new Error('npm 返回的版本号无效');
|
|
31
31
|
return { ...serviceUpdateStatus(root), latestVersion: latest.version };
|
|
@@ -33,9 +33,9 @@ export async function checkServiceUpdate(root) {
|
|
|
33
33
|
let scheduling = false;
|
|
34
34
|
export async function requestServiceUpdate(root, body) {
|
|
35
35
|
if (body.confirmed !== true || !/^[0-9a-f-]{36}$/i.test(body.operationId || '') || !versionPattern.test(body.targetVersion || '')) {
|
|
36
|
-
throw new Error('
|
|
36
|
+
throw new Error('请确认桌面服务更新及目标版本后再继续。');
|
|
37
37
|
}
|
|
38
|
-
if (scheduling) throw new Error('
|
|
38
|
+
if (scheduling) throw new Error('桌面服务更新正在安排。');
|
|
39
39
|
scheduling = true;
|
|
40
40
|
try {
|
|
41
41
|
const previous = readRecord(statusPath(root));
|
|
@@ -43,14 +43,14 @@ export async function requestServiceUpdate(root, body) {
|
|
|
43
43
|
if (previous && ['scheduled', 'running'].includes(previous.status)) {
|
|
44
44
|
const actual = previous.owner?.pid ? await desktopHost.process(previous.owner.pid) : null;
|
|
45
45
|
if (actual && actual.created === previous.owner.created && actual.executable === previous.owner.executable) {
|
|
46
|
-
throw new Error('
|
|
46
|
+
throw new Error('桌面服务已有更新正在执行。');
|
|
47
47
|
}
|
|
48
48
|
writeRecord(statusPath(root), { ...previous, status: 'failed', error: '更新进程已退出,未确认安装完成。' });
|
|
49
49
|
}
|
|
50
50
|
const latest = await checkServiceUpdate(root);
|
|
51
51
|
if (!latest.supported) throw new Error('当前安装缺少 npm 路径,请先安装支持手机更新的过渡版。');
|
|
52
52
|
if (latest.latestVersion !== body.targetVersion) throw new Error('最新版本已变化,请重新检查后确认。');
|
|
53
|
-
if (latest.currentVersion === body.targetVersion) throw new Error('
|
|
53
|
+
if (latest.currentVersion === body.targetVersion) throw new Error('桌面服务已是此版本。');
|
|
54
54
|
const installed = installation();
|
|
55
55
|
const directory = path.join(root, 'service-updates', body.operationId);
|
|
56
56
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
@@ -70,7 +70,7 @@ export async function requestServiceUpdate(root, body) {
|
|
|
70
70
|
pid: owner.pid, created: owner.created, executable: owner.executable } });
|
|
71
71
|
} catch (error) {
|
|
72
72
|
writeRecord(statusPath(root), { ...readRecord(statusPath(root)), status: 'failed', error: error.message });
|
|
73
|
-
throw new Error('
|
|
73
|
+
throw new Error('桌面服务更新启动未完成,请查看桌面服务更新日志;结果确认前不要重复提交。');
|
|
74
74
|
}
|
|
75
75
|
return serviceUpdateStatus(root);
|
|
76
76
|
} finally { scheduling = false; }
|
|
@@ -24,12 +24,20 @@ export const recoveryWarning = 'Codex Desktop 需要重新打开,才能接入
|
|
|
24
24
|
|
|
25
25
|
export async function recoveryStatus(stateRoot, host = desktopHost) {
|
|
26
26
|
if (host.desktopIntegration === false) return { status: 'unsupported', message: '此平台尚不支持 Desktop 自动接入。' };
|
|
27
|
-
|
|
27
|
+
let record = readRecord(recordPath(stateRoot));
|
|
28
28
|
if (!record) return { status: 'none', message: '尚未执行 Desktop 接入流程。' };
|
|
29
29
|
const lostWorker = active(record) && (record.worker
|
|
30
30
|
? !sameProcess(record.worker, await host.process(record.worker.pid))
|
|
31
31
|
: Date.now() - Date.parse(record.startedAt) > 30000);
|
|
32
32
|
if (lostWorker) {
|
|
33
|
+
// Process lookup is asynchronous: the worker may have committed its result
|
|
34
|
+
// while it was running. Never replace that result with a stale failure.
|
|
35
|
+
const latest = readRecord(recordPath(stateRoot));
|
|
36
|
+
if (latest?.operationId !== record.operationId || !active(latest)) {
|
|
37
|
+
const { worker, oldService, desktop, ...publicRecord } = latest || {};
|
|
38
|
+
return publicRecord;
|
|
39
|
+
}
|
|
40
|
+
record = latest;
|
|
33
41
|
record.status = 'failed'; record.error = '独立恢复进程已退出,恢复未完成。';
|
|
34
42
|
record.steps = record.steps?.map(step => ({ ...step, status: step.status === 'running' ? 'failed' : step.status }));
|
|
35
43
|
writeRecord(recordPath(stateRoot), record);
|
|
@@ -51,7 +59,7 @@ export async function planRecovery(stateRoot, host, available = portAvailable) {
|
|
|
51
59
|
return { desktop: info.desktop, action };
|
|
52
60
|
}
|
|
53
61
|
|
|
54
|
-
export async function requestRecovery(stateRoot, { confirmed, operationId = randomUUID()
|
|
62
|
+
export async function requestRecovery(stateRoot, { confirmed, operationId = randomUUID() } = {}, host = desktopHost) {
|
|
55
63
|
if (host.desktopIntegration === false) throw new Error('此平台尚不支持 Desktop 自动接入。');
|
|
56
64
|
if (confirmed !== true) throw new Error(recoveryWarning + ' 请明确确认后再执行。');
|
|
57
65
|
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(operationId)) throw new Error('无效的恢复操作编号。');
|
|
@@ -60,13 +68,12 @@ export async function requestRecovery(stateRoot, { confirmed, operationId = rand
|
|
|
60
68
|
if (previous.operationId === operationId) return previous;
|
|
61
69
|
if (active(previous)) throw new Error('已有恢复操作正在执行,请查看恢复状态。');
|
|
62
70
|
const planned = await planRecovery(stateRoot, host);
|
|
63
|
-
if (startOnly && (await host.inspect()).running) throw new Error('Codex 桌面端已经启动,未执行重启。');
|
|
64
71
|
let lock;
|
|
65
72
|
try { lock = fs.openSync(lockPath(stateRoot), 'wx'); }
|
|
66
73
|
catch (error) { if (error.code === 'EEXIST') throw new Error('已有恢复操作,请先查看恢复状态。'); throw error; }
|
|
67
74
|
fs.writeFileSync(lock, JSON.stringify({ operationId })); fs.closeSync(lock);
|
|
68
75
|
const record = { operationId, status: 'scheduled', stage: 'prepared', startedAt: new Date().toISOString(),
|
|
69
|
-
message: 'Codex 连接请求已接受,电脑管理连接保持运行。',
|
|
76
|
+
message: 'Codex 连接请求已接受,电脑管理连接保持运行。', steps: recoverySteps(), ...planned };
|
|
70
77
|
try {
|
|
71
78
|
writeRecord(recordPath(stateRoot), record);
|
|
72
79
|
const log = path.join(stateRoot, 'shared-recovery.log');
|
|
@@ -104,9 +111,7 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
|
|
|
104
111
|
const planned = await planRecovery(stateRoot, host, available);
|
|
105
112
|
record = { ...record, ...planned };
|
|
106
113
|
if (fs.existsSync(path.join(stateRoot, 'desktop-launch.lock'))) throw new Error('Desktop 正在启动,未与启动操作并行恢复。');
|
|
107
|
-
|
|
108
|
-
if ((await host.inspect()).running) throw new Error('Codex 桌面端已经启动,未执行重启。');
|
|
109
|
-
} else { stage('stopping-desktop'); await host.closeDesktop(record.desktop); }
|
|
114
|
+
stage('stopping-desktop'); await host.closeDesktop(record.desktop);
|
|
110
115
|
await waitFor(async () => !(await host.inspect()).running, '当前 Codex Desktop 未退出,未继续接入 Codex 内核。');
|
|
111
116
|
stage('starting-app-server'); await start({ stateRoot, host, recoveryId: operationId, progress: stage });
|
|
112
117
|
stage('checking-execution');
|
|
@@ -126,10 +126,11 @@ export async function streamSharedThread(response, threadId, client, normalize,
|
|
|
126
126
|
const delivered = subscription.snapshot ? normalized : catchup ? await normalize(catchup) : null;
|
|
127
127
|
const sent = Boolean(delivered) && !closed;
|
|
128
128
|
if (sent) await emit('update', delivered);
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
// Confirm every handoff after its catchup/snapshot has been delivered.
|
|
130
|
+
// This also releases a cached selection with a valid, unchanged token;
|
|
131
|
+
// transport subscription alone does not confirm the message boundary.
|
|
132
|
+
if (!closed) await emit('sync-ready', {
|
|
133
|
+
thread: { ...normalized.thread, turns: [] }, sync: normalized.sync,
|
|
133
134
|
});
|
|
134
135
|
client.traceSync?.('stream-initial-sent', threadId, { ms: Math.round(performance.now() - started), closed,
|
|
135
136
|
snapshot: subscription.snapshot, catchup: Boolean(catchup), sent, seq: normalized.sync?.seq,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walkhi/code-relax",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.50",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"tag": "beta"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@walkhi/code-relax-native-win32-x64": "0.1.0-beta.
|
|
45
|
-
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.
|
|
46
|
-
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.
|
|
44
|
+
"@walkhi/code-relax-native-win32-x64": "0.1.0-beta.50",
|
|
45
|
+
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.50",
|
|
46
|
+
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.50"
|
|
47
47
|
}
|
|
48
48
|
}
|