@oar-sh/oar 0.9.11 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -57,13 +57,18 @@ import {
57
57
  RELAY_EVENT_LINK_LOST_TYPE,
58
58
  RELAY_EVENT_LINK_RESTORED_TYPE,
59
59
  RELAY_EVENT_MESSAGE_KIND,
60
+ RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
61
+ RELAY_EVENT_SUBSCRIBABLE_TYPES,
60
62
  RELAY_EVENT_TYPES,
61
63
  RELAY_EVENT_VERSION,
62
64
  buildRelayEventMessageText,
63
65
  compileRelayEventMatch,
66
+ effectiveRelayEventTypes,
67
+ isLegacyDefaultRelayEventTypes,
64
68
  isRelayEventType,
65
69
  isUrgentRelayEventType,
66
70
  relayEventMatchText,
71
+ relayEventTypesTake,
67
72
  } from '../../shared/relay-events.mjs';
68
73
 
69
74
  export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
@@ -403,6 +408,11 @@ export function createRelayEventService({
403
408
  return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
404
409
  }
405
410
 
411
+ /** A subscription's events; the old default list counts as today's default. */
412
+ function storedTypes(row) {
413
+ return effectiveRelayEventTypes(parseJson(row?.events_json, []));
414
+ }
415
+
406
416
  function shapeSubscription(row) {
407
417
  if (!row) return null;
408
418
  const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
@@ -431,7 +441,10 @@ export function createRelayEventService({
431
441
  sessions: scope === 'all'
432
442
  ? 'all'
433
443
  : sessionIds.map((id) => ({ id, title: mirror ? (remoteTitles.get(id) || null) : (toText(conversation(id)?.title) || null) })),
434
- events: parseJson(row.events_json, []).filter((type) => RELAY_EVENT_TYPES.includes(type)),
444
+ // A mirror shows what the paired relay applies; a subscription here,
445
+ // stored with the old default, the default that applies now.
446
+ events: (mirror ? parseJson(row.events_json, []) : storedTypes(row))
447
+ .filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)),
435
448
  once: Number(row.once || 0) === 1,
436
449
  ...(toText(row.match) ? { match: row.match } : {}),
437
450
  stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
@@ -504,9 +517,13 @@ export function createRelayEventService({
504
517
  throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
505
518
  }
506
519
  const types = Array.isArray(events) && events.length
507
- ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_TYPES.includes(type)))]
520
+ ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)))]
508
521
  : [...RELAY_EVENT_DEFAULT_TYPES];
509
- if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_TYPES.join(', ')}`);
522
+ if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')}`);
523
+ // Named exactly as the old default was stored, this list would be read
524
+ // as today's default and lose its task_finished (effectiveRelayEventTypes);
525
+ // subagent_finished beside it changes nothing it takes and keeps it named.
526
+ if (isLegacyDefaultRelayEventTypes(types)) types.push(RELAY_EVENT_SUBAGENT_FINISHED_TYPE);
510
527
  const compiled = compileRelayEventMatch(match);
511
528
  if (!compiled.ok) throw new RelayEventError('invalid', compiled.error);
512
529
  const stalled = Number.isFinite(Number(stalledMinutes)) && stalledMinutes !== null && stalledMinutes !== ''
