@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,401 @@
|
|
|
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, the search
|
|
3
|
+
// for the first failure, the "output stops here" marker of a command piped
|
|
4
|
+
// into tail, and the texts for a task without output (the relay's answer
|
|
5
|
+
// uses them too). DOM nodes are built with createElement and textContent
|
|
6
|
+
// only: every string here comes from a worker's output.
|
|
7
|
+
|
|
8
|
+
import { ansiToSegments, stallMinutes } from './task-output-text.mjs';
|
|
9
|
+
|
|
10
|
+
// Mirrors the stall setting's bounds in shared/task-output-text.mjs.
|
|
11
|
+
export const DEFAULT_TASK_STALL_MINUTES = 10;
|
|
12
|
+
export const TASK_STALL_MINUTES_MIN = 1;
|
|
13
|
+
export const TASK_STALL_MINUTES_MAX = 120;
|
|
14
|
+
|
|
15
|
+
/** The minutes after which a quiet task is marked, from a payload value. */
|
|
16
|
+
export function normalizeStallThreshold(value) {
|
|
17
|
+
const minutes = Number(value);
|
|
18
|
+
if (value === null || value === undefined || value === '' || !Number.isFinite(minutes)) return DEFAULT_TASK_STALL_MINUTES;
|
|
19
|
+
return Math.min(TASK_STALL_MINUTES_MAX, Math.max(TASK_STALL_MINUTES_MIN, Math.round(minutes)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function count(value) {
|
|
23
|
+
const number = Number(value);
|
|
24
|
+
return Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The chip for a test runner's counts: "✓ 412 ✘ 2", "✓ 38". Null when the
|
|
29
|
+
* health names no counts. `failed` marks the red variant.
|
|
30
|
+
*/
|
|
31
|
+
export function runnerChip(health) {
|
|
32
|
+
const counts = health?.counts;
|
|
33
|
+
if (!counts || typeof counts !== 'object') return null;
|
|
34
|
+
const passed = count(counts.passed);
|
|
35
|
+
const failed = count(counts.failed);
|
|
36
|
+
const skipped = count(counts.skipped);
|
|
37
|
+
if (!passed && !failed && !skipped) return null;
|
|
38
|
+
const text = failed ? `✓ ${passed} ✘ ${failed}` : `✓ ${passed}`;
|
|
39
|
+
const runner = String(health?.runner || '').trim();
|
|
40
|
+
const titleParts = [`${passed} passed`, `${failed} failed`];
|
|
41
|
+
if (skipped) titleParts.push(`${skipped} skipped`);
|
|
42
|
+
return {
|
|
43
|
+
text,
|
|
44
|
+
failed: failed > 0,
|
|
45
|
+
title: `${runner ? `${runner}: ` : ''}${titleParts.join(', ')}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* "no output for 12 min" once a live task has been quiet a minute or more;
|
|
51
|
+
* `warn` from the threshold on. Null while output is fresh or unknown.
|
|
52
|
+
*/
|
|
53
|
+
export function stallHint(lastOutputAt, thresholdMinutes, now = Date.now()) {
|
|
54
|
+
const minutes = stallMinutes(lastOutputAt, now);
|
|
55
|
+
if (minutes == null || minutes < 1) return null;
|
|
56
|
+
return {
|
|
57
|
+
minutes,
|
|
58
|
+
text: `no output for ${minutes} min`,
|
|
59
|
+
warn: minutes >= normalizeStallThreshold(thresholdMinutes),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ✓, ✘, ■ or ? for an ended task, from its status and exit code. `unknown`
|
|
65
|
+
* (a task whose worker did not come back after a relay restart) is neither a
|
|
66
|
+
* success nor a failure: a neutral ?, not a green ✓.
|
|
67
|
+
*/
|
|
68
|
+
export function endedOutcome(task) {
|
|
69
|
+
const status = String(task?.status || '').trim().toLowerCase();
|
|
70
|
+
const exitCode = task?.exitCode;
|
|
71
|
+
const hasExit = exitCode !== null && exitCode !== undefined && exitCode !== '' && Number.isFinite(Number(exitCode));
|
|
72
|
+
const failed = status === 'failed' || (hasExit && Number(exitCode) !== 0);
|
|
73
|
+
const stopped = status === 'stopped' || status === 'killed' || status === 'cancelled';
|
|
74
|
+
const unknown = !failed && !stopped && !hasExit && status === 'unknown';
|
|
75
|
+
const label = failed
|
|
76
|
+
? (hasExit ? `failed, exit code ${Number(exitCode)}` : 'failed')
|
|
77
|
+
: stopped ? 'stopped'
|
|
78
|
+
: unknown ? 'ended, result unknown'
|
|
79
|
+
: status === 'completed' || (hasExit && Number(exitCode) === 0) ? 'completed' : 'ended';
|
|
80
|
+
return {
|
|
81
|
+
icon: failed ? '✘' : stopped ? '■' : unknown ? '?' : '✓',
|
|
82
|
+
ok: !failed && !stopped && !unknown,
|
|
83
|
+
failed,
|
|
84
|
+
label,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** "22 steps", "1 step", or '' when unknown. */
|
|
89
|
+
export function stepsLabel(steps) {
|
|
90
|
+
const number = Number(steps);
|
|
91
|
+
if (!Number.isFinite(number) || number < 0) return '';
|
|
92
|
+
const whole = Math.floor(number);
|
|
93
|
+
return `${whole} step${whole === 1 ? '' : 's'}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether a row opens the console: shell and Monitor tasks with output. */
|
|
97
|
+
export function taskHasConsole(task) {
|
|
98
|
+
const kind = String(task?.outputKind || '').trim();
|
|
99
|
+
if (kind === 'none') return false;
|
|
100
|
+
if (kind) return true;
|
|
101
|
+
if (String(task?.outputPath || '').trim()) return true;
|
|
102
|
+
const type = String(task?.taskType || '').trim();
|
|
103
|
+
return type === 'local_bash' || type === 'monitor';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function taskOutputUnavailable(task) {
|
|
107
|
+
return String(task?.outputKind || '').trim() === 'none';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why a task has no output to show: the console's `none` answer (the relay's
|
|
112
|
+
* output route imports these) and the row's hover title, in one wording.
|
|
113
|
+
*/
|
|
114
|
+
export const TASK_OUTPUT_NONE_MESSAGES = Object.freeze({
|
|
115
|
+
provider: 'Output is not available for this provider.',
|
|
116
|
+
subagent: 'Sub-agents have no shell output; open the sub-agent view.',
|
|
117
|
+
noOutputFile: 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.',
|
|
118
|
+
noPath: 'Output is not available for this task.',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Commands piped into tail ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
// A command line as its pipeline stages: `{ words, piped }` per simple
|
|
124
|
+
// command, `piped` when a `|` (or `|&`) feeds it. Quotes and backslashes are
|
|
125
|
+
// honoured, `||`, `&&`, `;`, `&` and newlines end a stage without a pipe;
|
|
126
|
+
// `2>&1` and `&>` are redirections, not separators, and a `#` that starts a
|
|
127
|
+
// word comments out the rest of its line. Not a shell: enough to tell where
|
|
128
|
+
// a `| tail` sits.
|
|
129
|
+
function pipelineStages(command) {
|
|
130
|
+
const text = String(command ?? '');
|
|
131
|
+
const stages = [];
|
|
132
|
+
let words = [];
|
|
133
|
+
let word = '';
|
|
134
|
+
let inWord = false;
|
|
135
|
+
let quote = '';
|
|
136
|
+
let piped = false;
|
|
137
|
+
const endWord = () => {
|
|
138
|
+
if (inWord) words.push(word);
|
|
139
|
+
word = '';
|
|
140
|
+
inWord = false;
|
|
141
|
+
};
|
|
142
|
+
const endStage = (nextPiped) => {
|
|
143
|
+
endWord();
|
|
144
|
+
if (words.length) stages.push({ words, piped });
|
|
145
|
+
words = [];
|
|
146
|
+
piped = nextPiped;
|
|
147
|
+
};
|
|
148
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
149
|
+
const char = text[index];
|
|
150
|
+
if (quote) {
|
|
151
|
+
if (char === quote) quote = '';
|
|
152
|
+
else if (char === '\\' && quote === '"' && index + 1 < text.length) word += text[++index];
|
|
153
|
+
else word += char;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (char === '\\') {
|
|
157
|
+
word += text[index + 1] ?? '';
|
|
158
|
+
inWord = true;
|
|
159
|
+
index += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (char === '#' && !inWord) {
|
|
163
|
+
while (index + 1 < text.length && text[index + 1] !== '\n') index += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (char === "'" || char === '"') {
|
|
167
|
+
quote = char;
|
|
168
|
+
inWord = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (char === '|') {
|
|
172
|
+
if (text[index + 1] === '|') {
|
|
173
|
+
index += 1;
|
|
174
|
+
endStage(false);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (text[index + 1] === '&') index += 1;
|
|
178
|
+
endStage(true);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (char === '&') {
|
|
182
|
+
const redirect = text[index - 1] === '>' || text[index - 1] === '<' || text[index + 1] === '>';
|
|
183
|
+
if (!redirect) {
|
|
184
|
+
if (text[index + 1] === '&') index += 1;
|
|
185
|
+
endStage(false);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (char === ';' || char === '\n' || char === '(' || char === ')') {
|
|
190
|
+
endStage(false);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (/\s/.test(char)) {
|
|
194
|
+
endWord();
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
word += char;
|
|
198
|
+
inWord = true;
|
|
199
|
+
}
|
|
200
|
+
endStage(false);
|
|
201
|
+
return stages;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// tail -f / -F / --follow streams; so does tail from a line (`-n +2`, `+2`).
|
|
205
|
+
function tailStreams(args) {
|
|
206
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
207
|
+
const arg = args[index];
|
|
208
|
+
if (/^--follow\b/.test(arg) || /^-[A-Za-z0-9]*[fF]/.test(arg)) return true;
|
|
209
|
+
if (/^\+\d/.test(arg) || /^-[nc]\+\d/.test(arg) || /^--(?:lines|bytes)=\+/.test(arg)) return true;
|
|
210
|
+
if ((arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') && /^\+\d/.test(args[index + 1] || '')) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 'tail' when the command line pipes into a tail that holds the output back
|
|
217
|
+
* until the command ends (`npm test 2>&1 | tail -40`), else ''. `tail -f`
|
|
218
|
+
* and `tail -n +N` stream and do not count; neither does a `|` inside quotes
|
|
219
|
+
* or a comment. `head` passes its lines on as they come (and ends the
|
|
220
|
+
* command early), so it holds nothing back.
|
|
221
|
+
*/
|
|
222
|
+
export function pipeHoldProgram(command) {
|
|
223
|
+
for (const { words, piped } of pipelineStages(command)) {
|
|
224
|
+
if (!piped) continue;
|
|
225
|
+
let at = 0;
|
|
226
|
+
while (at < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at])) at += 1;
|
|
227
|
+
const program = String(words[at] || '').split('/').pop();
|
|
228
|
+
if (program === 'tail' && !tailStreams(words.slice(at + 1))) return 'tail';
|
|
229
|
+
}
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 'tail' when a task's command line (its `command`, else its description) pipes into a holding tail. */
|
|
234
|
+
export function taskPipeHold(task) {
|
|
235
|
+
return pipeHoldProgram(task?.command) || pipeHoldProgram(task?.description);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What the console and the quiet row say about a command piped into `program`. */
|
|
239
|
+
export function pipeHoldNotice(program) {
|
|
240
|
+
if (!program) return null;
|
|
241
|
+
const hint = 'Logging to a file instead (> run.log 2>&1) and following that file shows it live.';
|
|
242
|
+
return {
|
|
243
|
+
text: `Output stops here: this command pipes into ${program}, so the rest arrives when it ends.`,
|
|
244
|
+
hint,
|
|
245
|
+
title: `This command pipes into ${program}, so its output arrives when it ends. ${hint}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether the console shows the "output stops here" marker: a live task
|
|
251
|
+
* whose command pipes into tail, as long as nothing came in since
|
|
252
|
+
* the console opened (`openedAt` the end offset of the first read).
|
|
253
|
+
*/
|
|
254
|
+
export function showPipeHoldMarker({ program, ended, kind, openedAt, nextOffset }) {
|
|
255
|
+
if (!program || ended || kind === 'none' || !kind) return false;
|
|
256
|
+
return !Number.isFinite(Number(openedAt)) || Number(nextOffset) <= Number(openedAt);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Line rows ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
// Inverse text arrives with its colours swapped (task-output-text.mjs); a
|
|
262
|
+
// side left unset takes the console's own colour of the other side.
|
|
263
|
+
function segmentClass(segment) {
|
|
264
|
+
const classes = [];
|
|
265
|
+
if (Number.isInteger(segment.fg)) classes.push(`tc-fg-${segment.fg}`);
|
|
266
|
+
else if (segment.inverse) classes.push('tc-fg-inverse');
|
|
267
|
+
if (Number.isInteger(segment.bg)) classes.push(`tc-bg-${segment.bg}`);
|
|
268
|
+
else if (segment.inverse) classes.push('tc-bg-inverse');
|
|
269
|
+
if (segment.bold) classes.push('tc-bold');
|
|
270
|
+
if (segment.dim) classes.push('tc-dim');
|
|
271
|
+
if (segment.italic) classes.push('tc-italic');
|
|
272
|
+
if (segment.underline) classes.push('tc-underline');
|
|
273
|
+
return classes.join(' ');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function segmentsOf(line) {
|
|
277
|
+
if (Array.isArray(line?.segments)) return line.segments;
|
|
278
|
+
if (Array.isArray(line?.ansi)) return line.ansi;
|
|
279
|
+
return ansiToSegments(String(line?.text ?? ''));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Plain text of a line, without its colour codes. */
|
|
283
|
+
export function lineText(line) {
|
|
284
|
+
return segmentsOf(line).map((segment) => String(segment?.text ?? '')).join('');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* One `.tc-line` row: SGR spans for the text, then "×N" for a folded repeat.
|
|
289
|
+
* `partial` marks the line still being written.
|
|
290
|
+
*/
|
|
291
|
+
export function buildLineNode(doc, line, { partial = false } = {}) {
|
|
292
|
+
const row = doc.createElement('div');
|
|
293
|
+
row.className = partial ? 'tc-line tc-line-partial' : 'tc-line';
|
|
294
|
+
const segments = segmentsOf(line);
|
|
295
|
+
if (!segments.length) row.appendChild(doc.createTextNode(''));
|
|
296
|
+
for (const segment of segments) {
|
|
297
|
+
const text = String(segment?.text ?? '');
|
|
298
|
+
if (!text) continue;
|
|
299
|
+
const className = segmentClass(segment);
|
|
300
|
+
if (!className) {
|
|
301
|
+
row.appendChild(doc.createTextNode(text));
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const span = doc.createElement('span');
|
|
305
|
+
span.className = className;
|
|
306
|
+
span.textContent = text;
|
|
307
|
+
row.appendChild(span);
|
|
308
|
+
}
|
|
309
|
+
const repeats = count(line?.count);
|
|
310
|
+
if (repeats > 1) {
|
|
311
|
+
const badge = doc.createElement('span');
|
|
312
|
+
badge.className = 'tc-line-count';
|
|
313
|
+
badge.textContent = `×${repeats}`;
|
|
314
|
+
row.appendChild(badge);
|
|
315
|
+
}
|
|
316
|
+
return row;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The failure lines of the runners the relay parses (vitest, jest, node:test,
|
|
320
|
+
// Playwright, pytest, turbo), as shared/task-output-text.mjs finds them: the
|
|
321
|
+
// per-test marks and the detailed reports. The jump lands on the earliest.
|
|
322
|
+
const FAILURE_LINE_PATTERNS = [
|
|
323
|
+
/^\s*(?:[✗✘×✕✖]|\[FAIL\])\s+(?!failing tests:)/,
|
|
324
|
+
/^\s*FAIL\s/,
|
|
325
|
+
/^\s{2}\d+\) /,
|
|
326
|
+
/^\s*●\s/,
|
|
327
|
+
/^_{3,} .+ _{3,}$/,
|
|
328
|
+
/\s(?:FAILED|ERROR)(?:\s+\[\s*\d+%\])?\s*$/,
|
|
329
|
+
/^not ok \d+ /,
|
|
330
|
+
/command finished with error/i,
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
/** Index of the first line that reads as a failing test, or -1. */
|
|
334
|
+
export function findFirstFailureIndex(lines) {
|
|
335
|
+
const list = Array.isArray(lines) ? lines : [];
|
|
336
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
337
|
+
const text = lineText(list[index]);
|
|
338
|
+
if (FAILURE_LINE_PATTERNS.some((pattern) => pattern.test(text))) return index;
|
|
339
|
+
}
|
|
340
|
+
return -1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The console header's meta line: "Shell · 4m 12s · failed, exit code 1".
|
|
345
|
+
* The stall hint goes beside it in its own element (it turns amber).
|
|
346
|
+
*/
|
|
347
|
+
export function consoleMetaParts({ task, elapsed, ended, status, exitCode, kind }) {
|
|
348
|
+
const parts = [];
|
|
349
|
+
const type = String(task?.taskType || '').trim();
|
|
350
|
+
parts.push(type === 'monitor' ? 'Monitor' : type === 'local_bash' ? 'Shell' : (type || 'Task'));
|
|
351
|
+
if (elapsed) parts.push(elapsed);
|
|
352
|
+
if (ended) parts.push(endedOutcome({ status, exitCode }).label);
|
|
353
|
+
// Held since a relay restart until its worker reports.
|
|
354
|
+
else if (status === 'held') parts.push('waiting for its worker after the relay restart');
|
|
355
|
+
else parts.push('running');
|
|
356
|
+
if (kind === 'snapshot') parts.push('last 8 KB, read again every 3 s');
|
|
357
|
+
if (kind === 'spool' && task?.mirrored) parts.push("output mirrored from the runtime's recent window");
|
|
358
|
+
return parts;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Live chunks ─────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/** Where the file stands after a chunk: its end offset, else the file size. */
|
|
364
|
+
export function chunkEndOffset(chunk) {
|
|
365
|
+
for (const key of ['endOffset', 'nextOffset', 'size']) {
|
|
366
|
+
const value = Number(chunk?.[key]);
|
|
367
|
+
if (chunk?.[key] !== undefined && chunk?.[key] !== null && Number.isFinite(value)) return value;
|
|
368
|
+
}
|
|
369
|
+
return NaN;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* What a `task_output_chunk` does to a view that holds the file up to
|
|
374
|
+
* `nextOffset` (of a file `size` bytes long): 'apply' when it continues
|
|
375
|
+
* exactly there, 'ignore' when it only repeats bytes already shown, and
|
|
376
|
+
* 'refetch' for a gap, an overlap, or a file that got shorter (the relay's
|
|
377
|
+
* `reset`, or a smaller size).
|
|
378
|
+
*/
|
|
379
|
+
export function chunkAction({ nextOffset, size }, chunk) {
|
|
380
|
+
if (chunk?.reset === true) return 'refetch';
|
|
381
|
+
const offset = Number(chunk?.offset);
|
|
382
|
+
if (!Number.isFinite(offset)) return 'ignore';
|
|
383
|
+
const chunkSize = Number(chunk?.size);
|
|
384
|
+
if (Number.isFinite(chunkSize) && Number.isFinite(Number(size)) && chunkSize < Number(size)) return 'refetch';
|
|
385
|
+
if (offset === Number(nextOffset)) return 'apply';
|
|
386
|
+
const end = chunkEndOffset(chunk);
|
|
387
|
+
if (offset < Number(nextOffset) && Number.isFinite(end) && end <= Number(nextOffset)) return 'ignore';
|
|
388
|
+
return 'refetch';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Bytes a modal may hold before "Load earlier" stops paging back. */
|
|
392
|
+
export const TASK_CONSOLE_MAX_LOADED_BYTES = 5 * 1024 * 1024;
|
|
393
|
+
|
|
394
|
+
/** The "Load earlier" button's state. */
|
|
395
|
+
export function loadEarlierState({ hasMoreBefore, loadedBytes, loading = false }) {
|
|
396
|
+
if (!hasMoreBefore) return { hidden: true, disabled: true, text: '' };
|
|
397
|
+
if (Number(loadedBytes) >= TASK_CONSOLE_MAX_LOADED_BYTES) {
|
|
398
|
+
return { hidden: false, disabled: true, text: 'older output not loaded' };
|
|
399
|
+
}
|
|
400
|
+
return { hidden: false, disabled: !!loading, text: loading ? 'Loading…' : 'Load earlier' };
|
|
401
|
+
}
|
|
@@ -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
|
+
}
|