@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
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// A queued relay restart, as every device sees it, and the confirmations
|
|
2
|
+
// before one: which sessions it waits for (links), how long it has waited,
|
|
3
|
+
// that new messages are held, which background tasks survive, and — once the
|
|
4
|
+
// maximum wait ran out — the question: restart now, keep waiting, or cancel.
|
|
5
|
+
// The relay never restarts by itself past the wait
|
|
6
|
+
// (server/services/relay-restart-queue.mjs). The banner row lives in
|
|
7
|
+
// host-suspend-ui.js's #pending-action-banner; this module renders it.
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
escHtml,
|
|
11
|
+
openSummaryModal,
|
|
12
|
+
closeSummaryModal,
|
|
13
|
+
renderSummaryModalContent,
|
|
14
|
+
setSummaryModalLoading,
|
|
15
|
+
summaryModalState,
|
|
16
|
+
showTransientRelayNotice,
|
|
17
|
+
} from './store.js';
|
|
18
|
+
import {
|
|
19
|
+
cancelRelayRestart,
|
|
20
|
+
keepWaitingForRelayRestart,
|
|
21
|
+
loadRelayRestartMaxWaitSetting,
|
|
22
|
+
loadRelayRestartPreview,
|
|
23
|
+
requestRelayRestart,
|
|
24
|
+
updateRelayRestartMaxWaitSetting as requestRelayRestartMaxWaitSetting,
|
|
25
|
+
} from './api-client.js';
|
|
26
|
+
import { describeRelayRestartError } from './relay-restart-error.mjs';
|
|
27
|
+
|
|
28
|
+
const CONFIRM_KIND = 'restart-relay';
|
|
29
|
+
const NOW_KIND = 'restart-relay-now';
|
|
30
|
+
const LIST_LIMIT = 6;
|
|
31
|
+
|
|
32
|
+
function plural(count, word, many = `${word}s`) {
|
|
33
|
+
return `${count} ${count === 1 ? word : many}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** "less than a minute", "12 min", "1 h 5 min". */
|
|
37
|
+
export function formatWaited(ms) {
|
|
38
|
+
const minutes = Math.floor(Math.max(0, Number(ms) || 0) / 60_000);
|
|
39
|
+
if (minutes < 1) return 'less than a minute';
|
|
40
|
+
if (minutes < 60) return `${minutes} min`;
|
|
41
|
+
const hours = Math.floor(minutes / 60);
|
|
42
|
+
const rest = minutes % 60;
|
|
43
|
+
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatMaxWait(ms) {
|
|
47
|
+
const value = Number(ms) || 0;
|
|
48
|
+
return value > 0 ? formatWaited(value) : 'no limit';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isQueued(state) {
|
|
52
|
+
return String(state?.status || '') === 'queued';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function actionOf(state) {
|
|
56
|
+
return state?.action === 'restart' || state?.restart ? 'restart' : 'shutdown';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function waitingSessions(state) {
|
|
60
|
+
return Array.isArray(state?.waitingFor) ? state.waitingFor : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runningTurns(state) {
|
|
64
|
+
const fromList = waitingSessions(state).reduce((sum, entry) => sum + (Number(entry?.turns) || 0), 0);
|
|
65
|
+
return fromList || Number(state?.queue?.processingCount || 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The question is up: the maximum wait ran out and the relay asks. */
|
|
69
|
+
export function isRelayRestartAsking(state) {
|
|
70
|
+
return isQueued(state) && !!state?.question;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The banner's first line, e.g. "🌄 Relay restart queued — waiting for 2 sessions". */
|
|
74
|
+
export function relayRestartHeadline(state) {
|
|
75
|
+
const action = actionOf(state);
|
|
76
|
+
const icon = action === 'restart' ? '🌄' : '⏻';
|
|
77
|
+
if (String(state?.status || '') === 'shutting_down') {
|
|
78
|
+
return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
|
|
79
|
+
}
|
|
80
|
+
if (!isQueued(state)) return '';
|
|
81
|
+
const sessions = waitingSessions(state).length;
|
|
82
|
+
const turns = runningTurns(state);
|
|
83
|
+
const what = sessions > 0 ? plural(sessions, 'session') : turns > 0 ? plural(turns, 'running turn') : '';
|
|
84
|
+
if (isRelayRestartAsking(state)) {
|
|
85
|
+
const now = action === 'restart' ? 'restart now' : 'shut down now';
|
|
86
|
+
return `${icon} The relay ${action} has waited ${formatMaxWait(state.maxWaitMs)}${what ? ` for ${what}` : ''} — ${now}, keep waiting or cancel?`;
|
|
87
|
+
}
|
|
88
|
+
if (!what) return `${icon} Relay ${action} queued — nothing is running, going ahead now`;
|
|
89
|
+
return `${icon} Relay ${action} queued — waiting for ${what}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** "waited 12 min of 30 min", from the start of the current waiting period. */
|
|
93
|
+
export function relayRestartWaitedText(state, nowMs = Date.now()) {
|
|
94
|
+
if (!isQueued(state)) return '';
|
|
95
|
+
const since = Date.parse(String(state.waitingSince || state.requestedAt || ''));
|
|
96
|
+
if (!Number.isFinite(since)) return '';
|
|
97
|
+
const waited = formatWaited(nowMs - since);
|
|
98
|
+
const limit = Number(state.maxWaitMs) || 0;
|
|
99
|
+
if (isRelayRestartAsking(state)) return `waited ${waited}`;
|
|
100
|
+
return limit > 0 ? `waited ${waited} of ${formatMaxWait(limit)}` : `waited ${waited}, no time limit`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// survives: true, false, or null for a task held since the last restart
|
|
104
|
+
// that its worker has not confirmed yet (nobody knows whether it runs).
|
|
105
|
+
function taskFate(task) {
|
|
106
|
+
if (task?.survives === false) return 'may stop';
|
|
107
|
+
if (task?.survives == null) return 'not yet confirmed';
|
|
108
|
+
return 'keeps running';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** "2 background agents: export (keeps running)". */
|
|
112
|
+
export function describeRestartBackgroundTask(task) {
|
|
113
|
+
const title = String(task?.title || '').trim() || 'a session';
|
|
114
|
+
const detail = String(task?.detail || '').trim() || plural(Number(task?.count) || 1, 'background task');
|
|
115
|
+
return `${title} — ${detail} (${taskFate(task)})`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function heldText(state) {
|
|
119
|
+
const count = Number(state?.held?.count ?? (Number(state?.queue?.pendingCount || 0) + Number(state?.queue?.parkedCount || 0))) || 0;
|
|
120
|
+
if (count > 0) return `New messages are held until the ${actionOf(state)}: ${plural(count, 'message')} waiting.`;
|
|
121
|
+
return `New messages are held until the ${actionOf(state)}.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sessionLink(entry, onOpen) {
|
|
125
|
+
const link = document.createElement('a');
|
|
126
|
+
link.href = '#';
|
|
127
|
+
link.className = 'relay-restart-session-link';
|
|
128
|
+
link.dataset.conversationId = String(entry?.conversationId || '');
|
|
129
|
+
link.textContent = String(entry?.title || entry?.conversationId || 'a session');
|
|
130
|
+
link.addEventListener('click', (event) => {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
onOpen?.(String(entry?.conversationId || ''));
|
|
134
|
+
});
|
|
135
|
+
return link;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function detailLine(className, label) {
|
|
139
|
+
const line = document.createElement('div');
|
|
140
|
+
line.className = `relay-restart-detail ${className}`;
|
|
141
|
+
if (label) line.appendChild(document.createTextNode(label));
|
|
142
|
+
return line;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function actionButton(label, className, onClick, disabled) {
|
|
146
|
+
const btn = document.createElement('button');
|
|
147
|
+
btn.type = 'button';
|
|
148
|
+
btn.className = `relay-toast-action ${className}`;
|
|
149
|
+
btn.textContent = label;
|
|
150
|
+
btn.disabled = !!disabled;
|
|
151
|
+
btn.addEventListener('click', (event) => {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
event.stopPropagation();
|
|
154
|
+
void onClick?.();
|
|
155
|
+
});
|
|
156
|
+
return btn;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The banner row for a queued or starting restart. `handlers`: onOpen
|
|
161
|
+
* (conversationId), onRestartNow, onKeepWaiting, onCancel; `busy` disables
|
|
162
|
+
* the buttons while a request is in flight.
|
|
163
|
+
*/
|
|
164
|
+
export function renderRelayRestartRow(state, { nowMs = Date.now(), handlers = {}, busy = false } = {}) {
|
|
165
|
+
const row = document.createElement('div');
|
|
166
|
+
row.className = 'pending-action-row relay-restart-row';
|
|
167
|
+
row.dataset.pending = 'relay-shutdown';
|
|
168
|
+
if (isRelayRestartAsking(state)) row.dataset.asking = '1';
|
|
169
|
+
const head = document.createElement('div');
|
|
170
|
+
head.className = 'relay-restart-head';
|
|
171
|
+
const text = document.createElement('span');
|
|
172
|
+
text.className = 'pending-action-text';
|
|
173
|
+
text.textContent = relayRestartHeadline(state);
|
|
174
|
+
head.appendChild(text);
|
|
175
|
+
const waited = relayRestartWaitedText(state, nowMs);
|
|
176
|
+
if (waited) {
|
|
177
|
+
const span = document.createElement('span');
|
|
178
|
+
span.className = 'relay-restart-waited';
|
|
179
|
+
span.textContent = `· ${waited}`;
|
|
180
|
+
head.appendChild(span);
|
|
181
|
+
}
|
|
182
|
+
row.appendChild(head);
|
|
183
|
+
if (!isQueued(state)) return row;
|
|
184
|
+
|
|
185
|
+
const sessions = waitingSessions(state);
|
|
186
|
+
if (sessions.length) {
|
|
187
|
+
const line = detailLine('relay-restart-waiting-for', 'Waiting for: ');
|
|
188
|
+
sessions.slice(0, LIST_LIMIT).forEach((entry, index) => {
|
|
189
|
+
if (index) line.appendChild(document.createTextNode(', '));
|
|
190
|
+
line.appendChild(sessionLink(entry, handlers.onOpen));
|
|
191
|
+
const turns = Number(entry?.turns) || 0;
|
|
192
|
+
if (turns > 1) line.appendChild(document.createTextNode(` (${turns} turns)`));
|
|
193
|
+
});
|
|
194
|
+
if (sessions.length > LIST_LIMIT) line.appendChild(document.createTextNode(`, +${sessions.length - LIST_LIMIT} more`));
|
|
195
|
+
row.appendChild(line);
|
|
196
|
+
}
|
|
197
|
+
if (state.holdsNewTurns !== false) row.appendChild(detailLine('relay-restart-held', heldText(state)));
|
|
198
|
+
const tasks = Array.isArray(state.backgroundTasks) ? state.backgroundTasks : [];
|
|
199
|
+
if (tasks.length) {
|
|
200
|
+
const line = detailLine('relay-restart-background', 'Background tasks: ');
|
|
201
|
+
tasks.slice(0, LIST_LIMIT).forEach((task, index) => {
|
|
202
|
+
if (index) line.appendChild(document.createTextNode('; '));
|
|
203
|
+
line.appendChild(sessionLink(task, handlers.onOpen));
|
|
204
|
+
const detail = String(task?.detail || '').trim();
|
|
205
|
+
line.appendChild(document.createTextNode(`${detail ? ` — ${detail}` : ''} (${taskFate(task)})`));
|
|
206
|
+
});
|
|
207
|
+
if (tasks.length > LIST_LIMIT) line.appendChild(document.createTextNode(`; +${tasks.length - LIST_LIMIT} more`));
|
|
208
|
+
if (state.survival?.summary) line.title = String(state.survival.summary);
|
|
209
|
+
row.appendChild(line);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const actions = document.createElement('div');
|
|
213
|
+
actions.className = 'relay-restart-actions';
|
|
214
|
+
const nowLabel = actionOf(state) === 'restart' ? 'Restart now' : 'Shut down now';
|
|
215
|
+
actions.appendChild(actionButton(nowLabel, 'relay-restart-now', handlers.onRestartNow, busy));
|
|
216
|
+
if (isRelayRestartAsking(state)) {
|
|
217
|
+
actions.appendChild(actionButton(`Keep waiting (${formatMaxWait(state.maxWaitMs)})`, 'relay-restart-keep-waiting', handlers.onKeepWaiting, busy));
|
|
218
|
+
}
|
|
219
|
+
actions.appendChild(actionButton(`Cancel ${actionOf(state)}`, 'pending-action-cancel', handlers.onCancel, busy));
|
|
220
|
+
row.appendChild(actions);
|
|
221
|
+
return row;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Updates the "waited …" labels in place, so the buttons are not rebuilt every second. */
|
|
225
|
+
export function refreshRelayRestartWaitedLabels(state, nowMs = Date.now(), root = document) {
|
|
226
|
+
const text = relayRestartWaitedText(state, nowMs);
|
|
227
|
+
for (const span of root?.querySelectorAll?.('.relay-restart-waited') || []) {
|
|
228
|
+
span.textContent = text ? `· ${text}` : '';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function listHtml(items) {
|
|
233
|
+
return items.length ? `<ul class="suspend-host-blockers">${items.map((item) => `<li>${item}</li>`).join('')}</ul>` : '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sessionItemHtml(entry, suffix = '') {
|
|
237
|
+
const title = escHtml(String(entry?.title || entry?.conversationId || 'a session'));
|
|
238
|
+
return `<strong>${title}</strong>${suffix ? ` ${escHtml(suffix)}` : ''}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function backgroundSectionHtml(picture) {
|
|
242
|
+
const tasks = Array.isArray(picture?.backgroundTasks) ? picture.backgroundTasks : [];
|
|
243
|
+
if (!tasks.length) return '';
|
|
244
|
+
const survive = tasks.filter((task) => task?.survives === true);
|
|
245
|
+
const stop = tasks.filter((task) => task?.survives === false);
|
|
246
|
+
const unknown = tasks.filter((task) => task?.survives == null);
|
|
247
|
+
const item = (task) => `<strong>${escHtml(String(task?.title || 'a session'))}</strong> — ${escHtml(String(task?.detail || plural(Number(task?.count) || 1, 'background task')))}`;
|
|
248
|
+
let html = '';
|
|
249
|
+
if (stop.length) html += `<p><strong>These background tasks may stop:</strong></p>${listHtml(stop.map(item))}`;
|
|
250
|
+
if (survive.length) html += `<p>These background tasks keep running:</p>${listHtml(survive.map(item))}`;
|
|
251
|
+
if (unknown.length) html += `<p>Not yet confirmed by their worker since the last relay restart, so whether they still run is not known:</p>${listHtml(unknown.map(item))}`;
|
|
252
|
+
if (picture?.survival?.summary) html += `<p class="settings-help">${escHtml(picture.survival.summary)}</p>`;
|
|
253
|
+
return `<div class="relay-restart-background-list">${html}</div>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The body of the Restart web relay confirmation for one picture (from
|
|
258
|
+
* GET /api/relay/shutdown). Pure so its shapes are unit-testable.
|
|
259
|
+
*/
|
|
260
|
+
export function renderRestartConfirmBody(picture = {}, { maxWaitMs = 0 } = {}) {
|
|
261
|
+
const sessions = Array.isArray(picture?.waitingFor) ? picture.waitingFor : [];
|
|
262
|
+
const heldCount = Number(picture?.held?.count || 0);
|
|
263
|
+
const parts = [];
|
|
264
|
+
if (sessions.length) {
|
|
265
|
+
parts.push(`<p>The restart waits until these running turns end:</p>`);
|
|
266
|
+
parts.push(listHtml(sessions.map((entry) => sessionItemHtml(entry, Number(entry?.turns) > 1 ? `(${entry.turns} turns)` : ''))));
|
|
267
|
+
parts.push(`<p>Until then new messages are held${heldCount ? ` (${plural(heldCount, 'message')} already waiting)` : ''}; they run after the restart. `
|
|
268
|
+
+ `${Number(maxWaitMs) > 0 ? `After ${escHtml(formatMaxWait(maxWaitMs))} the banner asks whether to restart now, keep waiting or cancel.` : 'There is no time limit on the wait (Settings › General).'}</p>`);
|
|
269
|
+
} else {
|
|
270
|
+
parts.push(`<p>Nothing is running: the relay restarts at once${heldCount ? `, and the ${plural(heldCount, 'waiting message')} run after it` : ''}.</p>`);
|
|
271
|
+
}
|
|
272
|
+
parts.push(backgroundSectionHtml(picture));
|
|
273
|
+
const buttons = [];
|
|
274
|
+
if (sessions.length) {
|
|
275
|
+
buttons.push('<button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart when idle</button>');
|
|
276
|
+
buttons.push('<button class="chat-title-action-btn danger-btn" type="button" onclick="confirmRestartRelayNow()">Restart now</button>');
|
|
277
|
+
} else {
|
|
278
|
+
buttons.push('<button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart web relay</button>');
|
|
279
|
+
}
|
|
280
|
+
buttons.push('<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>');
|
|
281
|
+
return `${parts.filter(Boolean).join('\n')}\n<div class="summary-modal-actions">${buttons.join('')}</div>`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The body of the "Restart now" confirmation: what it does not wait for. */
|
|
285
|
+
export function renderRestartNowBody(picture = {}, { action = 'restart', offerCancel = false } = {}) {
|
|
286
|
+
const sessions = Array.isArray(picture?.waitingFor) ? picture.waitingFor : [];
|
|
287
|
+
const workersSurvive = picture?.survival?.workersSurvive !== false;
|
|
288
|
+
const verb = action === 'restart' ? 'Restart' : 'Shut down';
|
|
289
|
+
const parts = [];
|
|
290
|
+
if (sessions.length) {
|
|
291
|
+
if (workersSurvive) {
|
|
292
|
+
parts.push(`<p><strong>${verb} now without waiting for ${plural(sessions.length, 'session')}:</strong></p>`);
|
|
293
|
+
parts.push(listHtml(sessions.map((entry) => sessionItemHtml(entry, Number(entry?.turns) > 1 ? `(${entry.turns} turns)` : ''))));
|
|
294
|
+
parts.push('<p>Their workers keep running and report back to the restarted relay; the live output of these turns pauses while the relay is away, and a turn whose reply does not arrive has to be sent again.</p>');
|
|
295
|
+
} else {
|
|
296
|
+
parts.push(`<p><strong>${verb} now may cut off ${plural(sessions.length, 'session')}:</strong></p>`);
|
|
297
|
+
parts.push(listHtml(sessions.map((entry) => sessionItemHtml(entry))));
|
|
298
|
+
parts.push('<p>Their workers may stop with the relay; a turn whose worker stops ends without a reply.</p>');
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
parts.push('<p>No turn is running any more.</p>');
|
|
302
|
+
}
|
|
303
|
+
parts.push(backgroundSectionHtml(picture));
|
|
304
|
+
parts.push(`<div class="summary-modal-actions">
|
|
305
|
+
<button class="chat-title-action-btn danger-btn" type="button" onclick="confirmRestartRelayNow()">${verb} now</button>
|
|
306
|
+
${offerCancel ? `<button class="chat-title-action-btn" type="button" onclick="cancelQueuedRelayRestart()">Cancel ${action}</button>` : ''}
|
|
307
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">${offerCancel ? 'Close' : 'Cancel'}</button>
|
|
308
|
+
</div>`);
|
|
309
|
+
return parts.filter(Boolean).join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let applyState = () => {};
|
|
313
|
+
let inFlight = false;
|
|
314
|
+
// What an open "now" button acts on: the action shown (a queued shutdown
|
|
315
|
+
// stays a shutdown) and the queued request it was shown for, or null from
|
|
316
|
+
// the ⋯ menu with nothing queued. The relay refuses a request that is gone.
|
|
317
|
+
let nowTarget = { action: 'restart', requestedAt: null };
|
|
318
|
+
|
|
319
|
+
function targetFrom(state) {
|
|
320
|
+
return isQueued(state)
|
|
321
|
+
? { action: actionOf(state), requestedAt: state.requestedAt || null }
|
|
322
|
+
: { action: 'restart', requestedAt: null };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A new restart state: a dialog shown for a queued request that is gone (it
|
|
327
|
+
* fired, was cancelled, the relay restarted) closes, so its button cannot
|
|
328
|
+
* restart a relay that came back.
|
|
329
|
+
*/
|
|
330
|
+
export function noteRelayRestartStateForDialogs(state) {
|
|
331
|
+
const kind = summaryModalState.kind;
|
|
332
|
+
if (kind !== CONFIRM_KIND && kind !== NOW_KIND) return;
|
|
333
|
+
const status = String(state?.status || '');
|
|
334
|
+
const gone = status === 'shutting_down'
|
|
335
|
+
|| (nowTarget.requestedAt && (status !== 'queued' || String(state?.requestedAt || '') !== nowTarget.requestedAt));
|
|
336
|
+
if (gone && !inFlight) closeSummaryModal();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** host-suspend-ui.js hands in how a fresh state reaches the banner. */
|
|
340
|
+
export function setRelayRestartStateSink(fn) {
|
|
341
|
+
applyState = typeof fn === 'function' ? fn : () => {};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function isRelayRestartRequestInFlight() {
|
|
345
|
+
return inFlight;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function withInFlight(fn) {
|
|
349
|
+
if (inFlight) return;
|
|
350
|
+
inFlight = true;
|
|
351
|
+
applyState(null, { rerenderOnly: true });
|
|
352
|
+
try {
|
|
353
|
+
await fn();
|
|
354
|
+
} finally {
|
|
355
|
+
inFlight = false;
|
|
356
|
+
applyState(null, { rerenderOnly: true });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function loadPictureInto({ kind, title, subtitle, render }) {
|
|
361
|
+
let result = null;
|
|
362
|
+
try {
|
|
363
|
+
result = await loadRelayRestartPreview('restart');
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (summaryModalState.kind !== kind) return;
|
|
366
|
+
renderSummaryModalContent({
|
|
367
|
+
title,
|
|
368
|
+
kind,
|
|
369
|
+
bodyHtml: `<div class="summary-error">Could not read what is running: ${escHtml(error?.message || 'unknown error')}</div>
|
|
370
|
+
<div class="summary-modal-actions">
|
|
371
|
+
<button class="chat-title-action-btn" type="button" onclick="confirmRestartWebRelay()">🌄 Restart when idle</button>
|
|
372
|
+
<button class="chat-title-action-btn" type="button" onclick="closeSummaryModal()">Cancel</button>
|
|
373
|
+
</div>`,
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (summaryModalState.kind !== kind) return;
|
|
378
|
+
if (result?.status) applyState(result);
|
|
379
|
+
renderSummaryModalContent({ title, subtitle, kind, bodyHtml: render(result || {}) });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** ⋯ → Restart web relay: shows what the restart would wait for first. */
|
|
383
|
+
export function openRestartRelayConfirmation() {
|
|
384
|
+
nowTarget = { action: 'restart', requestedAt: null };
|
|
385
|
+
const title = 'Restart web relay';
|
|
386
|
+
openSummaryModal({
|
|
387
|
+
title,
|
|
388
|
+
subtitle: 'Queues restart via /api/relay/shutdown',
|
|
389
|
+
kind: CONFIRM_KIND,
|
|
390
|
+
bodyHtml: '<div class="summary-loading">Checking what is running…</div>',
|
|
391
|
+
});
|
|
392
|
+
setSummaryModalLoading(true);
|
|
393
|
+
void loadPictureInto({
|
|
394
|
+
kind: CONFIRM_KIND,
|
|
395
|
+
title,
|
|
396
|
+
subtitle: 'Queues restart via /api/relay/shutdown',
|
|
397
|
+
render: (result) => {
|
|
398
|
+
nowTarget = targetFrom(result);
|
|
399
|
+
if (isQueued(result)) {
|
|
400
|
+
// Already queued: the same choices as the banner.
|
|
401
|
+
return `<p>${escHtml(relayRestartHeadline(result))}.</p>${renderRestartNowBody(result, { action: actionOf(result), offerCancel: true })}`;
|
|
402
|
+
}
|
|
403
|
+
return renderRestartConfirmBody(result.preview || {}, { maxWaitMs: result.maxWaitMs ?? result.preview?.maxWaitMs ?? 0 });
|
|
404
|
+
},
|
|
405
|
+
}).finally(() => setSummaryModalLoading(false));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The banner's "Restart now": a confirmation that names what it cuts off. */
|
|
409
|
+
export function openRestartNowConfirmation(state = null) {
|
|
410
|
+
nowTarget = targetFrom(state);
|
|
411
|
+
const action = nowTarget.action;
|
|
412
|
+
const title = action === 'restart' ? 'Restart web relay now' : 'Shut down web relay now';
|
|
413
|
+
openSummaryModal({
|
|
414
|
+
title,
|
|
415
|
+
subtitle: 'Does not wait for the running turns',
|
|
416
|
+
kind: NOW_KIND,
|
|
417
|
+
bodyHtml: state ? renderRestartNowBody(state, { action }) : '<div class="summary-loading">Checking what is running…</div>',
|
|
418
|
+
});
|
|
419
|
+
// Fresh from the relay: the banner's copy can be a second old.
|
|
420
|
+
void loadPictureInto({
|
|
421
|
+
kind: NOW_KIND,
|
|
422
|
+
title,
|
|
423
|
+
subtitle: 'Does not wait for the running turns',
|
|
424
|
+
render: (result) => {
|
|
425
|
+
// A dialog for a queued request keeps naming that request: if it is
|
|
426
|
+
// gone, the relay refuses the button.
|
|
427
|
+
if (isQueued(result) && !state) nowTarget = targetFrom(result);
|
|
428
|
+
return renderRestartNowBody(isQueued(result) ? result : (result.preview || {}), { action });
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function confirmRestartRelayNow() {
|
|
434
|
+
await withInFlight(async () => {
|
|
435
|
+
setSummaryModalLoading(true);
|
|
436
|
+
let result = null;
|
|
437
|
+
try {
|
|
438
|
+
const { action, requestedAt } = nowTarget;
|
|
439
|
+
result = await requestRelayRestart({
|
|
440
|
+
reason: `manual-${action}-now`,
|
|
441
|
+
restart: action === 'restart',
|
|
442
|
+
immediate: true,
|
|
443
|
+
...(requestedAt ? { expectRequestedAt: requestedAt } : {}),
|
|
444
|
+
});
|
|
445
|
+
} catch (error) {
|
|
446
|
+
closeSummaryModal();
|
|
447
|
+
const { kind, text } = describeRelayRestartError(error);
|
|
448
|
+
if (kind === 'refused') alert(text);
|
|
449
|
+
else showTransientRelayNotice(text, 9000);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
closeSummaryModal();
|
|
453
|
+
if (!result?.ok) {
|
|
454
|
+
alert('Failed to restart the relay');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (result.stale) {
|
|
458
|
+
showTransientRelayNotice('That queued restart is no longer pending; nothing was restarted.', 7000);
|
|
459
|
+
if (result.status) applyState(result);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (result.accepted === false) {
|
|
463
|
+
showTransientRelayNotice('The relay is already shutting down or restarting.', 7000);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
showTransientRelayNotice(nowTarget.action === 'restart' ? 'Relay restarting now.' : 'Relay shutting down now.', 7000);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export async function keepWaitingForQueuedRelayRestart() {
|
|
471
|
+
await withInFlight(async () => {
|
|
472
|
+
let result = null;
|
|
473
|
+
try {
|
|
474
|
+
result = await keepWaitingForRelayRestart();
|
|
475
|
+
} catch (error) {
|
|
476
|
+
showTransientRelayNotice(`Failed to keep the relay restart waiting: ${error?.message || 'unknown error'}`, 6000);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!result?.ok) return;
|
|
480
|
+
applyState(result);
|
|
481
|
+
showTransientRelayNotice(result.extended
|
|
482
|
+
? `The relay restart keeps waiting, for at most ${formatMaxWait(result.maxWaitMs)} more.`
|
|
483
|
+
: 'No relay restart was pending.', 5000);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export async function cancelQueuedRelayRestart() {
|
|
488
|
+
await withInFlight(async () => {
|
|
489
|
+
let result = null;
|
|
490
|
+
try {
|
|
491
|
+
result = await cancelRelayRestart();
|
|
492
|
+
} catch (error) {
|
|
493
|
+
showTransientRelayNotice(`Failed to cancel the queued relay restart: ${error?.message || 'unknown error'}`, 6000);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (summaryModalState.kind === CONFIRM_KIND || summaryModalState.kind === NOW_KIND) closeSummaryModal();
|
|
497
|
+
if (!result?.ok) {
|
|
498
|
+
showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
applyState(result);
|
|
502
|
+
showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled; held messages go out now.' : 'No relay restart was pending.', 4000);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ─── Settings › General: the maximum wait ────────────────────────────────────
|
|
507
|
+
// Mirrors server/services/relay-restart-queue.mjs (the browser cannot import
|
|
508
|
+
// it); the select's options are the allowed values.
|
|
509
|
+
let maxWaitMinutes = 30;
|
|
510
|
+
let maxWaitUpdateInFlight = false;
|
|
511
|
+
|
|
512
|
+
function syncMaxWaitSelect() {
|
|
513
|
+
const select = document.getElementById('relay-restart-max-wait-select');
|
|
514
|
+
if (!select || select.tagName !== 'SELECT') return;
|
|
515
|
+
select.value = String(maxWaitMinutes);
|
|
516
|
+
select.disabled = maxWaitUpdateInFlight;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function refreshRelayRestartMaxWaitSetting() {
|
|
520
|
+
syncMaxWaitSelect();
|
|
521
|
+
try {
|
|
522
|
+
const result = await loadRelayRestartMaxWaitSetting();
|
|
523
|
+
if (result && Number.isFinite(Number(result.maxWaitMinutes))) maxWaitMinutes = Number(result.maxWaitMinutes);
|
|
524
|
+
} catch {
|
|
525
|
+
// Keep the last known value; the setting is not critical.
|
|
526
|
+
} finally {
|
|
527
|
+
syncMaxWaitSelect();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export async function updateRelayRestartMaxWaitSetting(value) {
|
|
532
|
+
if (maxWaitUpdateInFlight) {
|
|
533
|
+
syncMaxWaitSelect();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
maxWaitUpdateInFlight = true;
|
|
537
|
+
syncMaxWaitSelect();
|
|
538
|
+
try {
|
|
539
|
+
const result = await requestRelayRestartMaxWaitSetting(Number(value));
|
|
540
|
+
if (result && Number.isFinite(Number(result.maxWaitMinutes))) maxWaitMinutes = Number(result.maxWaitMinutes);
|
|
541
|
+
showTransientRelayNotice(maxWaitMinutes === 0
|
|
542
|
+
? 'A queued relay restart now waits without a time limit.'
|
|
543
|
+
: `A queued relay restart asks after ${formatMaxWait(maxWaitMinutes * 60_000)}.`);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
alert(error?.message || 'Failed to update the maximum wait of a queued relay restart.');
|
|
546
|
+
} finally {
|
|
547
|
+
maxWaitUpdateInFlight = false;
|
|
548
|
+
syncMaxWaitSelect();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function initRelayRestartUi({ exposeOnWindow = true } = {}) {
|
|
553
|
+
if (!exposeOnWindow || typeof window === 'undefined') return;
|
|
554
|
+
window.openRestartRelayConfirmation = openRestartRelayConfirmation;
|
|
555
|
+
window.openRestartNowConfirmation = openRestartNowConfirmation;
|
|
556
|
+
window.confirmRestartRelayNow = confirmRestartRelayNow;
|
|
557
|
+
window.keepWaitingForQueuedRelayRestart = keepWaitingForQueuedRelayRestart;
|
|
558
|
+
window.cancelQueuedRelayRestart = cancelQueuedRelayRestart;
|
|
559
|
+
window.updateRelayRestartMaxWaitSetting = updateRelayRestartMaxWaitSetting;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function __resetRelayRestartUiForTests() {
|
|
563
|
+
applyState = () => {};
|
|
564
|
+
inFlight = false;
|
|
565
|
+
nowTarget = { action: 'restart', requestedAt: null };
|
|
566
|
+
maxWaitMinutes = 30;
|
|
567
|
+
maxWaitUpdateInFlight = false;
|
|
568
|
+
}
|