@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
@@ -121,7 +121,11 @@ export class DaytonaHostError extends PdaError {
121
121
  super(code, message, options);
122
122
  }
123
123
  }
124
- const BOX_PATH = /^\/[A-Za-z0-9._/-]+$/;
124
+ // Shell-inert characters only: the paths go into launcher command lines. `@` admits npm's scoped package directories
125
+ // (`node_modules/@scope/pkg`), where the package is installed with npm and not copied to a fixed directory.
126
+ const BOX_PATH = /^\/[A-Za-z0-9._@/-]+$/;
127
+ /** A path that climbs out of where it names: never a box path, whatever else is in it. */
128
+ const climbs = (path) => path.split("/").includes("..");
125
129
  const ACCOUNT = /^(?:[a-z_][a-z0-9_-]{0,31}|\d{1,10})$/;
126
130
  const FLEET = /^[a-z0-9][a-z0-9-]{0,62}$/;
127
131
  const UP = new Set(["creating", "pulling_snapshot", "pending_build", "building_snapshot", "starting", "restoring", "resuming", "resizing", "snapshotting", "forking", "stopping", "pausing", "paused"]);
@@ -152,6 +156,14 @@ export function sandboxName(prefix, runId, now) {
152
156
  return `${prefix}${slug}-${stamp}`.slice(0, 63);
153
157
  }
154
158
  /** The last line of a command's output that parses as JSON, or null. */
159
+ /**
160
+ * What the launcher printed, for a start it gave no reason for: the last 4 KB, or that it printed nothing. It prints only
161
+ * its state; the mount token is in a root-only file it reads, never in its output.
162
+ */
163
+ function launcherOutput(text) {
164
+ const out = text.trim();
165
+ return out ? `the launcher printed: ${out.slice(-4096)}` : "the launcher printed nothing and exited 0";
166
+ }
155
167
  function lastJson(text) {
156
168
  for (const line of text.trim().split("\n").reverse()) {
157
169
  try {
@@ -185,7 +197,7 @@ export function daytonaHost(opts) {
185
197
  const now = opts.now ?? Date.now;
186
198
  const launch = opts.launcher ?? `${packageDir}/dist/hosts/daytona-launch.js`;
187
199
  for (const [what, value] of Object.entries({ mountRoot, node, packageDir, archil, boxDir: dir, stageDir: stage, launcher: launch })) {
188
- if (!BOX_PATH.test(value))
200
+ if (!BOX_PATH.test(value) || climbs(value))
189
201
  throw new DaytonaHostError("INVALID_ARGUMENT", `${what} ${JSON.stringify(value)} is not a plain absolute path`);
190
202
  }
191
203
  if (!ACCOUNT.test(user) || !ACCOUNT.test(group))
@@ -288,7 +300,7 @@ export function daytonaHost(opts) {
288
300
  const r = await run(box, launcher("start", name, left), Math.ceil(left / 1000) + 10, "starting the instance");
289
301
  const state = lastJson(r.result);
290
302
  if (!state || !(state.spawned > 0)) {
291
- throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason ?? r.result.trim().slice(0, 300)}`);
303
+ throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason || launcherOutput(r.result)}`);
292
304
  }
293
305
  return handle;
294
306
  }
@@ -54,10 +54,13 @@ export declare class LocalHostError extends PdaError {
54
54
  cause?: unknown;
55
55
  });
56
56
  }
57
+ /** The argv of that child for `path`. */
58
+ export declare const statCodeArgv: (path: string) => string[];
57
59
  /** Exits a restart cannot fix: 65 data error, 70 store head unreadable, 75 fenced, 76 held. */
58
60
  export declare const TERMINAL_EXITS: readonly [65, 70, 75, 76];
59
61
  /** Minimal environment, a timeout that settles even if a grandchild keeps the pipes, and the input on stdin. */
60
62
  export declare const runCommand: Runner;
63
+ /** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
61
64
  export declare function currentBootId(): string;
62
65
  /** `systemctl show` output as a map. */
63
66
  export declare function parseShow(text: string): Record<string, string>;
