@walkhi/code-relax 0.1.0-beta.25 → 0.1.0-beta.26

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.
@@ -6,12 +6,18 @@ import { createFileUploadSession, FILE_CHUNK_BYTES, MAX_FILE_BYTES, MAX_FILE_COU
6
6
 
7
7
  // One authenticated connection carries requests and explicitly owned subscriptions.
8
8
  // The dispatcher is shared business routing, not an HTTP loopback proxy.
9
- export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, uploadsRoot }) {
9
+ export function attachLanSocket(server, { token, dispatch, authorizeDevice = () => false, onDeviceAuthorizationsChanged, uploadsRoot }) {
10
10
  // Allow four individually valid images to reach the aggregate-size validator,
11
11
  // so an oversized composition returns an error instead of dropping the connection.
12
12
  const wss = new WebSocketServer({ noServer: true, maxPayload: 48 * 1024 * 1024, perMessageDeflate: false });
13
13
  const uploadWss = new WebSocketServer({ noServer: true, maxPayload: 512 * 1024, perMessageDeflate: false });
14
14
  const activeUploads = new Set();
15
+ const deviceConnections = new Map();
16
+ const stopWatching = onDeviceAuthorizationsChanged?.(() => {
17
+ for (const connection of deviceConnections.values()) {
18
+ if (!authorizeDevice(connection.clientId, connection.credential)) connection.revoke();
19
+ }
20
+ });
15
21
  server.on('upgrade', (request, socket, head) => {
16
22
  const origin = request.headers.origin;
17
23
  const upload = request.url === '/api/file-transfer' && Boolean(uploadsRoot);
@@ -32,20 +38,26 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
32
38
  target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
33
39
  });
34
40
  uploadWss.on('connection', ws => {
35
- let authenticated = false, identity = '', upload;
41
+ let authenticated = false, stopped = false, identity = '', upload;
36
42
  let pending = Promise.resolve();
37
43
  const authTimer = setTimeout(() => ws.close(1008, 'Authentication required'), 5000);
38
- const reply = value => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
44
+ const reply = value => { if (!stopped && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value)); };
39
45
  ws.on('error', () => {});
40
- ws.on('close', () => {
46
+ const stop = () => {
47
+ if (stopped) return;
48
+ stopped = true;
49
+ deviceConnections.delete(ws);
41
50
  clearTimeout(authTimer);
42
51
  void pending.finally(async () => {
43
52
  await upload?.close();
44
53
  if (identity) activeUploads.delete(identity);
45
54
  });
46
- });
55
+ };
56
+ ws.on('close', stop);
47
57
  ws.on('message', (bytes, binary) => {
58
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
48
59
  pending = pending.then(async () => {
60
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
49
61
  if (binary || bytes.length > 512 * 1024) throw new Error('无效的上传分片');
50
62
  const message = JSON.parse(bytes.toString());
51
63
  if (!authenticated) {
@@ -56,6 +68,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
56
68
  if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) identity = 'local-token';
57
69
  }
58
70
  if (!identity || activeUploads.has(identity) || activeUploads.size >= 2) throw new Error('上传身份无效或工作站已有上传任务');
71
+ if (message.type === 'device-auth') deviceConnections.set(ws, {
72
+ clientId: message.clientId, credential: message.credential,
73
+ revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
74
+ });
59
75
  activeUploads.add(identity); authenticated = true; clearTimeout(authTimer);
60
76
  upload = createFileUploadSession(uploadsRoot);
61
77
  reply({ type: 'ready', maxFileBytes: MAX_FILE_BYTES, maxFileCount: MAX_FILE_COUNT,
@@ -67,10 +83,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
67
83
  });
68
84
  });
69
85
  wss.on('connection', ws => {
70
- let authenticated = false, alive = true;
86
+ let authenticated = false, alive = true, stopped = false;
71
87
  const active = new Map();
72
88
  const send = value => {
73
- if (ws.readyState !== WebSocket.OPEN) return;
89
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
74
90
  if (ws.bufferedAmount > 64 * 1024 * 1024) { ws.close(1013, 'Client too slow'); return; }
75
91
  ws.send(JSON.stringify(value));
76
92
  };
@@ -81,12 +97,17 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
81
97
  }, 30000);
82
98
  ws.on('pong', () => { alive = true; });
83
99
  ws.on('error', () => {}); // close rejects requests and releases every subscription.
84
- ws.on('close', () => {
85
- clearTimeout(authTimer); clearInterval(heartbeat);
100
+ const stop = () => {
101
+ if (stopped) return;
102
+ stopped = true;
103
+ deviceConnections.delete(ws);
104
+ clearTimeout(authTimer); clearInterval(heartbeat);
86
105
  for (const reply of active.values()) reply.end();
87
106
  active.clear();
88
- });
89
- ws.on('message', async (bytes, binary) => {
107
+ };
108
+ ws.on('close', stop);
109
+ ws.on('message', async (bytes, binary) => {
110
+ if (stopped || ws.readyState !== WebSocket.OPEN) return;
90
111
  let message;
91
112
  try { if (binary) throw new Error(); message = JSON.parse(bytes.toString()); }
92
113
  catch { ws.close(1008, 'Invalid JSON'); return; }
@@ -103,6 +124,10 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
103
124
  if (!accepted) {
104
125
  ws.close(1008, 'Authentication failed'); return;
105
126
  }
127
+ if (message.type === 'device-auth') deviceConnections.set(ws, {
128
+ clientId: message.clientId, credential: message.credential,
129
+ revoke() { stop(); ws.close(4401, 'Device authorization revoked'); },
130
+ });
106
131
  authenticated = true; clearTimeout(authTimer); send({ type: 'ready', version: 1 }); return;
107
132
  }
108
133
  const { id, type } = message;
@@ -186,6 +211,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
186
211
  });
187
212
  });
188
213
  return { close() {
214
+ stopWatching?.();
189
215
  for (const ws of wss.clients) ws.terminate(); wss.close();
190
216
  for (const ws of uploadWss.clients) ws.terminate(); uploadWss.close();
191
217
  } };
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import { adminPage } from './admin-page.mjs';
4
4
  import { startAdminCloud } from './admin-cloud.mjs';
5
+ import { MAX_ADMIN_REQUEST_BYTES } from './admin-state.mjs';
5
6
 
6
7
  function reply(response, status, body, type = 'application/json; charset=utf-8') {
7
8
  const bytes = typeof body === 'string' ? body : JSON.stringify(body);
@@ -53,7 +54,7 @@ export async function startAdminServer({ port = 45851, relayUrl = 'http://127.0.
53
54
  let size = 0;
54
55
  for await (const chunk of request) {
55
56
  size += chunk.length;
56
- if (size > 4096) { reply(response, 413, { error: 'Admin request too large' }); return; }
57
+ if (size > MAX_ADMIN_REQUEST_BYTES) { reply(response, 413, { error: 'Admin request too large' }); return; }
57
58
  chunks.push(chunk);
58
59
  }
59
60
  const upstream = await fetch(new URL(target.pathname + target.search, relayOrigin), {
@@ -2,6 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
+ // 80 title + 2000 body UTF-16 units fit even when every unit uses a six-byte JSON escape.
6
+ export const MAX_ADMIN_REQUEST_BYTES = 16 * 1024;
7
+
5
8
  export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
6
9
  let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
7
10
  if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
@@ -1,11 +1,23 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { EventEmitter } from 'node:events';
4
5
  import { digest, matches } from './wire.mjs';
5
6
  import { validId } from './device-store.mjs';
6
7
 
7
8
  const recordPath = stateRoot => path.join(stateRoot, 'lan-device-authorizations.json');
8
9
  const validHash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
10
+ const authorizationChanges = new EventEmitter();
11
+ const authorizationKey = stateRoot => {
12
+ const resolved = path.resolve(stateRoot);
13
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
14
+ };
15
+
16
+ export function onLanAuthorizationsChanged(stateRoot, listener) {
17
+ const key = authorizationKey(stateRoot);
18
+ authorizationChanges.on(key, listener);
19
+ return () => authorizationChanges.off(key, listener);
20
+ }
9
21
 
10
22
  export function saveLanAuthorizations(stateRoot, hostId, clients, revision) {
11
23
  if (!validId(hostId) || !Array.isArray(clients) || clients.length > 16 ||
@@ -17,6 +29,7 @@ export function saveLanAuthorizations(stateRoot, hostId, clients, revision) {
17
29
  fs.writeFileSync(temporary, JSON.stringify(record), { mode: 0o600, flag: 'wx' });
18
30
  fs.renameSync(temporary, file);
19
31
  } finally { fs.rmSync(temporary, { force: true }); }
32
+ authorizationChanges.emit(authorizationKey(stateRoot));
20
33
  }
21
34
  export function authorizeLanDevice(stateRoot, clientId, credential) {
22
35
  if (!validId(clientId)) return false;
@@ -50,21 +50,9 @@ async function createRelayRuntime(stateRoot) {
50
50
  let health = await serviceHealth(session());
51
51
  if (health?.status !== 'ok') throw new Error('桌面服务尚未运行,请先执行 relax start --json。');
52
52
  const stored = await credentialStore('load');
53
- let identities = stored.record || { version: 2, devices: {} };
53
+ const identities = stored.record || { version: 2, devices: {} };
54
54
  const validIdentity = (id, item) => /^[\w-]{16,128}$/.test(id) && item?.id === id &&
55
55
  validSecret(item.secret) && typeof item.registered === 'boolean';
56
- if (identities.version === 1 && identities.servers && typeof identities.servers === 'object' && !Array.isArray(identities.servers)) {
57
- const devices = {};
58
- for (const [url, item] of Object.entries(identities.servers)) {
59
- relayAddress(url);
60
- if (!validIdentity(item?.id, item) || devices[item.id]) throw new Error('本机设备身份损坏,未自动覆盖');
61
- devices[item.id] = item;
62
- }
63
- const previous = identities.servers[config.url];
64
- identities = { version: 2, devices };
65
- await credentialStore('save', identities);
66
- if (!config.deviceId && previous) config.deviceId = previous.id;
67
- }
68
56
  if (identities.version !== 2 || !identities.devices || typeof identities.devices !== 'object' || Array.isArray(identities.devices) ||
69
57
  Object.entries(identities.devices).some(([id, item]) => !validIdentity(id, item))) throw new Error('本机设备身份损坏,未自动覆盖');
70
58
  if (!config.deviceId) {
@@ -115,7 +103,7 @@ async function createRelayRuntime(stateRoot) {
115
103
  };
116
104
  async function shutdown() {
117
105
  if (closing) return;
118
- closing = true; clearTimeout(retryTimer);
106
+ closing = true; online = false; attachedCount = 0; clearTimeout(retryTimer);
119
107
  connector?.events.removeAllListeners(); connector?.close();
120
108
  }
121
109
  async function saveInvitation(message) {
@@ -139,7 +127,9 @@ async function createRelayRuntime(stateRoot) {
139
127
  try {
140
128
  const bridge = session();
141
129
  await refreshHealth();
130
+ if (closing) return;
142
131
  const lanCandidates = await currentLanCandidates();
132
+ if (closing) return;
143
133
  connector?.events.removeAllListeners(); connector?.close();
144
134
  const current = connector = connectBridgeRelay({ relayUrl: config.url, identity,
145
135
  usageCacheFile: path.join(stateRoot, 'relay-usage-tasks.json'),
@@ -162,6 +152,7 @@ async function createRelayRuntime(stateRoot) {
162
152
  if (![1008, 4401, 4406, 4409, 4429].includes(event.code)) scheduleRetry();
163
153
  });
164
154
  const registration = await current.ready;
155
+ if (closing) return;
165
156
  if (registration.protocolVersion !== 7 || registration.room !== identity.id) {
166
157
  current.events.removeAllListeners(); current.close(); throw Object.assign(new Error('中继服务器尚未升级设备授权协议,未使用临时配对降级。'), { code: 4401 });
167
158
  }
@@ -170,11 +161,14 @@ async function createRelayRuntime(stateRoot) {
170
161
  identities.devices[identity.id] = registeredIdentity;
171
162
  await credentialStore('save', identities); identity = registeredIdentity;
172
163
  }
164
+ if (closing) return;
173
165
  current.assertConnected();
174
166
  online = true; attempts = 0; paired = registration.paired; clientCount = registration.clientCount || 0; lastError = '';
175
167
  saveLanAuthorizations(stateRoot, identity.id, registration.clients || [], registration.revision);
176
- await saveInvitation(registration); audit('relay-connected', { paired });
168
+ await saveInvitation(registration);
169
+ if (!closing) audit('relay-connected', { paired });
177
170
  } catch (error) {
171
+ if (closing) return;
178
172
  online = false; lastError = error.message; audit('connect-failed', { message: error.message });
179
173
  if (![1008, 4401, 4406, 4409, 4429].includes(error.code)) retryAfterAttempt = true;
180
174
  else { clearTimeout(retryTimer); retryTimer = undefined; }
@@ -8,7 +8,7 @@ import { WebSocketServer } from 'ws';
8
8
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
9
9
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
10
10
  import { validProbeSignal } from './p2p-probe.mjs';
11
- import { relayAdminState } from './admin-state.mjs';
11
+ import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
12
12
  import { openRelayMysql } from './mysql-state.mjs';
13
13
  import { openEmailAccounts } from './email-accounts.mjs';
14
14
  import { usageMeter, usageReport } from './usage-meter.mjs';
@@ -33,6 +33,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
33
33
  const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
34
34
  const meter = accounts ? usageMeter(database.pool) : null;
35
35
  const cancelledAccountIds = new Set();
36
+ const accountCancellationWork = new Map();
36
37
  const diagnostics = database?.schemaVersions.includes('006_relay_diagnostics')
37
38
  ? relayDiagnostics(database.pool, error => log({ event: 'relay-diagnostic-error', message: error.message })) : null;
38
39
  const adminAccessFile = '/etc/codex-relay/admin-access.json';
@@ -145,6 +146,38 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
145
146
  'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
146
147
  response.end(value);
147
148
  }
149
+ function cancelAccountConnections(userId, token) {
150
+ const pending = accountCancellationWork.get(userId);
151
+ if (pending) return pending;
152
+ if (cancelledAccountIds.has(userId)) return Promise.resolve();
153
+ cancelledAccountIds.add(userId);
154
+ const work = (async () => {
155
+ try { await accounts.cancelAccount(token); }
156
+ catch (failure) { cancelledAccountIds.delete(userId); throw failure; }
157
+ for (const room of rooms.values()) {
158
+ for (const [key, arm] of room.usageArms || []) if (arm.userId === userId) room.usageArms.delete(key);
159
+ for (const [key, task] of room.usageTasks || []) if (task.userId === userId) room.usageTasks.delete(key);
160
+ for (const [clientId, connection] of room.connections) {
161
+ if (connection.userId !== userId) continue;
162
+ releaseRelay(connection); room.connections.delete(clientId);
163
+ if (room.host) send(room.host, { type: 'detach', session: connection.session });
164
+ connection.ws.close(4431, 'Account cancelled');
165
+ }
166
+ for (const [clientId, observer] of room.observers) {
167
+ if (observer.userId !== userId) continue;
168
+ room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
169
+ }
170
+ for (const [clientId, upload] of room.uploadClients) {
171
+ if (upload.userId !== userId) continue;
172
+ room.uploadClients.delete(clientId);
173
+ if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
174
+ upload.ws.close(4431, 'Account cancelled');
175
+ }
176
+ }
177
+ })().finally(() => accountCancellationWork.delete(userId));
178
+ accountCancellationWork.set(userId, work);
179
+ return work;
180
+ }
148
181
  async function handleAccount(request, response, target) {
149
182
  try {
150
183
  if (target.pathname === '/account/config' && request.method === 'GET') {
@@ -160,29 +193,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
160
193
  }
161
194
  if (target.pathname === '/account/me' && request.method === 'DELETE') {
162
195
  const user = await accounts.current(bearer);
163
- cancelledAccountIds.add(user.id);
164
- try { await accounts.cancelAccount(bearer); }
165
- catch (failure) { cancelledAccountIds.delete(user.id); throw failure; }
166
- for (const room of rooms.values()) {
167
- for (const [key, arm] of room.usageArms || []) if (arm.userId === user.id) room.usageArms.delete(key);
168
- for (const [key, task] of room.usageTasks || []) if (task.userId === user.id) room.usageTasks.delete(key);
169
- for (const [clientId, connection] of room.connections) {
170
- if (connection.userId !== user.id) continue;
171
- releaseRelay(connection); room.connections.delete(clientId);
172
- if (room.host) send(room.host, { type: 'detach', session: connection.session });
173
- connection.ws.close(4431, 'Account cancelled');
174
- }
175
- for (const [clientId, observer] of room.observers) {
176
- if (observer.userId !== user.id) continue;
177
- room.observers.delete(clientId); observer.ws.close(4431, 'Account cancelled');
178
- }
179
- for (const [clientId, upload] of room.uploadClients) {
180
- if (upload.userId !== user.id) continue;
181
- room.uploadClients.delete(clientId);
182
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
183
- upload.ws.close(4431, 'Account cancelled');
184
- }
185
- }
196
+ await cancelAccountConnections(user.id, bearer);
186
197
  accountReply(response, 200, { ok: true }); return;
187
198
  }
188
199
  if (request.method !== 'POST' || request.headers['content-type'] !== 'application/json') {
@@ -247,7 +258,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
247
258
  if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
248
259
  request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
249
260
  try {
250
- const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, 4096));
261
+ const body = request.method === 'DELETE' ? {} : JSON.parse(await readBody(request, MAX_ADMIN_REQUEST_BYTES));
251
262
  if (target.pathname === '/admin/api/settings' && request.method === 'PUT') {
252
263
  const state = await adminState.setMaxRelaySessions(body.maxRelaySessions);
253
264
  log({ event: 'admin-relay-limit-updated', maxRelaySessions: state.maxRelaySessions });
@@ -18,7 +18,7 @@ import { runtimePaths } from './platform/windows/runtime-paths.mjs';
18
18
  import { openControl } from './service-control.mjs';
19
19
  import { writeRecord, readRecord } from './service-lifecycle.mjs';
20
20
  import { ExecutionHealth } from './execution-health.mjs';
21
- import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
21
+ import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
22
22
  import { createRelayManager } from './self-relay/lifecycle.mjs';
23
23
  import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
24
24
  import { desktopHost } from './platform/windows/desktop-host.mjs';
@@ -521,7 +521,8 @@ async function handleRequest(request, response) {
521
521
  const server = http.createServer(handleRequest);
522
522
  const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
523
523
  uploadsRoot,
524
- authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
524
+ authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
525
+ onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
525
526
  });
526
527
 
527
528
  let control;
@@ -4,36 +4,69 @@ import { randomUUID } from 'node:crypto';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { startBackground } from './background-process.mjs';
7
- import { lifecycle, readRecord } from './service-lifecycle.mjs';
7
+ import { lifecycle, readRecord, writeRecord } from './service-lifecycle.mjs';
8
8
  import * as host from './platform/windows/service-host.mjs';
9
+ import { desktopHost } from './platform/windows/desktop-host.mjs';
9
10
 
10
11
  const script = fileURLToPath(import.meta.url);
12
+ const sameProcess = (expected, actual) => expected && actual && expected.pid === actual.pid &&
13
+ expected.created === actual.created && expected.executable === actual.executable;
14
+ async function processIdentity(pid) {
15
+ const current = await desktopHost.process(pid);
16
+ return current ? { pid: current.pid, created: current.created, executable: current.executable } : null;
17
+ }
11
18
 
12
19
  export async function requestServiceRestart(stateRoot) {
13
20
  const lock = path.join(stateRoot, 'service-restart.lock');
14
21
  const operationId = randomUUID();
15
- let handle;
22
+ const owner = await processIdentity(process.pid);
23
+ if (!owner) throw new Error('无法确认服务重启请求进程。');
24
+ const previous = readRecord(lock);
25
+ if (previous) {
26
+ const actual = previous.owner?.pid ? await processIdentity(previous.owner.pid) : null;
27
+ if (sameProcess(previous.owner, actual) || readRecord(lock)?.operationId !== previous.operationId) {
28
+ throw new Error('工作站服务已在重启中。');
29
+ }
30
+ fs.rmSync(lock);
31
+ }
32
+ const temporary = `${lock}.${operationId}.tmp`;
16
33
  try {
17
- handle = fs.openSync(lock, 'wx', 0o600);
18
- fs.writeFileSync(handle, JSON.stringify({ operationId, requestedAt: new Date().toISOString() }));
34
+ fs.writeFileSync(temporary, JSON.stringify({ operationId, owner, requestedAt: new Date().toISOString() }),
35
+ { flag: 'wx', mode: 0o600 });
36
+ // Publish a complete record exclusively; a crash cannot leave a half-written lock.
37
+ fs.linkSync(temporary, lock);
19
38
  } catch (error) {
20
39
  if (error.code === 'EEXIST') throw new Error('工作站服务已在重启中。');
21
40
  throw error;
22
- } finally { if (handle !== undefined) fs.closeSync(handle); }
41
+ } finally { fs.rmSync(temporary, { force: true }); }
23
42
  try {
24
43
  const log = path.join(stateRoot, 'service-restart.log');
25
- await startBackground(process.execPath, [script, '--worker', stateRoot, operationId], process.env, [log, log]);
44
+ const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, operationId], process.env, [log, log]);
45
+ const worker = await processIdentity(pid);
46
+ if (!worker) throw new Error('服务重启进程未启动。');
47
+ const current = readRecord(lock);
48
+ if (current?.operationId !== operationId) throw new Error('服务重启实例已失效。');
49
+ writeRecord(lock, { ...current, owner: worker });
26
50
  return { operationId, status: 'scheduled', message: '工作站服务重启已安排。' };
27
51
  } catch (error) {
28
- fs.rmSync(lock, { force: true }); throw error;
52
+ const current = readRecord(lock);
53
+ if (current?.operationId === operationId && sameProcess(current.owner, owner)) fs.rmSync(lock, { force: true });
54
+ throw error;
29
55
  }
30
56
  }
31
57
 
32
58
  async function run(stateRoot, operationId) {
33
59
  const lock = path.join(stateRoot, 'service-restart.lock');
34
- if (readRecord(lock)?.operationId !== operationId) throw new Error('服务重启实例已失效。');
35
- const service = lifecycle({ stateRoot, host });
36
60
  try {
61
+ const deadline = Date.now() + 30000;
62
+ while (true) {
63
+ const record = readRecord(lock);
64
+ if (record?.operationId !== operationId) throw new Error('服务重启实例已失效。');
65
+ if (record.owner?.pid === process.pid) break;
66
+ if (Date.now() >= deadline) throw new Error('服务重启进程交接超时。');
67
+ await delay(100);
68
+ }
69
+ const service = lifecycle({ stateRoot, host });
37
70
  await delay(1000);
38
71
  await service.stop();
39
72
  await service.start();
@@ -211,7 +211,11 @@ export class SharedAppServer extends EventEmitter {
211
211
  this.emit('turn', { thread: record.thread, turn, sequence,
212
212
  immediate: ['turn/completed', 'item/completed'].includes(method) });
213
213
  }
214
- if (method === 'turn/completed') record.thread.turns = record.thread.turns.slice(-10);
214
+ if (method === 'turn/completed' && record.thread.turns.length > 10) {
215
+ record.thread.turns = record.thread.turns.slice(-10);
216
+ // Native cursors are opaque; obtain a matching boundary before the next snapshot.
217
+ record.historyTrimmed = true;
218
+ }
215
219
  if (['turn/completed', 'thread/status/changed'].includes(method)) this.releaseInactiveThread(id);
216
220
  }
217
221
 
@@ -220,21 +224,25 @@ export class SharedAppServer extends EventEmitter {
220
224
  const slot = this.slot(threadId);
221
225
  slot.readers++;
222
226
  const task = slot.tail.then(async () => {
223
- let record = this.threads.get(threadId);
224
- if (record?.readOnly || record?.needsReload) {
225
- this.threads.delete(threadId);
226
- this.contexts.delete(threadId);
227
- record = null;
228
- }
229
- if (!record) {
227
+ let record = this.threads.get(threadId);
228
+ // Snapshot refreshes must not reset the sequence or tokens of live subscribers.
229
+ const previous = record?.readOnly ? null : record;
230
+ if (record?.readOnly || record?.needsReload) this.contexts.delete(threadId);
231
+ if (!record || record.readOnly || record.needsReload || record.historyTrimmed) {
230
232
  let result;
231
233
  try {
232
- result = await this.request('thread/resume', { threadId, excludeTurns: true,
233
- initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
234
- if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
235
- record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({ ...turn, items: [] })) },
236
- nextCursor: value.initialTurnsPage.nextCursor ?? null, sequence: 0, recoverAfter: 0,
237
- changes: new Map(), changeBytes: 0, tokens: new Map(), hydrating: true };
234
+ result = await this.request('thread/resume', { threadId, excludeTurns: true,
235
+ initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } }, value => {
236
+ if (!Array.isArray(value.initialTurnsPage?.data)) throw new Error('Codex 内核未返回分页历史。');
237
+ const retained = new Map((previous?.thread.turns || []).map(turn => [turn.id, turn]));
238
+ record = { thread: { ...value.thread, turns: [...value.initialTurnsPage.data].reverse().map(turn => ({
239
+ ...turn, items: retained.get(turn.id)?.items || [],
240
+ ...(retained.get(turn.id)?.itemPage ? { itemPage: retained.get(turn.id).itemPage } : {}),
241
+ })) },
242
+ nextCursor: value.initialTurnsPage.nextCursor ?? null,
243
+ sequence: previous?.sequence ?? 0, recoverAfter: previous?.recoverAfter ?? 0,
244
+ changes: previous?.changes ?? new Map(), changeBytes: previous?.changeBytes ?? 0,
245
+ tokens: previous?.tokens ?? new Map(), hydrating: true };
238
246
  this.threads.set(threadId, record);
239
247
  return value;
240
248
  });
@@ -346,7 +354,7 @@ export class SharedAppServer extends EventEmitter {
346
354
  if (!token || token.expires <= Date.now() || token.sequence !== requested.after) token = undefined;
347
355
  }
348
356
  let snapshot = false, catchup;
349
- if (!record || !token) {
357
+ if (!record || !token || (record.historyTrimmed && requested.after < record.recoverAfter)) {
350
358
  const fresh = await this.timeline(threadId);
351
359
  record = this.threads.get(threadId); token = record.tokens.get(fresh.sync.id);
352
360
  requested = { id: fresh.sync.id, epoch: fresh.sync.epoch, after: fresh.sync.seq };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.25",
3
+ "version": "0.1.0-beta.26",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {