@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  5. package/server/claude-worker/claude-session-process.mjs +4 -4
  6. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  7. package/server/db-schema.mjs +52 -0
  8. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  9. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  10. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  11. package/server/migrations/0022-relay-schedules.mjs +142 -0
  12. package/server/migrations/0023-relay-asks.mjs +141 -0
  13. package/server/public/app/ask-user-view.js +3 -0
  14. package/server/public/app/bootstrap.js +61 -3
  15. package/server/public/app/context-usage-view.mjs +17 -0
  16. package/server/public/app/conversation-group-order.mjs +35 -0
  17. package/server/public/app/inbox-ui.js +356 -0
  18. package/server/public/app/inbox-view.mjs +370 -0
  19. package/server/public/app/journal-view.js +23 -2
  20. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  21. package/server/public/app/relay-event-view.mjs +33 -7
  22. package/server/public/app/relay-schedule-view.mjs +119 -0
  23. package/server/public/app/relay-schedules-ui.js +172 -0
  24. package/server/public/app/socket-handlers.js +27 -3
  25. package/server/public/app/store.js +18 -5
  26. package/server/public/index.html +159 -2
  27. package/server/relay-tools.md +18 -3
  28. package/server/repositories/conversation-group-repository.mjs +97 -11
  29. package/server/repositories/remote-relay-repository.mjs +20 -2
  30. package/server/repositories/session-repository.mjs +7 -1
  31. package/server/routes/ask-user-routes.mjs +57 -3
  32. package/server/routes/cache-routes.mjs +6 -0
  33. package/server/routes/conversation-group-routes.mjs +17 -2
  34. package/server/routes/messages-routes.mjs +3 -3
  35. package/server/routes/relay-ask-routes.mjs +387 -0
  36. package/server/routes/relay-event-routes.mjs +3 -1
  37. package/server/routes/relay-schedule-routes.mjs +57 -0
  38. package/server/routes/sessions-routes.mjs +568 -42
  39. package/server/server-runtime.mjs +80 -1
  40. package/server/services/cache-rebuild-service.mjs +19 -2
  41. package/server/services/conversation-preferences-service.mjs +17 -0
  42. package/server/services/relay-ask-link-service.mjs +174 -0
  43. package/server/services/relay-ask-service.mjs +604 -0
  44. package/server/services/relay-event-service.mjs +589 -73
  45. package/server/services/relay-schedule-service.mjs +596 -0
  46. package/server/services/remote-relay-caller-context.mjs +89 -20
  47. package/server/services/remote-relay-dispatcher.mjs +956 -30
  48. package/shared/relay-asks.mjs +145 -0
  49. package/shared/relay-events.mjs +268 -18
  50. package/shared/relay-schedules.mjs +128 -0
  51. package/shared/remote-relay-contract.mjs +553 -25
  52. package/shared/remote-relay-tool-core.mjs +16 -5
