@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
|
@@ -23,6 +23,30 @@
|
|
|
23
23
|
* interval while its watched set has a turn or task running, none while it
|
|
24
24
|
* is idle: the watcher's sign that its watching still works.
|
|
25
25
|
*
|
|
26
|
+
* Options of a subscription (migrations/0019-relay-event-subscription-options.mjs),
|
|
27
|
+
* all applied where the watcher is (for a paired relay's sessions: on the
|
|
28
|
+
* mirror here):
|
|
29
|
+
*
|
|
30
|
+
* - `digest_minutes`: its non-urgent events are held and go as one row, N
|
|
31
|
+
* minutes after the last such row at the earliest.
|
|
32
|
+
* - `quiet_hours`: its non-urgent events that would go inside the window are
|
|
33
|
+
* held until it ends; no heartbeat is made inside it.
|
|
34
|
+
* - `exclude`: sessions an "all" subscription leaves out (the watched relay
|
|
35
|
+
* is told too, for all_idle, stalled and the heartbeat's list).
|
|
36
|
+
*
|
|
37
|
+
* question_asked and error are never held. A one-shot subscription's event
|
|
38
|
+
* keeps to its options after the subscription ended with it, and what a
|
|
39
|
+
* paired relay replays after a link gap keeps to them too. An event that several of the
|
|
40
|
+
* watcher's subscriptions took goes when the first of them lets it; the
|
|
41
|
+
* hourly cap stays the outer limit. Held events are undelivered rows like
|
|
42
|
+
* any, so they and their timers survive a restart (planDelivery).
|
|
43
|
+
*
|
|
44
|
+
* The watcher's own turns: a prompt it sent with send or create_session is
|
|
45
|
+
* noted (noteOwnPrompt). While it waits for the reply (beginOwnWait) that
|
|
46
|
+
* turn's turn_end is held; a wait that returns the finished reply
|
|
47
|
+
* (endOwnWait with `received`) drops it, and a turn_end that comes later is
|
|
48
|
+
* never recorded for it. Errors and questions of the turn still go.
|
|
49
|
+
*
|
|
26
50
|
* `transferSubscriptions` moves every subscription of one watcher, with its
|
|
27
51
|
* undelivered events, to another: a watcher is replaced without a moment
|
|
28
52
|
* where events go nowhere.
|
|
@@ -64,6 +88,7 @@ import {
|
|
|
64
88
|
RELAY_EVENT_LINK_LOST_TYPE,
|
|
65
89
|
RELAY_EVENT_LINK_RESTORED_TYPE,
|
|
66
90
|
RELAY_EVENT_MESSAGE_KIND,
|
|
91
|
+
RELAY_EVENT_QUESTION_ANSWERED_TYPE,
|
|
67
92
|
RELAY_EVENT_RELAY_LEVEL_TYPES,
|
|
68
93
|
RELAY_EVENT_RELAY_RESTARTED_TYPE,
|
|
69
94
|
RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
|
|
@@ -77,7 +102,9 @@ import {
|
|
|
77
102
|
isRelayEventType,
|
|
78
103
|
isRelayLevelOnly,
|
|
79
104
|
isUrgentRelayEventType,
|
|
105
|
+
normalizeRelayEventQuietHours,
|
|
80
106
|
relayEventMatchText,
|
|
107
|
+
relayEventQuietWindowEnd,
|
|
81
108
|
relayEventTypesTake,
|
|
82
109
|
} from '../../shared/relay-events.mjs';
|
|
83
110
|
|
|
@@ -145,10 +172,19 @@ export const RELAY_EVENT_PRUNE_SQL = Object.freeze({
|
|
|
145
172
|
DELETE FROM relay_event_subscriptions WHERE ended_at IS NOT NULL AND ended_at < ?
|
|
146
173
|
`,
|
|
147
174
|
});
|
|
175
|
+
// A turn_end held while the watcher waits for that turn's reply goes anyway
|
|
176
|
+
// this long after the wait should have ended (a wait that never reported).
|
|
177
|
+
const OWN_WAIT_GRACE_MS = 60_000;
|
|
178
|
+
// The watcher's own prompts are kept a day: longer than any digest or quiet
|
|
179
|
+
// window holds their turn_end.
|
|
180
|
+
export const RELAY_EVENT_OWN_PROMPT_KEEP_MS = 25 * HOUR_MS;
|
|
148
181
|
// A watched set must look idle twice, this far apart, before all_idle fires:
|
|
149
182
|
// a task's end and the continuation turn it opens are seconds apart.
|
|
150
183
|
const ALL_IDLE_SETTLE_MS = 10_000;
|
|
151
184
|
const LOG_PREFIX = '[relay-events]';
|
|
185
|
+
// The `subscription_id` of an answer told to the conversations that asked:
|
|
186
|
+
// no subscription stands behind it (noteQuestionAnswered).
|
|
187
|
+
const ASK_DELIVERY_PREFIX = 'ask:';
|
|
152
188
|
|
|
153
189
|
function toText(value) {
|
|
154
190
|
return String(value ?? '').trim();
|
|
@@ -261,7 +297,16 @@ export function createRelayEventService({
|
|
|
261
297
|
`),
|
|
262
298
|
listEventsOfMessage: db.prepare(`SELECT * FROM relay_events WHERE message_id = ? ORDER BY id ASC`),
|
|
263
299
|
markDelivered: db.prepare(`UPDATE relay_events SET delivered_at = ?, message_id = ? WHERE id = ?`),
|
|
300
|
+
setEventPayload: db.prepare(`UPDATE relay_events SET payload_json = ? WHERE id = ?`),
|
|
301
|
+
deleteEvent: db.prepare(`DELETE FROM relay_events WHERE id = ? AND delivered_at IS NULL`),
|
|
264
302
|
dropUndelivered: db.prepare(`DELETE FROM relay_events WHERE watcher_conversation_id = ? AND delivered_at IS NULL`),
|
|
303
|
+
// An answer to the watcher's own ask is owed without a subscription.
|
|
304
|
+
dropUndeliveredUnowed: db.prepare(`
|
|
305
|
+
DELETE FROM relay_events WHERE watcher_conversation_id = ? AND delivered_at IS NULL
|
|
306
|
+
AND subscription_id NOT IN (SELECT id FROM relay_event_subscriptions WHERE end_reason = 'fired')
|
|
307
|
+
AND json_extract(payload_json, '$.ends') IS NULL
|
|
308
|
+
AND substr(subscription_id, 1, ${ASK_DELIVERY_PREFIX.length}) <> '${ASK_DELIVERY_PREFIX}'
|
|
309
|
+
`),
|
|
265
310
|
// Wake-ups: the event messages that carried more than a heartbeat.
|
|
266
311
|
countWakes: db.prepare(`
|
|
267
312
|
SELECT COUNT(DISTINCT message_id) AS n FROM relay_events
|
|
@@ -321,6 +366,23 @@ export function createRelayEventService({
|
|
|
321
366
|
`),
|
|
322
367
|
take: db.prepare(`DELETE FROM app_settings WHERE key = ?`),
|
|
323
368
|
} : null;
|
|
369
|
+
// Migration 0019: without it a subscription takes no options and the
|
|
370
|
+
// watcher's own replies are delivered as before.
|
|
371
|
+
const optionsAvailable = available && db
|
|
372
|
+
.prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().some((column) => column.name === 'quiet_hours_json');
|
|
373
|
+
const optionSql = optionsAvailable ? {
|
|
374
|
+
setOptions: db.prepare(`UPDATE relay_event_subscriptions SET digest_minutes = ?, quiet_hours_json = ?, exclude_json = ? WHERE id = ?`),
|
|
375
|
+
markDigestDelivered: db.prepare(`UPDATE relay_event_subscriptions SET digest_last_at = ? WHERE id = ?`),
|
|
376
|
+
} : null;
|
|
377
|
+
const ownSql = optionsAvailable && tableExists('relay_event_own_prompts') ? {
|
|
378
|
+
insert: db.prepare(`
|
|
379
|
+
INSERT OR IGNORE INTO relay_event_own_prompts (message_id, watcher_conversation_id, relay_id, session_id, created_at)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?)
|
|
381
|
+
`),
|
|
382
|
+
get: db.prepare(`SELECT * FROM relay_event_own_prompts WHERE message_id = ?`),
|
|
383
|
+
markReceived: db.prepare(`UPDATE relay_event_own_prompts SET received_at = ? WHERE message_id = ? AND watcher_conversation_id = ?`),
|
|
384
|
+
pruneBefore: db.prepare(`DELETE FROM relay_event_own_prompts WHERE created_at < ?`),
|
|
385
|
+
} : null;
|
|
324
386
|
// Without migration 0012 the table has no remote_sync column.
|
|
325
387
|
const deleteEndedSubscriptionsBefore = available
|
|
326
388
|
? db.prepare(crossAvailable ? RELAY_EVENT_PRUNE_SQL.deleteEndedSubscriptionsBefore : RELAY_EVENT_PRUNE_SQL.deleteEndedSubscriptionsBeforeNoSync)
|
|
@@ -406,6 +468,11 @@ export function createRelayEventService({
|
|
|
406
468
|
const idleSinceBySubscription = new Map();
|
|
407
469
|
// heartbeat: subscription id → since when its watched set has been busy
|
|
408
470
|
const busySinceBySubscription = new Map();
|
|
471
|
+
// `${watcher}\n${messageId}` → { count, until }: the watcher waits for
|
|
472
|
+
// the reply to its own prompt; that turn's turn_end is held meanwhile.
|
|
473
|
+
const ownWaits = new Map();
|
|
474
|
+
// quiet_hours_json → the checked window (or null)
|
|
475
|
+
const quietCache = new Map();
|
|
409
476
|
let allIdleTimer = null;
|
|
410
477
|
let sweepTimer = null;
|
|
411
478
|
let started = false;
|
|
@@ -448,8 +515,38 @@ export function createRelayEventService({
|
|
|
448
515
|
return effectiveRelayEventTypes(parseJson(row?.events_json, []));
|
|
449
516
|
}
|
|
450
517
|
|
|
518
|
+
function quietOf(row) {
|
|
519
|
+
const text = toText(row?.quiet_hours_json);
|
|
520
|
+
if (!text) return null;
|
|
521
|
+
if (!quietCache.has(text)) quietCache.set(text, normalizeRelayEventQuietHours(parseJson(text, null)).value || null);
|
|
522
|
+
return quietCache.get(text);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** A subscription's options: `{ digest, quiet, exclude }` (0, null, [] when off). */
|
|
526
|
+
function optionsOf(row) {
|
|
527
|
+
const digest = Number(row?.digest_minutes) > 0 ? Math.trunc(Number(row.digest_minutes)) : 0;
|
|
528
|
+
const exclude = toText(row?.scope) === 'all' ? parseJson(row?.exclude_json, []) : [];
|
|
529
|
+
return {
|
|
530
|
+
digest,
|
|
531
|
+
quiet: quietOf(row),
|
|
532
|
+
exclude: (Array.isArray(exclude) ? exclude : []).map(toText).filter(Boolean),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function excludes(row, sessionId) {
|
|
537
|
+
const id = toText(sessionId);
|
|
538
|
+
return !!id && optionsOf(row).exclude.includes(id);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Whether `at` falls in the subscription's quiet window. */
|
|
542
|
+
function inQuietHours(row, at = now()) {
|
|
543
|
+
const quiet = quietOf(row);
|
|
544
|
+
return !!quiet && relayEventQuietWindowEnd(quiet, at) !== null;
|
|
545
|
+
}
|
|
546
|
+
|
|
451
547
|
function shapeSubscription(row) {
|
|
452
548
|
if (!row) return null;
|
|
549
|
+
const options = optionsOf(row);
|
|
453
550
|
const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
|
|
454
551
|
const sessionIds = scope === 'all' ? [] : parseJson(row.session_ids_json, []).map(toText).filter(Boolean);
|
|
455
552
|
const mirror = isMirror(row);
|
|
@@ -484,6 +581,9 @@ export function createRelayEventService({
|
|
|
484
581
|
...(toText(row.match) ? { match: row.match } : {}),
|
|
485
582
|
stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
|
|
486
583
|
...(Number(row.heartbeat_minutes) > 0 ? { heartbeatMinutes: Number(row.heartbeat_minutes) } : {}),
|
|
584
|
+
...(options.digest ? { digestMinutes: options.digest } : {}),
|
|
585
|
+
...(options.quiet ? { quietHours: options.quiet } : {}),
|
|
586
|
+
...(options.exclude.length ? { exclude: options.exclude } : {}),
|
|
487
587
|
createdAt: row.created_at,
|
|
488
588
|
...(row.ended_at ? { endedAt: row.ended_at, endReason: row.end_reason || null } : {}),
|
|
489
589
|
firedCount: Number(row.fired_count || 0),
|
|
@@ -523,23 +623,115 @@ export function createRelayEventService({
|
|
|
523
623
|
}
|
|
524
624
|
}
|
|
525
625
|
|
|
526
|
-
/**
|
|
626
|
+
/**
|
|
627
|
+
* A watcher's subscriptions: the active ones and those that ended within
|
|
628
|
+
* the hour. An active one says how many of its events its digest or quiet
|
|
629
|
+
* window holds (`heldCount`) and when the first of them is due
|
|
630
|
+
* (`nextDeliveryAt`, null when none is held).
|
|
631
|
+
*/
|
|
527
632
|
function listSubscriptions({ watcherConversationId, includeEndedSinceMs = HOUR_MS } = {}) {
|
|
528
633
|
if (!available) return [];
|
|
634
|
+
const watcher = toText(watcherConversationId);
|
|
529
635
|
const since = new Date(now() - includeEndedSinceMs).toISOString();
|
|
530
|
-
|
|
636
|
+
const held = heldBySubscription(watcher);
|
|
637
|
+
return sql.listForWatcher.all(watcher, since).map((row) => {
|
|
638
|
+
const shaped = shapeSubscription(row);
|
|
639
|
+
const mine = held.get(row.id);
|
|
640
|
+
// An ended one says so only while its last event (a one-shot's) is held.
|
|
641
|
+
if (shaped.endedAt && !mine) return shaped;
|
|
642
|
+
return { ...shaped, heldCount: mine?.count || 0, nextDeliveryAt: mine ? new Date(mine.next).toISOString() : null };
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** subscription id → `{ count, next }` of the watcher's events its options hold now. */
|
|
647
|
+
function heldBySubscription(watcher) {
|
|
648
|
+
const held = new Map();
|
|
649
|
+
if (!watcher || isServedKey(watcher)) return held;
|
|
650
|
+
let rows = [];
|
|
651
|
+
try {
|
|
652
|
+
rows = sql.listUndelivered.all(watcher);
|
|
653
|
+
} catch {
|
|
654
|
+
return held;
|
|
655
|
+
}
|
|
656
|
+
const at = now();
|
|
657
|
+
for (const entry of planDelivery(watcher, rows)) {
|
|
658
|
+
if (!entry.subscriptionId || entry.releaseAt <= at) continue;
|
|
659
|
+
const current = held.get(entry.subscriptionId) || { count: 0, next: Infinity };
|
|
660
|
+
current.count += 1;
|
|
661
|
+
current.next = Math.min(current.next, entry.releaseAt);
|
|
662
|
+
held.set(entry.subscriptionId, current);
|
|
663
|
+
}
|
|
664
|
+
return held;
|
|
531
665
|
}
|
|
532
666
|
|
|
533
667
|
// ─── Subscribe / unsubscribe ───────────────────────────────────────────────
|
|
534
668
|
|
|
669
|
+
/**
|
|
670
|
+
* A subscription's options, checked: `{ digest, quiet, exclude }` (null,
|
|
671
|
+
* null, [] when not asked for). `exclude` goes with "all" only. Throws
|
|
672
|
+
* RelayEventError (`invalid`, `unavailable` without migration 0019).
|
|
673
|
+
*/
|
|
674
|
+
function prepareOptions({ all, digestMinutes = null, quietHours = null, exclude = null }) {
|
|
675
|
+
let digest = null;
|
|
676
|
+
if (digestMinutes !== null && digestMinutes !== undefined && digestMinutes !== '') {
|
|
677
|
+
const number = Number(digestMinutes);
|
|
678
|
+
if (!Number.isFinite(number)) throw new RelayEventError('invalid', 'digest_minutes must be a number of minutes');
|
|
679
|
+
digest = Math.min(RELAY_EVENT_LIMITS.digestMinutesMax, Math.max(RELAY_EVENT_LIMITS.digestMinutesMin, Math.trunc(number)));
|
|
680
|
+
}
|
|
681
|
+
const quiet = normalizeRelayEventQuietHours(quietHours);
|
|
682
|
+
if (!quiet.ok) throw new RelayEventError('invalid', quiet.error);
|
|
683
|
+
const excluded = [...new Set((Array.isArray(exclude) ? exclude : []).map(toText).filter(Boolean))].map((id) => id.slice(0, 200));
|
|
684
|
+
if (excluded.length && !all) {
|
|
685
|
+
throw new RelayEventError('invalid', 'exclude goes with sessions ["all"] only; a list of sessions names what it watches.');
|
|
686
|
+
}
|
|
687
|
+
if (excluded.length > RELAY_EVENT_LIMITS.excludeMax) {
|
|
688
|
+
throw new RelayEventError('invalid', `exclude takes at most ${RELAY_EVENT_LIMITS.excludeMax} sessions.`);
|
|
689
|
+
}
|
|
690
|
+
if ((digest !== null || quiet.value || excluded.length) && !optionsAvailable) {
|
|
691
|
+
throw new RelayEventError('unavailable', 'digest_minutes, quiet_hours and exclude are not available on this relay right now (its storage is missing; see the server log).');
|
|
692
|
+
}
|
|
693
|
+
return { digest, quiet: quiet.value, exclude: excluded };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function optionsLogText(options) {
|
|
697
|
+
const parts = [
|
|
698
|
+
options?.digest ? `digest ${options.digest} min` : '',
|
|
699
|
+
options?.quiet ? `quiet ${options.quiet.from}-${options.quiet.to} ${options.quiet.tz}` : '',
|
|
700
|
+
options?.exclude?.length ? `${options.exclude.length} excluded` : '',
|
|
701
|
+
].filter(Boolean);
|
|
702
|
+
return parts.length ? ` [${parts.join(', ')}]` : '';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function storeOptions(id, options) {
|
|
706
|
+
if (!optionSql || !options) return;
|
|
707
|
+
optionSql.setOptions.run(
|
|
708
|
+
options.digest ?? null,
|
|
709
|
+
options.quiet ? JSON.stringify(options.quiet) : null,
|
|
710
|
+
options.exclude?.length ? JSON.stringify(options.exclude) : null,
|
|
711
|
+
id,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
535
715
|
/**
|
|
536
716
|
* What a subscribe asks for, checked: `{ all, ids, types, matchText,
|
|
537
|
-
* stalled, heartbeat }`. Throws RelayEventError (`invalid`,
|
|
538
|
-
* `
|
|
539
|
-
* relay_restarted (this relay's own watchers only); a
|
|
540
|
-
* names nothing else needs no sessions.
|
|
717
|
+
* stalled, heartbeat, options }`. Throws RelayEventError (`invalid`,
|
|
718
|
+
* `notFound`). `ownId` is the watcher, never watched by itself.
|
|
719
|
+
* `relayLevel` allows relay_restarted (this relay's own watchers only); a
|
|
720
|
+
* subscription that names nothing else needs no sessions.
|
|
541
721
|
*/
|
|
542
|
-
function prepareSubscription({
|
|
722
|
+
function prepareSubscription({
|
|
723
|
+
scope,
|
|
724
|
+
sessionIds,
|
|
725
|
+
events,
|
|
726
|
+
match,
|
|
727
|
+
stalledMinutes,
|
|
728
|
+
heartbeatMinutes,
|
|
729
|
+
digestMinutes = null,
|
|
730
|
+
quietHours = null,
|
|
731
|
+
exclude = null,
|
|
732
|
+
ownId = '',
|
|
733
|
+
relayLevel = false,
|
|
734
|
+
}) {
|
|
543
735
|
const all = scope === 'all';
|
|
544
736
|
const ids = all ? [] : [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map(toText).filter(Boolean))];
|
|
545
737
|
const named = Array.isArray(events) ? events.map(toText).filter(Boolean) : [];
|
|
@@ -557,9 +749,10 @@ export function createRelayEventService({
|
|
|
557
749
|
if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
|
|
558
750
|
throw new RelayEventError('invalid', `subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use "all" for more.`);
|
|
559
751
|
}
|
|
560
|
-
if (
|
|
561
|
-
throw new RelayEventError('ownSession', `Session ${
|
|
752
|
+
if (ownId && ids.includes(ownId)) {
|
|
753
|
+
throw new RelayEventError('ownSession', `Session ${ownId} is the conversation you are running in: it is never watched, events of your own turns would wake you up.`);
|
|
562
754
|
}
|
|
755
|
+
const options = prepareOptions({ all, digestMinutes, quietHours, exclude });
|
|
563
756
|
const missing = ids.filter((id) => !isLive(conversation(id)));
|
|
564
757
|
if (missing.length) {
|
|
565
758
|
throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
|
|
@@ -581,7 +774,7 @@ export function createRelayEventService({
|
|
|
581
774
|
? null
|
|
582
775
|
: Math.min(RELAY_EVENT_LIMITS.heartbeatMinutesMax, Math.max(RELAY_EVENT_LIMITS.heartbeatMinutesMin, Math.trunc(Number(heartbeatMinutes))));
|
|
583
776
|
if (heartbeat !== null && !Number.isFinite(heartbeat)) throw new RelayEventError('invalid', 'heartbeat_minutes must be a number of minutes');
|
|
584
|
-
return { all, ids, types, matchText: toText(match) ? String(match) : null, stalled, heartbeat };
|
|
777
|
+
return { all, ids, types, matchText: toText(match) ? String(match) : null, stalled, heartbeat, options };
|
|
585
778
|
}
|
|
586
779
|
|
|
587
780
|
/**
|
|
@@ -599,34 +792,42 @@ export function createRelayEventService({
|
|
|
599
792
|
match = '',
|
|
600
793
|
stalledMinutes = null,
|
|
601
794
|
heartbeatMinutes = null,
|
|
795
|
+
digestMinutes = null,
|
|
796
|
+
quietHours = null,
|
|
797
|
+
exclude = null,
|
|
602
798
|
} = {}) {
|
|
603
799
|
if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
|
|
604
800
|
const watcher = toText(watcherConversationId);
|
|
605
801
|
if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
|
|
606
|
-
const prepared = prepareSubscription({
|
|
802
|
+
const prepared = prepareSubscription({
|
|
803
|
+
scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, digestMinutes, quietHours, exclude, ownId: watcher, relayLevel: true,
|
|
804
|
+
});
|
|
607
805
|
const { all, ids, types } = prepared;
|
|
608
806
|
checkWatcherCapacity(watcher);
|
|
609
807
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
610
808
|
const at = isoNow();
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
809
|
+
db.transaction(() => {
|
|
810
|
+
sql.insertSubscription.run(
|
|
811
|
+
id,
|
|
812
|
+
watcher,
|
|
813
|
+
RELAY_EVENT_SELF_RELAY_ID,
|
|
814
|
+
all ? 'all' : 'sessions',
|
|
815
|
+
all ? null : JSON.stringify(ids),
|
|
816
|
+
JSON.stringify(types),
|
|
817
|
+
once ? 1 : 0,
|
|
818
|
+
prepared.matchText,
|
|
819
|
+
prepared.stalled,
|
|
820
|
+
prepared.heartbeat,
|
|
821
|
+
at,
|
|
822
|
+
);
|
|
823
|
+
storeOptions(id, prepared.options);
|
|
824
|
+
})();
|
|
624
825
|
const subscription = shapeSubscription(sql.getSubscription.get(id));
|
|
625
826
|
// The watched set starts from what it is doing now: all_idle fires when
|
|
626
827
|
// work that is running now (or starts later) has ended.
|
|
627
828
|
noteBusy(sql.getSubscription.get(id));
|
|
628
829
|
const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'this relay');
|
|
629
|
-
log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}`);
|
|
830
|
+
log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}${optionsLogText(prepared.options)}`);
|
|
630
831
|
broadcast();
|
|
631
832
|
return subscription;
|
|
632
833
|
}
|
|
@@ -790,10 +991,13 @@ export function createRelayEventService({
|
|
|
790
991
|
// A paired relay's events wait for its fetch (and the retention bound):
|
|
791
992
|
// its subscription's last events and its end must still reach it.
|
|
792
993
|
if (stillWatching.has(watcher) || isServedKey(watcher)) continue;
|
|
793
|
-
|
|
994
|
+
// The event of a one-shot subscription that fired is still owed: it
|
|
995
|
+
// may be held by that subscription's options.
|
|
996
|
+
try { sql.dropUndeliveredUnowed.run(watcher); } catch {}
|
|
794
997
|
const pending = flushTimers.get(watcher);
|
|
795
998
|
if (pending) clearTimer(pending.timer);
|
|
796
999
|
flushTimers.delete(watcher);
|
|
1000
|
+
scheduleFlush(watcher);
|
|
797
1001
|
}
|
|
798
1002
|
}
|
|
799
1003
|
|
|
@@ -833,7 +1037,7 @@ export function createRelayEventService({
|
|
|
833
1037
|
|
|
834
1038
|
function subscriptionCovers(row, sessionId) {
|
|
835
1039
|
if (row.watcher_conversation_id === sessionId) return false;
|
|
836
|
-
if (toText(row.scope) === 'all') return
|
|
1040
|
+
if (toText(row.scope) === 'all') return !excludes(row, sessionId);
|
|
837
1041
|
return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
|
|
838
1042
|
}
|
|
839
1043
|
|
|
@@ -903,6 +1107,12 @@ export function createRelayEventService({
|
|
|
903
1107
|
const ending = heartbeat ? [] : matches
|
|
904
1108
|
.filter(({ row, hit }) => Number(row.once || 0) === 1 && (!toText(row.match) || hit))
|
|
905
1109
|
.map(({ row }) => row.id);
|
|
1110
|
+
// The watcher already has this reply: it waited for it itself. One
|
|
1111
|
+
// that ends a one-shot subscription still goes, or it would never end.
|
|
1112
|
+
if (!ending.length && ownReplyReceived(watcher, event)) {
|
|
1113
|
+
log(`conv=${shortId(watcher)} turn_end of ${shortId(sessionId)} not recorded: the watcher received that reply itself`);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
906
1116
|
const payload = { ...stored, subscriptionIds: matches.map(({ row }) => row.id), ...(ending.length ? { ends: ending } : {}) };
|
|
907
1117
|
sql.insertEvent.run(first.id, watcher, RELAY_EVENT_SELF_RELAY_ID, sessionId || '', event.type, urgent ? 1 : 0, JSON.stringify(payload), event.at);
|
|
908
1118
|
if (!heartbeat) for (const { row } of matches) sql.markFired.run(event.at, row.id);
|
|
@@ -940,7 +1150,107 @@ export function createRelayEventService({
|
|
|
940
1150
|
return Math.max(now(), last + RELAY_EVENT_LIMITS.digestIntervalMs);
|
|
941
1151
|
}
|
|
942
1152
|
|
|
943
|
-
|
|
1153
|
+
// ─── Held events: options and the watcher's own waits ─────────────────────
|
|
1154
|
+
|
|
1155
|
+
function ownWaitKey(watcher, messageId) {
|
|
1156
|
+
return `${watcher}\n${messageId}`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Until when a turn_end waits for the watcher's own wait on its reply, or 0. */
|
|
1160
|
+
function ownWaitHold(watcher, row, payload) {
|
|
1161
|
+
if (toText(row.type) !== 'turn_end') return 0;
|
|
1162
|
+
const messageId = toText(payload?.detail?.messageId);
|
|
1163
|
+
if (!messageId) return 0;
|
|
1164
|
+
const wait = ownWaits.get(ownWaitKey(watcher, messageId));
|
|
1165
|
+
return wait && wait.until > now() ? wait.until : 0;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Whether a turn_end is of the watcher's own prompt whose finished reply it received. */
|
|
1169
|
+
function ownReplyReceived(watcher, event) {
|
|
1170
|
+
if (!ownSql || toText(event?.type) !== 'turn_end') return false;
|
|
1171
|
+
const messageId = toText(event?.detail?.messageId);
|
|
1172
|
+
if (!messageId) return false;
|
|
1173
|
+
try {
|
|
1174
|
+
const own = ownSql.get.get(messageId);
|
|
1175
|
+
return !!own && own.watcher_conversation_id === watcher && !!own.received_at;
|
|
1176
|
+
} catch {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* When one subscription lets a non-urgent event go: at once without
|
|
1183
|
+
* options; with a digest, digest_minutes after its last digest row (or its
|
|
1184
|
+
* start); a time inside the quiet window moves to the window's end.
|
|
1185
|
+
*/
|
|
1186
|
+
function optionRelease(sub, row) {
|
|
1187
|
+
const open = { releaseAt: 0, held: null, subscriptionId: null, digestSubscriptionId: null };
|
|
1188
|
+
if (!sub) return open;
|
|
1189
|
+
const { digest, quiet } = optionsOf(sub);
|
|
1190
|
+
if (!digest && !quiet) return open;
|
|
1191
|
+
let releaseAt = Date.parse(String(row.created_at)) || now();
|
|
1192
|
+
let held = null;
|
|
1193
|
+
if (digest) {
|
|
1194
|
+
const last = Date.parse(String(sub.digest_last_at || '')) || Date.parse(String(sub.created_at || '')) || 0;
|
|
1195
|
+
releaseAt = Math.max(releaseAt, last + digest * 60_000);
|
|
1196
|
+
held = { reason: 'digest', minutes: digest, subscriptionId: sub.id };
|
|
1197
|
+
}
|
|
1198
|
+
if (quiet) {
|
|
1199
|
+
const end = relayEventQuietWindowEnd(quiet, releaseAt);
|
|
1200
|
+
if (end !== null) {
|
|
1201
|
+
releaseAt = end;
|
|
1202
|
+
held = { reason: 'quiet', ...quiet };
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return { releaseAt, held, subscriptionId: sub.id, digestSubscriptionId: digest ? sub.id : null };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* When each undelivered event of a watcher may go: `{ row, payload,
|
|
1210
|
+
* releaseAt, held, subscriptionId, digestSubscriptionId }`, releaseAt 0
|
|
1211
|
+
* for at once. Urgent events and heartbeats are never held; a turn_end the
|
|
1212
|
+
* watcher is waiting for itself waits for that wait; the others go when
|
|
1213
|
+
* the first of the subscriptions that took them lets them (one without
|
|
1214
|
+
* options at once). Rebuilt from the rows and the subscriptions each
|
|
1215
|
+
* time, so a restart changes nothing.
|
|
1216
|
+
*/
|
|
1217
|
+
function planDelivery(watcher, rows) {
|
|
1218
|
+
const subscriptions = new Map(activeRows().filter((row) => row.watcher_conversation_id === watcher).map((row) => [row.id, row]));
|
|
1219
|
+
// A one-shot subscription ends as its event is recorded: that event
|
|
1220
|
+
// still keeps to its options.
|
|
1221
|
+
const subscriptionOf = (id) => {
|
|
1222
|
+
if (subscriptions.has(id)) return subscriptions.get(id);
|
|
1223
|
+
let row = null;
|
|
1224
|
+
try {
|
|
1225
|
+
row = sql.getSubscription.get(id) || null;
|
|
1226
|
+
} catch {}
|
|
1227
|
+
const fired = row && row.watcher_conversation_id === watcher && toText(row.end_reason) === 'fired' ? row : null;
|
|
1228
|
+
subscriptions.set(id, fired);
|
|
1229
|
+
return fired;
|
|
1230
|
+
};
|
|
1231
|
+
return rows.map((row) => {
|
|
1232
|
+
const payload = parseJson(row.payload_json, null);
|
|
1233
|
+
const open = { row, payload, releaseAt: 0, held: null, subscriptionId: null, digestSubscriptionId: null };
|
|
1234
|
+
if (Number(row.urgent) === 1 || isHeartbeatRow(row)) return open;
|
|
1235
|
+
const waitUntil = ownWaitHold(watcher, row, payload);
|
|
1236
|
+
if (waitUntil) return { ...open, releaseAt: waitUntil };
|
|
1237
|
+
const named = Array.isArray(payload?.subscriptionIds) && payload.subscriptionIds.length
|
|
1238
|
+
? payload.subscriptionIds.map(toText)
|
|
1239
|
+
: [toText(row.subscription_id)];
|
|
1240
|
+
// A subscription that ended otherwise (unsubscribed) has no say any
|
|
1241
|
+
// more; with none left that has, the event goes as usual.
|
|
1242
|
+
let best = null;
|
|
1243
|
+
for (const id of named) {
|
|
1244
|
+
const subscription = subscriptionOf(id);
|
|
1245
|
+
if (!subscription) continue;
|
|
1246
|
+
const option = optionRelease(subscription, row);
|
|
1247
|
+
if (!best || option.releaseAt < best.releaseAt) best = option;
|
|
1248
|
+
}
|
|
1249
|
+
return best ? { ...open, ...best } : open;
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function readyDue(watcher, undelivered) {
|
|
944
1254
|
if (undelivered.some((row) => Number(row.urgent) === 1)) return now();
|
|
945
1255
|
if (overCap(watcher)) {
|
|
946
1256
|
// Heartbeats are not held for the digest: they go after the usual
|
|
@@ -955,6 +1265,15 @@ export function createRelayEventService({
|
|
|
955
1265
|
return coalescedDue(undelivered);
|
|
956
1266
|
}
|
|
957
1267
|
|
|
1268
|
+
/** The next flush: what is ready by the usual rules, else the first held event's time. */
|
|
1269
|
+
function dueAtFor(watcher, plan) {
|
|
1270
|
+
const at = now();
|
|
1271
|
+
const ready = plan.filter((entry) => entry.releaseAt <= at).map((entry) => entry.row);
|
|
1272
|
+
const dues = plan.filter((entry) => entry.releaseAt > at).map((entry) => entry.releaseAt);
|
|
1273
|
+
if (ready.length) dues.push(readyDue(watcher, ready));
|
|
1274
|
+
return Math.min(...dues);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
958
1277
|
function scheduleFlush(watcher, { urgent = false } = {}) {
|
|
959
1278
|
// A paired relay's events wait for its fetch.
|
|
960
1279
|
if (isServedKey(watcher)) return;
|
|
@@ -965,7 +1284,7 @@ export function createRelayEventService({
|
|
|
965
1284
|
return;
|
|
966
1285
|
}
|
|
967
1286
|
if (!undelivered.length) return;
|
|
968
|
-
const dueAt = urgent ? now() : dueAtFor(watcher, undelivered);
|
|
1287
|
+
const dueAt = urgent ? now() : dueAtFor(watcher, planDelivery(watcher, undelivered));
|
|
969
1288
|
const current = flushTimers.get(watcher);
|
|
970
1289
|
if (current && current.dueAt <= dueAt) return;
|
|
971
1290
|
if (current) clearTimer(current.timer);
|
|
@@ -982,19 +1301,26 @@ export function createRelayEventService({
|
|
|
982
1301
|
}
|
|
983
1302
|
|
|
984
1303
|
/**
|
|
985
|
-
* Writes the watcher's undelivered events into its
|
|
986
|
-
* appended to its event row that is still pending, else
|
|
987
|
-
* Returns `{ messageId, merged, digest, count }` or null.
|
|
1304
|
+
* Writes the watcher's undelivered events that are not held into its
|
|
1305
|
+
* conversation now: appended to its event row that is still pending, else
|
|
1306
|
+
* as a new message. Returns `{ messageId, merged, digest, count }` or null.
|
|
988
1307
|
*/
|
|
989
1308
|
function flush(watcher) {
|
|
990
1309
|
if (!available || isServedKey(watcher)) return null;
|
|
991
|
-
|
|
992
|
-
if (!
|
|
1310
|
+
const undelivered = sql.listUndelivered.all(watcher);
|
|
1311
|
+
if (!undelivered.length) return null;
|
|
993
1312
|
const target = conversation(watcher);
|
|
994
1313
|
if (!isLive(target)) {
|
|
995
1314
|
sql.dropUndelivered.run(watcher);
|
|
996
1315
|
return null;
|
|
997
1316
|
}
|
|
1317
|
+
const readyAt = now();
|
|
1318
|
+
const plan = new Map(planDelivery(watcher, undelivered).filter((entry) => entry.releaseAt <= readyAt).map((entry) => [entry.row.id, entry]));
|
|
1319
|
+
let rows = undelivered.filter((row) => plan.has(row.id));
|
|
1320
|
+
if (!rows.length) {
|
|
1321
|
+
scheduleFlush(watcher);
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
998
1324
|
const urgent = rows.some((row) => Number(row.urgent) === 1);
|
|
999
1325
|
const capped = overCap(watcher);
|
|
1000
1326
|
let digest = false;
|
|
@@ -1012,11 +1338,22 @@ export function createRelayEventService({
|
|
|
1012
1338
|
digest = rows.some((row) => !isHeartbeatRow(row));
|
|
1013
1339
|
}
|
|
1014
1340
|
}
|
|
1015
|
-
|
|
1341
|
+
// An event a subscription's options held says so in its line's message;
|
|
1342
|
+
// the mark is stored, for the text rebuilt when more is appended.
|
|
1343
|
+
const marked = rows.map((row) => {
|
|
1344
|
+
const { payload, held } = plan.get(row.id);
|
|
1345
|
+
return { row, payload: payload && held ? { ...payload, held } : payload, held };
|
|
1346
|
+
});
|
|
1347
|
+
const events = marked.map((entry) => entry.payload).filter(Boolean);
|
|
1016
1348
|
const ended = [...new Set(events.flatMap((event) => (Array.isArray(event.ends) ? event.ends : [])))];
|
|
1349
|
+
const digestSubscriptions = [...new Set(rows.map((row) => plan.get(row.id).digestSubscriptionId).filter(Boolean))];
|
|
1017
1350
|
const at = isoNow();
|
|
1018
1351
|
let result = null;
|
|
1019
1352
|
const write = db.transaction(() => {
|
|
1353
|
+
for (const entry of marked) {
|
|
1354
|
+
if (entry.held && entry.payload) sql.setEventPayload.run(JSON.stringify(entry.payload), entry.row.id);
|
|
1355
|
+
}
|
|
1356
|
+
for (const id of digestSubscriptions) optionSql?.markDigestDelivered.run(at, id);
|
|
1020
1357
|
const pending = sql.findPendingEventRow.get(watcher, RELAY_EVENT_MESSAGE_KIND) || null;
|
|
1021
1358
|
if (pending) {
|
|
1022
1359
|
const earlier = sql.listEventsOfMessage.all(pending.id).map((row) => parseJson(row.payload_json, null)).filter(Boolean);
|
|
@@ -1148,13 +1485,17 @@ export function createRelayEventService({
|
|
|
1148
1485
|
match = '',
|
|
1149
1486
|
stalledMinutes = null,
|
|
1150
1487
|
heartbeatMinutes = null,
|
|
1488
|
+
exclude = null,
|
|
1151
1489
|
dryRun = false,
|
|
1152
1490
|
} = {}) {
|
|
1153
1491
|
if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
|
|
1154
1492
|
requireCross();
|
|
1155
1493
|
const subscriber = subscriberOf(subscriberRelayId);
|
|
1156
1494
|
const watcher = toText(watcherId).slice(0, 200);
|
|
1157
|
-
|
|
1495
|
+
// Digest and quiet hours are the watching relay's to apply; the sessions
|
|
1496
|
+
// an "all" subscription leaves out matter here too (all_idle, stalled,
|
|
1497
|
+
// the heartbeat's list).
|
|
1498
|
+
const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude });
|
|
1158
1499
|
const { all, ids, types } = prepared;
|
|
1159
1500
|
if (Number(remoteSql.countServedOfWatcher.get(subscriber, watcher)?.n || 0) >= RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher) {
|
|
1160
1501
|
throw new RelayEventError('limit', `That conversation already has ${RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher} subscriptions on this relay; unsubscribe one first.`);
|
|
@@ -1172,28 +1513,32 @@ export function createRelayEventService({
|
|
|
1172
1513
|
...(prepared.matchText ? { match: prepared.matchText } : {}),
|
|
1173
1514
|
stalledMinutes: prepared.stalled,
|
|
1174
1515
|
...(prepared.heartbeat ? { heartbeatMinutes: prepared.heartbeat } : {}),
|
|
1516
|
+
...(prepared.options.exclude.length ? { exclude: prepared.options.exclude } : {}),
|
|
1175
1517
|
};
|
|
1176
1518
|
}
|
|
1177
1519
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
1178
1520
|
const at = isoNow();
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1521
|
+
db.transaction(() => {
|
|
1522
|
+
remoteSql.insertServed.run(
|
|
1523
|
+
id,
|
|
1524
|
+
servedWatcherKey(subscriber),
|
|
1525
|
+
RELAY_EVENT_SELF_RELAY_ID,
|
|
1526
|
+
all ? 'all' : 'sessions',
|
|
1527
|
+
all ? null : JSON.stringify(ids),
|
|
1528
|
+
JSON.stringify(types),
|
|
1529
|
+
once ? 1 : 0,
|
|
1530
|
+
prepared.matchText,
|
|
1531
|
+
prepared.stalled,
|
|
1532
|
+
prepared.heartbeat,
|
|
1533
|
+
at,
|
|
1534
|
+
subscriber,
|
|
1535
|
+
toText(subscriberRelayName).slice(0, 60) || null,
|
|
1536
|
+
watcher || null,
|
|
1537
|
+
toText(watcherTitle).slice(0, 200) || null,
|
|
1538
|
+
at,
|
|
1539
|
+
);
|
|
1540
|
+
storeOptions(id, { exclude: prepared.options.exclude });
|
|
1541
|
+
})();
|
|
1197
1542
|
const row = sql.getSubscription.get(id);
|
|
1198
1543
|
noteBusy(row);
|
|
1199
1544
|
log(`relay ${shortId(subscriber)} subscribed ${id} to ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${types.join(',')})${once ? ' once' : ''}`);
|
|
@@ -1323,9 +1668,12 @@ export function createRelayEventService({
|
|
|
1323
1668
|
/**
|
|
1324
1669
|
* Stores this relay's side of a subscription a paired relay accepted:
|
|
1325
1670
|
* `remote` is that relay's subscription (id, scope, sessions with titles,
|
|
1326
|
-
* events, …).
|
|
1671
|
+
* events, …). `digestMinutes`, `quietHours` and `exclude` are applied
|
|
1672
|
+
* here, where the events are delivered (exclude also to what that relay
|
|
1673
|
+
* sends, should it not know the option). Returns the subscription as
|
|
1674
|
+
* list_subscriptions shows it.
|
|
1327
1675
|
*/
|
|
1328
|
-
function addMirror({ watcherConversationId, relayId, relayName = '', remote } = {}) {
|
|
1676
|
+
function addMirror({ watcherConversationId, relayId, relayName = '', remote, digestMinutes = null, quietHours = null, exclude = null } = {}) {
|
|
1329
1677
|
if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now.');
|
|
1330
1678
|
requireCross();
|
|
1331
1679
|
const watcher = toText(watcherConversationId);
|
|
@@ -1335,27 +1683,31 @@ export function createRelayEventService({
|
|
|
1335
1683
|
if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
|
|
1336
1684
|
checkWatcherCapacity(watcher);
|
|
1337
1685
|
const all = remote.scope === 'all';
|
|
1686
|
+
const options = prepareOptions({ all, digestMinutes, quietHours, exclude });
|
|
1338
1687
|
const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
|
|
1339
1688
|
.map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
|
|
1340
1689
|
.filter((session) => session.id);
|
|
1341
1690
|
const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
|
|
1342
1691
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
1343
1692
|
const at = isoNow();
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1693
|
+
db.transaction(() => {
|
|
1694
|
+
remoteSql.insertMirror.run(
|
|
1695
|
+
id,
|
|
1696
|
+
watcher,
|
|
1697
|
+
relay,
|
|
1698
|
+
all ? 'all' : 'sessions',
|
|
1699
|
+
all ? null : JSON.stringify(sessions.map((session) => session.id)),
|
|
1700
|
+
JSON.stringify(types.length ? types : [...RELAY_EVENT_DEFAULT_TYPES]),
|
|
1701
|
+
remote.once === true ? 1 : 0,
|
|
1702
|
+
toText(remote.match) ? String(remote.match) : null,
|
|
1703
|
+
Number(remote.stalledMinutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
|
|
1704
|
+
Number(remote.heartbeatMinutes) > 0 ? Number(remote.heartbeatMinutes) : null,
|
|
1705
|
+
at,
|
|
1706
|
+
remoteId,
|
|
1707
|
+
JSON.stringify({ relayName: toText(relayName) || null, sessions }),
|
|
1708
|
+
);
|
|
1709
|
+
storeOptions(id, options);
|
|
1710
|
+
})();
|
|
1359
1711
|
noteRemoteLinkOk(relay);
|
|
1360
1712
|
log(`conv=${shortId(watcher)} subscribed ${id} on a paired relay (${remoteId} there)`);
|
|
1361
1713
|
broadcast();
|
|
@@ -1417,6 +1769,11 @@ export function createRelayEventService({
|
|
|
1417
1769
|
prompt: 'text',
|
|
1418
1770
|
questionId: 'text',
|
|
1419
1771
|
approvalCard: 'bool',
|
|
1772
|
+
// question_answered
|
|
1773
|
+
answer: 'text',
|
|
1774
|
+
choice: 'text',
|
|
1775
|
+
dismissed: 'bool',
|
|
1776
|
+
via: 'text',
|
|
1420
1777
|
description: 'text',
|
|
1421
1778
|
summary: 'text',
|
|
1422
1779
|
taskId: 'text',
|
|
@@ -1482,7 +1839,9 @@ export function createRelayEventService({
|
|
|
1482
1839
|
* session changes there end or update the mirrors, confirmed ends clear
|
|
1483
1840
|
* their sync mark, and the cursor moves, all in one transaction. With
|
|
1484
1841
|
* `replay` (the first fetch after a gap) events are marked replayed and
|
|
1485
|
-
*
|
|
1842
|
+
* nothing is flushed yet: noteRemoteLinkRestored does that. They keep to
|
|
1843
|
+
* the subscriptions' options like any event (questions and errors go at
|
|
1844
|
+
* once).
|
|
1486
1845
|
* Returns per watcher `{ kept, urgent, dropped, ended }`.
|
|
1487
1846
|
*/
|
|
1488
1847
|
function applyRemotePull(relayId, { relayName = '', response = {}, replay = false } = {}) {
|
|
@@ -1503,16 +1862,23 @@ export function createRelayEventService({
|
|
|
1503
1862
|
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : null;
|
|
1504
1863
|
if (!payload || !isRelayEventType(payload.type)) continue;
|
|
1505
1864
|
const named = (Array.isArray(payload.subscriptionIds) ? payload.subscriptionIds : []).map(toText);
|
|
1865
|
+
const payloadSessionId = payload.session && typeof payload.session === 'object' ? remoteText(payload.session.id, 200) : '';
|
|
1506
1866
|
const groups = new Map();
|
|
1507
1867
|
for (const remoteId of named) {
|
|
1508
1868
|
const row = byRemote.get(remoteId);
|
|
1509
1869
|
if (!row || row.ended_at) continue;
|
|
1870
|
+
// Left out by this relay's exclude, should that relay not know it.
|
|
1871
|
+
if (excludes(row, payloadSessionId)) continue;
|
|
1510
1872
|
if (!groups.has(row.watcher_conversation_id)) groups.set(row.watcher_conversation_id, []);
|
|
1511
1873
|
groups.get(row.watcher_conversation_id).push(row);
|
|
1512
1874
|
}
|
|
1513
1875
|
for (const [watcher, mine] of groups) {
|
|
1876
|
+
// No heartbeat inside the quiet window (as for this relay's sessions).
|
|
1877
|
+
if (toText(payload.type) === RELAY_EVENT_HEARTBEAT_TYPE && mine.every((row) => inQuietHours(row))) continue;
|
|
1514
1878
|
const endingRemote = new Set((Array.isArray(payload.ends) ? payload.ends : []).map(toText));
|
|
1515
1879
|
const ends = mine.filter((row) => endingRemote.has(row.remote_subscription_id)).map((row) => row.id);
|
|
1880
|
+
// The watcher's own reply it received: dropped, unless it ends a one-shot subscription.
|
|
1881
|
+
if (!ends.length && ownReplyReceived(watcher, { type: toText(payload.type), detail: { messageId: cleanRemoteDetail(payload.detail).messageId } })) continue;
|
|
1516
1882
|
// The stored event is built from the allow-listed fields of the
|
|
1517
1883
|
// payload, never from the payload itself.
|
|
1518
1884
|
const detail = cleanRemoteDetail(payload.detail);
|
|
@@ -1532,7 +1898,7 @@ export function createRelayEventService({
|
|
|
1532
1898
|
...(ends.length ? { ends } : {}),
|
|
1533
1899
|
...(replay ? { replayed: true } : {}),
|
|
1534
1900
|
};
|
|
1535
|
-
const urgent =
|
|
1901
|
+
const urgent = item.urgent === true || isUrgentRelayEventType(event.type);
|
|
1536
1902
|
sql.insertEvent.run(mine[0].id, watcher, relay, toText(event.session?.id), event.type, urgent ? 1 : 0, JSON.stringify(event), at);
|
|
1537
1903
|
if (event.type !== RELAY_EVENT_HEARTBEAT_TYPE) for (const row of mine) sql.markFired.run(at, row.id);
|
|
1538
1904
|
for (const id of ends) {
|
|
@@ -1791,6 +2157,85 @@ export function createRelayEventService({
|
|
|
1791
2157
|
return { recorded, skipped };
|
|
1792
2158
|
}
|
|
1793
2159
|
|
|
2160
|
+
// ─── The watcher's own prompts ─────────────────────────────────────────────
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* The watcher sent a prompt with send or create_session (`messageId` is
|
|
2164
|
+
* the prompt's, on this relay or a paired one). Returns whether it was noted.
|
|
2165
|
+
*/
|
|
2166
|
+
function noteOwnPrompt({ watcherConversationId, relayId = RELAY_EVENT_SELF_RELAY_ID, sessionId, messageId } = {}) {
|
|
2167
|
+
const watcher = toText(watcherConversationId);
|
|
2168
|
+
const id = toText(messageId);
|
|
2169
|
+
if (!ownSql || !watcher || !id) return false;
|
|
2170
|
+
try {
|
|
2171
|
+
ownSql.insert.run(id, watcher, toText(relayId) || RELAY_EVENT_SELF_RELAY_ID, toText(sessionId), isoNow());
|
|
2172
|
+
return true;
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
warn(`own prompt not noted conv=${shortId(watcher)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
2175
|
+
return false;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/**
|
|
2180
|
+
* The watcher starts waiting (up to `waitMs`) for the reply to a prompt
|
|
2181
|
+
* it noted: until endOwnWait, that turn's turn_end is held. Returns the
|
|
2182
|
+
* token for endOwnWait, or null for a prompt that is not its own.
|
|
2183
|
+
*/
|
|
2184
|
+
function beginOwnWait({ watcherConversationId, messageId, waitMs = 0 } = {}) {
|
|
2185
|
+
const watcher = toText(watcherConversationId);
|
|
2186
|
+
const id = toText(messageId);
|
|
2187
|
+
if (!ownSql || !watcher || !id) return null;
|
|
2188
|
+
try {
|
|
2189
|
+
const own = ownSql.get.get(id);
|
|
2190
|
+
if (!own || own.watcher_conversation_id !== watcher) return null;
|
|
2191
|
+
} catch {
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
const key = ownWaitKey(watcher, id);
|
|
2195
|
+
const wait = ownWaits.get(key) || { count: 0, until: 0 };
|
|
2196
|
+
wait.count += 1;
|
|
2197
|
+
wait.until = Math.max(wait.until, now() + Math.max(0, Number(waitMs) || 0) + OWN_WAIT_GRACE_MS);
|
|
2198
|
+
ownWaits.set(key, wait);
|
|
2199
|
+
return { watcher, messageId: id };
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* The wait ended. `received`: it returned the finished reply (status done
|
|
2204
|
+
* with the reply), so the turn's turn_end is dropped (unless it ends a
|
|
2205
|
+
* one-shot subscription, which the watcher must hear of) and one that
|
|
2206
|
+
* comes later is not recorded. Otherwise the held turn_end goes as usual.
|
|
2207
|
+
*/
|
|
2208
|
+
function endOwnWait(token, { received = false } = {}) {
|
|
2209
|
+
const watcher = toText(token?.watcher);
|
|
2210
|
+
const id = toText(token?.messageId);
|
|
2211
|
+
if (!watcher || !id) return 0;
|
|
2212
|
+
const key = ownWaitKey(watcher, id);
|
|
2213
|
+
const wait = ownWaits.get(key);
|
|
2214
|
+
if (wait) {
|
|
2215
|
+
wait.count -= 1;
|
|
2216
|
+
if (wait.count <= 0) ownWaits.delete(key);
|
|
2217
|
+
}
|
|
2218
|
+
let dropped = 0;
|
|
2219
|
+
if (received && ownSql) {
|
|
2220
|
+
try {
|
|
2221
|
+
db.transaction(() => {
|
|
2222
|
+
ownSql.markReceived.run(isoNow(), id, watcher);
|
|
2223
|
+
for (const row of sql.listUndelivered.all(watcher)) {
|
|
2224
|
+
if (toText(row.type) !== 'turn_end' || Number(row.urgent) === 1) continue;
|
|
2225
|
+
const payload = parseJson(row.payload_json, null);
|
|
2226
|
+
if (toText(payload?.detail?.messageId) !== id || (Array.isArray(payload?.ends) && payload.ends.length)) continue;
|
|
2227
|
+
dropped += sql.deleteEvent.run(row.id).changes;
|
|
2228
|
+
}
|
|
2229
|
+
})();
|
|
2230
|
+
} catch (error) {
|
|
2231
|
+
warn(`own reply not marked conv=${shortId(watcher)}: ${toText(error?.message || error).slice(0, 200)}`);
|
|
2232
|
+
}
|
|
2233
|
+
if (dropped) log(`conv=${shortId(watcher)} received the reply it waited for: ${dropped} turn_end not delivered`);
|
|
2234
|
+
}
|
|
2235
|
+
scheduleFlush(watcher);
|
|
2236
|
+
return dropped;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
1794
2239
|
// ─── Hooks ─────────────────────────────────────────────────────────────────
|
|
1795
2240
|
|
|
1796
2241
|
function isRelayEventTurn(queueMessageId) {
|
|
@@ -1863,9 +2308,67 @@ export function createRelayEventService({
|
|
|
1863
2308
|
options,
|
|
1864
2309
|
questionId: toText(question.id),
|
|
1865
2310
|
...(toText(context?.source) === 'remote_relay' ? { approvalCard: true } : {}),
|
|
2311
|
+
// A question that does not block (remote_relay ask).
|
|
2312
|
+
...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
|
|
1866
2313
|
});
|
|
1867
2314
|
}
|
|
1868
2315
|
|
|
2316
|
+
/**
|
|
2317
|
+
* The owner answered or dismissed a question that does not block
|
|
2318
|
+
* (services/relay-ask-service.mjs). Each conversation in `notify` (the
|
|
2319
|
+
* session the ask is in and the conversation whose agent asked, both of
|
|
2320
|
+
* this relay) is told at once, whether it subscribed or not; then the
|
|
2321
|
+
* subscriptions on that session that take question_asked, as for any
|
|
2322
|
+
* event (paired relays' through their fetch), each watcher once. For an
|
|
2323
|
+
* ask kept on a paired relay (this relay's agent asked there) `relay` and
|
|
2324
|
+
* `sessionTitle` describe it, and only `notify` is told. Returns how many
|
|
2325
|
+
* conversations or watchers got it.
|
|
2326
|
+
*/
|
|
2327
|
+
function noteQuestionAnswered({ sessionId, sessionTitle = null, relay = null, notify = [], detail = {} } = {}) {
|
|
2328
|
+
if (!available) return 0;
|
|
2329
|
+
const id = toText(sessionId);
|
|
2330
|
+
const questionId = toText(detail.questionId);
|
|
2331
|
+
if (!id || !questionId) return 0;
|
|
2332
|
+
const clean = {
|
|
2333
|
+
questionId,
|
|
2334
|
+
prompt: String(detail.prompt ?? '').slice(0, 2000),
|
|
2335
|
+
...(detail.dismissed === true
|
|
2336
|
+
? { dismissed: true }
|
|
2337
|
+
: { answer: String(detail.answer ?? '').slice(0, 2000), choice: toText(detail.choice).slice(0, 200) }),
|
|
2338
|
+
kind: 'ask',
|
|
2339
|
+
};
|
|
2340
|
+
// Where the answer came from: a paired relay's Inbox, else this relay
|
|
2341
|
+
// (or, for an ask kept on a paired relay, what that relay said).
|
|
2342
|
+
const remoteId = toText(relay?.id);
|
|
2343
|
+
const remote = !!remoteId && remoteId !== RELAY_EVENT_SELF_RELAY_ID;
|
|
2344
|
+
const via = toText(detail.via).slice(0, 60) || (remote ? toText(relay?.name) : toText(relayName())) || '';
|
|
2345
|
+
if (via) clean.via = via;
|
|
2346
|
+
const event = remote
|
|
2347
|
+
? {
|
|
2348
|
+
v: RELAY_EVENT_VERSION,
|
|
2349
|
+
relay: { id: remoteId, name: toText(relay.name) || remoteId },
|
|
2350
|
+
session: { id, title: toText(sessionTitle) || null },
|
|
2351
|
+
type: RELAY_EVENT_QUESTION_ANSWERED_TYPE,
|
|
2352
|
+
at: isoNow(),
|
|
2353
|
+
detail: clean,
|
|
2354
|
+
}
|
|
2355
|
+
: buildEvent(RELAY_EVENT_QUESTION_ANSWERED_TYPE, id, clean);
|
|
2356
|
+
const told = new Set();
|
|
2357
|
+
for (const watcher of new Set((Array.isArray(notify) ? notify : []).map(toText).filter(Boolean))) {
|
|
2358
|
+
if (isServedKey(watcher) || !isLive(conversation(watcher))) continue;
|
|
2359
|
+
sql.insertEvent.run(`${ASK_DELIVERY_PREFIX}${questionId}`, watcher, event.relay.id, id, event.type, 1, JSON.stringify({ ...event, subscriptionIds: [] }), event.at);
|
|
2360
|
+
scheduleFlush(watcher, { urgent: true });
|
|
2361
|
+
told.add(watcher);
|
|
2362
|
+
}
|
|
2363
|
+
let recorded = 0;
|
|
2364
|
+
if (!remote) {
|
|
2365
|
+
const rows = localWatchRows().filter((row) => !told.has(row.watcher_conversation_id) && wants(row, event) && subscriptionCovers(row, id));
|
|
2366
|
+
if (rows.length) recorded = enqueueForRows(rows, event, id);
|
|
2367
|
+
}
|
|
2368
|
+
log(`question ${shortId(questionId)} of ${shortId(id)} ${clean.dismissed ? 'dismissed' : 'answered'}: told ${told.size} asking, ${recorded} watching`);
|
|
2369
|
+
return told.size + recorded;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
1869
2372
|
/** The tracker's edge: a background task started or ended. */
|
|
1870
2373
|
function noteTaskEdge({ type, conversationId, task } = {}) {
|
|
1871
2374
|
if (!available || !task) return;
|
|
@@ -1922,6 +2425,7 @@ export function createRelayEventService({
|
|
|
1922
2425
|
try { for (const r of sql.listRunningRunConversations.all()) ids.add(toText(r.conversation_id)); } catch {}
|
|
1923
2426
|
for (const id of tracker?.knownConversationIds?.() || []) ids.add(toText(id));
|
|
1924
2427
|
ids.delete(row.watcher_conversation_id);
|
|
2428
|
+
for (const id of optionsOf(row).exclude) ids.delete(id);
|
|
1925
2429
|
return [...ids].filter((id) => id && isLive(conversation(id)));
|
|
1926
2430
|
}
|
|
1927
2431
|
|
|
@@ -1962,6 +2466,9 @@ export function createRelayEventService({
|
|
|
1962
2466
|
const busy = busyEntries(row);
|
|
1963
2467
|
noteHeartbeatBusy(row, busy.length > 0);
|
|
1964
2468
|
if (!busy.length) continue;
|
|
2469
|
+
// None inside the quiet window: held, it would only say late that the
|
|
2470
|
+
// watching worked. The first after the window comes at once.
|
|
2471
|
+
if (inQuietHours(row, at)) continue;
|
|
1965
2472
|
const since = Math.max(busySinceBySubscription.get(row.id) || at, Date.parse(String(row.last_heartbeat_at || '')) || 0);
|
|
1966
2473
|
if (at - since < minutes * 60_000) continue;
|
|
1967
2474
|
sql.markHeartbeat.run(new Date(at).toISOString(), row.id);
|
|
@@ -2059,6 +2566,10 @@ export function createRelayEventService({
|
|
|
2059
2566
|
const at = now();
|
|
2060
2567
|
const events = sql.deleteDeliveredBefore.run(new Date(at - RELAY_EVENT_DELIVERED_KEEP_MS).toISOString()).changes;
|
|
2061
2568
|
const subscriptions = deleteEndedSubscriptionsBefore.run(new Date(at - RELAY_EVENT_ENDED_SUBSCRIPTION_KEEP_MS).toISOString()).changes;
|
|
2569
|
+
ownSql?.pruneBefore.run(new Date(at - RELAY_EVENT_OWN_PROMPT_KEEP_MS).toISOString());
|
|
2570
|
+
for (const [key, wait] of ownWaits) {
|
|
2571
|
+
if (wait.until <= at) ownWaits.delete(key);
|
|
2572
|
+
}
|
|
2062
2573
|
if (events || subscriptions) {
|
|
2063
2574
|
log(`pruned ${events} delivered event${events === 1 ? '' : 's'} and ${subscriptions} ended subscription${subscriptions === 1 ? '' : 's'}`);
|
|
2064
2575
|
}
|
|
@@ -2131,9 +2642,14 @@ export function createRelayEventService({
|
|
|
2131
2642
|
listActive,
|
|
2132
2643
|
record,
|
|
2133
2644
|
flush,
|
|
2645
|
+
noteOwnPrompt,
|
|
2646
|
+
beginOwnWait,
|
|
2647
|
+
endOwnWait,
|
|
2648
|
+
areOptionsAvailable: () => optionsAvailable,
|
|
2134
2649
|
noteTurnEnd,
|
|
2135
2650
|
noteError,
|
|
2136
2651
|
noteQuestion,
|
|
2652
|
+
noteQuestionAnswered,
|
|
2137
2653
|
noteTaskEdge,
|
|
2138
2654
|
noteConversationGone,
|
|
2139
2655
|
noteRestartRequested,
|