@@ -771,8 +788,9 @@ export function createRelayEventService({
771
788
  return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
772
789
  }
773
790
 
774
- function wants(row, type) {
775
- return parseJson(row.events_json, []).includes(type);
791
+ /** `event` is `{ type, detail }`: a task_finished's kind decides under subagent_finished. */
792
+ function wants(row, event) {
793
+ return relayEventTypesTake(storedTypes(row), event);
776
794
  }
777
795
 
778
796
  /** `match` filters the events it can be tried on; the others pass. */
@@ -807,7 +825,7 @@ export function createRelayEventService({
807
825
  if (!available || !RELAY_EVENT_TYPES.includes(type)) return 0;
808
826
  const id = toText(sessionId);
809
827
  if (!id) return 0;
810
- const rows = localWatchRows().filter((row) => wants(row, type) && subscriptionCovers(row, id));
828
+ const rows = localWatchRows().filter((row) => wants(row, { type, detail }) && subscriptionCovers(row, id));
811
829
  if (!rows.length) return 0;
812
830
  const event = buildEvent(type, id, detail);
813
831
  return enqueueForRows(rows, event, id);
@@ -1271,7 +1289,7 @@ export function createRelayEventService({
1271
1289
  const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
1272
1290
  .map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
1273
1291
  .filter((session) => session.id);
1274
- const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_TYPES.includes(type));
1292
+ const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
1275
1293
  const id = `sub_${randomBytes(5).toString('hex')}`;
1276
1294
  const at = isoNow();
1277
1295
  remoteSql.insertMirror.run(
@@ -1814,7 +1832,7 @@ export function createRelayEventService({
1814
1832
  }
1815
1833
  busyBySubscription.set(row.id, false);
1816
1834
  idleSinceBySubscription.delete(row.id);
1817
- if (!wants(row, 'all_idle')) continue;
1835
+ if (!wants(row, { type: 'all_idle' })) continue;
1818
1836
  const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
1819
1837
  const event = buildEvent('all_idle', '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id });
1820
1838
  enqueueForRows([row], event, '');
@@ -1832,7 +1850,7 @@ export function createRelayEventService({
1832
1850
  function evaluateStalled() {
1833
1851
  const at = now();
1834
1852
  for (const row of localWatchRows()) {
1835
- if (!wants(row, 'stalled')) continue;
1853
+ if (!wants(row, { type: 'stalled' })) continue;
1836
1854
  const limitMs = (Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault) * 60_000;
1837
1855
  for (const sessionId of candidateSessions(row)) {
1838
1856
  const view = describe(sessionId);
@@ -14,6 +14,7 @@ const DEFAULT_RELAY_TOOL_GUIDANCE = [
14
14
  'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
15
15
  'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
16
16
  'Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.',
17
+ 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.',
17
18
  ].join(' ');
18
19
 
19
20
  function loadRelayToolGuidance() {
@@ -32,6 +33,10 @@ function loadRelayToolGuidance() {
32
33
  }
33
34
 
34
35
  const RELAY_TOOL_GUIDANCE = loadRelayToolGuidance();
36
+ // Prompts stored before the `immediate` line was added carry the guidance
37
+ // without it.
38
+ const IMMEDIATE_GUIDANCE_LINE = 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.';
39
+ const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE.replace(` ${IMMEDIATE_GUIDANCE_LINE}`, '');
35
40
 
36
41
  const MODE_MARKERS = {
37
42
  plan: '[Relay mode: plan]',
@@ -100,21 +105,30 @@ function whitespaceFlexiblePattern(value) {
100
105
  .join('\\s+');
101
106
  }
102
107
 
108
+ // The patterns depend only on the mode, so they are built once per mode and
109
+ // reused: a long conversation sanitises every message on each history load.
110
+ const PROMPT_PREFIX_PATTERN_CACHE = new Map();
111
+
103
112
  function buildPromptPrefixPatterns(mode) {
104
113
  const requestedMode = String(mode || '').trim().toLowerCase();
105
- const modes = requestedMode && MODE_MARKERS[requestedMode]
106
- ? [requestedMode]
107
- : Object.keys(MODE_MARKERS);
108
- return modes.flatMap((key) => {
114
+ const cacheKey = requestedMode && MODE_MARKERS[requestedMode] ? requestedMode : '';
115
+ const cached = PROMPT_PREFIX_PATTERN_CACHE.get(cacheKey);
116
+ if (cached) return cached;
117
+ const modes = cacheKey ? [cacheKey] : Object.keys(MODE_MARKERS);
118
+ const patterns = modes.flatMap((key) => {
109
119
  const marker = MODE_MARKERS[key];
110
120
  const currentFullPrefix = `${marker} ${MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
111
121
  const legacyFullPrefix = `${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
112
122
  return [
113
123
  new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
114
124
  new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
125
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
126
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
115
127
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
116
128
  ];
117
129
  });
130
+ PROMPT_PREFIX_PATTERN_CACHE.set(cacheKey, patterns);
131
+ return patterns;
118
132
  }
119
133
 
120
134
  function stripAttachmentPromptArtifacts(text) {
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What a relay restart (or shutdown) waits for, holds and leaves running,
5
+ * read from the state the relay already has:
6
+ *
7
+ * - waitingFor: the sessions with a processing turn. A queued restart waits
8
+ * for these and only these (relay-shutdown-gate.mjs); "Restart now" does
9
+ * not wait for them.
10
+ * - held: pending and parked messages. While a restart is queued none of
11
+ * them is handed to a worker; they run after it.
12
+ * - backgroundTasks: sessions with running background work of a live
13
+ * worker (shell, monitor, sub-agents; the task panel's data, through
14
+ * background-work-view.mjs), each with whether it survives.
15
+ *
16
+ * Whether background work survives is a property of the session workers,
17
+ * not of the relay runtime: every worker is its own process (tmux, a
18
+ * detached process, a console window on Windows) and keeps its tasks when
19
+ * the runtime exits. A restart (exit 75) is taken by the relay's own
20
+ * supervisor or by the Copilot CLI extension, which start a new runtime;
21
+ * the workers report back to it. A shutdown under a systemd service is
22
+ * different: the service stops, and systemd stops every process of the
23
+ * service, which takes the workers with it unless they live outside it (a
24
+ * tmux session on a tmux server the user started elsewhere survives). The
25
+ * relay cannot tell which, so they "may stop". A Claude Cloud session runs at
26
+ * Anthropic and is never affected. `oar restart` and `oar stop` through
27
+ * systemd do not come through here at all.
28
+ */
29
+
30
+ import fs from 'node:fs';
31
+
32
+ const MAX_ROWS = 50;
33
+
34
+ function toText(value) {
35
+ return String(value ?? '').trim();
36
+ }
37
+
38
+ /**
39
+ * How this relay process is run, from its environment and argv: whether a
40
+ * systemd service runs it, and whether an outer supervisor (the Copilot CLI
41
+ * extension, `--supervised`) owns its restarts.
42
+ */
43
+ export function detectRelayHosting({
44
+ env = process.env,
45
+ argv = process.argv,
46
+ platform = process.platform,
47
+ readCgroup = () => fs.readFileSync('/proc/self/cgroup', 'utf8'),
48
+ } = {}) {
49
+ // systemd sets INVOCATION_ID for the processes of a unit, and a service's
50
+ // cgroup ends in `.service` (a terminal's shell sits in a `.scope`).
51
+ let systemd = false;
52
+ if (platform === 'linux' && toText(env?.INVOCATION_ID)) {
53
+ let cgroup = '';
54
+ try { cgroup = String(readCgroup() || ''); } catch {}
55
+ systemd = cgroup.split('\n').some((line) => /\.service\/?$/.test(line.trim()));
56
+ }
57
+ const supervised = Array.isArray(argv) && argv.includes('--supervised');
58
+ return { systemd, supervised, platform };
59
+ }
60
+
61
+ /** Whether the session workers outlive this action, and why, in one line each. */
62
+ export function describeRestartSurvival({ action = 'restart', hosting = {} } = {}) {
63
+ const restart = action === 'restart';
64
+ if (restart) {
65
+ return {
66
+ action: 'restart',
67
+ workersSurvive: true,
68
+ summary: 'Session workers are separate processes and keep running through a relay restart, with their background tasks; '
69
+ + 'they report back to the new relay within a minute.',
70
+ };
71
+ }
72
+ if (hosting.systemd) {
73
+ return {
74
+ action: 'shutdown',
75
+ workersSurvive: false,
76
+ summary: 'The relay runs as a systemd service: shutting it down stops the service and every process in it, '
77
+ + 'so session workers and their background tasks may stop too (a worker in a tmux session the service did not start keeps running).',
78
+ };
79
+ }
80
+ return {
81
+ action: 'shutdown',
82
+ workersSurvive: true,
83
+ summary: 'Session workers are separate processes and keep running while the relay is down, with their background tasks; '
84
+ + 'they report back when it starts again.',
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The picture for one action. `queueRows` is the queue grouped by
90
+ * conversation and status ({ conversation_id, status, cnt, title });
91
+ * `backgroundBlockers` the `background` rows of background-work-view.mjs.
92
+ */
93
+ export function buildRestartPicture({
94
+ action = 'restart',
95
+ queueRows = [],
96
+ backgroundBlockers = [],
97
+ // Tasks held since a relay restart (background-task-store.mjs
98
+ // `listRestored`, mapped to conversations): `{ conversationId, description }`.
99
+ heldTasks = [],
100
+ hosting = {},
101
+ providerOf = () => '',
102
+ titleOf = (conversationId) => toText(conversationId).slice(0, 8),
103
+ } = {}) {
104
+ const survival = describeRestartSurvival({ action, hosting });
105
+ const turns = new Map();
106
+ const held = new Map();
107
+ for (const row of Array.isArray(queueRows) ? queueRows : []) {
108
+ const conversationId = toText(row?.conversation_id);
109
+ const count = Number(row?.cnt || 0);
110
+ if (!conversationId || !(count > 0)) continue;
111
+ const status = toText(row?.status);
112
+ const target = status === 'processing' ? turns : (status === 'pending' || status === 'parked') ? held : null;
113
+ if (!target) continue;
114
+ const entry = target.get(conversationId) || { conversationId, title: toText(row?.title) || titleOf(conversationId), count: 0 };
115
+ entry.count += count;
116
+ target.set(conversationId, entry);
117
+ }
118
+ const waitingFor = [...turns.values()].slice(0, MAX_ROWS).map((entry) => ({
119
+ conversationId: entry.conversationId,
120
+ title: entry.title,
121
+ turns: entry.count,
122
+ }));
123
+ const heldConversations = [...held.values()];
124
+ const backgroundTasks = (Array.isArray(backgroundBlockers) ? backgroundBlockers : [])
125
+ .filter((blocker) => blocker && blocker.kind === 'background' && toText(blocker.conversationId))
126
+ .slice(0, MAX_ROWS)
127
+ .map((blocker) => {
128
+ const conversationId = toText(blocker.conversationId);
129
+ const cloud = toText(providerOf(conversationId)).toLowerCase() === 'claude-cloud';
130
+ const survives = cloud || survival.workersSurvive;
131
+ return {
132
+ conversationId,
133
+ title: toText(blocker.title) || titleOf(conversationId),
134
+ count: Number(blocker.count) > 0 ? Math.trunc(Number(blocker.count)) : 1,
135
+ detail: toText(blocker.detail).slice(0, 400),
136
+ survives,
137
+ why: cloud
138
+ ? 'runs in the cloud'
139
+ : survives ? 'its worker keeps running' : 'its worker may stop with the service',
140
+ };
141
+ });
142
+ // Held since the last restart: neither running nor ended as far as the
143
+ // relay knows, until the worker's set confirms them. Whether they survive
144
+ // the next one is not known either.
145
+ const listed = new Set(backgroundTasks.map((task) => task.conversationId));
146
+ const unconfirmed = new Map();
147
+ for (const entry of Array.isArray(heldTasks) ? heldTasks : []) {
148
+ const conversationId = toText(entry?.conversationId);
149
+ if (!conversationId || listed.has(conversationId)) continue;
150
+ const list = unconfirmed.get(conversationId) || [];
151
+ list.push(toText(entry?.description) || 'task');
152
+ unconfirmed.set(conversationId, list);
153
+ }
154
+ for (const [conversationId, descriptions] of unconfirmed) {
155
+ if (backgroundTasks.length >= MAX_ROWS) break;
156
+ const count = descriptions.length;
157
+ backgroundTasks.push({
158
+ conversationId,
159
+ title: titleOf(conversationId),
160
+ count,
161
+ detail: `${count} task${count === 1 ? '' : 's'} not yet confirmed since the last relay restart: ${descriptions.slice(0, 3).map((text) => text.slice(0, 80)).join('; ')}`,
162
+ survives: null,
163
+ why: 'its worker has not confirmed it since the last restart',
164
+ });
165
+ }
166
+ return {
167
+ waitingFor,
168
+ held: {
169
+ count: heldConversations.reduce((sum, entry) => sum + entry.count, 0),
170
+ conversations: heldConversations.slice(0, MAX_ROWS).map((entry) => ({
171
+ conversationId: entry.conversationId,
172
+ title: entry.title,
173
+ count: entry.count,
174
+ })),
175
+ },
176
+ backgroundTasks,
177
+ survival,
178
+ };
179
+ }
180
+
181
+ /** The live collector over the relay's database and background-work view. */
182
+ export function createRestartPictureCollector({
183
+ db,
184
+ backgroundWorkView = null,
185
+ // () => [{ conversationId, description }]: tasks held since a restart.
186
+ listHeldTasks = () => [],
187
+ hosting = detectRelayHosting(),
188
+ providerOf = () => '',
189
+ titleOf = undefined,
190
+ } = {}) {
191
+ let listQueue = null;
192
+ try {
193
+ listQueue = db?.prepare(`
194
+ SELECT q.conversation_id, q.status, COUNT(*) AS cnt, MAX(c.title) AS title
195
+ FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
196
+ WHERE q.status IN ('pending','processing','parked')
197
+ GROUP BY q.conversation_id, q.status
198
+ `) || null;
199
+ } catch {}
200
+ return function collectRestartPicture({ action = 'restart' } = {}) {
201
+ let queueRows = [];
202
+ try { queueRows = listQueue?.all() || []; } catch {}
203
+ let backgroundBlockers = [];
204
+ try { backgroundBlockers = backgroundWorkView?.backgroundBlockers?.() || []; } catch {}
205
+ let heldTasks = [];
206
+ try { heldTasks = listHeldTasks() || []; } catch {}
207
+ return buildRestartPicture({ action, queueRows, backgroundBlockers, heldTasks, hosting, providerOf, ...(titleOf ? { titleOf } : {}) });
208
+ };
209
+ }