@oar-sh/oar 0.9.10 → 0.9.12
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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- 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 +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -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/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- 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 +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -9,15 +9,19 @@
|
|
|
9
9
|
// and drops on a backed-up connection; a missed event shows up as a hole in
|
|
10
10
|
// the seq numbers and is fetched again, and so is the end of a run. Full-screen on
|
|
11
11
|
// phones; Escape, ×, a click outside and the system back close it.
|
|
12
|
+
//
|
|
13
|
+
// A shared view (and the owner's "Preview as shared") opens it read-only: the
|
|
14
|
+
// record comes filtered from the share's sub-agent route, without a socket, so
|
|
15
|
+
// a running agent's record is read again every few seconds; no Stop.
|
|
12
16
|
|
|
13
|
-
import { apiFetch, cancelSubagentRun } from './api-client.js';
|
|
17
|
+
import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
|
|
14
18
|
import { renderMarkdownPreview } from './router.js';
|
|
15
19
|
import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
|
|
16
20
|
import { createViewerHistory } from './viewer-history.mjs';
|
|
17
21
|
import { getSocket } from './socket-handlers.js';
|
|
18
22
|
import { createSubagentViewerWatch } from './subagent-viewer-watch.mjs';
|
|
19
23
|
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
20
|
-
import { getConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
24
|
+
import { getConversationBackgroundTasks, setSubagentStepCounter } from './background-tasks-view.mjs';
|
|
21
25
|
import { formatRunDuration, isRunningSubagentStatus, subagentRowHtml, subagentStatusLabel } from './subagent-row.mjs';
|
|
22
26
|
import {
|
|
23
27
|
createTimelineState,
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
const PAGE_LIMIT = 500;
|
|
31
35
|
const MAX_PAGES = 10;
|
|
32
36
|
const STOP_PENDING_MS = 15_000;
|
|
37
|
+
const SHARED_POLL_MS = 3_000;
|
|
33
38
|
|
|
34
39
|
const viewer = {
|
|
35
40
|
open: false,
|
|
@@ -44,6 +49,7 @@ const viewer = {
|
|
|
44
49
|
stopping: false,
|
|
45
50
|
stopTimer: null,
|
|
46
51
|
tickTimer: null,
|
|
52
|
+
sharedPollTimer: null,
|
|
47
53
|
openFolds: new Set(),
|
|
48
54
|
};
|
|
49
55
|
|
|
@@ -146,7 +152,7 @@ function buildEntryNode(entry) {
|
|
|
146
152
|
renderMarkdown,
|
|
147
153
|
children: viewer.children,
|
|
148
154
|
openDetails: viewer.openFolds.has(entry.seq),
|
|
149
|
-
clickable:
|
|
155
|
+
clickable: true,
|
|
150
156
|
});
|
|
151
157
|
return holder.firstElementChild;
|
|
152
158
|
}
|
|
@@ -215,6 +221,21 @@ function applyRunHeader(header) {
|
|
|
215
221
|
}
|
|
216
222
|
renderHeader();
|
|
217
223
|
syncTicker();
|
|
224
|
+
syncSharedPoll();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Without a socket a running record is read again while it is on screen.
|
|
228
|
+
function syncSharedPoll() {
|
|
229
|
+
const want = IS_SHARED_VIEW && viewer.open && runIsRunning(viewer.run);
|
|
230
|
+
if (want && !viewer.sharedPollTimer) {
|
|
231
|
+
viewer.sharedPollTimer = setInterval(() => {
|
|
232
|
+
if (!viewer.loading && document.visibilityState !== 'hidden') void fetchRecord();
|
|
233
|
+
}, SHARED_POLL_MS);
|
|
234
|
+
}
|
|
235
|
+
if (!want && viewer.sharedPollTimer) {
|
|
236
|
+
clearInterval(viewer.sharedPollTimer);
|
|
237
|
+
viewer.sharedPollTimer = null;
|
|
238
|
+
}
|
|
218
239
|
}
|
|
219
240
|
|
|
220
241
|
/**
|
|
@@ -231,7 +252,9 @@ async function fetchRecord({ fromStart = false } = {}) {
|
|
|
231
252
|
renderStatus();
|
|
232
253
|
try {
|
|
233
254
|
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
234
|
-
const data =
|
|
255
|
+
const data = IS_SHARED_VIEW
|
|
256
|
+
? await loadSharedSubagentRecord(runId, { after, limit: PAGE_LIMIT })
|
|
257
|
+
: await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/subagent/${encodeURIComponent(runId)}?after=${after}&limit=${PAGE_LIMIT}`);
|
|
235
258
|
if (token !== viewer.fetchToken || !viewer.open || viewer.state?.runId !== runId) return;
|
|
236
259
|
if (!data) {
|
|
237
260
|
viewer.error = viewer.state.entries.size ? '' : 'This agent\'s record could not be loaded.';
|
|
@@ -272,7 +295,7 @@ async function loadRun(runId) {
|
|
|
272
295
|
renderHeader();
|
|
273
296
|
renderStatus();
|
|
274
297
|
// Joined before the read, so nothing sent in between is lost.
|
|
275
|
-
await viewerWatch.watch(viewer.conversationId, id);
|
|
298
|
+
if (!IS_SHARED_VIEW) await viewerWatch.watch(viewer.conversationId, id);
|
|
276
299
|
if (!viewer.open || viewer.state?.runId !== id) return;
|
|
277
300
|
await fetchRecord({ fromStart: true });
|
|
278
301
|
// A finished record reads from the top; a running one follows its end.
|
|
@@ -317,6 +340,7 @@ export function closeSubagentViewer({ fromHistory = false } = {}) {
|
|
|
317
340
|
viewer.children = new Map();
|
|
318
341
|
clearTimeout(viewer.stopTimer);
|
|
319
342
|
syncTicker();
|
|
343
|
+
syncSharedPoll();
|
|
320
344
|
if (!fromHistory) viewerHistory?.closed();
|
|
321
345
|
}
|
|
322
346
|
|
|
@@ -338,7 +362,7 @@ async function openParent() {
|
|
|
338
362
|
|
|
339
363
|
async function stopRun() {
|
|
340
364
|
const run = viewer.run;
|
|
341
|
-
if (!run || viewer.stopping || !runIsRunning(run)) return;
|
|
365
|
+
if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
|
|
342
366
|
viewer.stopping = true;
|
|
343
367
|
renderHeader();
|
|
344
368
|
const task = liveTaskFor(run);
|
|
@@ -464,6 +488,12 @@ export function syncSubagentRowClock() {
|
|
|
464
488
|
export function initSubagentViewer() {
|
|
465
489
|
if (initialized) return;
|
|
466
490
|
initialized = true;
|
|
491
|
+
// A sub-agent row without the worker's `steps` counts the run's activity
|
|
492
|
+
// lines, the number its reply bubble shows.
|
|
493
|
+
setSubagentStepCounter((runId) => {
|
|
494
|
+
const activities = getSubagentRun(runId)?.activities;
|
|
495
|
+
return Array.isArray(activities) ? activities.length : null;
|
|
496
|
+
});
|
|
467
497
|
viewerHistory = createViewerHistory({
|
|
468
498
|
history: window.history,
|
|
469
499
|
mark: 'oarSubagentViewer',
|
|
@@ -475,7 +505,7 @@ export function initSubagentViewer() {
|
|
|
475
505
|
// (a child row) opens that child in place.
|
|
476
506
|
document.addEventListener('click', (event) => {
|
|
477
507
|
const trigger = event.target?.closest?.('[data-subagent-open]');
|
|
478
|
-
if (!trigger
|
|
508
|
+
if (!trigger) return;
|
|
479
509
|
event.preventDefault();
|
|
480
510
|
event.stopPropagation();
|
|
481
511
|
const runId = trigger.getAttribute('data-subagent-open');
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Pure pieces of the task console and the task rows: the pass/fail chip, the
|
|
2
|
+
// stall hint, the ✓/✘ of an ended task, the console's line rows and the
|
|
3
|
+
// search for the first failure. DOM nodes are built with createElement and
|
|
4
|
+
// textContent only: every string here comes from a worker's output.
|
|
5
|
+
|
|
6
|
+
import { ansiToSegments, stallMinutes } from './task-output-text.mjs';
|
|
7
|
+
|
|
8
|
+
// Mirrors the stall setting's bounds in shared/task-output-text.mjs.
|
|
9
|
+
export const DEFAULT_TASK_STALL_MINUTES = 10;
|
|
10
|
+
export const TASK_STALL_MINUTES_MIN = 1;
|
|
11
|
+
export const TASK_STALL_MINUTES_MAX = 120;
|
|
12
|
+
|
|
13
|
+
/** The minutes after which a quiet task is marked, from a payload value. */
|
|
14
|
+
export function normalizeStallThreshold(value) {
|
|
15
|
+
const minutes = Number(value);
|
|
16
|
+
if (value === null || value === undefined || value === '' || !Number.isFinite(minutes)) return DEFAULT_TASK_STALL_MINUTES;
|
|
17
|
+
return Math.min(TASK_STALL_MINUTES_MAX, Math.max(TASK_STALL_MINUTES_MIN, Math.round(minutes)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function count(value) {
|
|
21
|
+
const number = Number(value);
|
|
22
|
+
return Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The chip for a test runner's counts: "✓ 412 ✘ 2", "✓ 38". Null when the
|
|
27
|
+
* health names no counts. `failed` marks the red variant.
|
|
28
|
+
*/
|
|
29
|
+
export function runnerChip(health) {
|
|
30
|
+
const counts = health?.counts;
|
|
31
|
+
if (!counts || typeof counts !== 'object') return null;
|
|
32
|
+
const passed = count(counts.passed);
|
|
33
|
+
const failed = count(counts.failed);
|
|
34
|
+
const skipped = count(counts.skipped);
|
|
35
|
+
if (!passed && !failed && !skipped) return null;
|
|
36
|
+
const text = failed ? `✓ ${passed} ✘ ${failed}` : `✓ ${passed}`;
|
|
37
|
+
const runner = String(health?.runner || '').trim();
|
|
38
|
+
const titleParts = [`${passed} passed`, `${failed} failed`];
|
|
39
|
+
if (skipped) titleParts.push(`${skipped} skipped`);
|
|
40
|
+
return {
|
|
41
|
+
text,
|
|
42
|
+
failed: failed > 0,
|
|
43
|
+
title: `${runner ? `${runner}: ` : ''}${titleParts.join(', ')}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* "no output for 12 min" once a live task has been quiet a minute or more;
|
|
49
|
+
* `warn` from the threshold on. Null while output is fresh or unknown.
|
|
50
|
+
*/
|
|
51
|
+
export function stallHint(lastOutputAt, thresholdMinutes, now = Date.now()) {
|
|
52
|
+
const minutes = stallMinutes(lastOutputAt, now);
|
|
53
|
+
if (minutes == null || minutes < 1) return null;
|
|
54
|
+
return {
|
|
55
|
+
minutes,
|
|
56
|
+
text: `no output for ${minutes} min`,
|
|
57
|
+
warn: minutes >= normalizeStallThreshold(thresholdMinutes),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ✓ or ✘ for an ended task, from its status and exit code. */
|
|
62
|
+
export function endedOutcome(task) {
|
|
63
|
+
const status = String(task?.status || '').trim().toLowerCase();
|
|
64
|
+
const exitCode = task?.exitCode;
|
|
65
|
+
const hasExit = exitCode !== null && exitCode !== undefined && exitCode !== '' && Number.isFinite(Number(exitCode));
|
|
66
|
+
const failed = status === 'failed' || (hasExit && Number(exitCode) !== 0);
|
|
67
|
+
const stopped = status === 'stopped' || status === 'killed' || status === 'cancelled';
|
|
68
|
+
const label = failed
|
|
69
|
+
? (hasExit ? `failed, exit code ${Number(exitCode)}` : 'failed')
|
|
70
|
+
: stopped ? 'stopped' : status === 'completed' || (hasExit && Number(exitCode) === 0) ? 'completed' : 'ended';
|
|
71
|
+
return {
|
|
72
|
+
icon: failed ? '✘' : stopped ? '■' : '✓',
|
|
73
|
+
ok: !failed && !stopped,
|
|
74
|
+
failed,
|
|
75
|
+
label,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** "22 steps", "1 step", or '' when unknown. */
|
|
80
|
+
export function stepsLabel(steps) {
|
|
81
|
+
const number = Number(steps);
|
|
82
|
+
if (!Number.isFinite(number) || number < 0) return '';
|
|
83
|
+
const whole = Math.floor(number);
|
|
84
|
+
return `${whole} step${whole === 1 ? '' : 's'}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether a row opens the console: shell and Monitor tasks with output. */
|
|
88
|
+
export function taskHasConsole(task) {
|
|
89
|
+
const kind = String(task?.outputKind || '').trim();
|
|
90
|
+
if (kind === 'none') return false;
|
|
91
|
+
if (kind) return true;
|
|
92
|
+
if (String(task?.outputPath || '').trim()) return true;
|
|
93
|
+
const type = String(task?.taskType || '').trim();
|
|
94
|
+
return type === 'local_bash' || type === 'monitor';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function taskOutputUnavailable(task) {
|
|
98
|
+
return String(task?.outputKind || '').trim() === 'none';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Line rows ───────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function segmentClass(segment) {
|
|
104
|
+
const classes = [];
|
|
105
|
+
if (Number.isInteger(segment.fg)) classes.push(`tc-fg-${segment.fg}`);
|
|
106
|
+
if (Number.isInteger(segment.bg)) classes.push(`tc-bg-${segment.bg}`);
|
|
107
|
+
if (segment.bold) classes.push('tc-bold');
|
|
108
|
+
if (segment.dim) classes.push('tc-dim');
|
|
109
|
+
if (segment.italic) classes.push('tc-italic');
|
|
110
|
+
if (segment.underline) classes.push('tc-underline');
|
|
111
|
+
return classes.join(' ');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function segmentsOf(line) {
|
|
115
|
+
if (Array.isArray(line?.segments)) return line.segments;
|
|
116
|
+
if (Array.isArray(line?.ansi)) return line.ansi;
|
|
117
|
+
return ansiToSegments(String(line?.text ?? ''));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Plain text of a line, without its colour codes. */
|
|
121
|
+
export function lineText(line) {
|
|
122
|
+
return segmentsOf(line).map((segment) => String(segment?.text ?? '')).join('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One `.tc-line` row: SGR spans for the text, then "×N" for a folded repeat.
|
|
127
|
+
* `partial` marks the line still being written.
|
|
128
|
+
*/
|
|
129
|
+
export function buildLineNode(doc, line, { partial = false } = {}) {
|
|
130
|
+
const row = doc.createElement('div');
|
|
131
|
+
row.className = partial ? 'tc-line tc-line-partial' : 'tc-line';
|
|
132
|
+
const segments = segmentsOf(line);
|
|
133
|
+
if (!segments.length) row.appendChild(doc.createTextNode(''));
|
|
134
|
+
for (const segment of segments) {
|
|
135
|
+
const text = String(segment?.text ?? '');
|
|
136
|
+
if (!text) continue;
|
|
137
|
+
const className = segmentClass(segment);
|
|
138
|
+
if (!className) {
|
|
139
|
+
row.appendChild(doc.createTextNode(text));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const span = doc.createElement('span');
|
|
143
|
+
span.className = className;
|
|
144
|
+
span.textContent = text;
|
|
145
|
+
row.appendChild(span);
|
|
146
|
+
}
|
|
147
|
+
const repeats = count(line?.count);
|
|
148
|
+
if (repeats > 1) {
|
|
149
|
+
const badge = doc.createElement('span');
|
|
150
|
+
badge.className = 'tc-line-count';
|
|
151
|
+
badge.textContent = `×${repeats}`;
|
|
152
|
+
row.appendChild(badge);
|
|
153
|
+
}
|
|
154
|
+
return row;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The failure lines of the runners the relay parses (vitest, jest, node:test,
|
|
158
|
+
// Playwright, pytest, turbo), as shared/task-output-text.mjs finds them: the
|
|
159
|
+
// per-test marks and the detailed reports. The jump lands on the earliest.
|
|
160
|
+
const FAILURE_LINE_PATTERNS = [
|
|
161
|
+
/^\s*(?:[✗✘×✕✖]|\[FAIL\])\s+(?!failing tests:)/,
|
|
162
|
+
/^\s*FAIL\s/,
|
|
163
|
+
/^\s{2}\d+\) /,
|
|
164
|
+
/^\s*●\s/,
|
|
165
|
+
/^_{3,} .+ _{3,}$/,
|
|
166
|
+
/\s(?:FAILED|ERROR)(?:\s+\[\s*\d+%\])?\s*$/,
|
|
167
|
+
/^not ok \d+ /,
|
|
168
|
+
/command finished with error/i,
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/** Index of the first line that reads as a failing test, or -1. */
|
|
172
|
+
export function findFirstFailureIndex(lines) {
|
|
173
|
+
const list = Array.isArray(lines) ? lines : [];
|
|
174
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
175
|
+
const text = lineText(list[index]);
|
|
176
|
+
if (FAILURE_LINE_PATTERNS.some((pattern) => pattern.test(text))) return index;
|
|
177
|
+
}
|
|
178
|
+
return -1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The console header's meta line: "Shell · 4m 12s · failed, exit code 1".
|
|
183
|
+
* The stall hint goes beside it in its own element (it turns amber).
|
|
184
|
+
*/
|
|
185
|
+
export function consoleMetaParts({ task, elapsed, ended, status, exitCode, kind }) {
|
|
186
|
+
const parts = [];
|
|
187
|
+
const type = String(task?.taskType || '').trim();
|
|
188
|
+
parts.push(type === 'monitor' ? 'Monitor' : type === 'local_bash' ? 'Shell' : (type || 'Task'));
|
|
189
|
+
if (elapsed) parts.push(elapsed);
|
|
190
|
+
if (ended) parts.push(endedOutcome({ status, exitCode }).label);
|
|
191
|
+
else parts.push('running');
|
|
192
|
+
if (kind === 'snapshot') parts.push('last 8 KB, read again every 3 s');
|
|
193
|
+
if (kind === 'spool' && task?.mirrored) parts.push("output mirrored from the runtime's recent window");
|
|
194
|
+
return parts;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Live chunks ─────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/** Where the file stands after a chunk: its end offset, else the file size. */
|
|
200
|
+
export function chunkEndOffset(chunk) {
|
|
201
|
+
for (const key of ['endOffset', 'nextOffset', 'size']) {
|
|
202
|
+
const value = Number(chunk?.[key]);
|
|
203
|
+
if (chunk?.[key] !== undefined && chunk?.[key] !== null && Number.isFinite(value)) return value;
|
|
204
|
+
}
|
|
205
|
+
return NaN;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a `task_output_chunk` does to a view that holds the file up to
|
|
210
|
+
* `nextOffset` (of a file `size` bytes long): 'apply' when it continues
|
|
211
|
+
* exactly there, 'ignore' when it only repeats bytes already shown, and
|
|
212
|
+
* 'refetch' for a gap, an overlap, or a file that got shorter (the relay's
|
|
213
|
+
* `reset`, or a smaller size).
|
|
214
|
+
*/
|
|
215
|
+
export function chunkAction({ nextOffset, size }, chunk) {
|
|
216
|
+
if (chunk?.reset === true) return 'refetch';
|
|
217
|
+
const offset = Number(chunk?.offset);
|
|
218
|
+
if (!Number.isFinite(offset)) return 'ignore';
|
|
219
|
+
const chunkSize = Number(chunk?.size);
|
|
220
|
+
if (Number.isFinite(chunkSize) && Number.isFinite(Number(size)) && chunkSize < Number(size)) return 'refetch';
|
|
221
|
+
if (offset === Number(nextOffset)) return 'apply';
|
|
222
|
+
const end = chunkEndOffset(chunk);
|
|
223
|
+
if (offset < Number(nextOffset) && Number.isFinite(end) && end <= Number(nextOffset)) return 'ignore';
|
|
224
|
+
return 'refetch';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Bytes a modal may hold before "Load earlier" stops paging back. */
|
|
228
|
+
export const TASK_CONSOLE_MAX_LOADED_BYTES = 5 * 1024 * 1024;
|
|
229
|
+
|
|
230
|
+
/** The "Load earlier" button's state. */
|
|
231
|
+
export function loadEarlierState({ hasMoreBefore, loadedBytes, loading = false }) {
|
|
232
|
+
if (!hasMoreBefore) return { hidden: true, disabled: true, text: '' };
|
|
233
|
+
if (Number(loadedBytes) >= TASK_CONSOLE_MAX_LOADED_BYTES) {
|
|
234
|
+
return { hidden: false, disabled: true, text: 'older output not loaded' };
|
|
235
|
+
}
|
|
236
|
+
return { hidden: false, disabled: !!loading, text: loading ? 'Loading…' : 'Load earlier' };
|
|
237
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Tells the relay which task's output the console shows, so its new bytes
|
|
2
|
+
// arrive live on this page only (`task_output_watch` / `_unwatch`, one room
|
|
3
|
+
// per socket). The console waits for the watch's answer before it reads the
|
|
4
|
+
// tail: bytes written between the read and the join would otherwise be lost
|
|
5
|
+
// until the next gap shows them. The relay's rooms may or may not survive a
|
|
6
|
+
// reconnect, so the console watches again on every connect.
|
|
7
|
+
|
|
8
|
+
const WATCH_TIMEOUT_MS = 3000;
|
|
9
|
+
|
|
10
|
+
export function createTaskConsoleWatch({ getSocket, timeoutMs = WATCH_TIMEOUT_MS } = {}) {
|
|
11
|
+
let watched = null;
|
|
12
|
+
|
|
13
|
+
async function watch(conversationId, taskId) {
|
|
14
|
+
watched = { conversationId: String(conversationId || ''), taskId: String(taskId || '') };
|
|
15
|
+
const socket = getSocket?.();
|
|
16
|
+
if (!socket?.connected || !watched.conversationId || !watched.taskId) return { ok: false, code: 'offline' };
|
|
17
|
+
let result;
|
|
18
|
+
try {
|
|
19
|
+
result = await socket.timeout(timeoutMs).emitWithAck('task_output_watch', {
|
|
20
|
+
conversationId: watched.conversationId,
|
|
21
|
+
taskId: watched.taskId,
|
|
22
|
+
});
|
|
23
|
+
} catch {
|
|
24
|
+
return { ok: false, code: 'timeout' };
|
|
25
|
+
}
|
|
26
|
+
return result && typeof result === 'object' ? result : { ok: false, code: 'bad-answer' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unwatch() {
|
|
30
|
+
if (!watched) return;
|
|
31
|
+
watched = null;
|
|
32
|
+
const socket = getSocket?.();
|
|
33
|
+
if (socket?.connected) socket.emit('task_output_unwatch', {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
watch,
|
|
38
|
+
unwatch,
|
|
39
|
+
/** The task the relay should be sending, or null. */
|
|
40
|
+
current: () => (watched ? { ...watched } : null),
|
|
41
|
+
};
|
|
42
|
+
}
|