@oar-sh/oar 0.9.9 → 0.9.10
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 +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Who receives a sub-agent's record live: only the pages whose viewer shows
|
|
4
|
+
// that run. The viewer names its run with `subagent_viewer_watch` (on open,
|
|
5
|
+
// on switching to a child or its parent, and after every reconnect) and
|
|
6
|
+
// leaves with `subagent_viewer_unwatch`; the relay keeps each socket in at
|
|
7
|
+
// most one viewer room. `emitSubagentTimeline` sends volatile to that room,
|
|
8
|
+
// so a page without the viewer gets nothing and a connection that cannot take
|
|
9
|
+
// a batch drops it — the viewer fetches whatever it misses.
|
|
10
|
+
|
|
11
|
+
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
12
|
+
|
|
13
|
+
const ROOM_PREFIX = 'subagent-viewer:';
|
|
14
|
+
|
|
15
|
+
export function subagentViewerRoom(runId) {
|
|
16
|
+
return `${ROOM_PREFIX}${String(runId || '')}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sends a run's record batches to its viewers. socket.io drops a volatile
|
|
21
|
+
* packet whenever the connection is still writing an earlier one, even one
|
|
22
|
+
* sent in the same tick, and a worker post that changes the run's header
|
|
23
|
+
* emits the run's status just before its batch. So batches wait one tick,
|
|
24
|
+
* merged per run (the newest copy of an entry wins), and go out together:
|
|
25
|
+
* dropped only when the connection is really backed up.
|
|
26
|
+
*/
|
|
27
|
+
export function createSubagentTimelinePublisher(io, { defer = setImmediate } = {}) {
|
|
28
|
+
const pending = new Map();
|
|
29
|
+
let scheduled = false;
|
|
30
|
+
|
|
31
|
+
function flush() {
|
|
32
|
+
scheduled = false;
|
|
33
|
+
const batches = [...pending.values()];
|
|
34
|
+
pending.clear();
|
|
35
|
+
for (const batch of batches) {
|
|
36
|
+
const entries = [...batch.entries.values()].sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
37
|
+
try {
|
|
38
|
+
io.to(subagentViewerRoom(batch.subagentRunId)).volatile.emit('subagent_timeline', { ...batch.head, entries });
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return function publish(payload) {
|
|
44
|
+
const runId = String(payload?.subagentRunId || '');
|
|
45
|
+
if (!runId || !payload?.entries?.length) return;
|
|
46
|
+
let batch = pending.get(runId);
|
|
47
|
+
if (!batch) {
|
|
48
|
+
batch = { subagentRunId: runId, head: null, entries: new Map() };
|
|
49
|
+
pending.set(runId, batch);
|
|
50
|
+
}
|
|
51
|
+
const { entries, ...head } = payload;
|
|
52
|
+
batch.head = { ...head, truncated: !!(batch.head?.truncated || head.truncated) };
|
|
53
|
+
for (const entry of entries) batch.entries.set(Number(entry.seq), entry);
|
|
54
|
+
if (!scheduled) {
|
|
55
|
+
scheduled = true;
|
|
56
|
+
defer(flush);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeAck(ack, payload) {
|
|
62
|
+
if (typeof ack !== 'function') return;
|
|
63
|
+
try { ack(payload); } catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `getRun(runId)` returns the run row (`id`, `conversation_id`) or null.
|
|
68
|
+
* Sockets reaching `registerSocket` are already authenticated (the `io.use`
|
|
69
|
+
* gate, and the re-check of recovered sockets in the connection handler).
|
|
70
|
+
*/
|
|
71
|
+
export function createSubagentViewerRooms({ getRun } = {}) {
|
|
72
|
+
if (typeof getRun !== 'function') throw new Error('createSubagentViewerRooms requires getRun');
|
|
73
|
+
|
|
74
|
+
function leave(socket) {
|
|
75
|
+
const current = socket.data?.subagentViewerRoom;
|
|
76
|
+
if (current) socket.leave(current);
|
|
77
|
+
if (socket.data) socket.data.subagentViewerRoom = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function watch(socket, payload) {
|
|
81
|
+
const conversationId = String(payload?.conversationId || '').trim();
|
|
82
|
+
const runId = sanitizeSubagentRunId(payload?.subagentRunId);
|
|
83
|
+
if (!conversationId || !runId) return { ok: false, code: 'missing-id' };
|
|
84
|
+
let run = null;
|
|
85
|
+
try {
|
|
86
|
+
run = getRun(runId);
|
|
87
|
+
} catch {
|
|
88
|
+
return { ok: false, code: 'unavailable' };
|
|
89
|
+
}
|
|
90
|
+
if (!run) return { ok: false, code: 'not-found' };
|
|
91
|
+
if (String(run.conversation_id || '') !== conversationId) return { ok: false, code: 'conversation-mismatch' };
|
|
92
|
+
const room = subagentViewerRoom(runId);
|
|
93
|
+
if (socket.data?.subagentViewerRoom !== room) {
|
|
94
|
+
leave(socket);
|
|
95
|
+
socket.join(room);
|
|
96
|
+
if (socket.data) socket.data.subagentViewerRoom = room;
|
|
97
|
+
} else if (!socket.rooms?.has?.(room)) {
|
|
98
|
+
// A recovered socket keeps its data; its rooms may or may not be back.
|
|
99
|
+
socket.join(room);
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, subagentRunId: runId };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function registerSocket(socket) {
|
|
105
|
+
if (!socket || typeof socket.on !== 'function') return;
|
|
106
|
+
socket.on('subagent_viewer_watch', (payload, ack) => {
|
|
107
|
+
const result = watch(socket, payload);
|
|
108
|
+
// A refused watch leaves no stale room behind: the page shows another run now.
|
|
109
|
+
if (!result.ok) leave(socket);
|
|
110
|
+
safeAck(ack, result);
|
|
111
|
+
});
|
|
112
|
+
socket.on('subagent_viewer_unwatch', (_payload, ack) => {
|
|
113
|
+
leave(socket);
|
|
114
|
+
safeAck(ack, { ok: true });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { registerSocket };
|
|
119
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Node removes its own error listener from a socket the moment it emits
|
|
2
|
+
// 'upgrade'. Until a handler claims the socket and listens itself, a client
|
|
3
|
+
// that resets the connection raises an error nobody hears, and an unheard
|
|
4
|
+
// 'error' is an uncaught exception that ends the process. Socket.IO leaves a
|
|
5
|
+
// socket on a path it does not own alone for a full second before it closes
|
|
6
|
+
// it, so any upgrade to an unknown path could take the relay down.
|
|
7
|
+
|
|
8
|
+
const guarded = new WeakSet();
|
|
9
|
+
|
|
10
|
+
export function guardUpgradeSocket(socket) {
|
|
11
|
+
if (!socket || typeof socket.on !== 'function' || guarded.has(socket)) return;
|
|
12
|
+
guarded.add(socket);
|
|
13
|
+
socket.on('error', () => {
|
|
14
|
+
try { socket.destroy(); } catch {}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Relay events: what a watching session is told about the other sessions of
|
|
2
|
+
// its relay, and the message the relay writes into the watcher's
|
|
3
|
+
// conversation to tell it.
|
|
4
|
+
//
|
|
5
|
+
// One event is `{ v, relay: { id, name }, session: { id, title }, type, at,
|
|
6
|
+
// detail }`. `relay.id` is 'self' for a session on the same relay, the
|
|
7
|
+
// watching relay's registry id for a session on a paired relay (its lines
|
|
8
|
+
// then start with the relay's name). `replayed: true` marks an event a paired
|
|
9
|
+
// relay kept while the link to it was down.
|
|
10
|
+
//
|
|
11
|
+
// SDK-free and side-effect free: imported by the server, the remote_relay
|
|
12
|
+
// contract and the browser (which renders the event rows).
|
|
13
|
+
|
|
14
|
+
export const RELAY_EVENT_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
// `messages.kind` of the message the relay writes into the watcher's
|
|
17
|
+
// conversation. The browser renders such a row as an event, never as a
|
|
18
|
+
// message of the user's.
|
|
19
|
+
export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
|
|
20
|
+
|
|
21
|
+
export const RELAY_EVENT_TYPES = Object.freeze([
|
|
22
|
+
'question_asked',
|
|
23
|
+
'error',
|
|
24
|
+
'turn_end',
|
|
25
|
+
'task_started',
|
|
26
|
+
'task_finished',
|
|
27
|
+
'task_failed',
|
|
28
|
+
'stalled',
|
|
29
|
+
'all_idle',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Not one a subscription names in `events`: `heartbeat_minutes` turns it on.
|
|
33
|
+
export const RELAY_EVENT_HEARTBEAT_TYPE = 'heartbeat';
|
|
34
|
+
// Not subscribable either: every watcher with subscriptions on a paired relay
|
|
35
|
+
// gets them in the one row that replays what that relay kept while the link
|
|
36
|
+
// to it was down.
|
|
37
|
+
export const RELAY_EVENT_LINK_LOST_TYPE = 'link_lost';
|
|
38
|
+
export const RELAY_EVENT_LINK_RESTORED_TYPE = 'link_restored';
|
|
39
|
+
const LINK_EVENT_TYPES = Object.freeze([RELAY_EVENT_LINK_LOST_TYPE, RELAY_EVENT_LINK_RESTORED_TYPE]);
|
|
40
|
+
const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBEAT_TYPE, ...LINK_EVENT_TYPES]);
|
|
41
|
+
|
|
42
|
+
// What a subscription gets when it names no events: everything but the
|
|
43
|
+
// start of a task, which a watcher asks for explicitly.
|
|
44
|
+
export const RELAY_EVENT_DEFAULT_TYPES = Object.freeze(RELAY_EVENT_TYPES.filter((type) => type !== 'task_started'));
|
|
45
|
+
|
|
46
|
+
// Never held back: not by the 30 s coalescing, not by the digest past the
|
|
47
|
+
// hourly cap (they still count toward it).
|
|
48
|
+
export const RELAY_EVENT_URGENT_TYPES = Object.freeze(['question_asked', 'error']);
|
|
49
|
+
|
|
50
|
+
export const RELAY_EVENT_SCOPES = Object.freeze(['sessions', 'all']);
|
|
51
|
+
|
|
52
|
+
export const RELAY_EVENT_LIMITS = Object.freeze({
|
|
53
|
+
coalesceMs: 30_000,
|
|
54
|
+
wakesPerHour: 20,
|
|
55
|
+
digestIntervalMs: 10 * 60_000,
|
|
56
|
+
stalledMinutesDefault: 15,
|
|
57
|
+
stalledMinutesMin: 1,
|
|
58
|
+
stalledMinutesMax: 24 * 60,
|
|
59
|
+
heartbeatMinutesMin: 1,
|
|
60
|
+
heartbeatMinutesMax: 24 * 60,
|
|
61
|
+
matchMaxChars: 200,
|
|
62
|
+
sessionsMax: 50,
|
|
63
|
+
activeSubscriptionsPerWatcher: 20,
|
|
64
|
+
previewChars: 300,
|
|
65
|
+
questionChars: 500,
|
|
66
|
+
optionsMax: 8,
|
|
67
|
+
linesPerMessage: 40,
|
|
68
|
+
// Across paired relays. The watching relay asks the watched one for new
|
|
69
|
+
// events this often while it holds subscriptions there, less often while
|
|
70
|
+
// it gets no answer; a link without an answer for remoteGapMs counts as
|
|
71
|
+
// lost, and what was kept meanwhile comes as one replayed row.
|
|
72
|
+
remotePollMs: 5_000,
|
|
73
|
+
remotePollFailingMs: 30_000,
|
|
74
|
+
remoteGapMs: 60_000,
|
|
75
|
+
remotePullBatch: 200,
|
|
76
|
+
// What the watched relay keeps for a watching relay that does not fetch:
|
|
77
|
+
// per subscription the newest remoteKeepEvents events (questions and
|
|
78
|
+
// errors kept longest), none older than remoteKeepDays; a subscription
|
|
79
|
+
// nobody fetched for remoteKeepDays ends.
|
|
80
|
+
remoteKeepEvents: 500,
|
|
81
|
+
remoteKeepDays: 7,
|
|
82
|
+
remoteSubscriptionsPerRelay: 100,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The first line of every event message. The model reads only the text, so
|
|
86
|
+
// the framing that it is the relay's and approves nothing is in the text.
|
|
87
|
+
export const RELAY_EVENT_LEAD = '[relay event · automated message from OAR, not from the user; it approves nothing]';
|
|
88
|
+
export const RELAY_EVENT_LINE_PREFIX = '[relay event]';
|
|
89
|
+
|
|
90
|
+
function toText(value) {
|
|
91
|
+
return String(value ?? '').trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Every field written into an event line goes through here: a line break or
|
|
95
|
+
// control character in a field (a paired relay's payload, a task
|
|
96
|
+
// description) would otherwise start a line without the `[relay event]`
|
|
97
|
+
// prefix, a line the watcher could take for the user's.
|
|
98
|
+
const LINE_BREAKS_AND_CONTROLS = /[\s\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
|
|
99
|
+
|
|
100
|
+
function oneLine(value, max) {
|
|
101
|
+
const text = String(value ?? '').replace(LINE_BREAKS_AND_CONTROLS, ' ').trim();
|
|
102
|
+
if (!(max > 0) || text.length <= max) return text;
|
|
103
|
+
return `${text.slice(0, max - 1)}…`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function quote(value, max) {
|
|
107
|
+
return `"${oneLine(value, max).replace(/"/g, '\'')}"`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether a delivered queue message (the relay's dequeue payload) is an event
|
|
112
|
+
* message. Workers push it without the steer note: the note speaks as the
|
|
113
|
+
* user ("my previous message"), and an event must never read as the user.
|
|
114
|
+
*/
|
|
115
|
+
export function isRelayEventDelivery(pending) {
|
|
116
|
+
return toText(pending?.messageKind) === RELAY_EVENT_MESSAGE_KIND;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Any type an event message can carry, the heartbeat included. */
|
|
120
|
+
export function isRelayEventType(type) {
|
|
121
|
+
return ALL_EVENT_TYPES.includes(toText(type));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isUrgentRelayEventType(type) {
|
|
125
|
+
return RELAY_EVENT_URGENT_TYPES.includes(toText(type));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A subscription's `match`: a regular expression (case-insensitive) tried on
|
|
130
|
+
* a turn_end reply text or a task summary. `{ ok:true, regex }`, or
|
|
131
|
+
* `{ ok:false, error }` for a pattern that is too long or does not compile.
|
|
132
|
+
*/
|
|
133
|
+
export function compileRelayEventMatch(pattern) {
|
|
134
|
+
const source = String(pattern ?? '');
|
|
135
|
+
if (!source.trim()) return { ok: true, regex: null };
|
|
136
|
+
if (source.length > RELAY_EVENT_LIMITS.matchMaxChars) {
|
|
137
|
+
return { ok: false, error: `match is longer than ${RELAY_EVENT_LIMITS.matchMaxChars} characters` };
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return { ok: true, regex: new RegExp(source, 'i') };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return { ok: false, error: `match is not a valid regular expression: ${oneLine(error?.message || error, 120)}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The text a `match` is tried on, or null when the event has none. */
|
|
147
|
+
export function relayEventMatchText(event) {
|
|
148
|
+
const detail = event?.detail || {};
|
|
149
|
+
if (event?.type === 'turn_end') return String(detail.replyText ?? detail.preview ?? '');
|
|
150
|
+
if (event?.type === 'task_finished' || event?.type === 'task_failed') {
|
|
151
|
+
return [detail.description, detail.summary].map(toText).filter(Boolean).join('\n');
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isRemoteEvent(event) {
|
|
157
|
+
const id = toText(event?.relay?.id);
|
|
158
|
+
return !!id && id !== 'self';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The name a paired relay's lines start with: no quotes, no separator dots. */
|
|
162
|
+
function relayDisplayName(event) {
|
|
163
|
+
return oneLine(event?.relay?.name || event?.relay?.id, 60).replace(/["·]+/g, ' ').replace(/\s+/g, ' ').trim() || 'paired relay';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `[relay event] ` for this relay's sessions, `[relay event] lab-relay · ` for a paired relay's. */
|
|
167
|
+
function linePrefix(event) {
|
|
168
|
+
return isRemoteEvent(event)
|
|
169
|
+
? `${RELAY_EVENT_LINE_PREFIX} ${relayDisplayName(event)} · `
|
|
170
|
+
: `${RELAY_EVENT_LINE_PREFIX} `;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sessionLabel(event) {
|
|
174
|
+
// Parentheses are the id's own delimiters in the line.
|
|
175
|
+
const id = oneLine(event?.session?.id, 80).replace(/[()]/g, '');
|
|
176
|
+
const title = oneLine(event?.session?.title, 80) || 'untitled';
|
|
177
|
+
return `session ${quote(title, 80)} (${id})`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function minutesBetween(from, to) {
|
|
181
|
+
const start = Date.parse(String(from || ''));
|
|
182
|
+
const end = Date.parse(String(to || ''));
|
|
183
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
|
|
184
|
+
return Math.max(0, Math.round((end - start) / 60_000));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function taskOutcome(detail) {
|
|
188
|
+
const kind = oneLine(detail.kind, 40) || 'task';
|
|
189
|
+
const parts = [kind];
|
|
190
|
+
const exitCode = Number.isInteger(detail.exitCode) ? detail.exitCode : null;
|
|
191
|
+
const status = oneLine(detail.status, 40);
|
|
192
|
+
if (exitCode !== null) parts.push(`exit ${exitCode}`);
|
|
193
|
+
else if (status === 'completed' && detail.statusReported !== false) parts.push('exit ok');
|
|
194
|
+
else if (status === 'completed') parts.push('ended, outcome not reported');
|
|
195
|
+
else if (status) parts.push(status);
|
|
196
|
+
return parts.join(', ');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One line of the event message, the watcher's to read. */
|
|
200
|
+
export function formatRelayEventLine(event) {
|
|
201
|
+
const type = oneLine(event?.type, 40);
|
|
202
|
+
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
|
|
203
|
+
const prefix = linePrefix(event);
|
|
204
|
+
if (type === RELAY_EVENT_LINK_LOST_TYPE) {
|
|
205
|
+
const since = oneLine(detail.since, 40);
|
|
206
|
+
return `${prefix}${type}: no answer from relay ${quote(relayDisplayName(event), 60)}${since ? ` since ${since}` : ''}; your subscriptions there saw nothing meanwhile`;
|
|
207
|
+
}
|
|
208
|
+
if (type === RELAY_EVENT_LINK_RESTORED_TYPE) {
|
|
209
|
+
const minutes = minutesBetween(detail.since, event?.at);
|
|
210
|
+
const kept = Number(detail.kept) || 0;
|
|
211
|
+
const dropped = Number(detail.dropped) || 0;
|
|
212
|
+
const parts = [`${prefix}${type}: relay ${quote(relayDisplayName(event), 60)} answers again${minutes !== null ? ` after ${minutes} min` : ''}`];
|
|
213
|
+
parts.push(kept ? `${kept} event${kept === 1 ? '' : 's'} it kept meanwhile ${kept === 1 ? 'is' : 'are'} listed above` : 'nothing happened meanwhile');
|
|
214
|
+
if (dropped) {
|
|
215
|
+
parts.push(`${dropped} older event${dropped === 1 ? ' was' : 's were'} not kept (it keeps at most ${RELAY_EVENT_LIMITS.remoteKeepEvents} per subscription, for ${RELAY_EVENT_LIMITS.remoteKeepDays} days)`);
|
|
216
|
+
}
|
|
217
|
+
const ended = (Array.isArray(detail.ended) ? detail.ended : []).map((id) => oneLine(id, 80)).filter(Boolean);
|
|
218
|
+
if (ended.length) parts.push(`subscription${ended.length === 1 ? '' : 's'} ${ended.join(', ')} ended there meanwhile`);
|
|
219
|
+
return parts.join('; ');
|
|
220
|
+
}
|
|
221
|
+
if (type === 'all_idle') {
|
|
222
|
+
const count = Number(detail.sessionCount);
|
|
223
|
+
const set = detail.scope === 'all'
|
|
224
|
+
? 'any watched session'
|
|
225
|
+
: `the ${Number.isFinite(count) && count > 0 ? `${count} ` : ''}watched session${count === 1 ? '' : 's'}`;
|
|
226
|
+
return `${prefix}all_idle: no turn or task is running in ${set}`
|
|
227
|
+
+ (oneLine(detail.subscriptionId, 80) ? ` (subscription ${oneLine(detail.subscriptionId, 80)})` : '');
|
|
228
|
+
}
|
|
229
|
+
if (type === RELAY_EVENT_HEARTBEAT_TYPE) {
|
|
230
|
+
const busy = (Array.isArray(detail.busy) ? detail.busy : [])
|
|
231
|
+
.slice(0, 10)
|
|
232
|
+
.map((entry) => `${quote(entry?.title || entry?.id || 'untitled', 60)} (${oneLine(entry?.what, 40) || 'busy'})`);
|
|
233
|
+
const watched = detail.scope === 'all' ? 'all sessions' : `${Number(detail.sessionCount) || 0} session${Number(detail.sessionCount) === 1 ? '' : 's'}`;
|
|
234
|
+
return `${prefix}heartbeat: still watching ${watched}`
|
|
235
|
+
+ (oneLine(detail.subscriptionId, 80) ? ` (subscription ${oneLine(detail.subscriptionId, 80)})` : '')
|
|
236
|
+
+ (busy.length ? `; working: ${busy.join(', ')}` : '');
|
|
237
|
+
}
|
|
238
|
+
const head = `${prefix}${sessionLabel(event)}: ${type}`;
|
|
239
|
+
switch (type) {
|
|
240
|
+
case 'question_asked': {
|
|
241
|
+
const options = (Array.isArray(detail.options) ? detail.options : [])
|
|
242
|
+
.map((option) => oneLine(option, 80))
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.slice(0, RELAY_EVENT_LIMITS.optionsMax);
|
|
245
|
+
const parts = [`${head} ${quote(detail.prompt, RELAY_EVENT_LIMITS.questionChars)}`];
|
|
246
|
+
if (options.length) parts.push(`options: ${options.map((option) => `[${option}]`).join(' ')}`);
|
|
247
|
+
if (detail.approvalCard) parts.push('(an approval card: only the user can answer it)');
|
|
248
|
+
else if (oneLine(detail.questionId, 80)) parts.push(`(question_id ${oneLine(detail.questionId, 80)})`);
|
|
249
|
+
return parts.join(' ');
|
|
250
|
+
}
|
|
251
|
+
case 'error': {
|
|
252
|
+
const what = oneLine(detail.message, RELAY_EVENT_LIMITS.previewChars);
|
|
253
|
+
return `${head} ${oneLine(detail.kind, 40) || 'error'}${what ? `: ${what}` : ''}`;
|
|
254
|
+
}
|
|
255
|
+
case 'turn_end': {
|
|
256
|
+
const status = oneLine(detail.status, 40) || 'done';
|
|
257
|
+
const preview = oneLine(detail.preview ?? detail.replyText, RELAY_EVENT_LIMITS.previewChars);
|
|
258
|
+
return `${head} (${status})${preview ? ` ${quote(preview, RELAY_EVENT_LIMITS.previewChars)}` : ''}`;
|
|
259
|
+
}
|
|
260
|
+
case 'task_started':
|
|
261
|
+
return `${head} ${quote(detail.description || 'task', 120)} (${oneLine(detail.kind, 40) || 'task'})`;
|
|
262
|
+
case 'task_finished':
|
|
263
|
+
case 'task_failed': {
|
|
264
|
+
const summary = oneLine(detail.summary, 200);
|
|
265
|
+
return `${head} ${quote(detail.description || 'task', 120)} (${taskOutcome(detail)})${summary ? `: ${summary}` : ''}`;
|
|
266
|
+
}
|
|
267
|
+
case 'stalled': {
|
|
268
|
+
const minutes = Number(detail.quietMinutes);
|
|
269
|
+
const what = oneLine(detail.what, 80) || 'work';
|
|
270
|
+
return `${head}: no activity for ${Number.isFinite(minutes) ? minutes : '?'} min while ${what} is still running`;
|
|
271
|
+
}
|
|
272
|
+
default:
|
|
273
|
+
return head;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const URGENT_ORDER = ['question_asked', 'error'];
|
|
278
|
+
|
|
279
|
+
function replayOrder(a, b) {
|
|
280
|
+
const rank = (event) => {
|
|
281
|
+
const index = URGENT_ORDER.indexOf(toText(event?.type));
|
|
282
|
+
return index === -1 ? URGENT_ORDER.length : index;
|
|
283
|
+
};
|
|
284
|
+
return rank(a) - rank(b) || (Date.parse(String(a?.at || '')) || 0) - (Date.parse(String(b?.at || '')) || 0);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function pushCapped(lines, events) {
|
|
288
|
+
const shown = events.slice(0, RELAY_EVENT_LIMITS.linesPerMessage);
|
|
289
|
+
for (const event of shown) lines.push(formatRelayEventLine(event));
|
|
290
|
+
if (events.length > shown.length) lines.push(`${RELAY_EVENT_LINE_PREFIX} … and ${events.length - shown.length} more`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The whole message for one wake-up: the lead line, then one line per event
|
|
295
|
+
* (at most `linesPerMessage`, then a count of the rest). `digest` says the
|
|
296
|
+
* hourly cap was reached and these were gathered over ten minutes; `ended`
|
|
297
|
+
* lists one-shot subscriptions this message ends.
|
|
298
|
+
*
|
|
299
|
+
* What a paired relay kept while the link to it was down (`replayed`, and
|
|
300
|
+
* the link_lost / link_restored lines) comes after the rest, one section per
|
|
301
|
+
* relay: link_lost, a "Replayed:" line, the kept events with questions and
|
|
302
|
+
* errors first, link_restored.
|
|
303
|
+
*/
|
|
304
|
+
export function buildRelayEventMessageText(events, { digest = false, ended = [] } = {}) {
|
|
305
|
+
const list = (Array.isArray(events) ? events : []).filter(Boolean);
|
|
306
|
+
const replayRelay = (event) => (isRemoteEvent(event) && (event.replayed === true || LINK_EVENT_TYPES.includes(toText(event.type))))
|
|
307
|
+
? toText(event.relay.id)
|
|
308
|
+
: '';
|
|
309
|
+
const plain = list.filter((event) => !replayRelay(event));
|
|
310
|
+
const replays = new Map();
|
|
311
|
+
for (const event of list) {
|
|
312
|
+
const relayId = replayRelay(event);
|
|
313
|
+
if (!relayId) continue;
|
|
314
|
+
if (!replays.has(relayId)) replays.set(relayId, []);
|
|
315
|
+
replays.get(relayId).push(event);
|
|
316
|
+
}
|
|
317
|
+
const lines = [RELAY_EVENT_LEAD];
|
|
318
|
+
if (digest && plain.length) {
|
|
319
|
+
lines.push(`Digest of ${plain.length} event${plain.length === 1 ? '' : 's'} (the limit of ${RELAY_EVENT_LIMITS.wakesPerHour} wake-ups an hour is reached; until it frees up, events come every ${Math.round(RELAY_EVENT_LIMITS.digestIntervalMs / 60_000)} min):`);
|
|
320
|
+
}
|
|
321
|
+
pushCapped(lines, plain);
|
|
322
|
+
for (const group of replays.values()) {
|
|
323
|
+
const lost = group.filter((event) => event.type === RELAY_EVENT_LINK_LOST_TYPE);
|
|
324
|
+
const restored = group.filter((event) => event.type === RELAY_EVENT_LINK_RESTORED_TYPE);
|
|
325
|
+
const kept = group.filter((event) => !LINK_EVENT_TYPES.includes(toText(event.type))).sort(replayOrder);
|
|
326
|
+
for (const event of lost) lines.push(formatRelayEventLine(event));
|
|
327
|
+
if (kept.length) {
|
|
328
|
+
lines.push(`Replayed: ${kept.length} event${kept.length === 1 ? '' : 's'} relay ${quote(relayDisplayName(group[0]), 60)} kept while the link was down, questions and errors first:`);
|
|
329
|
+
pushCapped(lines, kept);
|
|
330
|
+
}
|
|
331
|
+
for (const event of restored) lines.push(formatRelayEventLine(event));
|
|
332
|
+
}
|
|
333
|
+
const endedIds = (Array.isArray(ended) ? ended : []).map((id) => oneLine(id, 80)).filter(Boolean);
|
|
334
|
+
if (endedIds.length) {
|
|
335
|
+
lines.push(`${RELAY_EVENT_LINE_PREFIX} one-shot subscription${endedIds.length === 1 ? '' : 's'} ${endedIds.join(', ')} ended with this event.`);
|
|
336
|
+
}
|
|
337
|
+
return lines.join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// An event line: the prefix, a paired relay's name, the session, the type.
|
|
341
|
+
export const RELAY_EVENT_LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
|
|
342
|
+
const DIGEST_LINE_PATTERN = /^Digest of \d+ events?/;
|
|
343
|
+
const REPLAYED_LINE_PATTERN = /^Replayed: \d+ events?/;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The browser's view of an event message: the event lines without the lead
|
|
347
|
+
* and the summary the collapsed row shows (`3 events · question_asked,
|
|
348
|
+
* turn_end`). Tolerates any text: a row whose text is not an event message
|
|
349
|
+
* still renders, as one line.
|
|
350
|
+
*/
|
|
351
|
+
export function parseRelayEventMessageText(text) {
|
|
352
|
+
const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
|
|
353
|
+
const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
|
|
354
|
+
const digest = DIGEST_LINE_PATTERN.test(body[0] || '');
|
|
355
|
+
const replayed = body.some((line) => REPLAYED_LINE_PATTERN.test(line));
|
|
356
|
+
const lines = body.filter((line) => !DIGEST_LINE_PATTERN.test(line) && !REPLAYED_LINE_PATTERN.test(line));
|
|
357
|
+
const types = [];
|
|
358
|
+
let count = 0;
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
const match = RELAY_EVENT_LINE_PATTERN.exec(line);
|
|
361
|
+
const type = match && isRelayEventType(match[1]) ? match[1] : '';
|
|
362
|
+
if (!type) continue;
|
|
363
|
+
count += 1;
|
|
364
|
+
if (!types.includes(type)) types.push(type);
|
|
365
|
+
}
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
const more = /^\[relay event\] … and (\d+) more$/.exec(line);
|
|
368
|
+
if (more) count += Number(more[1]) || 0;
|
|
369
|
+
}
|
|
370
|
+
return { lines, types, count: count || lines.length, digest, replayed };
|
|
371
|
+
}
|