@oar-sh/oar 0.9.20 → 0.9.22

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 (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -3,6 +3,14 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
6
+ import { normalizeBuildId, readGitCommit, resolveBuildCommit } from '../../shared/build-id.mjs';
7
+ import { isWorkerOnOtherBuild } from './worker-recycle-service.mjs';
8
+
9
+ // The commit readers moved to shared/ so the workers can state their build id
10
+ // too; they are re-exported here so the routes and tests importing them keep
11
+ // their import.
12
+ export { readGitCommit, resolveBuildCommit };
13
+
6
14
  // What the About OAR modal shows: the running build, the providers that are
7
15
  // on, what this relay has stored over its life, and how it is doing now.
8
16
  //
@@ -64,58 +72,6 @@ function readFileText(fsImpl, filePath) {
64
72
  }
65
73
  }
66
74
 
67
- const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
68
-
69
- /**
70
- * The commit a git checkout has checked out, read from its .git files rather
71
- * than from a `git` process: HEAD, then the loose ref, then packed-refs. A
72
- * linked worktree's .git is a file naming its git dir, whose refs live in the
73
- * common dir. Anything unexpected reads as null. `pathImpl` is the host's path
74
- * module; tests pass path.posix and path.win32.
75
- */
76
- export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
77
- if (!root) return null;
78
- const dotGit = pathImpl.join(root, '.git');
79
- let gitDir = dotGit;
80
- const pointer = readFileText(fsImpl, dotGit);
81
- if (pointer !== null) {
82
- const match = pointer.match(/^gitdir:\s*(.+)$/m);
83
- if (!match) return null;
84
- gitDir = pathImpl.resolve(root, match[1].trim());
85
- }
86
- const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
87
- if (head === null) return null;
88
- const headText = head.trim();
89
- if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
90
- const refMatch = headText.match(/^ref:\s*(\S+)$/);
91
- if (!refMatch) return null;
92
- const ref = refMatch[1];
93
- const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
94
- const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
95
- for (const dir of new Set([gitDir, commonDir])) {
96
- const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
97
- if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
98
- }
99
- const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
100
- for (const line of packed.split(/\r?\n/)) {
101
- const [hash, name] = line.trim().split(/\s+/);
102
- if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
103
- }
104
- return null;
105
- }
106
-
107
- /** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
108
- export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
109
- const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
110
- if (fromGit) return fromGit;
111
- try {
112
- const stamp = String(JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '{}')?.gitHead || '').trim();
113
- return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
114
- } catch {
115
- return null;
116
- }
117
- }
118
-
119
75
  export function readInstalledPackageVersion(packageRoot, packageName, { fsImpl = fs } = {}) {
120
76
  if (!packageRoot || !packageName) return null;
121
77
  try {
@@ -338,6 +294,7 @@ async function fileSize(fsImpl, filePath) {
338
294
  * @param {string|null} [deps.dbPath] the database file; its -wal file is counted too
339
295
  * @param {string} deps.version
340
296
  * @param {string|null} [deps.commit]
297
+ * @param {string|null} [deps.buildId] the relay's build id (shared/build-id.mjs), to tell the workers on other code
341
298
  * @param {string} [deps.installMethod]
342
299
  * @param {string} deps.startedAt ISO time this relay process started
343
300
  * @param {() => object} [deps.getProviderSettings] `{ github: { enabled, engine }, claude: { enabled }, … }`
@@ -354,6 +311,7 @@ export function createAboutService({
354
311
  describeBackups = null,
355
312
  version = '0.0.0',
356
313
  commit = null,
314
+ buildId = null,
357
315
  installMethod = null,
358
316
  startedAt,
359
317
  packageRoot = null,
@@ -397,20 +355,30 @@ export function createAboutService({
397
355
  return totalsInFlight;
398
356
  }
399
357
 
358
+ // The running workers, by provider and by build: after a deploy the
359
+ // workers keep the code they started with until the recycle replaces them,
360
+ // and About shows how many still do. A relay that does not know its own
361
+ // build id cannot tell, so both build counts are null then.
400
362
  function runningWorkersByProvider() {
401
363
  const counts = new Map();
402
364
  let total = 0;
365
+ const relayBuildId = normalizeBuildId(buildId);
366
+ let onOtherBuild = 0;
403
367
  for (const worker of listWorkers() || []) {
404
368
  const status = String(worker?.status || '').trim().toLowerCase();
405
369
  if (!ACTIVE_WORKER_STATUSES.has(status) || !isPidAlive(worker?.pid)) continue;
406
370
  total += 1;
371
+ if (isWorkerOnOtherBuild(worker, relayBuildId)) onOtherBuild += 1;
407
372
  const conversationId = String(worker?.conversationId || '').trim();
408
373
  const provider = conversationId
409
374
  ? normalizeProviderId(providerForConversation.get(conversationId)?.provider_type)
410
375
  : 'unassigned';
411
376
  counts.set(provider, (counts.get(provider) || 0) + 1);
412
377
  }
413
- return { total, counts };
378
+ const builds = relayBuildId
379
+ ? { total, onThisBuild: total - onOtherBuild, onOtherBuild }
380
+ : { total, onThisBuild: null, onOtherBuild: null };
381
+ return { total, counts, builds };
414
382
  }
415
383
 
416
384
  function describeProviders(workerCounts) {
@@ -474,6 +442,7 @@ export function createAboutService({
474
442
  startedAt: startedAt || null,
475
443
  uptimeSeconds,
476
444
  providers: describeProviders(workers.counts),
445
+ workers: workers.builds,
477
446
  stats: {
478
447
  conversations: totals.conversations.total,
479
448
  archivedConversations: totals.conversations.archived,
@@ -28,29 +28,16 @@
28
28
  * the fence lets them through, and the waiting state ages out.
29
29
  */
