@oar-sh/oar 0.9.11 → 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 +126 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +17 -3
- 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 +128 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +159 -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 +9 -0
- package/server/grok-worker/acp-host-services.mjs +42 -3
- 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/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/api-client.js +13 -0
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +95 -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-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +433 -3
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/journal-view.js +252 -66
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/settings-modal.js +110 -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 +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/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +38 -8
- package/server/public/app/transcript-jump-bottom.mjs +48 -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 +239 -6
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +8 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/routes/messages-routes.mjs +249 -3
- package/server/routes/sessions-routes.mjs +133 -63
- package/server/server-runtime.mjs +77 -0
- package/server/services/background-task-store.mjs +240 -3
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +130 -2
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/remote-relay-contract.mjs +32 -2
- package/shared/task-output-text.mjs +737 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
|
@@ -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
|
+
}
|