@oar-sh/oar 0.9.21 → 0.9.23

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 (82) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +28 -0
  4. package/package.json +13 -14
  5. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  6. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  8. package/server/claude-worker/claude-session-process.mjs +9 -4
  9. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  13. package/server/db-schema.mjs +19 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  15. package/server/grok-worker/grok-session-worker.mjs +10 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  17. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  18. package/server/migrations/0038-turn-seq.mjs +92 -0
  19. package/server/public/app/activity-replay-state.mjs +160 -42
  20. package/server/public/app/api-client.js +7 -0
  21. package/server/public/app/ask-user-view.js +4 -3
  22. package/server/public/app/bootstrap.js +62 -4
  23. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  24. package/server/public/app/context-usage-view.mjs +68 -0
  25. package/server/public/app/conversation-view.js +306 -49
  26. package/server/public/app/header-priority-actions.mjs +5 -1
  27. package/server/public/app/held-details.mjs +202 -0
  28. package/server/public/app/inbox-ui.js +70 -79
  29. package/server/public/app/inbox-view.mjs +35 -19
  30. package/server/public/app/journal-view.js +17 -0
  31. package/server/public/app/live-activity-cap.mjs +103 -0
  32. package/server/public/app/questions-panel-ui.js +634 -0
  33. package/server/public/app/questions-stub-view.mjs +40 -0
  34. package/server/public/app/questions-view.mjs +89 -0
  35. package/server/public/app/relay-board-view.js +12 -2
  36. package/server/public/app/relay-thoughts.mjs +15 -1
  37. package/server/public/app/session-history-ui.js +1 -0
  38. package/server/public/app/settings-modal.js +6 -0
  39. package/server/public/app/socket-handlers.js +42 -11
  40. package/server/public/app/stream-view-preference.mjs +60 -0
  41. package/server/public/app/subagent-fold.mjs +6 -3
  42. package/server/public/app/subagent-timeline-view.mjs +12 -5
  43. package/server/public/app/subagent-viewer.mjs +8 -6
  44. package/server/public/app/thoughts-preference.mjs +79 -0
  45. package/server/public/app/turn-stream-view.mjs +124 -0
  46. package/server/public/index.html +391 -214
  47. package/server/repositories/message-repository.mjs +10 -0
  48. package/server/repositories/question-repository.mjs +51 -7
  49. package/server/repositories/relay-turn-rows.mjs +56 -0
  50. package/server/routes/ask-user-routes.mjs +34 -5
  51. package/server/routes/messages-routes.mjs +46 -34
  52. package/server/routes/relay-ask-routes.mjs +15 -5
  53. package/server/routes/sessions-routes.mjs +77 -6
  54. package/server/server-runtime.mjs +199 -171
  55. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  56. package/server/services/earlier-database-service.mjs +1 -1
  57. package/server/services/file-serving.mjs +13 -0
  58. package/server/services/idle-timers.mjs +184 -0
  59. package/server/services/native-addon-build-service.mjs +224 -0
  60. package/server/services/oar-cli-helpers.mjs +7 -4
  61. package/server/services/queue-wake-hook.mjs +145 -0
  62. package/server/services/queue-waker.mjs +127 -0
  63. package/server/services/relay-ask-service.mjs +64 -5
  64. package/server/services/relay-console-log.mjs +2 -3
  65. package/server/services/relay-event-service.mjs +4 -1
  66. package/server/services/remote-relay-dispatcher.mjs +23 -4
  67. package/server/services/remote-relay-loopback.mjs +3 -3
  68. package/server/services/sdk-session-sync-service.mjs +1 -1
  69. package/server/services/session-history-refresh-service.mjs +4 -0
  70. package/server/services/session-worker-supervisor-service.mjs +36 -0
  71. package/server/services/session-worker-websocket-service.mjs +143 -15
  72. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  73. package/server/services/update-install-service.mjs +32 -1
  74. package/shared/ask-user-bridge.mjs +9 -6
  75. package/shared/relay-asks.mjs +10 -0
  76. package/shared/relay-events.mjs +1 -0
  77. package/shared/worker-runtime/heartbeat.mjs +26 -0
  78. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  79. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
  80. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  81. package/server/services/package-data-rescue.mjs +0 -139
  82. package/server/tty-console-bootstrap.mjs +0 -75
