@oar-sh/oar 0.9.14 → 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 +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -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/settings-modal.js +17 -3
- 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 +613 -56
- 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/oar-cli-helpers.mjs +12 -4
- 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/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- 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
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Questions that don't block: remote_relay's `ask` (shared/relay-asks.mjs).
|
|
5
|
+
* An agent asks the owner something and goes on working; the question shows
|
|
6
|
+
* as a card in its session and in the Inbox, and when the owner answers or
|
|
7
|
+
* dismisses it the relay tells the session the ask is in and the
|
|
8
|
+
* conversation that asked (a lead asking for its implementer) with a relay
|
|
9
|
+
* event, question_answered (relay-event-service.mjs noteQuestionAnswered).
|
|
10
|
+
*
|
|
11
|
+
* Kept in `relay_asks` (migrations/0023-relay-asks.mjs), never in
|
|
12
|
+
* `relay_questions`: those cards belong to a turn that waits on them, and the
|
|
13
|
+
* queue, the stall check and the expiry treat them so.
|
|
14
|
+
*
|
|
15
|
+
* Across paired relays an ask lives where its session lives:
|
|
16
|
+
*
|
|
17
|
+
* - Asked here by a paired relay's agent (`creator`): stored like any, with
|
|
18
|
+
* that relay's id and conversation; that relay looks for the outcome
|
|
19
|
+
* (statusForRelay) and tells its own conversation. The session here is
|
|
20
|
+
* told by this relay.
|
|
21
|
+
* - Asked by this relay's agent about a session on a paired relay: stored
|
|
22
|
+
* there; here a mirror row (`relay_id` = the pairing's registry id,
|
|
23
|
+
* `remote_id` = its id there) that the link (relay-ask-link-service.mjs)
|
|
24
|
+
* brings up to date until it is closed, then the conversation that asked
|
|
25
|
+
* is told (applyRemoteStatus).
|
|
26
|
+
*
|
|
27
|
+
* A pending ask ends when its session is archived or deleted (cancelled,
|
|
28
|
+
* nobody told) and moves with a compaction. Closed asks are kept a week.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
RELAY_ASK_CLOSED_STATUSES,
|
|
35
|
+
RELAY_ASK_ID_PREFIX,
|
|
36
|
+
RELAY_ASK_KIND,
|
|
37
|
+
RELAY_ASK_LIMITS,
|
|
38
|
+
RELAY_ASK_SOCKET_EVENT,
|
|
39
|
+
normalizeRelayAskAnswer,
|
|
40
|
+
normalizeRelayAskInput,
|
|
41
|
+
} from '../../shared/relay-asks.mjs';
|
|
42
|
+
import { relayAsksTableProblem } from '../migrations/0023-relay-asks.mjs';
|
|
43
|
+
|
|
44
|
+
const LOG_PREFIX = '[relay-asks]';
|
|
45
|
+
const SELF = 'self';
|
|
46
|
+
const PRUNE_INTERVAL_MS = 60 * 60_000;
|
|
47
|
+
|
|
48
|
+
function toText(value) {
|
|
49
|
+
return String(value ?? '').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shortId(value) {
|
|
53
|
+
return toText(value).slice(0, 8);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseJson(value, fallback) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(String(value ?? ''));
|
|
59
|
+
return parsed ?? fallback;
|
|
60
|
+
} catch {
|
|
61
|
+
return fallback;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clip(value, max) {
|
|
66
|
+
return toText(value).replace(/[\r\n]+/g, ' ').slice(0, max);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class RelayAskError extends Error {
|
|
70
|
+
constructor(code, message, extra = {}) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.code = code;
|
|
73
|
+
this.extra = extra;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A paired relay's asker as stored: capped one-line texts, nothing else. */
|
|
78
|
+
export function normalizeRelayAskCreator(raw) {
|
|
79
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
80
|
+
const creator = {
|
|
81
|
+
relayId: clip(raw.relayId, 100),
|
|
82
|
+
relayName: clip(raw.relayName, 60),
|
|
83
|
+
conversationId: clip(raw.conversationId, 200),
|
|
84
|
+
conversationTitle: clip(raw.conversationTitle, 200),
|
|
85
|
+
};
|
|
86
|
+
return creator.relayId ? creator : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createRelayAskService({
|
|
90
|
+
db,
|
|
91
|
+
emit = () => {},
|
|
92
|
+
// relay-event-service.mjs: question_asked for watchers, question_answered
|
|
93
|
+
// for the askers.
|
|
94
|
+
relayEvents = null,
|
|
95
|
+
// push-dispatch-service.mjs notifyQuestion, for urgency "now".
|
|
96
|
+
pushQuestion = () => {},
|
|
97
|
+
now = () => Date.now(),
|
|
98
|
+
newId = () => `${RELAY_ASK_ID_PREFIX}${randomBytes(6).toString('hex')}`,
|
|
99
|
+
logger = console,
|
|
100
|
+
} = {}) {
|
|
101
|
+
if (!db) throw new Error('db is required');
|
|
102
|
+
|
|
103
|
+
const problem = (() => {
|
|
104
|
+
try {
|
|
105
|
+
return relayAsksTableProblem(db);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return toText(error?.message || error) || 'unreadable';
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
const available = !problem;
|
|
111
|
+
|
|
112
|
+
const sql = available ? {
|
|
113
|
+
insert: db.prepare(`
|
|
114
|
+
INSERT INTO relay_asks
|
|
115
|
+
(id, relay_id, remote_id, conversation_id, conversation_title, created_by, creator_json,
|
|
116
|
+
question, options_json, recommendation, detail, urgency, status, created_at, updated_at)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
|
118
|
+
`),
|
|
119
|
+
get: db.prepare(`SELECT * FROM relay_asks WHERE id = ?`),
|
|
120
|
+
getMirror: db.prepare(`SELECT * FROM relay_asks WHERE relay_id = ? AND remote_id = ?`),
|
|
121
|
+
// The Inbox: this relay's asks, pending and closed since.
|
|
122
|
+
listHere: db.prepare(`
|
|
123
|
+
SELECT * FROM relay_asks
|
|
124
|
+
WHERE relay_id = 'self' AND (status = 'pending' OR (status IN ('answered', 'dismissed') AND answered_at >= ?))
|
|
125
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
126
|
+
`),
|
|
127
|
+
// list_questions: the asks in a session or asked by it, mirrors included.
|
|
128
|
+
listOf: db.prepare(`
|
|
129
|
+
SELECT * FROM relay_asks
|
|
130
|
+
WHERE (conversation_id = ? OR created_by = ?)
|
|
131
|
+
AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
|
|
132
|
+
ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
133
|
+
`),
|
|
134
|
+
countPendingHere: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND status = 'pending'`),
|
|
135
|
+
countPendingIn: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
|
|
136
|
+
countPendingBy: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
|
|
137
|
+
close: db.prepare(`
|
|
138
|
+
UPDATE relay_asks SET status = ?, answer = ?, choice = ?, answered_at = ?, answered_via = ?, updated_at = ?
|
|
139
|
+
WHERE id = ? AND status = 'pending'
|
|
140
|
+
`),
|
|
141
|
+
markNotified: db.prepare(`UPDATE relay_asks SET notified_at = ? WHERE id = ?`),
|
|
142
|
+
listPendingIn: db.prepare(`SELECT * FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
|
|
143
|
+
cancelIn: db.prepare(`
|
|
144
|
+
UPDATE relay_asks SET status = 'cancelled', updated_at = ? WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'
|
|
145
|
+
`),
|
|
146
|
+
cancelMirrorsBy: db.prepare(`
|
|
147
|
+
UPDATE relay_asks SET status = 'cancelled', updated_at = ? WHERE relay_id <> 'self' AND created_by = ? AND status = 'pending'
|
|
148
|
+
`),
|
|
149
|
+
carryIn: db.prepare(`UPDATE relay_asks SET conversation_id = ? WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
|
|
150
|
+
carryBy: db.prepare(`UPDATE relay_asks SET created_by = ? WHERE created_by = ? AND status = 'pending'`),
|
|
151
|
+
// Mirrors: the paired relays with asks of this relay's agents still open there.
|
|
152
|
+
listMirrorRelays: db.prepare(`SELECT DISTINCT relay_id FROM relay_asks WHERE relay_id <> 'self' AND status = 'pending'`),
|
|
153
|
+
listPendingMirrors: db.prepare(`SELECT * FROM relay_asks WHERE relay_id = ? AND status = 'pending' ORDER BY created_at ASC LIMIT ?`),
|
|
154
|
+
// Mirrors of a removed pairing: nobody answers them any more.
|
|
155
|
+
cancelMirrorsOf: db.prepare(`UPDATE relay_asks SET status = 'cancelled', updated_at = ? WHERE relay_id = ? AND status = 'pending'`),
|
|
156
|
+
pruneClosed: db.prepare(`DELETE FROM relay_asks WHERE status <> 'pending' AND updated_at < ?`),
|
|
157
|
+
getConversation: db.prepare(`SELECT id, title, archived, status, origin_json FROM conversations WHERE id = ?`),
|
|
158
|
+
} : null;
|
|
159
|
+
|
|
160
|
+
let lastPruneAt = 0;
|
|
161
|
+
|
|
162
|
+
function isoNow() {
|
|
163
|
+
return new Date(now()).toISOString();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function log(line) {
|
|
167
|
+
try { logger?.log?.(`${LOG_PREFIX} ${line}`); } catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function warn(line) {
|
|
171
|
+
try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function requireAvailable() {
|
|
175
|
+
if (!available) {
|
|
176
|
+
throw new RelayAskError('unavailable', 'Questions that don\'t block are not available on this relay right now (see its server log).');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function conversation(id) {
|
|
181
|
+
const key = toText(id);
|
|
182
|
+
if (!key) return null;
|
|
183
|
+
try {
|
|
184
|
+
return sql.getConversation.get(key) || null;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isLive(row) {
|
|
191
|
+
return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** An ask as agents, the session's card and the Inbox see it. */
|
|
195
|
+
function shape(row) {
|
|
196
|
+
if (!row) return null;
|
|
197
|
+
const mirror = toText(row.relay_id) !== SELF;
|
|
198
|
+
const session = mirror ? null : conversation(row.conversation_id);
|
|
199
|
+
const creator = normalizeRelayAskCreator(parseJson(row.creator_json, null));
|
|
200
|
+
const asker = conversation(row.created_by);
|
|
201
|
+
const options = parseJson(row.options_json, []);
|
|
202
|
+
const closed = toText(row.status) !== 'pending';
|
|
203
|
+
return {
|
|
204
|
+
id: mirror ? toText(row.remote_id) : row.id,
|
|
205
|
+
kind: RELAY_ASK_KIND,
|
|
206
|
+
...(mirror ? { relayId: row.relay_id } : {}),
|
|
207
|
+
session: {
|
|
208
|
+
id: row.conversation_id,
|
|
209
|
+
title: (mirror ? toText(row.conversation_title) : toText(session?.title)) || null,
|
|
210
|
+
// Who started the session: the owner, or an agent (the Inbox's filter).
|
|
211
|
+
...(mirror ? {} : { startedBy: toText(session?.origin_json) ? 'agent' : 'owner' }),
|
|
212
|
+
},
|
|
213
|
+
// The card views of relay questions read these names.
|
|
214
|
+
conversationId: row.conversation_id,
|
|
215
|
+
prompt: row.question,
|
|
216
|
+
choices: Array.isArray(options) ? options : [],
|
|
217
|
+
question: row.question,
|
|
218
|
+
options: Array.isArray(options) ? options : [],
|
|
219
|
+
recommendation: toText(row.recommendation) || null,
|
|
220
|
+
detail: String(row.detail || '') || null,
|
|
221
|
+
urgency: row.urgency === 'now' ? 'now' : 'normal',
|
|
222
|
+
status: row.status,
|
|
223
|
+
...(row.status === 'answered' ? { answer: row.answer || '', choice: toText(row.choice) || null } : {}),
|
|
224
|
+
...(closed && row.answered_at ? { answeredAt: row.answered_at } : {}),
|
|
225
|
+
...(closed && toText(row.answered_via) ? { answeredVia: toText(row.answered_via) } : {}),
|
|
226
|
+
// Who asked: a conversation here, or a paired relay's agent.
|
|
227
|
+
askedBy: creator
|
|
228
|
+
? { relay: creator.relayName || null, conversationId: creator.conversationId || null, title: creator.conversationTitle || null }
|
|
229
|
+
: (toText(row.created_by) ? { conversationId: row.created_by, title: toText(asker?.title) || null } : null),
|
|
230
|
+
onBehalf: creator ? true : (!!toText(row.created_by) && row.created_by !== row.conversation_id),
|
|
231
|
+
createdAt: row.created_at,
|
|
232
|
+
updatedAt: row.updated_at,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function pendingCount() {
|
|
237
|
+
if (!available) return 0;
|
|
238
|
+
try {
|
|
239
|
+
return Number(sql.countPendingHere.get()?.n || 0);
|
|
240
|
+
} catch {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function changed(row) {
|
|
246
|
+
try { emit(RELAY_ASK_SOCKET_EVENT, { ask: shape(row), pending: pendingCount() }); } catch {}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function prune() {
|
|
250
|
+
if (!available || now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
|
|
251
|
+
lastPruneAt = now();
|
|
252
|
+
try {
|
|
253
|
+
sql.pruneClosed.run(new Date(now() - RELAY_ASK_LIMITS.closedKeepMs).toISOString());
|
|
254
|
+
} catch (error) {
|
|
255
|
+
warn(`prune failed: ${toText(error?.message || error).slice(0, 200)}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Stores an ask about `conversationId` (a session of this relay). The asker
|
|
261
|
+
* is `createdBy` (a conversation here: the same one, or a lead asking for
|
|
262
|
+
* another) or `creator` (a paired relay's agent). Returns the ask.
|
|
263
|
+
*/
|
|
264
|
+
function create({ conversationId, createdBy = null, creator = null, ...input } = {}) {
|
|
265
|
+
requireAvailable();
|
|
266
|
+
const session = conversation(conversationId);
|
|
267
|
+
if (!isLive(session)) {
|
|
268
|
+
throw new RelayAskError('notFound', `No live session ${toText(conversationId)} on this relay; use an id from list_sessions.`, { session: toText(conversationId) });
|
|
269
|
+
}
|
|
270
|
+
const checked = normalizeRelayAskInput(input);
|
|
271
|
+
if (!checked.ok) throw new RelayAskError('invalid', checked.error);
|
|
272
|
+
const asker = toText(createdBy) || null;
|
|
273
|
+
const remoteCreator = asker ? null : normalizeRelayAskCreator(creator);
|
|
274
|
+
const limit = RELAY_ASK_LIMITS.pendingPerConversation;
|
|
275
|
+
if (asker && Number(sql.countPendingBy.get(asker)?.n || 0) >= limit) {
|
|
276
|
+
throw new RelayAskError('limit', `This conversation already has ${limit} questions pending; withdraw one (list_questions shows them) or wait for an answer.`);
|
|
277
|
+
}
|
|
278
|
+
if (Number(sql.countPendingIn.get(session.id)?.n || 0) >= limit) {
|
|
279
|
+
throw new RelayAskError('limit', `Session ${session.id} already has ${limit} questions pending for the user; wait for an answer.`);
|
|
280
|
+
}
|
|
281
|
+
const { question, options, recommendation, detail, urgency } = checked.value;
|
|
282
|
+
const id = newId();
|
|
283
|
+
const at = isoNow();
|
|
284
|
+
sql.insert.run(
|
|
285
|
+
id,
|
|
286
|
+
SELF,
|
|
287
|
+
null,
|
|
288
|
+
session.id,
|
|
289
|
+
null,
|
|
290
|
+
asker,
|
|
291
|
+
remoteCreator ? JSON.stringify(remoteCreator) : null,
|
|
292
|
+
question,
|
|
293
|
+
options.length ? JSON.stringify(options) : null,
|
|
294
|
+
recommendation || null,
|
|
295
|
+
detail || null,
|
|
296
|
+
urgency,
|
|
297
|
+
at,
|
|
298
|
+
at,
|
|
299
|
+
);
|
|
300
|
+
const row = sql.get.get(id);
|
|
301
|
+
const ask = shape(row);
|
|
302
|
+
log(`${id} in ${shortId(session.id)} by ${remoteCreator ? `relay ${shortId(remoteCreator.relayId)}` : shortId(asker)}${urgency === 'now' ? ' (now)' : ''}`);
|
|
303
|
+
changed(row);
|
|
304
|
+
try { relayEvents?.noteQuestion?.({ id, conversationId: session.id, prompt: question, choices: options, kind: RELAY_ASK_KIND }); } catch {}
|
|
305
|
+
if (urgency === 'now') {
|
|
306
|
+
try { void pushQuestion({ id, conversationId: session.id, prompt: question, choices: options }); } catch {}
|
|
307
|
+
}
|
|
308
|
+
prune();
|
|
309
|
+
return ask;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function get(id) {
|
|
313
|
+
if (!available) return null;
|
|
314
|
+
const row = sql.get.get(toText(id));
|
|
315
|
+
return row && row.relay_id === SELF ? shape(row) : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The Inbox's asks of this relay: pending, and those closed in the last answeredKeepMs. */
|
|
319
|
+
function listHere() {
|
|
320
|
+
if (!available) return [];
|
|
321
|
+
const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
|
|
322
|
+
return sql.listHere.all(since, RELAY_ASK_LIMITS.listMax).map(shape);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* list_questions: the asks in a conversation or asked by it (mirrors on
|
|
327
|
+
* paired relays included), pending first, then those closed in the last
|
|
328
|
+
* answeredKeepMs.
|
|
329
|
+
*/
|
|
330
|
+
function listOf(conversationId) {
|
|
331
|
+
if (!available) return [];
|
|
332
|
+
const id = toText(conversationId);
|
|
333
|
+
const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
|
|
334
|
+
const rows = sql.listOf.all(id, id, since, RELAY_ASK_LIMITS.listMax);
|
|
335
|
+
return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Who is told the outcome of a row here: its session and the conversation that asked. */
|
|
339
|
+
function tell(row) {
|
|
340
|
+
const ask = shape(row);
|
|
341
|
+
// `via`: the paired relay whose Inbox it came from; none for this relay's
|
|
342
|
+
// own UI (the event service names this relay then).
|
|
343
|
+
const via = toText(row.answered_via) ? { via: toText(row.answered_via) } : {};
|
|
344
|
+
const detail = row.status === 'answered'
|
|
345
|
+
? { questionId: row.id, prompt: row.question, answer: row.answer || '', choice: toText(row.choice), ...via }
|
|
346
|
+
: { questionId: row.id, prompt: row.question, dismissed: true, ...via };
|
|
347
|
+
try {
|
|
348
|
+
const notify = [...new Set([row.conversation_id, row.created_by].filter(Boolean))];
|
|
349
|
+
relayEvents?.noteQuestionAnswered?.({ sessionId: row.conversation_id, notify, detail });
|
|
350
|
+
sql.markNotified.run(isoNow(), row.id);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
warn(`${row.id} outcome not told: ${toText(error?.message || error).slice(0, 200)}`);
|
|
353
|
+
}
|
|
354
|
+
return ask;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function closeHere(id, status, { answer = null, choice = null, via = null } = {}) {
|
|
358
|
+
requireAvailable();
|
|
359
|
+
const row = sql.get.get(toText(id));
|
|
360
|
+
if (!row || row.relay_id !== SELF) throw new RelayAskError('notFound', `No question ${toText(id)} on this relay.`);
|
|
361
|
+
if (row.status !== 'pending') throw new RelayAskError('closed', `Question ${row.id} is already ${row.status}.`, { status: row.status });
|
|
362
|
+
const at = isoNow();
|
|
363
|
+
const result = sql.close.run(status, answer, choice || null, at, clip(via, 60) || null, at, row.id);
|
|
364
|
+
if (!result.changes) throw new RelayAskError('closed', `Question ${row.id} was closed meanwhile.`);
|
|
365
|
+
return sql.get.get(row.id);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The owner's answer: `choice` (an option) and/or `answer` (text). `via`
|
|
370
|
+
* names the paired relay whose Inbox it came from (null: this relay's own
|
|
371
|
+
* UI). Tells the session and the asker. Returns the ask.
|
|
372
|
+
*/
|
|
373
|
+
function answer(id, { answer: text = '', choice = '', via = null } = {}) {
|
|
374
|
+
requireAvailable();
|
|
375
|
+
const current = sql.get.get(toText(id));
|
|
376
|
+
if (!current || current.relay_id !== SELF) throw new RelayAskError('notFound', `No question ${toText(id)} on this relay.`);
|
|
377
|
+
const checked = normalizeRelayAskAnswer(shape(current), { answer: text, choice });
|
|
378
|
+
if (!checked.ok) throw new RelayAskError('invalid', checked.error);
|
|
379
|
+
const row = closeHere(id, 'answered', { answer: checked.value.answer, choice: checked.value.choice, via });
|
|
380
|
+
log(`${row.id} answered${via ? ` from relay ${clip(via, 30)}` : ''}`);
|
|
381
|
+
changed(row);
|
|
382
|
+
return tell(row);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The owner dismissed it: the asker is told there will be no answer. */
|
|
386
|
+
function dismiss(id, { via = null } = {}) {
|
|
387
|
+
const row = closeHere(id, 'dismissed', { via });
|
|
388
|
+
log(`${row.id} dismissed${via ? ` from relay ${clip(via, 30)}` : ''}`);
|
|
389
|
+
changed(row);
|
|
390
|
+
return tell(row);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The agent that asked takes it back: `byConversationId` (a conversation
|
|
395
|
+
* here) or `byRelayId` (a paired relay's id, for its agents' asks). Nobody
|
|
396
|
+
* is told: the asker knows.
|
|
397
|
+
*/
|
|
398
|
+
function withdraw(id, { byConversationId = null, byRelayId = null } = {}) {
|
|
399
|
+
requireAvailable();
|
|
400
|
+
const row = sql.get.get(toText(id));
|
|
401
|
+
if (!row || row.relay_id !== SELF) throw new RelayAskError('notFound', `No question ${toText(id)} on this relay.`);
|
|
402
|
+
const creator = normalizeRelayAskCreator(parseJson(row.creator_json, null));
|
|
403
|
+
const mine = byRelayId
|
|
404
|
+
? !!creator && creator.relayId === toText(byRelayId)
|
|
405
|
+
: !!toText(byConversationId) && row.created_by === toText(byConversationId);
|
|
406
|
+
if (!mine) throw new RelayAskError('forbidden', `Question ${row.id} was asked by another conversation; only the one that asked may withdraw it.`);
|
|
407
|
+
const closed = closeHere(row.id, 'withdrawn');
|
|
408
|
+
log(`${row.id} withdrawn`);
|
|
409
|
+
changed(closed);
|
|
410
|
+
return shape(closed);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Its session was archived or deleted: its pending asks end, and so do the mirrors it asked. */
|
|
414
|
+
function noteConversationGone(conversationId) {
|
|
415
|
+
if (!available) return 0;
|
|
416
|
+
const id = toText(conversationId);
|
|
417
|
+
if (!id) return 0;
|
|
418
|
+
const pending = sql.listPendingIn.all(id);
|
|
419
|
+
const at = isoNow();
|
|
420
|
+
const cancelled = sql.cancelIn.run(at, id).changes + sql.cancelMirrorsBy.run(at, id).changes;
|
|
421
|
+
for (const row of pending) changed(sql.get.get(row.id));
|
|
422
|
+
if (cancelled) log(`conv=${shortId(id)} gone: ${cancelled} pending question${cancelled === 1 ? '' : 's'} cancelled`);
|
|
423
|
+
return cancelled;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A compaction: the successor takes over the asks in and by the conversation. */
|
|
427
|
+
function carryToCompacted(sourceId, targetId) {
|
|
428
|
+
if (!available) return 0;
|
|
429
|
+
const from = toText(sourceId);
|
|
430
|
+
const to = toText(targetId);
|
|
431
|
+
if (!from || !to || from === to) return 0;
|
|
432
|
+
const moved = db.transaction(() => sql.carryIn.run(to, from).changes + sql.carryBy.run(to, from).changes)();
|
|
433
|
+
if (moved) log(`conv=${shortId(from)} compacted into ${shortId(to)}: ${moved} question${moved === 1 ? '' : 's'} moved`);
|
|
434
|
+
return moved;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Paired relays: the side where the session lives ───────────────────────
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What a paired relay may learn of the asks its agents asked here: per id,
|
|
441
|
+
* the status and the outcome, for the asks its relay id created only (any
|
|
442
|
+
* other id reads as unknown).
|
|
443
|
+
*/
|
|
444
|
+
function statusForRelay({ relayId, ids = [] } = {}) {
|
|
445
|
+
requireAvailable();
|
|
446
|
+
const subscriber = toText(relayId);
|
|
447
|
+
return (Array.isArray(ids) ? ids : []).slice(0, RELAY_ASK_LIMITS.listMax).map((raw) => {
|
|
448
|
+
const id = toText(raw);
|
|
449
|
+
const row = id ? sql.get.get(id) : null;
|
|
450
|
+
const creator = row && row.relay_id === SELF ? normalizeRelayAskCreator(parseJson(row.creator_json, null)) : null;
|
|
451
|
+
if (!creator || creator.relayId !== subscriber) return { id, status: 'unknown' };
|
|
452
|
+
return {
|
|
453
|
+
id,
|
|
454
|
+
status: row.status,
|
|
455
|
+
...(row.status === 'answered' ? { answer: row.answer || '', choice: toText(row.choice) || null } : {}),
|
|
456
|
+
...(row.answered_at ? { answeredAt: row.answered_at } : {}),
|
|
457
|
+
// A paired relay's Inbox it was answered from (none: this relay's UI).
|
|
458
|
+
...(toText(row.answered_via) ? { answeredVia: toText(row.answered_via) } : {}),
|
|
459
|
+
session: { id: row.conversation_id, title: toText(conversation(row.conversation_id)?.title) || null },
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ─── Paired relays: the side whose agent asked ─────────────────────────────
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* This relay's copy of an ask its agent (`createdBy`) put on the paired
|
|
468
|
+
* relay `relayId`; `remote` is the ask as that relay returned it.
|
|
469
|
+
*/
|
|
470
|
+
function addMirror({ relayId, createdBy, remote } = {}) {
|
|
471
|
+
requireAvailable();
|
|
472
|
+
const relay = toText(relayId);
|
|
473
|
+
const remoteId = toText(remote?.id);
|
|
474
|
+
if (!relay || relay === SELF || !remoteId) throw new RelayAskError('invalid', 'The paired relay did not return the question.');
|
|
475
|
+
const checked = normalizeRelayAskInput(remote);
|
|
476
|
+
if (!checked.ok) throw new RelayAskError('invalid', `The paired relay returned a question this relay does not take: ${checked.error}`);
|
|
477
|
+
const { question, options, recommendation, detail, urgency } = checked.value;
|
|
478
|
+
const at = isoNow();
|
|
479
|
+
const id = newId();
|
|
480
|
+
sql.insert.run(
|
|
481
|
+
id,
|
|
482
|
+
relay,
|
|
483
|
+
remoteId,
|
|
484
|
+
clip(remote?.session?.id || remote?.conversationId, 200),
|
|
485
|
+
clip(remote?.session?.title, 200) || null,
|
|
486
|
+
toText(createdBy) || null,
|
|
487
|
+
null,
|
|
488
|
+
question,
|
|
489
|
+
options.length ? JSON.stringify(options) : null,
|
|
490
|
+
recommendation || null,
|
|
491
|
+
detail || null,
|
|
492
|
+
urgency,
|
|
493
|
+
at,
|
|
494
|
+
at,
|
|
495
|
+
);
|
|
496
|
+
log(`${remoteId} asked on relay ${shortId(relay)} by ${shortId(createdBy)}`);
|
|
497
|
+
return shape(sql.get.get(id));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function getMirror(relayId, remoteId) {
|
|
501
|
+
if (!available) return null;
|
|
502
|
+
return shape(sql.getMirror.get(toText(relayId), toText(remoteId)));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The paired relays with asks of this relay's agents still open there. */
|
|
506
|
+
function mirrorRelays() {
|
|
507
|
+
if (!available) return [];
|
|
508
|
+
try {
|
|
509
|
+
return sql.listMirrorRelays.all().map((row) => toText(row.relay_id)).filter(Boolean);
|
|
510
|
+
} catch {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function pendingMirrorIds(relayId) {
|
|
516
|
+
if (!available) return [];
|
|
517
|
+
return sql.listPendingMirrors.all(toText(relayId), RELAY_ASK_LIMITS.listMax).map((row) => toText(row.remote_id));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* What the paired relay said of this relay's asks there: each one closed
|
|
522
|
+
* there is closed here, and its asker told (answered or dismissed; a
|
|
523
|
+
* withdrawn or vanished one quietly). Returns how many closed.
|
|
524
|
+
*/
|
|
525
|
+
function applyRemoteStatus(relayId, { relayName = '', statuses = [] } = {}) {
|
|
526
|
+
if (!available) return 0;
|
|
527
|
+
const relay = toText(relayId);
|
|
528
|
+
let closed = 0;
|
|
529
|
+
for (const state of Array.isArray(statuses) ? statuses : []) {
|
|
530
|
+
const row = sql.getMirror.get(relay, toText(state?.id));
|
|
531
|
+
if (!row || row.status !== 'pending') continue;
|
|
532
|
+
const status = toText(state.status);
|
|
533
|
+
if (status === 'pending') continue;
|
|
534
|
+
const final = ['answered', 'dismissed', 'withdrawn', 'cancelled'].includes(status) ? status : 'cancelled';
|
|
535
|
+
const at = isoNow();
|
|
536
|
+
const text = final === 'answered' ? String(state.answer ?? '').slice(0, RELAY_ASK_LIMITS.detailMax) : null;
|
|
537
|
+
const choice = final === 'answered' ? clip(state.choice, RELAY_ASK_LIMITS.optionMax) : null;
|
|
538
|
+
// The Inbox of a relay paired with that one it came from (this one,
|
|
539
|
+
// say); none: that relay's own UI, named by the event service.
|
|
540
|
+
const via = clip(state.answeredVia, 60);
|
|
541
|
+
if (!sql.close.run(final, text, choice || null, at, via || null, at, row.id).changes) continue;
|
|
542
|
+
closed += 1;
|
|
543
|
+
if (RELAY_ASK_CLOSED_STATUSES.includes(final) && toText(row.created_by)) {
|
|
544
|
+
try {
|
|
545
|
+
relayEvents?.noteQuestionAnswered?.({
|
|
546
|
+
sessionId: row.conversation_id,
|
|
547
|
+
sessionTitle: clip(state?.session?.title, 200) || row.conversation_title,
|
|
548
|
+
relay: { id: relay, name: toText(relayName) || relay },
|
|
549
|
+
notify: [row.created_by],
|
|
550
|
+
detail: final === 'answered'
|
|
551
|
+
? { questionId: row.remote_id, prompt: row.question, answer: text || '', choice: choice || '', ...(via ? { via } : {}) }
|
|
552
|
+
: { questionId: row.remote_id, prompt: row.question, dismissed: true, ...(via ? { via } : {}) },
|
|
553
|
+
});
|
|
554
|
+
sql.markNotified.run(isoNow(), row.id);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
warn(`${row.remote_id} outcome not told: ${toText(error?.message || error).slice(0, 200)}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
log(`${row.remote_id} on relay ${shortId(relay)} ${final}`);
|
|
560
|
+
}
|
|
561
|
+
return closed;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The pairing was removed: its open mirrors end. */
|
|
565
|
+
function noteRelayRemoved(relayId) {
|
|
566
|
+
if (!available) return 0;
|
|
567
|
+
return sql.cancelMirrorsOf.run(isoNow(), toText(relayId)).changes;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** The agent withdrew a mirror's ask there: closed here too. */
|
|
571
|
+
function closeMirror(relayId, remoteId, status = 'withdrawn') {
|
|
572
|
+
if (!available) return null;
|
|
573
|
+
const row = sql.getMirror.get(toText(relayId), toText(remoteId));
|
|
574
|
+
if (!row) return null;
|
|
575
|
+
if (row.status === 'pending') {
|
|
576
|
+
const at = isoNow();
|
|
577
|
+
sql.close.run(status, null, null, at, null, at, row.id);
|
|
578
|
+
}
|
|
579
|
+
return shape(sql.get.get(row.id));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
isAvailable: () => available,
|
|
584
|
+
problem: () => problem,
|
|
585
|
+
create,
|
|
586
|
+
get,
|
|
587
|
+
listHere,
|
|
588
|
+
listOf,
|
|
589
|
+
pendingCount,
|
|
590
|
+
answer,
|
|
591
|
+
dismiss,
|
|
592
|
+
withdraw,
|
|
593
|
+
noteConversationGone,
|
|
594
|
+
carryToCompacted,
|
|
595
|
+
statusForRelay,
|
|
596
|
+
addMirror,
|
|
597
|
+
getMirror,
|
|
598
|
+
mirrorRelays,
|
|
599
|
+
pendingMirrorIds,
|
|
600
|
+
applyRemoteStatus,
|
|
601
|
+
closeMirror,
|
|
602
|
+
noteRelayRemoved,
|
|
603
|
+
};
|
|
604
|
+
}
|