@@ -0,0 +1,387 @@
1
+ 'use strict';
2
+
3
+ // Questions that don't block (remote_relay `ask`) and the Inbox.
4
+ //
5
+ // For the browser (this relay's user; a request from another relay's agent,
6
+ // or this relay's own through the loopback, is refused):
7
+ //
8
+ // GET /api/relay-asks?conversationId= the asks a session's card shows
9
+ // GET /api/inbox every pending question here and on
10
+ // each paired relay that answers
11
+ // POST /api/inbox/relays/:relayId/asks/:id/answer | /dismiss
12
+ // an ask of a paired relay, answered
13
+ // from this Inbox (its cards are
14
+ // answered there only)
15
+ //
16
+ // An ask of this relay is answered through the question routes
17
+ // (ask-user-routes.mjs), like a card.
18
+ //
19
+ // For paired relays (only another relay's request, under the inbound switch,
20
+ // filed under the relay id it states):
21
+ //
22
+ // POST /api/relay-asks/remote/ask its agent asks about a session here
23
+ // POST /api/relay-asks/remote/status the outcome of the asks it asked
24
+ // POST /api/relay-asks/remote/withdraw it takes one back
25
+ // POST /api/relay-asks/remote/answer | /dismiss
26
+ // its user answers an ask of this
27
+ // relay from its Inbox: asks only;
28
+ // an approval or any other card is
29
+ // never answered over the pairing
30
+ // GET /api/relay-asks/remote/inbox what its Inbox shows of this relay
31
+ //
32
+ // Every text of an ask or card is an agent's: it goes out as data, and the
33
+ // browser shows it as plain text.
34
+
35
+ import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
36
+ import { RELAY_ASK_LIMITS, relayQuestionKind } from '../../shared/relay-asks.mjs';
37
+ import { schemaFields } from '../../shared/question-schema.mjs';
38
+ import { findOriginRemoteRelay, admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
39
+ import { RelayAskError } from '../services/relay-ask-service.mjs';
40
+ import { cleanSubscriberRelayId } from '../services/relay-event-service.mjs';
41
+ import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute } from '../services/relay-ask-link-service.mjs';
42
+
43
+ const RELAY_ASK_ERROR_STATUS = Object.freeze({
44
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
45
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
46
+ closed: [409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`],
47
+ forbidden: [403, REMOTE_RELAY_ERROR_CODES.forbidden],
48
+ limit: [409, REMOTE_RELAY_ERROR_CODES.questionLimit],
49
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
50
+ });
51
+
52
+ // A paired relay's Inbox is asked this long before it counts as unreachable.
53
+ const INBOX_REMOTE_TIMEOUT_MS = 5_000;
54
+ // Cards answered in the last day are listed under the divider, at most these.
55
+ const INBOX_ANSWERED_CARDS_MAX = 50;
56
+ // Registry states that mean "do not even try" for the Inbox.
57
+ const UNREACHABLE_STATES = new Set(['offline', 'unauthorized']);
58
+
59
+ export function sendRelayAskError(res, error, what) {
60
+ if (error instanceof RelayAskError) {
61
+ const [status, code] = RELAY_ASK_ERROR_STATUS[error.code] || [400, REMOTE_RELAY_ERROR_CODES.invalidInput];
62
+ return res.status(status).json({ error: error.message, code, ...(error.extra && typeof error.extra === 'object' ? error.extra : {}) });
63
+ }
64
+ return res.status(500).json({ error: `${what} failed: ${String(error?.message || error)}`, code: REMOTE_RELAY_ERROR_CODES.internal });
65
+ }
66
+
67
+ function asObject(value) {
68
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
69
+ }
70
+
71
+ function toText(value) {
72
+ return String(value ?? '').trim();
73
+ }
74
+
75
+ /**
76
+ * One Inbox item of a card a turn waits on (a formatted relay question).
77
+ * `forRemote`: for a paired relay's Inbox, where it is shown, never
78
+ * answered: an approval card goes with its header alone, an answered card
79
+ * without its answer, and nothing that routes an answer goes at all.
80
+ */
81
+ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
82
+ const kind = relayQuestionKind(question);
83
+ const context = asObject(question?.context);
84
+ const base = {
85
+ id: toText(question?.id),
86
+ kind,
87
+ session,
88
+ header: toText(context.header) || null,
89
+ status: toText(question?.status) || 'pending',
90
+ createdAt: question?.createdAt || null,
91
+ ...(question?.status === 'answered' ? { answeredAt: question?.answeredAt || null } : {}),
92
+ };
93
+ if (forRemote && kind === 'approval') return { ...base, question: '' };
94
+ const fields = schemaFields(question?.requestSchema);
95
+ return {
96
+ ...base,
97
+ question: String(question?.prompt || ''),
98
+ options: Array.isArray(question?.choices) ? question.choices.map((choice) => String(choice ?? '')).filter(Boolean) : [],
99
+ allowFreeform: question?.allowFreeform !== false,
100
+ multiSelect: context.multiSelect === true,
101
+ structured: fields.length > 0,
102
+ // A paired relay learns that a card was answered, never the answer.
103
+ ...(forRemote ? {} : {
104
+ ...(question?.status === 'answered' ? { answer: String(question?.answer || '') } : {}),
105
+ sdkSessionId: question?.sdkSessionId || null,
106
+ }),
107
+ };
108
+ }
109
+
110
+ /** One Inbox item of an ask (relay-ask-service.mjs shape). */
111
+ export function inboxItemOfAsk(ask) {
112
+ const {
113
+ id, session, question, options, recommendation, detail, urgency, status, answer, choice,
114
+ answeredAt, answeredVia, askedBy, onBehalf, createdAt,
115
+ } = ask;
116
+ return {
117
+ id,
118
+ kind: 'ask',
119
+ session,
120
+ question,
121
+ options,
122
+ recommendation,
123
+ detail,
124
+ urgency,
125
+ status,
126
+ ...(answer !== undefined ? { answer, choice } : {}),
127
+ ...(answeredAt ? { answeredAt } : {}),
128
+ ...(answeredVia ? { answeredVia } : {}),
129
+ askedBy,
130
+ onBehalf,
131
+ allowFreeform: true,
132
+ createdAt,
133
+ };
134
+ }
135
+
136
+ export function registerRelayAskRoutes(app, deps) {
137
+ const {
138
+ auth,
139
+ db,
140
+ stmts,
141
+ formatQuestionRow,
142
+ relayAskService = null,
143
+ relayAskLinkService = null,
144
+ remoteRelayInbound = null,
145
+ // The pairings (remote-relay-registry-service.mjs) and the client that
146
+ // calls them (remote-relay-client.mjs): the Inbox of paired relays.
147
+ remoteRelayRegistry = null,
148
+ remoteRelayClient = null,
149
+ now = () => Date.now(),
150
+ } = deps;
151
+
152
+ const conversationStmt = db?.prepare?.(`SELECT id, title, origin_json FROM conversations WHERE id = ?`) || null;
153
+ const answeredCardsStmt = db?.prepare?.(`
154
+ SELECT * FROM relay_questions WHERE status = 'answered' AND answered_at >= ? ORDER BY answered_at DESC LIMIT ?
155
+ `) || null;
156
+
157
+ function sessionOf(id) {
158
+ let row = null;
159
+ try {
160
+ row = conversationStmt?.get(toText(id)) || null;
161
+ } catch {}
162
+ return { id: toText(id), title: toText(row?.title) || null, startedBy: toText(row?.origin_json) ? 'agent' : 'owner' };
163
+ }
164
+
165
+ function available(res) {
166
+ if (relayAskService?.isAvailable?.()) return true;
167
+ res.status(503).json({
168
+ error: 'Questions that don\'t block are not available on this relay right now (see its server log).',
169
+ code: REMOTE_RELAY_ERROR_CODES.unsupported,
170
+ });
171
+ return false;
172
+ }
173
+
174
+ /** This relay's user only: another relay's agent, and this relay's own, are refused. */
175
+ function refuseRemote(req, res) {
176
+ const request = readRemoteRelayRequest(req, remoteRelayInbound || {});
177
+ if (!request.remote) return false;
178
+ res.status(403).json({ error: 'The Inbox is this relay\'s user\'s; agents use the remote_relay tool.', code: REMOTE_RELAY_ERROR_CODES.forbidden });
179
+ return true;
180
+ }
181
+
182
+ /**
183
+ * The paired relay a request comes from: `{ relayId, name }`, or null after
184
+ * answering. `name` is what this relay calls it, else the name it gives
185
+ * itself in `claimedName` (a pairing one way round knows no name here).
186
+ */
187
+ function admitPairedRelay(req, res, claimedName = '') {
188
+ const request = admitRemoteRelayRequest(req, res, remoteRelayInbound);
189
+ if (!request) return null;
190
+ if (!request.remote || request.local) {
191
+ res.status(403).json({
192
+ error: 'Only another relay calls these routes; this relay\'s own agents use the remote_relay tool and its user the Inbox.',
193
+ code: REMOTE_RELAY_ERROR_CODES.forbidden,
194
+ });
195
+ return null;
196
+ }
197
+ const relayId = cleanSubscriberRelayId(request.origin?.relayId);
198
+ if (!relayId) {
199
+ res.status(400).json({ error: 'The calling relay sent no relay id.', code: REMOTE_RELAY_ERROR_CODES.invalidInput });
200
+ return null;
201
+ }
202
+ const paired = findOriginRemoteRelay(remoteRelayInbound, request.origin);
203
+ const claimed = toText(claimedName).replace(/[\r\n]+/g, ' ').slice(0, 60);
204
+ return { relayId, name: toText(paired?.name) || claimed || toText(request.origin?.relayName) || relayId };
205
+ }
206
+
207
+ /** What an Inbox shows of this relay: its asks, and the cards turns wait on. */
208
+ function localInboxItems({ forRemote = false } = {}) {
209
+ const items = (relayAskService?.isAvailable?.() ? relayAskService.listHere() : []).map(inboxItemOfAsk);
210
+ let pending = [];
211
+ try {
212
+ pending = stmts.listQuestions.all('pending', null, null).map(formatQuestionRow).filter(Boolean);
213
+ } catch {}
214
+ let answered = [];
215
+ try {
216
+ const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
217
+ answered = (answeredCardsStmt?.all(since, INBOX_ANSWERED_CARDS_MAX) || []).map(formatQuestionRow).filter(Boolean);
218
+ } catch {}
219
+ for (const question of [...pending, ...answered]) {
220
+ items.push(inboxItemOfCard(question, sessionOf(question.conversationId), { forRemote }));
221
+ }
222
+ return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
223
+ }
224
+
225
+ function pendingOf(items) {
226
+ return items.filter((item) => item.status === 'pending').length;
227
+ }
228
+
229
+ // GET /api/relay-asks?conversationId= — the asks a session's card shows
230
+ // (pending, and closed in the last day), or this relay's for the Inbox.
231
+ app.get('/api/relay-asks', auth, (req, res) => {
232
+ if (refuseRemote(req, res)) return;
233
+ if (!relayAskService?.isAvailable?.()) return res.json({ available: false, asks: [], pending: 0 });
234
+ const conversationId = toText(req.query.conversationId);
235
+ const asks = conversationId
236
+ ? relayAskService.listOf(conversationId).filter((ask) => !ask.relayId && ask.conversationId === conversationId)
237
+ : relayAskService.listHere();
238
+ res.json({ available: true, asks, pending: relayAskService.pendingCount() });
239
+ });
240
+
241
+ async function remoteInbox(relay) {
242
+ const entry = { id: relay.id, name: toText(relay.name) || relay.id, url: toText(relay.url) || null };
243
+ if (UNREACHABLE_STATES.has(toText(relay.lastStatus).toLowerCase())) {
244
+ return { ...entry, reachable: false, error: toText(relay.lastError) || toText(relay.lastStatus), items: [] };
245
+ }
246
+ try {
247
+ const response = await remoteRelayClient.request(relay, 'GET', RELAY_ASK_REMOTE_PATHS.inbox, { timeoutMs: INBOX_REMOTE_TIMEOUT_MS });
248
+ const items = (Array.isArray(response?.items) ? response.items : []).slice(0, RELAY_ASK_LIMITS.listMax);
249
+ return { ...entry, reachable: true, items };
250
+ } catch (error) {
251
+ const text = isMissingRelayAskRoute(error)
252
+ ? 'runs an OAR version without the Inbox'
253
+ : toText(error?.message || error).slice(0, 200);
254
+ return { ...entry, reachable: false, error: text, items: [] };
255
+ }
256
+ }
257
+
258
+ // GET /api/inbox — every pending question across this relay and the
259
+ // paired relays that answer, with what was answered in the last day.
260
+ app.get('/api/inbox', auth, async (req, res) => {
261
+ if (refuseRemote(req, res)) return;
262
+ const own = localInboxItems();
263
+ const selfName = toText(remoteRelayRegistry?.selfName?.()) || 'this relay';
264
+ let paired = [];
265
+ try {
266
+ paired = remoteRelayClient && remoteRelayRegistry?.list ? (remoteRelayRegistry.list() || []) : [];
267
+ } catch {}
268
+ const remote = await Promise.all(paired.map(remoteInbox));
269
+ const relays = [{ id: 'self', name: selfName, self: true, reachable: true, items: own }, ...remote];
270
+ res.json({ relays, pending: relays.reduce((sum, relay) => sum + pendingOf(relay.items), 0) });
271
+ });
272
+
273
+ function pairedRelayOf(relayId) {
274
+ try {
275
+ return remoteRelayRegistry?.get?.(toText(relayId)) || null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ async function answerThere(req, res, action) {
282
+ if (refuseRemote(req, res)) return;
283
+ const relay = pairedRelayOf(req.params.relayId);
284
+ if (!relay || !remoteRelayClient) return res.status(404).json({ error: 'No paired relay with that id.', code: REMOTE_RELAY_ERROR_CODES.unknown });
285
+ const body = asObject(req.body);
286
+ try {
287
+ const response = await remoteRelayClient.request(relay, 'POST', RELAY_ASK_REMOTE_PATHS[action], {
288
+ body: {
289
+ id: toText(req.params.id),
290
+ ...(action === 'answer' ? { answer: String(body.answer ?? ''), choice: String(body.choice ?? '') } : {}),
291
+ // Shown there as where the answer came from, when it has no name for this relay.
292
+ via: toText(remoteRelayRegistry?.selfName?.()),
293
+ },
294
+ });
295
+ res.json({ ok: true, ask: asObject(response?.ask) });
296
+ } catch (error) {
297
+ const status = Number(error?.status) >= 400 && Number(error.status) < 500 ? Number(error.status) : 502;
298
+ res.status(status).json({ error: toText(error?.message) || `Relay "${relay.name}" did not answer.`, code: toText(error?.code) || REMOTE_RELAY_ERROR_CODES.offline });
299
+ }
300
+ }
301
+
302
+ // POST /api/inbox/relays/:relayId/asks/:id/answer — `{ answer, choice }`.
303
+ app.post('/api/inbox/relays/:relayId/asks/:id/answer', auth, (req, res) => { void answerThere(req, res, 'answer'); });
304
+ app.post('/api/inbox/relays/:relayId/asks/:id/dismiss', auth, (req, res) => { void answerThere(req, res, 'dismiss'); });
305
+
306
+ // ─── Paired relays ─────────────────────────────────────────────────────────
307
+
308
+ // POST /api/relay-asks/remote/ask — `{ session, question, options,
309
+ // recommendation, detail, urgency, asker: { conversationId, title, relayName } }`.
310
+ app.post('/api/relay-asks/remote/ask', auth, (req, res) => {
311
+ const body = asObject(req.body);
312
+ const asker = asObject(body.asker);
313
+ const caller = admitPairedRelay(req, res, asker.relayName);
314
+ if (!caller || !available(res)) return;
315
+ try {
316
+ const ask = relayAskService.create({
317
+ conversationId: toText(body.session),
318
+ creator: { relayId: caller.relayId, relayName: caller.name, conversationId: toText(asker.conversationId), conversationTitle: toText(asker.title) },
319
+ question: body.question,
320
+ options: body.options,
321
+ recommendation: body.recommendation,
322
+ detail: body.detail,
323
+ urgency: body.urgency,
324
+ });
325
+ res.json({ ok: true, ask });
326
+ } catch (error) {
327
+ sendRelayAskError(res, error, 'The ask');
328
+ }
329
+ });
330
+
331
+ // POST /api/relay-asks/remote/status — `{ ids }`: the asks this relay's caller asked.
332
+ app.post('/api/relay-asks/remote/status', auth, (req, res) => {
333
+ const caller = admitPairedRelay(req, res);
334
+ if (!caller || !available(res)) return;
335
+ try {
336
+ res.json({ ok: true, asks: relayAskService.statusForRelay({ relayId: caller.relayId, ids: asObject(req.body).ids }) });
337
+ } catch (error) {
338
+ sendRelayAskError(res, error, 'The status');
339
+ }
340
+ });
341
+
342
+ // POST /api/relay-asks/remote/withdraw — `{ id }`.
343
+ app.post('/api/relay-asks/remote/withdraw', auth, (req, res) => {
344
+ const caller = admitPairedRelay(req, res);
345
+ if (!caller || !available(res)) return;
346
+ try {
347
+ res.json({ ok: true, ask: relayAskService.withdraw(toText(asObject(req.body).id), { byRelayId: caller.relayId }) });
348
+ } catch (error) {
349
+ sendRelayAskError(res, error, 'The withdrawal');
350
+ }
351
+ });
352
+
353
+ // POST /api/relay-asks/remote/answer — `{ id, answer, choice }`, and
354
+ // /dismiss — `{ id }`: an ask of this relay, answered from a paired
355
+ // relay's Inbox. Only asks: an id of any other card is not found here.
356
+ app.post('/api/relay-asks/remote/answer', auth, (req, res) => {
357
+ const body = asObject(req.body);
358
+ const caller = admitPairedRelay(req, res, body.via);
359
+ if (!caller || !available(res)) return;
360
+ try {
361
+ res.json({ ok: true, ask: relayAskService.answer(toText(body.id), { answer: String(body.answer ?? ''), choice: String(body.choice ?? ''), via: caller.name }) });
362
+ } catch (error) {
363
+ sendRelayAskError(res, error, 'The answer');
364
+ }
365
+ });
366
+
367
+ app.post('/api/relay-asks/remote/dismiss', auth, (req, res) => {
368
+ const body = asObject(req.body);
369
+ const caller = admitPairedRelay(req, res, body.via);
370
+ if (!caller || !available(res)) return;
371
+ try {
372
+ res.json({ ok: true, ask: relayAskService.dismiss(toText(body.id), { via: caller.name }) });
373
+ } catch (error) {
374
+ sendRelayAskError(res, error, 'The dismissal');
375
+ }
376
+ });
377
+
378
+ // GET /api/relay-asks/remote/inbox — what a paired relay's Inbox shows of this one.
379
+ app.get('/api/relay-asks/remote/inbox', auth, (req, res) => {
380
+ const caller = admitPairedRelay(req, res);
381
+ if (!caller) return;
382
+ const items = localInboxItems({ forRemote: true });
383
+ res.json({ ok: true, items, pending: pendingOf(items) });
384
+ });
385
+
386
+ return { localInboxItems, noteAsked: (relayId) => relayAskLinkService?.noteAsked?.(relayId) };
387
+ }
@@ -92,7 +92,8 @@ export function registerRelayEventRoutes(app, deps) {
92
92
  // POST /api/relay-events/remote/subscribe — a paired relay's agent
93
93
  // subscribes: `{ watcher: { conversationId, title }, subscriber: { name },
94
94
  // scope, sessions, events, once, match, stalled_minutes, heartbeat_minutes,
95
- // dry_run }`. A dry run checks everything and stores nothing.
95
+ // exclude, dry_run }`. A dry run checks everything and stores nothing.
96
+ // Digest and quiet hours are the watching relay's: never sent here.
96
97
  app.post('/api/relay-events/remote/subscribe', auth, (req, res) => {
97
98
  const subscriber = admitPairedRelay(req, res);
98
99
  if (!subscriber) return;
@@ -111,6 +112,7 @@ export function registerRelayEventRoutes(app, deps) {
111
112
  match: typeof body.match === 'string' ? body.match : '',
112
113
  stalledMinutes: body.stalled_minutes ?? null,
113
114
  heartbeatMinutes: body.heartbeat_minutes ?? null,
115
+ exclude: Array.isArray(body.exclude) ? body.exclude.map((id) => String(id ?? '')) : null,
114
116
  dryRun: body.dry_run === true,
115
117
  });
116
118
  res.json({ ok: true, ...(body.dry_run === true ? { dryRun: true } : {}), subscription });
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ // Relay schedules, for the browser: the active schedules (the "scheduled"
4
+ // marker and the list in the header) and Cancel. Agents use the
5
+ // remote_relay tool instead; a request from another relay's agent is
6
+ // refused here, so only this relay's user cancels a schedule from outside
7
+ // its conversation.
8
+
9
+ import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
10
+ import { readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
11
+ import { RelayScheduleError } from '../services/relay-schedule-service.mjs';
12
+
13
+ const RELAY_SCHEDULE_ERROR_STATUS = Object.freeze({
14
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
15
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
16
+ limit: [409, REMOTE_RELAY_ERROR_CODES.scheduleLimit],
17
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
18
+ });
19
+
20
+ export function registerRelayScheduleRoutes(app, deps) {
21
+ const {
22
+ auth,
23
+ relayScheduleService = null,
24
+ remoteRelayInbound = null,
25
+ } = deps;
26
+
27
+ function refuseRemote(req, res) {
28
+ const request = readRemoteRelayRequest(req, remoteRelayInbound || {});
29
+ if (!request.remote) return false;
30
+ res.status(403).json({ error: 'Relay schedules are managed by this relay\'s user and its own agents only.' });
31
+ return true;
32
+ }
33
+
34
+ // GET /api/relay-schedules — every active schedule.
35
+ app.get('/api/relay-schedules', auth, (req, res) => {
36
+ if (refuseRemote(req, res)) return;
37
+ if (!relayScheduleService?.isAvailable?.()) return res.json({ available: false, schedules: [] });
38
+ res.json({ available: true, schedules: relayScheduleService.listActive() });
39
+ });
40
+
41
+ // DELETE /api/relay-schedules/:id — the user cancels a schedule.
42
+ app.delete('/api/relay-schedules/:id', auth, (req, res) => {
43
+ if (refuseRemote(req, res)) return;
44
+ try {
45
+ const schedule = relayScheduleService
46
+ ? relayScheduleService.unschedule({ scheduleId: String(req.params.id || '') })
47
+ : (() => { throw new RelayScheduleError('unavailable', 'Schedules are not available on this relay right now.'); })();
48
+ res.json({ ok: true, schedule });
49
+ } catch (error) {
50
+ if (error instanceof RelayScheduleError) {
51
+ const [status, code] = RELAY_SCHEDULE_ERROR_STATUS[error.code] || [400, REMOTE_RELAY_ERROR_CODES.invalidInput];
52
+ return res.status(status).json({ error: error.message, code });
53
+ }
54
+ res.status(500).json({ error: `Cancel failed: ${String(error?.message || error)}`, code: REMOTE_RELAY_ERROR_CODES.internal });
55
+ }
56
+ });
57
+ }