@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +268 -28
  3. package/dist/app.d.ts +5 -0
  4. package/dist/app.js +3 -2
  5. package/dist/claim.d.ts +34 -2
  6. package/dist/claim.js +86 -5
  7. package/dist/cli.d.ts +6 -0
  8. package/dist/cli.js +98 -33
  9. package/dist/commands.d.ts +70 -0
  10. package/dist/commands.js +257 -0
  11. package/dist/confine.d.ts +47 -7
  12. package/dist/confine.js +148 -19
  13. package/dist/env.d.ts +17 -26
  14. package/dist/env.js +73 -68
  15. package/dist/errors.d.ts +2 -1
  16. package/dist/errors.js +1 -1
  17. package/dist/fork.d.ts +23 -0
  18. package/dist/fork.js +103 -3
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +30 -6
  21. package/dist/hosts/daytona.js +15 -3
  22. package/dist/hosts/local-host.d.ts +7 -1
  23. package/dist/hosts/local-host.js +30 -16
  24. package/dist/index.d.ts +31 -15
  25. package/dist/index.js +23 -15
  26. package/dist/internals.d.ts +3 -0
  27. package/dist/internals.js +6 -0
  28. package/dist/lease.d.ts +5 -2
  29. package/dist/lease.js +3 -1
  30. package/dist/local-run.d.ts +10 -0
  31. package/dist/local-run.js +64 -0
  32. package/dist/park.d.ts +4 -3
  33. package/dist/park.js +4 -3
  34. package/dist/process-table.d.ts +38 -0
  35. package/dist/process-table.js +158 -0
  36. package/dist/run.d.ts +52 -10
  37. package/dist/run.js +42 -7
  38. package/dist/status.d.ts +11 -3
  39. package/dist/status.js +15 -13
  40. package/dist/store.d.ts +14 -2
  41. package/dist/store.js +16 -4
  42. package/dist/supervise.d.ts +22 -3
  43. package/dist/supervise.js +40 -13
  44. package/dist/watchdog.d.ts +3 -1
  45. package/dist/watchdog.js +46 -13
  46. package/package.json +12 -7
package/dist/run.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Context, JsonValue } from "@earendil-works/chord";
2
2
  import { Harness } from "@earendil-works/pi-durable";
3
- import type { HarnessOptions, Storage, ToolRegistration } from "@earendil-works/pi-durable";
3
+ import type { HarnessOptions, Seq, Storage, ToolRegistration } from "@earendil-works/pi-durable";
4
4
  import type { SqliteDatabase, SqliteExecutor } from "@earendil-works/pi-durable/storage/sqlite";
5
5
  import { type AcquireOptions, type ArchilHost, type Claim, type RunRef } from "./claim.js";
6
6
  import { FencedError, PdaError } from "./errors.js";
@@ -57,11 +57,15 @@ export interface LeaseOptions {
57
57
  /** How often the self-fence timer looks; default 1 s. */
58
58
  readonly checkMs?: number;
59
59
  }
60
- export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "store" | "seal" | "harness" | "live" | "resume";
60
+ export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "commands" | "store" | "seal" | "harness" | "live" | "before-resume" | "resume";
61
61
  export type ReleaseStep = "close" | "cleanup" | "barrier" | "seal" | "unlock" | "unmount";