@@ -18,6 +18,7 @@ import { createClaudeSessionRootResolver } from '../services/claude-session-root
18
18
  import {
19
19
  startClaudeSession,
20
20
  createCanUseTool,
21
+ isPersonOnlyAsk,
21
22
  readContextUsage,
22
23
  readPlanUsage,
23
24
  normalizeClaudeEffort,
@@ -2354,9 +2355,10 @@ export function createClaudeSessionRunner({
2354
2355
  // nothing else is left: the cap exists to bound a retry storm's chatter,
2355
2356
  // while a structured entry carries transcript geometry no later line can
2356
2357
  // restore (a compaction boundary buffered between turns, then 50 task
2357
- // notifications, used to lose its break row silently). The client's
2358
- // capRelayActivityEntries protects structured rows too, but head-caps the
2359
- // prose it keeps; here the newest lines are the informative ones.
2358
+ // notifications, used to lose its break row silently). The relay's
2359
+ // capRelayActivityEntries protects structured rows too and keeps the
2360
+ // newest prose of each thread; here too the newest lines are the
2361
+ // informative ones.
2360
2362
  while (proc.pendingActivities.length > MAX_PENDING_ACTIVITIES) {
2361
2363
  const proseIndex = proc.pendingActivities.findIndex((carried) => typeof carried === 'string');
2362
2364
  proc.pendingActivities.splice(proseIndex === -1 ? 0 : proseIndex, 1);
@@ -3806,8 +3808,9 @@ export function createClaudeSessionRunner({
3806
3808
  // denied. Register a continuation turn first: it is born processing,
3807
3809
  // gives the card a real queue row, and the flow's eventual top-level
3808
3810
  // result (or stream end) closes it like any other continuation.
3811
+ // A person-only safety ask is a question card too.
3809
3812
  if (
3810
- toolName === 'AskUserQuestion'
3813
+ (toolName === 'AskUserQuestion' || isPersonOnlyAsk(options))
3811
3814
  && proc === processRef
3812
3815
  && !processRef.activeCtx
3813
3816
  && !processRef.pendingDelivered.length
@@ -4582,6 +4585,8 @@ export function createClaudeSessionRunner({
4582
4585
  backgroundTaskIds,
4583
4586
  resyncBackgroundTasks,
4584
4587
  flushKeptTaskSettles,
4588
+ // Settle notices still kept for the relay: the keepalive stays busy.
4589
+ keptTaskSettleCount: () => keptTaskSettles.size(),
4585
4590
  shutdown,
4586
4591
  // Test seams / observability.
4587
4592
  _getProcess: () => proc,
@@ -14,6 +14,7 @@ import {
14
14
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
15
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
16
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
17
18
  import { resolveBuildId } from '../../shared/build-id.mjs';
18
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
19
20
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
@@ -174,10 +175,16 @@ export async function runClaudeSessionWorker({
174
175
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
175
176
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
176
177
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
178
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
177
179
  });
178
180
 
181
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
182
+ // while working, 30 s while idle on a relay that allows it.
183
+ let keepalive = null;
179
184
  const wsLink = createWorkerWebSocketLink({
180
185
  serverUrl,
186
+ readyRefreshMs: 0,
187
+ onRelayFrame: () => keepalive?.nudge(),
181
188
  token,
182
189
  dbg,
183
190
  getSessionReady: () => true,
@@ -239,6 +246,7 @@ export async function runClaudeSessionWorker({
239
246
  dbg('unknown worker control ignored', type || '(none)');
240
247
  },
241
248
  });
249
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
242
250
  linkBridge.attach(wsLink);
243
251
 
244
252
  let shuttingDown = false;
@@ -247,6 +255,7 @@ export async function runClaudeSessionWorker({
247
255
  shuttingDown = true;
248
256
  dbg(`shutting down (${signal})`);
249
257
  try { wsLink.stop(); } catch {}
258
+ try { keepalive.stop(); } catch {}
250
259
  try { heartbeat.stopHeartbeat(); } catch {}
251
260
  try { controlPoller.stop(); } catch {}
252
261
  // Consumed steers still settling must fail, not be left for dead-worker
@@ -271,6 +280,6 @@ export async function runClaudeSessionWorker({
271
280
  });
272
281
 
273
282
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''} build=${buildId || 'unknown'}`);
274
- heartbeat.startHeartbeat();
283
+ keepalive.start();
275
284
  wsLink.start();
276
285
  }
@@ -5133,6 +5133,7 @@ export function createCopilotSdkSessionRunner({
5133
5133
  resyncBackgroundTasks,
5134
5134
  // The relay answered a heartbeat: settle notices kept for it go out.
5135
5135
  flushKeptTaskSettles: () => keptTaskSettles.flush(),
5136
+ keptTaskSettleCount: () => keptTaskSettles.size(),
5136
5137
  cancelPushedMessage,
5137
5138
  // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
5138
5139
  // subagent lane's Stop tries — the runtime tracks sync agents in the same
@@ -20,6 +20,7 @@ import {
20
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
23
24
  import { resolveBuildId } from '../../shared/build-id.mjs';
24
25
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
25
26
  import {
@@ -158,10 +159,16 @@ async function main() {
158
159
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
159
160
  // The relay answered: settle notices it missed go out.
160
161
  onAnswered: () => turnRunner.flushKeptTaskSettles?.(),
162
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
161
163
  });
162
164
 
165
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
166
+ // while working, 30 s while idle on a relay that allows it.
167
+ let keepalive = null;
163
168
  const wsLink = createWorkerWebSocketLink({
164
169
  serverUrl,
170
+ readyRefreshMs: 0,
171
+ onRelayFrame: () => keepalive?.nudge(),
165
172
  token,
166
173
  dbg,
167
174
  buildId,
@@ -213,11 +220,13 @@ async function main() {
213
220
  dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
214
221
  },
215
222
  });
223
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
216
224
  linkBridge.attach(wsLink);
217
225
 
218
226
  const shutdown = async (signal) => {
219
227
  dbg(`shutting down (${signal})`);
220
228
  try { wsLink.stop(); } catch {}
229
+ try { keepalive.stop(); } catch {}
221
230
  try { heartbeat.stopHeartbeat(); } catch {}
222
231
  try { controlPoller.stop(); } catch {}
223
232
  // Rows whose prompt the runtime consumed and whose settle marker has not
@@ -246,7 +255,7 @@ async function main() {
246
255
  });
247
256
 
248
257
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
249
- heartbeat.startHeartbeat();
258
+ keepalive.start();
250
259
  wsLink.start();
251
260
  }
252
261
 
@@ -13,6 +13,7 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
16
17
  import { resolveBuildId } from '../../shared/build-id.mjs';
17
18
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
18
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
@@ -110,8 +111,13 @@ async function main() {
110
111
  getSteeringState: () => buildCursorSteeringState(turnRunner),
111
112
  });
112
113
 
114
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
115
+ // while working, 30 s while idle on a relay that allows it.
116
+ let keepalive = null;
113
117
  const wsLink = createWorkerWebSocketLink({
114
118
  serverUrl,
119
+ readyRefreshMs: 0,
120
+ onRelayFrame: () => keepalive?.nudge(),
115
121
  token,
116
122
  dbg,
117
123
  buildId,
@@ -128,10 +134,12 @@ async function main() {
128
134
  }
129
135
  },
130
136
  });
137
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
131
138
 
132
139
  const shutdown = async (signal) => {
133
140
  dbg(`shutting down (${signal})`);
134
141
  try { wsLink.stop(); } catch {}
142
+ try { keepalive.stop(); } catch {}
135
143
  try { heartbeat.stopHeartbeat(); } catch {}
136
144
  try { controlPoller.stop(); } catch {}
137
145
  // The agent handle owns a SQLite store; close it before exiting — but
@@ -158,7 +166,7 @@ async function main() {
158
166
  });
159
167
 
160
168
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
161
- heartbeat.startHeartbeat();
169
+ keepalive.start();
162
170
  wsLink.start();
163
171
  }
164
172
 
@@ -42,6 +42,8 @@ import { migrateClaudeApiUsageCheckpoints } from './migrations/0033-claude-api-u
42
42
  import { migrateClaudeApiUsageTotals } from './migrations/0034-claude-api-usage-totals.mjs';
43
43
  import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan-only.mjs';
44
44
  import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
45
+ import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
46
+ import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
45
47
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
46
48
 
47
49
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -1077,6 +1079,23 @@ try {
1077
1079
  console.warn(`[token-usage] migration 0036 failed; retrying on next boot: ${error?.message || error}`);
1078
1080
  }
1079
1081
 
1082
+ // The turn an ask was made in: one column on 0023's table. The ask service
1083
+ // works without it, so a failure is logged and retried next boot like 0023.
1084
+ try {
1085
+ migrateRelayAsksQueueId(db);
1086
+ } catch (error) {
1087
+ console.warn(`[relay-asks] migration 0037 failed; retrying on next boot: ${error?.message || error}`);
1088
+ }
1089
+
1090
+ // One order across a turn's thoughts and tool lines (the stream view). The
1091
+ // repository checks for the column and works without it (no numbers, the
1092
+ // grouped layout), so a failure is logged and retried next boot.
1093
+ try {
1094
+ migrateTurnSeq(db);
1095
+ } catch (error) {
1096
+ console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
1097
+ }
1098
+
1080
1099
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
1081
1100
  if (!conversationColumns.includes('archived')) {
1082
1101
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
@@ -248,6 +248,7 @@ export function createGrokTaskPublisher({
248
248
  resync: () => publish({ resync: true }),
249
249
  /** The relay answered a heartbeat: settle notices kept for it go out. */
250
250
  flushKeptSettles: () => keptSettles.flush(),
251
+ keptSettleCount: () => keptSettles.size(),
251
252
  sweep,
252
253
  close() {
253
254
  clearInterval(sweepTimer);
@@ -12,6 +12,7 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
15
16
  import { resolveBuildId } from '../../shared/build-id.mjs';
16
17
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
17
18
  import { createControlPoller } from '../../shared/control-poller.mjs';
@@ -113,13 +114,19 @@ async function main() {
113
114
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
114
115
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
115
116
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
117
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
116
118
  // No steering here; the snapshot tells the relay when it may replace
117
119
  // this worker: no turn, and no open terminal (they end with the worker).
118
120
  getSteeringState: () => buildGrokSteeringState(turnRunner),
119
121
  });
120
122
 
123
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
124
+ // while working, 30 s while idle on a relay that allows it.
125
+ let keepalive = null;
121
126
  const wsLink = createWorkerWebSocketLink({
122
127
  serverUrl,
128
+ readyRefreshMs: 0,
129
+ onRelayFrame: () => keepalive?.nudge(),
123
130
  token,
124
131
  dbg,
125
132
  buildId,
@@ -154,10 +161,12 @@ async function main() {
154
161
  dbg('unknown worker control ignored', type || '(none)');
155
162
  },
156
163
  });
164
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
157
165
 
158
166
  const shutdown = async (signal) => {
159
167
  dbg(`shutting down (${signal})`);
160
168
  try { wsLink.stop(); } catch {}
169
+ try { keepalive.stop(); } catch {}
161
170
  try { heartbeat.stopHeartbeat(); } catch {}
162
171
  try { controlPoller.stop(); } catch {}
163
172
  try { taskPublisher.close(); } catch {}
@@ -183,7 +192,7 @@ async function main() {
183
192
  });
184
193
 
185
194
  dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
186
- heartbeat.startHeartbeat();
195
+ keepalive.start();
187
196
  wsLink.start();
188
197
  }
189
198
 
@@ -673,6 +673,7 @@ export function createGrokTurnRunner({
673
673
  resyncBackgroundTasks: () => terminalTasks?.resync(),
674
674
  stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
675
675
  flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
676
+ keptTaskSettleCount: () => terminalTasks?.keptSettleCount?.() || 0,
676
677
  setTurnCeilingMs(value) {
677
678
  const ms = Number(value);
678
679
  turnCeilingMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the turn an ask was made in.
5
+ *
6
+ * `relay_asks.queue_id`: the queue row of the turn that was live in the
7
+ * asking conversation when its agent asked (services/relay-ask-service.mjs
8
+ * create), so the browser can show the ask in that turn's bubble. NULL for
9
+ * an ask made on behalf of another session, one a paired relay's agent made
10
+ * here, a mirror, and every ask from before this migration. No index: it is
11
+ * read with the row, never looked up by.
12
+ *
13
+ * Not one of RELAY_ASK_COLUMNS (0023): the ask service works without it and
14
+ * then stores nothing.
15
+ *
16
+ * Additive and idempotent. Safe to run on every boot; nothing to do while
17
+ * there is no relay_asks table (0023 not run yet). Rolling back needs no
18
+ * data change: older code ignores the column.
19
+ *
20
+ * Usage: node server/migrations/0037-relay-asks-queue-id.mjs [path/to/oar.db]
21
+ */
22
+
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+ import process from 'process';
26
+ import Database from 'better-sqlite3';
27
+ import { cliDbPath } from '../services/database-file.mjs';
28
+
29
+ function tableExists(db, name) {
30
+ return !!db
31
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
32
+ .get(name);
33
+ }
34
+
35
+ export function migrateRelayAsksQueueId(db) {
36
+ if (!tableExists(db, 'relay_asks')) return { applied: false, columnsAdded: 0 };
37
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_asks)`).all().map((column) => column.name));
38
+ if (present.has('queue_id')) return { applied: false, columnsAdded: 0 };
39
+ db.exec(`ALTER TABLE relay_asks ADD COLUMN queue_id TEXT`);
40
+ return { applied: true, columnsAdded: 1 };
41
+ }
42
+
43
+ export function migrate(dbPath, options = {}) {
44
+ const db = new Database(dbPath, options);
45
+ try {
46
+ return migrateRelayAsksQueueId(db);
47
+ } finally {
48
+ db.close();
49
+ }
50
+ }
51
+
52
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
53
+ if (isMain) {
54
+ try {
55
+ // The given path, else the database in server/data (oar.db, else the
56
+ // name before 0.9.20); never a new one.
57
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
58
+ const result = migrate(dbPath);
59
+ console.log(`Migration 0037 on ${dbPath}`);
60
+ console.log(result.applied ? ` relay asks: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
61
+ process.exit(0);
62
+ } catch (err) {
63
+ console.error(`Migration 0037 failed: ${err?.message || err}`);
64
+ process.exit(1);
65
+ }
66
+ }
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: one order across a turn's thoughts and tool lines.
5
+ *
6
+ * `relay_activity.turn_seq` and `relay_thought.turn_seq`: a number per queue
7
+ * message shared by both tables (repositories/question-repository.mjs
8
+ * nextTurnSeq), given when a row is first written, so the stream view can
9
+ * show a turn's thoughts and tool calls in the order they happened. NULL for
10
+ * every row from before this migration and for rows a session-history
11
+ * rebuild writes; a turn with such a row keeps the grouped layout.
12
+ *
13
+ * Indexes on (queue_message_id, turn_seq), partial (only numbered rows): the
14
+ * next number is a max() over them. Not unique: one counter spans two tables.
15
+ *
16
+ * Not in SCHEMA_SQL (db-schema.mjs): that block runs before the migrations,
17
+ * so an index on the column there would fail on every existing database.
18
+ *
19
+ * Additive and idempotent. Safe to run on every boot; a table that does not
20
+ * exist yet is left alone. Rolling back needs no data change: older code
21
+ * ignores the columns.
22
+ *
23
+ * Usage: node server/migrations/0038-turn-seq.mjs [path/to/oar.db]
24
+ */
25
+
26
+ import { fileURLToPath } from 'url';
27
+ import path from 'path';
28
+ import process from 'process';
29
+ import Database from 'better-sqlite3';
30
+ import { cliDbPath } from '../services/database-file.mjs';
31
+
32
+ export const TURN_SEQ_TABLES = Object.freeze(['relay_activity', 'relay_thought']);
33
+
34
+ function tableExists(db, name) {
35
+ return !!db
36
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
37
+ .get(name);
38
+ }
39
+
40
+ export function hasTurnSeqColumn(db, table) {
41
+ try {
42
+ return db.prepare(`PRAGMA table_info(${table})`).all().some((column) => column.name === 'turn_seq');
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function migrateTurnSeq(db) {
49
+ let columnsAdded = 0;
50
+ let indexesAdded = 0;
51
+ for (const table of TURN_SEQ_TABLES) {
52
+ if (!tableExists(db, table)) continue;
53
+ if (!hasTurnSeqColumn(db, table)) {
54
+ db.exec(`ALTER TABLE ${table} ADD COLUMN turn_seq INTEGER`);
55
+ columnsAdded += 1;
56
+ }
57
+ const index = `idx_${table}_queue_turn_seq`;
58
+ const present = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`).get(index);
59
+ if (!present) {
60
+ db.exec(`CREATE INDEX ${index} ON ${table}(queue_message_id, turn_seq) WHERE turn_seq IS NOT NULL`);
61
+ indexesAdded += 1;
62
+ }
63
+ }
64
+ return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
65
+ }
66
+
67
+ export function migrate(dbPath, options = {}) {
68
+ const db = new Database(dbPath, options);
69
+ try {
70
+ return migrateTurnSeq(db);
71
+ } finally {
72
+ db.close();
73
+ }
74
+ }
75
+
76
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
77
+ if (isMain) {
78
+ try {
79
+ // The given path, else the database in server/data (oar.db, else the
80
+ // name before 0.9.20); never a new one.
81
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
82
+ const result = migrate(dbPath);
83
+ console.log(`Migration 0038 on ${dbPath}`);
84
+ console.log(result.applied
85
+ ? ` turn order: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
86
+ : ' skipped: nothing to change');
87
+ process.exit(0);
88
+ } catch (err) {
89
+ console.error(`Migration 0038 failed: ${err?.message || err}`);
90
+ process.exit(1);
91
+ }
92
+ }