30
30
 
31
+ import { workerReleaseHold } from './worker-replacement.mjs';
32
+
31
33
  export const CLAUDE_BILLING_SWAP_WAIT_TTL_MS = 15_000;
32
34
 
33
35
  /**
34
- * What keeps a worker from being replaced now: null when nothing does, else
35
- * 'turn', 'question', 'compaction' or 'background'. From the steering
36
- * snapshot its heartbeat sends; a worker too old to say `releasable` is read
37
- * from the rest of it.
36
+ * What keeps a worker from being replaced now (worker-replacement.mjs, which
37
+ * the worker recycle shares). Kept under its old name for the importers that
38
+ * know it by that.
38
39
  */
39
- export function claudeWorkerReleaseHold(worker) {
40
- if (!worker) return null;
41
- const steering = worker.steering || null;
42
- if (steering && typeof steering.releasable === 'boolean') {
43
- return steering.releasable ? null : (steering.releaseHold || 'turn');
44
- }
45
- if (steering) {
46
- if (steering.holdReason === 'question') return 'question';
47
- if (steering.holdReason === 'compaction') return 'compaction';
48
- if (steering.turnActive) return 'turn';
49
- if (steering.autoCompactWindow?.backgroundWork) return 'background';
50
- return null;
51
- }
52
- return String(worker.status || '') === 'processing' ? 'turn' : null;
53
- }
40
+ export const claudeWorkerReleaseHold = workerReleaseHold;
54
41
 
