@oar-sh/oar 0.9.15 → 0.9.16
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 +85 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +568 -42
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
|
@@ -45,6 +45,8 @@ import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
|
|
|
45
45
|
import { createPreviewScopeResolver, registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
46
46
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
47
47
|
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
48
|
+
import { registerRelayScheduleRoutes } from './routes/relay-schedule-routes.mjs';
|
|
49
|
+
import { registerRelayAskRoutes } from './routes/relay-ask-routes.mjs';
|
|
48
50
|
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
49
51
|
import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
|
|
50
52
|
import { createShareRepository } from './repositories/share-repository.mjs';
|
|
@@ -84,6 +86,9 @@ import {
|
|
|
84
86
|
} from './services/restart-resume-service.mjs';
|
|
85
87
|
import { createBackgroundWorkView } from './services/background-work-view.mjs';
|
|
86
88
|
import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
89
|
+
import { createRelayScheduleService } from './services/relay-schedule-service.mjs';
|
|
90
|
+
import { createRelayAskService } from './services/relay-ask-service.mjs';
|
|
91
|
+
import { createRelayAskLinkService } from './services/relay-ask-link-service.mjs';
|
|
87
92
|
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
88
93
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
89
94
|
import {
|
|
@@ -5128,6 +5133,11 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
5128
5133
|
targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
|
|
5129
5134
|
});
|
|
5130
5135
|
tx();
|
|
5136
|
+
// Its schedules go on in the new conversation, both the ones that fire
|
|
5137
|
+
// into it and the ones it set: the archived source would end them.
|
|
5138
|
+
try { relayScheduleService.carryToCompacted(sourceConversationId, targetConversationId); } catch {}
|
|
5139
|
+
// And so do its questions that don't block, asked in it or by it.
|
|
5140
|
+
try { relayAskService.carryToCompacted(sourceConversationId, targetConversationId); } catch {}
|
|
5131
5141
|
|
|
5132
5142
|
return {
|
|
5133
5143
|
sourceConversationId,
|
|
@@ -5458,6 +5468,37 @@ relayEventService = createRelayEventService({
|
|
|
5458
5468
|
logger: console,
|
|
5459
5469
|
});
|
|
5460
5470
|
|
|
5471
|
+
// ─── Relay schedules ──────────────────────────────────────────────────────────
|
|
5472
|
+
// Prompts agents scheduled with remote_relay's `schedule`: kept in the
|
|
5473
|
+
// database, their timers rebuilt at boot (services/relay-schedule-service.mjs).
|
|
5474
|
+
const relayScheduleService = createRelayScheduleService({
|
|
5475
|
+
db,
|
|
5476
|
+
stmts,
|
|
5477
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
5478
|
+
// The scheduled message is an ordinary pending one, like a relay event's.
|
|
5479
|
+
wake: () => {
|
|
5480
|
+
void primePendingSessionWorkers('relay-schedule');
|
|
5481
|
+
sessionWorkerWebSocketService?.emitQueueChanged?.('relay-schedule');
|
|
5482
|
+
},
|
|
5483
|
+
// A firing into another session is a prompt of the conversation that set
|
|
5484
|
+
// it: the agents' loop guard counts it (and holds it once it wants the user).
|
|
5485
|
+
admitFiring: ({ createdBy }) => remoteRelayDispatcher.admitScheduledFiring({ createdBy }),
|
|
5486
|
+
logger: console,
|
|
5487
|
+
});
|
|
5488
|
+
|
|
5489
|
+
// ─── Questions that don't block ───────────────────────────────────────────────
|
|
5490
|
+
// remote_relay's `ask`: a card in the session and the Inbox; the answer
|
|
5491
|
+
// reaches the askers as a relay event (services/relay-ask-service.mjs).
|
|
5492
|
+
const relayAskService = createRelayAskService({
|
|
5493
|
+
db,
|
|
5494
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
5495
|
+
relayEvents: relayEventService,
|
|
5496
|
+
// urgency "now" only.
|
|
5497
|
+
pushQuestion: (question) => pushDispatchService.notifyQuestion?.(question),
|
|
5498
|
+
logger: console,
|
|
5499
|
+
});
|
|
5500
|
+
if (!relayAskService.isAvailable()) console.warn(`[relay-asks] not available this boot: ${relayAskService.problem()}`);
|
|
5501
|
+
|
|
5461
5502
|
/** A worker died: its tasks are gone, and whoever watches its sessions hears of it. */
|
|
5462
5503
|
function noteWorkerDeathForEvents(sessionId, conversationIds, reason) {
|
|
5463
5504
|
const ids = new Set((conversationIds || []).map((id) => String(id || '').trim()).filter(Boolean));
|
|
@@ -6883,7 +6924,10 @@ const remoteRelayRegistry = createRemoteRelayRegistry({
|
|
|
6883
6924
|
platform: process.platform,
|
|
6884
6925
|
version: RUNNING_VERSION,
|
|
6885
6926
|
emit: (event, payload) => io.emit(event, payload),
|
|
6886
|
-
onRemove: (entry) => {
|
|
6927
|
+
onRemove: (entry) => {
|
|
6928
|
+
void relayEventLinkService?.noteRelayRemoved(entry);
|
|
6929
|
+
try { relayAskService.noteRelayRemoved(entry?.id); } catch {}
|
|
6930
|
+
},
|
|
6887
6931
|
logger: console,
|
|
6888
6932
|
});
|
|
6889
6933
|
relayEventLinkService = createRelayEventLinkService({
|
|
@@ -6892,6 +6936,13 @@ relayEventLinkService = createRelayEventLinkService({
|
|
|
6892
6936
|
client: remoteRelayClient,
|
|
6893
6937
|
logger: console,
|
|
6894
6938
|
});
|
|
6939
|
+
// The answers to asks this relay's agents put on paired relays.
|
|
6940
|
+
const relayAskLinkService = createRelayAskLinkService({
|
|
6941
|
+
asks: relayAskService,
|
|
6942
|
+
registry: remoteRelayRegistry,
|
|
6943
|
+
client: remoteRelayClient,
|
|
6944
|
+
logger: console,
|
|
6945
|
+
});
|
|
6895
6946
|
const remoteRelayPairing = createRemoteRelayPairing({
|
|
6896
6947
|
registry: remoteRelayRegistry,
|
|
6897
6948
|
client: remoteRelayClient,
|
|
@@ -6946,6 +6997,9 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
|
|
|
6946
6997
|
resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
|
|
6947
6998
|
relayEvents: relayEventService,
|
|
6948
6999
|
relayEventLink: relayEventLinkService,
|
|
7000
|
+
relaySchedules: relayScheduleService,
|
|
7001
|
+
relayAsks: relayAskService,
|
|
7002
|
+
relayAskLink: relayAskLinkService,
|
|
6949
7003
|
logger: console,
|
|
6950
7004
|
});
|
|
6951
7005
|
|
|
@@ -7165,6 +7219,8 @@ const sharedRouteDeps = {
|
|
|
7165
7219
|
collectHostActivity,
|
|
7166
7220
|
usageLimitPauseService,
|
|
7167
7221
|
relayEventService,
|
|
7222
|
+
relayScheduleService,
|
|
7223
|
+
relayAskService,
|
|
7168
7224
|
sessionActivityTracker,
|
|
7169
7225
|
resolveConversationRecord,
|
|
7170
7226
|
markSharedViewerPresence,
|
|
@@ -7210,6 +7266,22 @@ registerRelayEventRoutes(app, {
|
|
|
7210
7266
|
relayEventService,
|
|
7211
7267
|
remoteRelayInbound,
|
|
7212
7268
|
});
|
|
7269
|
+
registerRelayScheduleRoutes(app, {
|
|
7270
|
+
auth,
|
|
7271
|
+
relayScheduleService,
|
|
7272
|
+
remoteRelayInbound,
|
|
7273
|
+
});
|
|
7274
|
+
registerRelayAskRoutes(app, {
|
|
7275
|
+
auth,
|
|
7276
|
+
db,
|
|
7277
|
+
stmts,
|
|
7278
|
+
formatQuestionRow,
|
|
7279
|
+
relayAskService,
|
|
7280
|
+
relayAskLinkService,
|
|
7281
|
+
remoteRelayInbound,
|
|
7282
|
+
remoteRelayRegistry,
|
|
7283
|
+
remoteRelayClient,
|
|
7284
|
+
});
|
|
7213
7285
|
registerRelayRestartRoutes(app, {
|
|
7214
7286
|
auth,
|
|
7215
7287
|
// Read only: a GET neither fires the restart nor asks the question.
|
|
@@ -7572,6 +7644,11 @@ void primePendingSessionWorkers('startup');
|
|
|
7572
7644
|
// heartbeat are swept from here on.
|
|
7573
7645
|
relayEventService.start();
|
|
7574
7646
|
relayEventLinkService.start();
|
|
7647
|
+
// Schedules get their timers back; one that came due while the relay was
|
|
7648
|
+
// down fires now, once.
|
|
7649
|
+
relayScheduleService.start();
|
|
7650
|
+
// Asks on paired relays still open there: look for their answers again.
|
|
7651
|
+
relayAskLinkService.start();
|
|
7575
7652
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
7576
7653
|
void primePendingSessionWorkers('maintenance');
|
|
7577
7654
|
detachedWorkerWatch.sweep().catch((error) => {
|
|
@@ -7892,6 +7969,8 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
7892
7969
|
try { relayEventService.noteRelayExit({ action: runtimeShutdownExitCode === RELAY_RESTART_EXIT_CODE ? 'restart' : 'shutdown', reason }); } catch {}
|
|
7893
7970
|
try { relayEventService.stop(); } catch {}
|
|
7894
7971
|
try { relayEventLinkService.stop(); } catch {}
|
|
7972
|
+
try { relayScheduleService.stop(); } catch {}
|
|
7973
|
+
try { relayAskLinkService.stop(); } catch {}
|
|
7895
7974
|
tmuxInspectorSocketService.stop();
|
|
7896
7975
|
stopWorkspaceFileWatcher();
|
|
7897
7976
|
sshTunnelManager.stop();
|
|
@@ -68,6 +68,9 @@ export function createCacheRebuildService({
|
|
|
68
68
|
// Session groups: a purged head leaves its group, and a wipe is followed
|
|
69
69
|
// by a repair of what the wiped rows headed. Absent in older wiring.
|
|
70
70
|
conversationGroups = null,
|
|
71
|
+
// (conversationId, { deleted }) => void: a conversation this service
|
|
72
|
+
// archived or removed (its relay schedules end). Absent in older wiring.
|
|
73
|
+
onConversationGone = null,
|
|
71
74
|
}) {
|
|
72
75
|
const legacyConversationList = buildLegacyConversationList(db);
|
|
73
76
|
const boundConversationList = buildBoundConversationList(db);
|
|
@@ -99,10 +102,19 @@ export function createCacheRebuildService({
|
|
|
99
102
|
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
|
|
100
103
|
})();
|
|
101
104
|
closePreviews(sid);
|
|
105
|
+
noteGone(sid, { deleted: true });
|
|
102
106
|
deleteOrphanedUploads(orphanedUploads);
|
|
103
107
|
return orphanedUploads;
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
function noteGone(conversationId, options) {
|
|
111
|
+
try {
|
|
112
|
+
onConversationGone?.(conversationId, options);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.warn(`[cache] the end of a conversation was not passed on: ${error?.message || error}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
function closePreviews(conversationId) {
|
|
107
119
|
try {
|
|
108
120
|
closeConversationPreviews?.(conversationId);
|
|
@@ -115,7 +127,9 @@ export function createCacheRebuildService({
|
|
|
115
127
|
const sid = normalizeId(conversationId);
|
|
116
128
|
if (!sid) return false;
|
|
117
129
|
const result = db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ? AND status != 'deleted'`).run(new Date().toISOString(), sid);
|
|
118
|
-
|
|
130
|
+
const archived = Number(result?.changes || 0) > 0;
|
|
131
|
+
if (archived) noteGone(sid, { deleted: false });
|
|
132
|
+
return archived;
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
function fullWipeCache() {
|
|
@@ -176,7 +190,10 @@ export function createCacheRebuildService({
|
|
|
176
190
|
})();
|
|
177
191
|
|
|
178
192
|
for (const conversationId of conversationIds) {
|
|
179
|
-
if (!manualTitleConversationSet.has(conversationId))
|
|
193
|
+
if (!manualTitleConversationSet.has(conversationId)) {
|
|
194
|
+
closePreviews(conversationId);
|
|
195
|
+
noteGone(conversationId, { deleted: true });
|
|
196
|
+
}
|
|
180
197
|
}
|
|
181
198
|
|
|
182
199
|
deleteOrphanedUploads(uploadHashes);
|
|
@@ -41,6 +41,9 @@ export function persistConversationPreferences({
|
|
|
41
41
|
// fallback is the relay default (on / summarized).
|
|
42
42
|
thinkingEnabled = undefined,
|
|
43
43
|
thinkingDisplay = undefined,
|
|
44
|
+
// The owner's "agents in this conversation may start sessions" (a boolean;
|
|
45
|
+
// `undefined` leaves it alone). The route decides who may write it.
|
|
46
|
+
mayCreateSessions = undefined,
|
|
44
47
|
updatedAt = new Date().toISOString(),
|
|
45
48
|
createIfMissing = false,
|
|
46
49
|
createTitle = 'Session',
|
|
@@ -56,6 +59,7 @@ export function persistConversationPreferences({
|
|
|
56
59
|
const enabled = writesThinkingEnabled ? parseThinkingEnabled(thinkingEnabled) : DEFAULT_THINKING_ENABLED;
|
|
57
60
|
const writesThinkingDisplay = thinkingDisplay !== undefined;
|
|
58
61
|
const display = writesThinkingDisplay ? parseThinkingDisplay(thinkingDisplay) : null;
|
|
62
|
+
const writesMayCreateSessions = mayCreateSessions !== undefined;
|
|
59
63
|
if (!db || !stmts || !convId || !mode) {
|
|
60
64
|
return {
|
|
61
65
|
ok: false,
|
|
@@ -66,6 +70,7 @@ export function persistConversationPreferences({
|
|
|
66
70
|
autoCompactWindow: null,
|
|
67
71
|
thinkingEnabled: DEFAULT_THINKING_ENABLED,
|
|
68
72
|
thinkingDisplay: DEFAULT_THINKING_DISPLAY,
|
|
73
|
+
mayCreateSessions: false,
|
|
69
74
|
updatedAt,
|
|
70
75
|
};
|
|
71
76
|
}
|
|
@@ -99,6 +104,10 @@ export function persistConversationPreferences({
|
|
|
99
104
|
extraSets.push('thinking_display = ?');
|
|
100
105
|
extraValues.push(display);
|
|
101
106
|
}
|
|
107
|
+
if (writesMayCreateSessions) {
|
|
108
|
+
extraSets.push('may_create_sessions = ?');
|
|
109
|
+
extraValues.push(mayCreateSessions === true ? 1 : 0);
|
|
110
|
+
}
|
|
102
111
|
try {
|
|
103
112
|
if (extraSets.length) {
|
|
104
113
|
db.prepare(`
|
|
@@ -136,12 +145,20 @@ export function persistConversationPreferences({
|
|
|
136
145
|
thinkingDisplay: writesThinkingDisplay
|
|
137
146
|
? display
|
|
138
147
|
: parseThinkingDisplay(existing?.thinking_display),
|
|
148
|
+
mayCreateSessions: writesMayCreateSessions
|
|
149
|
+
? mayCreateSessions === true
|
|
150
|
+
: readMayCreateSessions(existing),
|
|
139
151
|
updatedAt,
|
|
140
152
|
};
|
|
141
153
|
});
|
|
142
154
|
return writePreferences();
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
/** The owner's "agents in this conversation may start sessions" of a conversations row. */
|
|
158
|
+
export function readMayCreateSessions(row) {
|
|
159
|
+
return Number(row?.may_create_sessions) === 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
145
162
|
export function persistConversationModelPreference({
|
|
146
163
|
db,
|
|
147
164
|
stmts,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The asking side of questions that don't block on paired relays: while an
|
|
5
|
+
* ask this relay's agent put on a paired relay is open there (a mirror row,
|
|
6
|
+
* relay-ask-service.mjs), this relay asks that relay every few seconds how it
|
|
7
|
+
* stands (POST /api/relay-asks/remote/status) and closes what closed there;
|
|
8
|
+
* the ask service tells the conversation that asked. Only this relay calls,
|
|
9
|
+
* so a pairing in one direction is enough. A relay that does not answer is
|
|
10
|
+
* asked less often until it does; nothing is lost meanwhile, the asks wait
|
|
11
|
+
* there.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
|
|
15
|
+
import { RELAY_ASK_LIMITS } from '../../shared/relay-asks.mjs';
|
|
16
|
+
|
|
17
|
+
// The routes a relay serves its paired relays for asks (routes/relay-ask-routes.mjs).
|
|
18
|
+
export const RELAY_ASK_REMOTE_PATHS = Object.freeze({
|
|
19
|
+
ask: '/api/relay-asks/remote/ask',
|
|
20
|
+
status: '/api/relay-asks/remote/status',
|
|
21
|
+
withdraw: '/api/relay-asks/remote/withdraw',
|
|
22
|
+
answer: '/api/relay-asks/remote/answer',
|
|
23
|
+
dismiss: '/api/relay-asks/remote/dismiss',
|
|
24
|
+
inbox: '/api/relay-asks/remote/inbox',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const LOG_PREFIX = '[relay-asks]';
|
|
28
|
+
|
|
29
|
+
function toText(value) {
|
|
30
|
+
return String(value ?? '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A paired relay that predates asks answers their routes with Express's HTML
|
|
35
|
+
* 404: no JSON, no code. A 404 of these routes always carries one.
|
|
36
|
+
*/
|
|
37
|
+
export function isMissingRelayAskRoute(error) {
|
|
38
|
+
return toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound && !toText(error?.remoteBody?.code);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function relayWithoutAsksText(name) {
|
|
42
|
+
return `Relay "${toText(name) || 'the paired relay'}" runs an OAR version without questions that don't block; it needs an update before its sessions can be asked for from here.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createRelayAskLinkService({
|
|
46
|
+
asks,
|
|
47
|
+
// `get(id)`: the registry entry (address and token) of a paired relay.
|
|
48
|
+
registry,
|
|
49
|
+
// `request(relay, method, path, { body, timeoutMs })`: remote-relay-client.mjs.
|
|
50
|
+
client,
|
|
51
|
+
setTimer = (fn, ms) => {
|
|
52
|
+
const timer = setTimeout(fn, ms);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
return timer;
|
|
55
|
+
},
|
|
56
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
57
|
+
logger = console,
|
|
58
|
+
pollMs = RELAY_ASK_LIMITS.remotePollMs,
|
|
59
|
+
failingPollMs = RELAY_ASK_LIMITS.remotePollFailingMs,
|
|
60
|
+
timeoutMs = 15_000,
|
|
61
|
+
} = {}) {
|
|
62
|
+
if (!asks) throw new Error('asks is required');
|
|
63
|
+
// paired relay id → { timer, running, again }
|
|
64
|
+
const links = new Map();
|
|
65
|
+
let started = false;
|
|
66
|
+
|
|
67
|
+
function warn(line) {
|
|
68
|
+
try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function linkState(relayId) {
|
|
72
|
+
if (!links.has(relayId)) links.set(relayId, { timer: null, running: null, again: false });
|
|
73
|
+
return links.get(relayId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function forget(relayId) {
|
|
77
|
+
const link = links.get(relayId);
|
|
78
|
+
if (link?.timer) clearTimer(link.timer);
|
|
79
|
+
links.delete(relayId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function schedule(relayId, delayMs) {
|
|
83
|
+
if (!started) return;
|
|
84
|
+
const link = linkState(relayId);
|
|
85
|
+
if (link.timer) clearTimer(link.timer);
|
|
86
|
+
link.timer = setTimer(() => {
|
|
87
|
+
link.timer = null;
|
|
88
|
+
void fetchNow(relayId);
|
|
89
|
+
}, Math.max(0, delayMs));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchOnce(relayId) {
|
|
93
|
+
const ids = asks.pendingMirrorIds(relayId);
|
|
94
|
+
if (!ids.length) return { ok: true, idle: true };
|
|
95
|
+
const relay = registry?.get?.(relayId) || null;
|
|
96
|
+
if (!relay) {
|
|
97
|
+
asks.noteRelayRemoved(relayId);
|
|
98
|
+
return { ok: false, removed: true };
|
|
99
|
+
}
|
|
100
|
+
let response;
|
|
101
|
+
try {
|
|
102
|
+
response = await client.request(relay, 'POST', RELAY_ASK_REMOTE_PATHS.status, { body: { ids }, timeoutMs });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { ok: false, error: toText(error?.message || error).slice(0, 300) || 'no answer' };
|
|
105
|
+
}
|
|
106
|
+
if (!response || !Array.isArray(response.asks)) return { ok: false, error: 'unexpected answer' };
|
|
107
|
+
asks.applyRemoteStatus(relayId, { relayName: toText(relay.name), statuses: response.asks });
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Asks a paired relay now (one already running is followed by one more). Never throws. */
|
|
112
|
+
function fetchNow(relayId) {
|
|
113
|
+
const id = toText(relayId);
|
|
114
|
+
const link = linkState(id);
|
|
115
|
+
if (link.timer) {
|
|
116
|
+
clearTimer(link.timer);
|
|
117
|
+
link.timer = null;
|
|
118
|
+
}
|
|
119
|
+
if (link.running) {
|
|
120
|
+
link.again = true;
|
|
121
|
+
return link.running;
|
|
122
|
+
}
|
|
123
|
+
link.running = (async () => {
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = await fetchOnce(id);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
warn(`status from paired relay ${id.slice(0, 12)} failed: ${toText(error?.message || error).slice(0, 200)}`);
|
|
129
|
+
result = { ok: false };
|
|
130
|
+
} finally {
|
|
131
|
+
link.running = null;
|
|
132
|
+
}
|
|
133
|
+
if (result.removed) {
|
|
134
|
+
forget(id);
|
|
135
|
+
} else if (link.again) {
|
|
136
|
+
link.again = false;
|
|
137
|
+
schedule(id, 0);
|
|
138
|
+
} else if (asks.pendingMirrorIds(id).length) {
|
|
139
|
+
schedule(id, result.ok ? pollMs : failingPollMs);
|
|
140
|
+
} else {
|
|
141
|
+
forget(id);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
})();
|
|
145
|
+
return link.running;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** An ask was just put on that relay: look for its answer from now on. */
|
|
149
|
+
function noteAsked(relayId) {
|
|
150
|
+
const id = toText(relayId);
|
|
151
|
+
if (!id || links.get(id)?.timer || links.get(id)?.running) return;
|
|
152
|
+
schedule(id, pollMs);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function start() {
|
|
156
|
+
if (started) return;
|
|
157
|
+
started = true;
|
|
158
|
+
let delay = 0;
|
|
159
|
+
for (const relayId of asks.mirrorRelays()) {
|
|
160
|
+
schedule(relayId, delay);
|
|
161
|
+
delay += 250;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stop() {
|
|
166
|
+
started = false;
|
|
167
|
+
for (const link of links.values()) {
|
|
168
|
+
if (link.timer) clearTimer(link.timer);
|
|
169
|
+
link.timer = null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { start, stop, fetchNow, noteAsked, isPolling: (relayId) => links.has(toText(relayId)) };
|
|
174
|
+
}
|