@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * "After a relay restart, tell interrupted sessions to carry on" (opt-in,
5
+ * per relay, off by default).
6
+ *
7
+ * Session workers outlive the relay's runtime, and a turn whose worker lived
8
+ * through the restart simply goes on. A turn whose worker did not (it died
9
+ * with the relay, or came back without the turn) is dealt with by the
10
+ * recovery paths, and most of them requeue it: the row runs again by itself,
11
+ * and nothing more is needed. A few cannot run again, because the prompt
12
+ * already reached the agent and a second delivery would run it twice:
13
+ *
14
+ * - a background continuation (a turn the agent opened itself), torn down;
15
+ * - a steer the CLI had already taken (`consumed_at`), failed;
16
+ * - a turn that had started (activity, a stream) when its live worker no
17
+ * longer claimed it, failed instead of replayed (`turn-replay-blocked`).
18
+ *
19
+ * The agent never hears that its turn stopped. With the setting on, such a
20
+ * session gets ONE note of the relay's in the conversation once its rows are
21
+ * settled: the relay restarted, the turn was interrupted, check what you
22
+ * were doing and carry on. The user's message is never sent again.
23
+ *
24
+ * Which turns count: the rows that were `processing` when this runtime
25
+ * started (`captureBoot`), followed through the recovery paths
26
+ * (`noteOutcome`). A row its worker claims after the start (a heartbeat
27
+ * that lists it: the worker lived through the restart and has the turn) is
28
+ * the restart's no more: whatever ends it later is not put down to it.
29
+ * A conversation gets a note when one of its rows was cut off and none was
30
+ * requeued (that one runs again: a note would double the work), claimed by
31
+ * a worker that lived through the restart, or failed for another reason.
32
+ * Nothing for idle sessions, nothing when
33
+ * the setting is off when the note would be sent, nothing for an archived or
34
+ * deleted conversation, nothing while a usage-limit follow-up is held (it
35
+ * carries the turn on itself), at most one note per conversation and
36
+ * runtime. The note is an ordinary pending row, queued behind whatever the
37
+ * user has queued already, so it never jumps ahead of a user message; its
38
+ * delivery starts the session's worker as for any message.
39
+ */
40
+
41
+ import { randomUUID } from 'node:crypto';
42
+
43
+ export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
44
+ export const RESTART_RESUME_KIND = 'relay-restart-resume';
45
+ export const RESTART_RESUME_LEAD = 'Automatic message from OAR, not from the user:';
46
+
47
+ // How long after the start a cut-off turn is still put down to the restart.
48
+ const DEFAULT_WINDOW_MS = 30 * 60_000;
49
+
50
+ /** The stored value as on or off; off unless it was switched on. */
51
+ export function readRestartResumeSetting(raw) {
52
+ return ['1', 'true', 'on'].includes(String(raw ?? '').trim().toLowerCase());
53
+ }
54
+
55
+ export function parseRestartResumeUpdate(value) {
56
+ if (typeof value !== 'boolean') return { ok: false, error: 'enabled must be true or false' };
57
+ return { ok: true, enabled: value };
58
+ }
59
+
60
+ function defaultFormatTime(ms) {
61
+ const at = new Date(Number(ms));
62
+ if (!Number.isFinite(at.getTime())) return 'just now';
63
+ return `${at.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
64
+ }
65
+
66
+ /** The note's text. */
67
+ export function buildRestartResumeNoteText({ restartedAt, formatTime = defaultFormatTime } = {}) {
68
+ return `${RESTART_RESUME_LEAD} the relay restarted at ${formatTime(restartedAt)}, and the turn you were working on was interrupted before it finished. `
69
+ + 'Check where you were (the files you changed, the commands and background tasks you started) and carry on from there. '
70
+ + 'Do not repeat steps that already completed; if the work was already done, say so briefly.';
71
+ }
72
+
73
+ const OUTCOMES = new Set(['cut', 'requeued', 'failed', 'claimed']);
74
+
75
+ function text(value) {
76
+ return String(value ?? '').trim();
77
+ }
78
+
79
+ export function createRestartResumeService({
80
+ db,
81
+ stmts,
82
+ isEnabled = () => false,
83
+ emit = () => {},
84
+ wake = () => {},
85
+ now = () => Date.now(),
86
+ bootAt = now(),
87
+ windowMs = DEFAULT_WINDOW_MS,
88
+ formatTime = defaultFormatTime,
89
+ logger = console,
90
+ } = {}) {
91
+ if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
92
+ const listProcessing = db.prepare(`
93
+ SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
94
+ owner_sdk_session_id, runtime_session_id
95
+ FROM queue WHERE status = 'processing'
96
+ `);
97
+ const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
98
+ let heldPause = null;
99
+ try {
100
+ // A held usage-limit follow-up (usage-limit-pause-service.mjs).
101
+ heldPause = db.prepare(`
102
+ SELECT 1 FROM queue
103
+ WHERE conversation_id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
104
+ LIMIT 1
105
+ `);
106
+ } catch {}
107
+
108
+ // queue id → conversation id, for the rows processing at the start.
109
+ const watched = new Map();
110
+ // conversation id → { rows: Map(queue id → { row, outcome }) }
111
+ const conversations = new Map();
112
+ // Conversations that had their note in this runtime.
113
+ const noted = new Set();
114
+
115
+ function log(line) {
116
+ try { logger?.log?.(`[restart-resume] ${line}`); } catch {}
117
+ }
118
+
119
+ function expired() {
120
+ return now() - bootAt > windowMs;
121
+ }
122
+
123
+ /** The rows processing when this runtime started; returns how many. */
124
+ function captureBoot() {
125
+ let rows = [];
126
+ try { rows = listProcessing.all(); } catch {}
127
+ for (const row of rows) {
128
+ const conversationId = text(row.conversation_id);
129
+ const id = text(row.id);
130
+ if (!conversationId || !id) continue;
131
+ watched.set(id, conversationId);
132
+ let entry = conversations.get(conversationId);
133
+ if (!entry) conversations.set(conversationId, entry = { rows: new Map() });
134
+ entry.rows.set(id, { row, outcome: null });
135
+ }
136
+ if (rows.length) log(`${rows.length} turn(s) were running when the relay started; following them`);
137
+ return rows.length;
138
+ }
139
+
140
+ /**
141
+ * What a recovery path did with a row: `cut` (it cannot run again),
142
+ * `requeued` (it runs again by itself) or `failed` (ended for another
143
+ * reason); or `claimed`: its worker, alive after the restart, holds it.
144
+ * Only the rows of the start count, and only their first outcome.
145
+ */
146
+ function noteOutcome(row, outcome) {
147
+ const id = text(row?.id ?? row);
148
+ const conversationId = watched.get(id);
149
+ if (!conversationId || !OUTCOMES.has(outcome)) return false;
150
+ watched.delete(id);
151
+ const entry = conversations.get(conversationId)?.rows.get(id);
152
+ if (entry && !entry.outcome) entry.outcome = outcome;
153
+ settle(conversationId);
154
+ return true;
155
+ }
156
+
157
+ // Decides a conversation once none of its rows of the start is processing.
158
+ function settle(conversationId) {
159
+ const entry = conversations.get(conversationId);
160
+ if (!entry) return;
161
+ for (const [id, item] of entry.rows) {
162
+ if (item.outcome) continue;
163
+ let status = '';
164
+ try { status = text(statusOf.get(id)?.status).toLowerCase(); } catch {}
165
+ if (status === 'processing') return;
166
+ // Back in the queue by a path that did not say so: it runs again.
167
+ item.outcome = status === 'pending' || status === 'parked' ? 'requeued' : 'settled';
168
+ watched.delete(id);
169
+ }
170
+ conversations.delete(conversationId);
171
+ const outcomes = [...entry.rows.values()].map((item) => item.outcome);
172
+ if (!outcomes.includes('cut')) return;
173
+ if (outcomes.includes('requeued')) {
174
+ log(`conv=${conversationId.slice(0, 8)} no note: the interrupted turn runs again by itself`);
175
+ return;
176
+ }
177
+ if (outcomes.includes('claimed')) {
178
+ log(`conv=${conversationId.slice(0, 8)} no note: its worker lived through the restart and has its turn`);
179
+ return;
180
+ }
181
+ if (outcomes.includes('failed')) {
182
+ log(`conv=${conversationId.slice(0, 8)} no note: the turn failed, the failure says what to do`);
183
+ return;
184
+ }
185
+ const cut = [...entry.rows.values()].find((item) => item.outcome === 'cut')?.row || null;
186
+ sendNote(conversationId, cut);
187
+ }
188
+
189
+ function skip(conversationId, why) {
190
+ log(`conv=${conversationId.slice(0, 8)} no note: ${why}`);
191
+ return null;
192
+ }
193
+
194
+ function sendNote(conversationId, cutRow) {
195
+ if (noted.has(conversationId)) return skip(conversationId, 'it had one after this restart');
196
+ let enabled = false;
197
+ try { enabled = isEnabled() === true; } catch {}
198
+ if (!enabled) return skip(conversationId, 'the setting is off');
199
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
200
+ if (!conversation || text(conversation.status) === 'deleted') return skip(conversationId, 'the conversation is gone');
201
+ if (Number(conversation.archived) === 1) return skip(conversationId, 'the conversation is archived');
202
+ try {
203
+ if (heldPause?.get(conversationId)) return skip(conversationId, 'a usage-limit follow-up carries the turn on');
204
+ } catch {}
205
+ noted.add(conversationId);
206
+
207
+ const at = new Date(now()).toISOString();
208
+ const messageId = randomUUID();
209
+ const noteText = buildRestartResumeNoteText({ restartedAt: bootAt, formatTime });
210
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
211
+ const latestModel = stmts.getLatestConversationModel?.get?.(conversationId)?.model || null;
212
+ // The model and mode the interrupted turn ran with.
213
+ const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
214
+ const modelVariant = text(cutRow?.model_variant_id) || model;
215
+ const relayMode = text(cutRow?.relay_mode) || 'agent';
216
+ const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
217
+ const write = db.transaction(() => {
218
+ stmts.insertMsg.run(
219
+ messageId,
220
+ conversationId,
221
+ 'user',
222
+ noteText,
223
+ modelVariant || null,
224
+ relayMode,
225
+ null,
226
+ at,
227
+ modelVariant || null,
228
+ null,
229
+ text(modelVariant).toLowerCase() === 'auto' ? 'auto' : 'manual',
230
+ );
231
+ stmts.setMessageKind?.run?.(RESTART_RESUME_KIND, messageId);
232
+ stmts.updateConvTime?.run?.(at, conversationId);
233
+ const queueArgs = [
234
+ messageId,
235
+ conversationId,
236
+ text(cutRow?.runtime_session_id) || runtimeSession?.id || null,
237
+ 0,
238
+ model,
239
+ modelVariant || null,
240
+ text(cutRow?.reasoning_effort) || null,
241
+ text(cutRow?.context_tier) || null,
242
+ relayMode,
243
+ noteText,
244
+ null,
245
+ at,
246
+ owner,
247
+ owner ? at : null,
248
+ null,
249
+ null,
250
+ ];
251
+ if (stmts.queueHasImageOperationId || stmts.insertQ?.source?.includes?.('image_operation_id')) queueArgs.push(null);
252
+ stmts.insertQ.run(...queueArgs);
253
+ });
254
+ try {
255
+ write();
256
+ } catch (error) {
257
+ noted.delete(conversationId);
258
+ try { logger?.warn?.(`[restart-resume] conv=${conversationId.slice(0, 8)} note not queued: ${error?.message || error}`); } catch {}
259
+ return null;
260
+ }
261
+ try {
262
+ emit('user_message', {
263
+ conversationId,
264
+ messageId,
265
+ senderClientId: null,
266
+ message: {
267
+ role: 'user',
268
+ text: noteText,
269
+ model: modelVariant || null,
270
+ mode: relayMode,
271
+ attachments: [],
272
+ timestamp: at,
273
+ kind: RESTART_RESUME_KIND,
274
+ },
275
+ });
276
+ } catch {}
277
+ try { emit('message_status', { messageId, conversationId, status: 'pending' }); } catch {}
278
+ try { wake({ conversationId, ownerSessionId: owner, messageId }); } catch {}
279
+ log(`conv=${conversationId.slice(0, 8)} note queued: its turn was cut off by the restart`);
280
+ return messageId;
281
+ }
282
+
283
+ /** Decides the conversations whose rows settled on their own; ends the watch after the window. */
284
+ function tick() {
285
+ if (!conversations.size) return;
286
+ if (expired()) {
287
+ watched.clear();
288
+ conversations.clear();
289
+ return;
290
+ }
291
+ for (const conversationId of [...conversations.keys()]) settle(conversationId);
292
+ }
293
+
294
+ return {
295
+ captureBoot,
296
+ noteOutcome,
297
+ tick,
298
+ /** What is still followed, for tests and diagnostics. */
299
+ pending() {
300
+ return [...conversations.keys()];
301
+ },
302
+ };
303
+ }
@@ -249,7 +249,9 @@ export function createSessionActivityTracker({
249
249
  };
250
250
  }
251
251
 
252
- function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true, absent = false } = {}) {
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
- if (settled.summary) ended.summary = settled.summary.slice(0, 500);
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;
@@ -296,6 +296,8 @@ export function createSessionWorkerProcessInspector({
296
296
  platform = process.platform,
297
297
  execFileSyncImpl = execFileSync,
298
298
  execFileImpl = execFile,
299
+ // Where Windows keeps PowerShell (the bare name when it is not there).
300
+ systemToolPath = windowsSystemToolPath,
299
301
  now = () => Date.now(),
300
302
  snapshotCacheMs = SNAPSHOT_CACHE_MS,
301
303
  slowReadMs = SLOW_READ_MS,
@@ -320,7 +322,7 @@ export function createSessionWorkerProcessInspector({
320
322
 
321
323
  function getWindowsProcessSnapshot() {
322
324
  if (platform !== 'win32') return [];
323
- const output = execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
325
+ const output = execFileSyncImpl(systemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
324
326
  // stderr is kept (not shown): a failed read reports it.
325
327
  stdio: ['ignore', 'pipe', 'pipe'],
326
328
  windowsHide: true,
@@ -376,7 +378,7 @@ export function createSessionWorkerProcessInspector({
376
378
  if (platform !== 'win32') return Promise.resolve([]);
377
379
  return new Promise((resolve, reject) => {
378
380
  execFileImpl(
379
- windowsSystemToolPath('powershell.exe'),
381
+ systemToolPath('powershell.exe'),
380
382
  ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
381
383
  { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
382
384
  (error, stdout, stderr) => {
@@ -545,7 +547,7 @@ export function createSessionWorkerProcessInspector({
545
547
  function stopWindowsPids(pids) {
546
548
  const ids = uniquePids(pids);
547
549
  if (!ids.length) return [];
548
- execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
550
+ execFileSyncImpl(systemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
549
551
  stdio: ['ignore', 'ignore', 'ignore'],
550
552
  });
551
553
  return ids;
@@ -557,7 +559,7 @@ export function createSessionWorkerProcessInspector({
557
559
  if (!ids.length) return Promise.resolve([]);
558
560
  return new Promise((resolve, reject) => {
559
561
  execFileImpl(
560
- windowsSystemToolPath('powershell.exe'),
562
+ systemToolPath('powershell.exe'),
561
563
  ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)],
562
564
  { windowsHide: true, timeout: WINDOWS_STOP_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
563
565
  (error) => {
@@ -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
+ }