@oar-sh/oar 0.9.11 → 0.9.13
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 +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
// The task console: a shell or Monitor task's output, opened from its row in
|
|
2
|
+
// the composer's background-task panel. The last lines come from
|
|
3
|
+
// GET /api/conversation/:id/background-task/:taskId/output; new bytes arrive
|
|
4
|
+
// as `task_output_chunk`, which the relay sends only to pages watching the
|
|
5
|
+
// task (task-console-watch.mjs). A chunk that does not continue where the
|
|
6
|
+
// view stands (a dropped chunk, a rewritten file) reads the tail again.
|
|
7
|
+
// "Load earlier" pages back up to 5 MB (from the top of the output, or the
|
|
8
|
+
// header while following the end). A provider that can only give a snapshot
|
|
9
|
+
// is read again every 3 s instead. A command piped into tail gets a
|
|
10
|
+
// marker where its output stops until it ends. On wider screens the console
|
|
11
|
+
// ends above the task's row in the composer panel, so the row's chip stays
|
|
12
|
+
// in view; full-screen on phones. Escape, ×, a click outside and the system
|
|
13
|
+
// back close it.
|
|
14
|
+
|
|
15
|
+
import { apiFetchOrThrow } from './api-client.js';
|
|
16
|
+
import { currentConvId } from './store.js';
|
|
17
|
+
import { createViewerHistory } from './viewer-history.mjs';
|
|
18
|
+
import { getSocket } from './socket-handlers.js';
|
|
19
|
+
import { createTaskConsoleWatch } from './task-console-watch.mjs';
|
|
20
|
+
import { createLineFolder } from './task-output-text.mjs';
|
|
21
|
+
import {
|
|
22
|
+
findBackgroundTask,
|
|
23
|
+
getBackgroundTaskHealth,
|
|
24
|
+
getTaskStallMinutes,
|
|
25
|
+
isBackgroundTaskStopping,
|
|
26
|
+
onBackgroundTasksPanelRendered,
|
|
27
|
+
stopBackgroundTask,
|
|
28
|
+
subscribeBackgroundTasks,
|
|
29
|
+
} from './background-tasks-view.mjs';
|
|
30
|
+
import {
|
|
31
|
+
TASK_OUTPUT_NONE_MESSAGES,
|
|
32
|
+
buildLineNode,
|
|
33
|
+
chunkAction,
|
|
34
|
+
chunkEndOffset,
|
|
35
|
+
consoleMetaParts,
|
|
36
|
+
findFirstFailureIndex,
|
|
37
|
+
loadEarlierState,
|
|
38
|
+
pipeHoldNotice,
|
|
39
|
+
runnerChip,
|
|
40
|
+
showPipeHoldMarker,
|
|
41
|
+
stallHint,
|
|
42
|
+
taskPipeHold,
|
|
43
|
+
} from './task-console-render.mjs';
|
|
44
|
+
|
|
45
|
+
const TAIL_LINES = 2000;
|
|
46
|
+
const SNAPSHOT_POLL_MS = 3000;
|
|
47
|
+
// Wider than a phone, the console stops above the task's row when the row's
|
|
48
|
+
// top is at least this far down the window (the console keeps about 320 px
|
|
49
|
+
// of it); otherwise it covers the page as before.
|
|
50
|
+
const PHONE_MAX_WIDTH = 680;
|
|
51
|
+
const ABOVE_ROW_MIN_TOP = 360;
|
|
52
|
+
|
|
53
|
+
const tc = {
|
|
54
|
+
open: false,
|
|
55
|
+
conversationId: '',
|
|
56
|
+
taskId: '',
|
|
57
|
+
kind: '',
|
|
58
|
+
message: '',
|
|
59
|
+
error: '',
|
|
60
|
+
loading: false,
|
|
61
|
+
loadingEarlier: false,
|
|
62
|
+
fetchToken: 0,
|
|
63
|
+
// Lines paged in by "Load earlier" (completed lines, oldest first) and
|
|
64
|
+
// their nodes; the tail and everything live goes through the folder.
|
|
65
|
+
earlierLines: [],
|
|
66
|
+
earlierNodes: [],
|
|
67
|
+
folder: null,
|
|
68
|
+
folded: { lines: [], partial: '' },
|
|
69
|
+
liveNodes: [],
|
|
70
|
+
partialNode: null,
|
|
71
|
+
startOffset: 0,
|
|
72
|
+
nextOffset: 0,
|
|
73
|
+
// Where the output stood at the first read: past it, output came in.
|
|
74
|
+
openedAt: null,
|
|
75
|
+
size: 0,
|
|
76
|
+
hasMoreBefore: false,
|
|
77
|
+
loadedBytes: 0,
|
|
78
|
+
ended: false,
|
|
79
|
+
status: '',
|
|
80
|
+
exitCode: null,
|
|
81
|
+
health: null,
|
|
82
|
+
following: true,
|
|
83
|
+
pendingChunks: null,
|
|
84
|
+
refetching: false,
|
|
85
|
+
snapshotTimer: null,
|
|
86
|
+
tickTimer: null,
|
|
87
|
+
note: '',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
let consoleHistory = null;
|
|
91
|
+
let initialized = false;
|
|
92
|
+
const consoleWatch = createTaskConsoleWatch({ getSocket });
|
|
93
|
+
|
|
94
|
+
function elements() {
|
|
95
|
+
return {
|
|
96
|
+
modal: document.getElementById('task-console-modal'),
|
|
97
|
+
title: document.getElementById('task-console-title'),
|
|
98
|
+
meta: document.getElementById('task-console-meta'),
|
|
99
|
+
stall: document.getElementById('task-console-stall'),
|
|
100
|
+
chip: document.getElementById('task-console-chip'),
|
|
101
|
+
failure: document.getElementById('task-console-failure'),
|
|
102
|
+
stop: document.getElementById('task-console-stop'),
|
|
103
|
+
scroll: document.getElementById('task-console-scroll'),
|
|
104
|
+
earlier: document.getElementById('task-console-earlier'),
|
|
105
|
+
earlierTop: document.getElementById('task-console-earlier-top'),
|
|
106
|
+
body: document.getElementById('task-console-body'),
|
|
107
|
+
hold: document.getElementById('task-console-hold'),
|
|
108
|
+
status: document.getElementById('task-console-status'),
|
|
109
|
+
jump: document.getElementById('task-console-jump'),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isAtBottom(scroll) {
|
|
114
|
+
return scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 24;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatDuration(fromMs, toMs) {
|
|
118
|
+
if (!Number.isFinite(fromMs) || fromMs <= 0 || !Number.isFinite(toMs) || toMs < fromMs) return '';
|
|
119
|
+
const seconds = Math.floor((toMs - fromMs) / 1000);
|
|
120
|
+
if (seconds < 60) return `${seconds}s`;
|
|
121
|
+
const minutes = Math.floor(seconds / 60);
|
|
122
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
123
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toMillis(value) {
|
|
127
|
+
const number = Number(value);
|
|
128
|
+
if (Number.isFinite(number)) return number;
|
|
129
|
+
return Date.parse(String(value || ''));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function currentTask() {
|
|
133
|
+
return findBackgroundTask(tc.conversationId, tc.taskId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Header ──────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function renderHeader() {
|
|
139
|
+
const { title, meta, stall, chip, failure, stop } = elements();
|
|
140
|
+
const found = currentTask();
|
|
141
|
+
// The panel's row, else what the output answer said about the task.
|
|
142
|
+
const task = found?.task || tc.answerTask || { taskId: tc.taskId };
|
|
143
|
+
const ended = tc.ended || !!found?.ended;
|
|
144
|
+
const status = found?.ended ? (found.task.status ?? tc.status) : tc.status;
|
|
145
|
+
const exitCode = found?.ended ? (found.task.exitCode ?? tc.exitCode) : tc.exitCode;
|
|
146
|
+
if (title) title.textContent = String(task.description || tc.taskId || 'Task');
|
|
147
|
+
const started = toMillis(task.startedAt);
|
|
148
|
+
const endedAt = ended ? toMillis(task.endedAt) : Date.now();
|
|
149
|
+
if (meta) {
|
|
150
|
+
meta.textContent = consoleMetaParts({
|
|
151
|
+
task,
|
|
152
|
+
elapsed: formatDuration(started, Number.isFinite(endedAt) ? endedAt : NaN),
|
|
153
|
+
ended,
|
|
154
|
+
status,
|
|
155
|
+
exitCode,
|
|
156
|
+
kind: tc.kind,
|
|
157
|
+
}).join(' · ');
|
|
158
|
+
}
|
|
159
|
+
const health = getBackgroundTaskHealth(tc.conversationId, tc.taskId) || tc.health;
|
|
160
|
+
const holdNotice = pipeHoldNotice(taskPipeHold(task));
|
|
161
|
+
if (stall) {
|
|
162
|
+
const hint = ended ? null : stallHint(health?.lastOutputAt, getTaskStallMinutes(tc.conversationId));
|
|
163
|
+
stall.hidden = !hint;
|
|
164
|
+
stall.textContent = hint ? hint.text : '';
|
|
165
|
+
stall.className = hint?.warn ? 'tc-stall tc-stall-warn' : 'tc-stall';
|
|
166
|
+
stall.title = holdNotice ? holdNotice.title : '';
|
|
167
|
+
}
|
|
168
|
+
const chipInfo = runnerChip(health);
|
|
169
|
+
if (chip) {
|
|
170
|
+
chip.hidden = !chipInfo;
|
|
171
|
+
chip.textContent = chipInfo ? chipInfo.text : '';
|
|
172
|
+
chip.title = chipInfo ? chipInfo.title : '';
|
|
173
|
+
chip.className = chipInfo?.failed ? 'bg-task-chip bg-task-chip-fail' : 'bg-task-chip';
|
|
174
|
+
}
|
|
175
|
+
if (failure) failure.hidden = !chipInfo?.failed;
|
|
176
|
+
if (stop) {
|
|
177
|
+
const stoppable = !ended && !!found && task.stoppable !== false;
|
|
178
|
+
const stopping = isBackgroundTaskStopping(tc.conversationId, tc.taskId);
|
|
179
|
+
stop.hidden = !stoppable;
|
|
180
|
+
stop.disabled = stopping;
|
|
181
|
+
stop.textContent = stopping ? 'Stopping…' : 'Stop';
|
|
182
|
+
}
|
|
183
|
+
renderHold();
|
|
184
|
+
syncLayout();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The "output stops here" marker under the last line, while nothing new came. */
|
|
188
|
+
function renderHold() {
|
|
189
|
+
const { hold } = elements();
|
|
190
|
+
if (!hold) return;
|
|
191
|
+
const found = currentTask();
|
|
192
|
+
const program = taskPipeHold(found?.task || tc.answerTask);
|
|
193
|
+
const notice = pipeHoldNotice(program);
|
|
194
|
+
const show = !!notice && !tc.loading && showPipeHoldMarker({
|
|
195
|
+
program,
|
|
196
|
+
ended: tc.ended || !!found?.ended,
|
|
197
|
+
kind: tc.kind,
|
|
198
|
+
openedAt: tc.openedAt,
|
|
199
|
+
nextOffset: tc.nextOffset,
|
|
200
|
+
});
|
|
201
|
+
hold.hidden = !show;
|
|
202
|
+
if (!show) return;
|
|
203
|
+
hold.querySelector('.tc-hold-text').textContent = notice.text;
|
|
204
|
+
hold.querySelector('.tc-hold-hint').textContent = notice.hint;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Wider than a phone, the modal ends above the task's row in the composer
|
|
209
|
+
* panel, so the row (its ✓/✘ chip, clock and Stop) stays in view and live.
|
|
210
|
+
* Without the row on screen, or with too little room above it, the console
|
|
211
|
+
* covers the page as before.
|
|
212
|
+
*/
|
|
213
|
+
function syncLayout() {
|
|
214
|
+
const { modal } = elements();
|
|
215
|
+
if (!modal) return;
|
|
216
|
+
let bottom = 0;
|
|
217
|
+
if (tc.open && window.innerWidth > PHONE_MAX_WIDTH) {
|
|
218
|
+
const panel = document.getElementById('background-tasks-panel');
|
|
219
|
+
const row = panel && !panel.hidden && panel.hasAttribute('open')
|
|
220
|
+
? panel.querySelector(`.bg-task-row[data-task-id="${CSS.escape(tc.taskId)}"]`)
|
|
221
|
+
: null;
|
|
222
|
+
const rect = row?.getBoundingClientRect?.();
|
|
223
|
+
if (rect && rect.height > 0 && rect.top >= ABOVE_ROW_MIN_TOP && rect.top < window.innerHeight) {
|
|
224
|
+
bottom = Math.ceil(window.innerHeight - rect.top + 6);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
modal.classList.toggle('tc-above-panel', bottom > 0);
|
|
228
|
+
modal.style.bottom = bottom > 0 ? `${bottom}px` : '';
|
|
229
|
+
// Above the panel the page below stays usable: the console is no modal then.
|
|
230
|
+
const dialog = modal.querySelector('[role="dialog"]');
|
|
231
|
+
if (bottom > 0) dialog?.removeAttribute('aria-modal');
|
|
232
|
+
else dialog?.setAttribute('aria-modal', 'true');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderStatus() {
|
|
236
|
+
const { status } = elements();
|
|
237
|
+
if (!status) return;
|
|
238
|
+
const hasLines = tc.earlierLines.length || tc.folded.lines.length || tc.folded.partial;
|
|
239
|
+
let text = '';
|
|
240
|
+
if (tc.error) text = tc.error;
|
|
241
|
+
else if (tc.kind === 'none') text = tc.message || TASK_OUTPUT_NONE_MESSAGES.provider;
|
|
242
|
+
else if (tc.loading && !hasLines) text = 'Loading…';
|
|
243
|
+
else if (!hasLines && !tc.note) text = tc.ended ? 'This task wrote no output.' : 'No output yet. Lines appear as the task writes them.';
|
|
244
|
+
if (tc.note) text = text ? `${text} ${tc.note}` : tc.note;
|
|
245
|
+
status.textContent = text;
|
|
246
|
+
status.hidden = !text;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Both "Load earlier" buttons: the one above the first line, and the
|
|
250
|
+
// header's, which is in reach while following the end.
|
|
251
|
+
function renderEarlierButton() {
|
|
252
|
+
const { earlier, earlierTop } = elements();
|
|
253
|
+
const state = loadEarlierState({ hasMoreBefore: tc.hasMoreBefore, loadedBytes: tc.loadedBytes, loading: tc.loadingEarlier });
|
|
254
|
+
for (const button of [earlier, earlierTop]) {
|
|
255
|
+
if (!button) continue;
|
|
256
|
+
button.hidden = state.hidden;
|
|
257
|
+
button.disabled = state.disabled;
|
|
258
|
+
button.textContent = state.text;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Lines ───────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function clearLines() {
|
|
265
|
+
const { body, jump } = elements();
|
|
266
|
+
if (body) body.textContent = '';
|
|
267
|
+
tc.earlierLines = [];
|
|
268
|
+
tc.earlierNodes = [];
|
|
269
|
+
tc.liveNodes = [];
|
|
270
|
+
tc.partialNode = null;
|
|
271
|
+
tc.folder = createLineFolder();
|
|
272
|
+
tc.folded = { lines: [], partial: '' };
|
|
273
|
+
if (jump) jump.hidden = true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function afterContentChange(appended) {
|
|
277
|
+
const { scroll, jump } = elements();
|
|
278
|
+
renderStatus();
|
|
279
|
+
renderHold();
|
|
280
|
+
if (!scroll) return;
|
|
281
|
+
if (tc.following) {
|
|
282
|
+
scroll.scrollTop = scroll.scrollHeight;
|
|
283
|
+
if (jump) jump.hidden = true;
|
|
284
|
+
} else if (appended && jump) {
|
|
285
|
+
jump.hidden = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Draw what the folder holds: the last known line again (its ×N can grow), then the new ones. */
|
|
290
|
+
function renderLive() {
|
|
291
|
+
const { body } = elements();
|
|
292
|
+
if (!body) return;
|
|
293
|
+
const { lines, partial } = tc.folded;
|
|
294
|
+
const before = tc.liveNodes.length;
|
|
295
|
+
let appended = false;
|
|
296
|
+
const from = Math.max(0, before - 1);
|
|
297
|
+
for (let index = from; index < lines.length; index += 1) {
|
|
298
|
+
const node = buildLineNode(document, lines[index]);
|
|
299
|
+
const existing = tc.liveNodes[index];
|
|
300
|
+
if (existing) {
|
|
301
|
+
existing.replaceWith(node);
|
|
302
|
+
} else {
|
|
303
|
+
body.insertBefore(node, tc.partialNode && tc.partialNode.parentNode === body ? tc.partialNode : null);
|
|
304
|
+
appended = true;
|
|
305
|
+
}
|
|
306
|
+
tc.liveNodes[index] = node;
|
|
307
|
+
}
|
|
308
|
+
if (partial) {
|
|
309
|
+
const node = buildLineNode(document, { text: partial, count: 1 }, { partial: true });
|
|
310
|
+
if (tc.partialNode?.parentNode === body) tc.partialNode.replaceWith(node);
|
|
311
|
+
else body.appendChild(node);
|
|
312
|
+
tc.partialNode = node;
|
|
313
|
+
appended = true;
|
|
314
|
+
} else if (tc.partialNode) {
|
|
315
|
+
tc.partialNode.remove();
|
|
316
|
+
tc.partialNode = null;
|
|
317
|
+
}
|
|
318
|
+
afterContentChange(appended);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function feed(text) {
|
|
322
|
+
tc.folded = tc.folder.feed(String(text ?? ''));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function prependEarlier(lines) {
|
|
326
|
+
const { body, scroll } = elements();
|
|
327
|
+
if (!body || !lines.length) return;
|
|
328
|
+
const heightBefore = scroll ? scroll.scrollHeight : 0;
|
|
329
|
+
const fragment = document.createDocumentFragment();
|
|
330
|
+
const nodes = lines.map((line) => buildLineNode(document, line));
|
|
331
|
+
for (const node of nodes) fragment.appendChild(node);
|
|
332
|
+
body.insertBefore(fragment, body.firstChild);
|
|
333
|
+
tc.earlierLines = [...lines, ...tc.earlierLines];
|
|
334
|
+
tc.earlierNodes = [...nodes, ...tc.earlierNodes];
|
|
335
|
+
// The reader stays on the line they were reading.
|
|
336
|
+
if (scroll) scroll.scrollTop += scroll.scrollHeight - heightBefore;
|
|
337
|
+
renderStatus();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function allLineNodes() {
|
|
341
|
+
return [...tc.earlierNodes, ...tc.liveNodes];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ─── Loading ─────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
function outputUrl(params = '') {
|
|
347
|
+
return `/api/conversation/${encodeURIComponent(tc.conversationId)}/background-task/${encodeURIComponent(tc.taskId)}/output${params}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function errorText(error) {
|
|
351
|
+
if (error?.status === 404) return 'This task\'s output is no longer available.';
|
|
352
|
+
if (error?.status === 409) return 'The session\'s worker is not connected, so the output cannot be read now.';
|
|
353
|
+
return error?.message || 'The output could not be loaded.';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function applyAnswerMeta(data) {
|
|
357
|
+
tc.kind = String(data?.kind || tc.kind || 'file');
|
|
358
|
+
if (data?.health && typeof data.health === 'object') tc.health = data.health;
|
|
359
|
+
if (data?.ended === true) tc.ended = true;
|
|
360
|
+
if (data?.status !== undefined && data?.status !== null) tc.status = String(data.status);
|
|
361
|
+
if (data?.exitCode !== undefined) tc.exitCode = data.exitCode;
|
|
362
|
+
if (data?.task && typeof data.task === 'object') tc.answerTask = data.task;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Read the tail and draw it from scratch (first open, a gap, a snapshot
|
|
367
|
+
* tick). With `before`, the tail ends exactly where the task's room stands,
|
|
368
|
+
* so the room's next chunk continues it byte for byte.
|
|
369
|
+
*/
|
|
370
|
+
async function fetchTail({ before = null } = {}) {
|
|
371
|
+
if (!tc.open) return;
|
|
372
|
+
const token = ++tc.fetchToken;
|
|
373
|
+
const key = `${tc.conversationId}:${tc.taskId}`;
|
|
374
|
+
tc.loading = true;
|
|
375
|
+
if (!tc.pendingChunks) tc.pendingChunks = [];
|
|
376
|
+
renderStatus();
|
|
377
|
+
let data;
|
|
378
|
+
try {
|
|
379
|
+
const params = Number.isInteger(before) && before >= 0
|
|
380
|
+
? `?before=${before}&lines=${TAIL_LINES}`
|
|
381
|
+
: `?lines=${TAIL_LINES}`;
|
|
382
|
+
data = await apiFetchOrThrow(outputUrl(params));
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (token !== tc.fetchToken || !tc.open) return;
|
|
385
|
+
tc.error = errorText(error);
|
|
386
|
+
tc.loading = false;
|
|
387
|
+
tc.pendingChunks = null;
|
|
388
|
+
renderStatus();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (token !== tc.fetchToken || !tc.open || key !== `${tc.conversationId}:${tc.taskId}`) return;
|
|
392
|
+
tc.loading = false;
|
|
393
|
+
tc.error = '';
|
|
394
|
+
const pending = tc.pendingChunks || [];
|
|
395
|
+
tc.pendingChunks = null;
|
|
396
|
+
if (!data) {
|
|
397
|
+
renderStatus();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
applyAnswerMeta(data);
|
|
401
|
+
clearLines();
|
|
402
|
+
if (tc.kind === 'none') {
|
|
403
|
+
tc.message = String(data.message || '');
|
|
404
|
+
tc.hasMoreBefore = false;
|
|
405
|
+
syncSnapshotPoll();
|
|
406
|
+
renderEarlierButton();
|
|
407
|
+
renderHeader();
|
|
408
|
+
renderStatus();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
tc.note = data.unavailable
|
|
412
|
+
? 'The output file can no longer be read.'
|
|
413
|
+
: data.pending ? 'Waiting for the worker to send the output…' : '';
|
|
414
|
+
tc.startOffset = Number(data.startOffset) || 0;
|
|
415
|
+
tc.nextOffset = Number.isFinite(Number(data.endOffset)) ? Number(data.endOffset) : Number(data.size) || 0;
|
|
416
|
+
tc.size = Number(data.size) || tc.nextOffset;
|
|
417
|
+
tc.hasMoreBefore = data.hasMoreBefore === true;
|
|
418
|
+
if (tc.openedAt === null) tc.openedAt = tc.nextOffset;
|
|
419
|
+
if (typeof data.text === 'string') {
|
|
420
|
+
feed(data.text);
|
|
421
|
+
tc.loadedBytes = Math.max(0, tc.nextOffset - tc.startOffset) || data.text.length;
|
|
422
|
+
} else {
|
|
423
|
+
// No raw slice (an older relay or a snapshot answer): the line model.
|
|
424
|
+
const lines = Array.isArray(data.lines) ? data.lines : [];
|
|
425
|
+
tc.folded = { lines: lines.map((line) => ({ ...line })), partial: '' };
|
|
426
|
+
tc.loadedBytes = Math.max(0, tc.nextOffset - tc.startOffset);
|
|
427
|
+
}
|
|
428
|
+
renderEarlierButton();
|
|
429
|
+
renderHeader();
|
|
430
|
+
renderLive();
|
|
431
|
+
syncSnapshotPoll();
|
|
432
|
+
// Chunks that came while the tail was read: those past it still count.
|
|
433
|
+
for (const chunk of pending) applyChunk(chunk);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Pages back. `reveal` (the header's button): the view goes to the lines
|
|
438
|
+
* just loaded, at the top, instead of staying where the reader was.
|
|
439
|
+
*/
|
|
440
|
+
async function loadEarlier({ reveal = false } = {}) {
|
|
441
|
+
if (!tc.open || tc.loadingEarlier || !tc.hasMoreBefore) return;
|
|
442
|
+
if (loadEarlierState({ hasMoreBefore: true, loadedBytes: tc.loadedBytes }).disabled) return;
|
|
443
|
+
const token = tc.fetchToken;
|
|
444
|
+
tc.loadingEarlier = true;
|
|
445
|
+
renderEarlierButton();
|
|
446
|
+
try {
|
|
447
|
+
const data = await apiFetchOrThrow(outputUrl(`?before=${encodeURIComponent(tc.startOffset)}&lines=${TAIL_LINES}`));
|
|
448
|
+
if (token !== tc.fetchToken || !tc.open || !data) return;
|
|
449
|
+
const lines = Array.isArray(data.lines) ? data.lines : [];
|
|
450
|
+
const start = Number(data.startOffset);
|
|
451
|
+
tc.loadedBytes += Number.isFinite(start) ? Math.max(0, tc.startOffset - start) : String(data.text || '').length;
|
|
452
|
+
if (Number.isFinite(start)) tc.startOffset = start;
|
|
453
|
+
tc.hasMoreBefore = data.hasMoreBefore === true;
|
|
454
|
+
prependEarlier(lines);
|
|
455
|
+
const { scroll } = elements();
|
|
456
|
+
if (reveal && scroll && lines.length) {
|
|
457
|
+
tc.following = false;
|
|
458
|
+
scroll.scrollTop = 0;
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
if (token === tc.fetchToken) {
|
|
462
|
+
tc.note = errorText(error);
|
|
463
|
+
renderStatus();
|
|
464
|
+
}
|
|
465
|
+
} finally {
|
|
466
|
+
tc.loadingEarlier = false;
|
|
467
|
+
renderEarlierButton();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function syncSnapshotPoll() {
|
|
472
|
+
const want = tc.open && tc.kind === 'snapshot' && !tc.ended;
|
|
473
|
+
if (want && !tc.snapshotTimer) {
|
|
474
|
+
tc.snapshotTimer = setInterval(() => {
|
|
475
|
+
if (!tc.loading && document.visibilityState !== 'hidden') void fetchTail();
|
|
476
|
+
}, SNAPSHOT_POLL_MS);
|
|
477
|
+
}
|
|
478
|
+
if (!want && tc.snapshotTimer) {
|
|
479
|
+
clearInterval(tc.snapshotTimer);
|
|
480
|
+
tc.snapshotTimer = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Join the task's room, then read the tail up to the offset the room stands
|
|
486
|
+
* at. Chunks that arrive meanwhile wait and are sorted out after the read
|
|
487
|
+
* (the ones already covered by it are dropped).
|
|
488
|
+
*/
|
|
489
|
+
async function syncWithRoom() {
|
|
490
|
+
if (!tc.open) return;
|
|
491
|
+
const { conversationId, taskId } = tc;
|
|
492
|
+
tc.pendingChunks = tc.pendingChunks || [];
|
|
493
|
+
let before = null;
|
|
494
|
+
if (!tc.ended && tc.kind !== 'snapshot' && tc.kind !== 'none') {
|
|
495
|
+
const answer = await consoleWatch.watch(conversationId, taskId);
|
|
496
|
+
if (!tc.open || tc.taskId !== taskId || tc.conversationId !== conversationId) return;
|
|
497
|
+
if (answer?.ok && Number.isInteger(answer.offset)) before = answer.offset;
|
|
498
|
+
}
|
|
499
|
+
await fetchTail({ before });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function refetchTail() {
|
|
503
|
+
if (tc.refetching) return;
|
|
504
|
+
tc.refetching = true;
|
|
505
|
+
void syncWithRoom().finally(() => { tc.refetching = false; });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function applyChunk(chunk) {
|
|
509
|
+
const action = chunkAction({ nextOffset: tc.nextOffset, size: tc.size }, chunk);
|
|
510
|
+
if (action === 'ignore') return;
|
|
511
|
+
if (action === 'refetch') {
|
|
512
|
+
refetchTail();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const text = String(chunk.text ?? '');
|
|
516
|
+
feed(text);
|
|
517
|
+
const end = chunkEndOffset(chunk);
|
|
518
|
+
tc.nextOffset = Number.isFinite(end) ? end : tc.nextOffset + text.length;
|
|
519
|
+
const size = Number(chunk.size);
|
|
520
|
+
if (Number.isFinite(size)) tc.size = Math.max(size, tc.nextOffset);
|
|
521
|
+
tc.loadedBytes += text.length;
|
|
522
|
+
if (chunk.ended === true && !tc.ended) {
|
|
523
|
+
tc.ended = true;
|
|
524
|
+
renderHeader();
|
|
525
|
+
}
|
|
526
|
+
renderLive();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ─── Open / close ────────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
function resetState() {
|
|
532
|
+
tc.kind = '';
|
|
533
|
+
tc.message = '';
|
|
534
|
+
tc.error = '';
|
|
535
|
+
tc.note = '';
|
|
536
|
+
tc.loading = false;
|
|
537
|
+
tc.loadingEarlier = false;
|
|
538
|
+
tc.startOffset = 0;
|
|
539
|
+
tc.nextOffset = 0;
|
|
540
|
+
tc.openedAt = null;
|
|
541
|
+
tc.size = 0;
|
|
542
|
+
tc.hasMoreBefore = false;
|
|
543
|
+
tc.loadedBytes = 0;
|
|
544
|
+
tc.health = null;
|
|
545
|
+
tc.status = '';
|
|
546
|
+
tc.exitCode = null;
|
|
547
|
+
tc.ended = false;
|
|
548
|
+
tc.answerTask = null;
|
|
549
|
+
tc.following = true;
|
|
550
|
+
tc.pendingChunks = null;
|
|
551
|
+
tc.refetching = false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export async function openTaskConsole({ conversationId = currentConvId, taskId } = {}) {
|
|
555
|
+
const convId = String(conversationId || '').trim();
|
|
556
|
+
const id = String(taskId || '').trim();
|
|
557
|
+
if (!convId || !id) return;
|
|
558
|
+
const { modal } = elements();
|
|
559
|
+
if (!modal) return;
|
|
560
|
+
if (tc.open && (tc.conversationId !== convId || tc.taskId !== id)) consoleWatch.unwatch();
|
|
561
|
+
tc.conversationId = convId;
|
|
562
|
+
tc.taskId = id;
|
|
563
|
+
resetState();
|
|
564
|
+
tc.ended = !!currentTask()?.ended;
|
|
565
|
+
clearLines();
|
|
566
|
+
if (!tc.open) {
|
|
567
|
+
tc.open = true;
|
|
568
|
+
modal.classList.add('visible');
|
|
569
|
+
modal.setAttribute('aria-hidden', 'false');
|
|
570
|
+
document.body.classList.add('task-console-open');
|
|
571
|
+
consoleHistory?.opened();
|
|
572
|
+
}
|
|
573
|
+
renderHeader();
|
|
574
|
+
renderEarlierButton();
|
|
575
|
+
renderStatus();
|
|
576
|
+
syncTicker();
|
|
577
|
+
modal.querySelector('.task-console-close')?.focus?.({ preventScroll: true });
|
|
578
|
+
// Joined before the read, so nothing written in between is lost. An ended
|
|
579
|
+
// task writes nothing more; its room is not needed.
|
|
580
|
+
await syncWithRoom();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function closeTaskConsole({ fromHistory = false } = {}) {
|
|
584
|
+
if (!tc.open) return;
|
|
585
|
+
tc.open = false;
|
|
586
|
+
tc.fetchToken += 1;
|
|
587
|
+
const { modal } = elements();
|
|
588
|
+
modal?.classList.remove('visible');
|
|
589
|
+
modal?.setAttribute('aria-hidden', 'true');
|
|
590
|
+
document.body.classList.remove('task-console-open');
|
|
591
|
+
consoleWatch.unwatch();
|
|
592
|
+
clearLines();
|
|
593
|
+
resetState();
|
|
594
|
+
syncLayout();
|
|
595
|
+
renderHold();
|
|
596
|
+
syncSnapshotPoll();
|
|
597
|
+
syncTicker();
|
|
598
|
+
if (!fromHistory) consoleHistory?.closed();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function isTaskConsoleOpen() {
|
|
602
|
+
return tc.open;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function jumpToFirstFailure() {
|
|
606
|
+
const lines = [...tc.earlierLines, ...tc.folded.lines];
|
|
607
|
+
const index = findFirstFailureIndex(lines);
|
|
608
|
+
const node = index >= 0 ? allLineNodes()[index] : null;
|
|
609
|
+
if (!node) {
|
|
610
|
+
tc.note = tc.hasMoreBefore
|
|
611
|
+
? 'The first failure is in older output: load earlier lines to reach it.'
|
|
612
|
+
: 'No failing test line found in the loaded output.';
|
|
613
|
+
renderStatus();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
tc.note = '';
|
|
617
|
+
renderStatus();
|
|
618
|
+
tc.following = false;
|
|
619
|
+
for (const marked of elements().body?.querySelectorAll('.tc-line-target') || []) marked.classList.remove('tc-line-target');
|
|
620
|
+
node.classList.add('tc-line-target');
|
|
621
|
+
node.scrollIntoView({ block: 'center' });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function stopTask() {
|
|
625
|
+
const found = currentTask();
|
|
626
|
+
if (!found || found.ended) return;
|
|
627
|
+
const stopping = stopBackgroundTask(tc.conversationId, tc.taskId);
|
|
628
|
+
renderHeader();
|
|
629
|
+
await stopping;
|
|
630
|
+
renderHeader();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ─── Live updates ────────────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
/** `task_output_chunk`: new bytes of the task on screen. */
|
|
636
|
+
export function handleTaskOutputChunk(payload) {
|
|
637
|
+
if (!tc.open || tc.kind === 'none' || tc.kind === 'snapshot') return;
|
|
638
|
+
if (String(payload?.taskId || '') !== tc.taskId) return;
|
|
639
|
+
if (String(payload?.conversationId || '') && String(payload.conversationId) !== tc.conversationId) return;
|
|
640
|
+
if (tc.pendingChunks) {
|
|
641
|
+
tc.pendingChunks.push(payload);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
applyChunk(payload);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** The socket came back: watch the task again and read what was missed. */
|
|
648
|
+
export async function handleTaskConsoleReconnect() {
|
|
649
|
+
if (!tc.open) return;
|
|
650
|
+
refetchTail();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// One timer for the header's clock and stall hint, only while open.
|
|
654
|
+
function syncTicker() {
|
|
655
|
+
if (tc.open && !tc.tickTimer) tc.tickTimer = setInterval(renderHeader, 1000);
|
|
656
|
+
if (!tc.open && tc.tickTimer) {
|
|
657
|
+
clearInterval(tc.tickTimer);
|
|
658
|
+
tc.tickTimer = null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─── Wiring ──────────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
export function initTaskConsole() {
|
|
665
|
+
if (initialized) return;
|
|
666
|
+
initialized = true;
|
|
667
|
+
consoleHistory = createViewerHistory({
|
|
668
|
+
history: window.history,
|
|
669
|
+
mark: 'oarTaskConsole',
|
|
670
|
+
onBack: () => closeTaskConsole({ fromHistory: true }),
|
|
671
|
+
});
|
|
672
|
+
window.addEventListener('popstate', () => consoleHistory.onPopState());
|
|
673
|
+
|
|
674
|
+
document.addEventListener('click', (event) => {
|
|
675
|
+
const trigger = event.target?.closest?.('[data-task-console]');
|
|
676
|
+
if (!trigger) return;
|
|
677
|
+
event.preventDefault();
|
|
678
|
+
event.stopPropagation();
|
|
679
|
+
void openTaskConsole({ taskId: trigger.getAttribute('data-task-console') });
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// The row's task moved to the ended set, its health changed, or Stop
|
|
683
|
+
// settled: the header follows.
|
|
684
|
+
subscribeBackgroundTasks((conversationId) => {
|
|
685
|
+
if (!tc.open || String(conversationId || '') !== tc.conversationId) return;
|
|
686
|
+
const found = currentTask();
|
|
687
|
+
if (found?.ended && !tc.ended) {
|
|
688
|
+
tc.ended = true;
|
|
689
|
+
consoleWatch.unwatch();
|
|
690
|
+
syncSnapshotPoll();
|
|
691
|
+
}
|
|
692
|
+
renderHeader();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const { modal, stop, scroll, jump, earlier, earlierTop, failure, chip } = elements();
|
|
696
|
+
modal?.addEventListener('click', (event) => {
|
|
697
|
+
if (event.target === modal) closeTaskConsole();
|
|
698
|
+
});
|
|
699
|
+
modal?.querySelector('.task-console-close')?.addEventListener('click', () => closeTaskConsole());
|
|
700
|
+
stop?.addEventListener('click', () => void stopTask());
|
|
701
|
+
earlier?.addEventListener('click', () => void loadEarlier());
|
|
702
|
+
earlierTop?.addEventListener('click', () => void loadEarlier({ reveal: true }));
|
|
703
|
+
window.addEventListener('resize', () => {
|
|
704
|
+
if (tc.open) syncLayout();
|
|
705
|
+
});
|
|
706
|
+
failure?.addEventListener('click', () => jumpToFirstFailure());
|
|
707
|
+
chip?.addEventListener('click', () => jumpToFirstFailure());
|
|
708
|
+
jump?.addEventListener('click', () => {
|
|
709
|
+
tc.following = true;
|
|
710
|
+
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
|
711
|
+
jump.hidden = true;
|
|
712
|
+
});
|
|
713
|
+
scroll?.addEventListener('scroll', () => {
|
|
714
|
+
tc.following = isAtBottom(scroll);
|
|
715
|
+
if (tc.following && jump) jump.hidden = true;
|
|
716
|
+
}, { passive: true });
|
|
717
|
+
document.addEventListener('keydown', (event) => {
|
|
718
|
+
if (event.key !== 'Escape' || !tc.open) return;
|
|
719
|
+
// Above the panel, Escape in the composer (or any field outside the
|
|
720
|
+
// console) is that field's own.
|
|
721
|
+
if (modal?.classList.contains('tc-above-panel') && !modal.contains(event.target)
|
|
722
|
+
&& event.target?.closest?.('input, textarea, select, [contenteditable="true"], [contenteditable=""]')) return;
|
|
723
|
+
event.preventDefault();
|
|
724
|
+
closeTaskConsole();
|
|
725
|
+
});
|
|
726
|
+
// The panel drew its rows again (a fold, a task set): the row may have moved.
|
|
727
|
+
onBackgroundTasksPanelRendered(() => {
|
|
728
|
+
if (tc.open) syncLayout();
|
|
729
|
+
});
|
|
730
|
+
}
|