@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
@@ -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 } = {}) => {
@@ -12,7 +12,7 @@ import { resolveStateLayout } from './oar-state-layout.mjs';
12
12
  import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
13
13
 
14
14
  const SHA256_NAME = /^[a-f0-9]{64}$/;
15
- // The install-time rescue's copies (package-data-rescue.mjs), in the data folder.
15
+ // The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
16
16
  export const RESCUE_DIR_NAME = 'rescued-package-data';
17
17
  // What was looked at already: never offered again unless it changed.
18
18
  export const CHECKED_FILE = 'earlier-databases.json';
@@ -60,6 +60,19 @@ export function withFileVersion(url, version) {
60
60
  return appendUrlParam(String(url || ''), 'v', String(version || ''));
61
61
  }
62
62
 
63
+ /**
64
+ * One of the relay's own files (sw.js, the socket.io client) through
65
+ * res.sendFile, rooted at its folder. Given a bare absolute path, express 5's
66
+ * send refuses any path with a dot folder in it (dotfiles 'ignore' looks at
67
+ * every segment without a root): a 404 for an install under ~/.oar, nvm or
68
+ * ~/.npm-global. With the folder as root only the file name is looked at.
69
+ */
70
+ export function sendOwnFile(res, absolutePath, next) {
71
+ res.sendFile(path.basename(absolutePath), { root: path.dirname(absolutePath) }, (error) => {
72
+ if (error) next(error);
73
+ });
74
+ }
75
+
63
76
  /**
64
77
  * `cacheControl` replaces the no-store default for content whose URL names
65
78
  * its bytes (an upload addressed by its hash): such an answer may be kept.
@@ -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,224 @@
1
+ /**
2
+ * better-sqlite3 13 ships its binary for linux (glibc and musl), darwin and
3
+ * win32 on x64 and arm64, and runs no install script. Anywhere else (Android
4
+ * under Termux, 32-bit ARM, FreeBSD) the first database open finds no binary
5
+ * and throws: the binding has to be compiled from the sources the package
6
+ * ships. OAR runs that build itself, after an update (the Update button,
7
+ * `oar update`) and as `oar build-native`, never as an install script of its
8
+ * own package: one would bring back the install-script warning every package
9
+ * scanner shows for OAR, and npm 12 skips it unless the user allows it.
10
+ *
11
+ * The build is node-gyp's, the one npm bundles (npx's as the fallback), in the
12
+ * package's own folder with `--force_build=1`, as the package's
13
+ * `build-release` script runs it. It needs Python 3, make and a C++ compiler.
14
+ */
15
+ import { spawn as defaultSpawn } from 'child_process';
16
+ import fs from 'fs';
17
+ import { createRequire } from 'module';
18
+ import path from 'path';
19
+
20
+ import { killTree, runToCompletion, tailOf } from './cli-process-runner.mjs';
21
+
22
+ export const BETTER_SQLITE3_PREBUILD_PLATFORMS = Object.freeze(['linux', 'darwin', 'win32']);
23
+ export const BETTER_SQLITE3_PREBUILD_ARCHS = Object.freeze(['x64', 'arm64']);
24
+ export const NATIVE_BUILD_COMMAND = 'oar build-native';
25
+ // sqlite3.c is one large file: a phone takes minutes for it.
26
+ export const NATIVE_BUILD_TIMEOUT_MS = 30 * 60_000;
27
+ export const TERMUX_BUILD_TOOLS = 'pkg install python clang make';
28
+
29
+ const BINDING_FILE = path.join('build', 'Release', 'better_sqlite3.node');
30
+
31
+ /** True where better-sqlite3 has no prebuilt binary and must be compiled. */
32
+ export function needsSourceBuild({ platform = process.platform, arch = process.arch } = {}) {
33
+ return !(BETTER_SQLITE3_PREBUILD_PLATFORMS.includes(platform) && BETTER_SQLITE3_PREBUILD_ARCHS.includes(arch));
34
+ }
35
+
36
+ /** The folder of the better-sqlite3 this package loads, or null. */
37
+ export function resolveBetterSqlite3Dir({ packageRoot, requireFrom = createRequire } = {}) {
38
+ try {
39
+ const require = requireFrom(path.join(path.resolve(String(packageRoot || '.')), 'package.json'));
40
+ return path.dirname(require.resolve('better-sqlite3/package.json'));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** The error better-sqlite3 throws when it finds neither a prebuilt nor a built binary. */
47
+ export function isMissingSqliteBindingError(error) {
48
+ const code = String(error?.code || '');
49
+ return (code === 'MODULE_NOT_FOUND' || code === 'ERR_DLOPEN_FAILED')
50
+ && /better_sqlite3\.node|better-sqlite3[\\/]prebuilds[\\/][^\\/]+\.node/.test(String(error?.message || ''));
51
+ }
52
+
53
+ /**
54
+ * The lines a relay prints, instead of a stack trace, when it cannot open a
55
+ * database because the binding is missing.
56
+ */
57
+ export function missingSqliteBindingLines({ platform = process.platform, arch = process.arch, error = null } = {}) {
58
+ const target = `${platform}-${arch}`;
59
+ if (!needsSourceBuild({ platform, arch })) {
60
+ // There, but not loadable on this system. better-sqlite3 loads its
61
+ // prebuilt binding before a built one, so no build here would be used.
62
+ if (String(error?.code || '') === 'ERR_DLOPEN_FAILED') {
63
+ return [
64
+ `The SQLite binding that better-sqlite3 ships for ${target} does not load on this system (${String(error?.message || '').split('\n')[0]}).`,
65
+ 'Its C library is too old for it, or the system is not one the binding supports.',
66
+ 'Update the operating system, or run the relay on a supported one.',
67
+ ];
68
+ }
69
+ return [
70
+ `The SQLite binding of better-sqlite3 for ${target} is missing from the installed package (${String(error?.message || '').split('\n')[0]}).`,
71
+ 'Reinstall OAR: npm install -g @oar-sh/oar',
72
+ ];
73
+ }
74
+ return [
75
+ `better-sqlite3 has no prebuilt SQLite binding for ${target}, and it has not been built here yet.`,
76
+ `Build it with: ${NATIVE_BUILD_COMMAND}`,
77
+ platform === 'android'
78
+ ? `It needs Python 3, make and a C++ compiler: ${TERMUX_BUILD_TOOLS}`
79
+ : 'It needs Python 3, make and a C++ compiler.',
80
+ ];
81
+ }
82
+
83
+ // The node-gyp that ships inside npm, beside this Node; null when there is none.
84
+ function bundledNodeGyp({ execPath, platform, existsImpl, realpathImpl }) {
85
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix;
86
+ const nodeDir = pathImpl.dirname(String(execPath || ''));
87
+ const npmRoots = [];
88
+ if (platform !== 'win32') {
89
+ // <prefix>/bin/npm is a link to <prefix>/lib/node_modules/npm/bin/npm-cli.js.
90
+ try { npmRoots.push(pathImpl.dirname(pathImpl.dirname(realpathImpl(pathImpl.join(nodeDir, 'npm'))))); } catch {}
91
+ npmRoots.push(pathImpl.join(nodeDir, '..', 'lib', 'node_modules', 'npm'));
92
+ } else {
93
+ npmRoots.push(pathImpl.join(nodeDir, 'node_modules', 'npm'));
94
+ }
95
+ for (const root of npmRoots) {
96
+ const file = pathImpl.join(root, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js');
97
+ if (existsImpl(file)) return file;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * How to run node-gyp: npm's own (npm_config_node_gyp when npm set it), else
104
+ * `npx --yes node-gyp`. Returns { command, args, env }.
105
+ */
106
+ export function resolveNodeGypInvocation({
107
+ execPath = process.execPath,
108
+ platform = process.platform,
109
+ env = process.env,
110
+ existsImpl = fs.existsSync,
111
+ realpathImpl = fs.realpathSync,
112
+ } = {}) {
113
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix;
114
+ const nodeDir = pathImpl.dirname(String(execPath || ''));
115
+ const separator = platform === 'win32' ? ';' : ':';
116
+ const pathKey = Object.keys(env || {}).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
117
+ // node-gyp runs `node` from the gyp files: this Node first on its PATH.
118
+ const childEnv = { ...env, [pathKey]: [nodeDir, String(env?.[pathKey] || '')].filter(Boolean).join(separator) };
119
+ // Node for Termux has no android_ndk_path in its common.gypi, which gyp
120
+ // then refuses as undefined; an empty one builds with Termux's own clang.
121
+ if (platform === 'android' && !/\bandroid_ndk_path=/.test(String(env?.GYP_DEFINES || ''))) {
122
+ childEnv.GYP_DEFINES = [String(env?.GYP_DEFINES || '').trim(), "android_ndk_path=''"].filter(Boolean).join(' ');
123
+ }
124
+ const fromNpm = String(env?.npm_config_node_gyp || '').trim();
125
+ const gyp = (fromNpm && existsImpl(fromNpm) ? fromNpm : null) || bundledNodeGyp({ execPath, platform, existsImpl, realpathImpl });
126
+ if (gyp) return { command: execPath, args: [gyp], env: childEnv };
127
+ if (platform === 'win32') {
128
+ // npx.cmd runs through a shell, which splits a path with a space in it
129
+ // (C:\Program Files): this Node with npm's npx-cli.js, else npx.cmd by name.
130
+ const npxCli = pathImpl.join(nodeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js');
131
+ if (existsImpl(npxCli)) return { command: execPath, args: [npxCli, '--yes', 'node-gyp'], env: childEnv };
132
+ return { command: 'npx.cmd', args: ['--yes', 'node-gyp'], env: childEnv };
133
+ }
134
+ const npx = pathImpl.join(nodeDir, 'npx');
135
+ return { command: existsImpl(npx) ? npx : 'npx', args: ['--yes', 'node-gyp'], env: childEnv };
136
+ }
137
+
138
+ // What a failed build's output says is missing, as one line to act on.
139
+ function missingToolHint(output, platform) {
140
+ const text = String(output || '');
141
+ const missing = /find Python|Could not find any Python|python3?: (?:not found|command not found)/i.test(text)
142
+ || /make: (?:not found|command not found)|not found: make|spawn make ENOENT/i.test(text)
143
+ || /(?:c\+\+|g\+\+|clang\+\+|cc|clang): (?:not found|command not found)|No such file or directory.*(?:c\+\+|g\+\+|clang)/i.test(text);
144
+ if (!missing) return null;
145
+ return platform === 'android'
146
+ ? `Install the build tools first: ${TERMUX_BUILD_TOOLS}, then run ${NATIVE_BUILD_COMMAND} again.`
147
+ : `Install Python 3, make and a C++ compiler (build-essential on Debian and Ubuntu), then run ${NATIVE_BUILD_COMMAND} again.`;
148
+ }
149
+
150
+ // Opens a database in a child Node with the binding just built, named by its
151
+ // path: the default loader would take a prebuilt one first where one ships.
152
+ function bindingLoads({ dir, execPath, spawnImpl, env }) {
153
+ const binding = path.join(dir, BINDING_FILE);
154
+ const script = `const Database = require(${JSON.stringify(dir)}); new Database(':memory:', { nativeBinding: ${JSON.stringify(binding)} }).close();`;
155
+ return runToCompletion(
156
+ () => spawnImpl(execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'pipe'], env, windowsHide: true }),
157
+ { timeoutMs: 60_000 },
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Builds the binding when this platform has no prebuilt one. `force` builds
163
+ * where one ships too, to check the toolchain; better-sqlite3 still loads its
164
+ * prebuilt binding first there. Resolves (never rejects) with { ok, status, error?, hint? }:
165
+ * status 'prebuilt' (nothing to do), 'built', 'failed'. Every line of the
166
+ * build goes to `onOutput`; what it says about itself goes to `logger.log`.
167
+ */
168
+ export async function buildBetterSqlite3({
169
+ packageRoot,
170
+ force = false,
171
+ platform = process.platform,
172
+ arch = process.arch,
173
+ execPath = process.execPath,
174
+ env = process.env,
175
+ spawnImpl = defaultSpawn,
176
+ existsImpl = fs.existsSync,
177
+ realpathImpl = fs.realpathSync,
178
+ requireFrom = createRequire,
179
+ timeoutMs = NATIVE_BUILD_TIMEOUT_MS,
180
+ onOutput = null,
181
+ onChild = null,
182
+ logger = console,
183
+ } = {}) {
184
+ if (!force && !needsSourceBuild({ platform, arch })) return { ok: true, status: 'prebuilt' };
185
+ const dir = resolveBetterSqlite3Dir({ packageRoot, requireFrom });
186
+ if (!dir) {
187
+ const error = `better-sqlite3 is not installed under ${packageRoot}`;
188
+ logger.error?.(`[oar] ${error}.`);
189
+ return { ok: false, status: 'failed', error };
190
+ }
191
+ const gyp = resolveNodeGypInvocation({ execPath, platform, env, existsImpl, realpathImpl });
192
+ const args = [...gyp.args, 'rebuild', '--release', '--force_build=1'];
193
+ logger.log?.(`[oar] Building the SQLite binding of better-sqlite3 for ${platform}-${arch} in ${dir}; this takes a few minutes.`);
194
+ const result = await runToCompletion(
195
+ () => spawnImpl(gyp.command, args, {
196
+ cwd: dir,
197
+ env: gyp.env,
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ ...(platform === 'win32' ? { windowsHide: true, shell: /\.cmd$/i.test(gyp.command) } : { detached: true }),
200
+ }),
201
+ {
202
+ timeoutMs,
203
+ killChild: (child, signal) => killTree(child, signal, { platform }),
204
+ onChild,
205
+ onOutput,
206
+ },
207
+ );
208
+ if (!result.ok || !existsImpl(path.join(dir, BINDING_FILE))) {
209
+ const error = result.error || `node-gyp exited with code ${result.code}`;
210
+ const hint = missingToolHint(result.output, platform);
211
+ logger.error?.(`[oar] The build of the SQLite binding failed: ${error}.`);
212
+ if (hint) logger.error?.(`[oar] ${hint}`);
213
+ else logger.error?.(`[oar] The end of its output:\n${tailOf(result.output, 2_000)}`);
214
+ return { ok: false, status: 'failed', error, ...(hint ? { hint } : {}) };
215
+ }
216
+ const check = await bindingLoads({ dir, execPath, spawnImpl, env: gyp.env });
217
+ if (!check.ok) {
218
+ const error = `the built binding does not load: ${tailOf(check.output || check.error || '', 500).trim()}`;
219
+ logger.error?.(`[oar] ${error}`);
220
+ return { ok: false, status: 'failed', error };
221
+ }
222
+ logger.log?.('[oar] The SQLite binding is built.');
223
+ return { ok: true, status: 'built' };
224
+ }
@@ -86,6 +86,7 @@ const CLI_COMMANDS = {
86
86
  autostart: { words: ['signin', 'boot', 'off', 'status'], flags: ['--replace'] },
87
87
  uninstall: { flags: ['--purge'] },
88
88
  update: { flags: ['--beta'], values: ['--to'] },
89
+ 'build-native': { flags: ['--force'] },
89
90
  copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
90
91
  };
91
92
 
@@ -116,6 +117,9 @@ export function usageText() {
116
117
  ' oar backup [--reason <text>]',
117
118
  ' A copy of the database into the backup folder (safe while the relay runs)',
118
119
  ' oar update [--beta] [--to <version>]',
120
+ ' oar build-native [--force]',
121
+ ' Build the SQLite binding where no prebuilt one ships (Termux, 32-bit ARM);',
122
+ ' --force builds where one ships too, which only tests the build tools',
119
123
  ' oar help This text',
120
124
  ' oar --version',
121
125
  ].join('\n');
@@ -401,11 +405,10 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
401
405
  * native binary). npm 12 runs no install script it was not told to allow, and
402
406
  * a global install has no package.json of its own to say so; npm 10 and 11
403
407
  * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
404
- * holds this list to the lockfile.
408
+ * holds this list to the lockfile. better-sqlite3 13 has no install script:
409
+ * where it ships no binary, OAR builds it (native-addon-build-service.mjs).
405
410
  */
406
- // @oar-sh/oar itself: its preinstall copies the data an earlier version
407
- // kept inside the package before npm deletes it (package-data-rescue.mjs).
408
- export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['@oar-sh/oar', 'better-sqlite3', 'koffi']);
411
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['koffi']);
409
412
  export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
410
413
 
411
414
  /**
@@ -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
+ }