55
42
  export function createClaudeBillingSwapService({
56
43
  // sid → the registry's entry for the session's worker, or null.
@@ -208,7 +195,7 @@ export function createClaudeBillingSwapService({
208
195
  running = true;
209
196
  noteProbeFailure(sid, state, fence, error);
210
197
  }
211
- const hold = running ? 'turn' : claudeWorkerReleaseHold(worker);
198
+ const hold = running ? 'turn' : workerReleaseHold(worker);
212
199
  if (hold) {
213
200
  setPending(sid, state, {
214
201
  to: fence.rowBilling,
@@ -157,12 +157,9 @@ export function buildClaudeCloudDelivery({ conversation = null, runtimeSession =
157
157
  if (!isClaudeCloudProviderType(runtimeSession?.provider_type)) return null;
158
158
  const source = parseCloudSourceJson(conversation?.cloud_source_json);
159
159
  if (!source) return null;
160
- const sessionId = toText(runtimeSession?.claude_cloud_session_id);
161
- const lastSequence = toText(runtimeSession?.claude_cloud_last_sequence);
162
160
  const fallback = toText(fallbackEnvironmentId);
163
161
  return {
164
- sessionId: isSafeClaudeCloudId(sessionId) ? sessionId : null,
165
- lastSequence: SEQUENCE_PATTERN.test(lastSequence) ? lastSequence : null,
162
+ ...readStoredClaudeCloudBinding(runtimeSession),
166
163
  repoUrl: source.repoUrl,
167
164
  branch: source.branch,
168
165
  environmentId: source.environmentId || (isSafeClaudeCloudId(fallback) ? fallback : null),
@@ -170,6 +167,20 @@ export function buildClaudeCloudDelivery({ conversation = null, runtimeSession =
170
167
  };
171
168
  }
172
169
 
170
+ /**
171
+ * The cloud session a runtime session row is bound to and the cursor its
172
+ * worker reads on from: `{ sessionId, lastSequence }`, each null when absent
173
+ * or not in the shape the worker may put into a request path.
174
+ */
175
+ export function readStoredClaudeCloudBinding(runtimeSession = null) {
176
+ const sessionId = toText(runtimeSession?.claude_cloud_session_id);
177
+ const lastSequence = toText(runtimeSession?.claude_cloud_last_sequence);
178
+ return {
179
+ sessionId: isSafeClaudeCloudId(sessionId) ? sessionId : null,
180
+ lastSequence: SEQUENCE_PATTERN.test(lastSequence) ? lastSequence : null,
181
+ };
182
+ }
183
+
173
184
  /** The larger of two sequence numbers (strings of digits), so a late report never moves the cursor back. */
174
185
  function laterSequence(stored, reported) {
175
186
  const a = SEQUENCE_PATTERN.test(toText(stored)) ? toText(stored) : null;
@@ -264,6 +275,31 @@ export function createClaudeCloudSessionService({
264
275
  return { ok: true, cloud };
265
276
  }
266
277
 
278
+ /**
279
+ * GET /api/claude-cloud-session: the binding a starting worker attaches to
280
+ * before any delivery (so it follows the turns the session starts by
281
+ * itself, as the worker it replaces did). `id` is the conversation id or
282
+ * the runtime session's sdk session id, which is all a worker knows of its
283
+ * conversation. A known conversation that has no cloud session (none yet,
284
+ * or another provider) answers `sessionId: null`; only an unknown id is
285
+ * 404. Returns `{ ok, statusCode, error }` or `{ ok: true, sessionId,
286
+ * lastSequence }`.
287
+ */
288
+ function describeStoredBinding(id = '') {
289
+ const key = toText(id);
290
+ if (!key) return { ok: false, statusCode: 400, error: 'Missing conversationId' };
291
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(key)
292
+ || stmts.getRuntimeSessionBySdkSessionId?.get?.(key)
293
+ || null;
294
+ if (!runtimeSession && !stmts.getConvAnyStatus?.get?.(key)) {
295
+ return { ok: false, statusCode: 404, error: 'Conversation not found' };
296
+ }
297
+ if (!runtimeSession || !isClaudeCloudProviderType(runtimeSession.provider_type)) {
298
+ return { ok: true, sessionId: null, lastSequence: null };
299
+ }
300
+ return { ok: true, ...readStoredClaudeCloudBinding(runtimeSession) };
301
+ }
302
+
267
303
  /**
268
304
  * Archives the cloud session of a conversation that is being deleted or
269
305
  * archived here. Best effort: it returns at once (true when an archive was
@@ -322,5 +358,11 @@ export function createClaudeCloudSessionService({
322
358
  return true;
323
359
  }
324
360
 
325
- return { cloudPayloadFor, recordWorkerReport, archiveSessionInBackground, unarchiveSessionInBackground };
361
+ return {
362
+ cloudPayloadFor,
363
+ recordWorkerReport,
364
+ describeStoredBinding,
365
+ archiveSessionInBackground,
366
+ unarchiveSessionInBackground,
367
+ };
326
368
  }
@@ -211,6 +211,9 @@ export function createCloudflaredTunnelManager({
211
211
  io = null,
212
212
  logger = console,
213
213
  runtimeShutdownRef = () => false,
214
+ // The tunnel stopped blocking queue delivery (it connected): the relay
215
+ // hands out what waited for it.
216
+ onBlockingLifted = () => {},
214
217
  platform = process.platform,
215
218
  spawnImpl = spawn,
216
219
  nowIso = () => new Date().toISOString(),
@@ -267,7 +270,11 @@ export function createCloudflaredTunnelManager({
267
270
  };
268
271
 
269
272
  const updateBlockingState = () => {
273
+ const wasBlocking = state.blocking;
270
274
  state.blocking = state.required && state.mode === 'managed' && !state.connected;
275
+ if (wasBlocking && !state.blocking) {
276
+ try { onBlockingLifted(); } catch {}
277
+ }
271
278
  };
272
279
 
273
280
  const scheduleReconnect = (spawnTunnel, { slowest = false } = {}) => {
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ // Timers a relay keeps only while there is something to time: the CLI
4
+ // presence deadline and the intervals armed by work and disarmed by their own
5
+ // tick. An idle relay runs neither.
6
+
7
+ // ─── CLI presence ──────────────────────────────────────────────────────────────
8
+
9
+ // Whether "the CLI" (the relay's workers) has been heard from recently, kept
10
+ // without a polling timer. Every touch moves the last-seen time; one one-shot
11
+ // deadline timer, armed only while something was seen, notices when the
12
+ // window ran out. A deadline that fires early (a touch moved last-seen) arms
13
+ // again for the remainder, so an online relay wakes about once per window and
14
+ // an offline one not at all.
15
+ //
16
+ // The window depends on the worker sockets: an idle worker beats only every
17
+ // 30 s (shared/worker-runtime/worker-keepalive.mjs), so while a worker socket
18
+ // is open the window is wide enough for one lost beat. With no socket left a
19
+ // relay whose workers are all gone still reads offline within the short
20
+ // window, as before.
21
+
22
+ export const CLI_ONLINE_WINDOW_MS = 10_000;
23
+ export const CLI_ONLINE_WINDOW_WITH_WORKER_SOCKETS_MS = 45_000;
24
+
25
+ export function createCliPresence({
26
+ windowMs = CLI_ONLINE_WINDOW_MS,
27
+ socketWindowMs = CLI_ONLINE_WINDOW_WITH_WORKER_SOCKETS_MS,
28
+ // Called once when the window ran out after a touch (the deadline timer, or
29
+ // the last worker socket closing). The presence is cleared before the call.
30
+ onExpired = () => {},
31
+ now = () => Date.now(),
32
+ setTimeoutImpl = setTimeout,
33
+ clearTimeoutImpl = clearTimeout,
34
+ } = {}) {
35
+ let lastSeenMs = null;
36
+ let workerSocketCount = 0;
37
+ let deadlineTimer = null;
38
+
39
+ function currentWindowMs() {
40
+ return workerSocketCount > 0 ? socketWindowMs : windowMs;
41
+ }
42
+
43
+ function remainingMs() {
44
+ if (lastSeenMs === null) return 0;
45
+ return lastSeenMs + currentWindowMs() - now();
46
+ }
47
+
48
+ function disarm() {
49
+ if (!deadlineTimer) return;
50
+ clearTimeoutImpl(deadlineTimer);
51
+ deadlineTimer = null;
52
+ }
53
+
54
+ function arm(delayMs) {
55
+ disarm();
56
+ deadlineTimer = setTimeoutImpl(onDeadline, Math.max(1, delayMs));
57
+ deadlineTimer?.unref?.();
58
+ }
59
+
60
+ function expire() {
61
+ disarm();
62
+ lastSeenMs = null;
63
+ try { onExpired(); } catch {}
64
+ }
65
+
66
+ function onDeadline() {
67
+ deadlineTimer = null;
68
+ if (lastSeenMs === null) return;
69
+ const left = remainingMs();
70
+ if (left > 0) {
71
+ arm(left);
72
+ return;
73
+ }
74
+ expire();
75
+ }
76
+
77
+ function touch() {
78
+ lastSeenMs = now();
79
+ // An armed deadline is left alone: when it fires it finds the newer
80
+ // touch and arms again for what is left.
81
+ if (!deadlineTimer) arm(currentWindowMs());
82
+ }
83
+
84
+ /** The worker socket count changed: the window may have shrunk. */
85
+ function noteWorkerSocketCount(count) {
86
+ workerSocketCount = Math.max(0, Number(count) || 0);
87
+ if (lastSeenMs === null) return;
88
+ const left = remainingMs();
89
+ if (left <= 0) {
90
+ expire();
91
+ return;
92
+ }
93
+ arm(left);
94
+ }
95
+
96
+ /** Forget the last touch without calling onExpired (an explicit offline). */
97
+ function clear() {
98
+ disarm();
99
+ lastSeenMs = null;
100
+ }
101
+
102
+ function isFresh() {
103
+ return remainingMs() > 0;
104
+ }
105
+
106
+ return {
107
+ touch,
108
+ clear,
109
+ isFresh,
110
+ noteWorkerSocketCount,
111
+ status: () => ({
112
+ lastSeenMs,
113
+ workerSocketCount,
114
+ windowMs: currentWindowMs(),
115
+ armed: deadlineTimer !== null,
116
+ }),
117
+ };
118
+ }
119
+
120
+ // ─── Armable interval ──────────────────────────────────────────────────────────
121
+
122
+ // An interval that runs only while there is something for it to do: armed by
123
+ // whoever creates that work (a share viewer arriving, a pending row waiting
124
+ // for its worker), disarmed by its own tick once the work is gone. An idle
125
+ // relay then has no such timer at all. The timer is unref'd, and stop() (the
126
+ // runtime's shutdown) disarms it for good.
127
+
128
+ export function createArmableInterval({
129
+ intervalMs,
130
+ // One tick. Returns (or resolves to) `false` to disarm: nothing is left to
131
+ // do. Anything else keeps the interval running. An arm that lands while a
132
+ // tick is still running (the tick awaits) keeps the interval running even
133
+ // if that tick returns false: the work it was armed for may be newer than
134
+ // what the tick looked at.
135
+ tick,
136
+ onError = () => {},
137
+ setIntervalImpl = setInterval,
138
+ clearIntervalImpl = clearInterval,
139
+ } = {}) {
140
+ if (typeof tick !== 'function') throw new Error('createArmableInterval requires tick');
141
+ const periodMs = Math.max(1, Number(intervalMs) || 1);
142
+ let timer = null;
143
+ let stopped = false;
144
+ let generation = 0;
145
+
146
+ function disarm() {
147
+ if (!timer) return;
148
+ clearIntervalImpl(timer);
149
+ timer = null;
150
+ }
151
+
152
+ async function runTick() {
153
+ const armedAt = generation;
154
+ let keep;
155
+ try {
156
+ keep = await tick();
157
+ } catch (error) {
158
+ try { onError(error); } catch {}
159
+ return;
160
+ }
161
+ if (keep === false && generation === armedAt) disarm();
162
+ }
163
+
164
+ function arm() {
165
+ if (stopped) return false;
166
+ generation += 1;
167
+ if (timer) return false;
168
+ timer = setIntervalImpl(() => { void runTick(); }, periodMs);
169
+ timer?.unref?.();
170
+ return true;
171
+ }
172
+
173
+ function stop() {
174
+ stopped = true;
175
+ disarm();
176
+ }
177
+
178
+ return {
179
+ arm,
180
+ disarm,
181
+ stop,
182
+ isArmed: () => timer !== null,
183
+ };
184
+ }
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ // Wakes the relay's queue delivery from the database itself. Every write
4
+ // that can make a queue row deliverable (a new pending row, a requeue, a
5
+ // retry time or owner or provider changed, a turn ending, a pending row
6
+ // cancelled or deleted) fires a TEMP trigger on this connection, which calls
7
+ // a JS function that only notes the reason and schedules one wake for after
8
+ // the current tick, so after the write's transaction. The function never
9
+ // touches the database and never throws.
10
+ //
11
+ // No row path has to remember to wake the queue, and none added later can
12
+ // forget: without it, a path that wrote a row and did not wake waited for
13
+ // the next poll. The triggers are TEMP (this connection only, nothing in the
14
+ // schema or the migrations). Their conditions name only the columns the
15
+ // queue table has, a self-test compiles every trigger inside a rolled-back
16
+ // savepoint, and on any error the triggers are dropped again and the caller
17
+ // falls back to polling: a broken trigger would fail every write to the
18
+ // queue.
19
+
20
+ export const QUEUE_WAKE_FUNCTION = 'oar_queue_wake';
21
+ const TRIGGER_PREFIX = 'oar_queue_wake_';
22
+ const TRIGGER_NAMES = ['insert', 'pending', 'settle', 'leave_pending', 'delete'].map((name) => `${TRIGGER_PREFIX}${name}`);
23
+ // A bulk statement calls the function once per row: the reasons of one tick
24
+ // are kept as a small set.
25
+ const MAX_REASONS = 8;
26
+
27
+ function queueColumns(db) {
28
+ return new Set(db.prepare('PRAGMA table_info(queue)').all().map((column) => String(column.name)));
29
+ }
30
+
31
+ /** The CREATE TEMP TRIGGER statements, their conditions built from the columns there are. */
32
+ export function buildQueueWakeTriggerSql(columns) {
33
+ const has = (name) => columns.has(name);
34
+ if (!has('status')) throw new Error('queue has no status column');
35
+ const changed = ["OLD.status IS NOT 'pending'"];
36
+ for (const column of ['next_attempt_at', 'owner_sdk_session_id', 'provider']) {
37
+ if (has(column)) changed.push(`OLD.${column} IS NOT NEW.${column}`);
38
+ }
39
+ const call = (reason) => `BEGIN SELECT ${QUEUE_WAKE_FUNCTION}('${reason}'); END`;
40
+ return [
41
+ // A new pending row.
42
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}insert AFTER INSERT ON main.queue
43
+ WHEN NEW.status = 'pending' ${call('insert')}`,
44
+ // A row that became pending, or a pending row whose retry time, owner or
45
+ // provider changed (a requeue, a hold released, a row moved to another
46
+ // worker). A claim leaves pending and a lease refresh stays processing:
47
+ // neither wakes.
48
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}pending AFTER UPDATE ON main.queue
49
+ WHEN NEW.status = 'pending' AND (${changed.join(' OR ')}) ${call('requeue')}`,
50
+ // A turn ended: its session's next row, or one held behind it by the plan
51
+ // boundary, may go now.
52
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}settle AFTER UPDATE ON main.queue
53
+ WHEN OLD.status = 'processing' AND NEW.status IS NOT 'processing' ${call('settle')}`,
54
+ // A pending row cancelled, failed or parked: a row held behind it may go.
55
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}leave_pending AFTER UPDATE ON main.queue
56
+ WHEN OLD.status = 'pending' AND NEW.status NOT IN ('pending', 'processing') ${call('cancel')}`,
57
+ // A pending or running row deleted (the cancel routes): the same.
58
+ `CREATE TEMP TRIGGER ${TRIGGER_PREFIX}delete AFTER DELETE ON main.queue
59
+ WHEN OLD.status IN ('pending', 'processing') ${call('delete')}`,
60
+ ];
61
+ }
62
+
63
+ function dropTriggers(db) {
64
+ for (const name of TRIGGER_NAMES) {
65
+ try { db.exec(`DROP TRIGGER IF EXISTS temp.${name}`); } catch {}
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Compiles every trigger by preparing a write of each kind, and runs one real
71
+ * update, inside a savepoint that is rolled back: a condition naming a column
72
+ * the table lacks fails here instead of on the first real write.
73
+ */
74
+ function selfTestTriggers(db) {
75
+ db.exec('SAVEPOINT oar_queue_wake_self_test');
76
+ try {
77
+ db.prepare('UPDATE queue SET status = status WHERE rowid = (SELECT rowid FROM queue LIMIT 1)').run();
78
+ db.prepare('UPDATE queue SET status = status WHERE 0').run();
79
+ db.prepare('DELETE FROM queue WHERE 0').run();
80
+ db.prepare('INSERT INTO queue SELECT * FROM queue WHERE 0').run();
81
+ } finally {
82
+ db.exec('ROLLBACK TO oar_queue_wake_self_test');
83
+ db.exec('RELEASE oar_queue_wake_self_test');
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Installs the triggers on `db`. `onWake(reason)` runs once per tick in which
89
+ * queue writes fired them, with the reasons joined. Returns `{ installed }`;
90
+ * when false the triggers are gone again and the caller must poll.
91
+ */
92
+ export function installQueueWakeHook(db, {
93
+ onWake = () => {},
94
+ log = () => {},
95
+ schedule = setImmediate,
96
+ selfTest = selfTestTriggers,
97
+ } = {}) {
98
+ const reasons = new Set();
99
+ let scheduled = false;
100
+ let muted = false;
101
+
102
+ function flush() {
103
+ scheduled = false;
104
+ const reason = [...reasons].join(',') || 'queue-write';
105
+ reasons.clear();
106
+ try {
107
+ onWake(reason);
108
+ } catch (error) {
109
+ log(`queue wake failed: ${error?.message || error}`);
110
+ }
111
+ }
112
+
113
+ // Called by SQLite from inside a write: no database access, no throw.
114
+ function wakeFromTrigger(reason) {
115
+ try {
116
+ if (muted) return null;
117
+ if (reasons.size < MAX_REASONS) reasons.add(String(reason || 'queue-write'));
118
+ if (!scheduled) {
119
+ scheduled = true;
120
+ schedule(flush);
121
+ }
122
+ } catch {}
123
+ return null;
124
+ }
125
+
126
+ try {
127
+ const sql = buildQueueWakeTriggerSql(queueColumns(db));
128
+ // The function first: a trigger calling a function that does not exist
129
+ // yet would fail the write.
130
+ db.function(QUEUE_WAKE_FUNCTION, { deterministic: false }, wakeFromTrigger);
131
+ dropTriggers(db);
132
+ for (const statement of sql) db.exec(statement);
133
+ muted = true;
134
+ try {
135
+ selfTest(db);
136
+ } finally {
137
+ muted = false;
138
+ }
139
+ return { installed: true, triggers: [...TRIGGER_NAMES] };
140
+ } catch (error) {
141
+ dropTriggers(db);
142
+ log(`queue wake triggers not installed, the queue is polled instead: ${error?.message || error}`);
143
+ return { installed: false, error };
144
+ }
145
+ }