@walkhi/code-relax 0.1.0-beta.25 → 0.1.0-beta.27
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/shared/file-citations.cjs +82 -0
- package/dist/src/lan-socket-server.mjs +37 -11
- package/dist/src/self-relay/admin-server.mjs +2 -1
- package/dist/src/self-relay/admin-state.mjs +3 -0
- package/dist/src/self-relay/lan-authorizations.mjs +13 -0
- package/dist/src/self-relay/lifecycle.mjs +9 -15
- package/dist/src/self-relay/server.mjs +36 -25
- package/dist/src/server.mjs +71 -27
- package/dist/src/service-restart.mjs +42 -9
- package/dist/src/shared-app-server.mjs +23 -15
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory();
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
4
|
+
else root.CodexFileCitations = api;
|
|
5
|
+
})(globalThis, function () {
|
|
6
|
+
const prefix = ':codex-file-citation{';
|
|
7
|
+
|
|
8
|
+
function parse(source, start) {
|
|
9
|
+
if (!source.startsWith(prefix, start)) return null;
|
|
10
|
+
const attributes = Object.create(null);
|
|
11
|
+
let position = start + prefix.length;
|
|
12
|
+
while (position < source.length) {
|
|
13
|
+
while (/[ \t]/.test(source[position] || '')) position++;
|
|
14
|
+
if (source[position] === '}') return { attributes, end: position + 1 };
|
|
15
|
+
const key = /^[A-Za-z_][\w-]*/.exec(source.slice(position));
|
|
16
|
+
if (!key) return null;
|
|
17
|
+
position += key[0].length;
|
|
18
|
+
const afterKey = position;
|
|
19
|
+
while (/[ \t]/.test(source[position] || '')) position++;
|
|
20
|
+
let value = '';
|
|
21
|
+
const assigned = source[position] === '=';
|
|
22
|
+
if (assigned) {
|
|
23
|
+
position++;
|
|
24
|
+
while (/[ \t]/.test(source[position] || '')) position++;
|
|
25
|
+
const quote = source[position];
|
|
26
|
+
if (quote === '"' || quote === "'") {
|
|
27
|
+
position++;
|
|
28
|
+
while (position < source.length && source[position] !== quote) {
|
|
29
|
+
if (/[\r\n]/.test(source[position])) return null;
|
|
30
|
+
if (source[position] === '\\' && source[position + 1] === quote) position++;
|
|
31
|
+
value += source[position++];
|
|
32
|
+
}
|
|
33
|
+
if (source[position++] !== quote) return null;
|
|
34
|
+
} else {
|
|
35
|
+
const match = /^[^\s{}"']+/.exec(source.slice(position));
|
|
36
|
+
if (!match) return null;
|
|
37
|
+
value = match[0];
|
|
38
|
+
position += value.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
attributes[key[0]] = value;
|
|
42
|
+
if ((assigned || position === afterKey) && source[position] !== '}' && !/[ \t]/.test(source[position] || '')) return null;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function positiveLine(value) {
|
|
48
|
+
const line = /^\d+$/.test(value || '') ? Number(value) : 0;
|
|
49
|
+
return Number.isSafeInteger(line) && line > 0 ? line : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function install(markdown) {
|
|
53
|
+
markdown.inline.ruler.before('text', 'codex_file_citation', (state, silent) => {
|
|
54
|
+
if (!state.env?.allowFileCitations || state.linkLevel > 0) return false;
|
|
55
|
+
const result = parse(state.src, state.pos);
|
|
56
|
+
const attributes = result?.attributes;
|
|
57
|
+
const path = attributes?.path?.trim();
|
|
58
|
+
if (!path || /[\0\r\n]/.test(path)) return false;
|
|
59
|
+
const local = /^(?:[a-z]:[\\/]|\/(?!\/))/i.test(path);
|
|
60
|
+
if (!local && !/^https?:\/\//i.test(path)) return false;
|
|
61
|
+
const line = local ? positiveLine(attributes.lineRangeStart ?? attributes.line_range_start) : null;
|
|
62
|
+
const end = local ? positiveLine(attributes.lineRangeEnd ?? attributes.line_range_end) : null;
|
|
63
|
+
const reference = local ? encodeURI(path) + (line ? `:${line}` : '') : path;
|
|
64
|
+
const href = markdown.normalizeLink(reference);
|
|
65
|
+
if (!markdown.validateLink(href)) return false;
|
|
66
|
+
if (!silent) {
|
|
67
|
+
const link = state.push('link_open', 'a', 1);
|
|
68
|
+
link.attrSet('href', href);
|
|
69
|
+
link.attrSet('title', path);
|
|
70
|
+
link.meta = { fileCitation: { path, line, end: line && end >= line ? end : null, purpose: attributes.purpose } };
|
|
71
|
+
const label = state.push('text', '', 0);
|
|
72
|
+
label.content = local ? path.split(/[\\/]/).filter(Boolean).at(-1) || path
|
|
73
|
+
: attributes.label || attributes.title || path;
|
|
74
|
+
state.push('link_close', 'a', -1);
|
|
75
|
+
}
|
|
76
|
+
state.pos = result.end;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.freeze({ install });
|
|
82
|
+
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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('
|
|
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 >
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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,
|
|
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 });
|
package/dist/src/server.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import vm from 'node:vm';
|
|
8
8
|
import sharp from 'sharp';
|
|
9
9
|
import { attachLanSocket } from './lan-socket-server.mjs';
|
|
10
|
-
import fileMentions from '../shared/file-mentions.cjs';
|
|
10
|
+
import fileMentions from '../shared/file-mentions.cjs';
|
|
11
|
+
import fileCitations from '../shared/file-citations.cjs';
|
|
11
12
|
import { validateInlineImage } from './message-images.mjs';
|
|
12
13
|
import { ThreadWriterConflictError } from './shared-app-server.mjs';
|
|
13
14
|
import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
|
|
@@ -18,7 +19,7 @@ import { runtimePaths } from './platform/windows/runtime-paths.mjs';
|
|
|
18
19
|
import { openControl } from './service-control.mjs';
|
|
19
20
|
import { writeRecord, readRecord } from './service-lifecycle.mjs';
|
|
20
21
|
import { ExecutionHealth } from './execution-health.mjs';
|
|
21
|
-
import { authorizeLanDevice } from './self-relay/lan-authorizations.mjs';
|
|
22
|
+
import { authorizeLanDevice, onLanAuthorizationsChanged } from './self-relay/lan-authorizations.mjs';
|
|
22
23
|
import { createRelayManager } from './self-relay/lifecycle.mjs';
|
|
23
24
|
import { generateAndSetThreadTitle } from './thread-title-generation.mjs';
|
|
24
25
|
import { desktopHost } from './platform/windows/desktop-host.mjs';
|
|
@@ -29,7 +30,10 @@ import { TurnModelHistory } from './turn-model-history.mjs';
|
|
|
29
30
|
|
|
30
31
|
const turnModelHistory = new TurnModelHistory();
|
|
31
32
|
|
|
32
|
-
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
|
33
|
+
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
const MAX_FILE_DOWNLOAD_BYTES = 16 * 1024 * 1024;
|
|
35
|
+
// Each Base64 response fits the 8 MiB relay limit; full chunks have no padding.
|
|
36
|
+
const FILE_DOWNLOAD_CHUNK_BYTES = 3 * 1024 * 1024;
|
|
33
37
|
const MAX_CACHED_ATTACHMENTS = 32;
|
|
34
38
|
const MAX_CACHED_FILES = 64;
|
|
35
39
|
const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
@@ -48,16 +52,25 @@ const ALLOWED_MODELS = new Set([
|
|
|
48
52
|
]);
|
|
49
53
|
const ALLOWED_THINKING = new Set(['low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
50
54
|
|
|
51
|
-
class RequestError extends Error {
|
|
55
|
+
class RequestError extends Error {
|
|
52
56
|
constructor(statusCode, message, details) {
|
|
53
57
|
super(message);
|
|
54
58
|
this.statusCode = statusCode;
|
|
55
59
|
this.details = details;
|
|
56
60
|
this.code = details?.code;
|
|
57
61
|
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function downloadOffset(rawOffset, size) {
|
|
65
|
+
const offset = Number(rawOffset);
|
|
66
|
+
if (!/^(0|[1-9]\d*)$/.test(rawOffset || '') || !Number.isSafeInteger(offset) ||
|
|
67
|
+
offset > size || offset % FILE_DOWNLOAD_CHUNK_BYTES !== 0) {
|
|
68
|
+
throw new RequestError(400, '无效的文件下载位置。');
|
|
69
|
+
}
|
|
70
|
+
return offset;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readRuntimeVersion() {
|
|
61
74
|
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
62
75
|
while (true) {
|
|
63
76
|
const manifest = readRecord(path.join(directory, 'package.json'));
|
|
@@ -77,7 +90,8 @@ const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
|
|
|
77
90
|
const markdownItScript = fs.readFileSync(path.join(webRoot, 'vendor', 'markdown-it.umd.min.js'));
|
|
78
91
|
const markdownSandbox = { atob };
|
|
79
92
|
vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
|
|
80
|
-
const imageMarkdown = markdownSandbox.markdownit({ html: false });
|
|
93
|
+
const imageMarkdown = markdownSandbox.markdownit({ html: false });
|
|
94
|
+
fileCitations.install(imageMarkdown);
|
|
81
95
|
const attachmentCache = new Map();
|
|
82
96
|
const fileCache = new Map();
|
|
83
97
|
const fileCapabilitySecret = crypto.randomBytes(32);
|
|
@@ -229,9 +243,18 @@ async function handleRequest(request, response) {
|
|
|
229
243
|
} catch {}
|
|
230
244
|
}
|
|
231
245
|
if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
if (url.searchParams.get('variant') === 'thumbnail') {
|
|
247
|
+
const asset = await attachmentThumbnail(image);
|
|
248
|
+
sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const size = image.buffer.length;
|
|
252
|
+
if (size > MAX_ATTACHMENT_BYTES) throw new RequestError(413, '图片超过下载上限(16 MiB),无法下载。');
|
|
253
|
+
if (!size) throw new RequestError(400, '图片为空,无法下载。');
|
|
254
|
+
const offset = downloadOffset(url.searchParams.get('offset'), size);
|
|
255
|
+
const data = image.buffer.subarray(offset, Math.min(size, offset + FILE_DOWNLOAD_CHUNK_BYTES));
|
|
256
|
+
sendJson(response, 200, { name: image.name || '图片', size, offset, mimeType: image.mimeType,
|
|
257
|
+
dataBase64: data.toString('base64') });
|
|
235
258
|
return;
|
|
236
259
|
}
|
|
237
260
|
|
|
@@ -239,14 +262,34 @@ async function handleRequest(request, response) {
|
|
|
239
262
|
if (request.method === 'GET' && fileMatch) {
|
|
240
263
|
const file = fileCache.get(fileMatch[1]);
|
|
241
264
|
if (!file) throw new RequestError(404, '文件授权已失效,请重新读取对话。');
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
throw new RequestError(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
265
|
+
const offset = downloadOffset(url.searchParams.get('offset'), file.size);
|
|
266
|
+
let handle;
|
|
267
|
+
try { handle = fs.openSync(file.path, 'r'); }
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (error.code === 'ENOENT') throw new RequestError(404, '电脑上的文件已不存在。');
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const validate = () => {
|
|
274
|
+
const stat = fs.fstatSync(handle);
|
|
275
|
+
if (!stat.isFile() || stat.size !== file.size || stat.mtimeMs !== file.mtimeMs) {
|
|
276
|
+
fileCache.delete(fileMatch[1]);
|
|
277
|
+
throw new RequestError(409, '电脑上的文件已发生变化,请重新读取对话。');
|
|
278
|
+
}
|
|
279
|
+
if (stat.size > MAX_FILE_DOWNLOAD_BYTES) throw new RequestError(413, '文件超过下载上限(16 MiB),无法下载。');
|
|
280
|
+
if (!stat.size) throw new RequestError(400, '文件为空,无法下载。');
|
|
281
|
+
};
|
|
282
|
+
validate();
|
|
283
|
+
const data = Buffer.alloc(Math.min(FILE_DOWNLOAD_CHUNK_BYTES, file.size - offset));
|
|
284
|
+
let bytesRead = 0;
|
|
285
|
+
while (bytesRead < data.length) {
|
|
286
|
+
const count = fs.readSync(handle, data, bytesRead, data.length - bytesRead, offset + bytesRead);
|
|
287
|
+
if (!count) throw new RequestError(409, '电脑上的文件读取不完整,请重新读取对话。');
|
|
288
|
+
bytesRead += count;
|
|
289
|
+
}
|
|
290
|
+
validate();
|
|
291
|
+
sendJson(response, 200, { name: file.name, size: file.size, offset, dataBase64: data.toString('base64') });
|
|
292
|
+
} finally { fs.closeSync(handle); }
|
|
250
293
|
return;
|
|
251
294
|
}
|
|
252
295
|
|
|
@@ -521,7 +564,8 @@ async function handleRequest(request, response) {
|
|
|
521
564
|
const server = http.createServer(handleRequest);
|
|
522
565
|
const lanSocket = attachLanSocket(server, { token: options.token, dispatch: handleRequest,
|
|
523
566
|
uploadsRoot,
|
|
524
|
-
authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
|
|
567
|
+
authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
|
|
568
|
+
onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
|
|
525
569
|
});
|
|
526
570
|
|
|
527
571
|
let control;
|
|
@@ -1055,8 +1099,8 @@ function markdownImageAssets(text) {
|
|
|
1055
1099
|
const assets = Object.create(null);
|
|
1056
1100
|
function visit(tokens) {
|
|
1057
1101
|
for (const token of tokens) {
|
|
1058
|
-
const value = token.type === 'image' ? token.attrGet('src')
|
|
1059
|
-
: token.type === 'link_open' ? token.attrGet('href') : null;
|
|
1102
|
+
const value = token.type === 'image' ? token.attrGet('src')
|
|
1103
|
+
: token.type === 'link_open' && !token.meta?.fileCitation ? token.attrGet('href') : null;
|
|
1060
1104
|
if (value && !Object.hasOwn(assets, value)) {
|
|
1061
1105
|
let localPath;
|
|
1062
1106
|
try { localPath = decodeURIComponent(value); } catch { continue; }
|
|
@@ -1068,10 +1112,10 @@ function markdownImageAssets(text) {
|
|
|
1068
1112
|
if (asset.available) assets[value] = asset;
|
|
1069
1113
|
}
|
|
1070
1114
|
}
|
|
1071
|
-
if (token.children) visit(token.children);
|
|
1115
|
+
if (token.children && token.type !== 'image') visit(token.children);
|
|
1072
1116
|
}
|
|
1073
1117
|
}
|
|
1074
|
-
visit(imageMarkdown.parse(text, {}));
|
|
1118
|
+
visit(imageMarkdown.parse(text, { allowFileCitations: true }));
|
|
1075
1119
|
return assets;
|
|
1076
1120
|
}
|
|
1077
1121
|
|
|
@@ -1092,10 +1136,10 @@ function markdownFileAssets(text) {
|
|
|
1092
1136
|
}
|
|
1093
1137
|
}
|
|
1094
1138
|
}
|
|
1095
|
-
if (token.children) visit(token.children);
|
|
1139
|
+
if (token.children && token.type !== 'image') visit(token.children);
|
|
1096
1140
|
}
|
|
1097
1141
|
}
|
|
1098
|
-
visit(imageMarkdown.parse(text, {}));
|
|
1142
|
+
visit(imageMarkdown.parse(text, { allowFileCitations: true }));
|
|
1099
1143
|
return assets;
|
|
1100
1144
|
}
|
|
1101
1145
|
|
|
@@ -1103,7 +1147,7 @@ function rememberLocalFile(filePath) {
|
|
|
1103
1147
|
try {
|
|
1104
1148
|
const resolved = path.resolve(filePath);
|
|
1105
1149
|
const stat = fs.statSync(resolved);
|
|
1106
|
-
if (!stat.isFile()
|
|
1150
|
+
if (!stat.isFile()) return null;
|
|
1107
1151
|
const id = crypto.createHmac('sha256', fileCapabilitySecret)
|
|
1108
1152
|
.update(resolved).update('\0').update(String(stat.size)).update('\0').update(String(stat.mtimeMs)).digest('hex');
|
|
1109
1153
|
if (!fileCache.has(id)) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 {
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 };
|