@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.
- package/CHANGELOG.md +17 -1
- package/COMPATIBILITY.md +15 -6
- package/README.md +1 -1
- package/dist/package.json +2 -1
- package/dist/src/cli/arguments.d.mts +1 -0
- package/dist/src/cli/arguments.mjs +4 -0
- package/dist/src/cli/basic_command_handlers.mjs +2 -0
- package/dist/src/cli/cli_commands.mjs +2 -0
- package/dist/src/cli/project_commands.d.mts +8 -0
- package/dist/src/cli/runtime_commands.d.mts +2 -19
- package/dist/src/cli/runtime_commands.mjs +3 -90
- package/dist/src/cli/runtime_stack_runner.d.mts +41 -0
- package/dist/src/cli/runtime_stack_runner.mjs +104 -0
- package/dist/src/cli/runtime_workload_admission.d.mts +8 -0
- package/dist/src/cli/runtime_workload_admission.mjs +22 -0
- package/dist/src/cli/source_commands.d.mts +8 -0
- package/dist/src/cli/workload_command_handlers.d.mts +2 -0
- package/dist/src/cli/workload_command_handlers.mjs +38 -0
- package/dist/src/project/configuration_contract.d.mts +5 -0
- package/dist/src/project/configuration_normalization.d.mts +4 -0
- package/dist/src/project/configuration_resolution.d.mts +4 -0
- package/dist/src/project/configuration_resource_policy.d.mts +4 -0
- package/dist/src/project/configuration_resource_policy.mjs +20 -1
- package/dist/src/project/configuration_schema.mjs +1 -0
- package/dist/src/runtime/workload_queue.d.mts +39 -0
- package/dist/src/runtime/workload_queue.mjs +211 -0
- package/dist/src/runtime/workload_queue_location.d.mts +1 -0
- package/dist/src/runtime/workload_queue_location.mjs +43 -0
- package/dist/src/runtime/workload_queue_store.d.mts +27 -0
- package/dist/src/runtime/workload_queue_store.mjs +182 -0
- package/dist/src/shared/cancellation.mjs +1 -0
- package/docs/architecture.md +9 -0
- package/docs/commands.md +26 -6
- package/docs/configuration.md +33 -1
- package/docs/getting-started.md +1 -1
- package/docs/releasing.md +4 -0
- package/docs/roadmap.md +8 -6
- package/docs/stability.md +4 -4
- package/docs/updating.md +2 -2
- package/docs/vscode.md +1 -1
- 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"),
|
|
@@ -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
|
+
}
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
19
|
-
|
|
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
|
|
54
|
-
optional commands
|
|
55
|
-
|
|
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
|