@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
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
28
28
|
import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
29
29
|
import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
|
|
30
|
+
import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
|
|
30
31
|
import {
|
|
31
32
|
RELAY_EVENT_REMOTE_PULL_PATH,
|
|
32
33
|
RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
|
|
@@ -1281,7 +1282,37 @@ export function createRemoteRelayDispatcher({
|
|
|
1281
1282
|
* `lastActivityAt` of a session, from the relay's `sessionState` /
|
|
1282
1283
|
* `sessionTasks`. A relay that predates them gives none.
|
|
1283
1284
|
*/
|
|
1284
|
-
|
|
1285
|
+
/**
|
|
1286
|
+
* The console facts read_session adds to a task (`row` is the conversation
|
|
1287
|
+
* payload): `taskId` for read_task_output, `output` whether there is any to
|
|
1288
|
+
* read, and `health` (minutes since the last output, the quiet threshold,
|
|
1289
|
+
* test-run counts) for a task with an output file.
|
|
1290
|
+
*/
|
|
1291
|
+
function taskConsoleFields(row, id) {
|
|
1292
|
+
const taskId = toText(id);
|
|
1293
|
+
if (!taskId || !isPlainObject(row)) return {};
|
|
1294
|
+
const live = (Array.isArray(row.backgroundTasks) ? row.backgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
|
|
1295
|
+
const ended = live ? null : (Array.isArray(row.endedBackgroundTasks) ? row.endedBackgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
|
|
1296
|
+
const task = live || ended;
|
|
1297
|
+
if (!task) return {};
|
|
1298
|
+
const kind = toText(task.outputKind) || (toText(task.outputPath) ? 'file' : 'none');
|
|
1299
|
+
const health = isPlainObject(task.health) ? task.health : null;
|
|
1300
|
+
const quietAfter = Number(row.taskStallMinutes) || null;
|
|
1301
|
+
const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
|
|
1302
|
+
return {
|
|
1303
|
+
taskId,
|
|
1304
|
+
output: kind !== 'none',
|
|
1305
|
+
...(health ? {
|
|
1306
|
+
health: {
|
|
1307
|
+
...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
|
|
1308
|
+
...(quietAfter && minutes !== null ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
|
|
1309
|
+
...(isPlainObject(health.counts) ? { counts: { runner: toText(health.runner) || null, ...health.counts } } : {}),
|
|
1310
|
+
},
|
|
1311
|
+
} : {}),
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function sessionActivityFields(row, { console: withConsole = false } = {}) {
|
|
1285
1316
|
const state = toText(row?.sessionState);
|
|
1286
1317
|
if (!SESSION_STATES.has(state)) return {};
|
|
1287
1318
|
const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
|
|
@@ -1298,6 +1329,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1298
1329
|
...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
|
|
1299
1330
|
...(task.endedAt ? { endedAt: task.endedAt } : {}),
|
|
1300
1331
|
...(toText(task.id) ? { id: toText(task.id) } : {}),
|
|
1332
|
+
...(withConsole ? taskConsoleFields(row, task.id) : {}),
|
|
1301
1333
|
}));
|
|
1302
1334
|
return {
|
|
1303
1335
|
state,
|
|
@@ -1487,7 +1519,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1487
1519
|
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1488
1520
|
archived: conversation?.archived === true,
|
|
1489
1521
|
active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
|
|
1490
|
-
...sessionActivityFields(conversation),
|
|
1522
|
+
...sessionActivityFields(conversation, { console: true }),
|
|
1491
1523
|
...(via ? { via } : {}),
|
|
1492
1524
|
...(group ? { group } : {}),
|
|
1493
1525
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
@@ -1500,6 +1532,101 @@ export function createRemoteRelayDispatcher({
|
|
|
1500
1532
|
};
|
|
1501
1533
|
}
|
|
1502
1534
|
|
|
1535
|
+
/**
|
|
1536
|
+
* read_task_output: the end of a background shell or Monitor task's
|
|
1537
|
+
* output (GET …/background-task/:taskId/output on the target relay, which
|
|
1538
|
+
* scrubs it), as plain lines without colours, at most `lines` lines and
|
|
1539
|
+
* 32 KB; minutes since its last output; test-run counts when it is one.
|
|
1540
|
+
*/
|
|
1541
|
+
async function readTaskOutput(ctx, args) {
|
|
1542
|
+
let answer;
|
|
1543
|
+
try {
|
|
1544
|
+
answer = await call(ctx, 'GET', sessionPath(args.session, `/background-task/${encodeURIComponent(args.task_id)}/output`), {
|
|
1545
|
+
query: { lines: args.lines },
|
|
1546
|
+
});
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
const status = Number(error?.status) || null;
|
|
1549
|
+
if (isRemoteRelayError(error) && (status === 404 || toText(error.code) === REMOTE_RELAY_ERROR_CODES.notFound)) {
|
|
1550
|
+
const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
|
|
1551
|
+
if (toText(body.code) === 'task-not-found') {
|
|
1552
|
+
throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
|
|
1553
|
+
`Task ${args.task_id} is not running in session ${args.session} on relay "${ctx.relay.name}" and did not end there within the last hour; read_session lists the session's tasks.`);
|
|
1554
|
+
}
|
|
1555
|
+
if (!/conversation not found/i.test(toText(body.error))) {
|
|
1556
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
1557
|
+
`Relay "${ctx.relay.name}" cannot show background task output (it runs an older OAR version).`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1562
|
+
const model = Array.isArray(answer?.lines) ? answer.lines : [];
|
|
1563
|
+
const task = isPlainObject(answer?.task) ? answer.task : {};
|
|
1564
|
+
const ended = answer?.ended === true;
|
|
1565
|
+
// Plain text, a repeat folded as ×N; cleaned again, whatever the other
|
|
1566
|
+
// side did.
|
|
1567
|
+
const rendered = model.map((line) => {
|
|
1568
|
+
const text = stripSgr(cleanTaskOutput(String(line?.text ?? '')));
|
|
1569
|
+
const count = Number(line?.count) > 1 ? Number(line.count) : 1;
|
|
1570
|
+
return count > 1 ? `${text} [×${count}]` : text;
|
|
1571
|
+
});
|
|
1572
|
+
let tail = rendered.slice(-args.lines);
|
|
1573
|
+
let cut = tail.length < rendered.length;
|
|
1574
|
+
let bytes = 0;
|
|
1575
|
+
let keepFrom = tail.length;
|
|
1576
|
+
for (let index = tail.length - 1; index >= 0; index -= 1) {
|
|
1577
|
+
bytes += Buffer.byteLength(tail[index], 'utf8') + 1;
|
|
1578
|
+
if (bytes > REMOTE_RELAY_LIMITS.taskOutputBytesMax) break;
|
|
1579
|
+
keepFrom = index;
|
|
1580
|
+
}
|
|
1581
|
+
if (keepFrom > 0) {
|
|
1582
|
+
tail = tail.slice(keepFrom);
|
|
1583
|
+
cut = true;
|
|
1584
|
+
}
|
|
1585
|
+
const health = isPlainObject(answer?.health) ? answer.health : null;
|
|
1586
|
+
const lastOutputAt = Number(health?.lastOutputAt) || Number(answer?.mtime) || null;
|
|
1587
|
+
const minutes = !ended && lastOutputAt ? stallMinutes(lastOutputAt, now()) : null;
|
|
1588
|
+
const quietAfter = Number(answer?.taskStallMinutes) || null;
|
|
1589
|
+
let counts = null;
|
|
1590
|
+
if (isPlainObject(health?.counts)) {
|
|
1591
|
+
counts = { runner: toText(health.runner) || null, ...health.counts, final: health.final === true };
|
|
1592
|
+
} else {
|
|
1593
|
+
const parsed = parseRunnerSummary(model);
|
|
1594
|
+
if (parsed) counts = { runner: parsed.runner, passed: parsed.passed, failed: parsed.failed, skipped: parsed.skipped, final: parsed.final };
|
|
1595
|
+
}
|
|
1596
|
+
const kind = toText(answer?.kind) || 'file';
|
|
1597
|
+
let note = null;
|
|
1598
|
+
if (kind === 'none') note = toText(answer?.message) || 'Output is not available for this provider.';
|
|
1599
|
+
else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
|
|
1600
|
+
else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
|
|
1601
|
+
else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
|
|
1602
|
+
const status = ended ? (toText(answer?.status) || 'unknown') : 'running';
|
|
1603
|
+
const exitCode = Number.isInteger(answer?.exitCode) ? answer.exitCode : null;
|
|
1604
|
+
const parts = [`${tail.length} line${tail.length === 1 ? '' : 's'}`, ended ? `ended (${status}${exitCode !== null ? `, exit ${exitCode}` : ''})` : 'running'];
|
|
1605
|
+
if (minutes !== null) parts.push(`last output ${minutes} min ago`);
|
|
1606
|
+
if (counts) parts.push(`${counts.passed} passed, ${counts.failed} failed`);
|
|
1607
|
+
return {
|
|
1608
|
+
summary: `read_task_output → ${ctx.relay.name} session ${shortId(args.session)} task ${shortId(args.task_id)}: ${parts.join(', ')}`,
|
|
1609
|
+
task: {
|
|
1610
|
+
id: args.task_id,
|
|
1611
|
+
description: toText(task.description),
|
|
1612
|
+
type: toText(task.taskType) || null,
|
|
1613
|
+
startedAt: task.startedAt || null,
|
|
1614
|
+
status,
|
|
1615
|
+
exitCode,
|
|
1616
|
+
...(task.endedAt ? { endedAt: task.endedAt } : {}),
|
|
1617
|
+
},
|
|
1618
|
+
stall: minutes === null ? null : {
|
|
1619
|
+
minutes,
|
|
1620
|
+
since: new Date(lastOutputAt).toISOString(),
|
|
1621
|
+
...(quietAfter ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
|
|
1622
|
+
},
|
|
1623
|
+
counts,
|
|
1624
|
+
tail,
|
|
1625
|
+
truncated: cut || answer?.hasMoreBefore === true,
|
|
1626
|
+
...(note ? { note } : {}),
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1503
1630
|
function outcomeSummary(action, ctx, outcome) {
|
|
1504
1631
|
const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
|
|
1505
1632
|
return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
|
|
@@ -2272,6 +2399,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2272
2399
|
usage: usageAction,
|
|
2273
2400
|
list_sessions: listSessions,
|
|
2274
2401
|
read_session: readSession,
|
|
2402
|
+
read_task_output: readTaskOutput,
|
|
2275
2403
|
wait: waitAction,
|
|
2276
2404
|
send,
|
|
2277
2405
|
create_session: createSession,
|
|
@@ -249,7 +249,9 @@ export function createSessionActivityTracker({
|
|
|
249
249
|
};
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
// `replaceSummary`: the end as the worker reported it, whose summary (or
|
|
253
|
+
// none) replaces the live row's, which was a progress line.
|
|
254
|
+
function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true, absent = false, replaceSummary = false } = {}) {
|
|
253
255
|
if (task.graceTimer) {
|
|
254
256
|
clearTimer(task.graceTimer);
|
|
255
257
|
task.graceTimer = null;
|
|
@@ -260,6 +262,7 @@ export function createSessionActivityTracker({
|
|
|
260
262
|
task.status = normalizeTaskStatus(status || 'completed');
|
|
261
263
|
task.statusReported = reported;
|
|
262
264
|
if (summary) task.summary = toText(summary).slice(0, 500);
|
|
265
|
+
else if (replaceSummary) task.summary = null;
|
|
263
266
|
const code = Number.isInteger(exitCode) ? exitCode : exitCodeFromSummary(task.summary);
|
|
264
267
|
task.exitCode = code;
|
|
265
268
|
task.endedAtMs = now();
|
|
@@ -406,6 +409,7 @@ export function createSessionActivityTracker({
|
|
|
406
409
|
status: normalizeTaskStatus(status || 'completed'),
|
|
407
410
|
summary: toText(summary) || null,
|
|
408
411
|
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
412
|
+
replaceSummary: true,
|
|
409
413
|
};
|
|
410
414
|
entry.lastActivityAt = now();
|
|
411
415
|
if (!task && key && endedByAbsence.delete(`${id}:${key}`)) {
|
|
@@ -416,7 +420,7 @@ export function createSessionActivityTracker({
|
|
|
416
420
|
if (ended) {
|
|
417
421
|
ended.status = settled.status;
|
|
418
422
|
ended.statusReported = true;
|
|
419
|
-
|
|
423
|
+
ended.summary = settled.summary ? settled.summary.slice(0, 500) : null;
|
|
420
424
|
ended.exitCode = settled.exitCode ?? exitCodeFromSummary(ended.summary);
|
|
421
425
|
}
|
|
422
426
|
return;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Who receives a background task's output live: the pages whose console
|
|
4
|
+
// shows that task. The console joins with `task_output_watch { conversationId,
|
|
5
|
+
// taskId, offset }` (on open and after every reconnect) and leaves with
|
|
6
|
+
// `task_output_unwatch`; a socket is in at most one task room. While a room
|
|
7
|
+
// has members the relay stats the task's file every second and sends what
|
|
8
|
+
// was appended as `task_output_chunk { conversationId, taskId, offset,
|
|
9
|
+
// endOffset, text, size, mtime, ended }` (at most 64 KB per emit, cleaned like
|
|
10
|
+
// every other read). A page whose next expected offset is not `offset`
|
|
11
|
+
// fetches the tail again; a file that shrank is sent as an empty chunk with
|
|
12
|
+
// `reset: true`. Nobody watching costs nothing: the room's poller stops when
|
|
13
|
+
// it empties.
|
|
14
|
+
|
|
15
|
+
import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
|
|
16
|
+
import { inspectOutputFile, readRange, taskOutputSource } from './task-output-service.mjs';
|
|
17
|
+
|
|
18
|
+
const ROOM_PREFIX = 'task-output:';
|
|
19
|
+
// Chunks one tick may send for a fast writer (4 × 64 KB a second).
|
|
20
|
+
const MAX_CHUNKS_PER_TICK = 4;
|
|
21
|
+
// A watcher whose offset is this far behind the room is caught up by itself;
|
|
22
|
+
// further back, it fetches the tail.
|
|
23
|
+
const CATCH_UP_BYTES = 256 * 1024;
|
|
24
|
+
|
|
25
|
+
export function taskOutputRoom(conversationId, taskId) {
|
|
26
|
+
return `${ROOM_PREFIX}${String(conversationId || '')}:${String(taskId || '')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeAck(ack, payload) {
|
|
30
|
+
if (typeof ack !== 'function') return;
|
|
31
|
+
try { ack(payload); } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function idOf(value) {
|
|
35
|
+
const text = String(value ?? '').trim();
|
|
36
|
+
return text && text.length <= 200 && !/[\s\0]/.test(text) ? text : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `resolveTask(conversationId, taskId)` returns `{ task, ended, key }` for a
|
|
41
|
+
* task in the conversation's live or ended set, or null: the same access
|
|
42
|
+
* check as the output route. Sockets reaching `registerSocket` are already
|
|
43
|
+
* authenticated.
|
|
44
|
+
*/
|
|
45
|
+
export function createTaskOutputRooms({
|
|
46
|
+
io,
|
|
47
|
+
resolveTask,
|
|
48
|
+
intervalMs = 1000,
|
|
49
|
+
chunkBytes = TASK_OUTPUT_LIMITS.chunkBytes,
|
|
50
|
+
roomSize = (room) => io?.sockets?.adapter?.rooms?.get?.(room)?.size || 0,
|
|
51
|
+
readChunk = readRange,
|
|
52
|
+
inspect = inspectOutputFile,
|
|
53
|
+
setIntervalImpl = setInterval,
|
|
54
|
+
clearIntervalImpl = clearInterval,
|
|
55
|
+
logger = console,
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (typeof resolveTask !== 'function') throw new Error('createTaskOutputRooms requires resolveTask');
|
|
58
|
+
// room → { conversationId, taskId, path, offset, timer, busy }
|
|
59
|
+
const watched = new Map();
|
|
60
|
+
|
|
61
|
+
function stopRoom(room) {
|
|
62
|
+
const entry = watched.get(room);
|
|
63
|
+
if (!entry) return;
|
|
64
|
+
if (entry.timer) clearIntervalImpl(entry.timer);
|
|
65
|
+
watched.delete(room);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function leave(socket) {
|
|
69
|
+
const current = socket.data?.taskOutputRoom;
|
|
70
|
+
if (current) socket.leave(current);
|
|
71
|
+
if (socket.data) socket.data.taskOutputRoom = null;
|
|
72
|
+
if (current && roomSize(current) === 0) stopRoom(current);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lookup(conversationId, taskId) {
|
|
76
|
+
try {
|
|
77
|
+
return resolveTask(conversationId, taskId);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Where a room may start in a file of `size` bytes: after the last line
|
|
84
|
+
// break of its last 64 KB (never inside a character, an escape or a line
|
|
85
|
+
// whose credentials would be scrubbed in two halves), 0 when there is none
|
|
86
|
+
// in a shorter file.
|
|
87
|
+
async function safeOffset(path, size) {
|
|
88
|
+
if (!(size > 0)) return 0;
|
|
89
|
+
const start = Math.max(0, size - chunkBytes);
|
|
90
|
+
try {
|
|
91
|
+
const probe = await readChunk(path, { start, maxBytes: size - start, holdOpenLine: true, longLineBytes: chunkBytes });
|
|
92
|
+
return probe?.ok && Number.isInteger(probe.endOffset) ? probe.endOffset : size;
|
|
93
|
+
} catch {
|
|
94
|
+
return size;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `flush`: send the open last line too (the writer is idle, the task ended,
|
|
99
|
+
// or the bytes were already sent to the room).
|
|
100
|
+
async function sendRange(target, entry, start, end, ended, { flush = true } = {}) {
|
|
101
|
+
let offset = start;
|
|
102
|
+
for (let sent = 0; sent < MAX_CHUNKS_PER_TICK && offset < end; sent += 1) {
|
|
103
|
+
const chunk = await readChunk(entry.path, {
|
|
104
|
+
start: offset,
|
|
105
|
+
maxBytes: Math.min(chunkBytes, end - offset),
|
|
106
|
+
holdOpenLine: !flush,
|
|
107
|
+
longLineBytes: chunkBytes,
|
|
108
|
+
});
|
|
109
|
+
if (!chunk.ok || chunk.endOffset <= offset) break;
|
|
110
|
+
target.emit('task_output_chunk', {
|
|
111
|
+
conversationId: entry.conversationId,
|
|
112
|
+
taskId: entry.taskId,
|
|
113
|
+
offset: chunk.offset,
|
|
114
|
+
endOffset: chunk.endOffset,
|
|
115
|
+
text: chunk.text,
|
|
116
|
+
size: chunk.size,
|
|
117
|
+
mtime: chunk.mtime,
|
|
118
|
+
ended,
|
|
119
|
+
});
|
|
120
|
+
offset = chunk.endOffset;
|
|
121
|
+
}
|
|
122
|
+
return offset;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function poll(room) {
|
|
126
|
+
const entry = watched.get(room);
|
|
127
|
+
if (!entry || entry.busy) return;
|
|
128
|
+
if (roomSize(room) === 0) {
|
|
129
|
+
stopRoom(room);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
entry.busy = true;
|
|
133
|
+
try {
|
|
134
|
+
const found = lookup(entry.conversationId, entry.taskId);
|
|
135
|
+
// The task left both sets (an hour after its end): nothing more comes.
|
|
136
|
+
if (found === null) {
|
|
137
|
+
stopRoom(room);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const ended = !!found?.ended;
|
|
141
|
+
const info = await inspect(entry.path);
|
|
142
|
+
// Not there (yet, or any more): keep waiting.
|
|
143
|
+
if (!info.ok) return;
|
|
144
|
+
const target = io.to(room);
|
|
145
|
+
// Unchanged since the last tick: the writer is idle, an open line goes out.
|
|
146
|
+
const idle = info.size === entry.lastSize;
|
|
147
|
+
entry.lastSize = info.size;
|
|
148
|
+
if (info.size < entry.offset) {
|
|
149
|
+
entry.offset = await safeOffset(entry.path, info.size);
|
|
150
|
+
target.emit('task_output_chunk', {
|
|
151
|
+
conversationId: entry.conversationId,
|
|
152
|
+
taskId: entry.taskId,
|
|
153
|
+
offset: entry.offset,
|
|
154
|
+
endOffset: entry.offset,
|
|
155
|
+
text: '',
|
|
156
|
+
size: info.size,
|
|
157
|
+
mtime: info.mtimeMs,
|
|
158
|
+
ended,
|
|
159
|
+
reset: true,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (info.size > entry.offset) {
|
|
164
|
+
entry.offset = await sendRange(target, entry, entry.offset, info.size, ended, { flush: idle || ended });
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
logger?.warn?.(`[task-output] live read failed for task ${String(entry.taskId).slice(0, 12)}: ${error?.message || error}`);
|
|
168
|
+
} finally {
|
|
169
|
+
const still = watched.get(room);
|
|
170
|
+
if (still) still.busy = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function watch(socket, payload) {
|
|
175
|
+
const conversationId = idOf(payload?.conversationId);
|
|
176
|
+
const taskId = idOf(payload?.taskId);
|
|
177
|
+
if (!conversationId || !taskId) return { ok: false, code: 'missing-id' };
|
|
178
|
+
const found = lookup(conversationId, taskId);
|
|
179
|
+
if (found === undefined) return { ok: false, code: 'unavailable' };
|
|
180
|
+
if (!found) return { ok: false, code: 'not-found' };
|
|
181
|
+
const { kind, path } = taskOutputSource(found.task);
|
|
182
|
+
// A snapshot is fetched again by the console, `none` has nothing.
|
|
183
|
+
if (!path) return { ok: false, code: kind === 'snapshot' ? 'snapshot' : 'no-output' };
|
|
184
|
+
let info = await inspect(path);
|
|
185
|
+
// A file not there yet (a spool is named before its first byte) is
|
|
186
|
+
// waited for: the room starts at offset 0.
|
|
187
|
+
if (!info.ok && info.code === 'missing') info = { ok: true, size: 0, mtimeMs: null };
|
|
188
|
+
if (!info.ok) return { ok: false, code: info.code || 'unreadable' };
|
|
189
|
+
|
|
190
|
+
const room = taskOutputRoom(conversationId, taskId);
|
|
191
|
+
if (socket.data?.taskOutputRoom !== room) {
|
|
192
|
+
leave(socket);
|
|
193
|
+
socket.join(room);
|
|
194
|
+
if (socket.data) socket.data.taskOutputRoom = room;
|
|
195
|
+
} else if (!socket.rooms?.has?.(room)) {
|
|
196
|
+
// A recovered socket keeps its data; its rooms may or may not be back.
|
|
197
|
+
socket.join(room);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const requested = Number(payload?.offset);
|
|
201
|
+
const wanted = Number.isInteger(requested) && requested >= 0 && requested <= info.size ? requested : null;
|
|
202
|
+
// Where the page's tail ended, so nothing written between its fetch and
|
|
203
|
+
// this watch is lost; a page far behind (or one that names no offset)
|
|
204
|
+
// starts at a safe place near the end and fetches the tail up to it.
|
|
205
|
+
const startOffset = watched.has(room) ? 0
|
|
206
|
+
: (wanted !== null && info.size - wanted <= CATCH_UP_BYTES ? wanted : await safeOffset(path, info.size));
|
|
207
|
+
let entry = watched.get(room);
|
|
208
|
+
if (!entry) {
|
|
209
|
+
entry = {
|
|
210
|
+
conversationId,
|
|
211
|
+
taskId,
|
|
212
|
+
path,
|
|
213
|
+
offset: startOffset,
|
|
214
|
+
lastSize: info.size,
|
|
215
|
+
timer: null,
|
|
216
|
+
busy: false,
|
|
217
|
+
};
|
|
218
|
+
watched.set(room, entry);
|
|
219
|
+
entry.timer = setIntervalImpl(() => { poll(room).catch(() => {}); }, intervalMs);
|
|
220
|
+
entry.timer?.unref?.();
|
|
221
|
+
} else if (wanted !== null && wanted < entry.offset && entry.offset - wanted <= CATCH_UP_BYTES) {
|
|
222
|
+
// A second page joins behind the room: it gets the gap on its own.
|
|
223
|
+
await sendRange(socket, entry, wanted, entry.offset, !!found.ended);
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, taskId, offset: entry.offset, size: info.size };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function registerSocket(socket) {
|
|
229
|
+
if (!socket || typeof socket.on !== 'function') return;
|
|
230
|
+
socket.on('task_output_watch', (payload, ack) => {
|
|
231
|
+
watch(socket, payload)
|
|
232
|
+
.then((result) => {
|
|
233
|
+
// A refused watch leaves no stale room behind: the page shows another task now.
|
|
234
|
+
if (!result.ok) leave(socket);
|
|
235
|
+
safeAck(ack, result);
|
|
236
|
+
})
|
|
237
|
+
.catch(() => {
|
|
238
|
+
leave(socket);
|
|
239
|
+
safeAck(ack, { ok: false, code: 'unavailable' });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
socket.on('task_output_unwatch', (_payload, ack) => {
|
|
243
|
+
leave(socket);
|
|
244
|
+
safeAck(ack, { ok: true });
|
|
245
|
+
});
|
|
246
|
+
// socket.io takes a closed socket out of its rooms; the poller stops at
|
|
247
|
+
// its next tick when that emptied the room.
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
registerSocket,
|
|
252
|
+
/** Rooms with a poller, for tests and diagnostics. */
|
|
253
|
+
watchedRooms: () => [...watched.keys()],
|
|
254
|
+
poll,
|
|
255
|
+
stop() {
|
|
256
|
+
for (const room of [...watched.keys()]) stopRoom(room);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|