@walkhi/code-relax 0.1.0-beta.50 → 0.1.0-beta.59
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 +105 -105
- package/dist/bin/cleanup-accounts.mjs +63 -63
- package/dist/bin/codex-remote.mjs +403 -403
- package/dist/bin/publish-android-release.mjs +63 -63
- package/dist/bin/release-upload-server.mjs +190 -190
- package/dist/bin/set-relay-admin-password.mjs +11 -0
- package/dist/build-platform.json +1 -1
- package/dist/get-shared-service-url.ps1 +14 -14
- package/dist/shared/code-browser.cjs +159 -159
- package/dist/shared/file-changes.cjs +31 -31
- package/dist/shared/file-citations.cjs +117 -117
- package/dist/shared/file-mentions.cjs +92 -92
- package/dist/shared/message-delivery.cjs +115 -115
- package/dist/shared/p2p-file-channel.cjs +87 -87
- package/dist/shared/release-version.cjs +31 -0
- package/dist/shared/server-requests.cjs +311 -311
- package/dist/shared/timeline-sync.cjs +173 -173
- package/dist/skills/code-relax/SKILL.md +22 -22
- package/dist/src/bounded-process-log.mjs +63 -63
- package/dist/src/celsius-client.mjs +107 -107
- package/dist/src/dots-approval-timeline.mjs +99 -99
- package/dist/src/dots-avatar.mjs +42 -42
- package/dist/src/dots-chat-projection.mjs +50 -50
- package/dist/src/dots-chat-server.mjs +483 -483
- package/dist/src/dots-message-images.mjs +319 -319
- package/dist/src/dots-profile.mjs +77 -77
- package/dist/src/dots-room-activity.mjs +172 -172
- package/dist/src/dots-room-client.mjs +137 -135
- package/dist/src/dots-room-projection.mjs +94 -94
- package/dist/src/dots-schedules.mjs +66 -66
- package/dist/src/dots-summary-tasks.mjs +32 -32
- package/dist/src/dots-task-catalog.mjs +430 -430
- package/dist/src/file-transfer-range.mjs +22 -22
- package/dist/src/file-upload-session.mjs +146 -130
- package/dist/src/hosted-history-app-server.mjs +103 -103
- package/dist/src/hosted-history-cache.mjs +113 -113
- package/dist/src/lan-socket-server.mjs +338 -266
- package/dist/src/managed-app-server.mjs +373 -373
- package/dist/src/managed-endpoint.mjs +25 -25
- package/dist/src/message-upload-files.mjs +109 -109
- package/dist/src/onboarding.mjs +12 -12
- package/dist/src/platform/README.md +1 -1
- package/dist/src/platform/codex-protocol.mjs +46 -46
- package/dist/src/platform/desktop-project-store.mjs +219 -208
- package/dist/src/platform/linux/desktop-host.mjs +87 -87
- package/dist/src/platform/linux/process.mjs +53 -53
- package/dist/src/platform/linux/system.mjs +50 -50
- package/dist/src/platform/linux/user-environment.mjs +9 -9
- package/dist/src/platform/macos/codex-runtime.mjs +125 -125
- package/dist/src/platform/macos/desktop-host.mjs +86 -86
- package/dist/src/platform/macos/user-environment.mjs +9 -9
- package/dist/src/platform/native-package.mjs +58 -58
- package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
- package/dist/src/platform/windows/codex-runtime.mjs +215 -215
- package/dist/src/platform/windows/credential-store.mjs +27 -27
- package/dist/src/platform/windows/credential-store.ps1 +49 -49
- package/dist/src/platform/windows/desktop-host.mjs +65 -65
- package/dist/src/platform/windows/desktop-info.ps1 +38 -38
- package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
- package/dist/src/platform/windows/dots-profile.mjs +49 -49
- package/dist/src/platform/windows/launch-worker.mjs +41 -41
- package/dist/src/platform/windows/project-locations.mjs +16 -16
- package/dist/src/platform/windows/service-host.mjs +35 -35
- package/dist/src/platform/windows/user-environment.mjs +52 -52
- package/dist/src/project-management.mjs +58 -58
- package/dist/src/runtime-discovery.mjs +47 -47
- package/dist/src/self-relay/account-cleanup.mjs +30 -30
- package/dist/src/self-relay/admin-auth.mjs +59 -0
- package/dist/src/self-relay/admin-login-page.mjs +7 -0
- package/dist/src/self-relay/admin-page.mjs +87 -80
- package/dist/src/self-relay/admin-server.mjs +49 -18
- package/dist/src/self-relay/admin-style.mjs +58 -14
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
- package/dist/src/self-relay/admin-workstations.mjs +143 -0
- package/dist/src/self-relay/connection-reporting.mjs +38 -0
- package/dist/src/self-relay/connector.mjs +444 -439
- package/dist/src/self-relay/demo.mjs +31 -31
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +183 -183
- package/dist/src/self-relay/file-wire.mjs +13 -13
- package/dist/src/self-relay/lifecycle.mjs +11 -0
- package/dist/src/self-relay/p2p-probe.mjs +168 -168
- package/dist/src/self-relay/phone-connections.mjs +55 -0
- package/dist/src/self-relay/privacy-page.mjs +26 -26
- package/dist/src/self-relay/public-releases.mjs +67 -67
- package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
- package/dist/src/self-relay/server.mjs +63 -3
- package/dist/src/self-relay/usage-meter.mjs +141 -141
- package/dist/src/server.mjs +23 -9
- package/dist/src/service-doctor.mjs +148 -148
- package/dist/src/service-install.mjs +97 -97
- package/dist/src/service-lifecycle.mjs +139 -139
- package/dist/src/service-update.mjs +6 -3
- package/dist/src/shared-app-server.mjs +683 -666
- package/dist/src/shared-catalog.mjs +132 -132
- package/dist/src/shared-thread-stream.mjs +165 -165
- package/dist/src/usage-reset.mjs +17 -17
- package/dist/src/workstation-downloads.mjs +120 -120
- package/package.json +4 -4
- package/tools/postinstall.mjs +186 -186
|
@@ -1,41 +1,46 @@
|
|
|
1
|
-
import WebSocket from 'ws';
|
|
2
|
-
import { loopbackUrl } from './wire.mjs';
|
|
3
|
-
|
|
4
|
-
// The RTC peer belongs to an authenticated relay chat session. Its teardown
|
|
5
|
-
// closes this channel on logout, expiry, revocation or signaling disconnect.
|
|
6
|
-
export function attachRtcFile(channel, connection, authorized) {
|
|
7
|
-
let local, authenticated = false;
|
|
8
|
-
const timer = setTimeout(() => channel.close(1008), 5000);
|
|
9
|
-
channel.addEventListener('close', () => { clearTimeout(timer); local?.terminate(); });
|
|
10
|
-
channel.onmessage = event => {
|
|
11
|
-
try {
|
|
12
|
-
if (!authorized()) throw new Error('File authorization expired');
|
|
13
|
-
if (!local) {
|
|
14
|
-
if (typeof event.data !== 'string') throw new Error('File authentication required');
|
|
15
|
-
const auth = JSON.parse(event.data);
|
|
16
|
-
if (auth.type !== 'auth' || !['upload', 'download'].includes(auth.purpose)) throw new Error('Invalid file purpose');
|
|
17
|
-
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
18
|
-
loopbackUrl(url.href, '/api/file-transfer');
|
|
19
|
-
local = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
20
|
-
local.on('open', () => {
|
|
21
|
-
if (!authorized() || channel.readyState !== 1) { local.terminate(); return; }
|
|
22
|
-
local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { loopbackUrl } from './wire.mjs';
|
|
3
|
+
|
|
4
|
+
// The RTC peer belongs to an authenticated relay chat session. Its teardown
|
|
5
|
+
// closes this channel on logout, expiry, revocation or signaling disconnect.
|
|
6
|
+
export function attachRtcFile(channel, connection, authorized, clientId) {
|
|
7
|
+
let local, authenticated = false;
|
|
8
|
+
const timer = setTimeout(() => channel.close(1008), 5000);
|
|
9
|
+
channel.addEventListener('close', () => { clearTimeout(timer); local?.terminate(); });
|
|
10
|
+
channel.onmessage = event => {
|
|
11
|
+
try {
|
|
12
|
+
if (!authorized()) throw new Error('File authorization expired');
|
|
13
|
+
if (!local) {
|
|
14
|
+
if (typeof event.data !== 'string') throw new Error('File authentication required');
|
|
15
|
+
const auth = JSON.parse(event.data);
|
|
16
|
+
if (auth.type !== 'auth' || !['upload', 'download'].includes(auth.purpose)) throw new Error('Invalid file purpose');
|
|
17
|
+
const url = new URL(connection.url); url.pathname = '/api/file-transfer';
|
|
18
|
+
loopbackUrl(url.href, '/api/file-transfer');
|
|
19
|
+
local = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
|
|
20
|
+
local.on('open', () => {
|
|
21
|
+
if (!authorized() || channel.readyState !== 1) { local.terminate(); return; }
|
|
22
|
+
local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose,
|
|
23
|
+
uploadSessionId: auth.uploadSessionId, resumeUpload: auth.resumeUpload, relayClientId: clientId }));
|
|
24
|
+
});
|
|
25
|
+
local.on('message', (bytes, binary) => {
|
|
26
|
+
if (!authorized() || channel.readyState !== 1) { channel.close(4401); return; }
|
|
27
|
+
try {
|
|
28
|
+
if (!authenticated) {
|
|
29
|
+
const response = binary ? null : JSON.parse(bytes.toString());
|
|
30
|
+
if (response?.type === 'error') {
|
|
31
|
+
channel.send(bytes.toString()); clearTimeout(timer); return;
|
|
32
|
+
}
|
|
33
|
+
if (response?.type !== 'ready') throw new Error('File authentication failed');
|
|
34
|
+
authenticated = true; clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
channel.send(binary ? bytes : bytes.toString());
|
|
37
|
+
} catch { channel.close(1011); }
|
|
38
|
+
});
|
|
39
|
+
local.on('close', () => channel.close(1011)); local.on('error', () => channel.close(1011));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!authenticated || local.readyState !== WebSocket.OPEN || local.bufferedAmount > 1024 * 1024) throw new Error('File channel unavailable');
|
|
43
|
+
local.send(typeof event.data === 'string' ? event.data : Buffer.from(event.data));
|
|
44
|
+
} catch { channel.close(1008); }
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -19,6 +19,8 @@ import { accountConnections } from './account-connections.mjs';
|
|
|
19
19
|
import { usageMeter, usageReport } from './usage-meter.mjs';
|
|
20
20
|
import { relayDiagnostics } from './diagnostics.mjs';
|
|
21
21
|
import { connectionInfo, deviceInfo } from './device-info.mjs';
|
|
22
|
+
import { listWorkstations, workstationDetail, workstationSummary, resolveWorkstationAccounts, resolveWorkstationConnections } from './admin-workstations.mjs';
|
|
23
|
+
import { phoneConnectionEvents, validPhoneConnection } from './phone-connections.mjs';
|
|
22
24
|
|
|
23
25
|
// Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
|
|
24
26
|
export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
|
|
@@ -57,6 +59,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
57
59
|
const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
|
|
58
60
|
if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
|
|
59
61
|
const store = database?.devices || deviceStore(stateFile);
|
|
62
|
+
const phoneEvents = phoneConnectionEvents(database?.pool);
|
|
60
63
|
const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
|
|
61
64
|
const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
|
|
62
65
|
let activeRelaySessions = 0;
|
|
@@ -295,6 +298,21 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
295
298
|
const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
|
|
296
299
|
timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
|
|
297
300
|
if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
|
|
301
|
+
if (request.method === 'GET' && target.pathname === '/admin/api/workstations') {
|
|
302
|
+
const workstations = await resolveWorkstationAccounts(listWorkstations(rooms, { details: true }), database?.pool);
|
|
303
|
+
await resolveWorkstationConnections(workstations, phoneEvents);
|
|
304
|
+
adminReply(response, 200, { sampledAt: new Date().toISOString(), workstations: workstations.map(workstationSummary) }); return;
|
|
305
|
+
}
|
|
306
|
+
if (request.method === 'GET' && target.pathname.startsWith('/admin/api/workstations/')) {
|
|
307
|
+
const id = target.pathname.slice('/admin/api/workstations/'.length);
|
|
308
|
+
const room = validId(id) && rooms.get(id);
|
|
309
|
+
if (!room) { adminReply(response, 404, { error: '未找到工作站' }); return; }
|
|
310
|
+
const [workstation] = await resolveWorkstationAccounts([
|
|
311
|
+
workstationDetail(room, debugAllowlist.devices.some(entry => entry.deviceId === room.id))], database?.pool);
|
|
312
|
+
await resolveWorkstationConnections([workstation], phoneEvents);
|
|
313
|
+
workstation.connectionEvents = await phoneEvents.list(room.id);
|
|
314
|
+
adminReply(response, 200, { sampledAt: new Date().toISOString(), workstation }); return;
|
|
315
|
+
}
|
|
298
316
|
if (request.method === 'GET' && target.pathname === '/admin/api/status') {
|
|
299
317
|
const state = adminState.read();
|
|
300
318
|
adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
|
|
@@ -402,10 +420,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
402
420
|
if (wss.clients.size >= maxConnections || ++admissions > 240) {
|
|
403
421
|
socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
|
|
404
422
|
}
|
|
405
|
-
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
|
|
423
|
+
wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request));
|
|
406
424
|
});
|
|
407
|
-
wss.on('connection', ws => {
|
|
425
|
+
wss.on('connection', (ws, request) => {
|
|
426
|
+
const address = request.headers['x-real-ip'];
|
|
427
|
+
ws.adminInfo = { connectedAt: new Date().toISOString(),
|
|
428
|
+
address: typeof address === 'string' && isIP(address) ? address : request.socket.remoteAddress };
|
|
408
429
|
let role, room, session, clientId, alive = true;
|
|
430
|
+
const phoneEventIds = new Set();
|
|
409
431
|
let accountConnection;
|
|
410
432
|
function invalidateAccountConnection() {
|
|
411
433
|
if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
|
|
@@ -533,6 +555,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
533
555
|
const user = await accounts.current(message.accountToken);
|
|
534
556
|
if (!accountConnection.activate(user.expiresAt)) return;
|
|
535
557
|
userId = user.id;
|
|
558
|
+
ws.adminInfo.account = { id: user.id, email: user.email };
|
|
536
559
|
}
|
|
537
560
|
catch { return fail(4431, 'Account session invalid'); }
|
|
538
561
|
if (ws.readyState !== ws.OPEN) return;
|
|
@@ -591,6 +614,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
591
614
|
room.usageTasks?.clear(); room.usageArms?.clear();
|
|
592
615
|
}
|
|
593
616
|
room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
|
|
617
|
+
room.adminPhoneConnections = undefined;
|
|
618
|
+
room.adminLastOnlineAt = ws.adminInfo.connectedAt; room.adminLastHostName = hostName;
|
|
594
619
|
room.usageTasks ||= new Map(); room.usageArms ||= new Map();
|
|
595
620
|
if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
|
|
596
621
|
room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
|
|
@@ -682,7 +707,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
682
707
|
if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
|
|
683
708
|
const announcement = adminState.read().announcement;
|
|
684
709
|
if (announcement) send(ws, { type: 'announcement', announcement });
|
|
685
|
-
send(room.host, { type: 'attach', session, clientId,
|
|
710
|
+
send(room.host, { type: 'attach', session, clientId, chat: Boolean(userId),
|
|
711
|
+
p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
|
|
686
712
|
}
|
|
687
713
|
if (role === 'device-manager') {
|
|
688
714
|
if (!room.clients.some(item => item.id === clientId)) return fail(4401, 'Device authorization revoked');
|
|
@@ -690,12 +716,44 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
690
716
|
await manageDevices(room, ws, message, clientId); return;
|
|
691
717
|
}
|
|
692
718
|
if (role === 'host') {
|
|
719
|
+
if (['phone-connection', 'phone-connections'].includes(message.type)) {
|
|
720
|
+
if (room.host !== ws) return;
|
|
721
|
+
if (message.type === 'phone-connections') {
|
|
722
|
+
if (!Array.isArray(message.active) || message.active.length > 128 ||
|
|
723
|
+
message.active.some(item => !validPhoneConnection(item))) return fail(1008, 'Invalid phone connection snapshot');
|
|
724
|
+
room.adminPhoneConnections = new Map(message.active.filter(item => room.clients.some(client => client.id === item.clientId))
|
|
725
|
+
.map(item => [item.connectionId, { connectionId: item.connectionId, clientId: item.clientId, mode: item.mode }]));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
const event = message.event;
|
|
729
|
+
if (!validPhoneConnection(event)) return fail(1008, 'Invalid phone connection event');
|
|
730
|
+
// Revocation can overtake the workstation's final close event.
|
|
731
|
+
if (!room.clients.some(client => client.id === event.clientId)) {
|
|
732
|
+
send(ws, { type: 'phone-connection-ack', id: event.id }); return;
|
|
733
|
+
}
|
|
734
|
+
try { await phoneEvents.record(room.id, event); }
|
|
735
|
+
catch (error) { log({ event: 'phone-connection-error', message: error.message }); return; }
|
|
736
|
+
if (room.host !== ws) return;
|
|
737
|
+
if (phoneEventIds.has(event.id)) { send(ws, { type: 'phone-connection-ack', id: event.id }); return; }
|
|
738
|
+
phoneEventIds.add(event.id);
|
|
739
|
+
if (phoneEventIds.size > 256) phoneEventIds.delete(phoneEventIds.values().next().value);
|
|
740
|
+
room.adminPhoneConnections ||= new Map();
|
|
741
|
+
if (event.state === 'connected') room.adminPhoneConnections.set(event.connectionId,
|
|
742
|
+
{ connectionId: event.connectionId, clientId: event.clientId, mode: event.mode });
|
|
743
|
+
else room.adminPhoneConnections.delete(event.connectionId);
|
|
744
|
+
send(ws, { type: 'phone-connection-ack', id: event.id }); return;
|
|
745
|
+
}
|
|
693
746
|
if (room.host !== ws) return;
|
|
694
747
|
if (['devices-list', 'device-revoke'].includes(message.type)) { await manageDevices(room, ws, message); return; }
|
|
695
748
|
if (message.type === 'device-connected') {
|
|
696
749
|
const at = new Date(Math.min(Date.parse(message.at), Date.now())).toISOString();
|
|
697
750
|
if (!validId(message.clientId)) return fail(1008, 'Invalid mobile identity');
|
|
698
751
|
await recordConnection(room, message.clientId, at, deviceInfo(message));
|
|
752
|
+
room.adminLanConnections ||= new Map();
|
|
753
|
+
if (room.clients.some(client => client.id === message.clientId) &&
|
|
754
|
+
(!room.adminLanConnections.has(message.clientId) || room.adminLanConnections.get(message.clientId) < at)) {
|
|
755
|
+
room.adminLanConnections.set(message.clientId, at);
|
|
756
|
+
}
|
|
699
757
|
return;
|
|
700
758
|
}
|
|
701
759
|
if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
|
|
@@ -874,12 +932,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
|
|
|
874
932
|
if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
|
|
875
933
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
|
|
876
934
|
releaseRelay(room.connections.get(clientId));
|
|
935
|
+
room.connections.get(clientId).adminRoute = 'direct';
|
|
877
936
|
} else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
|
|
878
937
|
const connection = room.connections.get(clientId);
|
|
879
938
|
if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
|
|
880
939
|
if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
|
|
881
940
|
ws.close(4428, 'Server busy'); return;
|
|
882
941
|
}
|
|
942
|
+
if (connection.userId && !controlPayload(message.payload)) connection.adminRoute = 'relay';
|
|
883
943
|
send(room.host, { type: 'data', session, payload: message.payload });
|
|
884
944
|
} else fail();
|
|
885
945
|
} catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
|
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { recordDailyUsage } from './usage-daily.mjs';
|
|
3
|
-
|
|
4
|
-
const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
|
|
5
|
-
|
|
6
|
-
// Count confirmed intervals only; a lost connection never creates estimated time.
|
|
7
|
-
export function usageMeter(pool) {
|
|
8
|
-
async function action(connection, computerId, clientId, kind, key) {
|
|
9
|
-
if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
|
|
10
|
-
const column = kind === 'message' ? 'message_count' : 'turn_count';
|
|
11
|
-
const db = await pool.getConnection();
|
|
12
|
-
try {
|
|
13
|
-
await db.beginTransaction();
|
|
14
|
-
const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
|
|
15
|
-
if (!users.length) { await db.commit(); return false; }
|
|
16
|
-
const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
|
|
17
|
-
(computer_id,kind,action_key,user_id,client_id,occurred_at)
|
|
18
|
-
VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
|
|
19
|
-
[computerId, kind, key, connection.userId, clientId]);
|
|
20
|
-
if (result.affectedRows === 1) {
|
|
21
|
-
await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
|
|
22
|
-
VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
|
|
23
|
-
updated_at=VALUES(updated_at)`, [connection.userId]);
|
|
24
|
-
await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
|
|
25
|
-
VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
|
|
26
|
-
updated_at=VALUES(updated_at)`, [computerId]);
|
|
27
|
-
}
|
|
28
|
-
await db.commit();
|
|
29
|
-
return result.affectedRows === 1;
|
|
30
|
-
} catch (error) { await db.rollback(); throw error; }
|
|
31
|
-
finally { db.release(); }
|
|
32
|
-
}
|
|
33
|
-
async function turnOrigin(computerId, key) {
|
|
34
|
-
const [rows] = await pool.execute(`SELECT a.user_id,a.client_id FROM relay_usage_actions a JOIN users u ON u.id=a.user_id
|
|
35
|
-
WHERE a.computer_id=? AND a.kind='turn' AND a.action_key=? AND u.status='active'`, [computerId, key]);
|
|
36
|
-
return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
|
|
37
|
-
}
|
|
38
|
-
async function pulse(connection, active, computerId, clientId, externalKey = null) {
|
|
39
|
-
if (!connection.userId) return;
|
|
40
|
-
if (!active && !connection.usageSessionId) return;
|
|
41
|
-
if (active && !connection.usageSessionId) {
|
|
42
|
-
const id = randomUUID();
|
|
43
|
-
const [inserted] = await pool.execute(`INSERT INTO relay_usage_sessions
|
|
44
|
-
(id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
|
|
45
|
-
SELECT ?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0 FROM users WHERE id=? AND status='active'`, [id, externalKey, connection.userId, computerId, clientId, connection.userId]);
|
|
46
|
-
if (!inserted.affectedRows) return;
|
|
47
|
-
connection.usageSessionId = id;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const id = connection.usageSessionId;
|
|
51
|
-
const db = await pool.getConnection();
|
|
52
|
-
try {
|
|
53
|
-
await db.beginTransaction();
|
|
54
|
-
const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
|
|
55
|
-
if (!users.length) { await db.commit(); connection.usageSessionId = undefined; return; }
|
|
56
|
-
const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
|
|
57
|
-
const row = rows[0];
|
|
58
|
-
if (!row || row.ended_at) throw new Error('Invalid relay usage session');
|
|
59
|
-
const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
|
|
60
|
-
const now = clock[0].now;
|
|
61
|
-
const nowMs = epoch(now);
|
|
62
|
-
const chargeStart = Math.max(epoch(row.confirmed_until), nowMs - 20000);
|
|
63
|
-
const seconds = Math.max(0, nowMs - chargeStart) / 1000;
|
|
64
|
-
const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
|
|
65
|
-
const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
|
|
66
|
-
const freeSeconds = subscribed ? 0 : seconds;
|
|
67
|
-
await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
|
|
68
|
-
free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
|
|
69
|
-
if (seconds > 0) {
|
|
70
|
-
await recordDailyUsage(db, row.user_id, row.computer_id, !subscribed,
|
|
71
|
-
chargeStart, nowMs, now);
|
|
72
|
-
await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
|
|
73
|
-
VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
74
|
-
updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
|
|
75
|
-
await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
|
|
76
|
-
VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
77
|
-
free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
|
|
78
|
-
[row.computer_id, seconds, freeSeconds, now]);
|
|
79
|
-
}
|
|
80
|
-
await db.commit();
|
|
81
|
-
if (!active) connection.usageSessionId = undefined;
|
|
82
|
-
} catch (error) { await db.rollback(); throw error; }
|
|
83
|
-
finally { db.release(); }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function lost(connection) {
|
|
87
|
-
const id = connection.usageSessionId;
|
|
88
|
-
connection.usageSessionId = undefined;
|
|
89
|
-
if (!id) return;
|
|
90
|
-
await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
|
|
91
|
-
WHERE id=? AND ended_at IS NULL`, [id]);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return { action, turnOrigin, pulse, lost };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function usageReport(pool, page, view = 'user') {
|
|
98
|
-
if (view === 'computer') {
|
|
99
|
-
const offset = (page - 1) * 30;
|
|
100
|
-
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_computer_days
|
|
101
|
-
WHERE usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND task_seconds>0`);
|
|
102
|
-
const [rows] = await pool.execute(`SELECT c.id,
|
|
103
|
-
d.task_seconds AS today_task_seconds,
|
|
104
|
-
d.free_task_seconds AS today_free_task_seconds,
|
|
105
|
-
COALESCE(a.message_count,0) AS message_count,
|
|
106
|
-
COALESCE(a.turn_count,0) AS turn_count,
|
|
107
|
-
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
108
|
-
FROM relay_usage_computer_days d JOIN computers c ON c.id=d.computer_id
|
|
109
|
-
LEFT JOIN (SELECT computer_id,SUM(kind='message') AS message_count,
|
|
110
|
-
SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
|
|
111
|
-
WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
|
|
112
|
-
AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
|
|
113
|
-
GROUP BY computer_id) a ON a.computer_id=c.id
|
|
114
|
-
LEFT JOIN (SELECT computer_id,MAX(confirmed_until) AS last_used_at
|
|
115
|
-
FROM relay_usage_sessions GROUP BY computer_id) s ON s.computer_id=c.id
|
|
116
|
-
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
|
|
117
|
-
ORDER BY d.task_seconds DESC,c.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
118
|
-
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
119
|
-
}
|
|
120
|
-
const offset = (page - 1) * 30;
|
|
121
|
-
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_user_days d
|
|
122
|
-
JOIN user_login_identities i ON i.user_id=d.user_id AND i.kind='email'
|
|
123
|
-
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0`);
|
|
124
|
-
const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
|
|
125
|
-
d.task_seconds AS today_task_seconds,
|
|
126
|
-
COALESCE(a.message_count,0) AS message_count,
|
|
127
|
-
COALESCE(a.turn_count,0) AS turn_count,
|
|
128
|
-
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
129
|
-
FROM relay_usage_user_days d JOIN users u ON u.id=d.user_id
|
|
130
|
-
JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
131
|
-
LEFT JOIN (SELECT user_id,SUM(kind='message') AS message_count,
|
|
132
|
-
SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
|
|
133
|
-
WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
|
|
134
|
-
AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
|
|
135
|
-
GROUP BY user_id) a ON a.user_id=u.id
|
|
136
|
-
LEFT JOIN (SELECT user_id,MAX(confirmed_until) AS last_used_at
|
|
137
|
-
FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
|
|
138
|
-
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
|
|
139
|
-
ORDER BY d.task_seconds DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
140
|
-
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
141
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { recordDailyUsage } from './usage-daily.mjs';
|
|
3
|
+
|
|
4
|
+
const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
|
|
5
|
+
|
|
6
|
+
// Count confirmed intervals only; a lost connection never creates estimated time.
|
|
7
|
+
export function usageMeter(pool) {
|
|
8
|
+
async function action(connection, computerId, clientId, kind, key) {
|
|
9
|
+
if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
|
|
10
|
+
const column = kind === 'message' ? 'message_count' : 'turn_count';
|
|
11
|
+
const db = await pool.getConnection();
|
|
12
|
+
try {
|
|
13
|
+
await db.beginTransaction();
|
|
14
|
+
const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
|
|
15
|
+
if (!users.length) { await db.commit(); return false; }
|
|
16
|
+
const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
|
|
17
|
+
(computer_id,kind,action_key,user_id,client_id,occurred_at)
|
|
18
|
+
VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
|
|
19
|
+
[computerId, kind, key, connection.userId, clientId]);
|
|
20
|
+
if (result.affectedRows === 1) {
|
|
21
|
+
await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
|
|
22
|
+
VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
|
|
23
|
+
updated_at=VALUES(updated_at)`, [connection.userId]);
|
|
24
|
+
await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
|
|
25
|
+
VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
|
|
26
|
+
updated_at=VALUES(updated_at)`, [computerId]);
|
|
27
|
+
}
|
|
28
|
+
await db.commit();
|
|
29
|
+
return result.affectedRows === 1;
|
|
30
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
31
|
+
finally { db.release(); }
|
|
32
|
+
}
|
|
33
|
+
async function turnOrigin(computerId, key) {
|
|
34
|
+
const [rows] = await pool.execute(`SELECT a.user_id,a.client_id FROM relay_usage_actions a JOIN users u ON u.id=a.user_id
|
|
35
|
+
WHERE a.computer_id=? AND a.kind='turn' AND a.action_key=? AND u.status='active'`, [computerId, key]);
|
|
36
|
+
return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
|
|
37
|
+
}
|
|
38
|
+
async function pulse(connection, active, computerId, clientId, externalKey = null) {
|
|
39
|
+
if (!connection.userId) return;
|
|
40
|
+
if (!active && !connection.usageSessionId) return;
|
|
41
|
+
if (active && !connection.usageSessionId) {
|
|
42
|
+
const id = randomUUID();
|
|
43
|
+
const [inserted] = await pool.execute(`INSERT INTO relay_usage_sessions
|
|
44
|
+
(id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
|
|
45
|
+
SELECT ?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0 FROM users WHERE id=? AND status='active'`, [id, externalKey, connection.userId, computerId, clientId, connection.userId]);
|
|
46
|
+
if (!inserted.affectedRows) return;
|
|
47
|
+
connection.usageSessionId = id;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const id = connection.usageSessionId;
|
|
51
|
+
const db = await pool.getConnection();
|
|
52
|
+
try {
|
|
53
|
+
await db.beginTransaction();
|
|
54
|
+
const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
|
|
55
|
+
if (!users.length) { await db.commit(); connection.usageSessionId = undefined; return; }
|
|
56
|
+
const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
|
|
57
|
+
const row = rows[0];
|
|
58
|
+
if (!row || row.ended_at) throw new Error('Invalid relay usage session');
|
|
59
|
+
const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
|
|
60
|
+
const now = clock[0].now;
|
|
61
|
+
const nowMs = epoch(now);
|
|
62
|
+
const chargeStart = Math.max(epoch(row.confirmed_until), nowMs - 20000);
|
|
63
|
+
const seconds = Math.max(0, nowMs - chargeStart) / 1000;
|
|
64
|
+
const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
|
|
65
|
+
const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
|
|
66
|
+
const freeSeconds = subscribed ? 0 : seconds;
|
|
67
|
+
await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
|
|
68
|
+
free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
|
|
69
|
+
if (seconds > 0) {
|
|
70
|
+
await recordDailyUsage(db, row.user_id, row.computer_id, !subscribed,
|
|
71
|
+
chargeStart, nowMs, now);
|
|
72
|
+
await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
|
|
73
|
+
VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
74
|
+
updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
|
|
75
|
+
await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
|
|
76
|
+
VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
|
|
77
|
+
free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
|
|
78
|
+
[row.computer_id, seconds, freeSeconds, now]);
|
|
79
|
+
}
|
|
80
|
+
await db.commit();
|
|
81
|
+
if (!active) connection.usageSessionId = undefined;
|
|
82
|
+
} catch (error) { await db.rollback(); throw error; }
|
|
83
|
+
finally { db.release(); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function lost(connection) {
|
|
87
|
+
const id = connection.usageSessionId;
|
|
88
|
+
connection.usageSessionId = undefined;
|
|
89
|
+
if (!id) return;
|
|
90
|
+
await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
|
|
91
|
+
WHERE id=? AND ended_at IS NULL`, [id]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { action, turnOrigin, pulse, lost };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function usageReport(pool, page, view = 'user') {
|
|
98
|
+
if (view === 'computer') {
|
|
99
|
+
const offset = (page - 1) * 30;
|
|
100
|
+
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_computer_days
|
|
101
|
+
WHERE usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND task_seconds>0`);
|
|
102
|
+
const [rows] = await pool.execute(`SELECT c.id,
|
|
103
|
+
d.task_seconds AS today_task_seconds,
|
|
104
|
+
d.free_task_seconds AS today_free_task_seconds,
|
|
105
|
+
COALESCE(a.message_count,0) AS message_count,
|
|
106
|
+
COALESCE(a.turn_count,0) AS turn_count,
|
|
107
|
+
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
108
|
+
FROM relay_usage_computer_days d JOIN computers c ON c.id=d.computer_id
|
|
109
|
+
LEFT JOIN (SELECT computer_id,SUM(kind='message') AS message_count,
|
|
110
|
+
SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
|
|
111
|
+
WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
|
|
112
|
+
AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
|
|
113
|
+
GROUP BY computer_id) a ON a.computer_id=c.id
|
|
114
|
+
LEFT JOIN (SELECT computer_id,MAX(confirmed_until) AS last_used_at
|
|
115
|
+
FROM relay_usage_sessions GROUP BY computer_id) s ON s.computer_id=c.id
|
|
116
|
+
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
|
|
117
|
+
ORDER BY d.task_seconds DESC,c.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
118
|
+
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
119
|
+
}
|
|
120
|
+
const offset = (page - 1) * 30;
|
|
121
|
+
const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_user_days d
|
|
122
|
+
JOIN user_login_identities i ON i.user_id=d.user_id AND i.kind='email'
|
|
123
|
+
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0`);
|
|
124
|
+
const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
|
|
125
|
+
d.task_seconds AS today_task_seconds,
|
|
126
|
+
COALESCE(a.message_count,0) AS message_count,
|
|
127
|
+
COALESCE(a.turn_count,0) AS turn_count,
|
|
128
|
+
COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
|
|
129
|
+
FROM relay_usage_user_days d JOIN users u ON u.id=d.user_id
|
|
130
|
+
JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
|
|
131
|
+
LEFT JOIN (SELECT user_id,SUM(kind='message') AS message_count,
|
|
132
|
+
SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
|
|
133
|
+
WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
|
|
134
|
+
AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
|
|
135
|
+
GROUP BY user_id) a ON a.user_id=u.id
|
|
136
|
+
LEFT JOIN (SELECT user_id,MAX(confirmed_until) AS last_used_at
|
|
137
|
+
FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
|
|
138
|
+
WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
|
|
139
|
+
ORDER BY d.task_seconds DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
|
|
140
|
+
return { page, pageSize: 30, total: counts[0].total, rows };
|
|
141
|
+
}
|
package/dist/src/server.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import vm from 'node:vm';
|
|
10
10
|
import sharp from 'sharp';
|
|
11
11
|
import { attachLanSocket } from './lan-socket-server.mjs';
|
|
12
|
+
import { connectionReporting } from './self-relay/connection-reporting.mjs';
|
|
12
13
|
import fileMentions from '../shared/file-mentions.cjs';
|
|
13
14
|
import fileCitations from '../shared/file-citations.cjs';
|
|
14
15
|
import { validateInlineImage } from './message-images.mjs';
|
|
@@ -92,6 +93,7 @@ function readRuntimeVersion() {
|
|
|
92
93
|
|
|
93
94
|
const options = parseOptions(process.argv.slice(2));
|
|
94
95
|
const runtimeVersion = readRuntimeVersion();
|
|
96
|
+
const serviceInstanceId = crypto.randomUUID();
|
|
95
97
|
|
|
96
98
|
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
97
99
|
const projectRoot = os.homedir();
|
|
@@ -266,8 +268,8 @@ async function handleRequest(request, response) {
|
|
|
266
268
|
const body = await readJsonBody(request);
|
|
267
269
|
if (body.confirmed !== true) throw new RequestError(400, '请确认启动桌面端。');
|
|
268
270
|
const result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' });
|
|
269
|
-
await runtimeDiscovery.sample();
|
|
270
|
-
sendJson(response, 202, result); return;
|
|
271
|
+
const observed = await runtimeDiscovery.sample();
|
|
272
|
+
sendJson(response, 202, { ...result, desktop: observed.desktop, checkedAt: observed.checkedAt }); return;
|
|
271
273
|
}
|
|
272
274
|
if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
|
|
273
275
|
const body = await readJsonBody(request);
|
|
@@ -283,7 +285,7 @@ async function handleRequest(request, response) {
|
|
|
283
285
|
if (request.method === 'POST' && url.pathname === '/api/service/restart') {
|
|
284
286
|
const body = await readJsonBody(request);
|
|
285
287
|
if (body.confirmed !== true) throw new RequestError(400, '请确认重启工作站服务后再继续。');
|
|
286
|
-
sendJson(response, 202, await requestServiceRestart(stateRoot)); return;
|
|
288
|
+
sendJson(response, 202, { ...await requestServiceRestart(stateRoot), serviceInstanceId }); return;
|
|
287
289
|
}
|
|
288
290
|
if (request.method === 'POST' && url.pathname === '/api/managed-app-server/restart') {
|
|
289
291
|
const body = await readJsonBody(request);
|
|
@@ -322,7 +324,7 @@ async function handleRequest(request, response) {
|
|
|
322
324
|
// must not trigger PowerShell, process-identity, or app-server checks.
|
|
323
325
|
const executionReady = executionHealth.state.ready && sharedServer?.ready === true;
|
|
324
326
|
sendJson(response, 200, {
|
|
325
|
-
status: 'ok', resident: true, version: runtimeVersion,
|
|
327
|
+
status: 'ok', resident: true, version: runtimeVersion, serviceInstanceId,
|
|
326
328
|
keepAwake: powerManager.snapshot(),
|
|
327
329
|
serviceUpdate: serviceUpdateStatus(stateRoot),
|
|
328
330
|
execution: { ...executionHealth.state, ready: executionReady, canWrite: executionReady,
|
|
@@ -392,7 +394,10 @@ async function handleRequest(request, response) {
|
|
|
392
394
|
return;
|
|
393
395
|
}
|
|
394
396
|
const localPath = url.searchParams.get('path') || '';
|
|
395
|
-
if (localFileRequest &&
|
|
397
|
+
if (localFileRequest && (/[\x00-\x1f]/.test(localPath)
|
|
398
|
+
|| !(process.platform === 'win32' ? /^[a-z]:[\\/]/i : /^\/(?!\/)/).test(localPath))) {
|
|
399
|
+
throw new RequestError(400, '无效的工作站文件路径。');
|
|
400
|
+
}
|
|
396
401
|
const file = localFileRequest ? describeLocalFile(localPath) : fileCache.get(fileMatch[1]);
|
|
397
402
|
if (!file) throw new RequestError(404, '电脑上的文件不存在或无法读取。');
|
|
398
403
|
if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
|
|
@@ -514,11 +519,16 @@ async function handleRequest(request, response) {
|
|
|
514
519
|
|
|
515
520
|
if (request.method === 'GET' && url.pathname === '/api/file-local-copy') {
|
|
516
521
|
const value = url.searchParams.get('source') || '';
|
|
517
|
-
const
|
|
518
|
-
if (
|
|
522
|
+
const match = /^([a-f0-9]{64})(?:\?recovery=([A-Za-z0-9_-]{1,4096}))?$/.exec(value);
|
|
523
|
+
if (!match) throw new RequestError(400, '无效的文件标识。');
|
|
524
|
+
const asset = attachmentCache.get(match[1])
|
|
525
|
+
|| dotsImages.restore(match[1], match[2] || url.searchParams.get('recovery') || '');
|
|
526
|
+
// A missing process cache does not make a local image an external file.
|
|
527
|
+
// The actual image endpoint owns recovery and download authorization.
|
|
528
|
+
if (!asset?.dotsFile) {
|
|
519
529
|
sendJson(response, 200, { external: false, localCopy: null }); return;
|
|
520
530
|
}
|
|
521
|
-
|
|
531
|
+
await dotsImages.authorization(asset.identity);
|
|
522
532
|
const copy = await workstationDownloads.copy(asset.id);
|
|
523
533
|
sendJson(response, 200, { external: true, localCopy: copy ? workstationCopyReference(copy, asset) : null,
|
|
524
534
|
cachedContent: asset.buffer ? { remotePath: asset.id, name: asset.name, size: asset.buffer.length, mimeType: asset.mimeType } : null });
|
|
@@ -759,7 +769,9 @@ async function handleRequest(request, response) {
|
|
|
759
769
|
}
|
|
760
770
|
const { input } = appServerTurnArguments('', body);
|
|
761
771
|
messageSubmissionStarted = true;
|
|
762
|
-
const
|
|
772
|
+
const createRpc = (method, params) => method === 'thread/start'
|
|
773
|
+
? execution.startThread(params) : rpc(method, params);
|
|
774
|
+
const created = await appServerEvents.createThread(createRpc, target, input, body, undefined, bindCreation);
|
|
763
775
|
const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
|
|
764
776
|
void generateAndSetThreadTitle({
|
|
765
777
|
request: rpc,
|
|
@@ -1021,6 +1033,8 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
|
|
|
1021
1033
|
uploadsRoot,
|
|
1022
1034
|
authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
|
|
1023
1035
|
onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
|
|
1036
|
+
onPhoneConnected: (...args) => connectionReporting(stateRoot).connected(...args),
|
|
1037
|
+
onPhoneDisconnected: id => connectionReporting(stateRoot).disconnected(id),
|
|
1024
1038
|
onDeviceConnected: (clientId, info) => {
|
|
1025
1039
|
try { recordLanConnection(stateRoot, clientId, info); }
|
|
1026
1040
|
catch (error) { console.error('记录局域网设备连接失败:', error.message); }
|