@oar-sh/oar 0.9.10 → 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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- 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 +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -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 +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- 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/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- 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/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- 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 +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live background-task sets, published by the session workers (REPLACE
|
|
5
|
+
* semantics, mirroring the SDK's background_tasks_changed signal) and keyed
|
|
6
|
+
* by the id the worker posts, its SDK session id.
|
|
7
|
+
*
|
|
8
|
+
* Memory only, and a relay restart starts it empty while the workers and
|
|
9
|
+
* their tasks keep running. The worker is the source of truth: its heartbeat
|
|
10
|
+
* carries a digest of its task ids, the relay compares it with the set held
|
|
11
|
+
* here and asks for the full set when the two differ (a restart, a lost
|
|
12
|
+
* post). `reported` remembers which workers this runtime has heard from
|
|
13
|
+
* about their tasks at all: until then "no set" means "not told yet", not
|
|
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
|
+
|
|
27
|
+
import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
28
|
+
import { TASK_OUTPUT_LIMITS } from '../../shared/task-output-text.mjs';
|
|
29
|
+
|
|
30
|
+
function keyOf(value) {
|
|
31
|
+
return String(value || '').trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ENDED_STATUSES = new Set(['completed', 'failed', 'stopped', 'unknown']);
|
|
35
|
+
|
|
36
|
+
/** A worker's word for how a task ended, as one of completed, failed, stopped, unknown. */
|
|
37
|
+
export function normalizeEndedTaskStatus(status, exitCode = null) {
|
|
38
|
+
const text = String(status || '').trim().toLowerCase();
|
|
39
|
+
if (ENDED_STATUSES.has(text)) return text;
|
|
40
|
+
if (['complete', 'done', 'success', 'succeeded', 'ok', 'exited'].includes(text)) {
|
|
41
|
+
return Number.isInteger(exitCode) && exitCode !== 0 ? 'failed' : 'completed';
|
|
42
|
+
}
|
|
43
|
+
if (['error', 'errored', 'failure'].includes(text)) return 'failed';
|
|
44
|
+
if (['killed', 'cancelled', 'canceled', 'aborted', 'interrupted', 'timeout', 'timed_out'].includes(text)) return 'stopped';
|
|
45
|
+
if (Number.isInteger(exitCode)) return exitCode === 0 ? 'completed' : 'failed';
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function exitCodeOf(value) {
|
|
50
|
+
if (value === null || value === undefined || value === '') return null;
|
|
51
|
+
const number = Number(value);
|
|
52
|
+
return Number.isInteger(number) ? number : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createBackgroundTaskStore({
|
|
56
|
+
now = () => Date.now(),
|
|
57
|
+
endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
|
|
58
|
+
endedMax = TASK_OUTPUT_LIMITS.endedMax,
|
|
59
|
+
} = {}) {
|
|
60
|
+
const sets = new Map();
|
|
61
|
+
// key → Map(taskId → ended row), newest last.
|
|
62
|
+
const endedRows = new Map();
|
|
63
|
+
// key → Map(taskId → { status, exitCode, outputPath, at }): how a task
|
|
64
|
+
// ended, told before the set dropped it.
|
|
65
|
+
const settledAhead = new Map();
|
|
66
|
+
// key → Map(taskId → the health poller's last reading).
|
|
67
|
+
const healthByKey = new Map();
|
|
68
|
+
const reported = new Set();
|
|
69
|
+
// key → the order of the newest set applied, `{ epoch, seq }` as the
|
|
70
|
+
// worker numbered it. `epoch` names the worker process; it is compared
|
|
71
|
+
// for equality only, never as a time.
|
|
72
|
+
const applied = new Map();
|
|
73
|
+
// key → the pid of the worker process whose heartbeat came last: the
|
|
74
|
+
// process that holds the session, by the worker's own word.
|
|
75
|
+
const holders = new Map();
|
|
76
|
+
// Keys whose pages were told of the placeholder for unreported work.
|
|
77
|
+
const announced = new Set();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sets,
|
|
81
|
+
replace(conversationId, tasks) {
|
|
82
|
+
const id = keyOf(conversationId);
|
|
83
|
+
if (!id) return;
|
|
84
|
+
reported.add(id);
|
|
85
|
+
const next = Array.isArray(tasks) ? tasks : [];
|
|
86
|
+
const nextIds = new Set(next.map((task) => keyOf(task?.taskId)).filter(Boolean));
|
|
87
|
+
// Tasks that left the set with a console become ended rows.
|
|
88
|
+
for (const task of sets.get(id)?.tasks || []) {
|
|
89
|
+
const taskId = keyOf(task?.taskId);
|
|
90
|
+
if (!taskId || nextIds.has(taskId) || !task?.outputPath) continue;
|
|
91
|
+
const ahead = settledAhead.get(id)?.get(taskId) || null;
|
|
92
|
+
addEnded(id, {
|
|
93
|
+
...task,
|
|
94
|
+
...(ahead?.steps !== undefined ? { steps: ahead.steps } : {}),
|
|
95
|
+
status: ahead?.status || 'unknown',
|
|
96
|
+
exitCode: ahead ? ahead.exitCode : (task.exitCode ?? null),
|
|
97
|
+
health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// A task back in the set (a resync after a restart) is not ended.
|
|
101
|
+
for (const taskId of nextIds) {
|
|
102
|
+
endedRows.get(id)?.delete(taskId);
|
|
103
|
+
settledAhead.get(id)?.delete(taskId);
|
|
104
|
+
}
|
|
105
|
+
const health = healthByKey.get(id);
|
|
106
|
+
if (health) {
|
|
107
|
+
for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
|
|
108
|
+
if (!health.size) healthByKey.delete(id);
|
|
109
|
+
}
|
|
110
|
+
if (next.length === 0) {
|
|
111
|
+
sets.delete(id);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
sets.set(id, { tasks: next, updatedAt: now() });
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* How a task ended (`/api/background-task-settled`): updates its ended
|
|
118
|
+
* row, or is kept for the moment the set drops it, or, for a task this
|
|
119
|
+
* runtime never saw live that names its output, becomes an ended row of
|
|
120
|
+
* its own. Returns the ended row when there is one.
|
|
121
|
+
*/
|
|
122
|
+
settle(conversationId, taskId, { status, exitCode, outputPath, description, taskType, startedAt, outputKind, steps } = {}) {
|
|
123
|
+
const id = keyOf(conversationId);
|
|
124
|
+
const task = keyOf(taskId);
|
|
125
|
+
if (!id || !task) return null;
|
|
126
|
+
prune(id);
|
|
127
|
+
const code = exitCodeOf(exitCode);
|
|
128
|
+
const normalized = normalizeEndedTaskStatus(status, code);
|
|
129
|
+
const stepCount = Number.isInteger(steps) && steps >= 0 ? steps : undefined;
|
|
130
|
+
const existing = endedRows.get(id)?.get(task);
|
|
131
|
+
if (existing) {
|
|
132
|
+
existing.status = normalized;
|
|
133
|
+
existing.exitCode = code;
|
|
134
|
+
if (!existing.outputPath && outputPath) {
|
|
135
|
+
existing.outputPath = outputPath;
|
|
136
|
+
if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
|
|
137
|
+
}
|
|
138
|
+
if (stepCount !== undefined) existing.steps = stepCount;
|
|
139
|
+
return existing;
|
|
140
|
+
}
|
|
141
|
+
const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
|
|
142
|
+
if (live) {
|
|
143
|
+
let ahead = settledAhead.get(id);
|
|
144
|
+
if (!ahead) settledAhead.set(id, ahead = new Map());
|
|
145
|
+
ahead.set(task, { status: normalized, exitCode: code, steps: stepCount, at: now() });
|
|
146
|
+
// The first path a task names wins. A row that had none was read
|
|
147
|
+
// from a snapshot (or not at all): from now on it is the file.
|
|
148
|
+
if (outputPath && !live.outputPath) {
|
|
149
|
+
live.outputPath = outputPath;
|
|
150
|
+
if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!outputPath) return null;
|
|
155
|
+
return addEnded(id, {
|
|
156
|
+
taskId: task,
|
|
157
|
+
taskType: keyOf(taskType) || 'local_bash',
|
|
158
|
+
description: String(description || '').trim().slice(0, 500),
|
|
159
|
+
startedAt: Number(startedAt) || null,
|
|
160
|
+
outputPath,
|
|
161
|
+
outputKind: outputKind || 'file',
|
|
162
|
+
stoppable: false,
|
|
163
|
+
status: normalized,
|
|
164
|
+
exitCode: code,
|
|
165
|
+
...(stepCount !== undefined ? { steps: stepCount } : {}),
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
/** The ended rows of these keys, newest first. */
|
|
169
|
+
ended(...keys) {
|
|
170
|
+
const rows = [];
|
|
171
|
+
for (const id of new Set(keys.map(keyOf).filter(Boolean))) {
|
|
172
|
+
prune(id);
|
|
173
|
+
rows.push(...(endedRows.get(id)?.values() || []));
|
|
174
|
+
}
|
|
175
|
+
return rows.sort((a, b) => b.endedAt - a.endedAt).map((row) => ({ ...row }));
|
|
176
|
+
},
|
|
177
|
+
/** The poller's last reading for a live task, or null. */
|
|
178
|
+
health(conversationId, taskId) {
|
|
179
|
+
return healthByKey.get(keyOf(conversationId))?.get(keyOf(taskId)) || null;
|
|
180
|
+
},
|
|
181
|
+
setHealth(conversationId, taskId, value) {
|
|
182
|
+
const id = keyOf(conversationId);
|
|
183
|
+
const task = keyOf(taskId);
|
|
184
|
+
if (!id || !task) return;
|
|
185
|
+
let health = healthByKey.get(id);
|
|
186
|
+
if (!value) {
|
|
187
|
+
health?.delete(task);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!health) healthByKey.set(id, health = new Map());
|
|
191
|
+
health.set(task, value);
|
|
192
|
+
},
|
|
193
|
+
/** The keys that hold ended rows. */
|
|
194
|
+
endedKeys() {
|
|
195
|
+
return [...endedRows.keys()];
|
|
196
|
+
},
|
|
197
|
+
/**
|
|
198
|
+
* The poller's reading of an ended task's file, taken after the end: the
|
|
199
|
+
* last output (a test runner's summary) often lands between two readings
|
|
200
|
+
* of the live task. Returns false when there is no such row.
|
|
201
|
+
*/
|
|
202
|
+
setEndedHealth(conversationId, taskId, value) {
|
|
203
|
+
const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
|
|
204
|
+
if (!row || !value) return false;
|
|
205
|
+
row.health = value;
|
|
206
|
+
return true;
|
|
207
|
+
},
|
|
208
|
+
/** Task rows with the poller's last reading on each as `health`. */
|
|
209
|
+
withHealth(tasks, ...keys) {
|
|
210
|
+
const ids = keys.map(keyOf).filter(Boolean);
|
|
211
|
+
return (Array.isArray(tasks) ? tasks : []).map((task) => {
|
|
212
|
+
const taskId = keyOf(task?.taskId);
|
|
213
|
+
const health = ids.map((id) => healthByKey.get(id)?.get(taskId)).find(Boolean);
|
|
214
|
+
return health ? { ...task, health } : task;
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* A task of these keys, live or ended: `{ key, task, ended }` or null.
|
|
219
|
+
* The live set wins over an ended row.
|
|
220
|
+
*/
|
|
221
|
+
findTask(keys, taskId) {
|
|
222
|
+
const task = keyOf(taskId);
|
|
223
|
+
const ids = [...new Set((Array.isArray(keys) ? keys : [keys]).map(keyOf).filter(Boolean))];
|
|
224
|
+
if (!task) return null;
|
|
225
|
+
for (const id of ids) {
|
|
226
|
+
const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
|
|
227
|
+
if (live) {
|
|
228
|
+
const health = healthByKey.get(id)?.get(task);
|
|
229
|
+
return { key: id, task: health ? { ...live, health } : live, ended: false };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const id of ids) {
|
|
233
|
+
prune(id);
|
|
234
|
+
const row = endedRows.get(id)?.get(task);
|
|
235
|
+
if (row) return { key: id, task: { ...row }, ended: true };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
},
|
|
239
|
+
get(conversationId) {
|
|
240
|
+
return sets.get(keyOf(conversationId))?.tasks || [];
|
|
241
|
+
},
|
|
242
|
+
/** The digest of the set held under the first of `keys` that has one. */
|
|
243
|
+
digestFor(...keys) {
|
|
244
|
+
const held = keys.map(keyOf).find((key) => key && sets.has(key));
|
|
245
|
+
return backgroundTaskDigest((held ? sets.get(held).tasks : []).map((task) => task?.taskId));
|
|
246
|
+
},
|
|
247
|
+
/**
|
|
248
|
+
* A worker's heartbeat digest against the set held for it: true when
|
|
249
|
+
* they agree. Agreement counts as a report, an empty set included.
|
|
250
|
+
*/
|
|
251
|
+
matches(digest, ...keys) {
|
|
252
|
+
const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
|
|
253
|
+
if (same) for (const key of keys.map(keyOf).filter(Boolean)) reported.add(key);
|
|
254
|
+
return same;
|
|
255
|
+
},
|
|
256
|
+
/** Whether this runtime has heard from the worker about its tasks. */
|
|
257
|
+
hasReport(...keys) {
|
|
258
|
+
return keys.map(keyOf).some((key) => key && reported.has(key));
|
|
259
|
+
},
|
|
260
|
+
/**
|
|
261
|
+
* Whether a set with this order may be applied, as `{ accepted, reason }`.
|
|
262
|
+
* Accepting records it. `seq` is the worker process's count of set
|
|
263
|
+
* posts, `epoch` names that process. A set without an order, from a
|
|
264
|
+
* worker that numbers none, always applies.
|
|
265
|
+
*
|
|
266
|
+
* Refused are:
|
|
267
|
+
* - `older`: same process, a count not above the newest applied. The
|
|
268
|
+
* post was overtaken on the way.
|
|
269
|
+
* - `not-holder`: the post's `pid` is not the pid of the process whose
|
|
270
|
+
* heartbeat came last for the session (`noteHolder`): a late post of
|
|
271
|
+
* the process before it, or a new process that has not beaten yet,
|
|
272
|
+
* which is told so and beats.
|
|
273
|
+
*
|
|
274
|
+
* A different epoch from the holder, or from a process that cannot be
|
|
275
|
+
* placed (no pid on the post, no heartbeat yet), is a new process and
|
|
276
|
+
* starts the count over, whichever of the two epochs is the larger: a clock that
|
|
277
|
+
* stepped back between two starts must not turn every set away. A late
|
|
278
|
+
* post that slips through that way is put right by the next heartbeat's
|
|
279
|
+
* digest.
|
|
280
|
+
*/
|
|
281
|
+
acceptOrder(conversationId, order, { pid = null } = {}) {
|
|
282
|
+
const id = keyOf(conversationId);
|
|
283
|
+
const epoch = Number(order?.epoch);
|
|
284
|
+
const seq = Number(order?.seq);
|
|
285
|
+
if (!id || !Number.isFinite(epoch) || !Number.isInteger(seq) || seq < 0) return { accepted: true, reason: null };
|
|
286
|
+
const holder = holders.get(id);
|
|
287
|
+
const from = Number(pid);
|
|
288
|
+
if (holder && Number.isInteger(from) && from > 0 && from !== holder) return { accepted: false, reason: 'not-holder' };
|
|
289
|
+
const last = applied.get(id);
|
|
290
|
+
if (last && epoch === last.epoch && seq <= last.seq) return { accepted: false, reason: 'older' };
|
|
291
|
+
applied.set(id, { epoch, seq });
|
|
292
|
+
return { accepted: true, reason: null };
|
|
293
|
+
},
|
|
294
|
+
/** A heartbeat of the session's worker came from this process. */
|
|
295
|
+
noteHolder(conversationId, pid) {
|
|
296
|
+
const id = keyOf(conversationId);
|
|
297
|
+
const from = Number(pid);
|
|
298
|
+
if (id && Number.isInteger(from) && from > 0) holders.set(id, from);
|
|
299
|
+
},
|
|
300
|
+
/** Records whether the pages know of the key's placeholder; true when that changed. */
|
|
301
|
+
announce(conversationId, shown) {
|
|
302
|
+
const id = keyOf(conversationId);
|
|
303
|
+
if (!id || announced.has(id) === !!shown) return false;
|
|
304
|
+
if (shown) announced.add(id);
|
|
305
|
+
else announced.delete(id);
|
|
306
|
+
return true;
|
|
307
|
+
},
|
|
308
|
+
/** The conversation is gone: everything held for these keys goes. */
|
|
309
|
+
/**
|
|
310
|
+
* Drops expired ended rows and pending settles of every key, also of
|
|
311
|
+
* conversations nobody reads again (the per-key prune runs on reads).
|
|
312
|
+
* Called from the health poller's tick.
|
|
313
|
+
*/
|
|
314
|
+
pruneAll() {
|
|
315
|
+
for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
|
|
316
|
+
},
|
|
317
|
+
forget(...keys) {
|
|
318
|
+
for (const id of keys.map(keyOf).filter(Boolean)) {
|
|
319
|
+
sets.delete(id);
|
|
320
|
+
reported.delete(id);
|
|
321
|
+
applied.delete(id);
|
|
322
|
+
holders.delete(id);
|
|
323
|
+
announced.delete(id);
|
|
324
|
+
endedRows.delete(id);
|
|
325
|
+
settledAhead.delete(id);
|
|
326
|
+
healthByKey.delete(id);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
function prune(id) {
|
|
332
|
+
const cutoff = now() - endedKeepMs;
|
|
333
|
+
const rows = endedRows.get(id);
|
|
334
|
+
if (rows) {
|
|
335
|
+
for (const [taskId, row] of rows) if (row.endedAt < cutoff) rows.delete(taskId);
|
|
336
|
+
if (!rows.size) endedRows.delete(id);
|
|
337
|
+
}
|
|
338
|
+
const ahead = settledAhead.get(id);
|
|
339
|
+
if (ahead) {
|
|
340
|
+
for (const [taskId, entry] of ahead) if (entry.at < cutoff) ahead.delete(taskId);
|
|
341
|
+
if (!ahead.size) settledAhead.delete(id);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function addEnded(id, task) {
|
|
346
|
+
let rows = endedRows.get(id);
|
|
347
|
+
if (!rows) endedRows.set(id, rows = new Map());
|
|
348
|
+
const taskId = keyOf(task.taskId);
|
|
349
|
+
rows.delete(taskId);
|
|
350
|
+
const row = {
|
|
351
|
+
...task,
|
|
352
|
+
taskId,
|
|
353
|
+
status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
|
|
354
|
+
exitCode: exitCodeOf(task.exitCode),
|
|
355
|
+
endedAt: now(),
|
|
356
|
+
stoppable: false,
|
|
357
|
+
};
|
|
358
|
+
if (!row.health) delete row.health;
|
|
359
|
+
rows.set(taskId, row);
|
|
360
|
+
settledAhead.get(id)?.delete(taskId);
|
|
361
|
+
while (rows.size > endedMax) rows.delete(rows.keys().next().value);
|
|
362
|
+
prune(id);
|
|
363
|
+
return row;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the relay can say about background work whose details it may not
|
|
5
|
+
* hold, because its task store and activity tracker live in memory and
|
|
6
|
+
* start empty at every runtime start while the workers keep running:
|
|
7
|
+
*
|
|
8
|
+
* - the placeholder: a live worker that says it has background work (the
|
|
9
|
+
* Claude worker's `autoCompactWindow.backgroundWork` on its heartbeat)
|
|
10
|
+
* while this runtime was never told its task set. Workers that answer a
|
|
11
|
+
* resync request fill the real set in within one heartbeat; this is for
|
|
12
|
+
* the ones still running older code. It is read, never stored: it goes
|
|
13
|
+
* when the set arrives, when the flag goes false and when the worker is
|
|
14
|
+
* gone, and it raises no start or end event.
|
|
15
|
+
* - the suspend blockers for background work, from the store and from the
|
|
16
|
+
* activity tracker (which also reads running sub-agent runs from the
|
|
17
|
+
* database), and the blocker for a runtime that only just started.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
|
|
22
|
+
UNREPORTED_BACKGROUND_TASK_ID,
|
|
23
|
+
} from './session-activity-tracker.mjs';
|
|
24
|
+
|
|
25
|
+
// Providers whose worker lists every running background sub-agent in its
|
|
26
|
+
// task set, by the run's id. Others may run background agents no set lists
|
|
27
|
+
// (their run rows are their only trace), so a set says nothing about a row.
|
|
28
|
+
const RUN_LISTING_PROVIDERS = new Set(['claude']);
|
|
29
|
+
|
|
30
|
+
function toText(value) {
|
|
31
|
+
return String(value ?? '').trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The placeholder as a row of the task panel: no details, no Stop button. */
|
|
35
|
+
export function unreportedBackgroundTaskCard() {
|
|
36
|
+
return {
|
|
37
|
+
taskId: UNREPORTED_BACKGROUND_TASK_ID,
|
|
38
|
+
taskType: 'unreported',
|
|
39
|
+
description: UNREPORTED_BACKGROUND_TASK_DESCRIPTION,
|
|
40
|
+
startedAt: null,
|
|
41
|
+
stoppable: false,
|
|
42
|
+
placeholder: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createBackgroundWorkView({
|
|
47
|
+
store,
|
|
48
|
+
db = null,
|
|
49
|
+
getRegistry = () => null,
|
|
50
|
+
getTracker = () => null,
|
|
51
|
+
isWorkerAlive = () => false,
|
|
52
|
+
// A conversation's id and its worker's session id are two ids, usually
|
|
53
|
+
// equal and not always. The tracker and the database speak of
|
|
54
|
+
// conversations; the registry, the store and the workers' own headers of
|
|
55
|
+
// sessions. These two map between them through the runtime session.
|
|
56
|
+
sessionIdOf = () => '',
|
|
57
|
+
conversationIdOf = () => '',
|
|
58
|
+
// conversationId → its provider ('claude', 'github', …).
|
|
59
|
+
providerOf = () => '',
|
|
60
|
+
titleOf = (conversationId) => toText(conversationId).slice(0, 8),
|
|
61
|
+
} = {}) {
|
|
62
|
+
let listRunningRuns = null;
|
|
63
|
+
if (db) {
|
|
64
|
+
try {
|
|
65
|
+
listRunningRuns = db.prepare(`
|
|
66
|
+
SELECT DISTINCT conversation_id FROM subagent_runs
|
|
67
|
+
WHERE status = 'running' AND background = 1 AND parent_subagent_id IS NULL
|
|
68
|
+
`);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mapped(resolve, id) {
|
|
73
|
+
try { return toText(resolve(id)); } catch { return ''; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The worker of a session id or of a conversation id. */
|
|
77
|
+
function workerOf(key) {
|
|
78
|
+
const registry = getRegistry();
|
|
79
|
+
const id = toText(key);
|
|
80
|
+
if (!registry || !id) return null;
|
|
81
|
+
// The registry's own indexes first; the mapping is a database read.
|
|
82
|
+
const direct = registry.getWorker?.(id) || registry.getWorkerByConversationId?.(id) || null;
|
|
83
|
+
if (direct) return direct;
|
|
84
|
+
const sessionId = mapped(sessionIdOf, id);
|
|
85
|
+
return (sessionId && sessionId !== id && registry.getWorker?.(sessionId)) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The conversation a worker serves; the registry's own field may hold its session id. */
|
|
89
|
+
function conversationOf(worker, fallback = '') {
|
|
90
|
+
return mapped(conversationIdOf, worker?.sdkSessionId) || toText(worker?.conversationId) || toText(fallback);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function workerAlive(key) {
|
|
94
|
+
const worker = workerOf(key);
|
|
95
|
+
return !!worker && isWorkerAlive(worker.sdkSessionId) === true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the placeholder stands for this worker (by session or conversation id). */
|
|
99
|
+
function hasUnreportedWork(key) {
|
|
100
|
+
const worker = workerOf(key);
|
|
101
|
+
if (!worker || worker.steering?.autoCompactWindow?.backgroundWork !== true) return false;
|
|
102
|
+
const keys = [worker.sdkSessionId, worker.conversationId, conversationOf(worker), key];
|
|
103
|
+
if (store.hasReport(...keys)) return false;
|
|
104
|
+
if (keys.some((entry) => store.get(entry).length > 0)) return false;
|
|
105
|
+
return isWorkerAlive(worker.sdkSessionId) === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function listUnreportedConversationIds() {
|
|
109
|
+
const workers = getRegistry()?.listWorkers?.() || [];
|
|
110
|
+
return workers
|
|
111
|
+
.filter((worker) => worker?.steering?.autoCompactWindow?.backgroundWork === true && hasUnreportedWork(worker.sdkSessionId))
|
|
112
|
+
.map((worker) => conversationOf(worker, worker.sdkSessionId))
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether the task set of a conversation's worker lists a sub-agent run
|
|
118
|
+
* (by the run's id or its task id): true or false, and null where the set
|
|
119
|
+
* cannot answer, because the worker has reported none in this runtime
|
|
120
|
+
* or its provider's sets do not list every background run. The tracker
|
|
121
|
+
* drops a `running` row that is not listed and has long been quiet.
|
|
122
|
+
*/
|
|
123
|
+
function runListed(conversationId, row) {
|
|
124
|
+
const conversation = toText(conversationId);
|
|
125
|
+
if (!RUN_LISTING_PROVIDERS.has(mapped(providerOf, conversation).toLowerCase())) return null;
|
|
126
|
+
const keys = [...new Set([conversation, mapped(sessionIdOf, conversation), toText(workerOf(conversation)?.sdkSessionId)].filter(Boolean))];
|
|
127
|
+
if (!store.hasReport(...keys)) return null;
|
|
128
|
+
const runId = toText(row?.id);
|
|
129
|
+
const taskId = toText(row?.task_id);
|
|
130
|
+
return keys.some((key) => store.get(key).some((task) => (runId && toText(task?.toolUseId) === runId)
|
|
131
|
+
|| (taskId && toText(task?.taskId) === taskId)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The task panel's rows for a conversation: its set, else the placeholder. */
|
|
135
|
+
function panelTasks(...keys) {
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
const tasks = store.get(key);
|
|
138
|
+
if (tasks.length) return tasks;
|
|
139
|
+
}
|
|
140
|
+
return keys.some((key) => toText(key) && hasUnreportedWork(key)) ? [unreportedBackgroundTaskCard()] : [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One `background` blocker per conversation with running background work
|
|
145
|
+
* of a live worker: the tasks of its published set, the running tasks the
|
|
146
|
+
* tracker knows (a background sub-agent run in the database, the
|
|
147
|
+
* placeholder), each task once.
|
|
148
|
+
*/
|
|
149
|
+
function backgroundBlockers() {
|
|
150
|
+
const byConversation = new Map();
|
|
151
|
+
const add = (conversationId, id, description) => {
|
|
152
|
+
const conversation = toText(conversationId);
|
|
153
|
+
if (!conversation) return;
|
|
154
|
+
let tasks = byConversation.get(conversation);
|
|
155
|
+
if (!tasks) byConversation.set(conversation, tasks = new Map());
|
|
156
|
+
const taskId = toText(id) || `task-${tasks.size + 1}`;
|
|
157
|
+
if (!tasks.has(taskId)) tasks.set(taskId, toText(description) || 'task');
|
|
158
|
+
};
|
|
159
|
+
for (const [key, entry] of store.sets) {
|
|
160
|
+
const tasks = Array.isArray(entry?.tasks) ? entry.tasks : [];
|
|
161
|
+
if (!tasks.length || !workerAlive(key)) continue;
|
|
162
|
+
const conversationId = conversationOf(workerOf(key), mapped(conversationIdOf, key) || key);
|
|
163
|
+
for (const task of tasks) add(conversationId, task?.taskId, task?.description || task?.taskType);
|
|
164
|
+
}
|
|
165
|
+
const tracker = getTracker();
|
|
166
|
+
if (tracker) {
|
|
167
|
+
const candidates = new Set();
|
|
168
|
+
try { for (const id of tracker.knownConversationIds?.() || []) candidates.add(toText(id)); } catch {}
|
|
169
|
+
try { for (const row of listRunningRuns?.all() || []) candidates.add(toText(row.conversation_id)); } catch {}
|
|
170
|
+
for (const conversationId of candidates) {
|
|
171
|
+
if (!conversationId || !workerAlive(conversationId)) continue;
|
|
172
|
+
let view = null;
|
|
173
|
+
try { view = tracker.describe(conversationId); } catch {}
|
|
174
|
+
for (const task of view?.tasks || []) {
|
|
175
|
+
// A run inside a turn is the turn's work; the turn is the blocker.
|
|
176
|
+
if (task.status !== 'running' || task.inTurn) continue;
|
|
177
|
+
add(conversationId, task.id, task.description || task.type);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...byConversation].map(([conversationId, tasks]) => {
|
|
182
|
+
// The placeholder's text names the way out; it is not cut short.
|
|
183
|
+
const what = [...tasks].slice(0, 3)
|
|
184
|
+
.map(([id, text]) => (id === UNREPORTED_BACKGROUND_TASK_ID ? text : text.slice(0, 80)))
|
|
185
|
+
.join('; ');
|
|
186
|
+
return {
|
|
187
|
+
kind: 'background',
|
|
188
|
+
conversationId,
|
|
189
|
+
title: titleOf(conversationId),
|
|
190
|
+
count: tasks.size,
|
|
191
|
+
detail: `${tasks.size} background agent${tasks.size === 1 ? '' : 's'}: ${what}`,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The blocker of a runtime that only just started: workers that outlived
|
|
198
|
+
* the one before it may not have reported their background work yet, so
|
|
199
|
+
* "nothing runs" is not known. Null once the window is over. Its text
|
|
200
|
+
* does not change while it stands: every change of a blocker goes out to
|
|
201
|
+
* every page.
|
|
202
|
+
*/
|
|
203
|
+
function bootBlocker() {
|
|
204
|
+
let boot = null;
|
|
205
|
+
try { boot = getTracker()?.bootReporting?.() || null; } catch {}
|
|
206
|
+
if (!boot?.active) return null;
|
|
207
|
+
return {
|
|
208
|
+
kind: 'boot',
|
|
209
|
+
detail: 'the relay just started, sessions are still reporting',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What holds a host suspend back for background work: the two above. */
|
|
214
|
+
function blockers() {
|
|
215
|
+
const boot = bootBlocker();
|
|
216
|
+
return [...backgroundBlockers(), ...(boot ? [boot] : [])];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
hasUnreportedWork,
|
|
221
|
+
listUnreportedConversationIds,
|
|
222
|
+
runListed,
|
|
223
|
+
panelTasks,
|
|
224
|
+
backgroundBlockers,
|
|
225
|
+
bootBlocker,
|
|
226
|
+
blockers,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -62,6 +62,12 @@ export function createCacheRebuildService({
|
|
|
62
62
|
bootstrapRuntimeSessionBindings,
|
|
63
63
|
collectOrphanedUploadsFromConversation,
|
|
64
64
|
deleteOrphanedUploads,
|
|
65
|
+
// (conversationId) => void: a hard-deleted conversation takes its preview
|
|
66
|
+
// links with it, as a delete from the UI does. Archiving keeps them.
|
|
67
|
+
closeConversationPreviews = null,
|
|
68
|
+
// Session groups: a purged head leaves its group, and a wipe is followed
|
|
69
|
+
// by a repair of what the wiped rows headed. Absent in older wiring.
|
|
70
|
+
conversationGroups = null,
|
|
65
71
|
}) {
|
|
66
72
|
const legacyConversationList = buildLegacyConversationList(db);
|
|
67
73
|
const boundConversationList = buildBoundConversationList(db);
|
|
@@ -81,6 +87,7 @@ export function createCacheRebuildService({
|
|
|
81
87
|
if (!sid) return [];
|
|
82
88
|
const orphanedUploads = collectOrphanedUploadsFromConversation(sid);
|
|
83
89
|
db.transaction(() => {
|
|
90
|
+
try { conversationGroups?.forgetConversation?.(sid); } catch {}
|
|
84
91
|
db.prepare(`DELETE FROM relay_questions WHERE conversation_id = ?`).run(sid);
|
|
85
92
|
db.prepare(`DELETE FROM relay_boards WHERE conversation_id = ?`).run(sid);
|
|
86
93
|
db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`).run(sid);
|
|
@@ -91,10 +98,19 @@ export function createCacheRebuildService({
|
|
|
91
98
|
db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(sid);
|
|
92
99
|
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
|
|
93
100
|
})();
|
|
101
|
+
closePreviews(sid);
|
|
94
102
|
deleteOrphanedUploads(orphanedUploads);
|
|
95
103
|
return orphanedUploads;
|
|
96
104
|
}
|
|
97
105
|
|
|
106
|
+
function closePreviews(conversationId) {
|
|
107
|
+
try {
|
|
108
|
+
closeConversationPreviews?.(conversationId);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.warn(`[preview] closing the previews of a purged conversation failed: ${error?.message || error}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
function archiveConversation(conversationId) {
|
|
99
115
|
const sid = normalizeId(conversationId);
|
|
100
116
|
if (!sid) return false;
|
|
@@ -112,6 +128,12 @@ export function createCacheRebuildService({
|
|
|
112
128
|
AND title_source = 'manual'
|
|
113
129
|
`).all().map((row) => normalizeId(row?.id)).filter(Boolean);
|
|
114
130
|
const manualTitleConversationSet = new Set(manualTitleConversations);
|
|
131
|
+
// The groups headed by a row about to go keep the head's title as their
|
|
132
|
+
// name, so a group whose hand-titled members survive is not nameless; the
|
|
133
|
+
// head link stays for a re-import under the same id.
|
|
134
|
+
try {
|
|
135
|
+
conversationGroups?.freezeNamesForHeads?.(conversationIds.filter((id) => !manualTitleConversationSet.has(id)), now);
|
|
136
|
+
} catch {}
|
|
115
137
|
const uploadHashes = [];
|
|
116
138
|
for (const conversationId of conversationIds) {
|
|
117
139
|
for (const hash of collectOrphanedUploadsFromConversation(conversationId)) {
|
|
@@ -147,8 +169,16 @@ export function createCacheRebuildService({
|
|
|
147
169
|
db.prepare(`DELETE FROM sdk_session_imports`).run();
|
|
148
170
|
db.prepare(`DELETE FROM upload_refs`).run();
|
|
149
171
|
db.prepare(`DELETE FROM uploaded_files`).run();
|
|
172
|
+
// Groups whose rows are all gone go too; one whose head was wiped but
|
|
173
|
+
// whose hand-titled members stay keeps the frozen name and the head
|
|
174
|
+
// link (a re-imported head takes it up again).
|
|
175
|
+
try { conversationGroups?.sweepEmpty?.(); } catch {}
|
|
150
176
|
})();
|
|
151
177
|
|
|
178
|
+
for (const conversationId of conversationIds) {
|
|
179
|
+
if (!manualTitleConversationSet.has(conversationId)) closePreviews(conversationId);
|
|
180
|
+
}
|
|
181
|
+
|
|
152
182
|
deleteOrphanedUploads(uploadHashes);
|
|
153
183
|
|
|
154
184
|
if (uploadsDir && fs?.existsSync?.(uploadsDir)) {
|