@rehearsal-db/core 1.0.0 → 1.0.2
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 +46 -1
- package/COMPATIBILITY.md +43 -0
- package/README.md +31 -12
- package/dist/package.json +2 -2
- package/dist/scripts/install/create_root_config.mjs +9 -2
- package/dist/src/cli/failure_output.d.mts +3 -0
- package/dist/src/cli/failure_output.mjs +48 -0
- package/dist/src/cli/rehearsal.mjs +15 -4
- package/dist/src/cli/runtime_action_handlers.mjs +11 -4
- package/dist/src/cli/runtime_commands.mjs +23 -24
- package/dist/src/cli/runtime_failure_summary.d.mts +1 -0
- package/dist/src/cli/runtime_failure_summary.mjs +30 -4
- package/dist/src/cli/runtime_planning.mjs +6 -0
- package/dist/src/project/install_scaffold.mjs +7 -0
- package/dist/src/project/project_discovery_metadata.mjs +9 -11
- package/dist/src/runtime/migration_failure.d.mts +28 -0
- package/dist/src/runtime/migration_failure.mjs +197 -0
- package/dist/src/runtime/migration_failure_source.d.mts +16 -0
- package/dist/src/runtime/migration_failure_source.mjs +68 -0
- package/dist/src/runtime/plan.mjs +26 -15
- package/dist/src/shared/diagnostics.mjs +5 -0
- package/dist/src/shared/host_platform.d.mts +16 -0
- package/dist/src/shared/host_platform.mjs +109 -0
- package/dist/src/shared/operation_guard.d.mts +4 -1
- package/dist/src/shared/operation_guard.mjs +18 -13
- package/dist/src/shared/operation_progress.mjs +35 -1
- package/dist/src/shared/operation_progress_storage.d.mts +27 -6
- package/dist/src/shared/operation_progress_storage.mjs +65 -9
- package/dist/src/shared/process_environment.d.mts +2 -1
- package/dist/src/shared/process_environment.mjs +47 -5
- package/dist/src/targets/port_start_failure.d.mts +15 -0
- package/dist/src/targets/port_start_failure.mjs +46 -0
- package/dist/src/targets/postgresql.mjs +8 -7
- package/dist/src/targets/postgresql_process.d.mts +8 -1
- package/dist/src/targets/postgresql_process.mjs +46 -2
- package/dist/src/targets/supabase_process.mjs +2 -1
- package/dist/src/targets/supabase_start_failure.mjs +5 -1
- package/docs/architecture.md +32 -2
- package/docs/commands.md +35 -0
- package/docs/getting-started.md +48 -0
- package/docs/index.md +15 -9
- package/docs/releasing.md +28 -24
- package/docs/roadmap.md +8 -3
- package/docs/stability.md +8 -4
- package/docs/troubleshooting.md +54 -2
- package/docs/tutorial.md +93 -8
- package/docs/vscode.md +220 -37
- package/package.json +2 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/** Translate provider-native SQLSTATE evidence into fixed, privacy-safe guidance. */
|
|
2
|
+
import { parseMigrationFailureSource, readMigrationSourceFrame, renderMigrationSourceFrame, } from "./migration_failure_source.mjs";
|
|
3
|
+
const explanations = {
|
|
4
|
+
"42601": [
|
|
5
|
+
"syntax",
|
|
6
|
+
"The database reported a SQL syntax error.",
|
|
7
|
+
"Review the candidate SQL for missing keywords, punctuation or malformed statements; then review the new exact plan before retrying.",
|
|
8
|
+
],
|
|
9
|
+
"23502": [
|
|
10
|
+
"constraint",
|
|
11
|
+
"Rows do not satisfy a NOT NULL requirement.",
|
|
12
|
+
"Review existing NULL values and any new writes. Backfill or transform incompatible rows before enforcing NOT NULL.",
|
|
13
|
+
],
|
|
14
|
+
"23503": [
|
|
15
|
+
"constraint",
|
|
16
|
+
"Rows do not satisfy a foreign-key constraint.",
|
|
17
|
+
"Review missing referenced rows and migration ordering before enforcing the foreign key.",
|
|
18
|
+
],
|
|
19
|
+
"23505": [
|
|
20
|
+
"constraint",
|
|
21
|
+
"Rows do not satisfy a uniqueness requirement.",
|
|
22
|
+
"Review duplicate values and choose a data-preserving resolution before adding the unique constraint or index.",
|
|
23
|
+
],
|
|
24
|
+
"23514": [
|
|
25
|
+
"constraint",
|
|
26
|
+
"Rows do not satisfy a CHECK constraint.",
|
|
27
|
+
"Existing rows may not satisfy the new constraint. Review and backfill incompatible rows before enforcing it; also check values written by the migration.",
|
|
28
|
+
],
|
|
29
|
+
"23P01": [
|
|
30
|
+
"constraint",
|
|
31
|
+
"Rows conflict with an exclusion constraint.",
|
|
32
|
+
"Review overlapping or conflicting values before enforcing the exclusion constraint.",
|
|
33
|
+
],
|
|
34
|
+
"42P01": [
|
|
35
|
+
"schema",
|
|
36
|
+
"The migration references a table that is unavailable in this schema.",
|
|
37
|
+
"Review table names, schema qualification and migration ordering; this is not a confirmed syntax error.",
|
|
38
|
+
],
|
|
39
|
+
"42703": [
|
|
40
|
+
"schema",
|
|
41
|
+
"The migration references a column that is unavailable in this schema.",
|
|
42
|
+
"Review column names and migration ordering; this is not a confirmed syntax error.",
|
|
43
|
+
],
|
|
44
|
+
"42704": [
|
|
45
|
+
"schema",
|
|
46
|
+
"The migration references a database object that is unavailable.",
|
|
47
|
+
"Review object names, schema qualification and migration ordering.",
|
|
48
|
+
],
|
|
49
|
+
"42804": [
|
|
50
|
+
"schema",
|
|
51
|
+
"The migration uses incompatible data types.",
|
|
52
|
+
"Review the column types and any required explicit, data-preserving conversion.",
|
|
53
|
+
],
|
|
54
|
+
"42P07": [
|
|
55
|
+
"schema",
|
|
56
|
+
"The migration attempts to create a table that already exists.",
|
|
57
|
+
"Review baseline history and candidate ordering instead of deleting migration history.",
|
|
58
|
+
],
|
|
59
|
+
"42701": [
|
|
60
|
+
"schema",
|
|
61
|
+
"The migration attempts to create a column that already exists.",
|
|
62
|
+
"Review baseline history and candidate ordering instead of deleting migration history.",
|
|
63
|
+
],
|
|
64
|
+
"42501": [
|
|
65
|
+
"permission",
|
|
66
|
+
"The database denied permission for a migration operation.",
|
|
67
|
+
"Review the intended local role and required privileges; do not switch to hosted credentials or bypass permissions.",
|
|
68
|
+
],
|
|
69
|
+
"28P01": [
|
|
70
|
+
"permission",
|
|
71
|
+
"The database rejected authentication.",
|
|
72
|
+
"Review the disposable local runtime credentials without printing or sharing them.",
|
|
73
|
+
],
|
|
74
|
+
"57014": [
|
|
75
|
+
"timeout_or_cancel",
|
|
76
|
+
"The database reported a cancelled statement, possibly from a timeout.",
|
|
77
|
+
"Review statement timeout and cancellation evidence. No automatic retry or successful rollback is implied.",
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
export const migrationFailureDiagnostic = ({ provider, sqlState = null, evidence = sqlState ? "provider_sqlstate" : "unavailable", }) => {
|
|
81
|
+
const safeCode = sqlState &&
|
|
82
|
+
/^[0-9A-Z]{5}$/u.test(sqlState) &&
|
|
83
|
+
!/^(?:00|01|02)/u.test(sqlState)
|
|
84
|
+
? sqlState
|
|
85
|
+
: null;
|
|
86
|
+
const explanation = safeCode ? explanations[safeCode] : undefined;
|
|
87
|
+
const connectivity = safeCode?.startsWith("08");
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
provider,
|
|
91
|
+
classification: explanation?.[0] ?? (connectivity ? "connectivity" : "unknown"),
|
|
92
|
+
sqlState: safeCode,
|
|
93
|
+
evidence: safeCode
|
|
94
|
+
? "provider_sqlstate"
|
|
95
|
+
: evidence === "ambiguous"
|
|
96
|
+
? "ambiguous"
|
|
97
|
+
: "unavailable",
|
|
98
|
+
summary: explanation?.[1] ??
|
|
99
|
+
(connectivity
|
|
100
|
+
? "The database reported a connection failure."
|
|
101
|
+
: "The failure could not be classified from recognized provider SQLSTATE evidence. It is not a confirmed syntax error."),
|
|
102
|
+
suggestions: [
|
|
103
|
+
explanation?.[2] ??
|
|
104
|
+
(connectivity
|
|
105
|
+
? "Check the disposable local database connection and runtime status before retrying."
|
|
106
|
+
: "Review the exact candidate locally and its database requirements. Do not assume syntax, data or permissions are the cause."),
|
|
107
|
+
"No automatic SQL change or retry was performed. The failure itself does not prove rollback or cleanup; inspect runtime status before retrying.",
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
/** Read only known provider error envelopes, never arbitrary mentions of SQLSTATE. */
|
|
112
|
+
export const diagnoseMigrationFailure = ({ provider, stderr, }) => {
|
|
113
|
+
// Truncation could hide a conflicting error. Refuse rather than guess from a slice.
|
|
114
|
+
if (Buffer.byteLength(stderr) > 128 * 1024)
|
|
115
|
+
return migrationFailureDiagnostic({ provider });
|
|
116
|
+
const codes = new Set();
|
|
117
|
+
for (const line of stderr
|
|
118
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, "")
|
|
119
|
+
.split(/\r?\n/u)) {
|
|
120
|
+
const match = provider === "postgresql"
|
|
121
|
+
? line.match(/^(?:psql:(?:<stdin>|[^\r\n]+):\d+:\s*)?(?:ERROR|FATAL):\s+([0-9A-Z]{5})(?::(?:\s|$)|\s*$)/u)
|
|
122
|
+
: line.match(/^ERROR:\s+[^\r\n]*\(SQLSTATE ([0-9A-Z]{5})\)\s*$/u);
|
|
123
|
+
if (match?.[1] && !/^(?:00|01|02)/u.test(match[1]))
|
|
124
|
+
codes.add(match[1]);
|
|
125
|
+
}
|
|
126
|
+
return migrationFailureDiagnostic({
|
|
127
|
+
provider,
|
|
128
|
+
sqlState: codes.size === 1 ? [...codes][0] : null,
|
|
129
|
+
evidence: codes.size > 1 ? "ambiguous" : "unavailable",
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
export const renderMigrationFailureDiagnostic = (diagnostic) => [
|
|
133
|
+
`Migration SQLSTATE: ${diagnostic.sqlState ?? diagnostic.evidence}.`,
|
|
134
|
+
...(diagnostic.source === undefined
|
|
135
|
+
? []
|
|
136
|
+
: [renderMigrationSourceFrame(diagnostic.source)]),
|
|
137
|
+
diagnostic.summary,
|
|
138
|
+
...diagnostic.suggestions,
|
|
139
|
+
].join("\n");
|
|
140
|
+
/** Closed public projection: reject free text or metadata not generated by this owner. */
|
|
141
|
+
export const parseMigrationFailureDiagnostic = (value) => {
|
|
142
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
143
|
+
throw new Error("Invalid safe migration diagnostic.");
|
|
144
|
+
const record = value;
|
|
145
|
+
if (Object.keys(record)
|
|
146
|
+
.filter((key) => key !== "source")
|
|
147
|
+
.sort()
|
|
148
|
+
.join(",") !==
|
|
149
|
+
"classification,evidence,provider,schemaVersion,sqlState,suggestions,summary" ||
|
|
150
|
+
record.schemaVersion !== 1 ||
|
|
151
|
+
typeof record.provider !== "string" ||
|
|
152
|
+
!["postgresql", "supabase"].includes(record.provider) ||
|
|
153
|
+
!(record.sqlState === null || typeof record.sqlState === "string") ||
|
|
154
|
+
!["provider_sqlstate", "unavailable", "ambiguous"].includes(String(record.evidence)))
|
|
155
|
+
throw new Error("Invalid safe migration diagnostic.");
|
|
156
|
+
const canonical = migrationFailureDiagnostic({
|
|
157
|
+
provider: record.provider,
|
|
158
|
+
sqlState: record.sqlState,
|
|
159
|
+
evidence: record.evidence,
|
|
160
|
+
});
|
|
161
|
+
if (record.classification !== canonical.classification ||
|
|
162
|
+
record.sqlState !== canonical.sqlState ||
|
|
163
|
+
record.evidence !== canonical.evidence ||
|
|
164
|
+
record.summary !== canonical.summary ||
|
|
165
|
+
JSON.stringify(record.suggestions) !== JSON.stringify(canonical.suggestions))
|
|
166
|
+
throw new Error("Invalid safe migration diagnostic.");
|
|
167
|
+
return record.source === undefined
|
|
168
|
+
? canonical
|
|
169
|
+
: { ...canonical, source: parseMigrationFailureSource(record.source) };
|
|
170
|
+
};
|
|
171
|
+
/** Parse only Rehearsal's fixed migration header and adjacent fixed code line. */
|
|
172
|
+
export const readManagerMigrationFailure = (output) => {
|
|
173
|
+
if (Buffer.byteLength(output) > 128 * 1024)
|
|
174
|
+
return undefined;
|
|
175
|
+
const match = output.match(/^(?:[\t ]*Error:[\t ]*)?The (?:confirmed candidate|baseline) (PostgreSQL|Supabase) migration failed \(exit (?:-?\d+|unknown); stderr \d+ bytes\)\. SQL and provider output are withheld\.\r?\n[\t ]*Migration SQLSTATE: ([0-9A-Z]{5}|unavailable|ambiguous)\.[\t ]*$/mu);
|
|
176
|
+
if (!match)
|
|
177
|
+
return undefined;
|
|
178
|
+
const code = match[2];
|
|
179
|
+
const diagnostic = migrationFailureDiagnostic({
|
|
180
|
+
provider: match[1] === "PostgreSQL" ? "postgresql" : "supabase",
|
|
181
|
+
sqlState: /^[0-9A-Z]{5}$/u.test(code) ? code : null,
|
|
182
|
+
evidence: code === "ambiguous" ? "ambiguous" : "unavailable",
|
|
183
|
+
});
|
|
184
|
+
const afterCode = output
|
|
185
|
+
.slice(match.index + match[0].length)
|
|
186
|
+
.replace(/^\r?\n/u, "")
|
|
187
|
+
.split(/\r?\n/u)[0];
|
|
188
|
+
const source = readMigrationSourceFrame(output, afterCode);
|
|
189
|
+
const expectedKind = /The baseline /u.test(match[0])
|
|
190
|
+
? "baseline"
|
|
191
|
+
: "candidate";
|
|
192
|
+
return source === undefined ||
|
|
193
|
+
source.kind !== expectedKind ||
|
|
194
|
+
diagnostic.provider !== "postgresql"
|
|
195
|
+
? diagnostic
|
|
196
|
+
: { ...diagnostic, source };
|
|
197
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Closed attempted-input provenance. No statement/source-position inference. */
|
|
2
|
+
export interface MigrationFailureSource {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly kind: "baseline" | "candidate";
|
|
5
|
+
readonly filename: string;
|
|
6
|
+
readonly sha256: string;
|
|
7
|
+
readonly workspaceRelativePath: string | null;
|
|
8
|
+
readonly line: null;
|
|
9
|
+
readonly column: null;
|
|
10
|
+
readonly evidence: "owned_execution_input";
|
|
11
|
+
}
|
|
12
|
+
export declare const isMigrationWorkspacePath: (value: unknown) => value is string;
|
|
13
|
+
export declare const parseMigrationFailureSource: (value: unknown) => MigrationFailureSource;
|
|
14
|
+
export declare const renderMigrationSourceFrame: (source: MigrationFailureSource) => string;
|
|
15
|
+
/** Only a single complete, adjacent core frame can contribute provenance. */
|
|
16
|
+
export declare const readMigrationSourceFrame: (output: string, adjacentLine: string | undefined) => MigrationFailureSource | undefined;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const controls = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
2
|
+
export const isMigrationWorkspacePath = (value) => typeof value === "string" &&
|
|
3
|
+
Buffer.byteLength(value) <= 1024 &&
|
|
4
|
+
value.length > 0 &&
|
|
5
|
+
!/[\\:%?#]/u.test(value) &&
|
|
6
|
+
!controls.test(value) &&
|
|
7
|
+
value
|
|
8
|
+
.split("/")
|
|
9
|
+
.every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
10
|
+
export const parseMigrationFailureSource = (value) => {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
12
|
+
throw new Error("Invalid migration input provenance.");
|
|
13
|
+
const source = value;
|
|
14
|
+
if (Object.keys(source).sort().join(",") !==
|
|
15
|
+
"column,evidence,filename,kind,line,schemaVersion,sha256,workspaceRelativePath" ||
|
|
16
|
+
source.schemaVersion !== 1 ||
|
|
17
|
+
!["baseline", "candidate"].includes(String(source.kind)) ||
|
|
18
|
+
typeof source.kind !== "string" ||
|
|
19
|
+
typeof source.filename !== "string" ||
|
|
20
|
+
Buffer.byteLength(source.filename) > 255 ||
|
|
21
|
+
!/^\d{14}_[a-z0-9_]+\.sql$/u.test(source.filename) ||
|
|
22
|
+
typeof source.sha256 !== "string" ||
|
|
23
|
+
!/^[a-f0-9]{64}$/u.test(source.sha256) ||
|
|
24
|
+
(source.workspaceRelativePath !== null &&
|
|
25
|
+
(!isMigrationWorkspacePath(source.workspaceRelativePath) ||
|
|
26
|
+
source.workspaceRelativePath.split("/").at(-1) !== source.filename)) ||
|
|
27
|
+
source.line !== null ||
|
|
28
|
+
source.column !== null ||
|
|
29
|
+
source.evidence !== "owned_execution_input")
|
|
30
|
+
throw new Error("Invalid migration input provenance.");
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: 1,
|
|
33
|
+
kind: source.kind,
|
|
34
|
+
filename: source.filename,
|
|
35
|
+
sha256: source.sha256,
|
|
36
|
+
workspaceRelativePath: source.workspaceRelativePath,
|
|
37
|
+
line: null,
|
|
38
|
+
column: null,
|
|
39
|
+
evidence: "owned_execution_input",
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
const framePrefix = "Rehearsal migration input v1: ";
|
|
43
|
+
export const renderMigrationSourceFrame = (source) => framePrefix +
|
|
44
|
+
Buffer.from(JSON.stringify(parseMigrationFailureSource(source))).toString("base64url");
|
|
45
|
+
/** Only a single complete, adjacent core frame can contribute provenance. */
|
|
46
|
+
export const readMigrationSourceFrame = (output, adjacentLine) => {
|
|
47
|
+
try {
|
|
48
|
+
const frames = output
|
|
49
|
+
.split(/\r?\n/u)
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter((line) => line.startsWith(framePrefix));
|
|
52
|
+
if (frames.length !== 1 || adjacentLine?.trim() !== frames[0])
|
|
53
|
+
return undefined;
|
|
54
|
+
const encoded = frames[0].slice(framePrefix.length);
|
|
55
|
+
if (encoded.length > 4096 || !/^[A-Za-z0-9_-]+$/u.test(encoded))
|
|
56
|
+
return undefined;
|
|
57
|
+
const bytes = Buffer.from(encoded, "base64url");
|
|
58
|
+
if (bytes.length > 2048 || bytes.toString("base64url") !== encoded)
|
|
59
|
+
return undefined;
|
|
60
|
+
const source = parseMigrationFailureSource(JSON.parse(bytes.toString("utf8")));
|
|
61
|
+
return renderMigrationSourceFrame(source) === frames[0]
|
|
62
|
+
? source
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
@@ -21,6 +21,8 @@ import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
|
21
21
|
import { readBoundRuntimeSanitizationPolicy, validateRuntimeSanitizationPolicy, } from "../baseline/sanitization_policy.mjs";
|
|
22
22
|
import { buildAuthenticationReview, buildBaselineScopeReview, } from "./project_review.mjs";
|
|
23
23
|
import { readVerifiedBaselineShapeEvidence } from "../source/shape_files.mjs";
|
|
24
|
+
import { inspectHostPlatform } from "../shared/host_platform.mjs";
|
|
25
|
+
import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
|
|
24
26
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
25
27
|
const hasErrorCode = (error, code) => typeof error === "object" &&
|
|
26
28
|
error !== null &&
|
|
@@ -29,7 +31,7 @@ const hasErrorCode = (error, code) => typeof error === "object" &&
|
|
|
29
31
|
const commandAvailable = (command, args = ["--version"]) => {
|
|
30
32
|
const result = spawnSync(command, args, {
|
|
31
33
|
encoding: "utf8",
|
|
32
|
-
env:
|
|
34
|
+
env: createCleanProcessEnvironment(),
|
|
33
35
|
stdio: ["ignore", "pipe", "pipe"],
|
|
34
36
|
});
|
|
35
37
|
return {
|
|
@@ -378,6 +380,23 @@ export const inspectRehearsalMigrations = async (options = {}) => {
|
|
|
378
380
|
};
|
|
379
381
|
};
|
|
380
382
|
export const runRehearsalDoctor = async (options = {}) => {
|
|
383
|
+
const host = await inspectHostPlatform({
|
|
384
|
+
projectRoot: options.projectRoot ?? process.cwd(),
|
|
385
|
+
});
|
|
386
|
+
if (!host.supported) {
|
|
387
|
+
return {
|
|
388
|
+
state: "NOT READY",
|
|
389
|
+
checks: [
|
|
390
|
+
{
|
|
391
|
+
id: "operating-system",
|
|
392
|
+
label: "Operating-system support",
|
|
393
|
+
status: "fail",
|
|
394
|
+
detail: host.detail,
|
|
395
|
+
remediation: host.remediation,
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
381
400
|
let loaded;
|
|
382
401
|
try {
|
|
383
402
|
loaded = await loadRehearsalConfig(options);
|
|
@@ -426,12 +445,9 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
426
445
|
id: "operating-system",
|
|
427
446
|
label: "Operating-system support",
|
|
428
447
|
run: async () => {
|
|
429
|
-
|
|
430
|
-
throw new Error(`${process.platform} is not supported by the version-1 contract.`);
|
|
431
|
-
}
|
|
432
|
-
return `${process.platform} is in the supported matrix`;
|
|
448
|
+
return host.detail;
|
|
433
449
|
},
|
|
434
|
-
remediation:
|
|
450
|
+
remediation: host.remediation,
|
|
435
451
|
},
|
|
436
452
|
{
|
|
437
453
|
id: "package-manager",
|
|
@@ -594,9 +610,7 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
594
610
|
], {
|
|
595
611
|
cwd: projectRoot,
|
|
596
612
|
encoding: "utf8",
|
|
597
|
-
env:
|
|
598
|
-
? []
|
|
599
|
-
: [[key, process.env[key]]])),
|
|
613
|
+
env: createCleanProcessEnvironment(),
|
|
600
614
|
stdio: ["ignore", "pipe", "pipe"],
|
|
601
615
|
});
|
|
602
616
|
if (status.status !== 0 ||
|
|
@@ -614,12 +628,9 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
614
628
|
const status = spawnSync("supabase", ["status", "-o", "env", "--workdir", paths.runtimeWorkdir], {
|
|
615
629
|
cwd: projectRoot,
|
|
616
630
|
encoding: "utf8",
|
|
617
|
-
env: {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
: [[key, process.env[key]]])),
|
|
621
|
-
...serviceEnvironment,
|
|
622
|
-
},
|
|
631
|
+
env: createCleanProcessEnvironment({
|
|
632
|
+
overrides: serviceEnvironment,
|
|
633
|
+
}),
|
|
623
634
|
stdio: ["ignore", "pipe", "pipe"],
|
|
624
635
|
});
|
|
625
636
|
if (status.status !== 0 ||
|
|
@@ -84,6 +84,11 @@ export class RehearsalError extends Error {
|
|
|
84
84
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
85
85
|
const inferCategory = (error) => {
|
|
86
86
|
const message = errorMessage(error);
|
|
87
|
+
// Fixed driver phase precedes incidental words in safe remediation text.
|
|
88
|
+
if (/^(?:Error:\s*)?The baseline (?:PostgreSQL|Supabase) migration failed \(/mu.test(message))
|
|
89
|
+
return "baseline_invalid";
|
|
90
|
+
if (/^(?:Error:\s*)?The confirmed candidate (?:PostgreSQL|Supabase) migration failed \(/mu.test(message))
|
|
91
|
+
return "migration_candidate_failure";
|
|
87
92
|
if (/checksum/iu.test(message))
|
|
88
93
|
return "baseline_checksum_mismatch";
|
|
89
94
|
if (/container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu.test(message)) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const NATIVE_WINDOWS_REMEDIATION = "Use WSL2, not a native Windows terminal: run wsl --install -d Ubuntu in PowerShell and confirm version 2 with wsl --list --verbose (upgrade with wsl --set-version Ubuntu 2 if needed). Enable Docker Desktop Settings > Resources > WSL integration for that distribution. Keep the project in its Linux home filesystem (for example ~/projects, not /mnt/c), install a supported Linux Node.js version and project tools there, open it with VS Code Remote - WSL, then rerun rehearsal doctor inside WSL2.";
|
|
2
|
+
export interface HostPlatformInspection {
|
|
3
|
+
readonly kind: "macos" | "linux" | "wsl2" | "wsl-unverified" | "windows" | "other";
|
|
4
|
+
readonly supported: boolean;
|
|
5
|
+
readonly detail: string;
|
|
6
|
+
readonly remediation: string;
|
|
7
|
+
}
|
|
8
|
+
export interface HostPlatformOptions {
|
|
9
|
+
readonly projectRoot?: string;
|
|
10
|
+
readonly platform?: NodeJS.Platform;
|
|
11
|
+
readonly kernelRelease?: string;
|
|
12
|
+
readonly readKernelRelease?: () => Promise<string>;
|
|
13
|
+
readonly inspectFilesystem?: (path: string) => Promise<number>;
|
|
14
|
+
}
|
|
15
|
+
export declare const inspectHostPlatform: ({ projectRoot, platform, kernelRelease, readKernelRelease, inspectFilesystem, }?: HostPlatformOptions) => Promise<HostPlatformInspection>;
|
|
16
|
+
export declare const assertSupportedHostPlatform: (options?: HostPlatformOptions) => Promise<void>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Read-only host capability checks; never substitute environment hints for proof. */
|
|
2
|
+
import { open, statfs } from "node:fs/promises";
|
|
3
|
+
import { release } from "node:os";
|
|
4
|
+
import { RehearsalError } from "./diagnostics.mjs";
|
|
5
|
+
export const NATIVE_WINDOWS_REMEDIATION = "Use WSL2, not a native Windows terminal: run wsl --install -d Ubuntu in PowerShell and confirm version 2 with wsl --list --verbose (upgrade with wsl --set-version Ubuntu 2 if needed). Enable Docker Desktop Settings > Resources > WSL integration for that distribution. Keep the project in its Linux home filesystem (for example ~/projects, not /mnt/c), install a supported Linux Node.js version and project tools there, open it with VS Code Remote - WSL, then rerun rehearsal doctor inside WSL2.";
|
|
6
|
+
const readProcKernelRelease = async () => {
|
|
7
|
+
const file = await open("/proc/sys/kernel/osrelease", "r");
|
|
8
|
+
try {
|
|
9
|
+
const buffer = Buffer.alloc(512);
|
|
10
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
11
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
await file.close();
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// WSL's Windows drive mounts do not provide the private modes and atomic symlink
|
|
18
|
+
// contract used by baselines. Accept proven Linux filesystem types, not paths.
|
|
19
|
+
const WSL_POSIX_FILESYSTEM_TYPES = new Set([
|
|
20
|
+
0xef53, // ext2/ext3/ext4 (WSL2 distribution filesystem)
|
|
21
|
+
0x58465342, // XFS
|
|
22
|
+
0x9123683e, // Btrfs
|
|
23
|
+
]);
|
|
24
|
+
export const inspectHostPlatform = async ({ projectRoot = process.cwd(), platform = process.platform, kernelRelease = release(), readKernelRelease = readProcKernelRelease, inspectFilesystem = async (path) => (await statfs(path)).type, } = {}) => {
|
|
25
|
+
if (platform === "darwin") {
|
|
26
|
+
return {
|
|
27
|
+
kind: "macos",
|
|
28
|
+
supported: true,
|
|
29
|
+
detail: "macOS is in the supported host matrix; project readiness checks still apply.",
|
|
30
|
+
remediation: "Resolve the remaining project readiness checks on macOS.",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (platform === "win32") {
|
|
34
|
+
return {
|
|
35
|
+
kind: "windows",
|
|
36
|
+
supported: false,
|
|
37
|
+
detail: "Native Windows runtime and preparation operations are unsupported: POSIX private-file, process-group, and atomic baseline-pointer guarantees have not been established.",
|
|
38
|
+
remediation: NATIVE_WINDOWS_REMEDIATION,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (platform !== "linux") {
|
|
42
|
+
return {
|
|
43
|
+
kind: "other",
|
|
44
|
+
supported: false,
|
|
45
|
+
detail: "This operating system is outside the verified macOS/Linux runtime contract.",
|
|
46
|
+
remediation: "Use a supported macOS or Linux environment. Windows users should use WSL2 with the project on its Linux home filesystem and Docker Desktop WSL integration.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
let procRelease = "";
|
|
50
|
+
try {
|
|
51
|
+
procRelease = (await readKernelRelease()).slice(0, 512);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// os.release() is independent kernel evidence when procfs is unavailable.
|
|
55
|
+
}
|
|
56
|
+
const evidence = `${kernelRelease.slice(0, 512)}\n${procRelease}`;
|
|
57
|
+
if (!/microsoft|\bwsl2?\b/iu.test(evidence)) {
|
|
58
|
+
return {
|
|
59
|
+
kind: "linux",
|
|
60
|
+
supported: true,
|
|
61
|
+
detail: "Linux is in the supported host matrix; project readiness checks still apply.",
|
|
62
|
+
remediation: "Resolve the remaining project readiness checks on Linux.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (!/microsoft-standard|\bwsl2\b/iu.test(evidence)) {
|
|
66
|
+
return {
|
|
67
|
+
kind: "wsl-unverified",
|
|
68
|
+
supported: false,
|
|
69
|
+
detail: "A WSL kernel was detected, but WSL2 kernel capabilities were not proven. WSL1 is unsupported.",
|
|
70
|
+
remediation: NATIVE_WINDOWS_REMEDIATION,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
let filesystemType = null;
|
|
74
|
+
try {
|
|
75
|
+
filesystemType = await inspectFilesystem(projectRoot);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Unknown filesystem evidence must not be interpreted as POSIX capability.
|
|
79
|
+
}
|
|
80
|
+
if (filesystemType === null ||
|
|
81
|
+
!WSL_POSIX_FILESYSTEM_TYPES.has(filesystemType)) {
|
|
82
|
+
return {
|
|
83
|
+
kind: "wsl2",
|
|
84
|
+
supported: false,
|
|
85
|
+
detail: "WSL2 was detected, but this project is not on a proven Linux filesystem. Windows drive mounts and unknown filesystem types are unsupported for private baseline data.",
|
|
86
|
+
remediation: NATIVE_WINDOWS_REMEDIATION,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
kind: "wsl2",
|
|
91
|
+
supported: true,
|
|
92
|
+
detail: "WSL2 kernel and a Linux project filesystem were detected. Run in VS Code Remote - WSL with Linux tools and Docker Desktop WSL integration; the remaining readiness and private-file checks still apply.",
|
|
93
|
+
remediation: NATIVE_WINDOWS_REMEDIATION,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
export const assertSupportedHostPlatform = async (options = {}) => {
|
|
97
|
+
const inspection = await inspectHostPlatform(options);
|
|
98
|
+
if (inspection.supported)
|
|
99
|
+
return;
|
|
100
|
+
throw new RehearsalError({
|
|
101
|
+
category: "unsafe_environment",
|
|
102
|
+
code: "HOST_PLATFORM_UNSUPPORTED",
|
|
103
|
+
message: inspection.detail,
|
|
104
|
+
expected: "A supported macOS/Linux runtime with its existing safety guarantees.",
|
|
105
|
+
actual: inspection.kind,
|
|
106
|
+
refused: "No project configuration, child process, or runtime operation was started.",
|
|
107
|
+
suggestions: [inspection.remediation],
|
|
108
|
+
});
|
|
109
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Project-wide mutation lock and installed-package integrity guard. */
|
|
2
|
+
import type { MigrationFailureDiagnostic } from "../runtime/migration_failure.mjs";
|
|
2
3
|
import { type OperationProgressSnapshot, type RecentOperationProgress } from "./operation_progress_contract.mjs";
|
|
4
|
+
import { type RecentOperationFailure } from "./operation_progress_storage.mjs";
|
|
3
5
|
export interface InstalledPackageFingerprint {
|
|
4
6
|
readonly files: number;
|
|
5
7
|
readonly sha256: string;
|
|
@@ -8,7 +10,7 @@ export interface ProjectOperation {
|
|
|
8
10
|
readonly command: string;
|
|
9
11
|
readonly id: string;
|
|
10
12
|
readonly startedAt: string;
|
|
11
|
-
writeProgress(progress: OperationProgressSnapshot): Promise<void>;
|
|
13
|
+
writeProgress(progress: OperationProgressSnapshot, migrationFailure?: MigrationFailureDiagnostic): Promise<void>;
|
|
12
14
|
release(): Promise<void>;
|
|
13
15
|
releaseSync(): void;
|
|
14
16
|
}
|
|
@@ -22,6 +24,7 @@ export type ProjectOperationState = Readonly<{
|
|
|
22
24
|
progress: OperationProgressSnapshot | null;
|
|
23
25
|
}> | null;
|
|
24
26
|
recent: readonly RecentOperationProgress[] | null;
|
|
27
|
+
recentFailures?: readonly RecentOperationFailure[] | null;
|
|
25
28
|
}>;
|
|
26
29
|
export declare const createInstalledPackageFingerprint: ({ packageRoot, }: {
|
|
27
30
|
packageRoot: string;
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join, relative } from "node:path";
|
|
7
7
|
import { REHEARSAL_VERSION } from "./diagnostics.mjs";
|
|
8
8
|
import { refreshLiveOperationProgress, } from "./operation_progress_contract.mjs";
|
|
9
|
-
import { archiveOperationProgress, readOwnedOperationProgress,
|
|
9
|
+
import { archiveOperationProgress, readOwnedOperationProgress, readOwnedOperationProgressRecord, readRecentOperationHistory, writeOwnedOperationProgress, } from "./operation_progress_storage.mjs";
|
|
10
10
|
const LOCK_ROOT = join(tmpdir(), "rehearsal-db-operation-locks");
|
|
11
11
|
const OPERATION_KINDS = new Set([
|
|
12
12
|
"stop-stale",
|
|
@@ -164,7 +164,7 @@ export const acquireProjectOperation = async ({ projectRoot, command, }) => {
|
|
|
164
164
|
command,
|
|
165
165
|
id: digest(token),
|
|
166
166
|
startedAt: owner.startedAt,
|
|
167
|
-
async writeProgress(progress) {
|
|
167
|
+
async writeProgress(progress, migrationFailure) {
|
|
168
168
|
const current = await readPrivateLockOwner(ownerPath);
|
|
169
169
|
assertOwnership(current);
|
|
170
170
|
if (progress.startedAt !== owner.startedAt)
|
|
@@ -174,25 +174,29 @@ export const acquireProjectOperation = async ({ projectRoot, command, }) => {
|
|
|
174
174
|
token,
|
|
175
175
|
operationId: digest(token),
|
|
176
176
|
progress,
|
|
177
|
+
...(migrationFailure === undefined ? {} : { migrationFailure }),
|
|
177
178
|
});
|
|
178
179
|
},
|
|
179
180
|
async release() {
|
|
180
181
|
const current = await readPrivateLockOwner(ownerPath);
|
|
181
182
|
assertOwnership(current);
|
|
182
|
-
const
|
|
183
|
+
const stored = await readOwnedOperationProgressRecord({
|
|
183
184
|
lockDirectory,
|
|
184
185
|
token,
|
|
185
186
|
operationId: digest(token),
|
|
186
187
|
startedAt: owner.startedAt,
|
|
187
188
|
});
|
|
188
|
-
if (
|
|
189
|
+
if (stored) {
|
|
189
190
|
try {
|
|
190
191
|
await archiveOperationProgress({
|
|
191
192
|
projectHash,
|
|
192
193
|
operationKinds: OPERATION_KINDS,
|
|
193
194
|
kind: command,
|
|
194
195
|
rehearsalVersion: REHEARSAL_VERSION,
|
|
195
|
-
progress,
|
|
196
|
+
progress: stored.progress,
|
|
197
|
+
...(stored.migrationFailure === undefined
|
|
198
|
+
? {}
|
|
199
|
+
: { migrationFailure: stored.migrationFailure }),
|
|
196
200
|
});
|
|
197
201
|
}
|
|
198
202
|
catch {
|
|
@@ -240,7 +244,7 @@ export const acquireProjectOperation = async ({ projectRoot, command, }) => {
|
|
|
240
244
|
}
|
|
241
245
|
throw new Error("Rehearsal could not acquire its project operation lock.");
|
|
242
246
|
};
|
|
243
|
-
const operationState = (state, owner = null, progress = null, recent = null) => Object.freeze({
|
|
247
|
+
const operationState = (state, owner = null, progress = null, recent = null, recentFailures = null) => Object.freeze({
|
|
244
248
|
schemaVersion: 1,
|
|
245
249
|
state,
|
|
246
250
|
operation: owner
|
|
@@ -252,6 +256,7 @@ const operationState = (state, owner = null, progress = null, recent = null) =>
|
|
|
252
256
|
})
|
|
253
257
|
: null,
|
|
254
258
|
recent,
|
|
259
|
+
recentFailures,
|
|
255
260
|
});
|
|
256
261
|
export const inspectProjectOperationState = async ({ projectRoot, }) => {
|
|
257
262
|
await assertPrivateLockRoot();
|
|
@@ -259,7 +264,7 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
|
|
|
259
264
|
const projectHash = digest(canonicalRoot);
|
|
260
265
|
const lockDirectory = join(LOCK_ROOT, projectHash);
|
|
261
266
|
const ownerPath = join(lockDirectory, "owner.json");
|
|
262
|
-
const recent = await
|
|
267
|
+
const { recent, recentFailures } = await readRecentOperationHistory({
|
|
263
268
|
projectHash,
|
|
264
269
|
operationKinds: OPERATION_KINDS,
|
|
265
270
|
});
|
|
@@ -269,14 +274,14 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
|
|
|
269
274
|
}
|
|
270
275
|
catch (error) {
|
|
271
276
|
if (hasErrorCode(error, "ENOENT"))
|
|
272
|
-
return operationState("idle", null, null, recent);
|
|
277
|
+
return operationState("idle", null, null, recent, recentFailures);
|
|
273
278
|
throw error;
|
|
274
279
|
}
|
|
275
280
|
if (!details.isDirectory() ||
|
|
276
281
|
details.isSymbolicLink() ||
|
|
277
282
|
(details.mode & 0o077) !== 0 ||
|
|
278
283
|
(typeof process.getuid === "function" && details.uid !== process.getuid())) {
|
|
279
|
-
return operationState("invalid", null, null, recent);
|
|
284
|
+
return operationState("invalid", null, null, recent, recentFailures);
|
|
280
285
|
}
|
|
281
286
|
let owner;
|
|
282
287
|
try {
|
|
@@ -284,11 +289,11 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
|
|
|
284
289
|
}
|
|
285
290
|
catch (error) {
|
|
286
291
|
if (hasErrorCode(error, "ENOENT"))
|
|
287
|
-
return operationState("idle", null, null, recent);
|
|
288
|
-
return operationState("invalid", null, null, recent);
|
|
292
|
+
return operationState("idle", null, null, recent, recentFailures);
|
|
293
|
+
return operationState("invalid", null, null, recent, recentFailures);
|
|
289
294
|
}
|
|
290
295
|
if (owner.projectHash !== projectHash)
|
|
291
|
-
return operationState("invalid", null, null, recent);
|
|
296
|
+
return operationState("invalid", null, null, recent, recentFailures);
|
|
292
297
|
const live = processIsAlive(owner.pid);
|
|
293
298
|
const progress = await readOwnedOperationProgress({
|
|
294
299
|
lockDirectory,
|
|
@@ -296,7 +301,7 @@ export const inspectProjectOperationState = async ({ projectRoot, }) => {
|
|
|
296
301
|
operationId: digest(owner.token),
|
|
297
302
|
startedAt: owner.startedAt,
|
|
298
303
|
});
|
|
299
|
-
return operationState(live ? "busy" : "stale", owner, live && progress ? refreshLiveOperationProgress(progress) : progress, recent);
|
|
304
|
+
return operationState(live ? "busy" : "stale", owner, live && progress ? refreshLiveOperationProgress(progress) : progress, recent, recentFailures);
|
|
300
305
|
};
|
|
301
306
|
export const inspectProjectOperationLocks = async () => readdir(LOCK_ROOT).catch((error) => {
|
|
302
307
|
if (hasErrorCode(error, "ENOENT"))
|