@fitchmultz/pi-subagents 0.48.0 → 0.49.0
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 +14 -0
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +1 -1
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-routes.js +25 -6
- package/dist/pi-intercom/bridge-session.js +60 -45
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +10 -2
- package/docs/intercom-bridge.md +56 -25
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +39 -5
- package/src/pi-intercom/bridge-config.ts +1 -1
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-routes.ts +38 -8
- package/src/pi-intercom/bridge-session.ts +64 -61
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## [0.49.0] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Event-driven remote inbox waits through optional `waitMs` (0–10000 ms) and CLI `inbox --wait-ms`, preserving immediate reads by default, durable cursor paging and explicit acknowledgement.
|
|
10
|
+
- Native fallback wake two seconds after mailbox commit without intake acknowledgement, with durable shared admission of at most 20 attempts per sliding hour across all identities and URLs. Pending cold batches coalesce, retain retry deadlines across crashes and rotations, and recover automatically when allowance returns.
|
|
11
|
+
- Stopped-helper audit preflight for preserving known remaining wake allowance during migration; unverified legacy usage conservatively reserves the first hour.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Cancelling an inbox read no longer disconnects the certificate-bound native identity or interrupts unrelated requests. Revocation, expiry, broker disconnect and shutdown still cancel outstanding waits.
|
|
16
|
+
|
|
3
17
|
## [0.48.0] - 2026-10-14
|
|
4
18
|
|
|
5
19
|
### Added
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const EXTENSION_BUILD = Object.freeze({"version":"0.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.49.0","sha256":"6cd61dacb62268abb87c7005b638048a141cd17ad6091e486146bbd5e1d01338"});
|
|
@@ -5,7 +5,7 @@ import { parseEnv } from "node:util";
|
|
|
5
5
|
import { isUnknownArray } from "../shared/unknown.js";
|
|
6
6
|
import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.js";
|
|
7
7
|
import { launchPolicy } from "./bridge-launch-policy.js";
|
|
8
|
-
import { stateDirectory } from "./bridge-
|
|
8
|
+
import { stateDirectory } from "./bridge-storage.js";
|
|
9
9
|
import { readBridgeFile } from "./bridge-files.js";
|
|
10
10
|
const MAX_CLIENTS = 64;
|
|
11
11
|
function wakePolicy(value) {
|
|
@@ -1,63 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { isAbsolute, join } from "node:path";
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
1
|
+
import { privateDatabase } from "./bridge-storage.js";
|
|
4
2
|
import { isRecord } from "../shared/unknown.js";
|
|
5
3
|
import { normalizeMessage, normalizeSessionInfo } from "./types.js";
|
|
6
|
-
import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint,
|
|
7
|
-
export function stateDirectory(value) {
|
|
8
|
-
if (value === undefined) {
|
|
9
|
-
return;
|
|
10
|
-
}
|
|
11
|
-
const path = label(value, "stateDir", 4096);
|
|
12
|
-
const stat = lstatSync(path);
|
|
13
|
-
if (!isAbsolute(path) ||
|
|
14
|
-
!stat.isDirectory() ||
|
|
15
|
-
stat.uid !== process.getuid?.() ||
|
|
16
|
-
(stat.mode & 0o777) !== 0o700) {
|
|
17
|
-
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
18
|
-
}
|
|
19
|
-
return realpathSync(path);
|
|
20
|
-
}
|
|
21
|
-
function privateFile(path, create = false) {
|
|
22
|
-
const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
|
|
23
|
-
const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
|
|
24
|
-
try {
|
|
25
|
-
const stat = fstatSync(fd);
|
|
26
|
-
if (!stat.isFile() ||
|
|
27
|
-
stat.uid !== process.getuid?.() ||
|
|
28
|
-
(stat.mode & 0o777) !== 0o600 ||
|
|
29
|
-
stat.nlink !== 1) {
|
|
30
|
-
throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
finally {
|
|
34
|
-
closeSync(fd);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
4
|
+
import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint, } from "./bridge-protocol.js";
|
|
37
5
|
function database(stateDir, fingerprint) {
|
|
38
|
-
|
|
39
|
-
const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
40
|
-
privateFile(path, true);
|
|
41
|
-
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
42
|
-
try {
|
|
43
|
-
lstatSync(`${path}${suffix}`);
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
if (isRecord(error) && error.code === "ENOENT") {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
throw error;
|
|
50
|
-
}
|
|
51
|
-
privateFile(`${path}${suffix}`);
|
|
52
|
-
}
|
|
53
|
-
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
6
|
+
const { db } = privateDatabase(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
54
7
|
try {
|
|
55
8
|
db.exec(`
|
|
56
|
-
PRAGMA journal_mode=DELETE;
|
|
57
|
-
PRAGMA synchronous=FULL;
|
|
58
|
-
PRAGMA secure_delete=ON;
|
|
59
|
-
PRAGMA cache_size=-1024;
|
|
60
|
-
PRAGMA busy_timeout=1000;
|
|
61
9
|
CREATE TABLE IF NOT EXISTS mailbox (
|
|
62
10
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
11
|
id TEXT NOT NULL UNIQUE,
|
|
@@ -10,7 +10,10 @@ export class PersistentPresence {
|
|
|
10
10
|
owner;
|
|
11
11
|
constructor(owner) {
|
|
12
12
|
this.owner = owner;
|
|
13
|
-
|
|
13
|
+
this.restoreAll();
|
|
14
|
+
}
|
|
15
|
+
restoreAll() {
|
|
16
|
+
for (const identity of this.owner.clients().values()) {
|
|
14
17
|
this.restore(identity);
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -33,6 +36,7 @@ export class PersistentPresence {
|
|
|
33
36
|
cwd: identity.cwd,
|
|
34
37
|
expires: value.expires,
|
|
35
38
|
});
|
|
39
|
+
this.owner.restored(identity, value.expires);
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
catch {
|
|
@@ -9,7 +9,7 @@ const fields = {
|
|
|
9
9
|
"GET /v1/inbox": [],
|
|
10
10
|
"POST /v1/register": [],
|
|
11
11
|
"POST /v1/ack": ["ids"],
|
|
12
|
-
"POST /v1/send": ["to", "message"],
|
|
12
|
+
"POST /v1/send": ["to", "message", "passive", "messageId"],
|
|
13
13
|
"POST /v1/ask": ["to", "message", "timeoutMs"],
|
|
14
14
|
"POST /v1/reply": ["replyTo", "message"],
|
|
15
15
|
"POST /v1/start": ["name"],
|
|
@@ -60,16 +60,23 @@ function queryFor(req, allowed) {
|
|
|
60
60
|
return query;
|
|
61
61
|
}
|
|
62
62
|
function inboxQuery(req) {
|
|
63
|
-
const query = queryFor(req, ["after", "limit"]);
|
|
63
|
+
const query = queryFor(req, ["after", "limit", "waitMs"]);
|
|
64
64
|
const after = query.get("after") ?? "0";
|
|
65
65
|
const limit = query.get("limit") ?? String(INBOX_COUNT);
|
|
66
|
+
const waitMs = query.get("waitMs") ?? "0";
|
|
66
67
|
if (!/^(0|[1-9]\d{0,15})$/.test(after) ||
|
|
67
68
|
!Number.isSafeInteger(Number(after)) ||
|
|
68
69
|
!/^[1-9]\d{0,2}$/.test(limit) ||
|
|
69
|
-
Number(limit) > INBOX_COUNT
|
|
70
|
-
|
|
70
|
+
Number(limit) > INBOX_COUNT ||
|
|
71
|
+
!/^(0|[1-9]\d{0,3}|10000)$/.test(waitMs)) {
|
|
72
|
+
throw new Fault(400, "invalid_input", "after must be a nonnegative safe integer cursor; limit must be 1..256; waitMs must be 0..10000.");
|
|
71
73
|
}
|
|
72
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
route: "GET /v1/inbox",
|
|
76
|
+
after: Number(after),
|
|
77
|
+
limit: Number(limit),
|
|
78
|
+
waitMs: Number(waitMs),
|
|
79
|
+
};
|
|
73
80
|
}
|
|
74
81
|
function pageNumber(value, fallback, min, max) {
|
|
75
82
|
const number = value === null ? fallback : Number(value);
|
|
@@ -183,7 +190,7 @@ function messagingOperation(route, input, req) {
|
|
|
183
190
|
}
|
|
184
191
|
return { route, ids: [...new Set(input.ids.map((id) => label(id, "message ID")))] };
|
|
185
192
|
case "POST /v1/send":
|
|
186
|
-
return
|
|
193
|
+
return send(input);
|
|
187
194
|
case "POST /v1/ask":
|
|
188
195
|
return {
|
|
189
196
|
route,
|
|
@@ -200,6 +207,18 @@ function messagingOperation(route, input, req) {
|
|
|
200
207
|
return nativeControl(route, input);
|
|
201
208
|
}
|
|
202
209
|
}
|
|
210
|
+
function send(input) {
|
|
211
|
+
if (input.passive !== undefined && typeof input.passive !== "boolean") {
|
|
212
|
+
throw new Fault(400, "invalid_input", "passive must be a boolean.");
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
route: "POST /v1/send",
|
|
216
|
+
message: text(input.message),
|
|
217
|
+
to: label(input.to, "to").trim(),
|
|
218
|
+
passive: input.passive,
|
|
219
|
+
messageId: input.messageId === undefined ? undefined : label(input.messageId, "message ID"),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
203
222
|
export async function operationFor(req, route, signal) {
|
|
204
223
|
const input = await operationInput(req, route, signal);
|
|
205
224
|
switch (route) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
3
|
import { IntercomClient } from "./broker/client.js";
|
|
3
4
|
import { resolveSessionProjectId } from "./session-targets.js";
|
|
4
5
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
@@ -21,6 +22,7 @@ export class Session {
|
|
|
21
22
|
});
|
|
22
23
|
requests = new Set();
|
|
23
24
|
inbox;
|
|
25
|
+
inboxChanged = new AbortController();
|
|
24
26
|
replying = new Set();
|
|
25
27
|
pending;
|
|
26
28
|
controls = new ControlReplies();
|
|
@@ -48,9 +50,7 @@ export class Session {
|
|
|
48
50
|
this.touch();
|
|
49
51
|
}
|
|
50
52
|
guard(signal) {
|
|
51
|
-
|
|
52
|
-
cancelled(signal);
|
|
53
|
-
}
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
54
|
if (this.closed) {
|
|
55
55
|
throw this.closeReason;
|
|
56
56
|
}
|
|
@@ -211,12 +211,22 @@ export class Session {
|
|
|
211
211
|
this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
|
|
212
212
|
return;
|
|
213
213
|
}
|
|
214
|
-
if (result
|
|
215
|
-
|
|
214
|
+
if (result !== "duplicate" || !this.inbox.persistent) {
|
|
215
|
+
if (result !== "stored") {
|
|
216
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
217
|
+
}
|
|
218
|
+
this.inboxChanged.abort();
|
|
219
|
+
this.inboxChanged = new AbortController();
|
|
216
220
|
}
|
|
217
|
-
|
|
221
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
222
|
+
...this.fields(),
|
|
223
|
+
peer: from.id,
|
|
224
|
+
messageId: message.id,
|
|
225
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
226
|
+
});
|
|
218
227
|
if (result !== "lost") {
|
|
219
228
|
this.resolveReply(from, message);
|
|
229
|
+
this.owner.mailboxChanged?.();
|
|
220
230
|
}
|
|
221
231
|
this.presence();
|
|
222
232
|
}
|
|
@@ -228,14 +238,6 @@ export class Session {
|
|
|
228
238
|
pending.resolve({ from, message });
|
|
229
239
|
return true;
|
|
230
240
|
}
|
|
231
|
-
recordReceive(peer, messageId, result) {
|
|
232
|
-
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
233
|
-
...this.fields(),
|
|
234
|
-
peer,
|
|
235
|
-
messageId,
|
|
236
|
-
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
241
|
presence() {
|
|
240
242
|
if (this.closed) {
|
|
241
243
|
return;
|
|
@@ -311,17 +313,6 @@ export class Session {
|
|
|
311
313
|
const waiting = new WaitingAsk();
|
|
312
314
|
this.pending = waiting;
|
|
313
315
|
const { signal } = context;
|
|
314
|
-
const abort = () => {
|
|
315
|
-
try {
|
|
316
|
-
cancelled(signal);
|
|
317
|
-
}
|
|
318
|
-
catch (error) {
|
|
319
|
-
waiting.reject(error instanceof Error
|
|
320
|
-
? error
|
|
321
|
-
: new Fault(499, "client_aborted", "HTTP client disconnected; delivery may be unknown."));
|
|
322
|
-
}
|
|
323
|
-
};
|
|
324
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
325
316
|
try {
|
|
326
317
|
await this.connect(signal);
|
|
327
318
|
const peer = await this.target(operation.to, signal);
|
|
@@ -338,16 +329,37 @@ export class Session {
|
|
|
338
329
|
}
|
|
339
330
|
finally {
|
|
340
331
|
waiting.clear();
|
|
341
|
-
|
|
342
|
-
if (this.pending === waiting) {
|
|
343
|
-
this.pending = undefined;
|
|
344
|
-
}
|
|
332
|
+
this.pending = undefined;
|
|
345
333
|
}
|
|
346
334
|
}
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
335
|
+
async readInbox(operation, context) {
|
|
336
|
+
const read = () => ({
|
|
337
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
338
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
339
|
+
});
|
|
340
|
+
const deadline = performance.now() + operation.waitMs;
|
|
341
|
+
const losses = read().lostMessages;
|
|
342
|
+
for (;;) {
|
|
343
|
+
// Subscribe to the post-commit signal before rereading the authoritative cursor page.
|
|
344
|
+
const signal = AbortSignal.any([this.inboxChanged.signal, context.signal]);
|
|
345
|
+
const page = read();
|
|
346
|
+
const remaining = deadline - performance.now();
|
|
347
|
+
if (page.messages.length > 0 || page.lostMessages !== losses || remaining <= 0) {
|
|
348
|
+
return page;
|
|
349
|
+
}
|
|
350
|
+
log("inbox_wait", { ...this.fields(), requestId: context.requestId, after: operation.after });
|
|
351
|
+
try {
|
|
352
|
+
// A nonmatching commit must rearm the same cursor, not start parallel readers.
|
|
353
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
354
|
+
await delay(remaining, undefined, { signal });
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (!signal.aborted) {
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.guard(context.signal);
|
|
362
|
+
}
|
|
351
363
|
}
|
|
352
364
|
async answer(replyTo, message, context) {
|
|
353
365
|
const entry = this.inbox.get(replyTo);
|
|
@@ -371,6 +383,7 @@ export class Session {
|
|
|
371
383
|
}
|
|
372
384
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
373
385
|
this.inbox.remove(replyTo);
|
|
386
|
+
this.owner.mailboxChanged?.();
|
|
374
387
|
this.presence();
|
|
375
388
|
return data;
|
|
376
389
|
}
|
|
@@ -389,28 +402,30 @@ export class Session {
|
|
|
389
402
|
await this.connect(context.signal);
|
|
390
403
|
}
|
|
391
404
|
if (operation.route === "POST /v1/ack") {
|
|
392
|
-
|
|
405
|
+
const result = this.inbox.ack(operation.ids);
|
|
406
|
+
this.owner.mailboxChanged?.();
|
|
407
|
+
this.presence();
|
|
408
|
+
return result;
|
|
393
409
|
}
|
|
394
|
-
return
|
|
395
|
-
...this.inbox.page(operation.after, operation.limit),
|
|
396
|
-
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
397
|
-
};
|
|
410
|
+
return this.readInbox(operation, context);
|
|
398
411
|
}
|
|
399
412
|
await this.connect(context.signal);
|
|
400
413
|
switch (operation.route) {
|
|
401
414
|
case "POST /v1/register":
|
|
402
415
|
return { sessionId: this.client.sessionId, name: this.identity.name };
|
|
403
|
-
case "GET /v1/list":
|
|
404
|
-
const sessions = await this.peers(context.signal);
|
|
416
|
+
case "GET /v1/list":
|
|
405
417
|
return {
|
|
406
418
|
sessionId: this.client.sessionId,
|
|
407
|
-
sessions: bridgeSessionList(
|
|
419
|
+
sessions: bridgeSessionList(await this.peers(context.signal)),
|
|
408
420
|
};
|
|
421
|
+
case "POST /v1/send": {
|
|
422
|
+
const peer = await this.target(operation.to, context.signal);
|
|
423
|
+
const receipt = await this.send(peer.id, operation.message, context, {
|
|
424
|
+
messageId: operation.messageId,
|
|
425
|
+
delivery: operation.passive === true ? "passive" : "steer",
|
|
426
|
+
});
|
|
427
|
+
return { ...receipt };
|
|
409
428
|
}
|
|
410
|
-
case "POST /v1/send":
|
|
411
|
-
return {
|
|
412
|
-
...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
|
|
413
|
-
};
|
|
414
429
|
case "POST /v1/reply":
|
|
415
430
|
return this.answer(operation.replyTo, operation.message, context);
|
|
416
431
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { isRecord } from "../shared/unknown.js";
|
|
5
|
+
import { label } from "./bridge-protocol.js";
|
|
6
|
+
export function stateDirectory(value) {
|
|
7
|
+
if (value === undefined) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const path = label(value, "stateDir", 4096);
|
|
11
|
+
const stat = lstatSync(path);
|
|
12
|
+
if (!isAbsolute(path) ||
|
|
13
|
+
!stat.isDirectory() ||
|
|
14
|
+
stat.uid !== process.getuid?.() ||
|
|
15
|
+
(stat.mode & 0o777) !== 0o700) {
|
|
16
|
+
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
17
|
+
}
|
|
18
|
+
return realpathSync(path);
|
|
19
|
+
}
|
|
20
|
+
export function privateFile(path) {
|
|
21
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
22
|
+
try {
|
|
23
|
+
const stat = fstatSync(fd);
|
|
24
|
+
if (!stat.isFile() ||
|
|
25
|
+
stat.uid !== process.getuid?.() ||
|
|
26
|
+
(stat.mode & 0o777) !== 0o600 ||
|
|
27
|
+
stat.nlink !== 1) {
|
|
28
|
+
throw new Error("Storage files must be owned regular files with mode 0600 and one link.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Native SQLite FULL commits own durability; never replace an unreadable existing database. */
|
|
36
|
+
export function privateDatabase(stateDir, name) {
|
|
37
|
+
stateDirectory(stateDir);
|
|
38
|
+
const path = join(stateDir, name);
|
|
39
|
+
let created = false;
|
|
40
|
+
try {
|
|
41
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
42
|
+
closeSync(fd);
|
|
43
|
+
created = true;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!isRecord(error) || error.code !== "EEXIST") {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
privateFile(path);
|
|
51
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
52
|
+
try {
|
|
53
|
+
lstatSync(`${path}${suffix}`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
privateFile(`${path}${suffix}`);
|
|
62
|
+
}
|
|
63
|
+
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
64
|
+
try {
|
|
65
|
+
db.exec(`PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;
|
|
66
|
+
PRAGMA cache_size=-1024; PRAGMA busy_timeout=1000;`);
|
|
67
|
+
return { db, created };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
db.close();
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { privateDatabase, privateFile } from "./bridge-storage.js";
|
|
6
|
+
import { isRecord } from "../shared/unknown.js";
|
|
7
|
+
export const WAKE_OWNER_FILE = /^wake-owner-[0-9a-f-]{36}\.lock$/;
|
|
8
|
+
/** SQLite's OS lock survives pauses and is released on exit, without PID reuse or stale leases. */
|
|
9
|
+
export class WakeOwner {
|
|
10
|
+
name = `wake-owner-${randomUUID()}.lock`;
|
|
11
|
+
db;
|
|
12
|
+
path;
|
|
13
|
+
constructor(stateDir) {
|
|
14
|
+
this.path = join(stateDir, this.name);
|
|
15
|
+
this.db = privateDatabase(stateDir, this.name).db;
|
|
16
|
+
try {
|
|
17
|
+
this.db.exec("BEGIN EXCLUSIVE");
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
this.db.close();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
close() {
|
|
25
|
+
this.db.close();
|
|
26
|
+
rmSync(this.path, { force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function stoppedWakeOwner(stateDir, name) {
|
|
30
|
+
if (!WAKE_OWNER_FILE.test(name)) {
|
|
31
|
+
throw new Error("Invalid wake owner.");
|
|
32
|
+
}
|
|
33
|
+
const path = join(stateDir, name);
|
|
34
|
+
try {
|
|
35
|
+
privateFile(path);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
const db = new DatabaseSync(path);
|
|
44
|
+
try {
|
|
45
|
+
db.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE");
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (isRecord(error) && error.errcode === 5) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|