@oar-sh/oar 0.9.9 → 0.9.10
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/CHANGELOG.md +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Filesystem changes made from the explorer: create a folder, rename an
|
|
2
|
+
// entry, delete an entry, and count what a delete would remove. The callers
|
|
3
|
+
// (the routes) have already resolved the absolute target inside a browsable
|
|
4
|
+
// root; this module only touches the filesystem and turns its errors into
|
|
5
|
+
// `{ error, statusCode }` answers the routes can send as they are.
|
|
6
|
+
//
|
|
7
|
+
// Symbolic links are refused everywhere. The listings skip them, so the user
|
|
8
|
+
// never saw one in the explorer, and a rename or delete through a link would
|
|
9
|
+
// act on something outside the folder they are looking at.
|
|
10
|
+
|
|
11
|
+
import fsDefault from 'node:fs';
|
|
12
|
+
import pathDefault from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_COUNT_CAP = 10000;
|
|
15
|
+
|
|
16
|
+
function failure(error, fallback, statusCode = 500) {
|
|
17
|
+
const code = String(error?.code || '');
|
|
18
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return { error: 'Not found', statusCode: 404 };
|
|
19
|
+
if (code === 'EEXIST' || code === 'ENOTEMPTY') return { error: 'That name is already taken', statusCode: 409 };
|
|
20
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'EBUSY') return { error: 'Permission denied', statusCode: 403 };
|
|
21
|
+
return { error: error?.message || fallback, statusCode };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lstatOrNull(fs, target) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.lstatSync(target);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Create `target`; missing parents are created with it. */
|
|
34
|
+
export function createDirectory(target, { fs = fsDefault } = {}) {
|
|
35
|
+
try {
|
|
36
|
+
if (lstatOrNull(fs, target)) return { error: 'That name is already taken', statusCode: 409 };
|
|
37
|
+
fs.mkdirSync(target, { recursive: true });
|
|
38
|
+
return { ok: true };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return failure(error, 'Failed to create the folder');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rename `target` to `newName` inside its own parent. A name that differs
|
|
46
|
+
* only in case is allowed: on a case-insensitive filesystem the destination
|
|
47
|
+
* stat finds the source itself, which is not a collision.
|
|
48
|
+
*/
|
|
49
|
+
export function renameEntry(target, newName, { fs = fsDefault, path = pathDefault } = {}) {
|
|
50
|
+
const destination = path.join(path.dirname(target), newName);
|
|
51
|
+
try {
|
|
52
|
+
const source = lstatOrNull(fs, target);
|
|
53
|
+
if (!source) return { error: 'Not found', statusCode: 404 };
|
|
54
|
+
if (source.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
|
|
55
|
+
if (destination === target) return { ok: true, path: destination };
|
|
56
|
+
const existing = lstatOrNull(fs, destination);
|
|
57
|
+
const sameEntry = existing && existing.ino === source.ino && existing.dev === source.dev && existing.ino !== 0;
|
|
58
|
+
if (existing && !sameEntry) return { error: 'That name is already taken', statusCode: 409 };
|
|
59
|
+
fs.renameSync(target, destination);
|
|
60
|
+
return { ok: true, path: destination };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return failure(error, 'Failed to rename');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Remove `target`; a folder goes with everything inside it. */
|
|
67
|
+
export function removeEntry(target, { fs = fsDefault } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = lstatOrNull(fs, target);
|
|
70
|
+
if (!stat) return { error: 'Not found', statusCode: 404 };
|
|
71
|
+
if (stat.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
|
|
72
|
+
fs.rmSync(target, { recursive: true, force: false, maxRetries: 2 });
|
|
73
|
+
return { ok: true };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return failure(error, 'Failed to delete');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a delete of `target` would remove: `{ kind, files, dirs, capped }`.
|
|
81
|
+
* `dirs` excludes the target itself. The walk stops once `files + dirs`
|
|
82
|
+
* reaches `cap`, so a huge tree answers quickly with `capped: true`. Links
|
|
83
|
+
* inside count as files and are not followed.
|
|
84
|
+
*/
|
|
85
|
+
export function countEntries(target, { fs = fsDefault, path = pathDefault, cap = DEFAULT_COUNT_CAP } = {}) {
|
|
86
|
+
let stat = null;
|
|
87
|
+
try {
|
|
88
|
+
stat = lstatOrNull(fs, target);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return failure(error, 'Failed to read the entry');
|
|
91
|
+
}
|
|
92
|
+
if (!stat) return { error: 'Not found', statusCode: 404 };
|
|
93
|
+
if (stat.isSymbolicLink()) return { error: 'Links cannot be changed here', statusCode: 409 };
|
|
94
|
+
if (!stat.isDirectory()) return { ok: true, kind: 'file', files: 0, dirs: 0, capped: false };
|
|
95
|
+
|
|
96
|
+
let files = 0;
|
|
97
|
+
let dirs = 0;
|
|
98
|
+
let capped = false;
|
|
99
|
+
const stack = [target];
|
|
100
|
+
while (stack.length && !capped) {
|
|
101
|
+
const dir = stack.pop();
|
|
102
|
+
let entries = [];
|
|
103
|
+
try {
|
|
104
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
continue; // an unreadable branch is still removed by rm; it just stays uncounted
|
|
107
|
+
}
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
110
|
+
dirs += 1;
|
|
111
|
+
stack.push(path.join(dir, entry.name));
|
|
112
|
+
} else {
|
|
113
|
+
files += 1;
|
|
114
|
+
}
|
|
115
|
+
if (files + dirs >= cap) {
|
|
116
|
+
capped = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, kind: 'dir', files, dirs, capped };
|
|
122
|
+
}
|
|
@@ -399,13 +399,33 @@ export function createPreviewProxyServer({
|
|
|
399
399
|
|
|
400
400
|
// WebSocket upgrades (Vite HMR, an app's own socket) ride the same prefix.
|
|
401
401
|
function handleUpgrade(req, socket, head) {
|
|
402
|
+
// Node removes its own error listener from a socket the moment it emits
|
|
403
|
+
// 'upgrade', and the upstream socket handed to 'upgrade' comes bare too. A
|
|
404
|
+
// browser that drops the connection while the dev server is still
|
|
405
|
+
// answering would otherwise raise an uncaught ECONNRESET and end the
|
|
406
|
+
// whole relay. Whatever fails on either side takes both down.
|
|
407
|
+
let upstreamReq = null;
|
|
408
|
+
let upstreamSocket = null;
|
|
409
|
+
const teardown = () => {
|
|
410
|
+
try { socket.destroy(); } catch {}
|
|
411
|
+
try { upstreamSocket?.destroy(); } catch {}
|
|
412
|
+
try { upstreamReq?.destroy(); } catch {}
|
|
413
|
+
};
|
|
414
|
+
socket.on('error', teardown);
|
|
415
|
+
// A client that leaves before the dev server answers abandons the
|
|
416
|
+
// handshake. Once both sides are piped, an orderly close travels through
|
|
417
|
+
// the pipes, so the pending writes still reach the other side.
|
|
418
|
+
socket.once('close', teardown);
|
|
419
|
+
const reply = (text) => {
|
|
420
|
+
if (socket.destroyed) return;
|
|
421
|
+
try { socket.write(text); } catch {}
|
|
422
|
+
};
|
|
423
|
+
|
|
402
424
|
const resolved = resolveRequest(req);
|
|
403
425
|
// Static previews have nothing to upgrade to; refuse like an unknown token.
|
|
404
426
|
if (resolved.kind !== 'proxy' || resolved.entry?.mode === 'static') {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
} catch {}
|
|
408
|
-
try { socket.destroy(); } catch {}
|
|
427
|
+
reply('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
428
|
+
teardown();
|
|
409
429
|
return;
|
|
410
430
|
}
|
|
411
431
|
|
|
@@ -426,7 +446,7 @@ export function createPreviewProxyServer({
|
|
|
426
446
|
headers.connection = 'Upgrade';
|
|
427
447
|
headers.upgrade = String(req.headers.upgrade || 'websocket');
|
|
428
448
|
|
|
429
|
-
|
|
449
|
+
upstreamReq = httpImpl.request({
|
|
430
450
|
host: entry.targetHost,
|
|
431
451
|
port: entry.targetPort,
|
|
432
452
|
method: req.method,
|
|
@@ -436,25 +456,26 @@ export function createPreviewProxyServer({
|
|
|
436
456
|
});
|
|
437
457
|
guardConnectTimeout(upstreamReq, connectTimeoutMs, () => {});
|
|
438
458
|
|
|
439
|
-
upstreamReq.on('upgrade', (upstreamRes,
|
|
459
|
+
upstreamReq.on('upgrade', (upstreamRes, answeredSocket, upstreamHead) => {
|
|
460
|
+
upstreamSocket = answeredSocket;
|
|
461
|
+
upstreamSocket.on('error', teardown);
|
|
462
|
+
// The client left while the dev server was answering.
|
|
463
|
+
if (socket.destroyed) {
|
|
464
|
+
teardown();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
440
467
|
const lines = [`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}`];
|
|
441
468
|
for (const [name, value] of Object.entries(upstreamRes.headers)) {
|
|
442
469
|
for (const entryValue of Array.isArray(value) ? value : [value]) {
|
|
443
470
|
lines.push(`${name}: ${entryValue}`);
|
|
444
471
|
}
|
|
445
472
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
} catch {
|
|
449
|
-
try { upstreamSocket.destroy(); } catch {}
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
if (upstreamHead?.length) socket.write(upstreamHead);
|
|
473
|
+
reply(`${lines.join('\r\n')}\r\n\r\n`);
|
|
474
|
+
if (upstreamHead?.length) reply(upstreamHead);
|
|
453
475
|
if (head?.length) upstreamSocket.write(head);
|
|
476
|
+
socket.removeListener('close', teardown);
|
|
454
477
|
trackUpgradedSocket(socket);
|
|
455
478
|
trackUpgradedSocket(upstreamSocket);
|
|
456
|
-
upstreamSocket.on('error', () => { try { socket.destroy(); } catch {} });
|
|
457
|
-
socket.on('error', () => { try { upstreamSocket.destroy(); } catch {} });
|
|
458
479
|
upstreamSocket.pipe(socket);
|
|
459
480
|
socket.pipe(upstreamSocket);
|
|
460
481
|
});
|
|
@@ -462,18 +483,15 @@ export function createPreviewProxyServer({
|
|
|
462
483
|
// A dev server that answers an upgrade with a plain response (404 on an
|
|
463
484
|
// unknown HMR path) must not leave the client hanging.
|
|
464
485
|
upstreamReq.on('response', (upstreamRes) => {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
} catch {}
|
|
486
|
+
reply(`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}\r\nConnection: close\r\n\r\n`);
|
|
487
|
+
upstreamRes.on('error', () => {});
|
|
468
488
|
upstreamRes.resume();
|
|
469
|
-
|
|
489
|
+
teardown();
|
|
470
490
|
});
|
|
471
491
|
|
|
472
492
|
upstreamReq.on('error', () => {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
} catch {}
|
|
476
|
-
try { socket.destroy(); } catch {}
|
|
493
|
+
reply('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
494
|
+
teardown();
|
|
477
495
|
});
|
|
478
496
|
|
|
479
497
|
upstreamReq.end();
|
|
@@ -324,6 +324,22 @@ export function createPushDispatchService({
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// A turn that answers relay events (its message is the relay's event row,
|
|
328
|
+
// kind 'relay-event') pushes no "reply ready" or "failed" either: the
|
|
329
|
+
// watcher answers events many times an hour and pushes by itself when one
|
|
330
|
+
// matters. Question pushes are unchanged.
|
|
331
|
+
let messageKindStatement = null;
|
|
332
|
+
function turnAnswersRelayEvents(sourceMessageId) {
|
|
333
|
+
const id = String(sourceMessageId || '').trim();
|
|
334
|
+
if (!id) return false;
|
|
335
|
+
try {
|
|
336
|
+
messageKindStatement ??= db.prepare(`SELECT kind FROM messages WHERE id = ?`);
|
|
337
|
+
return String(messageKindStatement.get(id)?.kind || '').trim() === 'relay-event';
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
327
343
|
async function sendToSubscription(row, event) {
|
|
328
344
|
const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
|
|
329
345
|
const payload = renderPushNotification(event, preferences);
|
|
@@ -440,6 +456,7 @@ export function createPushDispatchService({
|
|
|
440
456
|
// the turn.
|
|
441
457
|
function notifyTurnComplete({ conversationId, messageId, sourceMessageId, text } = {}) {
|
|
442
458
|
if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
|
|
459
|
+
if (turnAnswersRelayEvents(sourceMessageId)) return Promise.resolve({ outcome: 'relay-event' });
|
|
443
460
|
return dispatch({
|
|
444
461
|
type: 'turnComplete',
|
|
445
462
|
conversationId: conversationId || null,
|
|
@@ -450,6 +467,7 @@ export function createPushDispatchService({
|
|
|
450
467
|
|
|
451
468
|
function notifyTurnFailed({ conversationId, messageId, sourceMessageId, text } = {}) {
|
|
452
469
|
if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
|
|
470
|
+
if (turnAnswersRelayEvents(sourceMessageId)) return Promise.resolve({ outcome: 'relay-event' });
|
|
453
471
|
return dispatch({
|
|
454
472
|
type: 'turnFailed',
|
|
455
473
|
conversationId: conversationId || null,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The watching side of relay event subscriptions on paired relays: while
|
|
5
|
+
* this relay holds subscriptions on a paired relay (or an end it has not
|
|
6
|
+
* delivered there yet), it fetches that relay's events every few seconds
|
|
7
|
+
* over the pairing and hands them to the event service, which delivers them
|
|
8
|
+
* like the events of this relay's own sessions. Only this relay calls, so a
|
|
9
|
+
* pairing in one direction is enough.
|
|
10
|
+
*
|
|
11
|
+
* A fetch (POST /api/relay-events/remote/pull) acknowledges what the last
|
|
12
|
+
* one stored, carries the ends and watcher names the other relay must
|
|
13
|
+
* learn, and comes back with new events and the subscriptions' state; the
|
|
14
|
+
* event service writes all of it and the cursor in one transaction, so
|
|
15
|
+
* nothing is lost or delivered twice when either relay restarts.
|
|
16
|
+
*
|
|
17
|
+
* A link with no answer for remoteGapMs (failing fetches, or this relay
|
|
18
|
+
* down) is lost from its last answer on. The first fetch after that, every
|
|
19
|
+
* page of it, is held and delivered as one replayed row per watcher with
|
|
20
|
+
* link_lost and link_restored around it. A shorter outage only delays.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
|
|
24
|
+
import { RELAY_EVENT_LIMITS } from '../../shared/relay-events.mjs';
|
|
25
|
+
|
|
26
|
+
export const RELAY_EVENT_REMOTE_SUBSCRIBE_PATH = '/api/relay-events/remote/subscribe';
|
|
27
|
+
export const RELAY_EVENT_REMOTE_PULL_PATH = '/api/relay-events/remote/pull';
|
|
28
|
+
|
|
29
|
+
const LOG_PREFIX = '[relay-events]';
|
|
30
|
+
// Pages of one fetch before the next tick goes on (remotePullBatch each).
|
|
31
|
+
const MAX_PAGES_PER_FETCH = 50;
|
|
32
|
+
|
|
33
|
+
function toText(value) {
|
|
34
|
+
return String(value ?? '').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A paired relay that predates cross-relay subscriptions answers their
|
|
39
|
+
* routes with Express's HTML 404: no JSON, no code. A 404 of these routes
|
|
40
|
+
* always carries one.
|
|
41
|
+
*/
|
|
42
|
+
export function isMissingRelayEventRoute(error) {
|
|
43
|
+
return toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound && !toText(error?.remoteBody?.code);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function relayWithoutCrossRelayEventsText(name) {
|
|
47
|
+
return `Relay "${toText(name) || 'the paired relay'}" runs an OAR version without event subscriptions across relays; it needs an update before its sessions can be watched from here.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function describeFailure(relay, error) {
|
|
51
|
+
if (isMissingRelayEventRoute(error)) return relayWithoutCrossRelayEventsText(relay?.name);
|
|
52
|
+
return toText(error?.message || error).slice(0, 300) || 'no answer';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPullAnswer(response) {
|
|
56
|
+
return !!response && typeof response === 'object' && Array.isArray(response.events) && Array.isArray(response.subscriptions);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createRelayEventLinkService({
|
|
60
|
+
relayEvents,
|
|
61
|
+
// `get(id)`: the registry entry (address and token) of a paired relay.
|
|
62
|
+
registry,
|
|
63
|
+
// `request(relay, method, path, { body, timeoutMs })`: remote-relay-client.mjs.
|
|
64
|
+
client,
|
|
65
|
+
now = () => Date.now(),
|
|
66
|
+
setTimer = (fn, ms) => {
|
|
67
|
+
const timer = setTimeout(fn, ms);
|
|
68
|
+
timer.unref?.();
|
|
69
|
+
return timer;
|
|
70
|
+
},
|
|
71
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
72
|
+
logger = console,
|
|
73
|
+
pollMs = RELAY_EVENT_LIMITS.remotePollMs,
|
|
74
|
+
failingPollMs = RELAY_EVENT_LIMITS.remotePollFailingMs,
|
|
75
|
+
gapMs = RELAY_EVENT_LIMITS.remoteGapMs,
|
|
76
|
+
timeoutMs = 15_000,
|
|
77
|
+
} = {}) {
|
|
78
|
+
if (!relayEvents) throw new Error('relayEvents is required');
|
|
79
|
+
// paired relay id → { timer, running, again, replay }
|
|
80
|
+
const links = new Map();
|
|
81
|
+
let started = false;
|
|
82
|
+
|
|
83
|
+
function warn(line) {
|
|
84
|
+
try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function linkState(relayId) {
|
|
88
|
+
if (!links.has(relayId)) links.set(relayId, { timer: null, running: null, again: false, replay: null, replayApplied: false });
|
|
89
|
+
return links.get(relayId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function forget(relayId) {
|
|
93
|
+
const link = links.get(relayId);
|
|
94
|
+
if (link?.timer) clearTimer(link.timer);
|
|
95
|
+
links.delete(relayId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function schedule(relayId, delayMs) {
|
|
99
|
+
if (!started) return;
|
|
100
|
+
const link = linkState(relayId);
|
|
101
|
+
if (link.timer) clearTimer(link.timer);
|
|
102
|
+
link.timer = setTimer(() => {
|
|
103
|
+
link.timer = null;
|
|
104
|
+
void fetchNow(relayId);
|
|
105
|
+
}, Math.max(0, delayMs));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function wanted(relayId) {
|
|
109
|
+
return relayEvents.remoteRelaysToPoll().includes(relayId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function failingForLong(relayId) {
|
|
113
|
+
const first = Date.parse(String(relayEvents.getRemoteLink(relayId).first_fail_at || ''));
|
|
114
|
+
return Number.isFinite(first) && now() - first >= gapMs;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether the link has been without an answer long enough to replay: since when. */
|
|
118
|
+
function gapSince(relayId) {
|
|
119
|
+
const health = relayEvents.getRemoteLink(relayId);
|
|
120
|
+
if (health.lost_at) return health.lost_at;
|
|
121
|
+
const lastOk = Date.parse(String(health.last_ok_at || ''));
|
|
122
|
+
return Number.isFinite(lastOk) && now() - lastOk >= gapMs ? health.last_ok_at : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function startReplay(link, relayId, since) {
|
|
126
|
+
const perWatcher = new Map();
|
|
127
|
+
for (const [watcher, subscriptionId] of relayEvents.remoteWatchers(relayId)) {
|
|
128
|
+
perWatcher.set(watcher, { subscriptionId, kept: 0, dropped: 0, ended: [] });
|
|
129
|
+
}
|
|
130
|
+
link.replay = { since, perWatcher };
|
|
131
|
+
link.replayApplied = false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function addToReplay(link, relayId, applied) {
|
|
135
|
+
for (const [watcher, info] of applied) {
|
|
136
|
+
let entry = link.replay.perWatcher.get(watcher);
|
|
137
|
+
if (!entry) {
|
|
138
|
+
const subscriptionId = relayEvents.remoteWatchers(relayId).get(watcher);
|
|
139
|
+
if (!subscriptionId) continue;
|
|
140
|
+
entry = { subscriptionId, kept: 0, dropped: 0, ended: [] };
|
|
141
|
+
link.replay.perWatcher.set(watcher, entry);
|
|
142
|
+
}
|
|
143
|
+
entry.kept += Number(info.kept) || 0;
|
|
144
|
+
entry.dropped += Number(info.dropped) || 0;
|
|
145
|
+
entry.ended.push(...(info.ended || []));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function fetchPages(relayId, link) {
|
|
150
|
+
const relay = registry?.get?.(relayId) || null;
|
|
151
|
+
if (!relay) {
|
|
152
|
+
// The pairing went without the hook (or before this boot).
|
|
153
|
+
relayEvents.noteRelayRemoved(relayId);
|
|
154
|
+
return { ok: false, removed: true, error: 'The pairing with that relay was removed.' };
|
|
155
|
+
}
|
|
156
|
+
for (let page = 0; page < MAX_PAGES_PER_FETCH; page += 1) {
|
|
157
|
+
const body = relayEvents.buildRemotePull(relayId);
|
|
158
|
+
if (!body.subscriptions.length && !body.ended.length && !link.replay) return { ok: true, idle: true };
|
|
159
|
+
if (!link.replay) {
|
|
160
|
+
const since = gapSince(relayId);
|
|
161
|
+
if (since) startReplay(link, relayId, since);
|
|
162
|
+
}
|
|
163
|
+
let response;
|
|
164
|
+
try {
|
|
165
|
+
response = await client.request(relay, 'POST', RELAY_EVENT_REMOTE_PULL_PATH, { body, timeoutMs });
|
|
166
|
+
if (!isPullAnswer(response)) throw new Error(`Relay "${toText(relay.name)}" gave an unexpected answer to the event fetch`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const text = describeFailure(relay, error);
|
|
169
|
+
relayEvents.noteRemoteLinkFailure(relayId, text);
|
|
170
|
+
// Nothing of a replay was written yet: the next fetch starts it again.
|
|
171
|
+
if (link.replay && !link.replayApplied) link.replay = null;
|
|
172
|
+
return { ok: false, error: text, unsupported: isMissingRelayEventRoute(error) };
|
|
173
|
+
}
|
|
174
|
+
const applied = relayEvents.applyRemotePull(relayId, { relayName: relay.name, response, replay: !!link.replay });
|
|
175
|
+
if (link.replay) {
|
|
176
|
+
link.replayApplied = true;
|
|
177
|
+
addToReplay(link, relayId, applied);
|
|
178
|
+
}
|
|
179
|
+
if (response.more !== true) break;
|
|
180
|
+
}
|
|
181
|
+
if (link.replay) {
|
|
182
|
+
const { since, perWatcher } = link.replay;
|
|
183
|
+
link.replay = null;
|
|
184
|
+
link.replayApplied = false;
|
|
185
|
+
relayEvents.noteRemoteLinkRestored(relayId, { relayName: relay.name, since, perWatcher });
|
|
186
|
+
}
|
|
187
|
+
return { ok: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Fetches from a paired relay now (a fetch already running is followed by
|
|
192
|
+
* one more). Resolves `{ ok, error?, idle?, unsupported? }`; never throws.
|
|
193
|
+
*/
|
|
194
|
+
function fetchNow(relayId) {
|
|
195
|
+
const id = toText(relayId);
|
|
196
|
+
const link = linkState(id);
|
|
197
|
+
if (link.timer) {
|
|
198
|
+
clearTimer(link.timer);
|
|
199
|
+
link.timer = null;
|
|
200
|
+
}
|
|
201
|
+
if (link.running) {
|
|
202
|
+
link.again = true;
|
|
203
|
+
return link.running;
|
|
204
|
+
}
|
|
205
|
+
link.running = (async () => {
|
|
206
|
+
let result;
|
|
207
|
+
try {
|
|
208
|
+
result = await fetchPages(id, link);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
warn(`fetch from paired relay ${id.slice(0, 12)} failed: ${toText(error?.message || error).slice(0, 200)}`);
|
|
211
|
+
result = { ok: false, error: toText(error?.message || error) };
|
|
212
|
+
} finally {
|
|
213
|
+
link.running = null;
|
|
214
|
+
}
|
|
215
|
+
if (result.removed) {
|
|
216
|
+
forget(id);
|
|
217
|
+
} else if (link.again) {
|
|
218
|
+
link.again = false;
|
|
219
|
+
schedule(id, 0);
|
|
220
|
+
} else if (wanted(id)) {
|
|
221
|
+
schedule(id, result.ok ? pollMs : (failingForLong(id) ? failingPollMs : pollMs));
|
|
222
|
+
} else if (!link.replay) {
|
|
223
|
+
forget(id);
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
})();
|
|
227
|
+
return link.running;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A fetch, waited for at most `waitMs` (list_subscriptions wants fresh state). */
|
|
231
|
+
async function refresh(relayId, { waitMs = 5_000 } = {}) {
|
|
232
|
+
let timer = null;
|
|
233
|
+
const timeout = new Promise((resolve) => {
|
|
234
|
+
timer = setTimer(() => resolve({ ok: false, error: 'timeout' }), waitMs);
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
return await Promise.race([fetchNow(relayId), timeout]);
|
|
238
|
+
} finally {
|
|
239
|
+
clearTimer(timer);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The pairing with a relay is being removed (`entry` still has its address
|
|
245
|
+
* and token): one last fetch tells it every subscription ended, then the
|
|
246
|
+
* mirrors end here. A relay that does not answer ends them itself after
|
|
247
|
+
* remoteKeepDays without a fetch.
|
|
248
|
+
*/
|
|
249
|
+
async function noteRelayRemoved(entry) {
|
|
250
|
+
const relayId = toText(entry?.id);
|
|
251
|
+
if (!relayId) return;
|
|
252
|
+
forget(relayId);
|
|
253
|
+
const rows = relayEvents.mirrorsOf(relayId);
|
|
254
|
+
if (rows.length && entry?.url) {
|
|
255
|
+
const body = {
|
|
256
|
+
after: Number(relayEvents.getRemoteLink(relayId).cursor) || 0,
|
|
257
|
+
subscriptions: [],
|
|
258
|
+
ended: rows.map((row) => row.remote_subscription_id),
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
await client.request(entry, 'POST', RELAY_EVENT_REMOTE_PULL_PATH, { body, timeoutMs: Math.min(timeoutMs, 10_000) });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
warn(`the paired relay being removed did not take the ends: ${describeFailure(entry, error)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
relayEvents.noteRelayRemoved(relayId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Boot: fetch from every paired relay this relay holds subscriptions on. */
|
|
270
|
+
function start() {
|
|
271
|
+
if (started) return;
|
|
272
|
+
started = true;
|
|
273
|
+
let delay = 0;
|
|
274
|
+
for (const relayId of relayEvents.remoteRelaysToPoll()) {
|
|
275
|
+
schedule(relayId, delay);
|
|
276
|
+
delay += 250;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stop() {
|
|
281
|
+
started = false;
|
|
282
|
+
for (const link of links.values()) {
|
|
283
|
+
if (link.timer) clearTimer(link.timer);
|
|
284
|
+
link.timer = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { start, stop, fetchNow, refresh, noteRelayRemoved, isPolling: (relayId) => links.has(toText(relayId)) };
|
|
289
|
+
}
|