@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.
- package/CHANGELOG.md +206 -0
- package/README.md +9 -9
- package/bin/oar.js +28 -0
- package/package.json +13 -14
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +9 -4
- package/server/claude-worker/claude-session-worker-main.mjs +10 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
- package/server/cursor-worker/cursor-session-worker.mjs +9 -1
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +1 -0
- package/server/grok-worker/grok-session-worker.mjs +10 -1
- package/server/grok-worker/grok-turn-runner.mjs +1 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +7 -0
- package/server/public/app/ask-user-view.js +4 -3
- package/server/public/app/bootstrap.js +62 -4
- package/server/public/app/chat-actions-menu-state.mjs +2 -0
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-view.js +306 -49
- package/server/public/app/header-priority-actions.mjs +5 -1
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/inbox-ui.js +70 -79
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +17 -0
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/session-history-ui.js +1 -0
- package/server/public/app/settings-modal.js +6 -0
- package/server/public/app/socket-handlers.js +42 -11
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +8 -6
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/index.html +391 -214
- package/server/repositories/message-repository.mjs +10 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/messages-routes.mjs +46 -34
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +77 -6
- package/server/server-runtime.mjs +199 -171
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/earlier-database-service.mjs +1 -1
- package/server/services/file-serving.mjs +13 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +7 -4
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-console-log.mjs +2 -3
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +143 -15
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/update-install-service.mjs +32 -1
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +26 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
- package/server/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- 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
|
|
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
|
-
|
|
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
|
+
}
|