@oar-sh/oar 0.9.15 → 0.9.17
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/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -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/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- 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/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- 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 +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- 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 +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- 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 +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- 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 +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -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
|
+
}
|