@@ -67,5 +70,8 @@ export declare function localHost(opts?: LocalHostOptions): HostDriver & {
67
70
  readonly hostName: string;
68
71
  readonly mountRoot: string;
69
72
  };
70
- /** Field 22 of /proc/<pid>/stat (start time in clock ticks), so a reused pid is never mistaken for the instance. */
73
+ /**
74
+ * A live process's start (field 22 of /proc/<pid>/stat, in clock ticks; on macOS the second it started), so a reused pid is
75
+ * never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
76
+ */
71
77
  export declare function procStartTicks(pid: number): number | null;
@@ -12,14 +12,14 @@
12
12
  // place reads nothing and reuses its live mount), the instance closes its stdin after reading, and stop removes it.
13
13
  // The token is never in argv, the environment, a systemd credential directory or anything the run's user can open.
14
14
  import { spawn } from "node:child_process";
15
- import { readFileSync } from "node:fs";
16
15
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
17
16
  import { hostname } from "node:os";
18
17
  import { extname, join } from "node:path";
19
18
  import { fileURLToPath } from "node:url";
20
- import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
19
+ import { ARCHIL_SCOPED, DEAD_CONNECTION, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
21
20
  import { EXIT_FENCED, EXIT_HELD, PdaError } from "../errors.js";
22
21
  import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
22
+ import { processTable, thisBoot } from "../process-table.js";
23
23
  export class LocalHostError extends PdaError {
24
24
  constructor(code, message, options = {}) {
25
25
  super(code, message, options);
@@ -32,7 +32,13 @@ const SYSTEMD_RUN = "/usr/bin/systemd-run";
32
32
  const ARCHIL = "/usr/bin/archil";
33
33
  const UNIT_SAFE = /^[A-Za-z0-9:_.-]{1,200}$/;
34
34
  const POLITE_UNMOUNT_MS = 10_000;
35
- const STAT = "/usr/bin/stat";
35
+ /**
36
+ * A stat of one path in a child process, so a stuck daemon never blocks this one: prints the errno code (or "ok"), never a
37
+ * message, which coreutils' `stat` prints in the locale's language.
38
+ */
39
+ const STAT_CODE = `try { require("node:fs").statSync(process.argv[1]); process.stdout.write("ok"); } catch (e) { process.stdout.write(String(e.code)); process.exitCode = 1; }`;
40
+ /** The argv of that child for `path`. */
41
+ export const statCodeArgv = (path) => [process.execPath, "-e", STAT_CODE, path];
36
42
  const FUSERMOUNT = "/usr/bin/fusermount";
37
43
  const UMOUNT = "/usr/bin/umount";
38
44
  /** How long a stat of the mountpoint may take before its daemon counts as alive but stuck (never removed). */
@@ -63,8 +69,12 @@ export const runCommand = (argv, opts = {}) => new Promise((resolve, reject) =>
63
69
  child.stdin.on("error", () => { });
64
70
  child.stdin.end(opts.input ?? "");
65
71
  });
72
+ /** This boot's id: `/proc/sys/kernel/random/boot_id`, or `kern.bootsessionuuid` on macOS. Throws when it cannot be read. */
66
73
  export function currentBootId() {
67
- return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
74
+ const boot = thisBoot();
75
+ if (boot === null)
76
+ throw new Error("this machine's boot id cannot be read");
77
+ return boot;
68
78
  }
69
79
  const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
70
80
  const okRan = (r) => r.code === 0 && !r.timedOut;
@@ -234,13 +244,14 @@ export function localHost(opts = {}) {
234
244
  await new Promise((r) => setTimeout(r, 100));
235
245
  }
236
246
  /**
237
- * Whether the mount at `mp` is dead: its FUSE daemon is gone, so the kernel answers with ENOTCONN ("Transport endpoint
238
- * is not connected"). The stat runs in a child with a timeout, so a live but stuck daemon never blocks this process; a
239
- * stat that answers or does not finish is a live mount.
247
+ * Whether the mount at `mp` is dead: its FUSE daemon is gone, so the stat fails with one of `DEAD_CONNECTION`'s codes
248
+ * (ENOTCONN, or ECONNABORTED for a stat in flight while the dying daemon's connection is torn down). The stat runs in a
249
+ * child with a timeout, so a live but stuck daemon never blocks this process, and the child prints the code, not a
250
+ * message; a stat that answers, fails otherwise or does not finish is a live mount.
240
251
  */
241
252
  async function deadMount(mp) {
242
- const r = await exec([STAT, "-c", "%i", mp], { timeoutMs: STAT_TIMEOUT_MS });
243
- return !r.timedOut && r.code !== 0 && /Transport endpoint is not connected/.test(r.stderr);
253
+ const r = await exec(statCodeArgv(mp), { timeoutMs: STAT_TIMEOUT_MS });
254
+ return !r.timedOut && r.code !== 0 && DEAD_CONNECTION.has(r.stdout.trim());
244
255
  }
245
256
  /** Remove a dead mount: `fusermount -u`, then a lazy `umount -l`. The mount table, not an exit code, decides. */
246
257
  async function removeDeadMount(mp) {
@@ -299,6 +310,11 @@ export function localHost(opts = {}) {
299
310
  return;
300
311
  if (h.mode === "child") {
301
312
  const pid = Number(h.pid);
313
+ // A handle comes back from run.json with whatever was written there, and stop signals its pid as a group: only a
314
+ // real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
315
+ // process it may signal), and without start ticks nothing tells a reused pid from the instance.
316
+ if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
317
+ return;
302
318
  const alive = () => procStartTicks(pid) === h.startTicks;
303
319
  if (!alive())
304
320
  return;
@@ -333,19 +349,17 @@ export function localHost(opts = {}) {
333
349
  },
334
350
  };
335
351
  }
336
- /** Field 22 of /proc/<pid>/stat (start time in clock ticks), so a reused pid is never mistaken for the instance. */
352
+ /**
353
+ * A live process's start (field 22 of /proc/<pid>/stat, in clock ticks; on macOS the second it started), so a reused pid is
354
+ * never mistaken for the instance. Null for a zombie, a process that is gone, or one the table cannot be read for.
355
+ */
337
356
  export function procStartTicks(pid) {
338
357
  if (!Number.isInteger(pid) || pid <= 0)
339
358
  return null;
340
- let stat;
341
359
  try {
342
- stat = readFileSync(`/proc/${pid}/stat`, "utf8");
360
+ return processTable().snapshot().info(pid)?.start ?? null;
343
361
  }
344
362
  catch {
345
363
  return null;
346
364
  }
347
- const rest = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
348
- if (rest[0] === "Z" || rest[0] === "X")
349
- return null;
350
- return Number(rest[19]);
351
365
  }
package/dist/index.d.ts CHANGED
@@ -1,15 +1,31 @@
1
- export * from "./app.js";
2
- export * from "./claim.js";
3
- export { PathOutsideWorkError, type RefusalReason } from "./confine.js";
4
- export * from "./env.js";
5
- export * from "./errors.js";
6
- export * from "./fork.js";
7
- export * from "./hosts/daytona.js";
8
- export * from "./hosts/docker.js";
9
- export * from "./hosts/local-host.js";
10
- export * from "./park.js";
11
- export * from "./run.js";
12
- export * from "./serve.js";
13
- export * from "./status.js";
14
- export * from "./store.js";
15
- export * from "./supervise.js";
1
+ export { openDurableRun, RunError, StoreBehindSealError } from "./run.js";
2
+ export type { BeforeResume, DurableRun, LeaseOptions, OpenDurableRunOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim, RunEnv, RunErrorCode, RunLease, StoreHeadSource, StoreReads } from "./run.js";
3
+ export { localRun } from "./local-run.js";
4
+ export type { LocalRun } from "./local-run.js";
5
+ export { CLAIM_UNMOUNTED, openClaimDir, RunRecordError } from "./status.js";
6
+ export type { ClaimDir, OpenClaimDirOptions, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
7
+ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
8
+ export type { ArchilEnvClaim, ArchilEnvFactory, ArchilEnvOptions, EnvErrorCode } from "./env.js";
9
+ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
10
+ export type { AcquireOptions, ArchilHost, Claim, ControlApi, PathlessResolver, RunRef, Unmounted } from "./claim.js";
11
+ export { PathOutsideWorkError } from "./confine.js";
12
+ export type { RefusalReason } from "./confine.js";
13
+ export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
14
+ export type { ClaimErrorCode, Holder } from "./errors.js";
15
+ export type { ArchilStore, Fence, FencedDatabase, Profile, StoreFencedError } from "./store.js";
16
+ export { AppError } from "./app.js";
17
+ export type { AppContext, AppModule, AppOptions } from "./app.js";
18
+ export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
19
+ export type { ArchilControl, CheckControl, CheckOptions, DiskClient, EnsureOptions, EnsureResult, HostDriver, HostHandle, HostStatus, Json, StartAttempt, StartReason, StonithOutcome, SuperviseErrorCode, SupervisorControl, TokenUser } from "./supervise.js";
20
+ export { localHost, LocalHostError } from "./hosts/local-host.js";
21
+ export type { LocalHostOptions, Ran, Runner } from "./hosts/local-host.js";
22
+ export { dockerHost, DockerHostError } from "./hosts/docker.js";
23
+ export type { ContainerInfo, DockerHostOptions, DockerRan, DockerRunner } from "./hosts/docker.js";
24
+ export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
25
+ export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHostOptions, DaytonaRestOptions, ExecResult, SandboxInfo } from "./hosts/daytona.js";
26
+ export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
27
+ export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
28
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
29
+ export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
30
+ export { fork, ForkError, forkLocal } from "./fork.js";
31
+ export type { ForkErrorCode, ForkLocalResult, ForkOptions, ForkResult } from "./fork.js";
package/dist/index.js CHANGED
@@ -1,16 +1,24 @@
1
- // Public API.
2
- export * from "./app.js";
3
- export * from "./claim.js";
1
+ // The package's public API. Every name is listed: a new export is a reviewed line here, and test/entries.test.ts pins the
2
+ // list of each entry. `./lease` is the narrow entry for a host that owns its Harness; `./internals` holds plumbing that
3
+ // examples and demos use, with no compatibility promise.
4
+ // The run: open it, on a disk or a local directory, with its environment, its claim and the errors it fails with.
5
+ export { openDurableRun, RunError, StoreBehindSealError } from "./run.js";
6
+ export { localRun } from "./local-run.js";
7
+ export { CLAIM_UNMOUNTED, openClaimDir, RunRecordError } from "./status.js";
8
+ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySafe } from "./env.js";
9
+ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
4
10
  export { PathOutsideWorkError } from "./confine.js";
5
- export * from "./env.js";
6
- export * from "./errors.js";
7
- export * from "./fork.js";
8
- export * from "./hosts/daytona.js";
9
- export * from "./hosts/docker.js";
10
- export * from "./hosts/local-host.js";
11
- export * from "./park.js";
12
- export * from "./run.js";
13
- export * from "./serve.js";
14
- export * from "./status.js";
15
- export * from "./store.js";
16
- export * from "./supervise.js";
11
+ export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
12
+ // The app contract: what `--app` names.
13
+ export { AppError } from "./app.js";
14
+ // The supervisor and the host drivers.
15
+ export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
16
+ export { localHost, LocalHostError } from "./hosts/local-host.js";
17
+ export { dockerHost, DockerHostError } from "./hosts/docker.js";
18
+ export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
19
+ // Serve: one HTTP request runs one turn.
20
+ export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
21
+ // Parking: an idle run gives its host back.
22
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
23
+ // Fork: a new run from a run's committed state.
24
+ export { fork, ForkError, forkLocal } from "./fork.js";
@@ -0,0 +1,3 @@
1
+ export { createRunDir, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath, unmountClaim } from "./claim.js";
2
+ export { LABEL_FLEET, LABEL_RUN, sandboxName, sandboxStatus, sweepSandboxes } from "./hosts/daytona.js";
3
+ export { isLeaseFresh, parseRunRecord, RUN_JSON } from "./status.js";
@@ -0,0 +1,6 @@
1
+ // `@parcha/pi-durable-disk/internals`: the claim, status and Daytona plumbing that this repository's examples and demos
2
+ // use. No compatibility promise: a name here may change or go in any release, and the CHANGELOG says when. A name moves
3
+ // to the root entry when something outside this repository needs it.
4
+ export { createRunDir, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath, unmountClaim } from "./claim.js";
5
+ export { LABEL_FLEET, LABEL_RUN, sandboxName, sandboxStatus, sweepSandboxes } from "./hosts/daytona.js";
6
+ export { isLeaseFresh, parseRunRecord, RUN_JSON } from "./status.js";
package/dist/lease.d.ts CHANGED
@@ -3,5 +3,8 @@ export type { LeaseOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim
3
3
  export * from "./errors.js";
4
4
  export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
5
5
  export type { ArchilStore, OpenOptions, PragmaMismatch, Profile } from "./store.js";
6
- export type { ArchilHost, Claim, RunRef } from "./claim.js";
7
- export type { RunHolder, RunRecord, RunStatus } from "./status.js";
6
+ export type { AcquireOptions, ArchilHost, Claim, RunRef, Unmounted } from "./claim.js";
7
+ export type { ClaimDir, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
8
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
9
+ export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
10
+ export type { DurableRun, RunEnv } from "./run.js";
package/dist/lease.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // The narrow entry (`@parcha/pi-durable-disk/lease`) for a host that owns its own pi-durable Harness and store connections: the
2
2
  // claim's lifecycle without the supervisor, the host drivers, the CLI or the app contract. Importing it must not pull those
3
- // in (test/lease.test.ts bundles it and checks); a name added here is part of that promise.
3
+ // in (test/lease-entry.test.ts bundles it and checks); a name added here is part of that promise.
4
4
  export { EXIT_DATAERR, EXIT_SOFTWARE, LeaseLapsedError, openRunLease, OWNER_LOCK, RunError, StoreBehindSealError, storeHead, STORE_FILE } from "./run.js";
5
5
  export * from "./errors.js";
6
6
  export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
7
+ // Parking, for a Harness the host opened over `openRunLease` (`leaseParkTarget`).
8
+ export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
@@ -0,0 +1,10 @@
1
+ import type { AcquireOptions, Claim } from "./claim.js";
2
+ import { type ClaimDir } from "./status.js";
3
+ export interface LocalRun {
4
+ /** Takes the claim: the directory, made when it is missing. */
5
+ readonly acquire: (options: AcquireOptions) => Promise<Claim>;
6
+ /** Holds the directory open as the run's root; a local directory is not an Archil mount, so its type is not checked. */
7
+ readonly claimDir: (root: string) => Promise<ClaimDir>;
8
+ }
9
+ /** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
10
+ export declare function localRun(root: string): LocalRun;
@@ -0,0 +1,64 @@
1
+ // A run on a plain local directory, for one machine: the claim is the directory itself. Nothing mounts and nothing is
2
+ // delegated, so nothing but this machine's owner lock stands between two processes and the run: it is for a laptop, a CI
3
+ // job or a test, never for a directory another machine can reach.
4
+ //
5
+ // const run = await openDurableRun(ref, { ...localRun("/var/runs/run-1"), harness: { models, registry } });
6
+ //
7
+ // No mount token is needed (`acquire` is replaced). The claim's barrier does not sync the files the agent wrote: the
8
+ // store's own commits are made durable by SQLite, and work/ is the operating system's to flush; a power loss can lose what
9
+ // it had not written yet. It refuses once the claim is fenced, as a mount's does.
10
+ //
11
+ // On macOS, for development: it has no /proc/self/fd and no setpriv, so there the run's root is addressed by its path
12
+ // (openClaimDir's `byPath`), commands keep the user's rights, the file tools are confined by path (confine.ts), and the
13
+ // commands are recorded from ps (process-table.ts). The README's "On macOS" says what that costs.
14
+ import { mkdir } from "node:fs/promises";
15
+ import { join, resolve } from "node:path";
16
+ import { FencedError } from "./errors.js";
17
+ import { openClaimDir } from "./status.js";
18
+ /** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
19
+ export function localRun(root) {
20
+ const dir = resolve(root);
21
+ return {
22
+ acquire: async ({ ref }) => {
23
+ await mkdir(dir, { recursive: true });
24
+ return new LocalClaim(ref, dir);
25
+ },
26
+ claimDir: (at) => openClaimDir(at, { fstype: null }),
27
+ };
28
+ }
29
+ class LocalClaim {
30
+ ref;
31
+ disk;
32
+ root;
33
+ work;
34
+ store;
35
+ /** `<root>/commands`: a local run has no unit cgroup, so the next open ends the commands a killed one left running. */
36
+ commands;
37
+ reused = false;
38
+ stray = null;
39
+ forced = false;
40
+ timings = { mountMs: 0, verifyMs: 0 };
41
+ #fence;
42
+ constructor(ref, root) {
43
+ this.ref = ref;
44
+ this.disk = ref.disk;
45
+ this.root = root;
46
+ this.work = join(root, "work");
47
+ this.store = join(root, "store");
48
+ this.commands = join(root, "commands");
49
+ }
50
+ get fenced() {
51
+ return this.#fence !== undefined;
52
+ }
53
+ markFenced(cause) {
54
+ this.#fence ??= { cause };
55
+ }
56
+ async barrier() {
57
+ if (this.#fence)
58
+ throw new FencedError(`${this.root} is fenced; nothing is retried on it`, { cause: this.#fence.cause });
59
+ return { ms: 0 };
60
+ }
61
+ async release() {
62
+ return { via: "none" };
63
+ }
64
+ }
package/dist/park.d.ts CHANGED
@@ -26,9 +26,10 @@ export type BusyState = {
26
26
  };
27
27
  /**
28
28
  * The deadline of a task that is only sleeping: `until` in pi's retry phase (generation and compaction), `pollAt` in
29
- * its deferred poll phase. This is pi-durable 1.0.4's internal checkpoint shape (harness/generation.js, compaction.js),
30
- * read here and nowhere else. Any other shape is no deadline, which counts as busy, so a pi that changes the shape keeps
31
- * instances up rather than parking them mid-work. pi#10325 asks for this as a contract.
29
+ * its deferred poll phase. This is pi-durable's internal checkpoint shape, the same in 1.0.4 and 1.1.0
30
+ * (harness/generation.js, compaction.js), read here and nowhere else. Any other shape is no deadline, which counts as
31
+ * busy, so a pi that changes the shape keeps instances up rather than parking them mid-work. pi#10325 asks for this as
32
+ * a contract.
32
33
  */
33
34
  export declare function waitDeadline(checkpoint: JsonValue | undefined): number | undefined;
34
35
  /**
package/dist/park.js CHANGED
@@ -27,9 +27,10 @@ export function leaseParkTarget(lease, harness, context = BACKGROUND_CONTEXT) {
27
27
  export const LOCAL_PARK_THRESHOLD_MS = 60_000;
28
28
  /**
29
29
  * The deadline of a task that is only sleeping: `until` in pi's retry phase (generation and compaction), `pollAt` in
30
- * its deferred poll phase. This is pi-durable 1.0.4's internal checkpoint shape (harness/generation.js, compaction.js),
31
- * read here and nowhere else. Any other shape is no deadline, which counts as busy, so a pi that changes the shape keeps
32
- * instances up rather than parking them mid-work. pi#10325 asks for this as a contract.
30
+ * its deferred poll phase. This is pi-durable's internal checkpoint shape, the same in 1.0.4 and 1.1.0
31
+ * (harness/generation.js, compaction.js), read here and nowhere else. Any other shape is no deadline, which counts as
32
+ * busy, so a pi that changes the shape keeps instances up rather than parking them mid-work. pi#10325 asks for this as
33
+ * a contract.
33
34
  */
34
35
  export function waitDeadline(checkpoint) {
35
36
  if (typeof checkpoint !== "object" || checkpoint === null || Array.isArray(checkpoint))
@@ -0,0 +1,38 @@
1
+ /** A live process (a zombie is not one): its parent, its group, and its start. */
2
+ export interface ProcessInfo {
3
+ readonly ppid: number;
4
+ readonly pgrp: number;
5
+ readonly start: number;
6
+ }
7
+ /** One look at the processes. Through ps, every `info` and `children` of one snapshot sees the same listing; /proc is read live. */
8
+ export interface ProcessSnapshot {
9
+ info(pid: number): ProcessInfo | undefined;
10
+ /** The direct children of `pid`. */
11
+ children(pid: number): number[];
12
+ /** Whether `pid` was started with `name=value` in its environment (false when it cannot be read). */
13
+ marked(pid: number, name: string, value: string): boolean;
14
+ /** Its command line, for a message; `?` when it cannot be read. */
15
+ commandLine(pid: number): string;
16
+ }
17
+ export interface ProcessTable {
18
+ /** A new look. Through ps it throws when the listing cannot be had or read: no process is ever taken for gone on a failed read. */
19
+ snapshot(): ProcessSnapshot;
20
+ /** This boot's id; undefined when it cannot be read. */
21
+ bootId(): string | undefined;
22
+ /** How often a recorder's walk is worth repeating: a /proc read is cheap, a ps listing is not. */
23
+ readonly walkMs: number;
24
+ }
25
+ /** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
26
+ export declare function procTable(proc?: string): ProcessTable;
27
+ /** Runs a program with arguments and returns what it printed; throws when it fails. */
28
+ export type CommandRunner = (file: string, args: readonly string[]) => string;
29
+ /** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
30
+ export declare function psTable(run?: CommandRunner): ProcessTable;
31
+ /** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
32
+ export declare const processTableHooks: {
33
+ table?: ProcessTable;
34
+ };
35
+ /** This machine's table: ps on macOS, /proc elsewhere. */
36
+ export declare function processTable(): ProcessTable;
37
+ /** This boot's id on this machine, null when it cannot be read. */
38
+ export declare function thisBoot(): string | null;
@@ -0,0 +1,158 @@
1
+ // The process table a run reads to know its commands and its holder. On Linux it is /proc, read live. macOS has no /proc:
2
+ // there it is one ps(1) listing per look, ps for a process's environment, and sysctl(8) for the boot. A process's start
3
+ // tells it from a later process with the same pid: on Linux its start time in clock ticks after boot (field 22 of
4
+ // /proc/<pid>/stat), on macOS the second it started (ps's `lstart`, read in UTC). A start is only ever compared with
5
+ // another read on the same machine and boot. On macOS a pid freed and handed out again within the same second would pass
6
+ // for the old process; pids there are handed out in order up to 99999 before one is reused.
7
+ import { execFileSync } from "node:child_process";
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ /** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
11
+ export function procTable(proc = "/proc") {
12
+ const read = (...parts) => readFileSync(join(proc, ...parts), "utf8");
13
+ const snapshot = {
14
+ // `stat`'s fields after the last `)`, since the command name may hold spaces and parentheses.
15
+ info(pid) {
16
+ try {
17
+ const text = read(String(pid), "stat");
18
+ const fields = text.slice(text.lastIndexOf(")") + 2).split(" ");
19
+ if (fields[0] === "Z" || fields[0] === "X")
20
+ return undefined;
21
+ return { ppid: Number(fields[1]), pgrp: Number(fields[2]), start: Number(fields[19]) };
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ },
27
+ // From each of its threads' `children` list.
28
+ children(pid) {
29
+ let tasks;
30
+ try {
31
+ tasks = readdirSync(join(proc, String(pid), "task"));
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ return tasks.flatMap((tid) => {
37
+ try {
38
+ return read(String(pid), "task", tid, "children").split(" ").filter(Boolean).map(Number);
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ });
44
+ },
45
+ marked(pid, name, value) {
46
+ try {
47
+ return read(String(pid), "environ").split("\0").includes(`${name}=${value}`);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ },
53
+ commandLine(pid) {
54
+ try {
55
+ return read(String(pid), "cmdline").split("\0").filter(Boolean).join(" ") || "?";
56
+ }
57
+ catch {
58
+ return "?";
59
+ }
60
+ },
61
+ };
62
+ return {
63
+ snapshot: () => snapshot,
64
+ bootId() {
65
+ try {
66
+ return read("sys/kernel/random/boot_id").trim() || undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ },
72
+ walkMs: 100,
73
+ };
74
+ }
75
+ const PS = "/bin/ps";
76
+ const SYSCTL = "/usr/sbin/sysctl";
77
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
78
+ /** In the C locale and UTC, so `lstart` reads the same whatever the user's settings. */
79
+ const runCommand = (file, args) => execFileSync(file, args, { encoding: "utf8", env: { LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000, maxBuffer: 64 << 20 });
80
+ /**
81
+ * One line of `ps -A -o pid=,ppid=,pgid=,stat=,lstart=` in UTC: `pid ppid pgid stat Www Mmm dd hh:mm:ss yyyy`. Null
82
+ * for a zombie; throws on a line it does not read.
83
+ */
84
+ function psLine(line) {
85
+ const f = line.trim().split(/\s+/);
86
+ const [pid, ppid, pgrp] = f.slice(0, 3).map(Number);
87
+ const month = MONTHS.indexOf(f[5] ?? "");
88
+ const [h, m, s] = (f[7] ?? "").split(":").map(Number);
89
+ const start = Date.UTC(Number(f[8]), month, Number(f[6]), h, m, s) / 1000;
90
+ if (f.length !== 9 || ![pid, ppid, pgrp, start].every(Number.isSafeInteger) || month < 0)
91
+ throw new Error(`ps printed a line this does not read: ${JSON.stringify(line)}`);
92
+ return f[3].startsWith("Z") ? null : [pid, { ppid: ppid, pgrp: pgrp, start }];
93
+ }
94
+ /** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
95
+ export function psTable(run = runCommand) {
96
+ let boot;
97
+ return {
98
+ snapshot() {
99
+ const rows = new Map();
100
+ for (const line of run(PS, ["-A", "-o", "pid=,ppid=,pgid=,stat=,lstart="]).split("\n")) {
101
+ if (line.trim() === "")
102
+ continue;
103
+ const row = psLine(line);
104
+ if (row !== null)
105
+ rows.set(...row);
106
+ }
107
+ return {
108
+ info: (pid) => rows.get(pid),
109
+ children: (pid) => [...rows].filter(([, info]) => info.ppid === pid).map(([child]) => child),
110
+ // `-E` prints the environment the process was started with after its arguments, separated by spaces; a mark is
111
+ // a name and a UUID, which hold none.
112
+ marked(pid, name, value) {
113
+ try {
114
+ return run(PS, ["-ww", "-E", "-p", String(pid), "-o", "command="]).split(/\s+/).includes(`${name}=${value}`);
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ },
120
+ commandLine(pid) {
121
+ try {
122
+ return run(PS, ["-ww", "-p", String(pid), "-o", "command="]).trim() || "?";
123
+ }
124
+ catch {
125
+ return "?";
126
+ }
127
+ },
128
+ };
129
+ },
130
+ // Read once it can be: a boot's id does not change.
131
+ bootId() {
132
+ try {
133
+ boot ??= run(SYSCTL, ["-n", "kern.bootsessionuuid"]).trim() || undefined;
134
+ }
135
+ catch {
136
+ // not now; the next call asks again
137
+ }
138
+ return boot;
139
+ },
140
+ walkMs: 500,
141
+ };
142
+ }
143
+ /** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
144
+ export const processTableHooks = {};
145
+ let platformTable;
146
+ /** This machine's table: ps on macOS, /proc elsewhere. */
147
+ export function processTable() {
148
+ if (processTableHooks.table !== undefined)
149
+ return processTableHooks.table;
150
+ if (platformTable?.platform !== process.platform) {
151
+ platformTable = { platform: process.platform, table: process.platform === "darwin" ? psTable() : procTable() };
152
+ }
153
+ return platformTable.table;
154
+ }
155
+ /** This boot's id on this machine, null when it cannot be read. */
156
+ export function thisBoot() {
157
+ return processTable().bootId() ?? null;
158
+ }