@oar-sh/oar 0.9.11 → 0.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +18 -4
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -12,16 +12,89 @@
|
|
|
12
12
|
* post). `reported` remembers which workers this runtime has heard from
|
|
13
13
|
* about their tasks at all: until then "no set" means "not told yet", not
|
|
14
14
|
* "no tasks".
|
|
15
|
+
*
|
|
16
|
+
* Ended tasks: a task that leaves the set while it names an `outputPath` (a
|
|
17
|
+
* shell or Monitor task with a console) is kept as an ended row for an hour,
|
|
18
|
+
* at most 20 per key, so the panel can still show how it ended and open its
|
|
19
|
+
* output. A settle post (`settle`) says how; one that arrives before the set
|
|
20
|
+
* drops the task is kept until it does. Memory only, like the sets.
|
|
21
|
+
*
|
|
22
|
+
* Health: the health poller's last reading per live task (`setHealth`), served
|
|
23
|
+
* with the task rows (`withHealth`) and carried onto the ended row, where the
|
|
24
|
+
* poller's reading after the end replaces it (`setEndedHealth`).
|
|
25
|
+
*
|
|
26
|
+
* Across a restart (`persistence`, the `background_task_rows` table): the
|
|
27
|
+
* console rows survive, the live sets do not. Ended rows come back as they
|
|
28
|
+
* were, for the rest of their hour. A live task with a console comes back
|
|
29
|
+
* held (`restored`): its console stays readable, but no panel, state or
|
|
30
|
+
* suspend check counts it as running, because only the worker can say that.
|
|
31
|
+
* The worker's next set settles it: a task in the set is live again, one not
|
|
32
|
+
* in it ended while the relay was away. A worker that never reports (it died
|
|
33
|
+
* with the relay) has its held tasks marked ended (`endUnconfirmed`), never
|
|
34
|
+
* running again. Health readings are not stored: the poller reads the files
|
|
35
|
+
* again. Sub-agent rows have no console and are not stored either.
|
|
15
36
|
*/
|
|
16
37
|
|
|
17
38
|
import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
39
|
+
import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
|
|
18
40
|
|
|
19
41
|
function keyOf(value) {
|
|
20
42
|
return String(value || '').trim();
|
|
21
43
|
}
|
|
22
44
|
|
|
23
|
-
|
|
45
|
+
const ENDED_STATUSES = new Set(['completed', 'failed', 'stopped', 'unknown']);
|
|
46
|
+
|
|
47
|
+
/** A worker's word for how a task ended, as one of completed, failed, stopped, unknown. */
|
|
48
|
+
export function normalizeEndedTaskStatus(status, exitCode = null) {
|
|
49
|
+
const text = String(status || '').trim().toLowerCase();
|
|
50
|
+
if (ENDED_STATUSES.has(text)) return text;
|
|
51
|
+
if (['complete', 'done', 'success', 'succeeded', 'ok', 'exited'].includes(text)) {
|
|
52
|
+
return Number.isInteger(exitCode) && exitCode !== 0 ? 'failed' : 'completed';
|
|
53
|
+
}
|
|
54
|
+
if (['error', 'errored', 'failure'].includes(text)) return 'failed';
|
|
55
|
+
if (['killed', 'cancelled', 'canceled', 'aborted', 'interrupted', 'timeout', 'timed_out'].includes(text)) return 'stopped';
|
|
56
|
+
if (Number.isInteger(exitCode)) return exitCode === 0 ? 'completed' : 'failed';
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function exitCodeOf(value) {
|
|
61
|
+
if (value === null || value === undefined || value === '') return null;
|
|
62
|
+
const number = Number(value);
|
|
63
|
+
return Number.isInteger(number) ? number : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A stored live row is confirmed at most this often while its worker's
|
|
67
|
+
// digest agrees (its `updated_at`), and one not confirmed for this long is a
|
|
68
|
+
// leftover of a worker that went away without a word: not restored.
|
|
69
|
+
const LIVE_TOUCH_EVERY_MS = 30 * 60_000;
|
|
70
|
+
const LIVE_STALE_MS = 6 * 60 * 60_000;
|
|
71
|
+
|
|
72
|
+
// What of a live console task is stored: what the console and an ended row
|
|
73
|
+
// need, not what changes with every progress post.
|
|
74
|
+
const STORED_LIVE_FIELDS = ['taskId', 'taskType', 'description', 'command', 'startedAt', 'outputPath', 'outputKind', 'stoppable', 'toolUseId'];
|
|
75
|
+
|
|
76
|
+
function storedLiveRow(task, ahead) {
|
|
77
|
+
const row = {};
|
|
78
|
+
for (const field of STORED_LIVE_FIELDS) if (task?.[field] !== undefined && task?.[field] !== null) row[field] = task[field];
|
|
79
|
+
// How it ended, told before the set dropped it.
|
|
80
|
+
if (ahead) row.settled = { status: ahead.status, exitCode: ahead.exitCode, ...(ahead.steps !== undefined ? { steps: ahead.steps } : {}) };
|
|
81
|
+
return row;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createBackgroundTaskStore({
|
|
85
|
+
now = () => Date.now(),
|
|
86
|
+
endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
|
|
87
|
+
endedMax = TASK_OUTPUT_LIMITS.endedMax,
|
|
88
|
+
persistence = null,
|
|
89
|
+
} = {}) {
|
|
24
90
|
const sets = new Map();
|
|
91
|
+
// key → Map(taskId → ended row), newest last.
|
|
92
|
+
const endedRows = new Map();
|
|
93
|
+
// key → Map(taskId → { status, exitCode, outputPath, at }): how a task
|
|
94
|
+
// ended, told before the set dropped it.
|
|
95
|
+
const settledAhead = new Map();
|
|
96
|
+
// key → Map(taskId → the health poller's last reading).
|
|
97
|
+
const healthByKey = new Map();
|
|
25
98
|
const reported = new Set();
|
|
26
99
|
// key → the order of the newest set applied, `{ epoch, seq }` as the
|
|
27
100
|
// worker numbered it. `epoch` names the worker process; it is compared
|
|
@@ -32,6 +105,18 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
|
|
|
32
105
|
const holders = new Map();
|
|
33
106
|
// Keys whose pages were told of the placeholder for unreported work.
|
|
34
107
|
const announced = new Set();
|
|
108
|
+
// key → Map(taskId → row): live console tasks of the runtime before, held
|
|
109
|
+
// until the worker's set says what became of them.
|
|
110
|
+
const restored = new Map();
|
|
111
|
+
// key → what was stored of its live console tasks last, to write only on change.
|
|
112
|
+
const storedLive = new Map();
|
|
113
|
+
// key → when its stored live rows were last written or confirmed.
|
|
114
|
+
const touchedAt = new Map();
|
|
115
|
+
// key → Map(taskId → when the runtime before last knew the held task running).
|
|
116
|
+
const heldSince = new Map();
|
|
117
|
+
const store = persistence && persistence.available !== false ? persistence : null;
|
|
118
|
+
|
|
119
|
+
restore();
|
|
35
120
|
|
|
36
121
|
return {
|
|
37
122
|
sets,
|
|
@@ -39,11 +124,206 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
|
|
|
39
124
|
const id = keyOf(conversationId);
|
|
40
125
|
if (!id) return;
|
|
41
126
|
reported.add(id);
|
|
42
|
-
|
|
43
|
-
|
|
127
|
+
const next = Array.isArray(tasks) ? tasks : [];
|
|
128
|
+
const nextIds = new Set(next.map((task) => keyOf(task?.taskId)).filter(Boolean));
|
|
129
|
+
// Tasks that left the set with a console become ended rows.
|
|
130
|
+
for (const task of sets.get(id)?.tasks || []) {
|
|
131
|
+
const taskId = keyOf(task?.taskId);
|
|
132
|
+
if (!taskId || nextIds.has(taskId) || !task?.outputPath) continue;
|
|
133
|
+
const ahead = settledAhead.get(id)?.get(taskId) || null;
|
|
134
|
+
addEnded(id, {
|
|
135
|
+
...task,
|
|
136
|
+
...(ahead?.steps !== undefined ? { steps: ahead.steps } : {}),
|
|
137
|
+
status: ahead?.status || 'unknown',
|
|
138
|
+
exitCode: ahead ? ahead.exitCode : (task.exitCode ?? null),
|
|
139
|
+
health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Tasks held since the restart: the worker's set says which still run
|
|
143
|
+
// (their console is the one they had; the first path a task names
|
|
144
|
+
// wins), and the others ended while the relay was away.
|
|
145
|
+
const held = restored.get(id);
|
|
146
|
+
if (held) {
|
|
147
|
+
restored.delete(id);
|
|
148
|
+
for (const [taskId, row] of held) {
|
|
149
|
+
const live = next.find((task) => keyOf(task?.taskId) === taskId);
|
|
150
|
+
if (live) {
|
|
151
|
+
if (row.outputPath && live.outputPath !== row.outputPath) {
|
|
152
|
+
live.outputPath = row.outputPath;
|
|
153
|
+
live.outputKind = row.outputKind;
|
|
154
|
+
}
|
|
155
|
+
if (row.settled && !settledAhead.get(id)?.has(taskId)) {
|
|
156
|
+
let ahead = settledAhead.get(id);
|
|
157
|
+
if (!ahead) settledAhead.set(id, ahead = new Map());
|
|
158
|
+
ahead.set(taskId, { ...row.settled, at: now() });
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
endRestored(id, row);
|
|
163
|
+
}
|
|
164
|
+
heldSince.delete(id);
|
|
165
|
+
}
|
|
166
|
+
// A task back in the set (a resync after a restart) is not ended.
|
|
167
|
+
for (const taskId of nextIds) {
|
|
168
|
+
if (endedRows.get(id)?.delete(taskId)) store?.remove(id, taskId);
|
|
169
|
+
settledAhead.get(id)?.delete(taskId);
|
|
170
|
+
}
|
|
171
|
+
const health = healthByKey.get(id);
|
|
172
|
+
if (health) {
|
|
173
|
+
for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
|
|
174
|
+
if (!health.size) healthByKey.delete(id);
|
|
175
|
+
}
|
|
176
|
+
if (next.length === 0) sets.delete(id);
|
|
177
|
+
else sets.set(id, { tasks: next, updatedAt: now() });
|
|
178
|
+
storeLive(id);
|
|
179
|
+
},
|
|
180
|
+
/**
|
|
181
|
+
* How a task ended (`/api/background-task-settled`): updates its ended
|
|
182
|
+
* row, or is kept for the moment the set drops it, or, for a task this
|
|
183
|
+
* runtime never saw live that names its output, becomes an ended row of
|
|
184
|
+
* its own. Returns the ended row when there is one.
|
|
185
|
+
*/
|
|
186
|
+
settle(conversationId, taskId, { status, exitCode, outputPath, description, command, taskType, startedAt, outputKind, steps } = {}) {
|
|
187
|
+
const id = keyOf(conversationId);
|
|
188
|
+
const task = keyOf(taskId);
|
|
189
|
+
if (!id || !task) return null;
|
|
190
|
+
prune(id);
|
|
191
|
+
const code = exitCodeOf(exitCode);
|
|
192
|
+
const normalized = normalizeEndedTaskStatus(status, code);
|
|
193
|
+
const stepCount = Number.isInteger(steps) && steps >= 0 ? steps : undefined;
|
|
194
|
+
const existing = endedRows.get(id)?.get(task);
|
|
195
|
+
if (existing) {
|
|
196
|
+
existing.status = normalized;
|
|
197
|
+
existing.exitCode = code;
|
|
198
|
+
if (!existing.outputPath && outputPath) {
|
|
199
|
+
existing.outputPath = outputPath;
|
|
200
|
+
if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
|
|
201
|
+
}
|
|
202
|
+
if (stepCount !== undefined) existing.steps = stepCount;
|
|
203
|
+
store?.saveEnded(id, existing);
|
|
204
|
+
return existing;
|
|
205
|
+
}
|
|
206
|
+
// A task held since the restart that ended while the relay was away
|
|
207
|
+
// (the worker sends the settle again): it is not running any more.
|
|
208
|
+
const heldRow = restored.get(id)?.get(task);
|
|
209
|
+
if (heldRow) {
|
|
210
|
+
restored.get(id).delete(task);
|
|
211
|
+
if (!restored.get(id).size) restored.delete(id);
|
|
212
|
+
return endRestored(id, {
|
|
213
|
+
...heldRow,
|
|
214
|
+
settled: { status: normalized, exitCode: code, ...(stepCount !== undefined ? { steps: stepCount } : {}) },
|
|
215
|
+
...(!heldRow.outputPath && outputPath ? { outputPath, outputKind: outputKind === 'spool' ? 'spool' : 'file' } : {}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
|
|
219
|
+
if (live) {
|
|
220
|
+
let ahead = settledAhead.get(id);
|
|
221
|
+
if (!ahead) settledAhead.set(id, ahead = new Map());
|
|
222
|
+
ahead.set(task, { status: normalized, exitCode: code, steps: stepCount, at: now() });
|
|
223
|
+
// The first path a task names wins. A row that had none was read
|
|
224
|
+
// from a snapshot (or not at all): from now on it is the file.
|
|
225
|
+
if (outputPath && !live.outputPath) {
|
|
226
|
+
live.outputPath = outputPath;
|
|
227
|
+
if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
|
|
228
|
+
}
|
|
229
|
+
storeLive(id);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (!outputPath) return null;
|
|
233
|
+
return addEnded(id, {
|
|
234
|
+
taskId: task,
|
|
235
|
+
taskType: keyOf(taskType) || 'local_bash',
|
|
236
|
+
description: String(description || '').trim().slice(0, 500),
|
|
237
|
+
...(command ? { command } : {}),
|
|
238
|
+
startedAt: Number(startedAt) || null,
|
|
239
|
+
outputPath,
|
|
240
|
+
outputKind: outputKind || 'file',
|
|
241
|
+
stoppable: false,
|
|
242
|
+
status: normalized,
|
|
243
|
+
exitCode: code,
|
|
244
|
+
...(stepCount !== undefined ? { steps: stepCount } : {}),
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
/** The ended rows of these keys, newest first. */
|
|
248
|
+
ended(...keys) {
|
|
249
|
+
const rows = [];
|
|
250
|
+
for (const id of new Set(keys.map(keyOf).filter(Boolean))) {
|
|
251
|
+
prune(id);
|
|
252
|
+
rows.push(...(endedRows.get(id)?.values() || []));
|
|
253
|
+
}
|
|
254
|
+
return rows.sort((a, b) => b.endedAt - a.endedAt).map((row) => ({ ...row }));
|
|
255
|
+
},
|
|
256
|
+
/** The poller's last reading for a live task, or null. */
|
|
257
|
+
health(conversationId, taskId) {
|
|
258
|
+
return healthByKey.get(keyOf(conversationId))?.get(keyOf(taskId)) || null;
|
|
259
|
+
},
|
|
260
|
+
setHealth(conversationId, taskId, value) {
|
|
261
|
+
const id = keyOf(conversationId);
|
|
262
|
+
const task = keyOf(taskId);
|
|
263
|
+
if (!id || !task) return;
|
|
264
|
+
let health = healthByKey.get(id);
|
|
265
|
+
if (!value) {
|
|
266
|
+
health?.delete(task);
|
|
44
267
|
return;
|
|
45
268
|
}
|
|
46
|
-
|
|
269
|
+
if (!health) healthByKey.set(id, health = new Map());
|
|
270
|
+
health.set(task, value);
|
|
271
|
+
},
|
|
272
|
+
/** The keys that hold ended rows. */
|
|
273
|
+
endedKeys() {
|
|
274
|
+
return [...endedRows.keys()];
|
|
275
|
+
},
|
|
276
|
+
/**
|
|
277
|
+
* The poller's reading of an ended task's file, taken after the end: the
|
|
278
|
+
* last output (a test runner's summary) often lands between two readings
|
|
279
|
+
* of the live task. Returns false when there is no such row.
|
|
280
|
+
*/
|
|
281
|
+
setEndedHealth(conversationId, taskId, value) {
|
|
282
|
+
const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
|
|
283
|
+
if (!row || !value) return false;
|
|
284
|
+
// Not stored: the poller reads the file again after a restart.
|
|
285
|
+
row.health = value;
|
|
286
|
+
return true;
|
|
287
|
+
},
|
|
288
|
+
/** Task rows with the poller's last reading on each as `health`. */
|
|
289
|
+
withHealth(tasks, ...keys) {
|
|
290
|
+
const ids = keys.map(keyOf).filter(Boolean);
|
|
291
|
+
return (Array.isArray(tasks) ? tasks : []).map((task) => {
|
|
292
|
+
const taskId = keyOf(task?.taskId);
|
|
293
|
+
const health = ids.map((id) => healthByKey.get(id)?.get(taskId)).find(Boolean);
|
|
294
|
+
return health ? { ...task, health } : task;
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
/**
|
|
298
|
+
* A task of these keys, live or ended: `{ key, task, ended }` or null.
|
|
299
|
+
* The live set wins over an ended row.
|
|
300
|
+
*/
|
|
301
|
+
findTask(keys, taskId) {
|
|
302
|
+
const task = keyOf(taskId);
|
|
303
|
+
const ids = [...new Set((Array.isArray(keys) ? keys : [keys]).map(keyOf).filter(Boolean))];
|
|
304
|
+
if (!task) return null;
|
|
305
|
+
for (const id of ids) {
|
|
306
|
+
const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
|
|
307
|
+
if (live) {
|
|
308
|
+
const health = healthByKey.get(id)?.get(task);
|
|
309
|
+
return { key: id, task: health ? { ...live, health } : live, ended: false };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const id of ids) {
|
|
313
|
+
prune(id);
|
|
314
|
+
const row = endedRows.get(id)?.get(task);
|
|
315
|
+
if (row) return { key: id, task: { ...row }, ended: true };
|
|
316
|
+
}
|
|
317
|
+
// Held since the restart: its console is readable while the worker
|
|
318
|
+
// has not reported yet.
|
|
319
|
+
for (const id of ids) {
|
|
320
|
+
const row = restored.get(id)?.get(task);
|
|
321
|
+
if (row) {
|
|
322
|
+
const { settled: _settled, ...rest } = row;
|
|
323
|
+
return { key: id, task: { ...rest }, ended: false, restored: true };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
47
327
|
},
|
|
48
328
|
get(conversationId) {
|
|
49
329
|
return sets.get(keyOf(conversationId))?.tasks || [];
|
|
@@ -58,8 +338,20 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
|
|
|
58
338
|
* they agree. Agreement counts as a report, an empty set included.
|
|
59
339
|
*/
|
|
60
340
|
matches(digest, ...keys) {
|
|
341
|
+
// Tasks held since the restart are settled only by a set the worker
|
|
342
|
+
// sends: until then the worker is asked for it, whatever its digest.
|
|
343
|
+
if (keys.map(keyOf).some((key) => key && restored.has(key))) return false;
|
|
61
344
|
const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
|
|
62
|
-
if (same)
|
|
345
|
+
if (same) {
|
|
346
|
+
for (const key of keys.map(keyOf).filter(Boolean)) {
|
|
347
|
+
reported.add(key);
|
|
348
|
+
// Its stored rows are still running: say so now and then, so a
|
|
349
|
+
// restart can tell them from a dead worker's leftovers.
|
|
350
|
+
if (store && storedLive.get(key) && now() - (touchedAt.get(key) || 0) >= LIVE_TOUCH_EVERY_MS) {
|
|
351
|
+
if (store.touchLive?.(key)) touchedAt.set(key, now());
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
63
355
|
return same;
|
|
64
356
|
},
|
|
65
357
|
/** Whether this runtime has heard from the worker about its tasks. */
|
|
@@ -114,15 +406,187 @@ export function createBackgroundTaskStore({ now = () => Date.now() } = {}) {
|
|
|
114
406
|
else announced.delete(id);
|
|
115
407
|
return true;
|
|
116
408
|
},
|
|
409
|
+
/**
|
|
410
|
+
* Read only: every task held since the restart, as `{ key, task }`
|
|
411
|
+
* (the stored row). Not running for any panel or check until the
|
|
412
|
+
* worker's set confirms it.
|
|
413
|
+
*/
|
|
414
|
+
listRestored() {
|
|
415
|
+
const rows = [];
|
|
416
|
+
for (const [key, held] of restored) {
|
|
417
|
+
for (const row of held.values()) {
|
|
418
|
+
const { settled, ...task } = row;
|
|
419
|
+
rows.push({ key, task });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return rows;
|
|
423
|
+
},
|
|
424
|
+
/** Whether tasks of these keys are held since the restart. */
|
|
425
|
+
hasRestored(...keys) {
|
|
426
|
+
return keys.map(keyOf).some((key) => key && restored.has(key));
|
|
427
|
+
},
|
|
428
|
+
/**
|
|
429
|
+
* The workers that did not report since the restart: their held tasks
|
|
430
|
+
* are marked ended (how is unknown), never running again. Returns the
|
|
431
|
+
* keys whose ended rows changed.
|
|
432
|
+
*/
|
|
433
|
+
endUnconfirmed() {
|
|
434
|
+
const changed = [];
|
|
435
|
+
for (const [id, held] of [...restored]) {
|
|
436
|
+
restored.delete(id);
|
|
437
|
+
for (const row of held.values()) endRestored(id, row);
|
|
438
|
+
storeLive(id);
|
|
439
|
+
changed.push(id);
|
|
440
|
+
}
|
|
441
|
+
return changed;
|
|
442
|
+
},
|
|
443
|
+
/**
|
|
444
|
+
* Drops expired ended rows and pending settles of every key, also of
|
|
445
|
+
* conversations nobody reads again (the per-key prune runs on reads).
|
|
446
|
+
* Called from the health poller's tick.
|
|
447
|
+
*/
|
|
448
|
+
pruneAll() {
|
|
449
|
+
for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
|
|
450
|
+
store?.pruneEnded(now() - endedKeepMs);
|
|
451
|
+
},
|
|
117
452
|
/** The conversation is gone: everything held for these keys goes. */
|
|
118
453
|
forget(...keys) {
|
|
119
|
-
|
|
454
|
+
const ids = keys.map(keyOf).filter(Boolean);
|
|
455
|
+
if (ids.length) store?.forget(ids);
|
|
456
|
+
for (const id of ids) {
|
|
457
|
+
restored.delete(id);
|
|
458
|
+
heldSince.delete(id);
|
|
459
|
+
touchedAt.delete(id);
|
|
460
|
+
storedLive.delete(id);
|
|
120
461
|
sets.delete(id);
|
|
121
462
|
reported.delete(id);
|
|
122
463
|
applied.delete(id);
|
|
123
464
|
holders.delete(id);
|
|
124
465
|
announced.delete(id);
|
|
466
|
+
endedRows.delete(id);
|
|
467
|
+
settledAhead.delete(id);
|
|
468
|
+
healthByKey.delete(id);
|
|
125
469
|
}
|
|
126
470
|
},
|
|
127
471
|
};
|
|
472
|
+
|
|
473
|
+
// The rows the runtime before kept: ended rows for the rest of their
|
|
474
|
+
// hour, live console tasks held for the worker's word.
|
|
475
|
+
function restore() {
|
|
476
|
+
if (!store) return;
|
|
477
|
+
const cutoff = now() - endedKeepMs;
|
|
478
|
+
const ended = [];
|
|
479
|
+
for (const entry of store.load()) {
|
|
480
|
+
const id = keyOf(entry.key);
|
|
481
|
+
const taskId = keyOf(entry.taskId);
|
|
482
|
+
if (!id || !taskId) continue;
|
|
483
|
+
if (entry.state === 'ended') {
|
|
484
|
+
const endedAt = Number(entry.endedAt ?? entry.row.endedAt);
|
|
485
|
+
if (!Number.isFinite(endedAt) || endedAt < cutoff) continue;
|
|
486
|
+
ended.push({ id, row: { ...entry.row, taskId, endedAt, stoppable: false } });
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (entry.state !== 'live') continue;
|
|
490
|
+
// Not confirmed for hours: its worker went away in a runtime before
|
|
491
|
+
// without a word. Nothing to show, now or as an ended row.
|
|
492
|
+
const since = Number(entry.updatedAt);
|
|
493
|
+
if (!Number.isFinite(since) || now() - since > LIVE_STALE_MS) {
|
|
494
|
+
store.remove(id, taskId);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
let held = restored.get(id);
|
|
498
|
+
if (!held) restored.set(id, held = new Map());
|
|
499
|
+
held.set(taskId, { ...entry.row, taskId });
|
|
500
|
+
let sinceOf = heldSince.get(id);
|
|
501
|
+
if (!sinceOf) heldSince.set(id, sinceOf = new Map());
|
|
502
|
+
sinceOf.set(taskId, Math.min(since, now()));
|
|
503
|
+
storedLive.set(id, null);
|
|
504
|
+
}
|
|
505
|
+
// Oldest first, as `addEnded` keeps them.
|
|
506
|
+
ended.sort((a, b) => a.row.endedAt - b.row.endedAt);
|
|
507
|
+
for (const { id, row } of ended) {
|
|
508
|
+
let rows = endedRows.get(id);
|
|
509
|
+
if (!rows) endedRows.set(id, rows = new Map());
|
|
510
|
+
rows.set(row.taskId, row);
|
|
511
|
+
while (rows.size > endedMax) rows.delete(rows.keys().next().value);
|
|
512
|
+
}
|
|
513
|
+
store.pruneEnded(cutoff);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// A held task that is not running any more becomes an ended row, with how
|
|
517
|
+
// it ended when the runtime before was told.
|
|
518
|
+
// It ended at the latest when the runtime before last knew it running, so
|
|
519
|
+
// that is its end time: a leftover from long ago does not count as fresh.
|
|
520
|
+
function endRestored(id, row) {
|
|
521
|
+
const { settled, ...task } = row;
|
|
522
|
+
const since = heldSince.get(id)?.get(keyOf(task.taskId));
|
|
523
|
+
heldSince.get(id)?.delete(keyOf(task.taskId));
|
|
524
|
+
if (!heldSince.get(id)?.size) heldSince.delete(id);
|
|
525
|
+
return addEnded(id, {
|
|
526
|
+
...task,
|
|
527
|
+
...(settled?.steps !== undefined ? { steps: settled.steps } : {}),
|
|
528
|
+
status: settled?.status || 'unknown',
|
|
529
|
+
exitCode: settled ? settled.exitCode : null,
|
|
530
|
+
}, { endedAt: Number.isFinite(since) ? since : undefined });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Stores the key's live console tasks when they changed (what is stored,
|
|
534
|
+
// not every progress post).
|
|
535
|
+
function storeLive(id) {
|
|
536
|
+
if (!store) return;
|
|
537
|
+
const ahead = settledAhead.get(id);
|
|
538
|
+
const rows = (sets.get(id)?.tasks || [])
|
|
539
|
+
.filter((task) => task?.outputPath && keyOf(task?.taskId))
|
|
540
|
+
.map((task) => storedLiveRow(task, ahead?.get(keyOf(task.taskId))));
|
|
541
|
+
// Held tasks stay stored until the worker's set settles them.
|
|
542
|
+
for (const row of restored.get(id)?.values() || []) rows.push(row);
|
|
543
|
+
const signature = JSON.stringify(rows);
|
|
544
|
+
if (storedLive.get(id) === signature) return;
|
|
545
|
+
if (!rows.length && !storedLive.has(id)) return;
|
|
546
|
+
if (store.saveLive(id, rows)) {
|
|
547
|
+
touchedAt.set(id, now());
|
|
548
|
+
if (rows.length) storedLive.set(id, signature);
|
|
549
|
+
else storedLive.delete(id);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function prune(id) {
|
|
554
|
+
const cutoff = now() - endedKeepMs;
|
|
555
|
+
const rows = endedRows.get(id);
|
|
556
|
+
if (rows) {
|
|
557
|
+
for (const [taskId, row] of rows) if (row.endedAt < cutoff) rows.delete(taskId);
|
|
558
|
+
if (!rows.size) endedRows.delete(id);
|
|
559
|
+
}
|
|
560
|
+
const ahead = settledAhead.get(id);
|
|
561
|
+
if (ahead) {
|
|
562
|
+
for (const [taskId, entry] of ahead) if (entry.at < cutoff) ahead.delete(taskId);
|
|
563
|
+
if (!ahead.size) settledAhead.delete(id);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function addEnded(id, task, { endedAt = now() } = {}) {
|
|
568
|
+
let rows = endedRows.get(id);
|
|
569
|
+
if (!rows) endedRows.set(id, rows = new Map());
|
|
570
|
+
const taskId = keyOf(task.taskId);
|
|
571
|
+
rows.delete(taskId);
|
|
572
|
+
const row = {
|
|
573
|
+
...task,
|
|
574
|
+
taskId,
|
|
575
|
+
status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
|
|
576
|
+
exitCode: exitCodeOf(task.exitCode),
|
|
577
|
+
endedAt,
|
|
578
|
+
stoppable: false,
|
|
579
|
+
};
|
|
580
|
+
if (!row.health) delete row.health;
|
|
581
|
+
rows.set(taskId, row);
|
|
582
|
+
settledAhead.get(id)?.delete(taskId);
|
|
583
|
+
store?.saveEnded(id, row);
|
|
584
|
+
while (rows.size > endedMax) {
|
|
585
|
+
const oldest = rows.keys().next().value;
|
|
586
|
+
rows.delete(oldest);
|
|
587
|
+
store?.remove(id, oldest);
|
|
588
|
+
}
|
|
589
|
+
prune(id);
|
|
590
|
+
return row;
|
|
591
|
+
}
|
|
128
592
|
}
|
|
@@ -37,10 +37,6 @@ const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
|
|
|
37
37
|
// differently-sized terminal this degrades to the pre-wrap behaviour.
|
|
38
38
|
const PTY_WRAP_COLUMNS = 80;
|
|
39
39
|
|
|
40
|
-
function normalizeText(value) {
|
|
41
|
-
return String(value == null ? '' : value).trim();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
40
|
/**
|
|
45
41
|
* Removes OSC sequences (including OSC-8 hyperlink wrappers), ANSI CSI
|
|
46
42
|
* sequences and the CR the PTY adds to every line, leaving the human-visible
|
|
@@ -74,23 +70,10 @@ export function joinWrappedLines(value) {
|
|
|
74
70
|
return joined.join('\n');
|
|
75
71
|
}
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
* URL get caught by the generic rule too, which is fine: the URL is already
|
|
82
|
-
* carried in the login payload.
|
|
83
|
-
*/
|
|
84
|
-
export function scrubSecrets(text, submittedCode = '') {
|
|
85
|
-
let output = String(text == null ? '' : text);
|
|
86
|
-
const code = normalizeText(submittedCode);
|
|
87
|
-
if (code.length >= 4) {
|
|
88
|
-
output = output.split(code).join('[redacted]');
|
|
89
|
-
}
|
|
90
|
-
return output
|
|
91
|
-
.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[redacted]')
|
|
92
|
-
.replace(/[A-Za-z0-9_-]{32,}/g, '[redacted]');
|
|
93
|
-
}
|
|
73
|
+
// Redacts anything token-shaped plus an exact secret the caller handed over.
|
|
74
|
+
// The rules live in the shared text pipeline, which also scrubs background
|
|
75
|
+
// task output (browser-safe, so it cannot import this module).
|
|
76
|
+
export { scrubSecrets } from '../../shared/task-output-text.mjs';
|
|
94
77
|
|
|
95
78
|
/** Collapses captured output into one short single-line tail for an error. */
|
|
96
79
|
export function tailOf(text, limit = MAX_ERROR_TAIL_CHARS) {
|