@rehearsal-db/core 1.2.0 → 1.3.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/COMPATIBILITY.md +15 -6
  3. package/README.md +1 -1
  4. package/dist/package.json +2 -1
  5. package/dist/src/cli/arguments.d.mts +1 -0
  6. package/dist/src/cli/arguments.mjs +4 -0
  7. package/dist/src/cli/basic_command_handlers.mjs +2 -0
  8. package/dist/src/cli/cli_commands.mjs +2 -0
  9. package/dist/src/cli/project_commands.d.mts +8 -0
  10. package/dist/src/cli/runtime_commands.d.mts +2 -19
  11. package/dist/src/cli/runtime_commands.mjs +3 -90
  12. package/dist/src/cli/runtime_stack_runner.d.mts +41 -0
  13. package/dist/src/cli/runtime_stack_runner.mjs +104 -0
  14. package/dist/src/cli/runtime_workload_admission.d.mts +8 -0
  15. package/dist/src/cli/runtime_workload_admission.mjs +22 -0
  16. package/dist/src/cli/source_commands.d.mts +8 -0
  17. package/dist/src/cli/workload_command_handlers.d.mts +2 -0
  18. package/dist/src/cli/workload_command_handlers.mjs +38 -0
  19. package/dist/src/project/configuration_contract.d.mts +5 -0
  20. package/dist/src/project/configuration_normalization.d.mts +4 -0
  21. package/dist/src/project/configuration_resolution.d.mts +4 -0
  22. package/dist/src/project/configuration_resource_policy.d.mts +4 -0
  23. package/dist/src/project/configuration_resource_policy.mjs +20 -1
  24. package/dist/src/project/configuration_schema.mjs +1 -0
  25. package/dist/src/runtime/workload_queue.d.mts +39 -0
  26. package/dist/src/runtime/workload_queue.mjs +211 -0
  27. package/dist/src/runtime/workload_queue_location.d.mts +1 -0
  28. package/dist/src/runtime/workload_queue_location.mjs +43 -0
  29. package/dist/src/runtime/workload_queue_store.d.mts +27 -0
  30. package/dist/src/runtime/workload_queue_store.mjs +182 -0
  31. package/dist/src/shared/cancellation.mjs +1 -0
  32. package/docs/architecture.md +9 -0
  33. package/docs/commands.md +26 -6
  34. package/docs/configuration.md +33 -1
  35. package/docs/getting-started.md +1 -1
  36. package/docs/releasing.md +4 -0
  37. package/docs/roadmap.md +8 -6
  38. package/docs/stability.md +4 -4
  39. package/docs/updating.md +2 -2
  40. package/docs/vscode.md +1 -1
  41. package/package.json +2 -1
@@ -1,5 +1,5 @@
1
1
  /** Resource limits and proof dependencies, validated before any runtime starts. */