62
62
  export interface OpenRunLeaseOptions {
63
- /** The mount token from the host driver (reusable, 24 h); travels only on the archil wrapper's stdin, never in an environment. */
64
- readonly mountToken: string;
63
+ /**
64
+ * The mount token from the host driver (reusable, 24 h); travels only on the archil wrapper's stdin, never in an
65
+ * environment. Required to mount an Archil disk (the default `acquire`); a run whose `acquire` is replaced (a local
66
+ * directory: `localRun`) needs none, and its `acquire` is handed an empty token.
67
+ */
68
+ readonly mountToken?: string;
65
69
  /** Default /mnt/archil. */
66
70
  readonly mountRoot?: string;
67
71
  /** Mount with `--force`: the takeover fallback when the supervisor could not revoke. */
@@ -110,7 +114,43 @@ export interface OpenDurableRunOptions<Tool extends ToolRegistration = ToolRegis
110
114
  readonly env?: (claim: Claim) => RunEnv;
111
115
  /** Wraps the raw database inside the store's fence (instrumentation, fault injection). */
112
116
  readonly decorateStore?: (database: SqliteDatabase) => SqliteDatabase;
117
+ /**
118
+ * Awaited once the Harness is open (its registry installed) and the run is live, before the Harness resumes: no
119
+ * resumed task takes a step until it settles, and what it admits (a submission, a write) is committed before anything
120
+ * a resumed task's steps commit. A host reconciles here what a resumed tool depends on (a browser, a mount). A rejection
121
+ * fails the open as any step does (`before-resume`), and the claim is released.
122
+ */
123
+ readonly beforeResume?: (run: BeforeResume) => void | Promise<void>;
124
+ }
125
+ /**
126
+ * What `beforeResume` sees: the open Harness, not yet resumed, the open's context, the incarnation it belongs to, and the
127
+ * store the Harness was opened over with its head (a recorder snapshots every document at `head` here).
128
+ */
129
+ export interface BeforeResume {
130
+ readonly harness: Harness;
131
+ readonly context: Context;
132
+ readonly generation: number;
133
+ /** This incarnation's `run.json`, which the open has already written (`running`, this holder, `detail` null). */
134
+ readonly record: RunRecord;
135
+ /**
136
+ * `run.json` as it was before this open wrote over it: how the run was left. After a park it is `sleeping` with its
137
+ * `wakeAt` and `detail` and the seal its release wrote; after a planned stop, `paused` or `done` with the `detail` it
138
+ * set; after a crash, `running` with the dead holder and no new seal. On a fork's first open it is the record `fork`
139
+ * wrote: `paused`, generation 0, `detail.forkedFrom`. Null only when there was no `run.json` (a new run's first open).
140
+ */
141
+ readonly previous: RunRecord | null;
142
+ readonly claim: RunClaim;
143
+ /** The store the Harness was opened over, read-only: four reads on an object of their own, and no way to a write. */
144
+ readonly storage: StoreReads;
145
+ /**
146
+ * The store's last committed sequence once the Harness is open, read just before the hook runs. Until the hook commits
147
+ * something, the store's current state is its state at `head`: a document that keeps no history (`latest`, and session
148
+ * and task documents) reads only at "current", a `rewindable` one at `head` too.
149
+ */
150
+ readonly head: Seq;
113
151
  }
152
+ /** The reads of a store that `beforeResume` gets. */
153
+ export type StoreReads = Pick<Storage, "scanConversations" | "scanTasks" | "scanDocuments" | "document">;
114
154
  /** The claim as the app sees it: paths, the fenced flag, and a barrier whose failure fences the run. */
