@oar-sh/oar 0.9.8 → 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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- 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 +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -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 +91 -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 +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -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 +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -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-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -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 +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -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/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SUBAGENT_TIMELINE_MAX_ENTRIES,
|
|
5
|
+
SUBAGENT_TIMELINE_TOOL_NAME_MAX_CHARS,
|
|
6
|
+
cutSubagentTimelineText,
|
|
7
|
+
isSubagentTimelineKind,
|
|
8
|
+
normalizeSubagentRunStatus,
|
|
9
|
+
} from '../../shared/subagent-timeline.mjs';
|
|
10
|
+
import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
|
|
11
|
+
|
|
12
|
+
export const TIMELINE_READ_DEFAULT_LIMIT = 500;
|
|
13
|
+
export const TIMELINE_READ_MAX_LIMIT = 2000;
|
|
14
|
+
const ENTRY_KEY_MAX_CHARS = 200;
|
|
15
|
+
const METADATA_MAX_CHARS = 4096;
|
|
16
|
+
const LEGACY_ROW_LIMIT = 2000;
|
|
17
|
+
export const TRUNCATED_NOTE_KEY = 'relay:truncated';
|
|
18
|
+
|
|
19
|
+
function parseJson(raw) {
|
|
20
|
+
if (!raw) return null;
|
|
21
|
+
try {
|
|
22
|
+
const value = JSON.parse(raw);
|
|
23
|
+
return value && typeof value === 'object' ? value : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function finiteOrNull(value) {
|
|
30
|
+
const number = Number(value);
|
|
31
|
+
return value !== null && value !== undefined && value !== '' && Number.isFinite(number) ? number : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cleanText(value, max) {
|
|
35
|
+
const text = String(value ?? '').trim();
|
|
36
|
+
return text ? text.slice(0, max) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The run as the browser sees it: the row panel, bubble and viewer render. */
|
|
40
|
+
export function mapSubagentRunHeader(row, { stoppable = false, legacy = false } = {}) {
|
|
41
|
+
const id = String(row?.id || '').trim();
|
|
42
|
+
if (!id) return null;
|
|
43
|
+
return {
|
|
44
|
+
subagentRunId: id,
|
|
45
|
+
messageId: String(row?.queue_message_id || '').trim() || null,
|
|
46
|
+
conversationId: String(row?.conversation_id || '').trim() || null,
|
|
47
|
+
parentSubagentId: row?.parent_subagent_id ? String(row.parent_subagent_id).trim() : null,
|
|
48
|
+
displayName: row?.display_name ? String(row.display_name).trim() : null,
|
|
49
|
+
status: String(row?.status || 'running').trim().toLowerCase() || 'running',
|
|
50
|
+
startedAt: row?.started_at || null,
|
|
51
|
+
updatedAt: row?.updated_at || null,
|
|
52
|
+
completedAt: row?.completed_at || null,
|
|
53
|
+
model: row?.model ? String(row.model) : null,
|
|
54
|
+
modelInherited: row?.model_inherited === null || row?.model_inherited === undefined ? null : Number(row.model_inherited) === 1,
|
|
55
|
+
subagentType: row?.subagent_type ? String(row.subagent_type) : null,
|
|
56
|
+
taskId: row?.task_id ? String(row.task_id) : null,
|
|
57
|
+
totalTokens: finiteOrNull(row?.total_tokens),
|
|
58
|
+
background: Number(row?.background || 0) === 1,
|
|
59
|
+
truncated: Number(row?.truncated || 0) === 1,
|
|
60
|
+
timelineOwner: row?.timeline_owner ? String(row.timeline_owner) : null,
|
|
61
|
+
stoppable: !!stoppable,
|
|
62
|
+
...(legacy ? { legacy: true } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mapTimelineRow(row) {
|
|
67
|
+
return {
|
|
68
|
+
seq: Number(row.seq),
|
|
69
|
+
kind: String(row.kind),
|
|
70
|
+
text: row.text === null || row.text === undefined ? '' : String(row.text),
|
|
71
|
+
toolName: row.tool_name || null,
|
|
72
|
+
toolUseId: row.tool_use_id || null,
|
|
73
|
+
metadata: parseJson(row.metadata_json),
|
|
74
|
+
done: Number(row.done) === 1,
|
|
75
|
+
createdAt: row.created_at || null,
|
|
76
|
+
updatedAt: row.updated_at || null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One entry as a writer sent it, made safe to store: a known kind, a key, the
|
|
82
|
+
* text cut to its kind's limit (the original length goes into the metadata),
|
|
83
|
+
* small metadata only. Null for anything that cannot be stored.
|
|
84
|
+
*/
|
|
85
|
+
export function sanitizeTimelineEntry(raw) {
|
|
86
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
87
|
+
const kind = String(raw.kind || '').trim();
|
|
88
|
+
if (!isSubagentTimelineKind(kind)) return null;
|
|
89
|
+
const key = String(raw.key ?? '').trim().slice(0, ENTRY_KEY_MAX_CHARS);
|
|
90
|
+
if (!key) return null;
|
|
91
|
+
let metadata = raw.metadata && typeof raw.metadata === 'object' && !Array.isArray(raw.metadata) ? { ...raw.metadata } : null;
|
|
92
|
+
const priorCut = finiteOrNull(metadata?.cut);
|
|
93
|
+
const { text, cut } = cutSubagentTimelineText(kind, raw.text, { cut: priorCut });
|
|
94
|
+
if (cut !== null) metadata = { ...(metadata || {}), cut };
|
|
95
|
+
let metadataJson = metadata ? JSON.stringify(metadata) : null;
|
|
96
|
+
if (metadataJson && metadataJson.length > METADATA_MAX_CHARS) metadataJson = cut !== null ? JSON.stringify({ cut }) : null;
|
|
97
|
+
return {
|
|
98
|
+
key,
|
|
99
|
+
kind,
|
|
100
|
+
text,
|
|
101
|
+
toolName: cleanText(raw.toolName, SUBAGENT_TIMELINE_TOOL_NAME_MAX_CHARS),
|
|
102
|
+
toolUseId: sanitizeSubagentRunId(raw.toolUseId) || null,
|
|
103
|
+
metadataJson,
|
|
104
|
+
done: raw.done === false ? 0 : 1,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A header patch as a writer sent it. Unknown or empty fields stay null. */
|
|
109
|
+
export function sanitizeRunHeaderPatch(raw) {
|
|
110
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
111
|
+
const totalTokens = finiteOrNull(raw.totalTokens);
|
|
112
|
+
const patch = {
|
|
113
|
+
model: cleanText(raw.model, 120),
|
|
114
|
+
modelInherited: typeof raw.modelInherited === 'boolean' ? (raw.modelInherited ? 1 : 0) : null,
|
|
115
|
+
subagentType: cleanText(raw.subagentType, 120),
|
|
116
|
+
taskId: cleanText(raw.taskId, 200),
|
|
117
|
+
totalTokens: totalTokens === null ? null : Math.max(0, Math.round(totalTokens)),
|
|
118
|
+
background: raw.background === true ? 1 : null,
|
|
119
|
+
truncated: raw.truncated === true ? 1 : null,
|
|
120
|
+
timelineOwner: cleanText(raw.timelineOwner, 40),
|
|
121
|
+
status: raw.status ? normalizeSubagentRunStatus(raw.status) : null,
|
|
122
|
+
};
|
|
123
|
+
return Object.values(patch).some((value) => value !== null) ? patch : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createSubagentTimelineService({ db, stmts, now = () => new Date().toISOString() } = {}) {
|
|
127
|
+
const supported = () => !!stmts?.subagentTimelineSupported && typeof stmts?.insertTimelineEntry?.run === 'function';
|
|
128
|
+
const headerSupported = () => typeof stmts?.updateSubagentRunHeader?.run === 'function';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Write a header patch onto a run. A status names the run's end (or its
|
|
132
|
+
* return to running); the end time is stamped once. Returns the updated row,
|
|
133
|
+
* or null when nothing was written.
|
|
134
|
+
*/
|
|
135
|
+
function applyHeader(runId, patch) {
|
|
136
|
+
if (!patch) return null;
|
|
137
|
+
const at = now();
|
|
138
|
+
const { status, ...fields } = patch;
|
|
139
|
+
const hasFields = Object.values(fields).some((value) => value !== null);
|
|
140
|
+
if (hasFields && headerSupported()) {
|
|
141
|
+
stmts.updateSubagentRunHeader.run({ id: runId, updatedAt: at, ...fields });
|
|
142
|
+
}
|
|
143
|
+
if (status) {
|
|
144
|
+
const terminal = status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
145
|
+
stmts.updateSubagentRunStatus?.run?.(status, at, terminal ? at : null, runId);
|
|
146
|
+
}
|
|
147
|
+
return stmts.getSubagentRun?.get?.(runId) || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Store a batch of entries for a run. A known key updates its entry in
|
|
152
|
+
* place; a new key takes the next seq. Past the per-run limit one note says
|
|
153
|
+
* the record was cut, the run is marked truncated, and later new entries are
|
|
154
|
+
* dropped (updates to entries already kept still land). Returns the entries
|
|
155
|
+
* as stored, for the live event.
|
|
156
|
+
*/
|
|
157
|
+
function applyEntries(run, rawEntries) {
|
|
158
|
+
if (!supported() || !run) return { entries: [], truncated: false };
|
|
159
|
+
const runId = String(run.id);
|
|
160
|
+
const conversationId = String(run.conversation_id);
|
|
161
|
+
const queueMessageId = run.queue_message_id ? String(run.queue_message_id) : null;
|
|
162
|
+
const entries = (Array.isArray(rawEntries) ? rawEntries : []).map(sanitizeTimelineEntry).filter(Boolean);
|
|
163
|
+
const stored = [];
|
|
164
|
+
let truncated = false;
|
|
165
|
+
const tx = db.transaction(() => {
|
|
166
|
+
let maxSeq = Number(stmts.getMaxTimelineSeq.get(runId)?.max_seq || 0);
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
const at = now();
|
|
169
|
+
const existing = stmts.getTimelineEntryByKey.get(runId, entry.key);
|
|
170
|
+
if (existing) {
|
|
171
|
+
stmts.updateTimelineEntryByKey.run({
|
|
172
|
+
runId,
|
|
173
|
+
entryKey: entry.key,
|
|
174
|
+
text: entry.text,
|
|
175
|
+
toolName: entry.toolName,
|
|
176
|
+
toolUseId: entry.toolUseId,
|
|
177
|
+
metadataJson: entry.metadataJson,
|
|
178
|
+
done: entry.done,
|
|
179
|
+
updatedAt: at,
|
|
180
|
+
});
|
|
181
|
+
stored.push(Number(existing.seq));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (maxSeq >= SUBAGENT_TIMELINE_MAX_ENTRIES) {
|
|
185
|
+
if (!stmts.getTimelineEntryByKey.get(runId, TRUNCATED_NOTE_KEY)) {
|
|
186
|
+
maxSeq += 1;
|
|
187
|
+
stmts.insertTimelineEntry.run({
|
|
188
|
+
runId,
|
|
189
|
+
conversationId,
|
|
190
|
+
queueMessageId,
|
|
191
|
+
entryKey: TRUNCATED_NOTE_KEY,
|
|
192
|
+
seq: maxSeq,
|
|
193
|
+
kind: 'note',
|
|
194
|
+
text: `The record stops here: a sub-agent keeps at most ${SUBAGENT_TIMELINE_MAX_ENTRIES} entries.`,
|
|
195
|
+
toolName: null,
|
|
196
|
+
toolUseId: null,
|
|
197
|
+
metadataJson: JSON.stringify({ truncated: true }),
|
|
198
|
+
done: 1,
|
|
199
|
+
createdAt: at,
|
|
200
|
+
updatedAt: at,
|
|
201
|
+
});
|
|
202
|
+
stmts.setSubagentRunTruncated?.run?.(at, runId);
|
|
203
|
+
stored.push(maxSeq);
|
|
204
|
+
}
|
|
205
|
+
truncated = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
maxSeq += 1;
|
|
209
|
+
stmts.insertTimelineEntry.run({
|
|
210
|
+
runId,
|
|
211
|
+
conversationId,
|
|
212
|
+
queueMessageId,
|
|
213
|
+
entryKey: entry.key,
|
|
214
|
+
seq: maxSeq,
|
|
215
|
+
kind: entry.kind,
|
|
216
|
+
text: entry.text,
|
|
217
|
+
toolName: entry.toolName,
|
|
218
|
+
toolUseId: entry.toolUseId,
|
|
219
|
+
metadataJson: entry.metadataJson,
|
|
220
|
+
done: entry.done,
|
|
221
|
+
createdAt: at,
|
|
222
|
+
updatedAt: at,
|
|
223
|
+
});
|
|
224
|
+
stored.push(maxSeq);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
tx.immediate ? tx.immediate() : tx();
|
|
228
|
+
if (!stored.length) return { entries: [], truncated };
|
|
229
|
+
const seqs = new Set(stored);
|
|
230
|
+
const minSeq = Math.min(...stored);
|
|
231
|
+
const rows = stmts.listTimelineEntries.all({ runId, afterSeq: minSeq - 1, limit: TIMELINE_READ_MAX_LIMIT })
|
|
232
|
+
.filter((row) => seqs.has(Number(row.seq)));
|
|
233
|
+
return { entries: rows.map(mapTimelineRow), truncated };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A frame Copilot or Cursor published on a sub-agent lane, copied into the
|
|
238
|
+
* run's timeline (the Claude worker writes its own). The key keeps one entry
|
|
239
|
+
* per thought and one reply text per run, updated in place.
|
|
240
|
+
*/
|
|
241
|
+
function mirrorFrame({ runId, kind, key, text, done = true, toolName = null, metadata = null }) {
|
|
242
|
+
if (!supported()) return null;
|
|
243
|
+
const run = stmts.getSubagentRun?.get?.(runId) || null;
|
|
244
|
+
if (!run || String(run.timeline_owner || '') === 'worker') return null;
|
|
245
|
+
const result = applyEntries(run, [{ key, kind, text, done, toolName, metadata }]);
|
|
246
|
+
if (!result.entries.length) return null;
|
|
247
|
+
return { run, entries: result.entries, truncated: result.truncated };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function legacyEntries(runId) {
|
|
251
|
+
const rows = [];
|
|
252
|
+
for (const row of stmts.listLegacyThoughtsBySubagentRun?.all?.(runId, LEGACY_ROW_LIMIT) || []) {
|
|
253
|
+
rows.push({ kind: 'thought', text: String(row.text || ''), done: Number(row.done) === 1, createdAt: row.created_at, order: 1 });
|
|
254
|
+
}
|
|
255
|
+
for (const row of stmts.listLegacyActivityBySubagentRun?.all?.(runId, LEGACY_ROW_LIMIT) || []) {
|
|
256
|
+
const text = String(row.text || '');
|
|
257
|
+
const toolMatch = /^Tool \(([^)]+)\)/.exec(text);
|
|
258
|
+
rows.push({
|
|
259
|
+
kind: toolMatch ? 'tool_call' : 'note',
|
|
260
|
+
text,
|
|
261
|
+
toolName: toolMatch ? toolMatch[1] : null,
|
|
262
|
+
done: true,
|
|
263
|
+
createdAt: row.created_at,
|
|
264
|
+
order: 2,
|
|
265
|
+
metadata: { legacy: true },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
for (const row of stmts.listLegacyStreamBySubagentRun?.all?.(runId, LEGACY_ROW_LIMIT) || []) {
|
|
269
|
+
const text = String(row.text || '');
|
|
270
|
+
if (!text.trim()) continue;
|
|
271
|
+
rows.push({ kind: 'text', text, done: Number(row.done) === 1, createdAt: row.created_at, order: 3 });
|
|
272
|
+
}
|
|
273
|
+
rows.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')) || a.order - b.order);
|
|
274
|
+
return rows.map((row, index) => ({
|
|
275
|
+
seq: index + 1,
|
|
276
|
+
kind: row.kind,
|
|
277
|
+
text: row.text,
|
|
278
|
+
toolName: row.toolName || null,
|
|
279
|
+
toolUseId: null,
|
|
280
|
+
metadata: row.metadata || null,
|
|
281
|
+
done: row.done,
|
|
282
|
+
createdAt: row.createdAt || null,
|
|
283
|
+
updatedAt: row.createdAt || null,
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A run's record for the viewer: entries after `afterSeq` (plus open ones),
|
|
289
|
+
* its child runs, and whether this page reached the end. A run with no
|
|
290
|
+
* timeline rows that no worker owns is served from what it recorded before
|
|
291
|
+
* the timeline existed.
|
|
292
|
+
*/
|
|
293
|
+
function readTimeline(run, { afterSeq = 0, limit = TIMELINE_READ_DEFAULT_LIMIT, isStoppable = () => false } = {}) {
|
|
294
|
+
const runId = String(run.id);
|
|
295
|
+
const pageLimit = Math.max(1, Math.min(TIMELINE_READ_MAX_LIMIT, Number(limit) || TIMELINE_READ_DEFAULT_LIMIT));
|
|
296
|
+
const after = Math.max(0, Number(afterSeq) || 0);
|
|
297
|
+
const children = (stmts.listChildSubagentRuns?.all?.(runId) || [])
|
|
298
|
+
.map((row) => mapSubagentRunHeader(row, { stoppable: isStoppable(row) }))
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
let entries = [];
|
|
301
|
+
let legacy = false;
|
|
302
|
+
let complete = true;
|
|
303
|
+
if (supported()) {
|
|
304
|
+
const rows = stmts.listTimelineEntries.all({ runId, afterSeq: after, limit: pageLimit + 1 });
|
|
305
|
+
complete = rows.length <= pageLimit;
|
|
306
|
+
entries = rows.slice(0, pageLimit).map(mapTimelineRow);
|
|
307
|
+
const hasAny = after > 0 || entries.length > 0 || Number(stmts.getMaxTimelineSeq.get(runId)?.max_seq || 0) > 0;
|
|
308
|
+
if (!hasAny && !run.timeline_owner) {
|
|
309
|
+
entries = after > 0 ? [] : legacyEntries(runId);
|
|
310
|
+
legacy = entries.length > 0;
|
|
311
|
+
complete = true;
|
|
312
|
+
}
|
|
313
|
+
} else if (after === 0) {
|
|
314
|
+
entries = legacyEntries(runId);
|
|
315
|
+
legacy = entries.length > 0;
|
|
316
|
+
}
|
|
317
|
+
const lastSeq = entries.reduce((max, entry) => Math.max(max, entry.seq), after);
|
|
318
|
+
return {
|
|
319
|
+
run: mapSubagentRunHeader(run, { stoppable: isStoppable(run), legacy }),
|
|
320
|
+
entries,
|
|
321
|
+
children,
|
|
322
|
+
nextAfter: lastSeq,
|
|
323
|
+
complete,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { applyHeader, applyEntries, mirrorFrame, readTimeline, supported };
|
|
328
|
+
}
|
|
@@ -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
|
+
}
|