2
- import { assertPositiveInteger, } from "./configuration_validation.mjs";
2
+ import { assertKnownKeys, assertPlainObject, assertPositiveInteger, } from "./configuration_validation.mjs";
3
3
  export const APPLICATION_REQUIRED_SERVICES = Object.freeze([
4
4
  "database",
5
5
  "auth",
@@ -29,6 +29,24 @@ const nullablePositiveInteger = (value, path) => {
29
29
  return normalized;
30
30
  };
31
31
  export const normalizeResourcePolicy = (resources) => {
32
+ let workloadQueue;
33
+ if (resources.workloadQueue !== undefined) {
34
+ const path = "config.resources.workloadQueue";
35
+ const value = assertPlainObject(resources.workloadQueue, path);
36
+ assertKnownKeys(value, ["enabled", "waitTimeoutSeconds"], path);
37
+ if (typeof value.enabled !== "boolean")
38
+ throw new Error(`${path}.enabled must be a boolean.`);
39
+ const waitTimeoutSeconds = value.waitTimeoutSeconds === undefined ? 30 : value.waitTimeoutSeconds;
40
+ if (!Number.isInteger(waitTimeoutSeconds) ||
41
+ typeof waitTimeoutSeconds !== "number" ||
42
+ waitTimeoutSeconds < 0 ||
43
+ waitTimeoutSeconds > 300)
44
+ throw new Error(`${path}.waitTimeoutSeconds must be an integer from 0 to 300.`);
45
+ workloadQueue = Object.freeze({
46
+ enabled: value.enabled,
47
+ waitTimeoutSeconds,
48
+ });
49
+ }
32
50
  const schedule = resources.cleanupSchedule ?? "manual";
33
51
  if (typeof schedule !== "string" ||
34
52
  !["manual", "daily-preview", "weekly-preview"].includes(schedule)) {
@@ -39,6 +57,7 @@ export const normalizeResourcePolicy = (resources) => {
39
57
  throw new Error("config.resources.retainedEnvironmentCount must be at most 1000.");
40
58
  }
41
59
  return Object.freeze({
60
+ ...(workloadQueue === undefined ? {} : { workloadQueue }),
42
61
  staleAfterSeconds: nullablePositiveInteger(resources.staleAfterSeconds, "config.resources.staleAfterSeconds"),
43
62
  retainedEnvironmentCount,
44
63
  maximumBytes: nullablePositiveInteger(resources.maximumBytes, "config.resources.maximumBytes"),
@@ -92,6 +92,7 @@ export const validateConfigurationSchema = (input) => {
92
92
  "retainedEnvironmentCount",
93
93
  "maximumBytes",
94
94
  "cleanupSchedule",
95
+ "workloadQueue",
95
96
  ], "config.resources");
96
97
  const preparation = root.preparation === undefined
97
98
  ? null
@@ -0,0 +1,39 @@
1
+ import { type WorkloadTicket } from "./workload_queue_store.mjs";
2
+ interface QueueOptions {
3
+ stateDirectory?: string;
4
+ }
5
+ export declare const withWorkloadDiagnostics: <Result>(task: () => Promise<Result>) => Promise<Result>;
6
+ export declare const inspectWorkloads: (options?: QueueOptions) => Promise<{
7
+ schemaVersion: 1;
8
+ scope: "local-user-boot-process-namespace";
9
+ capacity: "not-measured";
10
+ recoveryDigest: string | null;
11
+ entries: {
12
+ id: string;
13
+ command: "migrate" | "reset" | "run" | "start";
14
+ pid: number;
15
+ phase: "choosing" | "held" | "queued";
16
+ owner: "alive" | "dead";
17
+ createdAt: string;
18
+ recoveryRequired: boolean;
19
+ }[];
20
+ }>;
21
+ /** Explicitly reviewed metadata recovery only; never stops processes or removes data. */
22
+ export declare const recoverWorkloads: (confirmation: string, options?: QueueOptions) => Promise<{
23
+ schemaVersion: 1;
24
+ recovered: number;
25
+ databasesChanged: false;
26
+ }>;
27
+ export declare const acquireWorkload: ({ command, waitTimeoutSeconds, signal, onWaiting, ...options }: QueueOptions & {
28
+ command: WorkloadTicket["command"];
29
+ waitTimeoutSeconds: number;
30
+ signal?: AbortSignal;
31
+ onWaiting?: () => void;
32
+ }) => Promise<{
33
+ release: () => Promise<void>;
34
+ }>;
35
+ export declare const withWorkloadAdmission: <Result>({ enabled, execute, ...options }: Parameters<typeof acquireWorkload>[0] & {
36
+ enabled: boolean;
37
+ execute: () => Promise<Result>;
38
+ }) => Promise<Result>;
39
+ export {};
@@ -0,0 +1,211 @@
1
+ /** Cooperative local-user lifecycle admission. Not a host-memory or container limit. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { performance } from "node:perf_hooks";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { RehearsalError } from "../shared/diagnostics.mjs";
6
+ import { workloadQueueDirectory } from "./workload_queue_location.mjs";
7
+ import { WorkloadQueueStore, } from "./workload_queue_store.mjs";
8
+ export const withWorkloadDiagnostics = async (task) => {
9
+ try {
10
+ return await task();
11
+ }
12
+ catch (error) {
13
+ if (error instanceof RehearsalError)
14
+ throw error;
15
+ throw new RehearsalError({
16
+ category: "runtime_dependency_failure",
17
+ code: "WORKLOAD_QUEUE_REFUSED",
18
+ message: error instanceof Error
19
+ ? error.message
20
+ : "Local workload coordination was refused.",
21
+ refused: "No new guarded runtime phase or foreign workload was started, stopped or recovered by this refusal.",
22
+ suggestions: [
23
+ "Run rehearsal workloads to inspect participating owners.",
24
+ "Cancel a waiting request or let its owner finish. Before recovering a dead holder, check its owned commands and descendants; never delete live queue state.",
25
+ ],
26
+ });
27
+ }
28
+ };
29
+ const openStore = async (options, create) => {
30
+ const store = new WorkloadQueueStore(await workloadQueueDirectory(options.stateDirectory));
31
+ return (await store.initialize(create)) ? store : null;
32
+ };
33
+ const earlier = (left, right) => left.number < right.number ||
34
+ (left.number === right.number && left.token < right.token);
35
+ const digest = (entries) => {
36
+ const stale = entries.filter(({ alive, ticket }) => !alive && ticket.phase === "held");
37
+ return stale.length
38
+ ? createHash("sha256")
39
+ .update(JSON.stringify(stale
40
+ .map(({ file, identity, ticket }) => ({ file, identity, ticket }))
41
+ .sort((a, b) => a.file.localeCompare(b.file))))
42
+ .digest("hex")
43
+ : null;
44
+ };
45
+ const snapshot = (entries) => ({
46
+ schemaVersion: 1,
47
+ scope: "local-user-boot-process-namespace",
48
+ capacity: "not-measured",
49
+ recoveryDigest: digest(entries),
50
+ entries: entries
51
+ .map(({ ticket, alive }) => ({
52
+ id: ticket.token,
53
+ command: ticket.command,
54
+ pid: ticket.pid,
55
+ phase: ticket.phase,
56
+ owner: alive ? "alive" : "dead",
57
+ createdAt: ticket.createdAt,
58
+ recoveryRequired: !alive && ticket.phase === "held",
59
+ }))
60
+ .sort((a, b) => a.id.localeCompare(b.id)),
61
+ });
62
+ export const inspectWorkloads = async (options = {}) => {
63
+ const store = await openStore(options, false);
64
+ return snapshot(store ? await store.list() : []);
65
+ };
66
+ /** Explicitly reviewed metadata recovery only; never stops processes or removes data. */
67
+ export const recoverWorkloads = async (confirmation, options = {}) => {
68
+ const store = await openStore(options, false);
69
+ if (!store)
70
+ throw new Error("No stale workload holder is available to recover.");
71
+ const entries = await store.list();
72
+ const expected = digest(entries);
73
+ if (!expected || confirmation !== expected)
74
+ throw new Error("Workload recovery confirmation changed or is missing. Run rehearsal workloads --json and review again.");
75
+ // A dead parent is not proof that its children are gone. The operator must check
76
+ // owned commands first; age, wait expiry and liveness never authorize auto-steal.
77
+ const eligible = entries.filter(({ alive, ticket }) => !alive && ticket.phase === "held");
78
+ for (const entry of eligible) {
79
+ const current = await store.read(entry.file);
80
+ if (!current || current.alive || current.identity !== entry.identity)
81
+ throw new Error("Workload recovery state changed; no live owner was removed.");
82
+ }
83
+ for (const entry of eligible)
84
+ await store.remove(entry);
85
+ return {
86
+ schemaVersion: 1,
87
+ recovered: eligible.length,
88
+ databasesChanged: false,
89
+ };
90
+ };
91
+ export const acquireWorkload = async ({ command, waitTimeoutSeconds, signal, onWaiting, ...options }) => {
92
+ if (!Number.isInteger(waitTimeoutSeconds) ||
93
+ waitTimeoutSeconds < 0 ||
94
+ waitTimeoutSeconds > 300)
95
+ throw new Error("Workload wait timeout must be an integer from 0 to 300 seconds.");
96
+ signal?.throwIfAborted();
97
+ const store = (await openStore(options, true));
98
+ if ((await store.list()).length >= 64)
99
+ throw new Error("Workload queue is full; no workload was started.");
100
+ const ticket = {
101
+ schemaVersion: 1,
102
+ token: randomUUID(),
103
+ pid: process.pid,
104
+ command,
105
+ phase: "choosing",
106
+ number: 0,
107
+ createdAt: new Date().toISOString(),
108
+ };
109
+ const file = `ticket-${ticket.token}.json`;
110
+ const removeOwned = async () => {
111
+ const entry = await store.read(file);
112
+ if (entry) {
113
+ if (entry.ticket.pid !== process.pid ||
114
+ entry.ticket.token !== ticket.token)
115
+ throw new Error("Workload ownership changed; no foreign ticket was removed.");
116
+ await store.remove(entry);
117
+ }
118
+ };
119
+ const started = performance.now();
120
+ let notified = false;
121
+ let held = false;
122
+ try {
123
+ // Choosing + number ordering follows the bakery protocol. Each participant
124
+ // writes only its unique record; no stale-reclaimer can rename a new owner.
125
+ await store.write(ticket, true);
126
+ const entries = await store.list();
127
+ ticket.number =
128
+ 1 + Math.max(0, ...entries.map(({ ticket: entry }) => entry.number));
129
+ if (!Number.isSafeInteger(ticket.number))
130
+ throw new Error("Workload ticket number limit reached.");
131
+ ticket.phase = "queued";
132
+ await store.write(ticket);
133
+ for (;;) {
134
+ signal?.throwIfAborted();
135
+ const current = await store.list();
136
+ const own = current.find((entry) => entry.file === file);
137
+ if (!own ||
138
+ own.ticket.phase !== "queued" ||
139
+ own.ticket.number !== ticket.number)
140
+ throw new Error("Workload ticket changed or disappeared; no workload was started.");
141
+ let blocked = false;
142
+ for (const entry of current) {
143
+ if (entry.file === file)
144
+ continue;
145
+ if (!entry.alive && entry.ticket.phase !== "held") {
146
+ await store.remove(entry); // This owner never began guarded work.
147
+ continue;
148
+ }
149
+ if (entry.ticket.phase === "choosing" ||
150
+ entry.ticket.phase === "held" ||
151
+ earlier(entry.ticket, ticket))
152
+ blocked = true;
153
+ }
154
+ if (!blocked) {
155
+ signal?.throwIfAborted();
156
+ ticket.phase = "held";
157
+ await store.write(ticket);
158
+ held = true;
159
+ break;
160
+ }
161
+ if (!notified) {
162
+ onWaiting?.();
163
+ notified = true;
164
+ }
165
+ if (performance.now() - started >= waitTimeoutSeconds * 1000)
166
+ throw new Error("Local workload queue wait expired. No queued runtime phase was started. Run rehearsal workloads; a dead holder requires reviewed recovery, not a forced stop.");
167
+ try {
168
+ await delay(Math.min(100, Math.max(1, waitTimeoutSeconds * 1000 - (performance.now() - started))), undefined, { signal });
169
+ }
170
+ catch (error) {
171
+ if (signal?.aborted)
172
+ throw signal.reason;
173
+ throw error;
174
+ }
175
+ }
176
+ }
177
+ finally {
178
+ if (!held)
179
+ await removeOwned();
180
+ }
181
+ let released = false;
182
+ return {
183
+ release: async () => {
184
+ if (!released) {
185
+ await removeOwned();
186
+ released = true;
187
+ }
188
+ },
189
+ };
190
+ };
191
+ export const withWorkloadAdmission = async ({ enabled, execute, ...options }) => {
192
+ if (!enabled)
193
+ return execute();
194
+ const lease = await withWorkloadDiagnostics(() => acquireWorkload(options));
195
+ let result;
196
+ try {
197
+ options.signal?.throwIfAborted();
198
+ result = await execute();
199
+ }
200
+ catch (error) {
201
+ try {
202
+ await lease.release();
203
+ }
204
+ catch (cleanupError) {
205
+ throw new AggregateError([error, cleanupError], "The runtime phase and its workload metadata release both failed.");
206
+ }
207
+ throw error;
208
+ }
209
+ await withWorkloadDiagnostics(() => lease.release());
210
+ return result;
211
+ };
@@ -0,0 +1 @@
1
+ export declare const workloadQueueDirectory: (stateDirectory?: string) => Promise<string>;
@@ -0,0 +1,43 @@
1
+ /** Stable same-user/boot/process-namespace coordination; never use per-run TMPDIR. */
2
+ import { execFile } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { readFile, readlink } from "node:fs/promises";
5
+ import { userInfo } from "node:os";
6
+ import { isAbsolute, join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ let scopePromise;
9
+ const currentScope = async () => {
10
+ let identity;
11
+ if (process.platform === "linux") {
12
+ identity = `${(await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim()}:${await readlink("/proc/self/ns/pid")}`;
13
+ }
14
+ else if (process.platform === "darwin") {
15
+ const { stdout } = await promisify(execFile)("/usr/sbin/sysctl", ["-n", "kern.boottime"], {
16
+ env: {},
17
+ timeout: 1000,
18
+ maxBuffer: 1024,
19
+ });
20
+ const boot = /^\{ sec = (\d+), usec = (\d+) \}/u.exec(stdout);
21
+ if (!boot)
22
+ throw new Error("Cannot establish the local workload queue boot identity.");
23
+ identity = `${boot[1]}:${boot[2]}`;
24
+ }
25
+ else {
26
+ throw new Error("The local workload queue requires macOS or Linux (including WSL).");
27
+ }
28
+ return createHash("sha256")
29
+ .update(`${process.platform}:${userInfo().uid}:${identity}`)
30
+ .digest("hex");
31
+ };
32
+ export const workloadQueueDirectory = async (stateDirectory) => {
33
+ const home = userInfo().homedir;
34
+ const root = stateDirectory ??
35
+ process.env.REHEARSAL_USER_STATE_DIRECTORY ??
36
+ (process.platform === "darwin"
37
+ ? join(home, "Library", "Application Support", "Rehearsal")
38
+ : join(home, ".local", "state", "rehearsal"));
39
+ if (!isAbsolute(root) || root.includes("\0"))
40
+ throw new Error("REHEARSAL_USER_STATE_DIRECTORY must be an absolute local state directory.");
41
+ scopePromise ??= currentScope();
42
+ return join(root, "workloads-v1", await scopePromise);
43
+ };
@@ -0,0 +1,27 @@
1
+ export interface WorkloadTicket {
2
+ schemaVersion: 1;
3
+ token: string;
4
+ pid: number;
5
+ command: "start" | "run" | "reset" | "migrate";
6
+ phase: "choosing" | "queued" | "held";
7
+ number: number;
8
+ createdAt: string;
9
+ }
10
+ export interface StoredWorkloadTicket {
11
+ ticket: WorkloadTicket;
12
+ file: string;
13
+ identity: string;
14
+ alive: boolean;
15
+ }
16
+ export declare const workloadOwnerAlive: (pid: number) => boolean;
17
+ export declare class WorkloadQueueStore {
18
+ readonly directory: string;
19
+ private rootIdentity;
20
+ constructor(directory: string);
21
+ initialize(create: boolean): Promise<boolean>;
22
+ assertRoot(): Promise<void>;
23
+ read(file: string): Promise<StoredWorkloadTicket | null>;
24
+ list(): Promise<StoredWorkloadTicket[]>;
25
+ write(ticket: WorkloadTicket, initial?: boolean): Promise<void>;
26
+ remove(entry: StoredWorkloadTicket): Promise<void>;
27
+ }
@@ -0,0 +1,182 @@
1
+ /** Private bounded atomic ticket files. A ticket name is never reused. */
2
+ import { constants } from "node:fs";
3
+ import { link, lstat, mkdir, open, opendir, rename, unlink, } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { dirname, join } from "node:path";
6
+ const missing = (error) => error.code === "ENOENT";
7
+ const identity = (stat) => `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
8
+ const privateOwned = (stat) => stat.uid === process.getuid() && (stat.mode & 0o077) === 0;
9
+ const validTicket = (value, token) => value !== null &&
10
+ typeof value === "object" &&
11
+ value.schemaVersion === 1 &&
12
+ value.token === token &&
13
+ Number.isSafeInteger(value.pid) &&
14
+ value.pid > 0 &&
15
+ ["start", "run", "reset", "migrate"].includes(value.command) &&
16
+ ["choosing", "queued", "held"].includes(value.phase) &&
17
+ Number.isSafeInteger(value.number) &&
18
+ value.number >= 0 &&
19
+ (value.phase === "choosing") === (value.number === 0) &&
20
+ typeof value.createdAt === "string" &&
21
+ Number.isFinite(Date.parse(value.createdAt)) &&
22
+ new Date(value.createdAt).toISOString() === value.createdAt &&
23
+ Object.keys(value).sort().join(",") ===
24
+ "command,createdAt,number,phase,pid,schemaVersion,token";
25
+ export const workloadOwnerAlive = (pid) => {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ if (error.code === "ESRCH")
32
+ return false;
33
+ // EPERM/unknown visibility is never permission to reclaim.
34
+ throw new Error("Cannot prove workload owner liveness; no workload was admitted or recovered.");
35
+ }
36
+ };
37
+ export class WorkloadQueueStore {
38
+ directory;
39
+ rootIdentity;
40
+ constructor(directory) {
41
+ this.directory = directory;
42
+ }
43
+ async initialize(create) {
44
+ if (create) {
45
+ await mkdir(dirname(this.directory), { recursive: true, mode: 0o700 });
46
+ await mkdir(this.directory, { mode: 0o700 }).catch((error) => {
47
+ if (error.code !== "EEXIST")
48
+ throw error;
49
+ });
50
+ }
51
+ const parent = await lstat(dirname(this.directory)).catch((error) => {
52
+ if (missing(error))
53
+ return null;
54
+ throw error;
55
+ });
56
+ if (!parent)
57
+ return false;
58
+ if (!parent.isDirectory() || !privateOwned(parent))
59
+ throw new Error("Workload state parent must be a private owned directory, not a link.");
60
+ let stat;
61
+ try {
62
+ stat = await lstat(this.directory);
63
+ }
64
+ catch (error) {
65
+ if (missing(error))
66
+ return false;
67
+ throw error;
68
+ }
69
+ if (!stat.isDirectory() || !privateOwned(stat))
70
+ throw new Error("Workload queue must be a private owned directory, not a link.");
71
+ this.rootIdentity = `${stat.dev}:${stat.ino}`;
72
+ return true;
73
+ }
74
+ async assertRoot() {
75
+ const stat = await lstat(this.directory);
76
+ if (!stat.isDirectory() ||
77
+ !privateOwned(stat) ||
78
+ `${stat.dev}:${stat.ino}` !== this.rootIdentity)
79
+ throw new Error("Workload queue directory changed; no further workload phase was started.");
80
+ }
81
+ async read(file) {
82
+ await this.assertRoot();
83
+ const match = /^ticket-([a-f0-9-]{36})\.json$/u.exec(file);
84
+ if (!match)
85
+ throw new Error("Unknown workload queue entry; inspect local state without deleting it.");
86
+ for (let attempt = 0; attempt < 4; attempt++) {
87
+ let handle;
88
+ try {
89
+ handle = await open(join(this.directory, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
90
+ const before = await handle.stat();
91
+ if (!before.isFile() || !privateOwned(before) || before.size > 4096)
92
+ throw new Error("Invalid workload ticket ownership, type or size; queue refused.");
93
+ const bytes = Buffer.alloc(4097);
94
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
95
+ if (bytesRead > 4096)
96
+ throw new Error("Workload ticket exceeds the bounded record size.");
97
+ const after = await lstat(join(this.directory, file));
98
+ if (identity(before) !== identity(after))
99
+ continue;
100
+ let ticket;
101
+ try {
102
+ ticket = JSON.parse(bytes.subarray(0, bytesRead).toString("utf8"));
103
+ }
104
+ catch {
105
+ throw new Error("Invalid workload ticket JSON; queue refused.");
106
+ }
107
+ if (!validTicket(ticket, match[1]))
108
+ throw new Error("Invalid workload ticket; queue refused.");
109
+ return {
110
+ ticket,
111
+ file,
112
+ identity: identity(after),
113
+ alive: workloadOwnerAlive(ticket.pid),
114
+ };
115
+ }
116
+ catch (error) {
117
+ if (missing(error))
118
+ return null;
119
+ throw error;
120
+ }
121
+ finally {
122
+ await handle?.close();
123
+ }
124
+ }
125
+ throw new Error("Workload ticket kept changing; no workload was admitted.");
126
+ }
127
+ async list() {
128
+ await this.assertRoot();
129
+ const entries = [];
130
+ const directory = await opendir(this.directory);
131
+ let count = 0;
132
+ for await (const entry of directory) {
133
+ if (++count > 128)
134
+ throw new Error("Workload queue entry limit reached; inspect state before retrying.");
135
+ // Complete files are published by link/rename; private drafts never grant admission.
136
+ if (/^draft-[a-f0-9-]{36}$/u.test(entry.name))
137
+ continue;
138
+ const value = await this.read(entry.name);
139
+ if (value)
140
+ entries.push(value);
141
+ }
142
+ return entries;
143
+ }
144
+ async write(ticket, initial = false) {
145
+ await this.assertRoot();
146
+ const file = join(this.directory, `ticket-${ticket.token}.json`);
147
+ const draft = join(this.directory, `draft-${randomUUID()}`);
148
+ const handle = await open(draft, "wx", 0o600);
149
+ try {
150
+ try {
151
+ await handle.writeFile(JSON.stringify(ticket));
152
+ await handle.sync();
153
+ }
154
+ finally {
155
+ await handle.close();
156
+ }
157
+ await this.assertRoot();
158
+ if (initial)
159
+ await link(draft, file);
160
+ else
161
+ await rename(draft, file);
162
+ }
163
+ finally {
164
+ await unlink(draft).catch((error) => {
165
+ if (!missing(error))
166
+ throw error;
167
+ });
168
+ }
169
+ }
170
+ async remove(entry) {
171
+ const current = await this.read(entry.file);
172
+ if (!current)
173
+ return;
174
+ if (entry.identity !== current.identity)
175
+ throw new Error("Workload ticket changed; this ticket was not recovered.");
176
+ await this.assertRoot();
177
+ await unlink(join(this.directory, entry.file)).catch((error) => {
178
+ if (!missing(error))
179
+ throw error;
180
+ });
181
+ }
182
+ }
@@ -14,6 +14,7 @@ const CANCELLABLE_COMMANDS = new Set([
14
14
  "refresh",
15
15
  "reset",
16
16
  "run",
17
+ "start",
17
18
  "stop-stale",
18
19
  ]);
19
20
  let activeSignal;
@@ -77,6 +77,15 @@ typed registry and produce the same result contract:
77
77
  `update_advisory.mts` bounds public release metadata, private preferences and
78
78
  installation-origin checks. Neither participates in passive JSON inspection.
79
79
  - `runtime_commands.mts` coordinates local runtimes and project proofs.
80
+ - `runtime_stack_runner.mts` selects configured targets, binds their reviewed engine
81
+ and configuration identities, executes the serial stack and retains receipt-bound
82
+ cleanup. `runtime_workload_admission.mts` applies optional lifecycle admission before
83
+ that work; application proofs and interactive sessions are deliberately outside it.
84
+ - `workload_command_handlers.mts` exposes local queue inspection and exact dead-holder
85
+ metadata recovery without a project lock or Docker. `runtime/workload_queue.mts`
86
+ owns cooperative lifecycle admission; its store owns bounded private atomic tickets,
87
+ and its location helper binds the local user, boot and PID namespace independently
88
+ of per-command temporary directories. No background scheduler is installed.
80
89
  - `runtime_planning.mts` owns read-only stack plans and exact candidate summaries.
81
90
  - `editor_command_handlers.mts` exposes bounded migration and configuration review
82
91
  contracts plus digest-bound, non-destructive stale-environment stop review. It also
package/docs/commands.md CHANGED
@@ -3,6 +3,26 @@
3
3
  Run commands from the project directory containing `rehearsal.config.mjs`. The examples
4
4
  use `npx`, so a global install is not required.
5
5
 
6
+ ## Participating local workloads
7
+
8
+ Core 1.3.0 and later builds advertising `rehearsalContracts.workloadQueue: 1` support
9
+ `npx --no-install rehearsal workloads --json`. This read-only command needs no
10
+ project configuration or Docker. Its version-1 `data` lists lifecycle ticket IDs,
11
+ commands, PIDs, phases (`choosing`, `queued`, `held`), owner liveness and creation
12
+ times. It reports `capacity: "not-measured"`: this is cooperative admission, not
13
+ a claim about spare host/VM memory. Project paths, migration SQL and credentials
14
+ are not included. Enable participation with
15
+ [`resources.workloadQueue`](configuration.md#resource-review-policies).
16
+
17
+ A dead holder blocks new admission until explicitly reviewed. Confirm its owned
18
+ commands and descendants have ended before running
19
+ `npx --no-install rehearsal workloads recover --confirm-workloads=<recoveryDigest>`.
20
+ The exact inspection digest binds only the dead held records; changed records,
21
+ missing confirmation and live owners refuse recovery. This operation clears local
22
+ queue metadata only. It does not stop processes/containers or alter databases.
23
+ Waiting requests can be cancelled; expiry never steals a live holder. Read-only
24
+ inspection does not clean stale records or create state directories.
25
+
6
26
  ## Guided mode
7
27
 
8
28
  For editor-style review, `npx rehearsal inspect plan --json` returns a bounded,
@@ -15,8 +35,8 @@ Rehearsal does not infer SQL safety from filenames.
15
35
  In a core build advertising `rehearsalContracts.migrationInsights: 1`, run
16
36
  `npx --no-install rehearsal inspect insights --json` to inspect pending SQL without
17
37
  starting an engine, connecting to a database, running application commands or changing
18
- files. This additive contract does not change `inspect plan` version 1. It is new
19
- development work, not a capability of the original tested core 1.1.0 candidate.
38
+ files. This additive contract, introduced in core 1.2.0, does not change
39
+ `inspect plan` version 1. Older releases without the capability do not provide it.
20
40
 
21
41
  The normal version-1 command envelope contains `data.schemaVersion: 1`, the exact
22
42
  complete `candidateSha256`, candidate count, aggregate verdict and per-target entries.
@@ -50,10 +70,10 @@ The CLI and these essential safety warnings remain free.
50
70
 
51
71
  ### Current-sandbox data impact
52
72
 
53
- A source build advertising `rehearsalContracts.migrationImpact: 1` adds two free,
54
- optional commands. This is unpublished development work, not a capability of an
55
- already-installed release. It does not change migration-plan v1, static Insights,
56
- failure diagnosis, or the gates for `run` and `migrate`.
73
+ A build advertising `rehearsalContracts.migrationImpact: 1` adds two free,
74
+ optional commands, introduced in core 1.2.0. Check the installed capability rather
75
+ than assuming an older release provides them. It does not change migration-plan v1,
76
+ static Insights, failure diagnosis, or the gates for `run` and `migrate`.
57
77
 
58
78
  ```bash
59
79
  npx --no-install rehearsal inspect impact --target=primary --json