@walkhi/code-relax 0.1.0-beta.71 → 0.1.0-beta.76
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/build-platform.json +1 -1
- package/dist/src/desktop-launcher.mjs +15 -6
- package/dist/src/dots-chat-server.mjs +38 -5
- package/dist/src/dots-room-activity.mjs +25 -4
- package/dist/src/dots-room-client.mjs +26 -6
- package/dist/src/dots-task-catalog.mjs +4 -1
- package/dist/src/lan-socket-server.mjs +3 -1
- package/dist/src/managed-app-server.mjs +26 -1
- package/dist/src/runtime-discovery.mjs +25 -5
- package/dist/src/self-relay/connector.mjs +9 -6
- package/dist/src/self-relay/file-wire.mjs +63 -0
- package/dist/src/self-relay/server.mjs +47 -20
- package/dist/src/server.mjs +19 -4
- package/package.json +4 -4
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.76"}
|
|
@@ -17,12 +17,21 @@ function desktopEnvironment(stateRoot, environment) {
|
|
|
17
17
|
|
|
18
18
|
// Opening the optional UI does not start, restart or supervise the kernel.
|
|
19
19
|
export async function startDesktop({ stateRoot, host, environment = process.env, restart = false }) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
let stage = 'inspect';
|
|
21
|
+
try {
|
|
22
|
+
const info = await host.inspect();
|
|
23
|
+
if (!info.desktop) throw new Error('未安装 Codex 桌面端。');
|
|
24
|
+
if (info.running && !restart) return { status: 'running', message: 'Codex 桌面端已启动。' };
|
|
25
|
+
if (info.running) { stage = 'close'; await host.closeDesktop(info.desktop); }
|
|
26
|
+
stage = 'activate';
|
|
27
|
+
await host.startDesktop(info.desktop, desktopEnvironment(stateRoot, environment));
|
|
28
|
+
return { status: 'accepted', message: restart ? '已提交 Codex 桌面端重启请求。' : '已提交 Codex 桌面端启动请求。' };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-operation-failed', restart, stage,
|
|
31
|
+
message: error.message, code: error.code, stderr: error.stderr }));
|
|
32
|
+
throw new Error(stage === 'close' ? '桌面端关闭失败,请查看工作站诊断日志。'
|
|
33
|
+
: stage === 'activate' ? '桌面端启动失败,请查看工作站诊断日志。' : '无法检查桌面端,请查看工作站诊断日志。', { cause: error });
|
|
34
|
+
}
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
export async function launchDesktop({ stateRoot, host, environment = process.env, wait = delay, timeout = 30000, recoveryId, progress = () => {}, service = lifecycle({ stateRoot, host: serviceHost }) }) {
|
|
@@ -33,11 +33,13 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
33
33
|
this.observedThreads = new Set(); this.reconnectAttempts = 0;
|
|
34
34
|
this.approvalTimeline = new DotsApprovalTimeline(this);
|
|
35
35
|
this.chatActivity = new DotsRoomActivity(this);
|
|
36
|
+
this.backgroundActivity = new Map();
|
|
36
37
|
this.chatActivity.on('unavailable', error => this.emit('activity-error', error));
|
|
37
38
|
this.rooms.on('message', (topic, value) => this.roomNotification(topic, value));
|
|
38
39
|
this.rooms.on('disconnected', reason => { this.invalidate(reason); this.scheduleReconnect(); });
|
|
39
|
-
this.hostDisconnected = () => { this.rooms.close(); this.invalidate({ source: 'hosted-app-server' }); };
|
|
40
|
-
this.hostConnected = () => { void this.restoreObservers().catch(error => this.emit('subscription-error', error)); };
|
|
40
|
+
this.hostDisconnected = () => { this.rooms.close(); this.invalidate({ source: 'hosted-app-server' }); this.scheduleReconnect(); };
|
|
41
|
+
this.hostConnected = () => { this.syncBackgroundActivity(); void this.restoreObservers().catch(error => this.emit('subscription-error', error)); };
|
|
42
|
+
this.profilesChanged = () => this.syncBackgroundActivity();
|
|
41
43
|
this.hostNotification = ({ method, params }) => this.executionNotification(method, params);
|
|
42
44
|
this.hostRequestsChanged = id => {
|
|
43
45
|
this.emit('requests-changed', id);
|
|
@@ -51,6 +53,31 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
51
53
|
};
|
|
52
54
|
transport.on('disconnected', this.hostDisconnected); transport.on('connected', this.hostConnected);
|
|
53
55
|
transport.on('host-notification', this.hostNotification); transport.on('requests-changed', this.hostRequestsChanged);
|
|
56
|
+
transport.on('profiles-changed', this.profilesChanged);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
startBackgroundActivity() {
|
|
60
|
+
if (this.closed || this.backgroundStarting) return;
|
|
61
|
+
this.backgroundEnabled = true;
|
|
62
|
+
this.backgroundStarting = this.transport.connect().then(() => {
|
|
63
|
+
if (this.closed) return;
|
|
64
|
+
this.syncBackgroundActivity();
|
|
65
|
+
this.transport.refreshLiveProfiles();
|
|
66
|
+
}).catch(error => { this.emit('activity-error', error); this.scheduleReconnect(); })
|
|
67
|
+
.finally(() => { this.backgroundStarting = null; });
|
|
68
|
+
}
|
|
69
|
+
syncBackgroundActivity() {
|
|
70
|
+
if (this.closed || !this.backgroundEnabled || !this.transport.ready) return;
|
|
71
|
+
const roots = new Set([...this.transport.profileNames.values()].map(profile => profile.activeRootThreadId).filter(Boolean));
|
|
72
|
+
for (const [id, release] of this.backgroundActivity) if (!roots.has(id)) {
|
|
73
|
+
release(); this.backgroundActivity.delete(id);
|
|
74
|
+
}
|
|
75
|
+
for (const id of roots) if (!this.backgroundActivity.has(id)) {
|
|
76
|
+
this.backgroundActivity.set(id, this.chatActivity.acquire(id, () => {}));
|
|
77
|
+
// Establish the existing bounded message window and durable cache without
|
|
78
|
+
// a phone consumer. Subsequent pushes use the normal incremental path.
|
|
79
|
+
void this.timeline(id).catch(error => this.emit('subscription-error', error));
|
|
80
|
+
}
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
get ready() { return this.transport?.ready === true && this.rooms?.ready === true; }
|
|
@@ -80,6 +107,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
80
107
|
this.traceSync('invalidated', null, reason);
|
|
81
108
|
clearTimeout(this.reconnectTimer);
|
|
82
109
|
this.epoch = randomUUID(); this.chatActivity.close(); this.approvalTimeline.clear();
|
|
110
|
+
this.backgroundActivity.clear();
|
|
83
111
|
this.roomApprovalResponses.clear();
|
|
84
112
|
for (const record of this.threads.values()) { record.needsReload = true; record.tokens.clear(); record.refresh = null; }
|
|
85
113
|
for (const timer of this.windowSaves.values()) clearTimeout(timer); this.windowSaves.clear();
|
|
@@ -87,7 +115,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
87
115
|
this.windowLoads.clear(); this.historyLoads.clear(); this.emit('disconnected');
|
|
88
116
|
}
|
|
89
117
|
scheduleReconnect() {
|
|
90
|
-
if (this.closed || !this.
|
|
118
|
+
if (this.closed || (!this.observedThreads.size && !this.backgroundEnabled) || this.reconnectAttempts >= 5) return;
|
|
91
119
|
clearTimeout(this.reconnectTimer);
|
|
92
120
|
this.reconnectTimer = setTimeout(() => {
|
|
93
121
|
this.reconnectTimer = null;
|
|
@@ -96,8 +124,10 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
96
124
|
this.reconnectTimer.unref();
|
|
97
125
|
}
|
|
98
126
|
async restoreObservers() {
|
|
99
|
-
if (!this.observedThreads.size || this.closed) return;
|
|
127
|
+
if ((!this.observedThreads.size && !this.backgroundEnabled) || this.closed) return;
|
|
100
128
|
await this.connect();
|
|
129
|
+
this.syncBackgroundActivity();
|
|
130
|
+
if (this.backgroundEnabled) this.transport.refreshLiveProfiles();
|
|
101
131
|
for (const id of this.observedThreads) await this.timeline(id);
|
|
102
132
|
this.reconnectAttempts = 0;
|
|
103
133
|
}
|
|
@@ -409,7 +439,8 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
409
439
|
if (epoch !== this.epoch) throw new Error('点点连接已变化,请核对后发送。');
|
|
410
440
|
const result = await this.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/messages`, {
|
|
411
441
|
method: 'POST', body: { content: { text: args.prompt, ...(attachments.length ? { attachments } : {}) },
|
|
412
|
-
request_id: requestId, idempotency_token: requestId
|
|
442
|
+
request_id: requestId, idempotency_token: requestId,
|
|
443
|
+
timezone_offset_min: new Date().getTimezoneOffset() } });
|
|
413
444
|
if (epoch !== this.epoch || !result?.id) throw Object.assign(new Error('发送结果未确认,请核对消息。'), { deliveryUnknown: true });
|
|
414
445
|
record.dirty++;
|
|
415
446
|
void this.refreshMessages(args.threadId, record).catch(error => {
|
|
@@ -482,6 +513,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
482
513
|
}
|
|
483
514
|
}
|
|
484
515
|
acquireActivity(threadId, listener) { return this.chatActivity.acquire(threadId, listener); }
|
|
516
|
+
retryActivity(threadId) { return this.chatActivity.retryUnavailable(threadId); }
|
|
485
517
|
acquirePanelActivity(threadId, listener) { return this.chatActivity.acquire(threadId, listener); }
|
|
486
518
|
isHistorySyncing() { return false; }
|
|
487
519
|
async readContext() { return { available: false }; }
|
|
@@ -493,6 +525,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
|
|
|
493
525
|
this.closed = true; clearTimeout(this.reconnectTimer); this.rooms.close(); this.invalidate(); this.chatActivity.dispose();
|
|
494
526
|
this.transport.off('disconnected', this.hostDisconnected); this.transport.off('connected', this.hostConnected);
|
|
495
527
|
this.transport.off('host-notification', this.hostNotification); this.transport.off('requests-changed', this.hostRequestsChanged);
|
|
528
|
+
this.transport.off('profiles-changed', this.profilesChanged);
|
|
496
529
|
for (const timer of this.windowSaves.values()) clearTimeout(timer); this.windowSaves.clear();
|
|
497
530
|
}
|
|
498
531
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
-
// Desktop 26.
|
|
3
|
+
// Desktop 26.1016.6573.0: room-live activity and responding heartbeats are
|
|
4
4
|
// independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
|
|
5
5
|
const typingTimeout = 10000, handoffTimeout = 5000;
|
|
6
6
|
const statusLabels = {
|
|
@@ -8,6 +8,7 @@ const statusLabels = {
|
|
|
8
8
|
payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
|
|
9
9
|
};
|
|
10
10
|
const waitingStates = new Set(['payment', 'needs input', 'paused']);
|
|
11
|
+
const retryableStatus = status => status == null || status === 408 || status === 429 || status >= 500;
|
|
11
12
|
|
|
12
13
|
export class DotsRoomActivity extends EventEmitter {
|
|
13
14
|
constructor(server) {
|
|
@@ -41,6 +42,13 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
41
42
|
if (!watch.listeners.size) { this.watches.delete(threadId); this.stop(watch); }
|
|
42
43
|
};
|
|
43
44
|
}
|
|
45
|
+
retryUnavailable(threadId) {
|
|
46
|
+
const watch = this.watches.get(threadId);
|
|
47
|
+
if (!watch?.unavailable || !watch.listeners.size || watch.controller || watch.timer) return false;
|
|
48
|
+
watch.attempts = 0;
|
|
49
|
+
void this.start(watch);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
44
52
|
publish(watch, activity) {
|
|
45
53
|
if (JSON.stringify(watch.activity) === JSON.stringify(activity)) return;
|
|
46
54
|
watch.activity = activity;
|
|
@@ -100,14 +108,17 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
100
108
|
}
|
|
101
109
|
apply(watch, value) {
|
|
102
110
|
if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
|
|
103
|
-
watch.revision = value.revision;
|
|
104
111
|
if (value.type === 'calpico-room-live-error') {
|
|
112
|
+
watch.revision = value.revision;
|
|
105
113
|
this.fail(watch, new Error('点点活动暂不可用。'), value.code === 'temporarily_unavailable'); return;
|
|
106
114
|
}
|
|
107
115
|
if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
|
|
116
|
+
watch.revision = value.revision;
|
|
117
|
+
clearTimeout(watch.initialTimer); watch.initialTimer = null;
|
|
108
118
|
const current = value.threads[0];
|
|
109
119
|
watch.idle = value.threads.length === 0;
|
|
110
|
-
|
|
120
|
+
if (value.threads.some(thread => thread.stream_state === 'attached')) watch.attempts = 0;
|
|
121
|
+
watch.unavailable = false; watch.liveActivity = null;
|
|
111
122
|
if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
|
|
112
123
|
if (current.turn_status === 'failed') {
|
|
113
124
|
this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
|
|
@@ -133,6 +144,11 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
133
144
|
const generation = ++watch.generation, controller = new AbortController();
|
|
134
145
|
watch.controller = controller; watch.pending = new Map(); watch.subscriptionId = null; watch.revision = -1;
|
|
135
146
|
const current = () => this.watches.get(watch.threadId) === watch && generation === watch.generation && !controller.signal.aborted;
|
|
147
|
+
// Ready/keepalive on SSE does not establish delivery on the Celsius topic.
|
|
148
|
+
watch.initialTimer = setTimeout(() => {
|
|
149
|
+
if (current()) this.fail(watch, new Error('点点活动首次状态等待超时。'), true);
|
|
150
|
+
}, 30000);
|
|
151
|
+
watch.initialTimer.unref();
|
|
136
152
|
try {
|
|
137
153
|
await this.server.connect();
|
|
138
154
|
const room = this.server.bindings.get(watch.threadId) || await this.server.room(watch.threadId);
|
|
@@ -155,9 +171,13 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
155
171
|
},
|
|
156
172
|
});
|
|
157
173
|
if (current()) this.fail(watch, new Error('点点活动连接已结束。'), true);
|
|
158
|
-
} catch (error) { if (current()) this.fail(watch, error,
|
|
174
|
+
} catch (error) { if (current()) this.fail(watch, error, retryableStatus(error.status)); }
|
|
159
175
|
}
|
|
160
176
|
fail(watch, error, retry) {
|
|
177
|
+
console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-activity',
|
|
178
|
+
threadId: watch.threadId, roomId: watch.roomId, status: error.status || 0,
|
|
179
|
+
stage: watch.subscriptionId ? 'state' : 'subscribe', retry, attempts: watch.attempts,
|
|
180
|
+
responseKind: error.details?.responseKind || 'none' }));
|
|
161
181
|
this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
|
|
162
182
|
if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
|
|
163
183
|
watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
|
|
@@ -166,6 +186,7 @@ export class DotsRoomActivity extends EventEmitter {
|
|
|
166
186
|
}
|
|
167
187
|
stop(watch, keepReply = false) {
|
|
168
188
|
watch.generation++; clearTimeout(watch.timer); watch.timer = null;
|
|
189
|
+
clearTimeout(watch.initialTimer); watch.initialTimer = null;
|
|
169
190
|
watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
|
|
170
191
|
watch.subscriptionId = null; watch.revision = -1; watch.idle = false;
|
|
171
192
|
if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
|
|
@@ -4,7 +4,7 @@ import { CelsiusClient } from './celsius-client.mjs';
|
|
|
4
4
|
const topic = 'calpico-chatgpt-messaging';
|
|
5
5
|
const identityKey = identity => JSON.stringify([identity?.accountId, identity?.userId]);
|
|
6
6
|
|
|
7
|
-
// Desktop 26.
|
|
7
|
+
// Desktop 26.1016.6573.0: the DM API and Celsius share the kernel account,
|
|
8
8
|
// but are independent of the hosted execution socket. Never replay a POST.
|
|
9
9
|
export class DotsRoomClient extends CelsiusClient {
|
|
10
10
|
constructor(transport) {
|
|
@@ -30,7 +30,9 @@ export class DotsRoomClient extends CelsiusClient {
|
|
|
30
30
|
const cancelled = () => controller.abort(signal.reason);
|
|
31
31
|
signal?.addEventListener('abort', cancelled, { once: true });
|
|
32
32
|
if (signal?.aborted) cancelled();
|
|
33
|
-
let sent = false, status, timer;
|
|
33
|
+
let sent = false, status, timer, responseKind = 'none';
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
const clientId = method === 'POST' && /^[a-f0-9-]{36}$/i.test(body?.request_id || '') ? body.request_id : undefined;
|
|
34
36
|
try {
|
|
35
37
|
return await new Promise((resolve, reject) => {
|
|
36
38
|
const session = http2.connect('https://chatgpt.com');
|
|
@@ -42,6 +44,10 @@ export class DotsRoomClient extends CelsiusClient {
|
|
|
42
44
|
clearTimeout(timer); controller.signal.removeEventListener('abort', abort);
|
|
43
45
|
request?.destroy(); session.destroy();
|
|
44
46
|
if (error) {
|
|
47
|
+
error.details = { ...error.details, ...(status ? { upstreamStatus: status } : {}), responseKind };
|
|
48
|
+
if (!controller.signal.aborted) console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
|
|
49
|
+
route: route.split('?')[0], clientId, status: status || 0, responseKind,
|
|
50
|
+
ms: Date.now() - started, outcome: 'failed' }));
|
|
45
51
|
if (method !== 'GET' && !onEvent) error.deliveryUnknown = sent && !(status >= 400 && status < 500 && status !== 408);
|
|
46
52
|
reject(error);
|
|
47
53
|
} else resolve(value);
|
|
@@ -50,7 +56,7 @@ export class DotsRoomClient extends CelsiusClient {
|
|
|
50
56
|
controller.signal.addEventListener('abort', abort, { once: true });
|
|
51
57
|
session.on('error', error => finish(error));
|
|
52
58
|
if (controller.signal.aborted) { abort(); return; }
|
|
53
|
-
const roomRoute = /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1];
|
|
59
|
+
const roomRoute = method === 'POST' ? /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1] : null;
|
|
54
60
|
request = session.request({ ':method': method, ':path': `/backend-api${route}`, ...auth.headers,
|
|
55
61
|
accept: onEvent ? 'text/event-stream' : 'application/json',
|
|
56
62
|
...(body != null ? { 'content-type': contentType || 'application/json' } : {}),
|
|
@@ -59,13 +65,27 @@ export class DotsRoomClient extends CelsiusClient {
|
|
|
59
65
|
request.setEncoding('utf8');
|
|
60
66
|
request.on('response', headers => {
|
|
61
67
|
status = headers[':status'];
|
|
62
|
-
|
|
68
|
+
const type = String(headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
|
69
|
+
responseKind = headers['cf-mitigated'] === 'challenge' ? 'challenge'
|
|
70
|
+
: type === 'application/json' ? 'json' : type === 'text/html' ? 'html' : type === 'text/event-stream' ? 'sse' : 'other';
|
|
71
|
+
if (roomRoute && current()) {
|
|
72
|
+
// Desktop replaces the route on every send response, including removal.
|
|
73
|
+
this.routes.delete(roomRoute);
|
|
74
|
+
const routeHint = headers['x-openai-thread-route'];
|
|
75
|
+
if (typeof routeHint === 'string' && routeHint && routeHint.length <= 1024) {
|
|
76
|
+
if (this.routes.size >= 128) this.routes.clear();
|
|
77
|
+
this.routes.set(roomRoute, routeHint);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
63
80
|
if (status < 200 || status >= 300) {
|
|
64
|
-
console.error(JSON.stringify({ event: 'dots-room-request', method, status, route: route.split('?')[0] }));
|
|
65
81
|
const message = status === 401 ? '点点登录状态需要恢复,请重新连接。'
|
|
66
|
-
: status === 403
|
|
82
|
+
: status === 403 ? '点点服务拒绝访问(HTTP 403),请稍后重试。'
|
|
83
|
+
: status === 404 ? '点点消息暂不可用,请稍后重新同步。'
|
|
67
84
|
: status === 429 ? '点点暂时繁忙,请稍后再试。' : '点点请求尚未完成,请稍后核对。';
|
|
68
85
|
finish(Object.assign(new Error(message), { status }));
|
|
86
|
+
} else if (roomRoute || onEvent) {
|
|
87
|
+
console.log(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
|
|
88
|
+
route: route.split('?')[0], clientId, status, responseKind, ms: Date.now() - started, outcome: 'response' }));
|
|
69
89
|
}
|
|
70
90
|
});
|
|
71
91
|
request.on('data', chunk => {
|
|
@@ -346,7 +346,10 @@ export class DotsAppServer extends HostedHistoryAppServer {
|
|
|
346
346
|
const previous = this.profileNames.get(id);
|
|
347
347
|
if (previous?.avatar && previous.avatarFileId === profile.avatarFileId) profile.avatar = previous.avatar;
|
|
348
348
|
}
|
|
349
|
-
if (identity === this.profileIdentity)
|
|
349
|
+
if (identity === this.profileIdentity) {
|
|
350
|
+
this.profileNames = names;
|
|
351
|
+
this.emit('profiles-changed');
|
|
352
|
+
}
|
|
350
353
|
}
|
|
351
354
|
|
|
352
355
|
refreshLiveProfiles() {
|
|
@@ -48,7 +48,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
48
48
|
target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
|
|
49
49
|
});
|
|
50
50
|
uploadWss.on('connection', ws => {
|
|
51
|
-
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId;
|
|
51
|
+
let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId, fileRelay = false;
|
|
52
52
|
let release, discardUpload = false;
|
|
53
53
|
const released = new Promise(resolve => { release = resolve; });
|
|
54
54
|
let queued = 0;
|
|
@@ -98,6 +98,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
98
98
|
if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) {
|
|
99
99
|
identity = typeof message.relayClientId === 'string' && message.relayClientId.length <= 200
|
|
100
100
|
? `relay:${message.relayClientId}` : 'local-token';
|
|
101
|
+
fileRelay = message.fileRelay === true;
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
if (identity && purpose === 'upload') {
|
|
@@ -146,6 +147,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
|
|
|
146
147
|
return;
|
|
147
148
|
}
|
|
148
149
|
if (purpose === 'download') {
|
|
150
|
+
if (fileRelay && /^\/api\/desktop\/frames\//.test(message.path || '')) throw new Error('桌面画面仅支持直连');
|
|
149
151
|
if (message.type !== 'read' || typeof message.path !== 'string' || message.path.length > 12000 ||
|
|
150
152
|
!/^\/api\/(?:images\/[a-f0-9]{64}|files\/(?:local|[a-f0-9]{64})|code\/(?:image|binary)|desktop\/frames\/[a-f0-9]{32})(?:\?|$)/.test(message.path) || message.path.includes('#')) throw new Error('无效的文件读取请求');
|
|
151
153
|
await dispatch({ method: 'GET', url: message.path, headers: {}, socketRequest: true, fileTransfer: true }, {
|
|
@@ -35,7 +35,8 @@ export function availableManagedPort() {
|
|
|
35
35
|
export class ManagedAppServer extends EventEmitter {
|
|
36
36
|
constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
|
|
37
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
|
|
38
|
+
selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime,
|
|
39
|
+
diagnose = () => {} }) {
|
|
39
40
|
super();
|
|
40
41
|
this.stateRoot = stateRoot;
|
|
41
42
|
this.host = host;
|
|
@@ -44,6 +45,7 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
44
45
|
this.selectPort = selectPort;
|
|
45
46
|
this.configureEnvironment = configureEnvironment;
|
|
46
47
|
this.resolveRuntime = resolveRuntime;
|
|
48
|
+
this.diagnose = diagnose;
|
|
47
49
|
this.wait = wait;
|
|
48
50
|
this.timeout = timeout;
|
|
49
51
|
this.now = now;
|
|
@@ -111,6 +113,13 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
111
113
|
throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
|
|
112
114
|
}
|
|
113
115
|
if (!actual) {
|
|
116
|
+
const selection = { reason: this.restarting ? 'confirmed-restart' : record?.pid ? 'previous-process-exited' : 'no-running-kernel',
|
|
117
|
+
version: runtime.version, source: runtime.source, executable: runtime.executable };
|
|
118
|
+
const selectionSignature = JSON.stringify(selection);
|
|
119
|
+
if (selectionSignature !== this.diagnosticSelectionSignature) {
|
|
120
|
+
this.diagnosticSelectionSignature = selectionSignature;
|
|
121
|
+
this.diagnose('kernel-start-selected', selection);
|
|
122
|
+
}
|
|
114
123
|
fs.rmSync(this.pendingPath, { force: true });
|
|
115
124
|
const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
|
|
116
125
|
for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
|
|
@@ -128,6 +137,8 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
128
137
|
writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
|
|
129
138
|
const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
|
|
130
139
|
[path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
|
|
140
|
+
this.diagnose('kernel-spawned', { pid, executable: runtime.executable, selectedVersion: runtime.version,
|
|
141
|
+
source: runtime.source, attempt: attempt + 1 });
|
|
131
142
|
const started = await this.host.process(pid);
|
|
132
143
|
if (!started) {
|
|
133
144
|
fs.rmSync(this.pendingPath, { force: true });
|
|
@@ -355,6 +366,20 @@ export class ManagedAppServer extends EventEmitter {
|
|
|
355
366
|
const changed = JSON.stringify(value) !== JSON.stringify(this.state);
|
|
356
367
|
this.state = value;
|
|
357
368
|
if (changed) this.emit('state', this.snapshot());
|
|
369
|
+
// Retry transitions remain visible to health consumers; diagnostic output
|
|
370
|
+
// records a repeated failure once until it changes or the kernel recovers.
|
|
371
|
+
if (value.state === 'starting' && this.diagnosticState?.state === 'failed') return;
|
|
372
|
+
const details = { state: value.state, ready: value.ready, pid: value.pid ?? null, created: value.created ?? null,
|
|
373
|
+
version: value.ready && this.client.ready ? this.client.serverVersion || null : null,
|
|
374
|
+
executable: value.executable || null, selectedVersion: value.selectedVersion || null,
|
|
375
|
+
preferredExecutable: value.preferredExecutable || null, source: value.source || null,
|
|
376
|
+
updatePending: value.updatePending === true, error: value.error || null };
|
|
377
|
+
const signature = JSON.stringify(details);
|
|
378
|
+
if (signature === this.diagnosticStateSignature) return;
|
|
379
|
+
this.diagnosticStateSignature = signature;
|
|
380
|
+
this.diagnosticState = details;
|
|
381
|
+
if (value.ready) this.diagnosticSelectionSignature = null;
|
|
382
|
+
this.diagnose('kernel-state', { ...details, retryAt: value.retryAt || null });
|
|
358
383
|
}
|
|
359
384
|
|
|
360
385
|
close() {
|
|
@@ -3,8 +3,8 @@ import { discoverCodex, resolveCodexRuntime, runtimeFingerprint, runtimeInstalla
|
|
|
3
3
|
// Advisory discovery is independent of execution health. Failure here must never
|
|
4
4
|
// disable an already running app-server or cause an automatic process restart.
|
|
5
5
|
export class RuntimeDiscovery {
|
|
6
|
-
constructor({ stateRoot, host, url, interval = 60000 }) {
|
|
7
|
-
Object.assign(this, { stateRoot, host, url, interval });
|
|
6
|
+
constructor({ stateRoot, host, url, interval = 60000, diagnose = () => {} }) {
|
|
7
|
+
Object.assign(this, { stateRoot, host, url, interval, diagnose });
|
|
8
8
|
this.state = {};
|
|
9
9
|
}
|
|
10
10
|
start() { this.stopped = false; void this.tick(); }
|
|
@@ -13,19 +13,29 @@ export class RuntimeDiscovery {
|
|
|
13
13
|
const errors = [];
|
|
14
14
|
try {
|
|
15
15
|
const candidates = discoverCodex();
|
|
16
|
-
const
|
|
16
|
+
const installation = await runtimeInstallationFingerprint();
|
|
17
|
+
const fingerprints = candidates.map(candidate => {
|
|
17
18
|
try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
|
|
18
19
|
catch (error) { return [candidate.executable, error.message]; }
|
|
19
|
-
})
|
|
20
|
+
});
|
|
21
|
+
const signature = JSON.stringify([installation, fingerprints]);
|
|
22
|
+
if (signature !== this.diagnosticCandidateSignature) {
|
|
23
|
+
this.diagnosticCandidateSignature = signature;
|
|
24
|
+
this.diagnose('runtime-candidates', { installation, candidates: candidates.map((candidate, index) => ({
|
|
25
|
+
executable: candidate.executable, source: candidate.source, fingerprint: fingerprints[index][1] })) });
|
|
26
|
+
}
|
|
20
27
|
if (signature !== this.signature) {
|
|
21
|
-
await resolveCodexRuntime(this.stateRoot, { refresh: true });
|
|
28
|
+
const selected = await resolveCodexRuntime(this.stateRoot, { refresh: true });
|
|
22
29
|
this.signature = signature;
|
|
30
|
+
this.diagnose('runtime-selected', { version: selected.version, source: selected.source,
|
|
31
|
+
executable: selected.executable, runningKernelPreserved: true });
|
|
23
32
|
}
|
|
24
33
|
} catch (error) { errors.push(error.message); }
|
|
25
34
|
try {
|
|
26
35
|
if (this.host.desktopIntegration === false) {
|
|
27
36
|
this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
|
|
28
37
|
if (errors.length) this.state.discoveryError = errors.join('\n');
|
|
38
|
+
this.diagnoseState();
|
|
29
39
|
return this.state;
|
|
30
40
|
}
|
|
31
41
|
const desktop = await this.host.inspect();
|
|
@@ -38,8 +48,18 @@ export class RuntimeDiscovery {
|
|
|
38
48
|
this.state = { checkedAt: new Date().toISOString() };
|
|
39
49
|
}
|
|
40
50
|
if (errors.length) this.state.discoveryError = errors.join('\n');
|
|
51
|
+
this.diagnoseState();
|
|
41
52
|
return this.state;
|
|
42
53
|
}
|
|
54
|
+
diagnoseState() {
|
|
55
|
+
const details = { desktop: this.state.desktop || null,
|
|
56
|
+
desktopRestartRecommended: this.state.desktopRestartRecommended === true,
|
|
57
|
+
discoveryError: this.state.discoveryError || null, runningKernelPreserved: true };
|
|
58
|
+
const signature = JSON.stringify(details);
|
|
59
|
+
if (signature === this.diagnosticStateSignature) return;
|
|
60
|
+
this.diagnosticStateSignature = signature;
|
|
61
|
+
this.diagnose('runtime-discovery', details);
|
|
62
|
+
}
|
|
43
63
|
async tick() {
|
|
44
64
|
await this.sample();
|
|
45
65
|
if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
|
|
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import WebSocket from 'ws';
|
|
5
5
|
import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
|
|
6
|
-
import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
|
|
6
|
+
import { routedFileBytes, parseFileBytes, sendFileFrame } from './file-wire.mjs';
|
|
7
7
|
import { attachRtcFile } from './rtc-file-bridge.mjs';
|
|
8
8
|
import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
|
|
9
9
|
import { createWindowsRouteHelper } from './windows-route-helper.mjs';
|
|
@@ -217,23 +217,26 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
|
|
|
217
217
|
const message = decode(bytes, binary);
|
|
218
218
|
if (message.type === 'file-host-ready') return;
|
|
219
219
|
if (message.type === 'file-open') {
|
|
220
|
+
if (typeof message.clientId !== 'string' || !/^[\w-]{16,128}$/.test(message.clientId)) throw new Error('Invalid file client identity');
|
|
220
221
|
const connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken };
|
|
221
222
|
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
222
223
|
loopbackUrl(url.href, '/api/file-transfer');
|
|
223
224
|
const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
224
225
|
fileLocals.set(message.session, socket);
|
|
225
|
-
socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose
|
|
226
|
+
socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose,
|
|
227
|
+
uploadSessionId: message.uploadSessionId, resumeUpload: message.resumeUpload,
|
|
228
|
+
relayClientId: message.clientId, fileRelay: true }));
|
|
226
229
|
socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
|
|
227
230
|
socket.on('close', () => {
|
|
228
231
|
if (fileLocals.get(message.session) !== socket) return;
|
|
229
232
|
fileLocals.delete(message.session);
|
|
230
|
-
|
|
233
|
+
sendFileFrame(receiver, { type: 'file-close', session: message.session });
|
|
231
234
|
});
|
|
232
235
|
socket.on('message', (data, isBinary) => {
|
|
233
236
|
if (fileLocals.get(message.session) !== socket) return socket.terminate();
|
|
234
|
-
if (receiver.readyState !== WebSocket.OPEN
|
|
235
|
-
if (isBinary) receiver
|
|
236
|
-
else
|
|
237
|
+
if (receiver.readyState !== WebSocket.OPEN) return socket.close(1011, 'File receiver unavailable');
|
|
238
|
+
if (isBinary) sendFileFrame(receiver, routedFileBytes(message.session, data));
|
|
239
|
+
else sendFileFrame(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
|
|
237
240
|
});
|
|
238
241
|
} else if (message.type === 'file-frame') {
|
|
239
242
|
const socket = fileLocals.get(message.session);
|
|
@@ -11,3 +11,66 @@ export function parseFileBytes(bytes) {
|
|
|
11
11
|
if (!/^[a-f0-9-]{36}$/.test(session)) throw new Error('Invalid file session');
|
|
12
12
|
return { session, data: bytes.subarray(36) };
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
const senders = new WeakMap();
|
|
16
|
+
// A workstation multiplexes up to eight three-chunk windows. Keep that burst
|
|
17
|
+
// bounded without dropping every transfer when one shared socket gets busy.
|
|
18
|
+
export function sendFileFrame(socket, data, { bytesPerSecond = 0, session = data?.session } = {}) {
|
|
19
|
+
if (!socket || socket.readyState !== socket.OPEN) return false;
|
|
20
|
+
let state = senders.get(socket);
|
|
21
|
+
if (!state) {
|
|
22
|
+
state = { queue: [], bytes: 0, active: false, nextAt: 0, timer: null, closed: false, bytesPerSecond };
|
|
23
|
+
senders.set(socket, state);
|
|
24
|
+
socket.once('close', () => {
|
|
25
|
+
state.closed = true; clearTimeout(state.timer); state.queue.length = 0; state.bytes = 0;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (state.closeAfter) return false;
|
|
29
|
+
const payload = typeof data === 'string' || Buffer.isBuffer(data) ? data : JSON.stringify(data);
|
|
30
|
+
const size = Buffer.byteLength(payload);
|
|
31
|
+
if (size > 512 * 1024 || state.bytes + size > 8 * 1024 * 1024 || state.queue.length >= 128) {
|
|
32
|
+
socket.close(1013, 'File receiver too slow'); return false;
|
|
33
|
+
}
|
|
34
|
+
state.queue.push({ payload, size, session }); state.bytes += size;
|
|
35
|
+
const pump = () => {
|
|
36
|
+
if (state.active || state.closed || socket.readyState !== socket.OPEN) return;
|
|
37
|
+
if (!state.queue.length) {
|
|
38
|
+
if (state.closeAfter) socket.close(state.closeAfter.code, state.closeAfter.reason);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const wait = state.nextAt - performance.now();
|
|
42
|
+
if (wait > 1) {
|
|
43
|
+
if (!state.timer) state.timer = setTimeout(() => { state.timer = null; pump(); }, wait);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const frame = state.queue.shift(); state.active = true;
|
|
47
|
+
if (state.bytesPerSecond) state.nextAt = performance.now() + frame.size * 1000 / state.bytesPerSecond;
|
|
48
|
+
socket.send(frame.payload, error => {
|
|
49
|
+
if (state.closed) return;
|
|
50
|
+
state.bytes -= frame.size; state.active = false;
|
|
51
|
+
if (error) { socket.close(1011, 'File delivery failed'); return; }
|
|
52
|
+
pump();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
state.pump = pump;
|
|
56
|
+
pump();
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Preserve an error/ack already queued before ending this file connection.
|
|
61
|
+
// Revocation and account expiry deliberately use immediate socket.close instead.
|
|
62
|
+
export function closeFileSocket(socket, code, reason) {
|
|
63
|
+
const state = senders.get(socket);
|
|
64
|
+
if (!state) { socket.close(code, reason); return; }
|
|
65
|
+
state.closeAfter = { code, reason };
|
|
66
|
+
state.pump();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function discardFileSession(socket, session) {
|
|
70
|
+
const state = senders.get(socket);
|
|
71
|
+
if (!state) return;
|
|
72
|
+
state.queue = state.queue.filter(frame => {
|
|
73
|
+
if (frame.session !== session) return true;
|
|
74
|
+
state.bytes -= frame.size; return false;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -9,7 +9,7 @@ import { deviceStore, validId } from './device-store.mjs';
|
|
|
9
9
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
|
|
12
|
-
import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
|
|
12
|
+
import { routedFileBytes, parseFileBytes, sendFileFrame, closeFileSocket, discardFileSession } from './file-wire.mjs';
|
|
13
13
|
import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
|
|
14
14
|
import { validProbeSignal } from './p2p-probe.mjs';
|
|
15
15
|
import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
|
|
@@ -137,7 +137,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
137
137
|
observer?.ws.close(4401, 'Device authorization revoked');
|
|
138
138
|
for (const [key, upload] of room.uploadClients || []) if (upload.clientId === message.id) {
|
|
139
139
|
room.uploadClients.delete(key);
|
|
140
|
-
if (room.uploadHost)
|
|
140
|
+
if (room.uploadHost) {
|
|
141
|
+
discardFileSession(room.uploadHost, upload.session);
|
|
142
|
+
send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
143
|
+
}
|
|
141
144
|
upload.ws.close(4401, 'Device authorization revoked');
|
|
142
145
|
}
|
|
143
146
|
for (const [socket, id] of room.deviceManagers || []) if (id === message.id) {
|
|
@@ -245,7 +248,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
245
248
|
for (const [clientId, upload] of room.uploadClients) {
|
|
246
249
|
if (upload.userId !== userId) continue;
|
|
247
250
|
room.uploadClients.delete(clientId);
|
|
248
|
-
if (room.uploadHost)
|
|
251
|
+
if (room.uploadHost) {
|
|
252
|
+
discardFileSession(room.uploadHost, upload.session);
|
|
253
|
+
send(room.uploadHost, { type: 'file-close', session: upload.session });
|
|
254
|
+
}
|
|
249
255
|
upload.ws.close(4431, 'Account cancelled');
|
|
250
256
|
}
|
|
251
257
|
}
|
|
@@ -438,7 +444,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
438
444
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
439
445
|
} else if (role === 'file-client' && room?.uploadClients.get(session)?.ws === ws) {
|
|
440
446
|
room.uploadClients.delete(session);
|
|
441
|
-
if (room.uploadHost)
|
|
447
|
+
if (room.uploadHost) {
|
|
448
|
+
discardFileSession(room.uploadHost, session);
|
|
449
|
+
send(room.uploadHost, { type: 'file-close', session });
|
|
450
|
+
}
|
|
442
451
|
}
|
|
443
452
|
ws.close(4431, 'Account session invalid');
|
|
444
453
|
}
|
|
@@ -491,7 +500,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
491
500
|
for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'File receiver inbound queue limit exceeded' : 'File receiver offline');
|
|
492
501
|
} else if (role === 'file-client' && room?.uploadClients?.get(session)?.ws === ws) {
|
|
493
502
|
room.uploadClients.delete(session);
|
|
494
|
-
if (room.uploadHost)
|
|
503
|
+
if (room.uploadHost) sendFileFrame(room.uploadHost, { type: 'file-close', session }, { bytesPerSecond: 4 * 1024 * 1024 });
|
|
495
504
|
}
|
|
496
505
|
});
|
|
497
506
|
let messageTail = Promise.resolve();
|
|
@@ -533,13 +542,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
533
542
|
const frame = parseFileBytes(bytes);
|
|
534
543
|
const client = room.uploadClients.get(frame.session);
|
|
535
544
|
if (!client || client.ws.readyState !== ws.OPEN) return;
|
|
536
|
-
|
|
537
|
-
client.ws.send(frame.data); return;
|
|
545
|
+
sendFileFrame(client.ws, frame.data, { bytesPerSecond: 1024 * 1024 }); return;
|
|
538
546
|
}
|
|
539
547
|
if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
|
|
540
548
|
if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
|
|
541
|
-
|
|
542
|
-
room.uploadHost.send(routedFileBytes(session, bytes)); return;
|
|
549
|
+
sendFileFrame(room.uploadHost, routedFileBytes(session, bytes), { bytesPerSecond: 4 * 1024 * 1024, session }); return;
|
|
543
550
|
}
|
|
544
551
|
return fail(1008, 'Invalid binary frame');
|
|
545
552
|
}
|
|
@@ -577,16 +584,31 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
577
584
|
!matches(message.credential, client.hash)) return fail();
|
|
578
585
|
room.uploadClients ||= new Map();
|
|
579
586
|
if (!['upload', 'download'].includes(message.purpose)) return fail(1008, 'Invalid file purpose');
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
if (message.purpose === 'upload' && (typeof message.uploadSessionId !== 'string' ||
|
|
588
|
+
!/^[a-f0-9-]{36}$/.test(message.uploadSessionId) || typeof message.resumeUpload !== 'boolean')) {
|
|
589
|
+
return fail(1008, 'Invalid upload session');
|
|
590
|
+
}
|
|
591
|
+
if (!await recordConnection(room, message.clientId, new Date().toISOString(), deviceInfo(message))) return fail(4401, 'Device authorization revoked');
|
|
592
|
+
if (ws.readyState !== ws.OPEN || !accountConnection.valid()) return;
|
|
593
|
+
if (room.host?.readyState !== ws.OPEN || room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
|
|
594
|
+
if (room.status !== 'active' || !room.clients.some(item => item.id === message.clientId && matches(message.credential, item.hash))) return fail(4401, 'Device authorization revoked');
|
|
595
|
+
const replacing = message.purpose === 'upload' ? [...room.uploadClients.values()].filter(item =>
|
|
596
|
+
item.clientId === message.clientId && item.uploadSessionId === message.uploadSessionId) : [];
|
|
597
|
+
const same = [...room.uploadClients.values()].filter(item => item.clientId === message.clientId && item.purpose === message.purpose && !replacing.includes(item));
|
|
598
|
+
const all = [...rooms.values()].flatMap(item => [...(item.uploadClients?.values() || [])]).filter(item => !replacing.includes(item));
|
|
582
599
|
if (same.length >= (message.purpose === 'upload' ? 1 : 3) ||
|
|
583
600
|
all.length >= 8 || (message.purpose === 'upload' && all.filter(item => item.purpose === 'upload').length >= 2)) {
|
|
584
601
|
return fail(4429, 'Too many file transfers');
|
|
585
602
|
}
|
|
603
|
+
for (const old of replacing) {
|
|
604
|
+
room.uploadClients.delete(old.session);
|
|
605
|
+
sendFileFrame(room.uploadHost, { type: 'file-close', session: old.session }, { bytesPerSecond: 4 * 1024 * 1024 });
|
|
606
|
+
old.ws.close(4409, 'Upload connection replaced');
|
|
607
|
+
}
|
|
586
608
|
role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
|
|
587
|
-
|
|
588
|
-
room.
|
|
589
|
-
|
|
609
|
+
room.uploadClients.set(session, { ws, session, clientId, userId, purpose: message.purpose, uploadSessionId: message.uploadSessionId });
|
|
610
|
+
sendFileFrame(room.uploadHost, { type: 'file-open', session, purpose: message.purpose, clientId,
|
|
611
|
+
uploadSessionId: message.uploadSessionId, resumeUpload: message.resumeUpload }, { bytesPerSecond: 4 * 1024 * 1024 }); return;
|
|
590
612
|
}
|
|
591
613
|
if (message.type === 'host') {
|
|
592
614
|
const durable = message.deviceId !== undefined;
|
|
@@ -881,7 +903,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
881
903
|
connection.ws.close(4401, 'Device authorization revoked');
|
|
882
904
|
}
|
|
883
905
|
room.connections.clear();
|
|
884
|
-
for (const client of room.uploadClients?.values() || [])
|
|
906
|
+
for (const client of room.uploadClients?.values() || []) {
|
|
907
|
+
if (room.uploadHost) {
|
|
908
|
+
discardFileSession(room.uploadHost, client.session);
|
|
909
|
+
send(room.uploadHost, { type: 'file-close', session: client.session });
|
|
910
|
+
}
|
|
911
|
+
client.ws.close(4401, 'Device authorization revoked');
|
|
912
|
+
}
|
|
885
913
|
room.uploadClients?.clear();
|
|
886
914
|
for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
|
|
887
915
|
room.observers.clear();
|
|
@@ -909,14 +937,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
909
937
|
const client = room.uploadClients.get(message.session);
|
|
910
938
|
if (!client) return;
|
|
911
939
|
if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
} else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
|
|
940
|
+
sendFileFrame(client.ws, { type: 'file-frame', payload: message.payload }, { bytesPerSecond: 1024 * 1024 });
|
|
941
|
+
} else if (message.type === 'file-close') closeFileSocket(client.ws, 1011, 'File receiver unavailable');
|
|
915
942
|
else fail(1008, 'Invalid file frame');
|
|
916
943
|
} else if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
|
|
917
944
|
if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
|
|
918
|
-
if (room.uploadHost?.
|
|
919
|
-
|
|
945
|
+
if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
|
|
946
|
+
sendFileFrame(room.uploadHost, { type: 'file-frame', session, payload: message.payload }, { bytesPerSecond: 4 * 1024 * 1024 });
|
|
920
947
|
} else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
|
|
921
948
|
const computer = normalizeOfficialComputer(message);
|
|
922
949
|
await commit(room, { officialComputer: computer });
|
package/dist/src/server.mjs
CHANGED
|
@@ -95,6 +95,11 @@ const options = parseOptions(process.argv.slice(2));
|
|
|
95
95
|
const runtimeVersion = readRuntimeVersion();
|
|
96
96
|
const serviceInstanceId = crypto.randomUUID();
|
|
97
97
|
|
|
98
|
+
function runtimeDiagnostic(event, details) {
|
|
99
|
+
console.log('[CodeRelax:runtime]', JSON.stringify({ at: new Date().toISOString(),
|
|
100
|
+
serviceVersion: runtimeVersion, servicePid: process.pid, serviceInstanceId, event, ...details }));
|
|
101
|
+
}
|
|
102
|
+
|
|
98
103
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
99
104
|
const projectRoot = os.homedir();
|
|
100
105
|
const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
|
|
@@ -115,13 +120,15 @@ const sessionPathCache = new Map();
|
|
|
115
120
|
const sessionSettingsCache = new Map();
|
|
116
121
|
const liveActivityCache = new Map();
|
|
117
122
|
let sharedServer = null;
|
|
118
|
-
const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
|
|
123
|
+
const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost, diagnose: runtimeDiagnostic });
|
|
119
124
|
const hostedHistoryCache = new HostedHistoryCache(stateRoot);
|
|
120
125
|
const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfiles(codexDataRoot, stateRoot), hostedHistoryCache, { uploadsRoot });
|
|
121
126
|
const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
|
|
122
127
|
cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
|
|
123
128
|
const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
|
|
124
|
-
|
|
129
|
+
managedAppServer.on('state', state => { if (state.ready) dotsChat.startBackgroundActivity(); });
|
|
130
|
+
const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost,
|
|
131
|
+
url: () => managedAppServer.snapshot().url, diagnose: runtimeDiagnostic });
|
|
125
132
|
const relayManager = createRelayManager(stateRoot);
|
|
126
133
|
let sharedCatalog = null, executionMode = 'ordinary';
|
|
127
134
|
function selectSharedExecution(enabled) {
|
|
@@ -286,8 +293,9 @@ async function handleRequest(request, response) {
|
|
|
286
293
|
if (request.method === 'POST' && ['/api/desktop/start', '/api/desktop/restart'].includes(url.pathname)) {
|
|
287
294
|
const body = await readJsonBody(request);
|
|
288
295
|
if (body.confirmed !== true) throw new RequestError(400, '请确认启动桌面端。');
|
|
289
|
-
|
|
290
|
-
|
|
296
|
+
let result, observed;
|
|
297
|
+
try { result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' }); }
|
|
298
|
+
finally { observed = await runtimeDiscovery.sample(); }
|
|
291
299
|
sendJson(response, 202, { ...result, desktop: observed.desktop, checkedAt: observed.checkedAt }); return;
|
|
292
300
|
}
|
|
293
301
|
if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
|
|
@@ -483,6 +491,12 @@ async function handleRequest(request, response) {
|
|
|
483
491
|
return;
|
|
484
492
|
}
|
|
485
493
|
|
|
494
|
+
const dotsActivityRetryMatch = /^\/api\/threads\/([^/]+)\/dots-activity\/retry$/.exec(url.pathname);
|
|
495
|
+
if (request.method === 'POST' && dotsActivityRetryMatch) {
|
|
496
|
+
if (!rootChat) throw new RequestError(405, '只有点点主聊天提供活动状态。');
|
|
497
|
+
sendJson(response, 200, { started: dotsChat.retryActivity(decodeURIComponent(dotsActivityRetryMatch[1])) });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
486
500
|
const dotsActivityMatch = /^\/api\/threads\/([^/]+)\/dots-activity-events$/.exec(url.pathname);
|
|
487
501
|
if (request.method === 'GET' && dotsActivityMatch) {
|
|
488
502
|
if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此活动面板。');
|
|
@@ -1077,6 +1091,7 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
|
|
|
1077
1091
|
|
|
1078
1092
|
let control;
|
|
1079
1093
|
const powerManager = createPowerManager(stateRoot);
|
|
1094
|
+
runtimeDiagnostic('service.starting', { platform: process.platform, nodeVersion: process.version });
|
|
1080
1095
|
void managedAppServer.start();
|
|
1081
1096
|
executionHealth.start();
|
|
1082
1097
|
runtimeDiscovery.start();
|
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.76",
|
|
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.76",
|
|
45
|
+
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.76",
|
|
46
|
+
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.76"
|
|
47
47
|
}
|
|
48
48
|
}
|