@oar-sh/oar 0.9.10 → 0.9.11
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 +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- 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/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 +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- 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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- 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 +787 -22
- 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-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- 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 +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -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 +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -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/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -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/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/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -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/worker-websocket-link.mjs +47 -15
|
@@ -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)) {
|
|
@@ -25,7 +25,7 @@ function createSdkDeleteSessionCaller(sdkClient) {
|
|
|
25
25
|
|
|
26
26
|
import { cleanupGeneratedImagesForConversation } from './generated-image-cleanup-service.mjs';
|
|
27
27
|
|
|
28
|
-
export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateRoot = null } = {}) {
|
|
28
|
+
export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateRoot = null, conversationGroups = null } = {}) {
|
|
29
29
|
const callDeleteSession = createSdkDeleteSessionCaller(sdkClient);
|
|
30
30
|
|
|
31
31
|
const stmts = {
|
|
@@ -87,6 +87,9 @@ export function createDeleteArchiveService(db, sdkClient, { resolveSessionStateR
|
|
|
87
87
|
messageRows: stmts.listConversationMessages.all(conversationId),
|
|
88
88
|
resolveSessionStateRoot,
|
|
89
89
|
});
|
|
90
|
+
// A head leaves its session group (title frozen as the name) before its
|
|
91
|
+
// row goes; a group left empty goes with it.
|
|
92
|
+
try { conversationGroups?.forgetConversation?.(conversationId); } catch {}
|
|
90
93
|
stmts.hardDeleteConversation(conversationId);
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Detached-worker watch
|
|
3
|
+
//
|
|
4
|
+
// A worker whose process lives on but whose websocket to the relay is gone
|
|
5
|
+
// gets no deliveries, and the relay spawns no replacement while its process
|
|
6
|
+
// is alive: a message for its session would wait for ever. A worker on Node
|
|
7
|
+
// 22 ended up like that after a relay restart, its one reconnect having met a
|
|
8
|
+
// runtime that was not ready yet.
|
|
9
|
+
//
|
|
10
|
+
// The watch looks at the sessions that have a message waiting. One whose
|
|
11
|
+
// worker process is found alive with no socket, no turn running and no
|
|
12
|
+
// background work is replaced once it has been detached for the grace time:
|
|
13
|
+
// the old process is stopped and a new worker spawned, which takes the
|
|
14
|
+
// waiting message at its hello. The grace time counts from the latest of the
|
|
15
|
+
// watch's start (a relay start), the session's last socket end and its last
|
|
16
|
+
// spawn; a worker still booting gets twice as long.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const DETACHED_WORKER_GRACE_MS = 60_000;
|
|
20
|
+
// A replacement that did not help (the new worker cannot connect either)
|
|
21
|
+
// is not repeated at once.
|
|
22
|
+
export const DETACHED_WORKER_REPLACE_COOLDOWN_MS = 5 * 60_000;
|
|
23
|
+
|
|
24
|
+
function normalizeSessionId(value) {
|
|
25
|
+
return String(value || '').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createDetachedWorkerWatch({
|
|
29
|
+
graceMs = DETACHED_WORKER_GRACE_MS,
|
|
30
|
+
replaceCooldownMs = DETACHED_WORKER_REPLACE_COOLDOWN_MS,
|
|
31
|
+
now = () => Date.now(),
|
|
32
|
+
// Whether the watch may act at all (routing on, no shutdown under way).
|
|
33
|
+
isEnabled = () => true,
|
|
34
|
+
// The session ids that have a message waiting for their worker.
|
|
35
|
+
listWaitingSessionIds = () => [],
|
|
36
|
+
hasWorkerSocket = () => true,
|
|
37
|
+
hasRunningTurn = () => true,
|
|
38
|
+
hasBackgroundWork = () => true,
|
|
39
|
+
// A kill just asked for, or a launch in flight: not the watch's business.
|
|
40
|
+
isLaunchBlocked = () => false,
|
|
41
|
+
// ({ launchMode, launchAtMs, awaitingHeartbeat }) for the session, or null.
|
|
42
|
+
getLaunchState = () => null,
|
|
43
|
+
// Resolves to whether a process of the session's worker is running.
|
|
44
|
+
isWorkerProcessAlive = async () => false,
|
|
45
|
+
// Stops the session's worker processes and spawns a new worker; resolves
|
|
46
|
+
// to { ok, error }.
|
|
47
|
+
replaceWorker = async () => ({ ok: false, error: 'not-configured' }),
|
|
48
|
+
log = () => {},
|
|
49
|
+
} = {}) {
|
|
50
|
+
const startedAtMs = Number(now());
|
|
51
|
+
const socketEndedAtMs = new Map();
|
|
52
|
+
const replacedAtMs = new Map();
|
|
53
|
+
const replacing = new Set();
|
|
54
|
+
let sweepInFlight = false;
|
|
55
|
+
|
|
56
|
+
function nowMs() {
|
|
57
|
+
const value = Number(now());
|
|
58
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function noteSocketEnded(sessionId) {
|
|
62
|
+
const sid = normalizeSessionId(sessionId);
|
|
63
|
+
if (sid) socketEndedAtMs.set(sid, nowMs());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function forgetOld(atMs) {
|
|
67
|
+
const horizon = Math.max(graceMs * 2, replaceCooldownMs);
|
|
68
|
+
for (const [sid, at] of socketEndedAtMs) {
|
|
69
|
+
if (atMs - at > horizon) socketEndedAtMs.delete(sid);
|
|
70
|
+
}
|
|
71
|
+
for (const [sid, at] of replacedAtMs) {
|
|
72
|
+
if (atMs - at > horizon) replacedAtMs.delete(sid);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// How long the session's worker has been detached for the watch's
|
|
77
|
+
// purposes, and the grace it gets.
|
|
78
|
+
function detachedFor(sid, atMs) {
|
|
79
|
+
const launch = getLaunchState(sid) || null;
|
|
80
|
+
const launchAtMs = Number(launch?.launchAtMs || 0);
|
|
81
|
+
const spawnedAtMs = launch?.launchMode === 'spawned' ? launchAtMs : 0;
|
|
82
|
+
const anchorMs = Math.max(startedAtMs, Number(socketEndedAtMs.get(sid) || 0), spawnedAtMs);
|
|
83
|
+
const booting = launch?.launchMode === 'spawned' && launch?.awaitingHeartbeat === true;
|
|
84
|
+
return { detachedMs: atMs - anchorMs, graceMs: booting ? graceMs * 2 : graceMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stillDetached(sid) {
|
|
88
|
+
return !hasWorkerSocket(sid) && !hasRunningTurn(sid) && !hasBackgroundWork(sid) && !isLaunchBlocked(sid);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function considerSession(sid, atMs) {
|
|
92
|
+
if (replacing.has(sid)) return false;
|
|
93
|
+
if (!stillDetached(sid)) return false;
|
|
94
|
+
const lastReplacedAtMs = replacedAtMs.get(sid);
|
|
95
|
+
if (lastReplacedAtMs && atMs - lastReplacedAtMs < replaceCooldownMs) return false;
|
|
96
|
+
const { detachedMs, graceMs: grace } = detachedFor(sid, atMs);
|
|
97
|
+
if (detachedMs < grace) return false;
|
|
98
|
+
if (!(await isWorkerProcessAlive(sid))) return false;
|
|
99
|
+
// The process probe can take a moment (a process list): the worker may
|
|
100
|
+
// have reconnected or taken a turn meanwhile.
|
|
101
|
+
if (!isEnabled() || !stillDetached(sid)) return false;
|
|
102
|
+
|
|
103
|
+
replacing.add(sid);
|
|
104
|
+
replacedAtMs.set(sid, atMs);
|
|
105
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} detachedMs=${detachedMs} — no socket while a message waits; replacing the worker`);
|
|
106
|
+
try {
|
|
107
|
+
const result = await replaceWorker(sid, { detachedMs });
|
|
108
|
+
if (result?.ok) {
|
|
109
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} replaced`);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} replace failed: ${String(result?.error || 'unknown')}`);
|
|
113
|
+
return false;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
log(`WORKER DETACHED session=${sid.slice(0, 8)} replace failed: ${error?.message || error}`);
|
|
116
|
+
return false;
|
|
117
|
+
} finally {
|
|
118
|
+
replacing.delete(sid);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One sweep at a time: a slow process list must not pile sweeps up.
|
|
123
|
+
async function sweep() {
|
|
124
|
+
if (sweepInFlight) return 0;
|
|
125
|
+
if (!isEnabled()) return 0;
|
|
126
|
+
sweepInFlight = true;
|
|
127
|
+
try {
|
|
128
|
+
const atMs = nowMs();
|
|
129
|
+
forgetOld(atMs);
|
|
130
|
+
const sessionIds = [...new Set((listWaitingSessionIds() || []).map(normalizeSessionId).filter(Boolean))];
|
|
131
|
+
let replaced = 0;
|
|
132
|
+
for (const sid of sessionIds) {
|
|
133
|
+
if (!isEnabled()) break;
|
|
134
|
+
try {
|
|
135
|
+
if (await considerSession(sid, atMs)) replaced += 1;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
log(`WORKER DETACHED check failed session=${sid.slice(0, 8)} err=${error?.message || error}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return replaced;
|
|
141
|
+
} finally {
|
|
142
|
+
sweepInFlight = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
noteSocketEnded,
|
|
148
|
+
sweep,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The list of messages a conversation hides from its shared viewers: one
|
|
4
|
+
// short plain-text preview per message, the same shape as the pin list, so
|
|
5
|
+
// the owner's 🙈 list and its count cover messages outside the loaded page.
|
|
6
|
+
// Owner-only: the shared payload never carries it.
|
|
7
|
+
|
|
8
|
+
import { buildPinPreview, countAttachments } from './message-pin-list.mjs';
|
|
9
|
+
|
|
10
|
+
export function buildHiddenMessageList(rows) {
|
|
11
|
+
return (Array.isArray(rows) ? rows : [])
|
|
12
|
+
.filter((row) => String(row?.id || '').trim())
|
|
13
|
+
.map((row) => ({
|
|
14
|
+
messageId: String(row.id).trim(),
|
|
15
|
+
role: String(row.role || '').trim(),
|
|
16
|
+
preview: buildPinPreview(row.text, row.mode),
|
|
17
|
+
timestamp: row.timestamp || null,
|
|
18
|
+
hiddenAt: row.share_hidden_at || null,
|
|
19
|
+
attachmentCount: countAttachments(row.attachments),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The actual suspend-to-RAM call. Windows only: `SetSuspendState 0,0,0` puts
|
|
@@ -25,7 +26,7 @@ export function runHostSuspendToRam({
|
|
|
25
26
|
return { ok: true, command, dryRun: true };
|
|
26
27
|
}
|
|
27
28
|
try {
|
|
28
|
-
const child = spawnImpl('rundll32.exe', ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
29
|
+
const child = spawnImpl(windowsSystemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
|
|
29
30
|
detached: true,
|
|
30
31
|
stdio: 'ignore',
|
|
31
32
|
windowsHide: true,
|
|
@@ -9,7 +9,7 @@ import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
|
|
|
9
9
|
export const MESSAGE_PIN_LIMIT = 100;
|
|
10
10
|
export const PIN_PREVIEW_MAX_LENGTH = 160;
|
|
11
11
|
|
|
12
|
-
function countAttachments(raw) {
|
|
12
|
+
export function countAttachments(raw) {
|
|
13
13
|
if (Array.isArray(raw)) return raw.length;
|
|
14
14
|
if (typeof raw !== 'string' || !raw) return 0;
|
|
15
15
|
try {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import net from 'net';
|
|
4
4
|
|
|
5
|
-
// Liveness for preview cards. Previews never
|
|
5
|
+
// Liveness for preview cards. Previews never expire, so the card has to say
|
|
6
6
|
// whether the dev server behind it is still up — a TCP connect is enough and,
|
|
7
7
|
// unlike an HTTP request, leaves nothing in the app's request log.
|
|
8
8
|
|
|
@@ -55,6 +55,10 @@ export function createPreviewHealthProbe({
|
|
|
55
55
|
// A slow sweep must not overlap itself; with the registry capped at 8
|
|
56
56
|
// previews and a 250ms connect budget, one pass is bounded at ~2s anyway.
|
|
57
57
|
if (sweeping) return;
|
|
58
|
+
// Store writes that failed (a locked database) are retried on this beat,
|
|
59
|
+
// so the table catches up with memory without a timer of its own, and
|
|
60
|
+
// rows a restore could not judge get their check again.
|
|
61
|
+
try { registry.maintain?.(); } catch {}
|
|
58
62
|
const previews = registry.list().filter((entry) => entry.mode !== 'static');
|
|
59
63
|
if (previews.length === 0) return;
|
|
60
64
|
sweeping = true;
|
|
@@ -70,6 +74,9 @@ export function createPreviewHealthProbe({
|
|
|
70
74
|
timer = setIntervalImpl(() => { sweep().catch(() => {}); }, intervalMs);
|
|
71
75
|
// An idle relay should not be held awake by a probe that has nothing to do.
|
|
72
76
|
if (typeof timer?.unref === 'function') timer.unref();
|
|
77
|
+
// One pass right away: port previews restored at boot start without a
|
|
78
|
+
// health state, and their cards should not wait a full interval for one.
|
|
79
|
+
sweep().catch(() => {});
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
function stop() {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import http from 'http';
|
|
4
4
|
|
|
5
|
-
import { parsePreviewPath } from './preview-registry-service.mjs';
|
|
5
|
+
import { parsePreviewPath, previewLogId } from './preview-registry-service.mjs';
|
|
6
6
|
import { serveStaticPreview } from './preview-static-handler.mjs';
|
|
7
7
|
|
|
8
8
|
// The preview listener. A bare http.Server on its own loopback port whose
|
|
@@ -270,12 +270,16 @@ export function createPreviewProxyServer({
|
|
|
270
270
|
socket.once('error', forget);
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
|
|
273
|
+
// The token is the capability and the request URL carries it, so the line
|
|
274
|
+
// names the preview by a hash and logs only the upstream path, without query.
|
|
275
|
+
function logHit(entry, req, upstreamPath) {
|
|
274
276
|
const now = Date.now();
|
|
275
277
|
const last = lastHitLogAt.get(entry.token) || 0;
|
|
276
278
|
if (now - last < 60_000) return;
|
|
277
279
|
lastHitLogAt.set(entry.token, now);
|
|
278
|
-
|
|
280
|
+
const target = entry.mode === 'static' ? 'static' : `:${entry.targetPort}`;
|
|
281
|
+
const pathOnly = String(upstreamPath || '/').split('?')[0];
|
|
282
|
+
logger?.log?.(`[preview] ${previewLogId(entry.token)} → ${target} ${req.method} ${pathOnly} from ${clientIp(req)}`);
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
function resolveRequest(req) {
|
|
@@ -309,7 +313,7 @@ export function createPreviewProxyServer({
|
|
|
309
313
|
|
|
310
314
|
const { entry, upstreamPath } = resolved;
|
|
311
315
|
registry.recordHit(entry.token);
|
|
312
|
-
logHit(entry, req);
|
|
316
|
+
logHit(entry, req, upstreamPath);
|
|
313
317
|
|
|
314
318
|
// Static previews are answered in-process — no upstream, no forwarding.
|
|
315
319
|
if (entry.mode === 'static') {
|