@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,562 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reading a background task's output for the console: the file the worker
|
|
5
|
+
* names as the task's `outputPath` (the Claude CLI's task file, a Copilot
|
|
6
|
+
* shell log, the worker's own spool), read by the relay itself.
|
|
7
|
+
*
|
|
8
|
+
* - readTail / readBefore / readRange: byte windows of the file, cut on line
|
|
9
|
+
* and character boundaries, returned cleaned (control codes out, colours
|
|
10
|
+
* kept, credentials scrubbed) with the processed line model.
|
|
11
|
+
* - the path guard: an absolute path of at most 1,024 characters, a regular
|
|
12
|
+
* file that is not a symlink (opened with O_NOFOLLOW where the platform has
|
|
13
|
+
* it), reads capped per request.
|
|
14
|
+
* - snapshots: the 8 KiB a worker posts for a task with no file (Claude's
|
|
15
|
+
* getTaskOutput), cached per task.
|
|
16
|
+
* - the health poller: every 10 s, for live tasks with an output file (and
|
|
17
|
+
* ended ones, read once more after the end), the time of the last output
|
|
18
|
+
* and the test runner's counts from the last 64 KB, sent to the pages as
|
|
19
|
+
* `background_tasks_health` when they change.
|
|
20
|
+
*
|
|
21
|
+
* The worker is trusted (it runs on this host as this user); the guard stops
|
|
22
|
+
* accidents, not an attacker.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
TASK_OUTPUT_LIMITS,
|
|
29
|
+
cleanTaskOutput,
|
|
30
|
+
foldTaskOutput,
|
|
31
|
+
normalizeTaskOutputKind,
|
|
32
|
+
normalizeTaskOutputPath,
|
|
33
|
+
parseRunnerSummary,
|
|
34
|
+
stripControlKeepSgr,
|
|
35
|
+
} from '../../shared/task-output-text.mjs';
|
|
36
|
+
|
|
37
|
+
const NEWLINE = 0x0a;
|
|
38
|
+
|
|
39
|
+
function clampInt(value, { min, max, fallback }) {
|
|
40
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
41
|
+
const number = Number(value);
|
|
42
|
+
if (!Number.isFinite(number)) return fallback;
|
|
43
|
+
return Math.min(max, Math.max(min, Math.trunc(number)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The file kinds the relay reads itself. */
|
|
47
|
+
export function isFileOutputKind(kind) {
|
|
48
|
+
return kind === 'file' || kind === 'spool';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A task row's output as `{ kind, path }` (`path` '' when there is no readable one). */
|
|
52
|
+
export function taskOutputSource(task) {
|
|
53
|
+
const path = normalizeTaskOutputPath(task?.outputPath);
|
|
54
|
+
const kind = normalizeTaskOutputKind(task?.outputKind, path);
|
|
55
|
+
return { kind, path: isFileOutputKind(kind) ? path : '' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the file at `filePath` is: `{ ok: true, size, mtimeMs }`, or
|
|
60
|
+
* `{ ok: false, code }` with code `invalid-path`, `missing`, `symlink`,
|
|
61
|
+
* `not-file` or `unreadable`.
|
|
62
|
+
*/
|
|
63
|
+
export async function inspectOutputFile(filePath, { fsImpl = fs } = {}) {
|
|
64
|
+
const path = normalizeTaskOutputPath(filePath);
|
|
65
|
+
if (!path) return { ok: false, code: 'invalid-path' };
|
|
66
|
+
let stats;
|
|
67
|
+
try {
|
|
68
|
+
stats = await fsImpl.promises.lstat(path);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : 'unreadable' };
|
|
71
|
+
}
|
|
72
|
+
if (stats.isSymbolicLink()) return { ok: false, code: 'symlink' };
|
|
73
|
+
if (!stats.isFile()) return { ok: false, code: 'not-file' };
|
|
74
|
+
return { ok: true, size: stats.size, mtimeMs: stats.mtimeMs };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Reads [start, end) of the file. Opened without following a symlink where
|
|
78
|
+
// the platform can, and checked again on the open handle: a path swapped
|
|
79
|
+
// between the lstat and the open is refused.
|
|
80
|
+
async function readBytes(path, start, end, { fsImpl = fs } = {}) {
|
|
81
|
+
// O_NONBLOCK: a FIFO swapped in after the lstat must not hold a pool
|
|
82
|
+
// thread until a writer appears (no effect on a regular file).
|
|
83
|
+
const flags = fsImpl.constants.O_RDONLY | (fsImpl.constants.O_NOFOLLOW || 0) | (fsImpl.constants.O_NONBLOCK || 0);
|
|
84
|
+
let handle;
|
|
85
|
+
try {
|
|
86
|
+
handle = await fsImpl.promises.open(path, flags);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : (error?.code === 'ELOOP' ? 'symlink' : 'unreadable') };
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const stats = await handle.stat();
|
|
92
|
+
if (!stats.isFile()) return { ok: false, code: 'not-file' };
|
|
93
|
+
const size = stats.size;
|
|
94
|
+
const from = Math.max(0, Math.min(start, size));
|
|
95
|
+
const to = Math.max(from, Math.min(end, size));
|
|
96
|
+
const buffer = Buffer.alloc(to - from);
|
|
97
|
+
let read = 0;
|
|
98
|
+
while (read < buffer.length) {
|
|
99
|
+
const { bytesRead } = await handle.read(buffer, read, buffer.length - read, from + read);
|
|
100
|
+
if (!bytesRead) break;
|
|
101
|
+
read += bytesRead;
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, buffer: buffer.subarray(0, read), start: from, size, mtimeMs: stats.mtimeMs };
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false, code: 'unreadable' };
|
|
106
|
+
} finally {
|
|
107
|
+
try { await handle.close(); } catch {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How many bytes of `buffer` can be decoded and cleaned now without cutting
|
|
113
|
+
* a UTF-8 character or an escape sequence in two: the rest waits for the
|
|
114
|
+
* next read (the writer is mid-write).
|
|
115
|
+
*/
|
|
116
|
+
export function safeChunkLength(buffer) {
|
|
117
|
+
let end = buffer.length;
|
|
118
|
+
// An incomplete UTF-8 sequence at the end.
|
|
119
|
+
let lead = end - 1;
|
|
120
|
+
let continuation = 0;
|
|
121
|
+
while (lead >= 0 && continuation < 3 && (buffer[lead] & 0xc0) === 0x80) {
|
|
122
|
+
lead -= 1;
|
|
123
|
+
continuation += 1;
|
|
124
|
+
}
|
|
125
|
+
if (lead >= 0) {
|
|
126
|
+
const byte = buffer[lead];
|
|
127
|
+
const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
|
|
128
|
+
if (width > 1 && continuation + 1 < width) end = lead;
|
|
129
|
+
}
|
|
130
|
+
// An escape sequence still open at the end (ESC, ESC [ 3 1, ESC ] title…).
|
|
131
|
+
const tailStart = Math.max(0, end - 256);
|
|
132
|
+
const tail = buffer.subarray(tailStart, end).toString('latin1');
|
|
133
|
+
const esc = tail.lastIndexOf('\x1b');
|
|
134
|
+
if (esc !== -1) {
|
|
135
|
+
const rest = tail.slice(esc);
|
|
136
|
+
const complete = /^\x1b\[[0-?]*[ -/]*[@-~]/.test(rest)
|
|
137
|
+
|| /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.test(rest)
|
|
138
|
+
|| /^\x1b[^[\]]/.test(rest);
|
|
139
|
+
// An OSC that never ends within 256 bytes is garbage; let it through.
|
|
140
|
+
const hopelessOsc = rest.startsWith('\x1b]') && rest.length >= 256;
|
|
141
|
+
if (!complete && !hopelessOsc) end = tailStart + esc;
|
|
142
|
+
}
|
|
143
|
+
return end;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The byte offset just after the first newline at or after `from`, or -1.
|
|
147
|
+
function afterNewline(buffer, from = 0) {
|
|
148
|
+
const at = buffer.indexOf(NEWLINE, from);
|
|
149
|
+
return at === -1 ? -1 : at + 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The start of the last `count` lines of `buffer` (a line ends with "\n";
|
|
153
|
+
// an unterminated last line counts as one).
|
|
154
|
+
function startOfLastLines(buffer, count) {
|
|
155
|
+
let lines = 0;
|
|
156
|
+
let position = buffer.length;
|
|
157
|
+
if (position > 0 && buffer[position - 1] === NEWLINE) position -= 1;
|
|
158
|
+
while (position > 0) {
|
|
159
|
+
const at = buffer.lastIndexOf(NEWLINE, position - 1);
|
|
160
|
+
lines += 1;
|
|
161
|
+
if (lines >= count || at === -1) return lines >= count ? at + 1 : 0;
|
|
162
|
+
position = at;
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function skipContinuationBytes(buffer, from) {
|
|
168
|
+
let start = from;
|
|
169
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
|
|
170
|
+
return start;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function shapeWindow(buffer, windowStart, size, mtimeMs) {
|
|
174
|
+
const text = cleanTaskOutput(buffer.toString('utf8'));
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
text,
|
|
178
|
+
startOffset: windowStart,
|
|
179
|
+
endOffset: windowStart + buffer.length,
|
|
180
|
+
size,
|
|
181
|
+
mtime: mtimeMs,
|
|
182
|
+
hasMoreBefore: windowStart > 0,
|
|
183
|
+
lines: foldTaskOutput(text),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The last `lines` lines of the bytes before `end`, within `maxBytes`.
|
|
188
|
+
async function readLinesEndingAt(filePath, end, { lines, maxBytes, trimEnd, fsImpl }) {
|
|
189
|
+
const windowStart = Math.max(0, end - maxBytes);
|
|
190
|
+
const read = await readBytes(filePath, windowStart, end, { fsImpl });
|
|
191
|
+
if (!read.ok) return read;
|
|
192
|
+
let buffer = read.buffer;
|
|
193
|
+
let start = 0;
|
|
194
|
+
// A window that starts mid-file starts on the next line; one long line
|
|
195
|
+
// with no newline at all starts on the next character.
|
|
196
|
+
if (read.start > 0) {
|
|
197
|
+
const next = afterNewline(buffer);
|
|
198
|
+
start = next === -1 || next >= buffer.length ? skipContinuationBytes(buffer, 0) : next;
|
|
199
|
+
}
|
|
200
|
+
buffer = buffer.subarray(start);
|
|
201
|
+
const fromLines = startOfLastLines(buffer, lines);
|
|
202
|
+
buffer = buffer.subarray(fromLines);
|
|
203
|
+
const firstByte = read.start + start + fromLines;
|
|
204
|
+
if (trimEnd) buffer = buffer.subarray(0, safeChunkLength(buffer));
|
|
205
|
+
return shapeWindow(buffer, firstByte, read.size, read.mtimeMs);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The end of a task's output: the last `lines` lines (default 2,000, at most
|
|
210
|
+
* 5,000) within `maxBytes` (at most 256 KB). Returns `{ ok, text,
|
|
211
|
+
* startOffset, endOffset, size, mtime, hasMoreBefore, lines }`: `text` the
|
|
212
|
+
* cleaned slice (for a viewer's own folder), `lines` the processed line
|
|
213
|
+
* model, offsets in bytes of the file. The end stops short of a character or
|
|
214
|
+
* escape the writer has not finished; `endOffset` is where the live chunks
|
|
215
|
+
* continue. `{ ok: false, code }` when the file cannot be read.
|
|
216
|
+
*/
|
|
217
|
+
export async function readTail(filePath, { lines, maxBytes, fsImpl = fs } = {}) {
|
|
218
|
+
const info = await inspectOutputFile(filePath, { fsImpl });
|
|
219
|
+
if (!info.ok) return info;
|
|
220
|
+
return readLinesEndingAt(normalizeTaskOutputPath(filePath), info.size, {
|
|
221
|
+
lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
|
|
222
|
+
maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
|
|
223
|
+
trimEnd: true,
|
|
224
|
+
fsImpl,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The `lines` lines that end at byte offset `before` (a `startOffset` an earlier read returned). */
|
|
229
|
+
export async function readBefore(filePath, { before, lines, maxBytes, fsImpl = fs } = {}) {
|
|
230
|
+
const info = await inspectOutputFile(filePath, { fsImpl });
|
|
231
|
+
if (!info.ok) return info;
|
|
232
|
+
const end = clampInt(before, { min: 0, max: info.size, fallback: info.size });
|
|
233
|
+
return readLinesEndingAt(normalizeTaskOutputPath(filePath), end, {
|
|
234
|
+
lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
|
|
235
|
+
maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
|
|
236
|
+
trimEnd: false,
|
|
237
|
+
fsImpl,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The bytes from `start` on, at most `maxBytes` (64 KB), for a live chunk:
|
|
243
|
+
* `{ ok, offset, endOffset, text, size, mtime }` with `text` cleaned and the
|
|
244
|
+
* end on a safe boundary. `endOffset === offset` when nothing complete is
|
|
245
|
+
* there yet.
|
|
246
|
+
*/
|
|
247
|
+
export async function readRange(filePath, {
|
|
248
|
+
start,
|
|
249
|
+
maxBytes = TASK_OUTPUT_LIMITS.chunkBytes,
|
|
250
|
+
// Stop after the last line break (`\n` or `\r`): credentials are scrubbed
|
|
251
|
+
// per piece, so a piece must not end inside one. An open line goes out
|
|
252
|
+
// only when it is `longLineBytes` long, or with a later call without this.
|
|
253
|
+
holdOpenLine = false,
|
|
254
|
+
longLineBytes = TASK_OUTPUT_LIMITS.chunkBytes,
|
|
255
|
+
fsImpl = fs,
|
|
256
|
+
} = {}) {
|
|
257
|
+
const path = normalizeTaskOutputPath(filePath);
|
|
258
|
+
if (!path) return { ok: false, code: 'invalid-path' };
|
|
259
|
+
const from = Math.max(0, Math.trunc(Number(start) || 0));
|
|
260
|
+
const read = await readBytes(path, from, from + Math.max(1, maxBytes), { fsImpl });
|
|
261
|
+
if (!read.ok) return read;
|
|
262
|
+
let length = safeChunkLength(read.buffer);
|
|
263
|
+
if (holdOpenLine && length > 0) {
|
|
264
|
+
let cut = -1;
|
|
265
|
+
for (let index = length - 1; index >= 0; index -= 1) {
|
|
266
|
+
const byte = read.buffer[index];
|
|
267
|
+
if (byte === NEWLINE || byte === 0x0d) {
|
|
268
|
+
cut = index + 1;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (cut > 0) length = cut;
|
|
273
|
+
else if (length < longLineBytes) length = 0;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
ok: true,
|
|
277
|
+
offset: read.start,
|
|
278
|
+
endOffset: read.start + length,
|
|
279
|
+
text: cleanTaskOutput(read.buffer.subarray(0, length).toString('utf8')),
|
|
280
|
+
size: read.size,
|
|
281
|
+
mtime: read.mtimeMs,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The output a worker posted for a task with no file (`output-snapshot`),
|
|
289
|
+
* per store key and task; kept an hour like the ended rows.
|
|
290
|
+
*/
|
|
291
|
+
export function createTaskOutputSnapshots({ now = () => Date.now(), keepMs = TASK_OUTPUT_LIMITS.endedKeepMs, max = 200 } = {}) {
|
|
292
|
+
const snapshots = new Map();
|
|
293
|
+
const keyOf = (key, taskId) => `${String(key || '').trim()}\u0000${String(taskId || '').trim()}`;
|
|
294
|
+
|
|
295
|
+
function prune() {
|
|
296
|
+
const cutoff = now() - keepMs;
|
|
297
|
+
for (const [key, entry] of snapshots) if (entry.at < cutoff) snapshots.delete(key);
|
|
298
|
+
while (snapshots.size > max) snapshots.delete(snapshots.keys().next().value);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
set(key, taskId, { output, totalBytes, truncated } = {}) {
|
|
303
|
+
const text = String(output ?? '').slice(0, 64 * 1024);
|
|
304
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
305
|
+
const total = Number.isInteger(Number(totalBytes)) && Number(totalBytes) >= bytes ? Number(totalBytes) : bytes;
|
|
306
|
+
const id = keyOf(key, taskId);
|
|
307
|
+
snapshots.delete(id);
|
|
308
|
+
snapshots.set(id, { output: text, totalBytes: total, truncated: truncated === true || total > bytes, at: now() });
|
|
309
|
+
prune();
|
|
310
|
+
},
|
|
311
|
+
get(...args) {
|
|
312
|
+
prune();
|
|
313
|
+
const [keys, taskId] = args;
|
|
314
|
+
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
|
315
|
+
const entry = snapshots.get(keyOf(key, taskId));
|
|
316
|
+
if (entry) return entry;
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
},
|
|
320
|
+
forget(key, taskId) {
|
|
321
|
+
snapshots.delete(keyOf(key, taskId));
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A snapshot as the output route answers it. */
|
|
327
|
+
export function shapeSnapshot(snapshot) {
|
|
328
|
+
const text = cleanTaskOutput(snapshot.output);
|
|
329
|
+
const bytes = Buffer.byteLength(snapshot.output, 'utf8');
|
|
330
|
+
return {
|
|
331
|
+
text,
|
|
332
|
+
startOffset: Math.max(0, snapshot.totalBytes - bytes),
|
|
333
|
+
endOffset: snapshot.totalBytes,
|
|
334
|
+
size: snapshot.totalBytes,
|
|
335
|
+
mtime: snapshot.at,
|
|
336
|
+
hasMoreBefore: false,
|
|
337
|
+
truncated: !!snapshot.truncated,
|
|
338
|
+
lines: foldTaskOutput(text),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Health ──────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Runner counts of the window `buffer` that starts at byte `windowStart` of
|
|
346
|
+
* the file: `{ counts, runner, final, firstFailureOffset }`, the offset the
|
|
347
|
+
* byte where the first failure's line starts (null when there is none in the
|
|
348
|
+
* window).
|
|
349
|
+
*/
|
|
350
|
+
export function healthOfWindow(buffer, windowStart) {
|
|
351
|
+
let start = 0;
|
|
352
|
+
if (windowStart > 0) {
|
|
353
|
+
const next = afterNewline(buffer);
|
|
354
|
+
start = next === -1 ? buffer.length : next;
|
|
355
|
+
}
|
|
356
|
+
const texts = [];
|
|
357
|
+
const offsets = [];
|
|
358
|
+
let position = start;
|
|
359
|
+
while (position < buffer.length) {
|
|
360
|
+
const at = buffer.indexOf(NEWLINE, position);
|
|
361
|
+
const end = at === -1 ? buffer.length : at;
|
|
362
|
+
const raw = buffer.subarray(position, end).toString('utf8');
|
|
363
|
+
// A `\r` redraw keeps its last state, as the console shows it.
|
|
364
|
+
const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
365
|
+
texts.push(stripControlKeepSgr(body.slice(body.lastIndexOf('\r') + 1)));
|
|
366
|
+
offsets.push(windowStart + position);
|
|
367
|
+
position = end + 1;
|
|
368
|
+
}
|
|
369
|
+
const summary = parseRunnerSummary(texts);
|
|
370
|
+
if (!summary) return { counts: null, runner: null, final: false, firstFailureOffset: null };
|
|
371
|
+
return {
|
|
372
|
+
counts: { passed: summary.passed, failed: summary.failed, skipped: summary.skipped, ...(summary.flaky ? { flaky: summary.flaky } : {}) },
|
|
373
|
+
runner: summary.runner,
|
|
374
|
+
final: summary.final,
|
|
375
|
+
firstFailureOffset: Number.isInteger(summary.firstFailureLine) ? offsets[summary.firstFailureLine] : null,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sameHealthFacts(a, b) {
|
|
380
|
+
if (!a || !b) return false;
|
|
381
|
+
return a.runner === b.runner
|
|
382
|
+
&& a.final === b.final
|
|
383
|
+
&& a.firstFailureOffset === b.firstFailureOffset
|
|
384
|
+
&& JSON.stringify(a.counts) === JSON.stringify(b.counts);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Every `intervalMs` (10 s) over the live tasks with an output file: `stat`,
|
|
389
|
+
* and when the size or time changed, the last 64 KB parsed for runner
|
|
390
|
+
* counts. Readings go into the store (`setHealth`); a key's readings go to
|
|
391
|
+
* the pages as `background_tasks_health { conversationId, health }` when the
|
|
392
|
+
* counts change, and when the last output moved on by a minute or more (the
|
|
393
|
+
* stall hint counts minutes). `conversationId` is the store key, as in
|
|
394
|
+
* `background_tasks`.
|
|
395
|
+
*
|
|
396
|
+
* Ended rows are read too, until a reading finds the file as the last one
|
|
397
|
+
* did: a task that ends between two ticks has its last output (a runner's
|
|
398
|
+
* summary) read after the end, onto the ended row and to the pages. `poke`
|
|
399
|
+
* runs a tick soon, for the moment a task ends.
|
|
400
|
+
*/
|
|
401
|
+
export function createTaskHealthPoller({
|
|
402
|
+
store,
|
|
403
|
+
io,
|
|
404
|
+
intervalMs = 10_000,
|
|
405
|
+
windowBytes = TASK_OUTPUT_LIMITS.healthBytes,
|
|
406
|
+
fsImpl = fs,
|
|
407
|
+
logger = console,
|
|
408
|
+
setIntervalImpl = setInterval,
|
|
409
|
+
clearIntervalImpl = clearInterval,
|
|
410
|
+
setTimeoutImpl = setTimeout,
|
|
411
|
+
pokeDelayMs = 250,
|
|
412
|
+
} = {}) {
|
|
413
|
+
let timer = null;
|
|
414
|
+
let running = false;
|
|
415
|
+
let again = false;
|
|
416
|
+
let poked = null;
|
|
417
|
+
// key → taskId → the reading last sent to the pages.
|
|
418
|
+
const sent = new Map();
|
|
419
|
+
// key → taskId → the `endedAt` of the ended row whose file was read after
|
|
420
|
+
// the end and found unchanged: nothing more to read for it.
|
|
421
|
+
const settledEnded = new Map();
|
|
422
|
+
|
|
423
|
+
async function readHealth(task, previous) {
|
|
424
|
+
const { path } = taskOutputSource(task);
|
|
425
|
+
if (!path) return null;
|
|
426
|
+
const info = await inspectOutputFile(path, { fsImpl });
|
|
427
|
+
// A file not there yet (a spool is named before its first byte): no
|
|
428
|
+
// output yet, quiet since the task started.
|
|
429
|
+
if (!info.ok && info.code === 'missing' && !previous?.size) {
|
|
430
|
+
const startedAt = Number(task?.startedAt) || null;
|
|
431
|
+
return { lastOutputAt: startedAt, size: 0, counts: null, runner: null, final: false, firstFailureOffset: null };
|
|
432
|
+
}
|
|
433
|
+
if (!info.ok) return previous || null;
|
|
434
|
+
if (previous && previous.size === info.size && previous.lastOutputAt === info.mtimeMs) return previous;
|
|
435
|
+
const windowStart = Math.max(0, info.size - windowBytes);
|
|
436
|
+
const read = await readBytes(path, windowStart, info.size, { fsImpl });
|
|
437
|
+
if (!read.ok) return previous || null;
|
|
438
|
+
return {
|
|
439
|
+
lastOutputAt: info.mtimeMs,
|
|
440
|
+
size: info.size,
|
|
441
|
+
...healthOfWindow(read.buffer, read.start),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The same file as the last reading saw, with the same facts.
|
|
446
|
+
function sameReading(a, b) {
|
|
447
|
+
if (a === b) return true;
|
|
448
|
+
return !!a && !!b && a.size === b.size && a.lastOutputAt === b.lastOutputAt && sameHealthFacts(a, b);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The ended rows of `key` read after their end: `{ taskId: reading }` of
|
|
452
|
+
// those whose reading changed.
|
|
453
|
+
async function readEnded(key) {
|
|
454
|
+
const changed = {};
|
|
455
|
+
const rows = store.ended?.(key) || [];
|
|
456
|
+
const done = settledEnded.get(key) || new Map();
|
|
457
|
+
const kept = new Map();
|
|
458
|
+
for (const row of rows) {
|
|
459
|
+
const taskId = String(row?.taskId || '').trim();
|
|
460
|
+
if (!taskId || !taskOutputSource(row).path) continue;
|
|
461
|
+
if (done.get(taskId) === row.endedAt) {
|
|
462
|
+
kept.set(taskId, row.endedAt);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
let reading = null;
|
|
466
|
+
try {
|
|
467
|
+
reading = await readHealth(row, row.health || null);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
logger?.warn?.(`[task-output] health read failed for ended task ${taskId.slice(0, 12)}: ${error?.message || error}`);
|
|
470
|
+
}
|
|
471
|
+
if (!reading) continue;
|
|
472
|
+
if (sameReading(row.health, reading)) {
|
|
473
|
+
kept.set(taskId, row.endedAt);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (store.setEndedHealth?.(key, taskId, reading)) changed[taskId] = reading;
|
|
477
|
+
}
|
|
478
|
+
if (kept.size) settledEnded.set(key, kept);
|
|
479
|
+
else settledEnded.delete(key);
|
|
480
|
+
return changed;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function tick({ poked: fromPoke = false } = {}) {
|
|
484
|
+
if (!store?.sets) return;
|
|
485
|
+
if (running) {
|
|
486
|
+
if (fromPoke) again = true;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
running = true;
|
|
490
|
+
try {
|
|
491
|
+
// Ended rows of conversations nobody opens again expire here.
|
|
492
|
+
store.pruneAll?.();
|
|
493
|
+
const keys = new Set([...store.sets.keys(), ...(store.endedKeys?.() || [])]);
|
|
494
|
+
for (const key of keys) {
|
|
495
|
+
const tasks = Array.isArray(store.sets.get(key)?.tasks) ? store.sets.get(key).tasks : [];
|
|
496
|
+
const changed = {};
|
|
497
|
+
let anyChange = false;
|
|
498
|
+
const current = {};
|
|
499
|
+
for (const task of tasks) {
|
|
500
|
+
const taskId = String(task?.taskId || '').trim();
|
|
501
|
+
if (!taskId || !taskOutputSource(task).path) continue;
|
|
502
|
+
let reading = null;
|
|
503
|
+
try {
|
|
504
|
+
reading = await readHealth(task, store.health(key, taskId));
|
|
505
|
+
} catch (error) {
|
|
506
|
+
logger?.warn?.(`[task-output] health read failed for task ${taskId.slice(0, 12)}: ${error?.message || error}`);
|
|
507
|
+
}
|
|
508
|
+
if (!reading) continue;
|
|
509
|
+
store.setHealth(key, taskId, reading);
|
|
510
|
+
current[taskId] = reading;
|
|
511
|
+
const last = sent.get(key)?.get(taskId);
|
|
512
|
+
if (!last || !sameHealthFacts(last, reading) || reading.lastOutputAt - last.lastOutputAt >= 60_000) {
|
|
513
|
+
changed[taskId] = reading;
|
|
514
|
+
anyChange = true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const endedChanged = await readEnded(key);
|
|
518
|
+
if (Object.keys(endedChanged).length) anyChange = true;
|
|
519
|
+
if (!anyChange) continue;
|
|
520
|
+
const record = new Map(sent.get(key) || []);
|
|
521
|
+
for (const [taskId, reading] of Object.entries(changed)) record.set(taskId, reading);
|
|
522
|
+
// Tasks gone from the set leave the record.
|
|
523
|
+
for (const taskId of [...record.keys()]) if (!current[taskId]) record.delete(taskId);
|
|
524
|
+
sent.set(key, record);
|
|
525
|
+
try {
|
|
526
|
+
io?.emit?.('background_tasks_health', { conversationId: key, health: { ...current, ...endedChanged } });
|
|
527
|
+
} catch {}
|
|
528
|
+
}
|
|
529
|
+
for (const key of [...sent.keys()]) if (!store.sets.has(key)) sent.delete(key);
|
|
530
|
+
for (const key of [...settledEnded.keys()]) if (!keys.has(key)) settledEnded.delete(key);
|
|
531
|
+
} finally {
|
|
532
|
+
running = false;
|
|
533
|
+
}
|
|
534
|
+
// A poke during the tick: once more, for what it may have missed.
|
|
535
|
+
if (again) {
|
|
536
|
+
again = false;
|
|
537
|
+
await tick();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
tick,
|
|
543
|
+
/** A task ended: a tick soon rather than within 10 s. */
|
|
544
|
+
poke() {
|
|
545
|
+
if (poked) return;
|
|
546
|
+
poked = setTimeoutImpl(() => {
|
|
547
|
+
poked = null;
|
|
548
|
+
tick({ poked: true }).catch(() => {});
|
|
549
|
+
}, pokeDelayMs);
|
|
550
|
+
poked?.unref?.();
|
|
551
|
+
},
|
|
552
|
+
start() {
|
|
553
|
+
if (timer) return;
|
|
554
|
+
timer = setIntervalImpl(() => { tick().catch(() => {}); }, intervalMs);
|
|
555
|
+
timer?.unref?.();
|
|
556
|
+
},
|
|
557
|
+
stop() {
|
|
558
|
+
if (timer) clearIntervalImpl(timer);
|
|
559
|
+
timer = null;
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// How long a backgrounded Bash command of a Claude session may run before
|
|
2
|
+
// Claude Code stops it, when the agent passes no `timeout` of its own. The
|
|
3
|
+
// CLI's own default is 30 minutes; long test or GPU runs were cut off there.
|
|
4
|
+
//
|
|
5
|
+
// Shared so the settings slider, the relay that stores the value and the
|
|
6
|
+
// worker that hands it to the CLI agree on bounds, steps and formatting. The
|
|
7
|
+
// browser mirrors the default in
|
|
8
|
+
// server/public/app/claude-background-command-limit-options.mjs (only
|
|
9
|
+
// server/public is served), and the slider's static markup in
|
|
10
|
+
// server/public/index.html starts at it; unit tests pin both to this module.
|
|
11
|
+
|
|
12
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
|
|
13
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
|
|
14
|
+
export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
|
|
15
|
+
export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
|
|
16
|
+
|
|
17
|
+
/** Clamped to 30 min … 8 h and snapped to the slider's 30-minute steps. */
|
|
18
|
+
export function normalizeClaudeBackgroundCommandLimitMinutes(value, fallback = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES) {
|
|
19
|
+
if (value === null || value === undefined) return fallback;
|
|
20
|
+
if (typeof value === 'string' && value.trim() === '') return fallback;
|
|
21
|
+
if (typeof value !== 'number' && typeof value !== 'string') return fallback;
|
|
22
|
+
const numeric = Number(value);
|
|
23
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
24
|
+
const step = CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES;
|
|
25
|
+
const snapped = Math.round(numeric / step) * step;
|
|
26
|
+
return Math.min(
|
|
27
|
+
CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
|
|
28
|
+
Math.max(CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES, snapped),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function claudeBackgroundCommandLimitMinutesToMs(minutes) {
|
|
33
|
+
return normalizeClaudeBackgroundCommandLimitMinutes(minutes) * 60_000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES * 60_000;
|
|
37
|
+
|
|
38
|
+
/** An unset setting means "never configured", which is the default. */
|
|
39
|
+
export function readClaudeBackgroundCommandLimitSetting(storedValue) {
|
|
40
|
+
const text = storedValue === null || storedValue === undefined ? '' : String(storedValue).trim();
|
|
41
|
+
return normalizeClaudeBackgroundCommandLimitMinutes(text);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseClaudeBackgroundCommandLimitUpdate(value) {
|
|
45
|
+
const minutes = normalizeClaudeBackgroundCommandLimitMinutes(value, null);
|
|
46
|
+
if (minutes === null) return { ok: false, error: 'backgroundCommandLimitMinutes must be a number' };
|
|
47
|
+
return { ok: true, minutes };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatClaudeBackgroundCommandLimitLabel(minutes) {
|
|
51
|
+
const normalized = normalizeClaudeBackgroundCommandLimitMinutes(minutes);
|
|
52
|
+
if (normalized < 60) return `${normalized} min`;
|
|
53
|
+
const hours = Math.floor(normalized / 60);
|
|
54
|
+
const remainder = normalized % 60;
|
|
55
|
+
return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The fields every Claude settings payload carries for the slider. */
|
|
59
|
+
export function claudeBackgroundCommandLimitSettingsFields(settings = {}) {
|
|
60
|
+
return {
|
|
61
|
+
backgroundCommandLimitMinutes: normalizeClaudeBackgroundCommandLimitMinutes(settings?.backgroundCommandLimitMinutes),
|
|
62
|
+
backgroundCommandLimitMinMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES,
|
|
63
|
+
backgroundCommandLimitMaxMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
|
|
64
|
+
backgroundCommandLimitStepMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES,
|
|
65
|
+
backgroundCommandLimitDefaultMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What a delivery's `settings` does to the worker's limit (ms): an absent or
|
|
71
|
+
* junk key (an older relay) keeps the last known value; a present one is
|
|
72
|
+
* clamped like the setting itself.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveDeliveredClaudeBackgroundCommandLimitMs(current, settings) {
|
|
75
|
+
const kept = Number.isFinite(Number(current)) && Number(current) > 0
|
|
76
|
+
? claudeBackgroundCommandLimitMinutesToMs(Number(current) / 60_000)
|
|
77
|
+
: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
|
|
78
|
+
if (!settings || typeof settings !== 'object') return kept;
|
|
79
|
+
if (!Object.prototype.hasOwnProperty.call(settings, 'claudeBackgroundCommandLimitMs')) return kept;
|
|
80
|
+
const delivered = Number(settings.claudeBackgroundCommandLimitMs);
|
|
81
|
+
if (settings.claudeBackgroundCommandLimitMs === null || !Number.isFinite(delivered) || delivered <= 0) return kept;
|
|
82
|
+
return claudeBackgroundCommandLimitMinutesToMs(delivered / 60_000);
|
|
83
|
+
}
|