115
155
  export interface RunClaim {
116
156
  readonly ref: RunRef;
@@ -139,6 +179,8 @@ export interface RunLease {
139
179
  readonly record: RunRecord;
140
180
  /** The seal the previous incarnation's clean release wrote; null on a first run and after a crash. */
141
181
  readonly sealedSeq: number | null;
182
+ /** `run.json` as the open found it, before it wrote this incarnation's: null when there was none (a new run's first open). */
183
+ readonly previous: RunRecord | null;
142
184
  readonly fenced: boolean;
143
185
  /**
144
186
  * The seal check on the app's store, before anything commits: the store's head (`storeHead`) below
@@ -228,12 +270,12 @@ export interface StoreHeadSource {
228
270
  readonly file: string;
229
271
  }
230
272
  /**
231
- * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable 1.0.4's
232
- * public `Storage` has no head query, and the seal check must run before `Harness.open` can commit (its recovery commit
233
- * would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq` and stores the
234
- * successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store without that table,
235
- * row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the check silently;
236
- * a unit test pins the read to the sequence pi's commits return.
273
+ * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
274
+ * `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
275
+ * recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
276
+ * and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
277
+ * without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
278
+ * check silently; a unit test pins the read to the sequence pi's commits return.
237
279
  *
238
280
  * `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
239
281
  * a raw connection such an error would read as STORE_HEAD_UNREADABLE.
package/dist/run.js CHANGED
@@ -10,6 +10,7 @@ import { DatabaseSync } from "node:sqlite";
10
10
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
11
  import { Harness } from "@earendil-works/pi-durable";
12
12
  import { acquire } from "./claim.js";
13
+ import { reapOrRefuse } from "./commands.js";
13
14
  import { archilEnv } from "./env.js";
14
15
  import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
15
16
  import { openArchilStore } from "./store.js";
@@ -59,6 +60,18 @@ export class LeaseLapsedError extends FencedError {
59
60
  this.by = by;
60
61
  }
61
62
  }
63
+ /**
64
+ * `storage`'s four reads on a frozen object that holds nothing else: no `commit` or `mintId` on it, and no reference to
65
+ * `storage` a cast could reach.
66
+ */
67
+ function storeReads(storage) {
68
+ return Object.freeze({
69
+ scanConversations: (...args) => storage.scanConversations(...args),
70
+ scanTasks: (...args) => storage.scanTasks(...args),
71
+ scanDocuments: (...args) => storage.scanDocuments(...args),
72
+ document: (...args) => storage.document(...args),
73
+ });
74
+ }
62
75
  /** Locks this process holds. A collected DatabaseSync closes its file, which would drop the lock without a word. */
63
76
  const heldLocks = new Set();
64
77
  const SQLITE_BUSY = 5;
@@ -119,12 +132,12 @@ export function takeOwnerLock(root) {
119
132
  };
120
133
  }
121
134
  /**
122
- * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable 1.0.4's
123
- * public `Storage` has no head query, and the seal check must run before `Harness.open` can commit (its recovery commit
124
- * would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq` and stores the
125
- * successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store without that table,
126
- * row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the check silently;
127
- * a unit test pins the read to the sequence pi's commits return.
135
+ * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
136
+ * `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
137
+ * recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
138
+ * and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
139
+ * without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
140
+ * check silently; a unit test pins the read to the sequence pi's commits return.
128
141
  *
129
142
  * `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
130
143
  * a raw connection such an error would read as STORE_HEAD_UNREADABLE.
@@ -265,6 +278,9 @@ class Lease {
265
278
  get sealedSeq() {
266
279
  return this.#previous?.sealedSeq ?? null;
267
280
  }
281
+ get previous() {
282
+ return this.#previous ?? null;
283
+ }
268
284
  get fenced() {
269
285
  return this.#fence !== undefined;
270
286
  }
@@ -605,9 +621,12 @@ async function openLease(ref, options, step) {
605
621
  catch (error) {
606
622
  throw new RunError("INVALID_ARGUMENT", `holder ${JSON.stringify(options.holder)}: ${error.message}`, { cause: error });
607
623
  }
624
+ if (options.acquire === undefined && !options.mountToken) {
625
+ throw new RunError("INVALID_ARGUMENT", "mountToken is required to mount an Archil disk; a run on a local directory passes localRun(root) instead");
626
+ }
608
627
  const claim = await step("acquire", () => (options.acquire ?? acquire)({
609
628
  ref,
610
- token: options.mountToken,
629
+ token: options.mountToken ?? "",
611
630
  ...(options.mountRoot === undefined ? {} : { mountRoot: options.mountRoot }),
612
631
  ...(options.force === undefined ? {} : { force: options.force }),
613
632
  ...(options.host === undefined ? {} : { host: options.host }),
@@ -680,6 +699,15 @@ class Run {
680
699
  const storage = lease.observe(this.#store.storage);
681
700
  this.#harness = await step("harness", () => Harness.open(storage, { ...this.#options.harness, env: this.env }, this.#context));
682
701
  await step("live", () => lease.live());
702
+ const before = this.#options.beforeResume;
703
+ if (before) {
704
+ // The storage the Harness was opened over (the lease observes its commits), and its head now: nothing commits
705
+ // between the open and the hook, since the Harness has not resumed.
706
+ await step("before-resume", async () => {
707
+ const head = (await storeHead(this.#store));
708
+ await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, previous: lease.previous, claim: lease.claim, storage: storeReads(storage), head });
709
+ });
710
+ }
683
711
  await step("resume", () => this.#harness.resume());
684
712
  }
685
713
  /** Undo a failed open: close what this layer opened, then the lease's own undo. */
@@ -735,6 +763,13 @@ export async function openDurableRun(ref, options) {
735
763
  let env;
736
764
  const lease = await openLease(ref, { ...options, context, cleanup: (c) => env?.cleanup(c) ?? Promise.resolve() }, step);
737
765
  try {
766
+ // A claim that records its commands (a local run's): end what a killed incarnation left running, now that the owner
767
+ // lock is held and before the Harness opens, so no command of it still writes in work/ once the run resumes.
768
+ const commands = lease.rawClaim.commands;
769
+ // A process of a killed incarnation that outlives its SIGKILL refuses the open (HeldError, 76): the run never resumes
770
+ // beside it, and its record stays for the next open.
771
+ if (commands !== undefined)
772
+ await step("commands", () => reapOrRefuse(commands));
738
773
  env = (options.env ?? ((c) => archilEnv(c)))(lease.rawClaim);
739
774
  }
740
775
  catch (error) {
package/dist/status.d.ts CHANGED
@@ -19,7 +19,7 @@ export type RunStatus = (typeof RUN_STATUSES)[number];
19
19
  export interface RunHolder {
20
20
  readonly driver: string;
21
21
  readonly host: string;
22
- /** `/proc/sys/kernel/random/boot_id`; null where the platform has none. */
22
+ /** `/proc/sys/kernel/random/boot_id` (`kern.bootsessionuuid` on macOS); null where it cannot be read. */
23
23
  readonly bootId: string | null;
24
24
  readonly pid: number;
25
25
  /** When this incarnation claimed the run (ISO 8601). */
@@ -81,12 +81,14 @@ export declare const ARCHIL_FSTYPE = "fuse.archil";
81
81
  * A run root held open as a directory for the life of the instance. `at` is `/proc/self/fd/<n>`, a path the kernel
82
82
  * resolves through the open directory and never through the root's path (Node has no openat; the magic link stands in
83
83
  * for it): a file made, written or renamed under `at` is in the directory opened at acquire, or the call fails (ENOTCONN
84
- * once the FUSE daemon is gone). It never lands on the local disk under a mountpoint whose mount went away.
84
+ * once the FUSE daemon is gone). It never lands on the local disk under a mountpoint whose mount went away. macOS has no
85
+ * such link: there `at` is the root's path (`byPath`), and only the checks below stand between a write and a root that was
86
+ * replaced.
85
87
  */
86
88
  export interface ClaimDir {
87
89
  /** The root's path, as the claim names it. */
88
90
  readonly root: string;
89
- /** `/proc/self/fd/<n>`. Throws once closed: the number may then name another file. */
91
+ /** `/proc/self/fd/<n>`, or the root's path `byPath`. Throws once closed: the number may then name another file. */
90
92
  readonly at: string;
91
93
  /**
92
94
  * Resolves while the root's path still resolves to the directory held open (same device, same inode). A mount that was
@@ -107,6 +109,12 @@ export interface OpenClaimDirOptions {
107
109
  readonly fstype?: string | null;
108
110
  /** Default `/proc/self/mountinfo`. */
109
111
  readonly mountinfo?: string;
112
+ /**
113
+ * `at` is the root's path, not `/proc/self/fd/<n>`: the default on macOS, which has none. A write is checked against the
114
+ * held directory first (`assertMounted`), but a root renamed and replaced between that check and the write gets the
115
+ * write. For a local directory only.
116
+ */
117
+ readonly byPath?: boolean;
110
118
  }
111
119
  /** `major:minor` of a device number, as `/proc/self/mountinfo` writes it (glibc's encoding). */
112
120
  export declare function deviceName(dev: bigint): string;
package/dist/status.js CHANGED
@@ -2,11 +2,12 @@
2
2
  // claim with write-to-temp, fsync, rename, and read by the supervisor (over the S3 API or a local mount). It is a hint
3
3
  // for the supervisor, never the source of truth: the store is. Any error writing it is a fence. The instance writes it
4
4
  // through its claim directory (`ClaimDir`), never by path, so a write cannot land under a mountpoint whose mount is gone.
5
- import { constants, readFileSync } from "node:fs";
5
+ import { constants } from "node:fs";
6
6
  import { open, readFile, rename, rm, stat } from "node:fs/promises";
7
7
  import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { FencedError, ownWriteFenced, PdaError } from "./errors.js";
10
+ import { thisBoot } from "./process-table.js";
10
11
  export const RUN_JSON = "run.json";
11
12
  /** The temporary file every write goes through; renamed over `run.json` once it is fsynced. */
12
13
  export const RUN_JSON_TEMP = "run.json.tmp";
@@ -204,7 +205,8 @@ export async function openClaimDir(root, options = {}) {
204
205
  await handle.close().catch(() => undefined);
205
206
  throw error instanceof FencedError ? error : unmounted(`checking the claim root ${root} failed: ${error.message}`, error);
206
207
  }
207
- const at = `/proc/self/fd/${handle.fd}`;
208
+ const byPath = options.byPath ?? process.platform === "darwin";
209
+ const at = byPath ? root : `/proc/self/fd/${handle.fd}`;
208
210
  let closed = false;
209
211
  const same = (path, now, claims) => {
210
212
  if (now.dev === claims.dev && now.ino === claims.ino)
@@ -234,10 +236,18 @@ export async function openClaimDir(root, options = {}) {
234
236
  async assertSame(name) {
235
237
  if (closed)
236
238
  throw unmounted(`the claim directory of ${root} is closed`);
237
- const [byPath, inDir] = await Promise.all([statOf(join(root, name)), statOf(join(at, name))]);
239
+ if (byPath) {
240
+ // No second way to name the entry: the root must still be the directory held, and the entry on its device.
241
+ const [dir, entry] = await Promise.all([statOf(root), statOf(join(root, name))]);
242
+ same(root, dir, held);
243
+ if (entry.dev !== held.dev)
244
+ throw unmounted(`${name} in the claim directory is on device ${deviceName(entry.dev)}, not the claim's ${deviceName(held.dev)}`);
245
+ return;
246
+ }
247
+ const [named, inDir] = await Promise.all([statOf(join(root, name)), statOf(join(at, name))]);
238
248
  if (inDir.dev !== held.dev)
239
249
  throw unmounted(`${name} in the claim directory is on device ${deviceName(inDir.dev)}, not the claim's ${deviceName(held.dev)}`);
240
- same(join(root, name), byPath, inDir);
250
+ same(join(root, name), named, inDir);
241
251
  },
242
252
  async close() {
243
253
  if (closed)
@@ -247,18 +257,10 @@ export async function openClaimDir(root, options = {}) {
247
257
  },
248
258
  };
249
259
  }
250
- function bootId() {
251
- try {
252
- return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || null;
253
- }
254
- catch {
255
- return null;
256
- }
257
- }
258
260
  /**
259
261
  * This process as a holder: the host driver's `handle` (default driver `local`, this machine's host name and boot id)
260
262
  * with this process's pid and `since`. A handle whose named fields have the wrong type is refused (RunRecordError).
261
263
  */
262
264
  export function currentHolder(handle = {}, since = new Date()) {
263
- return holderOf({ driver: "local", host: hostname(), bootId: bootId(), ...handle, pid: process.pid, since: since.toISOString() });
265
+ return holderOf({ driver: "local", host: hostname(), bootId: thisBoot(), ...handle, pid: process.pid, since: since.toISOString() });
264
266
  }
package/dist/store.d.ts CHANGED
@@ -39,8 +39,18 @@ export declare const PROFILE_PRAGMAS: {
39
39
  readonly mmap_size: 0;
40
40
  };
41
41
  };
42
- /** Read every pragma of `profile` back and refuse (StorePragmaError) when any differs. */
43
- export declare function assertPragmas(database: SqliteExecutor, profile: Profile): Promise<void>;
42
+ /**
43
+ * On macOS, fsync does not flush the drive's cache, so a commit FULL syncs could still be lost to a power cut. These make
44
+ * SQLite sync with F_FULLFSYNC instead, at every commit and every checkpoint.
45
+ */
46
+ export declare const FULLFSYNC_PRAGMAS: {
47
+ readonly fullfsync: 1;
48
+ readonly checkpoint_fullfsync: 1;
49
+ };
50
+ /** What `profile` must read back on `platform`: its own pragmas, and on macOS `FULLFSYNC_PRAGMAS` too. */
51
+ export declare function profilePragmas(profile: Profile, platform?: NodeJS.Platform): Readonly<Record<string, string | number>>;
52
+ /** Read every pragma of `profile` on `platform` (default this one) back and refuse (StorePragmaError) when any differs. */
53
+ export declare function assertPragmas(database: SqliteExecutor, profile: Profile, platform?: NodeJS.Platform): Promise<void>;
44
54
  /**
45
55
  * The fenced state of one handle. The first error whose result code is in the I/O, full or not-a-database class marks it
46
56
  * fenced, runs `onFenced` once (before the error propagates), and from then on every operation fails with the same
@@ -84,6 +94,8 @@ export type OpenOptions = {
84
94
  readonly onFenced?: (error: StoreFencedError) => void;
85
95
  /** Wraps the raw database inside the fence, for instrumentation and fault injection. */
86
96
  readonly decorate?: (database: SqliteDatabase) => SqliteDatabase;
97
+ /** The platform whose pragmas the store takes (`profilePragmas`); default this one. For tests. */
98
+ readonly platform?: NodeJS.Platform;
87
99
  };
88
100
  export type ArchilStore = {
89
101
  readonly storage: SqliteStorage;
package/dist/store.js CHANGED
@@ -64,10 +64,19 @@ export const PROFILE_PRAGMAS = {
64
64
  exclusive: { locking_mode: "exclusive", journal_mode: "wal", synchronous: 2, mmap_size: 0 },
65
65
  shared: { locking_mode: "normal", journal_mode: "wal", synchronous: 2, mmap_size: 0 },
66
66
  };
67
- /** Read every pragma of `profile` back and refuse (StorePragmaError) when any differs. */
68
- export async function assertPragmas(database, profile) {
67
+ /**
68
+ * On macOS, fsync does not flush the drive's cache, so a commit FULL syncs could still be lost to a power cut. These make
69
+ * SQLite sync with F_FULLFSYNC instead, at every commit and every checkpoint.
70
+ */
71
+ export const FULLFSYNC_PRAGMAS = { fullfsync: 1, checkpoint_fullfsync: 1 };
72
+ /** What `profile` must read back on `platform`: its own pragmas, and on macOS `FULLFSYNC_PRAGMAS` too. */
73
+ export function profilePragmas(profile, platform = process.platform) {
74
+ return platform === "darwin" ? { ...PROFILE_PRAGMAS[profile], ...FULLFSYNC_PRAGMAS } : PROFILE_PRAGMAS[profile];
75
+ }
76
+ /** Read every pragma of `profile` on `platform` (default this one) back and refuse (StorePragmaError) when any differs. */
77
+ export async function assertPragmas(database, profile, platform = process.platform) {
69
78
  const mismatches = [];
70
- for (const [pragma, expected] of Object.entries(PROFILE_PRAGMAS[profile])) {
79
+ for (const [pragma, expected] of Object.entries(profilePragmas(profile, platform))) {
71
80
  const row = await database.get(`PRAGMA ${pragma}`);
72
81
  const actual = row === undefined ? undefined : Object.values(row)[0];
73
82
  if (actual !== expected)
@@ -203,7 +212,10 @@ export async function openArchilStore(file, profile = "exclusive", options = {})
203
212
  }
204
213
  await database.exec("PRAGMA synchronous = FULL"); // fsync the WAL on every commit
205
214
  await database.exec("PRAGMA mmap_size = 0"); // no mmap I/O of the database file on a network filesystem
206
- await assertPragmas(database, profile);
215
+ const platform = options.platform ?? process.platform;
216
+ for (const pragma of platform === "darwin" ? Object.keys(FULLFSYNC_PRAGMAS) : [])
217
+ await database.exec(`PRAGMA ${pragma} = ON`);
218
+ await assertPragmas(database, profile, platform);
207
219
  const storage = await SqliteStorage.open(database);
208
220
  return { storage, database, profile, file };
209
221
  }
@@ -241,6 +241,25 @@ export type TokenUser = {
241
241
  expiresAt?: string;
242
242
  createdAt?: string;
243
243
  };
244
+ /** What `archilControl` returns: everything `ensureRunning`, `superviseRuns`, `checkHost`, `fork` and the token janitor use. */
245
+ export type ArchilControl = SupervisorControl & CheckControl & {
246
+ listUsers(): Promise<TokenUser[]>;
247
+ };
248
+ /** The two calls of the `disk` client (this package's dependency) that `archilControl` makes. */
249
+ export type DiskClient = Pick<typeof import("disk"), "configure" | "getDisk">;
250
+ /**
251
+ * One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
252
+ * caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
253
+ * control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
254
+ * seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
255
+ * `client` replaces the `disk` client (tests).
256
+ */
257
+ export declare function archilControl(options: {
258
+ readonly apiKey: string;
259
+ readonly disk: string;
260
+ readonly region: string;
261
+ readonly client?: DiskClient;
262
+ }): Promise<ArchilControl>;
244
263
  export interface TokenSweepOptions {
245
264
  /** The disk's token users (`getDisk(id).authorizedUsers`; the list lags by seconds). */
246
265
  listUsers(): Promise<TokenUser[]>;
@@ -330,9 +349,9 @@ export declare function checkHost(options: CheckOptions): Promise<CheckReport>;
330
349
  /**
331
350
  * Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
332
351
  * DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
333
- * store. So: revoke every delegation on the subtree, delete the files, then the directory markers deepest first (a
334
- * directory with children refuses), then list the prefix. Anything left is RUN_TREE_NOT_DELETED; a non-empty prefix is
335
- * never reported as deleted.
352
+ * store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
353
+ * directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
354
+ * RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
336
355
  */
337
356
  export declare function deleteRunTree(control: CheckControl, id: string): Promise<{
338
357
  objects: number;
package/dist/supervise.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { open } from "node:fs/promises";
7
7
  import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
- import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
9
+ import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, revokeBestEffort, revokeCompanions, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
10
10
  import { FencedError, HeldError, PdaError } from "./errors.js";
11
11
  import { parseRunRecord, RUN_JSON } from "./status.js";
12
12
  export const CONTROL_TIMEOUT_MS = 10_000;
@@ -106,7 +106,7 @@ export async function ensureRunning(ref, host, options) {
106
106
  }
107
107
  // A due wake still goes through the delegation check: an instance that wrote `sleeping` and died before releasing
108
108
  // left a held or orphaned delegation, which a plain start would hit (76) on every tick.
109
- const held = await listHeld(opts.control, ref.id, opts.pathless);
109
+ const { held, all } = await listHeld(opts.control, ref.id, opts.pathless);
110
110
  // A start in flight: the instance has not written run.json at its generation yet (it may not even have mounted).
111
111
  // Revoking it or starting a second one would fence what was just started.
112
112
  const baseGraceMs = opts.startGraceMs ?? leaseExpiryMs;
@@ -144,7 +144,7 @@ export async function ensureRunning(ref, host, options) {
144
144
  const inFlight = await starting();
145
145
  if (inFlight)
146
146
  return inFlight;
147
- await revokeListed(opts.control, ref.id, held);
147
+ await revokeListed(opts.control, ref.id, held, all);
148
148
  return start("orphaned", held);
149
149
  }
150
150
  if (held.some((d) => d.isPending && !d.isOrphaned))
@@ -157,7 +157,7 @@ export async function ensureRunning(ref, host, options) {
157
157
  if (inFlight)
158
158
  return inFlight;
159
159
  const stonith = status?.holder ? await stopHolder(host, status.holder, opts.stonithTimeoutMs ?? STONITH_TIMEOUT_MS) : { outcome: "no-holder" };
160
- await revokeListed(opts.control, ref.id, held);
160
+ await revokeListed(opts.control, ref.id, held, all);
161
161
  return start("lease-expired", held, stonith);
162
162
  }
163
163
  /**
@@ -219,9 +219,11 @@ async function ensureRunDir(control, id, owner) {
219
219
  await createRunDir(control, id, owner);
220
220
  return true;
221
221
  }
222
+ /** The run's delegations (`matchDelegations`), and the listing they came from. */
222
223
  async function listHeld(control, id, resolve) {
223
224
  try {
224
- return await findDelegations(control, id, resolve);
225
+ const all = await control.listDelegations();
226
+ return { held: await matchDelegations(all, id, resolve ?? pathlessResolver(control)), all };
225
227
  }
226
228
  catch (err) {
227
229
  throw new SuperviseError("CONTROL_API_FAILED", `listing delegations on ${runPath(id)} failed`, { cause: err });
@@ -231,18 +233,20 @@ const sameDelegation = (a, b) => a.clientId === b.clientId && a.inodeId === b.in
231
233
  /**
232
234
  * Revoke exactly the delegations the decision was made on, never a fresh listing: a racing supervisor may have started
233
235
  * a new holder since, and that one is not ours to judge. A revoke that fails is fine only if the delegation is gone.
236
+ * Then the same clients' private directories (`revokeCompanions`): those clients are gone or fenced.
234
237
  */
235
- async function revokeListed(control, id, held) {
238
+ async function revokeListed(control, id, held, all) {
236
239
  for (const d of held) {
237
240
  try {
238
241
  await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
239
242
  }
240
243
  catch (err) {
241
- const still = await listHeld(control, id).then((now) => now.some((n) => sameDelegation(n, d)));
244
+ const still = await listHeld(control, id).then((now) => now.held.some((n) => sameDelegation(n, d)));
242
245
  if (still)
243
246
  throw new SuperviseError("CONTROL_API_FAILED", `revoking ${runPath(id)} (client ${d.clientId}) failed`, { cause: err });
244
247
  }
245
248
  }
249
+ await revokeCompanions(control, held, all);
246
250
  }
247
251
  function settleWithin(promise, ms) {
248
252
  return new Promise((resolve) => {
@@ -343,6 +347,31 @@ export async function superviseRuns(refs, host, opts) {
343
347
  }
344
348
  return out;
345
349
  }
350
+ /**
351
+ * One Archil disk's control API from an API key, the object `pi-durable-disk supervise` builds for itself: a library
352
+ * caller that composes its own supervisor (with `daytonaHost`, or a driver of its own) passes it as `control`. The S3 and
353
+ * control calls go to the disk the key reaches; `listUsers` reads the disk record again on every call (the list lags by
354
+ * seconds). `configure` is the `disk` client's process-wide setting, so one process controls disks of one key and region.
355
+ * `client` replaces the `disk` client (tests).
356
+ */
357
+ export async function archilControl(options) {
358
+ const { configure, getDisk } = options.client ?? (await import("disk"));
359
+ configure({ apiKey: options.apiKey, region: options.region });
360
+ const disk = await getDisk(options.disk);
361
+ return {
362
+ getObject: (key) => disk.getObject(key),
363
+ headObject: (key) => disk.headObject(key),
364
+ putObject: (key, body, putOptions) => disk.putObject(key, body, putOptions),
365
+ addUser: (user) => disk.addUser(user),
366
+ removeUser: (type, identifier) => disk.removeUser(type, identifier),
367
+ listDelegations: () => disk.listDelegations(),
368
+ revokeDelegation: (d) => disk.revokeDelegation(d),
369
+ exec: (command) => disk.exec(command),
370
+ listObjects: (prefix, listOptions) => disk.listObjects(prefix, listOptions),
371
+ deleteObjects: (keys, deleteOptions) => disk.deleteObjects(keys, deleteOptions),
372
+ listUsers: async () => (await getDisk(options.disk)).authorizedUsers ?? [],
373
+ };
374
+ }
346
375
  const RELEASED = ["paused", "sleeping", "done", "failed"];
347
376
  /**
348
377
  * Remove mount-token users that no live mount needs. Removing a token user under a live mount makes that mount lose its
@@ -501,15 +530,13 @@ export async function checkHost(options) {
501
530
  /**
502
531
  * Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
503
532
  * DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
504
- * store. So: revoke every delegation on the subtree, delete the files, then the directory markers deepest first (a
505
- * directory with children refuses), then list the prefix. Anything left is RUN_TREE_NOT_DELETED; a non-empty prefix is
506
- * never reported as deleted.
533
+ * store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
534
+ * directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
535
+ * RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
507
536
  */
508
537
  export async function deleteRunTree(control, id) {
509
538
  const prefix = `${runPath(id)}/`;
510
- const held = await findDelegations(control, id);
511
- for (const d of held)
512
- await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
539
+ const held = await revokeBestEffort(control, id);
513
540
  const keys = (await control.listObjects(prefix, { recursive: true })).objects.map((o) => o.key);
514
541
  const dirs = [...new Set([...keys.filter((k) => k.endsWith("/")), prefix])];
515
542
  const depth = (k) => k.split("/").length;
@@ -9,6 +9,8 @@ export interface SweepOptions {
9
9
  readonly proc?: string;
10
10
  readonly sys?: string;
11
11
  readonly kill?: (pid: number, signal: NodeJS.Signals) => void;
12
+ /** Lists processes with this ps(1) instead of reading `proc`: the default on macOS, which has no /proc, unless `proc` is given. */
13
+ readonly ps?: (file: string, args: readonly string[]) => string;
12
14
  }
13
15
  /** One look at the shared state at `now`. True once the lease has lapsed, for good; the first look records the age. */
14
16
  export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
@@ -16,7 +18,7 @@ export declare const lapsed: (shared: BigInt64Array, now: bigint) => boolean;
16
18
  * SIGKILL the instance's commands: the process group of each direct child (pi starts each command as its own group's
17
19
  * leader), or the child alone when it shares the instance's group; with `ownCgroup`, every other process of the cgroup.
18
20
  * Never the instance or its group. A process this user may not signal (sudo's archil helpers) is skipped. Returns the
19
- * number of signals sent. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
21
+ * number of signals sent. Where there is no /proc (macOS) the processes come from one ps listing, and there is no cgroup. `stat` lines are `pid (comm) state ppid pgrp ...`, read after the last `)` since comm may hold
20
22
  * spaces and parentheses.
21
23
  */
22
24
  export declare const sweep: (instance: number, options?: SweepOptions) => number;