@oar-sh/oar 0.9.12 → 0.9.14
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/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -1,6 +1,128 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import crypto from "crypto";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { createRequire } from "module";
|
|
7
|
+
|
|
8
|
+
// An EPERM is a process this user may not signal: alive.
|
|
9
|
+
export function defaultIsProcessAlive(pid) {
|
|
10
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return error?.code === "EPERM";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let processTimes;
|
|
20
|
+
// When a process started, in ms, or null when that cannot be told. Windows
|
|
21
|
+
// asks the kernel (GetProcessTimes through koffi, which the Copilot SDK
|
|
22
|
+
// brings): PowerShell fails in ConstrainedLanguage mode and with a broken
|
|
23
|
+
// TEMP. Elsewhere `ps`.
|
|
24
|
+
export function processStartedAt(pid, { platform = process.platform } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
if (platform !== "win32") {
|
|
27
|
+
// `lstart`, the full date; LC_ALL=C to parse; unparsable: cannot tell.
|
|
28
|
+
const result = spawnSync("ps", ["-o", "lstart=", "-p", String(Number(pid))], { encoding: "utf8", timeout: 5000, env: { ...process.env, LC_ALL: "C" } });
|
|
29
|
+
const ms = Date.parse(String(result.stdout || "").trim());
|
|
30
|
+
return Number.isFinite(ms) ? ms : null;
|
|
31
|
+
}
|
|
32
|
+
if (!processTimes) {
|
|
33
|
+
const koffi = createRequire(import.meta.url)("koffi");
|
|
34
|
+
const kernel32 = koffi.load("kernel32.dll");
|
|
35
|
+
processTimes = {
|
|
36
|
+
open: kernel32.func("void * __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)"),
|
|
37
|
+
times: kernel32.func("bool __stdcall GetProcessTimes(void *h, _Out_ uint64_t *a, _Out_ uint64_t *b, _Out_ uint64_t *c, _Out_ uint64_t *d)"),
|
|
38
|
+
close: kernel32.func("bool __stdcall CloseHandle(void *h)"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const handle = processTimes.open(0x1000, false, Number(pid)); // PROCESS_QUERY_LIMITED_INFORMATION
|
|
42
|
+
if (!handle) return null;
|
|
43
|
+
try {
|
|
44
|
+
const created = [0n];
|
|
45
|
+
if (!processTimes.times(handle, created, [0n], [0n], [0n])) return null;
|
|
46
|
+
return Number(BigInt(created[0]) / 10000n) - 11644473600000; // FILETIME: 100 ns since 1601
|
|
47
|
+
} finally {
|
|
48
|
+
processTimes.close(handle);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let bootId;
|
|
56
|
+
// Linux's id of the running boot, '' elsewhere or when unreadable.
|
|
57
|
+
export function currentBootId({ platform = process.platform, readFile = fs.readFileSync } = {}) {
|
|
58
|
+
if (platform !== "linux") return "";
|
|
59
|
+
try {
|
|
60
|
+
bootId ??= String(readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
|
|
61
|
+
return bootId;
|
|
62
|
+
} catch {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A process's start as the system keeps it, which no later clock step moves:
|
|
69
|
+
* Windows' creation time (exact), Linux's start in ticks since boot with the
|
|
70
|
+
* boot id, elsewhere `ps` (to the second). Null when it cannot be told.
|
|
71
|
+
*/
|
|
72
|
+
export function processIdentity(pid, { platform = process.platform, readFile = fs.readFileSync } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
if (platform === "linux") {
|
|
75
|
+
const stat = String(readFile(`/proc/${Number(pid)}/stat`, "utf8"));
|
|
76
|
+
const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]; // field 22, starttime
|
|
77
|
+
return /^\d+$/.test(ticks) ? { start: `ticks:${ticks}`, bootId: currentBootId({ platform, readFile }) } : null;
|
|
78
|
+
}
|
|
79
|
+
const ms = processStartedAt(pid, { platform });
|
|
80
|
+
if (ms === null) return null;
|
|
81
|
+
return { start: platform === "win32" ? `win:${ms}` : ms };
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Exact for the kernel's own values, to the second for `ps`.
|
|
88
|
+
function sameStart(a, b) {
|
|
89
|
+
return typeof a === "number" && typeof b === "number" ? Math.abs(a - b) <= 1000 : a === b;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the relay a lock names may still run; true when that cannot be
|
|
94
|
+
* told: better a refused start than two relays. A lock this version wrote
|
|
95
|
+
* (format 2) never goes by the clock: a live pid (EPERM counts) holds it
|
|
96
|
+
* when its start matches the recorded holder, or when either start is
|
|
97
|
+
* unknown; it is stale when the pid's start differs, or when the boot id it
|
|
98
|
+
* recorded (Linux) is not the running one. Only a lock without the format
|
|
99
|
+
* (0.9.13's) goes by the clock, the one case a clock step can mislead:
|
|
100
|
+
* written before the last boot, or older than the pid's process by more
|
|
101
|
+
* than 5 s (a reused pid), it is stale.
|
|
102
|
+
*/
|
|
103
|
+
export function lockHolderMayRun({
|
|
104
|
+
lock,
|
|
105
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
106
|
+
identityOf = processIdentity,
|
|
107
|
+
bootIdOf = currentBootId,
|
|
108
|
+
startedAtOf = processStartedAt,
|
|
109
|
+
bootTime = Date.now() - os.uptime() * 1000,
|
|
110
|
+
}) {
|
|
111
|
+
const pid = Number.parseInt(String(lock?.pid ?? ""), 10);
|
|
112
|
+
if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return false;
|
|
113
|
+
if (lock?.format >= 2 || lock?.holder) {
|
|
114
|
+
const current = lock.holder ? identityOf(pid) : null;
|
|
115
|
+
const recordedBoot = lock.holder?.bootId || lock.bootId;
|
|
116
|
+
const runningBoot = current?.bootId || bootIdOf();
|
|
117
|
+
if (recordedBoot && runningBoot && recordedBoot !== runningBoot) return false;
|
|
118
|
+
return !current || sameStart(lock.holder.start, current.start);
|
|
119
|
+
}
|
|
120
|
+
const lockedAt = Date.parse(String(lock?.startedAt || ""));
|
|
121
|
+
if (Number.isFinite(lockedAt) && lockedAt < bootTime) return false;
|
|
122
|
+
const startedAt = startedAtOf(pid);
|
|
123
|
+
if (Number.isFinite(lockedAt) && startedAt !== null && startedAt > lockedAt + 5000) return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
4
126
|
|
|
5
127
|
function hashToken(value) {
|
|
6
128
|
const token = String(value || "").trim();
|
|
@@ -21,6 +143,9 @@ function safeReadLock(lockPath) {
|
|
|
21
143
|
pid: Number.parseInt(String(parsed?.pid ?? ""), 10),
|
|
22
144
|
tokenHash,
|
|
23
145
|
startedAt: typeof parsed?.startedAt === "string" ? parsed.startedAt : null,
|
|
146
|
+
holder: parsed?.holder && typeof parsed.holder === "object" ? parsed.holder : null,
|
|
147
|
+
format: Number(parsed?.format) || 0,
|
|
148
|
+
bootId: typeof parsed?.bootId === "string" ? parsed.bootId : "",
|
|
24
149
|
};
|
|
25
150
|
} catch {
|
|
26
151
|
return null;
|
|
@@ -33,23 +158,27 @@ export function createRelaySingletonGuard({
|
|
|
33
158
|
token,
|
|
34
159
|
now = () => new Date().toISOString(),
|
|
35
160
|
isProcessAlive,
|
|
161
|
+
// When a pid's process started, and when the machine booted (lockHolderMayRun).
|
|
162
|
+
identityOf = processIdentity,
|
|
163
|
+
bootIdOf = currentBootId,
|
|
164
|
+
startedAtOf = processStartedAt,
|
|
165
|
+
bootTime = Date.now() - os.uptime() * 1000,
|
|
166
|
+
// An unreadable lock is read again for `readRetryMs`; still unreadable,
|
|
167
|
+
// it is stale only when its time is more than `unreadableStaleMs` off.
|
|
168
|
+
readRetryMs = 2000,
|
|
169
|
+
unreadableStaleMs = 10_000,
|
|
170
|
+
clock = Date.now,
|
|
171
|
+
sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
|
|
36
172
|
logger = console,
|
|
37
173
|
}) {
|
|
38
174
|
const lockFilePath = path.resolve(String(lockPath || ""));
|
|
39
175
|
const currentPid = Number.parseInt(String(pid || process.pid), 10);
|
|
40
176
|
const currentTokenHash = hashToken(token);
|
|
41
|
-
const processAlive = typeof isProcessAlive === "function"
|
|
42
|
-
? isProcessAlive
|
|
43
|
-
: (candidatePid) => {
|
|
44
|
-
if (!Number.isInteger(candidatePid) || candidatePid <= 0) return false;
|
|
45
|
-
try {
|
|
46
|
-
process.kill(candidatePid, 0);
|
|
47
|
-
return true;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
};
|
|
177
|
+
const processAlive = typeof isProcessAlive === "function" ? isProcessAlive : defaultIsProcessAlive;
|
|
52
178
|
|
|
179
|
+
// The whole lock goes into a file of its own, linked into place (fails
|
|
180
|
+
// when a lock is there): no start ever reads one half written. Where the
|
|
181
|
+
// folder takes no hard links (FAT, exFAT), created and written in place.
|
|
53
182
|
function writeLock() {
|
|
54
183
|
fs.mkdirSync(path.dirname(lockFilePath), { recursive: true });
|
|
55
184
|
const payload = {
|
|
@@ -59,47 +188,182 @@ export function createRelaySingletonGuard({
|
|
|
59
188
|
// credential out of a file that has historically been world-readable.
|
|
60
189
|
tokenHash: currentTokenHash,
|
|
61
190
|
startedAt: now(),
|
|
191
|
+
// The holder, as lockHolderMayRun tells it without the clock (null:
|
|
192
|
+
// unknown, which then holds the lock while its pid lives).
|
|
193
|
+
format: 2,
|
|
194
|
+
holder: identityOf(currentPid) || null,
|
|
195
|
+
...(bootIdOf() ? { bootId: bootIdOf() } : {}),
|
|
62
196
|
};
|
|
63
|
-
const
|
|
197
|
+
const text = `${JSON.stringify(payload, null, 2)}\n`;
|
|
198
|
+
const staged = `${lockFilePath}.${currentPid}-${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
199
|
+
let linked = false;
|
|
200
|
+
let written = false;
|
|
64
201
|
try {
|
|
65
|
-
fs.writeFileSync(
|
|
202
|
+
fs.writeFileSync(staged, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
203
|
+
written = true;
|
|
204
|
+
fs.linkSync(staged, lockFilePath);
|
|
205
|
+
linked = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (written && error?.code === "EEXIST") throw error;
|
|
66
208
|
} finally {
|
|
67
|
-
try { fs.
|
|
209
|
+
if (written) try { fs.unlinkSync(staged); } catch {}
|
|
210
|
+
}
|
|
211
|
+
if (!linked) {
|
|
212
|
+
const fd = fs.openSync(lockFilePath, "wx", 0o600);
|
|
213
|
+
try {
|
|
214
|
+
fs.writeFileSync(fd, text, "utf8");
|
|
215
|
+
} finally {
|
|
216
|
+
try { fs.closeSync(fd); } catch {}
|
|
217
|
+
}
|
|
68
218
|
}
|
|
69
219
|
return payload;
|
|
70
220
|
}
|
|
71
221
|
|
|
72
|
-
|
|
222
|
+
// Read again for a while (an antivirus scan, a lock written in place);
|
|
223
|
+
// still unreadable: stale when its time is far off, else held.
|
|
224
|
+
function readLockWithRetry() {
|
|
225
|
+
const deadline = clock() + readRetryMs;
|
|
226
|
+
for (;;) {
|
|
227
|
+
const lock = safeReadLock(lockFilePath);
|
|
228
|
+
if (lock || clock() >= deadline) return lock;
|
|
229
|
+
sleep(100);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function unreadableLockIsStale() {
|
|
73
233
|
try {
|
|
74
|
-
return
|
|
75
|
-
} catch
|
|
76
|
-
|
|
77
|
-
throw error;
|
|
78
|
-
}
|
|
234
|
+
return Math.abs(clock() - fs.statSync(lockFilePath).mtimeMs) > unreadableStaleMs;
|
|
235
|
+
} catch {
|
|
236
|
+
return true;
|
|
79
237
|
}
|
|
238
|
+
}
|
|
80
239
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
|
|
84
|
-
const existingStartedAt = existingLock?.startedAt || "unknown";
|
|
240
|
+
const held = (message) => Object.assign(new Error(message), { code: "ERELAYLOCKED" });
|
|
241
|
+
const RENAME_RETRY_MS = [50, 100, 200, 400, 800];
|
|
85
242
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
243
|
+
// The lock as judged: its fields, or for one that cannot be read its size
|
|
244
|
+
// and time, which a moved-aside file must still have to be the same lock.
|
|
245
|
+
function sameLock(file, judged) {
|
|
246
|
+
try {
|
|
247
|
+
const lock = safeReadLock(file);
|
|
248
|
+
if (judged.lock) return !!lock && ["pid", "tokenHash", "startedAt"].every((key) => Object.is(lock[key], judged.lock[key]));
|
|
249
|
+
const stat = fs.statSync(file);
|
|
250
|
+
return !lock && stat.size === judged.size && stat.mtimeMs === judged.mtimeMs;
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
93
253
|
}
|
|
254
|
+
}
|
|
94
255
|
|
|
256
|
+
// A lock put back after it turned out fresh: linked, else (no hard links)
|
|
257
|
+
// created with its content; a lock there by then wins, the file stays.
|
|
258
|
+
function putBack(moved) {
|
|
95
259
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
260
|
+
try {
|
|
261
|
+
fs.linkSync(moved, lockFilePath);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error?.code === "EEXIST") throw error;
|
|
264
|
+
fs.writeFileSync(lockFilePath, fs.readFileSync(moved), { flag: "wx", mode: 0o600 });
|
|
265
|
+
}
|
|
266
|
+
fs.unlinkSync(moved);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logger?.warn?.(`[server] A relay lock moved aside could not be put back (${error?.code || error}); it is in ${moved}.`);
|
|
100
269
|
}
|
|
270
|
+
}
|
|
101
271
|
|
|
102
|
-
|
|
272
|
+
// Files a crashed recovery or write left beside the lock (`.stale-<pid>-`,
|
|
273
|
+
// `.<pid>-….tmp`), never read: removed once their process is gone.
|
|
274
|
+
function removeLeftovers() {
|
|
275
|
+
const base = path.basename(lockFilePath);
|
|
276
|
+
let names = [];
|
|
277
|
+
try { names = fs.readdirSync(path.dirname(lockFilePath)); } catch {}
|
|
278
|
+
for (const name of names) {
|
|
279
|
+
if (!name.startsWith(`${base}.`)) continue;
|
|
280
|
+
const leftPid = Number(/^\.(?:stale-)?(\d+)-[0-9a-f]+(?:\.tmp)?$/.exec(name.slice(base.length))?.[1]);
|
|
281
|
+
if (leftPid && leftPid !== currentPid && !processAlive(leftPid)) try { fs.unlinkSync(path.join(path.dirname(lockFilePath), name)); } catch {}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function acquire() {
|
|
286
|
+
const payload = takeLock();
|
|
287
|
+
removeLeftovers();
|
|
288
|
+
return payload;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// A stale lock is moved aside, never unlinked unseen: the file moved must
|
|
292
|
+
// be the one judged, else another start took the lock meanwhile and it
|
|
293
|
+
// goes back. Gone already (another start recovered it): from the top, at
|
|
294
|
+
// most three times.
|
|
295
|
+
function takeLock() {
|
|
296
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
297
|
+
try {
|
|
298
|
+
return writeLock();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error?.code !== "EEXIST") throw error;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const existingLock = readLockWithRetry();
|
|
304
|
+
let judged = { lock: existingLock };
|
|
305
|
+
if (!existingLock) {
|
|
306
|
+
if (!unreadableLockIsStale()) {
|
|
307
|
+
throw held(`A relay lock that cannot be read yet is there, written in the last ${unreadableStaleMs / 1000} s (lock ${lockFilePath}). If no relay is starting, delete it.`);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const stat = fs.statSync(lockFilePath);
|
|
311
|
+
judged = { lock: null, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
312
|
+
} catch {
|
|
313
|
+
if (attempt < 3) continue;
|
|
314
|
+
throw held(`The relay lock ${lockFilePath} keeps changing.`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const existingPid = Number.isInteger(existingLock?.pid) ? existingLock.pid : null;
|
|
318
|
+
const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
|
|
319
|
+
const existingStartedAt = existingLock?.startedAt || "unknown";
|
|
320
|
+
|
|
321
|
+
// A lock from before the last boot, or whose pid now belongs to a
|
|
322
|
+
// process started after it, is stale (a crash, a power loss, a forced
|
|
323
|
+
// reboot): recovered below.
|
|
324
|
+
if (existingPid && lockHolderMayRun({ lock: existingLock, isProcessAlive: processAlive, identityOf, bootIdOf, startedAtOf, bootTime })) {
|
|
325
|
+
const owner = existingTokenHash && existingTokenHash === currentTokenHash
|
|
326
|
+
? "Relay already running"
|
|
327
|
+
: "Relay appears to be owned by another live process";
|
|
328
|
+
// Delete the lock only when its holder cannot be told to be a relay.
|
|
329
|
+
const known = existingLock.holder && identityOf(existingPid);
|
|
330
|
+
throw held(`${owner} (pid=${existingPid}, startedAt=${existingStartedAt}; lock ${lockFilePath}). ${known
|
|
331
|
+
? `It is held by relay process ${existingPid}: oar stop, or end process ${existingPid}.`
|
|
332
|
+
: `If no relay is running, end process ${existingPid} or delete the lock.`}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const moved = `${lockFilePath}.stale-${currentPid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
336
|
+
for (let retry = 0; ; retry += 1) {
|
|
337
|
+
try {
|
|
338
|
+
fs.renameSync(lockFilePath, moved);
|
|
339
|
+
break;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (error?.code === "ENOENT") break;
|
|
342
|
+
// Held open (an antivirus scan): briefly, then counted as held.
|
|
343
|
+
if (retry < RENAME_RETRY_MS.length && ["EBUSY", "EPERM", "EACCES"].includes(error?.code)) {
|
|
344
|
+
sleep(RENAME_RETRY_MS[retry]);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
throw held(`The stale relay lock ${lockFilePath} could not be moved aside (${error?.code || error}); delete it if no relay is running.`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!fs.existsSync(moved)) {
|
|
351
|
+
if (attempt < 3) continue;
|
|
352
|
+
throw held(`The relay lock ${lockFilePath} keeps changing.`);
|
|
353
|
+
}
|
|
354
|
+
if (!sameLock(moved, judged)) {
|
|
355
|
+
putBack(moved);
|
|
356
|
+
throw held(`Another relay took the lock ${lockFilePath} while this one recovered it.`);
|
|
357
|
+
}
|
|
358
|
+
try { fs.unlinkSync(moved); } catch {}
|
|
359
|
+
logger?.warn?.(`[server] Recovered stale singleton lock: ${lockFilePath}`);
|
|
360
|
+
try {
|
|
361
|
+
return writeLock();
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error?.code !== "EEXIST") throw error;
|
|
364
|
+
if (attempt >= 3) throw held(`The relay lock ${lockFilePath} keeps changing.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
103
367
|
}
|
|
104
368
|
|
|
105
369
|
function release() {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
REMOTE_RELAY_PROVIDERS,
|
|
9
9
|
} from '../../shared/remote-relay-contract.mjs';
|
|
10
10
|
import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../../shared/question-timeout.mjs';
|
|
11
|
+
import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
|
|
11
12
|
import { createQuestionRepository } from '../repositories/question-repository.mjs';
|
|
12
13
|
import { pickLiveTurnRowId } from './live-turn-picker.mjs';
|
|
13
14
|
import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
|
|
@@ -427,8 +428,12 @@ export function createRemoteRelayCallerContext({
|
|
|
427
428
|
const current = questionStatements.getQuestion.get(questionId);
|
|
428
429
|
if (!current) return { approved: false, error: 'The approval question was removed.' };
|
|
429
430
|
if (current.status === 'answered') {
|
|
430
|
-
const
|
|
431
|
-
|
|
431
|
+
const decision = readApprovalAnswer(current.answer, { approve: APPROVAL_ALLOW, deny: APPROVAL_DENY });
|
|
432
|
+
if (decision === 'approve') return { approved: true };
|
|
433
|
+
if (decision === 'deny') return { approved: false };
|
|
434
|
+
// The answer route takes only the choices; anything else in the row
|
|
435
|
+
// is no decision of the user's, so it is not passed off as their Deny.
|
|
436
|
+
return { approved: false, error: 'The approval question was answered with neither Allow nor Deny. Go on without it, or ask the user first.' };
|
|
432
437
|
}
|
|
433
438
|
if (current.status !== 'pending') {
|
|
434
439
|
return {
|