@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. 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
- try {
406
- socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
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
- const upstreamReq = httpImpl.request({
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, upstreamSocket, upstreamHead) => {
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
- try {
447
- socket.write(`${lines.join('\r\n')}\r\n\r\n`);
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
- try {
466
- socket.write(`HTTP/1.1 ${upstreamRes.statusCode} ${upstreamRes.statusMessage}\r\nConnection: close\r\n\r\n`);
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
- try { socket.destroy(); } catch {}
489
+ teardown();
470
490
  });
471
491
 
472
492
  upstreamReq.on('error', () => {
473
- try {
474
- socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
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
+ }