@walkhi/code-relax 0.1.0-beta.16 → 0.1.0-beta.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/dist/bin/codex-remote.mjs +47 -37
- package/dist/bin/manage-relay-admin.mjs +20 -0
- package/dist/bin/migrate-relay-db.mjs +3 -1
- package/dist/bin/self-relay-demo.mjs +1 -1
- package/dist/shared/app-server-events.cjs +22 -9
- package/dist/skills/code-relax/SKILL.md +6 -5
- package/dist/src/desktop-launcher.mjs +1 -1
- package/dist/src/lan-socket-server.mjs +1 -1
- package/dist/src/onboarding.mjs +6 -6
- package/dist/src/platform/windows/resident-start.js +12 -0
- package/dist/src/platform/windows/resident-startup.ps1 +21 -8
- package/dist/src/platform/windows/user-environment.mjs +1 -1
- package/dist/src/self-relay/admin-page.mjs +13 -5
- package/dist/src/self-relay/admin-server.mjs +13 -8
- package/dist/src/self-relay/admin-style.mjs +9 -0
- package/dist/src/self-relay/admin-usage-page.mjs +28 -0
- package/dist/src/self-relay/connector.mjs +158 -20
- package/dist/src/self-relay/demo.mjs +1 -1
- package/dist/src/self-relay/email-accounts.mjs +17 -1
- package/dist/src/self-relay/lifecycle.mjs +5 -4
- package/dist/src/self-relay/mysql-state.mjs +1 -1
- package/dist/src/self-relay/server.mjs +105 -4
- package/dist/src/self-relay/usage-meter.mjs +125 -0
- package/dist/src/server.mjs +47 -9
- package/dist/src/service-control.mjs +4 -4
- package/dist/src/service-doctor.mjs +4 -4
- package/dist/src/service-health.mjs +2 -2
- package/dist/src/service-lifecycle.mjs +8 -8
- package/dist/src/shared-app-server.mjs +7 -4
- package/dist/src/shared-recovery.mjs +1 -1
- package/package.json +1 -1
- package/tools/postinstall.mjs +3 -3
|
@@ -10,6 +10,7 @@ import { relayAdminState } from './admin-state.mjs';
|
|
|
10
10
|
import { openRelayMysql } from './mysql-state.mjs';
|
|
11
11
|
import { adminPage } from './admin-page.mjs';
|
|
12
12
|
import { openEmailAccounts } from './email-accounts.mjs';
|
|
13
|
+
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
13
14
|
|
|
14
15
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
15
16
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
@@ -25,8 +26,9 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
25
26
|
if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
|
|
26
27
|
const hostHash = digest(hostKey), rooms = new Map();
|
|
27
28
|
const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
|
|
28
|
-
if (accountConfigFile && !database?.schemaVersions.includes('
|
|
29
|
+
if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
|
|
29
30
|
const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
|
|
31
|
+
const meter = accounts ? usageMeter(database.pool) : null;
|
|
30
32
|
const store = database?.devices || deviceStore(stateFile);
|
|
31
33
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
32
34
|
const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
|
|
@@ -117,7 +119,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
117
119
|
}
|
|
118
120
|
const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
119
121
|
if (target.pathname === '/account/me' && request.method === 'GET') {
|
|
120
|
-
|
|
122
|
+
const user = await accounts.current(bearer);
|
|
123
|
+
accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
|
|
121
124
|
}
|
|
122
125
|
if (target.pathname === '/account/logout' && request.method === 'POST') {
|
|
123
126
|
accountReply(response, 200, await accounts.logout(bearer)); return;
|
|
@@ -142,6 +145,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
142
145
|
if (request.method === 'GET' && target.pathname === '/admin') {
|
|
143
146
|
adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
|
|
144
147
|
}
|
|
148
|
+
if (target.pathname.startsWith('/admin/api/')) {
|
|
149
|
+
const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
|
|
150
|
+
let email;
|
|
151
|
+
try { email = (await accounts.current(token)).email; }
|
|
152
|
+
catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
|
|
153
|
+
const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
|
|
154
|
+
if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
|
|
155
|
+
if (target.pathname === '/admin/api/session' && request.method === 'GET') {
|
|
156
|
+
adminReply(response, 200, { email }); return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
145
159
|
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
146
160
|
const state = adminState.read();
|
|
147
161
|
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
@@ -151,6 +165,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
151
165
|
processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
|
|
152
166
|
announcement: state.announcement, announcements: state.announcements }); return;
|
|
153
167
|
}
|
|
168
|
+
if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
|
|
169
|
+
const page = Number(target.searchParams.get('page') || '1');
|
|
170
|
+
const user = target.searchParams.get('user') || '';
|
|
171
|
+
if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
|
|
172
|
+
adminReply(response, 400, { error: 'Invalid usage query' }); return;
|
|
173
|
+
}
|
|
174
|
+
adminReply(response, 200, await usageReport(database.pool, page, user)); return;
|
|
175
|
+
}
|
|
154
176
|
if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
|
|
155
177
|
request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
|
|
156
178
|
try {
|
|
@@ -245,10 +267,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
245
267
|
}).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
|
|
246
268
|
};
|
|
247
269
|
ws.on('error', () => {}); // close owns routing cleanup.
|
|
248
|
-
ws.on('close', (code, reason) => {
|
|
270
|
+
ws.on('close', (code, reason) => {
|
|
249
271
|
log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
|
|
250
272
|
clearTimeout(authTimer); clearInterval(heartbeat);
|
|
251
273
|
if (role === 'host' && room?.host === ws) {
|
|
274
|
+
for (const task of room.usageTasks?.values() || []) {
|
|
275
|
+
void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
|
|
276
|
+
}
|
|
277
|
+
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
252
278
|
room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
|
|
253
279
|
room.uploadHost?.close(4410, 'Computer offline');
|
|
254
280
|
for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
|
|
@@ -256,7 +282,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
256
282
|
if (!room.durable) rooms.delete(room.id);
|
|
257
283
|
for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
|
|
258
284
|
} else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
259
|
-
|
|
285
|
+
const connection = room.connections.get(clientId);
|
|
286
|
+
releaseRelay(connection);
|
|
260
287
|
room.connections.delete(clientId);
|
|
261
288
|
if (room.host) send(room.host, { type: 'detach', session });
|
|
262
289
|
} else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
|
|
@@ -323,7 +350,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
323
350
|
}
|
|
324
351
|
const oldHost = room.host;
|
|
325
352
|
room.observers ||= new Map();
|
|
353
|
+
if (oldHost && oldHost !== ws) {
|
|
354
|
+
for (const task of room.usageTasks?.values() || []) {
|
|
355
|
+
try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
356
|
+
}
|
|
357
|
+
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
358
|
+
}
|
|
326
359
|
room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
|
|
360
|
+
room.usageTasks ||= new Map(); room.usageArms ||= new Map();
|
|
327
361
|
if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
|
|
328
362
|
room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
|
|
329
363
|
role = 'host'; clearTimeout(authTimer);
|
|
@@ -382,6 +416,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
382
416
|
return;
|
|
383
417
|
}
|
|
384
418
|
if (!room.host) return fail(4410, 'Computer offline; authorization retained');
|
|
419
|
+
let userId;
|
|
420
|
+
if (accounts && message.accountToken !== undefined) {
|
|
421
|
+
try { userId = (await accounts.current(message.accountToken)).id; }
|
|
422
|
+
catch { return fail(4431, 'Account session invalid'); }
|
|
423
|
+
}
|
|
385
424
|
const oldConnection = room.connections.get(clientId);
|
|
386
425
|
role = 'client'; clearTimeout(authTimer); session = randomUUID();
|
|
387
426
|
if (oldConnection?.ws && oldConnection.ws !== ws) {
|
|
@@ -390,6 +429,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
390
429
|
oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
|
|
391
430
|
}
|
|
392
431
|
room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
|
|
432
|
+
userId,
|
|
393
433
|
repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
|
|
394
434
|
relayAdmitted: false, relayExempt: false,
|
|
395
435
|
capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
|
|
@@ -402,6 +442,67 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
402
442
|
}
|
|
403
443
|
if (role === 'host') {
|
|
404
444
|
if (room.host !== ws) return;
|
|
445
|
+
if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
|
|
446
|
+
if (message.type === 'usage-observer-lost') {
|
|
447
|
+
for (const task of room.usageTasks.values()) {
|
|
448
|
+
try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
449
|
+
}
|
|
450
|
+
room.usageTasks.clear();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const armKey = `${message.session}:${message.requestId}`;
|
|
454
|
+
const connection = [...room.connections.values()].find(item => item.session === message.session);
|
|
455
|
+
if (message.type === 'usage-arm') {
|
|
456
|
+
if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
|
|
457
|
+
(message.threadId !== null && (!plainText(message.threadId, 128, false)))) return;
|
|
458
|
+
for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
|
|
459
|
+
if (room.usageArms.size >= 256) return;
|
|
460
|
+
room.usageArms.set(armKey, { userId: connection.userId,
|
|
461
|
+
clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
|
|
462
|
+
session: message.session, at: Date.now() });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (message.type === 'usage-cancel') { room.usageArms.delete(armKey); return; }
|
|
466
|
+
if (message.type === 'usage-message') {
|
|
467
|
+
const arm = room.usageArms.get(armKey);
|
|
468
|
+
room.usageArms.delete(armKey);
|
|
469
|
+
if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) return;
|
|
470
|
+
try {
|
|
471
|
+
await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
|
|
472
|
+
if (message.turnKind === 'start' && plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
|
|
473
|
+
const key = `${message.threadId}:${message.turnId}`;
|
|
474
|
+
if (key.length <= 128 && !room.usageTasks.has(key)) {
|
|
475
|
+
const task = { userId: arm.userId, clientId: arm.clientId };
|
|
476
|
+
await meter.action(task, room.id, arm.clientId, 'turn', key);
|
|
477
|
+
if (message.observerReady === true) {
|
|
478
|
+
await meter.pulse(task, true, room.id, arm.clientId, key);
|
|
479
|
+
if (message.completed) await meter.pulse(task, false, room.id, arm.clientId);
|
|
480
|
+
else room.usageTasks.set(key, task);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
|
|
488
|
+
!plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
|
|
489
|
+
const key = `${message.threadId}:${message.turnId}`;
|
|
490
|
+
if (key.length > 128) return;
|
|
491
|
+
let task = room.usageTasks.get(key);
|
|
492
|
+
try {
|
|
493
|
+
if (message.method === 'resume' && !task) {
|
|
494
|
+
task = await meter.turnOrigin(room.id, key);
|
|
495
|
+
if (!task) return;
|
|
496
|
+
await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
|
|
497
|
+
room.usageTasks.set(key, task);
|
|
498
|
+
} else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
|
|
499
|
+
else if (task && message.method === 'turn/completed') {
|
|
500
|
+
await meter.pulse(task, false, room.id, task.clientId);
|
|
501
|
+
room.usageTasks.delete(key);
|
|
502
|
+
}
|
|
503
|
+
} catch (error) { log({ event: 'usage-error', message: error.message }); }
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
405
506
|
if (message.type === 'lan-candidates') {
|
|
406
507
|
room.lanCandidates = normalizeLanCandidates(message.candidates);
|
|
407
508
|
for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
|
|
4
|
+
|
|
5
|
+
// Count confirmed intervals only; a lost connection never creates estimated time.
|
|
6
|
+
export function usageMeter(pool) {
|
|
7
|
+
async function action(connection, computerId, clientId, kind, key) {
|
|
8
|
+
if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
|
|
9
|
+
const column = kind === 'message' ? 'message_count' : 'turn_count';
|
|
10
|
+
const db = await pool.getConnection();
|
|
11
|
+
try {
|
|
12
|
+
await db.beginTransaction();
|
|
13
|
+
const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
|
|
14
|
+
(computer_id,kind,action_key,user_id,client_id,occurred_at)
|
|
15
|
+
VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
|
|
16
|
+
[computerId, kind, key, connection.userId, clientId]);
|
|
17
|
+
if (result.affectedRows === 1) {
|
|
18
|
+
await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
|
|
19
|
+
VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
|
|
20
|
+
updated_at=VALUES(updated_at)`, [connection.userId]);
|
|
21
|
+
await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
|
|
22
|
+
VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
|
|
23
|
+
updated_at=VALUES(updated_at)`, [computerId]);
|
|
24
|
+
}
|
|
25
|
+
await db.commit();
|
|
26
|
+
return result.affectedRows === 1;
|
|
27
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
28
|
+
finally { db.release(); }
|
|
29
|
+
}
|
|
30
|
+
async function turnOrigin(computerId, key) {
|
|
31
|
+
const [rows] = await pool.execute(`SELECT user_id,client_id FROM relay_usage_actions
|
|
32
|
+
WHERE computer_id=? AND kind='turn' AND action_key=?`, [computerId, key]);
|
|
33
|
+
return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
|
|
34
|
+
}
|
|
35
|
+
async function pulse(connection, active, computerId, clientId, externalKey = null) {
|
|
36
|
+
if (!connection.userId) return;
|
|
37
|
+
if (!active && !connection.usageSessionId) return;
|
|
38
|
+
if (active && !connection.usageSessionId) {
|
|
39
|
+
const id = randomUUID();
|
|
40
|
+
await pool.execute(`INSERT INTO relay_usage_sessions
|
|
41
|
+
(id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
|
|
42
|
+
VALUES (?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0)`, [id, externalKey, connection.userId, computerId, clientId]);
|
|
43
|
+
connection.usageSessionId = id;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const id = connection.usageSessionId;
|
|
47
|
+
const db = await pool.getConnection();
|
|
48
|
+
try {
|
|
49
|
+
await db.beginTransaction();
|
|
50
|
+
const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
|
|
51
|
+
const row = rows[0];
|
|
52
|
+
if (!row || row.ended_at) throw new Error('Invalid relay usage session');
|
|
53
|
+
const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
|
|
54
|
+
const now = clock[0].now;
|
|
55
|
+
const elapsedMs = Math.max(0, epoch(now) - epoch(row.confirmed_until));
|
|
56
|
+
const seconds = Math.min(elapsedMs, 20000) / 1000;
|
|
57
|
+
const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
|
|
58
|
+
const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
|
|
59
|
+
const freeSeconds = subscribed ? 0 : seconds;
|
|
60
|
+
await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
|
|
61
|
+
free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
|
|
62
|
+
if (seconds > 0) {
|
|
63
|
+
await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
|
|
64
|
+
VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
65
|
+
updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
|
|
66
|
+
await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
|
|
67
|
+
VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
68
|
+
free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
|
|
69
|
+
[row.computer_id, seconds, freeSeconds, now]);
|
|
70
|
+
}
|
|
71
|
+
await db.commit();
|
|
72
|
+
if (!active) connection.usageSessionId = undefined;
|
|
73
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
74
|
+
finally { db.release(); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function lost(connection) {
|
|
78
|
+
const id = connection.usageSessionId;
|
|
79
|
+
connection.usageSessionId = undefined;
|
|
80
|
+
if (!id) return;
|
|
81
|
+
await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
|
|
82
|
+
WHERE id=? AND ended_at IS NULL`, [id]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { action, turnOrigin, pulse, lost };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function usageReport(pool, page, userId = '') {
|
|
89
|
+
if (userId) {
|
|
90
|
+
const [users] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
|
|
91
|
+
COALESCE(t.task_seconds,0) AS task_seconds,
|
|
92
|
+
COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count
|
|
93
|
+
FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
94
|
+
LEFT JOIN relay_usage_totals t ON t.user_id=u.id WHERE u.id=?`, [userId]);
|
|
95
|
+
if (!users.length) return null;
|
|
96
|
+
const [computers] = await pool.execute(`SELECT c.id AS computer_id,
|
|
97
|
+
COALESCE(s.task_seconds,0) AS task_seconds,
|
|
98
|
+
COALESCE(a.message_count,0) AS message_count,
|
|
99
|
+
COALESCE(a.turn_count,0) AS turn_count,
|
|
100
|
+
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
101
|
+
FROM computers c
|
|
102
|
+
LEFT JOIN (SELECT computer_id,
|
|
103
|
+
SUM(charged_seconds) AS task_seconds,
|
|
104
|
+
MAX(started_at) AS last_used_at FROM relay_usage_sessions WHERE user_id=? GROUP BY computer_id) s ON s.computer_id=c.id
|
|
105
|
+
LEFT JOIN (SELECT computer_id,
|
|
106
|
+
SUM(kind='message') AS message_count,SUM(kind='turn') AS turn_count,
|
|
107
|
+
MAX(occurred_at) AS last_used_at
|
|
108
|
+
FROM relay_usage_actions WHERE user_id=? GROUP BY computer_id) a ON a.computer_id=c.id
|
|
109
|
+
WHERE s.computer_id IS NOT NULL OR a.computer_id IS NOT NULL
|
|
110
|
+
ORDER BY last_used_at DESC`, [userId, userId]);
|
|
111
|
+
return { user: users[0], computers };
|
|
112
|
+
}
|
|
113
|
+
const offset = (page - 1) * 30;
|
|
114
|
+
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM user_login_identities WHERE kind='email'`);
|
|
115
|
+
const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
|
|
116
|
+
COALESCE(t.task_seconds,0) AS task_seconds,
|
|
117
|
+
COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count,
|
|
118
|
+
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
119
|
+
FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
120
|
+
LEFT JOIN relay_usage_totals t ON t.user_id=u.id
|
|
121
|
+
LEFT JOIN (SELECT user_id,MAX(started_at) AS last_used_at FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
|
|
122
|
+
LEFT JOIN (SELECT user_id,MAX(occurred_at) AS last_used_at FROM relay_usage_actions GROUP BY user_id) a ON a.user_id=u.id
|
|
123
|
+
ORDER BY task_seconds DESC,last_used_at DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
124
|
+
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
125
|
+
}
|
package/dist/src/server.mjs
CHANGED
|
@@ -283,7 +283,7 @@ async function handleRequest(request, response) {
|
|
|
283
283
|
return;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
if (request.method === 'GET' && url.pathname === '/api/usage-events') {
|
|
286
|
+
if (request.method === 'GET' && url.pathname === '/api/usage-events') {
|
|
287
287
|
const execution = sharedServer;
|
|
288
288
|
response.startEvents();
|
|
289
289
|
const emit = value => { if (!response.destroyed) response.event('usage', value); };
|
|
@@ -292,8 +292,45 @@ async function handleRequest(request, response) {
|
|
|
292
292
|
execution.on('usage', emit); execution.on('usage-error', failed); execution.on('disconnected', disconnected);
|
|
293
293
|
response.once('close', () => { execution.off('usage', emit); execution.off('usage-error', failed); execution.off('disconnected', disconnected); });
|
|
294
294
|
try { emit(await execution.usage()); } catch (error) { failed(error); }
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (request.method === 'GET' && url.pathname === '/api/turn-events') {
|
|
299
|
+
response.startEvents();
|
|
300
|
+
const listener = ({ method, params }) => {
|
|
301
|
+
if (!['turn/started', 'turn/completed'].includes(method)) return;
|
|
302
|
+
const threadId = params?.threadId, turnId = params?.turn?.id;
|
|
303
|
+
if (typeof threadId === 'string' && typeof turnId === 'string' && !response.destroyed) {
|
|
304
|
+
response.event('turn', { method, threadId, turnId });
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const disconnected = () => response.end();
|
|
308
|
+
sharedServer.on('notification', listener);
|
|
309
|
+
sharedServer.on('disconnected', disconnected);
|
|
310
|
+
response.once('close', () => {
|
|
311
|
+
sharedServer.off('notification', listener);
|
|
312
|
+
sharedServer.off('disconnected', disconnected);
|
|
313
|
+
});
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
|
|
318
|
+
if (request.method === 'GET' && activeTurnMatch) {
|
|
319
|
+
const threadId = decodeURIComponent(activeTurnMatch[1]);
|
|
320
|
+
await sharedServer.connect();
|
|
321
|
+
const { thread } = await sharedServer.request('thread/read', { threadId, includeTurns: false });
|
|
322
|
+
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
323
|
+
let turnId = null;
|
|
324
|
+
if (status === 'active') {
|
|
325
|
+
const page = await sharedServer.request('thread/turns/list', {
|
|
326
|
+
threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
327
|
+
});
|
|
328
|
+
const latest = page.data?.[0];
|
|
329
|
+
if (latest?.status === 'inProgress') turnId = latest.id;
|
|
330
|
+
}
|
|
331
|
+
sendJson(response, 200, { turnId });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
297
334
|
|
|
298
335
|
const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
|
|
299
336
|
if (request.method === 'POST' && createThreadMatch) {
|
|
@@ -473,7 +510,8 @@ async function handleRequest(request, response) {
|
|
|
473
510
|
await requireExecutionWritable(threadId);
|
|
474
511
|
const body = await readJsonBody(request);
|
|
475
512
|
if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
|
|
476
|
-
|
|
513
|
+
const submitted = await sharedServer.send(appServerTurnArguments(threadId, body));
|
|
514
|
+
sendJson(response, 200, { success: true, data: submitted.result, usage: submitted.usage });
|
|
477
515
|
return;
|
|
478
516
|
}
|
|
479
517
|
|
|
@@ -505,7 +543,7 @@ void managedAppServer.start();
|
|
|
505
543
|
executionHealth.start();
|
|
506
544
|
runtimeDiscovery.start();
|
|
507
545
|
server.on('error', error => {
|
|
508
|
-
console.error('
|
|
546
|
+
console.error('桌面服务监听失败:', error.message);
|
|
509
547
|
process.exit(1);
|
|
510
548
|
});
|
|
511
549
|
server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
|
|
@@ -528,10 +566,10 @@ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
|
|
|
528
566
|
pid: process.pid, controlPort: control.port });
|
|
529
567
|
}
|
|
530
568
|
} catch (error) {
|
|
531
|
-
console.error('
|
|
569
|
+
console.error('桌面服务管理通道启动失败:', error.message);
|
|
532
570
|
process.exit(1);
|
|
533
571
|
}
|
|
534
|
-
console.log(`Code Relax
|
|
572
|
+
console.log(`Code Relax 桌面服务已启动:http://127.0.0.1:${options.port}/`);
|
|
535
573
|
if (options.lan) {
|
|
536
574
|
console.log('LAN 模式使用鉴权 WebSocket,仅适合受信任局域网测试,不要暴露到公网。');
|
|
537
575
|
}
|
|
@@ -711,12 +749,12 @@ function messagePayload(body) {
|
|
|
711
749
|
return payload;
|
|
712
750
|
}
|
|
713
751
|
|
|
714
|
-
function appServerTurnArguments(threadId, body) {
|
|
752
|
+
function appServerTurnArguments(threadId, body) {
|
|
715
753
|
const payload = messagePayload(body);
|
|
716
754
|
const input = [];
|
|
717
755
|
if (payload.prompt) input.push({ type: 'text', text: payload.prompt });
|
|
718
756
|
for (const image of payload.images) input.push(image.startsWith('data:') ? { type: 'image', url: image } : { type: 'localImage', path: image });
|
|
719
|
-
const value = { threadId, input };
|
|
757
|
+
const value = { threadId, input, ...appServerEvents.mobileTurnSource(body.clientUserMessageId) };
|
|
720
758
|
if (typeof body.clientUserMessageId === 'string' && /^[a-f0-9]{32}$/.test(body.clientUserMessageId)) value.clientUserMessageId = body.clientUserMessageId;
|
|
721
759
|
if (payload.model) value.model = payload.model;
|
|
722
760
|
if (payload.thinking) value.effort = payload.thinking;
|
|
@@ -29,7 +29,7 @@ export async function openControl({ instanceId, secret, shutdown, status = () =>
|
|
|
29
29
|
}
|
|
30
30
|
if (request.method === 'POST') response.once('finish', () => {
|
|
31
31
|
Promise.resolve(shutdown()).catch(error => {
|
|
32
|
-
console.error('
|
|
32
|
+
console.error('桌面服务关闭失败:', error.message);
|
|
33
33
|
process.exitCode = 1;
|
|
34
34
|
});
|
|
35
35
|
});
|
|
@@ -56,16 +56,16 @@ export function controlRequest(session, method = 'GET', action = '') {
|
|
|
56
56
|
response.on('end', () => {
|
|
57
57
|
try {
|
|
58
58
|
if (response.statusCode === 409) throw new Error(JSON.parse(body).error);
|
|
59
|
-
if (response.statusCode !== 200) throw new Error('
|
|
59
|
+
if (response.statusCode !== 200) throw new Error('桌面服务管理通道身份验证失败。');
|
|
60
60
|
const identity = JSON.parse(body);
|
|
61
61
|
if (identity.instanceId !== session.instanceId || identity.pid !== session.pid) {
|
|
62
|
-
throw new Error('
|
|
62
|
+
throw new Error('桌面服务实例身份不匹配。');
|
|
63
63
|
}
|
|
64
64
|
resolve(identity);
|
|
65
65
|
} catch (error) { reject(error); }
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
-
request.on('timeout', () => request.destroy(new Error('
|
|
68
|
+
request.on('timeout', () => request.destroy(new Error('桌面服务管理通道响应超时。')));
|
|
69
69
|
request.on('error', reject);
|
|
70
70
|
request.end();
|
|
71
71
|
});
|
|
@@ -20,7 +20,7 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
|
|
|
20
20
|
});
|
|
21
21
|
await check('bridge-runtime', async () => {
|
|
22
22
|
await host.bridgeEnvironment();
|
|
23
|
-
add('bridge-runtime', 'ok', '
|
|
23
|
+
add('bridge-runtime', 'ok', '桌面服务启动环境已就绪;实际任务操作尚未验证。');
|
|
24
24
|
});
|
|
25
25
|
await check('network', async () => {
|
|
26
26
|
const address = await host.lanAddress();
|
|
@@ -37,10 +37,10 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
|
|
|
37
37
|
? `本机服务正常:${state.mode},端口 ${state.port},运行版本:${state.version || '未知'}` : '服务未就绪;安装后执行 relax start。');
|
|
38
38
|
if (state.running && installedVersion && state.version !== installedVersion) add('service-version', 'warning', state.version
|
|
39
39
|
? `已安装版本 ${installedVersion},但当前运行版本为 ${state.version};请重新执行安装或重启工作站服务。`
|
|
40
|
-
: `已安装版本 ${installedVersion}
|
|
40
|
+
: `已安装版本 ${installedVersion},但当前运行的桌面服务未报告版本,可能仍是旧进程;请重新执行安装或重启桌面服务。`);
|
|
41
41
|
if (state.running) add('managed-app-server', state.managedAppServer?.state === 'ready' ? 'ok' : 'error',
|
|
42
|
-
state.managedAppServer?.state === 'ready' ? '
|
|
43
|
-
: state.managedAppServer?.error || '
|
|
42
|
+
state.managedAppServer?.state === 'ready' ? 'Codex 内核已就绪。'
|
|
43
|
+
: state.managedAppServer?.error || 'Codex 内核尚未就绪。');
|
|
44
44
|
if (state.managedAppServer?.updatePending) add('managed-app-server-update', 'warning',
|
|
45
45
|
'已选择另一版本 CLI;当前 app-server 继续完成任务,下次内核启动时采用选定版本。');
|
|
46
46
|
if (state.managedAppServer?.launcherRestartRequired) add('managed-app-server-launcher', 'warning',
|
|
@@ -14,7 +14,7 @@ export function serviceHealth(session) {
|
|
|
14
14
|
socket.terminate();
|
|
15
15
|
if (error) reject(error); else resolve(value);
|
|
16
16
|
};
|
|
17
|
-
const timer = setTimeout(() => finish(new Error('
|
|
17
|
+
const timer = setTimeout(() => finish(new Error('桌面服务健康检查超时。')), 6000);
|
|
18
18
|
socket.on('open', () => socket.send(JSON.stringify({ type: 'auth', token })));
|
|
19
19
|
socket.on('message', data => {
|
|
20
20
|
try {
|
|
@@ -28,6 +28,6 @@ export function serviceHealth(session) {
|
|
|
28
28
|
});
|
|
29
29
|
socket.on('error', error => finish(error.code === 'ECONNREFUSED' ? null : error));
|
|
30
30
|
socket.on('close', code => finish(new Error(code === 1008
|
|
31
|
-
? '
|
|
31
|
+
? '桌面服务健康检查鉴权失败。' : '桌面服务健康检查连接已断开。')));
|
|
32
32
|
});
|
|
33
33
|
}
|
|
@@ -27,7 +27,7 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
27
27
|
|
|
28
28
|
async function alive(session) {
|
|
29
29
|
if (!session?.controlPort || !session.instanceId || !session.controlSecret) {
|
|
30
|
-
if (await health(session)) throw new Error('
|
|
30
|
+
if (await health(session)) throw new Error('当前为旧版桌面服务,请先使用旧版停止入口结束服务,再启动新版。');
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
try { await controlRequest(session); return true; }
|
|
@@ -35,7 +35,7 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
35
35
|
}
|
|
36
36
|
async function stop() {
|
|
37
37
|
const session = readRecord(sessionPath);
|
|
38
|
-
if (!await alive(session)) return { stopped: true, message: '
|
|
38
|
+
if (!await alive(session)) return { stopped: true, message: '桌面服务已停止。' };
|
|
39
39
|
await controlRequest(session, 'POST');
|
|
40
40
|
const deadline = Date.now() + 10000;
|
|
41
41
|
while (true) {
|
|
@@ -45,10 +45,10 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
45
45
|
// Verify refusal on the next connection; never resend the shutdown request.
|
|
46
46
|
if (error.code !== 'ECONNRESET') throw error;
|
|
47
47
|
}
|
|
48
|
-
if (Date.now() > deadline) throw new Error('
|
|
48
|
+
if (Date.now() > deadline) throw new Error('桌面服务未在 10 秒内退出;未强杀进程。');
|
|
49
49
|
await delay(100);
|
|
50
50
|
}
|
|
51
|
-
return { stopped: true, message: '
|
|
51
|
+
return { stopped: true, message: '桌面服务已正常关闭。' };
|
|
52
52
|
}
|
|
53
53
|
async function status() {
|
|
54
54
|
const session = readRecord(sessionPath);
|
|
@@ -78,7 +78,7 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
78
78
|
}
|
|
79
79
|
async function restartManagedAppServer() {
|
|
80
80
|
const session = readRecord(sessionPath);
|
|
81
|
-
if (!await alive(session)) throw new Error('
|
|
81
|
+
if (!await alive(session)) throw new Error('桌面服务尚未运行,无法迁移 Codex 内核。');
|
|
82
82
|
return controlRequest(session, 'POST', 'restart-managed-app-server');
|
|
83
83
|
}
|
|
84
84
|
async function start() {
|
|
@@ -110,11 +110,11 @@ export function lifecycle({ stateRoot, host }) {
|
|
|
110
110
|
const deadline = Date.now() + 10000;
|
|
111
111
|
let ready;
|
|
112
112
|
while (!(ready = readRecord(readyPath))) {
|
|
113
|
-
if (!processAlive(pid)) throw new Error('
|
|
114
|
-
if (Date.now() > deadline) throw new Error('
|
|
113
|
+
if (!processAlive(pid)) throw new Error('桌面服务启动失败,请查看 bridge.stderr.log。');
|
|
114
|
+
if (Date.now() > deadline) throw new Error('桌面服务未在 10 秒内就绪。');
|
|
115
115
|
await delay(100);
|
|
116
116
|
}
|
|
117
|
-
if (ready.instanceId !== instanceId || ready.pid !== pid) throw new Error('
|
|
117
|
+
if (ready.instanceId !== instanceId || ready.pid !== pid) throw new Error('桌面服务启动实例不匹配。');
|
|
118
118
|
session = { ...ready, controlSecret, executablePath: executable, scriptPath, port: config.port,
|
|
119
119
|
lan: true, appServer: false, accessUrl: `http://${address}:${config.port}/?token=${config.token}`,
|
|
120
120
|
startedAt: new Date().toISOString() };
|
|
@@ -136,15 +136,18 @@ export class SharedAppServer extends EventEmitter {
|
|
|
136
136
|
// Reading the live thread checks this service owns it without resume changing settings.
|
|
137
137
|
const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
|
|
138
138
|
const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
|
|
139
|
-
if (status === 'active') {
|
|
139
|
+
if (status === 'active') {
|
|
140
140
|
const page = await this.request('thread/turns/list', {
|
|
141
141
|
threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
|
|
142
142
|
});
|
|
143
143
|
const active = page.data?.[0];
|
|
144
144
|
if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId);
|
|
146
|
+
const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
|
|
147
|
+
return { result, usage: { kind: 'steer', turnId: active.id } };
|
|
148
|
+
}
|
|
149
|
+
const result = await this.request('turn/start', args);
|
|
150
|
+
return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
respondApproval(threadId, body) {
|
|
@@ -111,7 +111,7 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
|
|
|
111
111
|
await waitFor(async () => {
|
|
112
112
|
const health = await service.status();
|
|
113
113
|
return health.running && health.mode === 'lan-shared' && health.execution?.ready === true;
|
|
114
|
-
}, 'Codex
|
|
114
|
+
}, 'Codex 内核未就绪;桌面服务和中继连接仍保留。');
|
|
115
115
|
record = { ...record, status: 'completed', stage: 'ready', steps: record.steps.map(step => ({ ...step, status: 'completed' })),
|
|
116
116
|
message: 'Codex Desktop 已接入 Codex 内核;移动端控制保持就绪,原任务未自动重发。' };
|
|
117
117
|
} catch (error) { record = { ...record, status: 'failed', error: error.message,
|
package/package.json
CHANGED
package/tools/postinstall.mjs
CHANGED
|
@@ -59,9 +59,9 @@ export function formatSetupOutput(output, previous = {}) {
|
|
|
59
59
|
? `未重启(PID ${pid},启动代次 ${migration.launcherRevision ?? '未知'} 已是最新)`
|
|
60
60
|
: `已启动(PID ${pid},启动代次 ${migration.launcherRevision ?? '未知'})`;
|
|
61
61
|
return `Code Relax ${result.version || '未知版本'} 安装成功。\n`
|
|
62
|
-
+
|
|
63
|
-
+
|
|
64
|
-
+ `${result.desktopRestarted ? `Codex Desktop:已干净重启并重新接入(${result.desktopRestartReason === 'attach-managed-server' ? '
|
|
62
|
+
+ `Codex 内核:${appServer}\n`
|
|
63
|
+
+ `桌面服务:${result.restarted ? '已恢复并健康' : '已启动并健康'}\n`
|
|
64
|
+
+ `${result.desktopRestarted ? `Codex Desktop:已干净重启并重新接入(${result.desktopRestartReason === 'attach-managed-server' ? '接入 Codex 内核' : '内核迁移'})\n` : ''}`
|
|
65
65
|
+ `${result.desktopRestartRecommended ? 'Codex Desktop:建议完全退出后重新打开。\n' : ''}`;
|
|
66
66
|
} catch { return output; }
|
|
67
67
|
}
|