@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.
- package/CHANGELOG.md +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -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/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +309 -49
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- 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-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- 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 +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- 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-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- 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
|
-
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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' :
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|