@rehearsal-db/core 1.1.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 +43 -1
- package/COMPATIBILITY.md +40 -0
- package/README.md +7 -3
- package/dist/package.json +4 -1
- package/dist/src/cli/arguments.d.mts +1 -0
- package/dist/src/cli/arguments.mjs +5 -0
- package/dist/src/cli/basic_command_handlers.mjs +6 -0
- package/dist/src/cli/cli_commands.mjs +2 -0
- package/dist/src/cli/editor_command_handlers.mjs +24 -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/migration_history.d.mts +5 -1
- package/dist/src/runtime/migration_history.mjs +24 -6
- package/dist/src/runtime/migration_impact.d.mts +125 -0
- package/dist/src/runtime/migration_impact.mjs +278 -0
- package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
- package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
- package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
- package/dist/src/runtime/migration_impact_plan.mjs +297 -0
- package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
- package/dist/src/runtime/migration_impact_queries.mjs +270 -0
- package/dist/src/runtime/migration_insights.d.mts +137 -0
- package/dist/src/runtime/migration_insights.mjs +143 -0
- package/dist/src/runtime/migration_insights_input.d.mts +13 -0
- package/dist/src/runtime/migration_insights_input.mjs +153 -0
- package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
- package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
- package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
- package/dist/src/runtime/migration_sql_scan.mjs +369 -0
- package/dist/src/runtime/plan.d.mts +2 -1
- package/dist/src/runtime/plan.mjs +4 -4
- package/dist/src/runtime/topology.d.mts +1 -1
- package/dist/src/runtime/topology.mjs +2 -2
- 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 +2 -0
- package/dist/src/shared/held_path_ancestry.d.mts +7 -0
- package/dist/src/shared/held_path_ancestry.mjs +110 -0
- package/dist/src/shared/operation_guard.mjs +1 -0
- package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
- package/dist/src/targets/migration_impact_runtime.mjs +367 -0
- package/docs/README.md +20 -7
- package/docs/adapters.md +10 -0
- package/docs/architecture.md +84 -1
- package/docs/commands.md +189 -34
- package/docs/configuration.md +56 -6
- package/docs/getting-started.md +1 -1
- package/docs/index.md +1 -0
- package/docs/releasing.md +15 -9
- package/docs/roadmap.md +27 -6
- package/docs/runtime-lifecycle.md +10 -0
- package/docs/stability.md +4 -4
- package/docs/updating.md +73 -0
- package/docs/vscode.md +23 -17
- package/package.json +4 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const HELD_PATH_ANCESTRY_MAXIMUM_DIRECTORIES = 128;
|
|
2
|
+
export interface HeldPathAncestry {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
assertUnchanged(): Promise<void>;
|
|
5
|
+
close(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare const holdPathAncestry: (directory: string) => Promise<HeldPathAncestry>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamp-based concurrent-change guard for a canonical directory ancestry.
|
|
3
|
+
* This is not atomic openat resolution or a sandbox: it cannot prevent every
|
|
4
|
+
* transient access or privileged timestamp manipulation. Callers retain their
|
|
5
|
+
* own project-containment, regular-file and exact-input identity checks, use
|
|
6
|
+
* only `path` for reads, assert before/after those reads, and close in finally.
|
|
7
|
+
*/
|
|
8
|
+
import { constants } from "node:fs";
|
|
9
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
10
|
+
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
11
|
+
export const HELD_PATH_ANCESTRY_MAXIMUM_DIRECTORIES = 128;
|
|
12
|
+
const unavailable = () => new Error("Path ancestry is unavailable, unsafe or changed.");
|
|
13
|
+
const identity = (details) => {
|
|
14
|
+
if (!details.isDirectory() ||
|
|
15
|
+
details.isSymbolicLink() ||
|
|
16
|
+
typeof details.dev !== "bigint" ||
|
|
17
|
+
typeof details.ino !== "bigint" ||
|
|
18
|
+
typeof details.ctimeNs !== "bigint" ||
|
|
19
|
+
typeof details.mtimeNs !== "bigint")
|
|
20
|
+
throw unavailable();
|
|
21
|
+
return {
|
|
22
|
+
dev: details.dev,
|
|
23
|
+
ino: details.ino,
|
|
24
|
+
ctimeNs: details.ctimeNs,
|
|
25
|
+
mtimeNs: details.mtimeNs,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const same = (left, right) => left.dev === right.dev &&
|
|
29
|
+
left.ino === right.ino &&
|
|
30
|
+
left.ctimeNs === right.ctimeNs &&
|
|
31
|
+
left.mtimeNs === right.mtimeNs;
|
|
32
|
+
export const holdPathAncestry = async (directory) => {
|
|
33
|
+
const handles = [];
|
|
34
|
+
const entries = [];
|
|
35
|
+
let closed = false;
|
|
36
|
+
let closing;
|
|
37
|
+
const close = () => {
|
|
38
|
+
if (closing)
|
|
39
|
+
return closing;
|
|
40
|
+
closed = true;
|
|
41
|
+
closing = (async () => {
|
|
42
|
+
let failed = false;
|
|
43
|
+
for (const handle of handles.splice(0).reverse()) {
|
|
44
|
+
try {
|
|
45
|
+
await handle.close();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
failed = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (failed)
|
|
52
|
+
throw unavailable();
|
|
53
|
+
})();
|
|
54
|
+
return closing;
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
if (!isAbsolute(directory))
|
|
58
|
+
throw unavailable();
|
|
59
|
+
const path = resolve(directory);
|
|
60
|
+
const root = parse(path).root;
|
|
61
|
+
const segments = relative(root, path).split(sep).filter(Boolean);
|
|
62
|
+
if (segments.length + 1 > HELD_PATH_ANCESTRY_MAXIMUM_DIRECTORIES)
|
|
63
|
+
throw unavailable();
|
|
64
|
+
// Reject aliases and all symlink ancestors, including ancestors above the
|
|
65
|
+
// caller's project root. Root aliases must be canonicalized by the caller.
|
|
66
|
+
if ((await realpath(path)) !== path)
|
|
67
|
+
throw unavailable();
|
|
68
|
+
const paths = [root];
|
|
69
|
+
for (const segment of segments)
|
|
70
|
+
paths.push(join(paths.at(-1), segment));
|
|
71
|
+
for (const current of paths) {
|
|
72
|
+
const before = identity(await lstat(current, { bigint: true }));
|
|
73
|
+
const handle = await open(current, constants.O_RDONLY |
|
|
74
|
+
constants.O_DIRECTORY |
|
|
75
|
+
constants.O_NOFOLLOW |
|
|
76
|
+
constants.O_NONBLOCK);
|
|
77
|
+
// Retain immediately: a later stat failure must still close this handle.
|
|
78
|
+
handles.push(handle);
|
|
79
|
+
const opened = identity(await handle.stat({ bigint: true }));
|
|
80
|
+
const after = identity(await lstat(current, { bigint: true }));
|
|
81
|
+
if (!same(before, opened) || !same(before, after))
|
|
82
|
+
throw unavailable();
|
|
83
|
+
entries.push({ path: current, handle, identity: before });
|
|
84
|
+
}
|
|
85
|
+
const assertUnchanged = async () => {
|
|
86
|
+
try {
|
|
87
|
+
if (closed || (await realpath(path)) !== path)
|
|
88
|
+
throw unavailable();
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const held = identity(await entry.handle.stat({ bigint: true }));
|
|
91
|
+
const lexical = identity(await lstat(entry.path, { bigint: true }));
|
|
92
|
+
if (!same(entry.identity, held) || !same(entry.identity, lexical))
|
|
93
|
+
throw unavailable();
|
|
94
|
+
}
|
|
95
|
+
if ((await realpath(path)) !== path)
|
|
96
|
+
throw unavailable();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
await close().catch(() => { });
|
|
100
|
+
throw unavailable();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
await assertUnchanged();
|
|
104
|
+
return Object.freeze({ path, assertUnchanged, close });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
await close().catch(() => { });
|
|
108
|
+
throw unavailable();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BaselineManifest } from "../baseline/artifact.mjs";
|
|
2
|
+
import type { RuntimeTopologyTarget } from "../runtime/topology.mjs";
|
|
3
|
+
import { runOwnedProcess } from "../shared/owned_process.mjs";
|
|
4
|
+
export type MigrationImpactRuntimeFailureReason = "runtime_unavailable" | "ownership_mismatch" | "stale_runtime" | "query_failed" | "lock_timeout" | "statement_timeout" | "transaction_timeout" | "cancelled";
|
|
5
|
+
export declare class MigrationImpactRuntimeError extends Error {
|
|
6
|
+
readonly reason: MigrationImpactRuntimeFailureReason;
|
|
7
|
+
constructor(reason: MigrationImpactRuntimeFailureReason);
|
|
8
|
+
}
|
|
9
|
+
export interface MigrationImpactRuntime {
|
|
10
|
+
readonly identitySha256: string;
|
|
11
|
+
runSql(sql: string | (() => string)): Promise<string>;
|
|
12
|
+
assertCurrent(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export interface OpenMigrationImpactRuntimeOptions {
|
|
15
|
+
readonly projectRoot: string;
|
|
16
|
+
readonly target: RuntimeTopologyTarget;
|
|
17
|
+
readonly baseline: BaselineManifest;
|
|
18
|
+
readonly signal?: AbortSignal;
|
|
19
|
+
readonly runProcessImplementation?: typeof runOwnedProcess;
|
|
20
|
+
}
|
|
21
|
+
/** No lifecycle, adapter, source-reader, URL or credential-file access is permitted here. */
|
|
22
|
+
export declare function openMigrationImpactRuntime(options: OpenMigrationImpactRuntimeOptions): Promise<MigrationImpactRuntime>;
|