1429 lines
66 KiB
JavaScript
1429 lines
66 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { execFile, execFileSync } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createServer } from "node:http";
|
|
import { closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
import { access, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import net from "node:net";
|
|
import process from "node:process";
|
|
|
|
import { stringify as yamlStringify } from "yaml";
|
|
|
|
import { buildSafeEnvironment, deriveOverall, scanSecrets } from "./p1-acceptance.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const modulePath = fileURLToPath(import.meta.url);
|
|
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
|
|
const RUN_ID = /^p3-[0-9a-f]{32}$/;
|
|
const HEX32 = /^[0-9a-f]{32}$/;
|
|
const HEX40 = /^[0-9a-f]{40}$/;
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
const COMMAND = /^[a-z0-9][a-z0-9-]*$/;
|
|
const CHECK_RESULT_STATUS = new Set(["PASS", "FAIL"]);
|
|
const CHECK_IDS = Object.freeze([
|
|
"preflight",
|
|
"clean_state",
|
|
"ownership",
|
|
"effective_config_identity",
|
|
"dwh_processing",
|
|
"schema_review",
|
|
"schema_index",
|
|
"evidence_processing",
|
|
"content_only_reuse",
|
|
"dwh_change_fail_closed",
|
|
"memory_root",
|
|
"revision_scoped_records",
|
|
"negative_cases",
|
|
"secret_scan",
|
|
"cleanup_confinement",
|
|
]);
|
|
const TOPOLOGY = [
|
|
"remote.git",
|
|
"author",
|
|
"installation",
|
|
"installation/data",
|
|
"installation/data/sessions",
|
|
"installation/registry",
|
|
"installation/pi-state",
|
|
"fixture-secrets",
|
|
"fixtures",
|
|
"fixtures/logs",
|
|
"logs",
|
|
];
|
|
const MAX_REPORT_JSON_BYTES = 64 * 1024;
|
|
const MAX_REPORT_MD_BYTES = 32 * 1024;
|
|
const MAX_STDIO_BYTES = 512 * 1024;
|
|
const MAX_SECRET_SCAN_VIRTUAL_BYTES = 256 * 1024;
|
|
|
|
function nowIso() { return new Date().toISOString(); }
|
|
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
function assert(condition, message) { if (!condition) throw new Error(message); }
|
|
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
|
|
function canonicalRoot(repositoryRoot = defaultRepositoryRoot) {
|
|
return realpathSync(repositoryRoot);
|
|
}
|
|
|
|
export function canonicalIntegrationBase(repositoryRoot = defaultRepositoryRoot) {
|
|
return join(canonicalRoot(repositoryRoot), ".artifacts", "p3-integration");
|
|
}
|
|
|
|
export function validateRunRoot(repositoryRoot, runRoot, runId) {
|
|
if (!RUN_ID.test(runId)) throw new Error("invalid owned run id");
|
|
const base = canonicalIntegrationBase(repositoryRoot);
|
|
const lexical = resolve(runRoot);
|
|
if (dirname(lexical) !== base || basename(lexical) !== runId) throw new Error("run root is not a direct integration child");
|
|
return lexical;
|
|
}
|
|
|
|
function validateNoSymlinkAncestors(repositoryRoot, target) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const rel = relative(repo, target);
|
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path leaves repository");
|
|
let cursor = repo;
|
|
for (const part of rel.split(sep).filter(Boolean)) {
|
|
cursor = join(cursor, part);
|
|
if (!existsSync(cursor)) break;
|
|
const entry = lstatSync(cursor);
|
|
if (entry.isSymbolicLink()) throw new Error("owned path ancestor is a symlink");
|
|
}
|
|
}
|
|
|
|
async function atomicWrite(path, bytes, mode = 0o600) {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
const staging = join(dirname(path), `.${basename(path)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
let handle;
|
|
try {
|
|
handle = await open(staging, "wx", mode);
|
|
await handle.writeFile(bytes);
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await rename(staging, path);
|
|
const directory = openSync(dirname(path), fsConstants.O_RDONLY);
|
|
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
} catch (error) {
|
|
if (handle) await handle.close().catch(() => {});
|
|
await rm(staging, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function initialResources(run) {
|
|
return [
|
|
run.root,
|
|
join(run.root, "remote.git"),
|
|
join(run.root, "author"),
|
|
join(run.root, "installation"),
|
|
join(run.root, "installation", "registry"),
|
|
join(run.root, "installation", "data"),
|
|
join(run.root, "fixture-secrets"),
|
|
];
|
|
}
|
|
|
|
function ownershipValue(run) {
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "p3-acceptance",
|
|
runId: run.runId,
|
|
runNonce: run.nonce,
|
|
root: run.root,
|
|
repositoryRoot: run.repositoryRoot,
|
|
startedAt: run.startedAt,
|
|
pid: run.pid,
|
|
resources: initialResources(run),
|
|
};
|
|
}
|
|
|
|
async function writeOwnership(run) {
|
|
await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownershipValue(run), null, 2)}\n`);
|
|
}
|
|
|
|
export async function createOwnedRun({ repositoryRoot = defaultRepositoryRoot, runId, nonce, now, pid } = {}) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const base = canonicalIntegrationBase(repo);
|
|
validateNoSymlinkAncestors(repo, base);
|
|
await mkdir(join(repo, ".artifacts"), { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
|
|
await mkdir(base, { mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });
|
|
const id = runId ?? `p3-${randomBytes(16).toString("hex")}`;
|
|
const root = validateRunRoot(repo, join(base, id), id);
|
|
const run = {
|
|
repositoryRoot: repo,
|
|
root,
|
|
runId: id,
|
|
nonce: nonce ?? randomBytes(32).toString("hex"),
|
|
startedAt: now ?? nowIso(),
|
|
pid: pid ?? process.pid,
|
|
};
|
|
if (!HEX64.test(run.nonce) || !ISO_UTC.test(run.startedAt)) throw new Error("invalid ownership identity");
|
|
await mkdir(root, { mode: 0o700 });
|
|
await writeOwnership(run);
|
|
return run;
|
|
}
|
|
|
|
function strictOwnership(value, run, expectedNonce) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("ownership is malformed");
|
|
if (value.schemaVersion !== 1 || value.kind !== "p3-acceptance" || value.runId !== run.runId || value.runNonce !== expectedNonce
|
|
|| value.root !== run.root || value.repositoryRoot !== run.repositoryRoot || value.pid !== process.pid
|
|
|| !ISO_UTC.test(value.startedAt ?? "")
|
|
|| JSON.stringify(value.resources) !== JSON.stringify(initialResources(run))) throw new Error("ownership identity mismatch");
|
|
return value;
|
|
}
|
|
|
|
export async function readAndValidateOwnership({ repositoryRoot = defaultRepositoryRoot, runRoot, expectedNonce }) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const id = basename(resolve(runRoot));
|
|
const lexical = validateRunRoot(repo, runRoot, id);
|
|
const rootEntry = await lstat(lexical);
|
|
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) throw new Error("owned run root is not a directory");
|
|
const ownershipPath = join(lexical, "ownership.json");
|
|
const ownershipEntry = await lstat(ownershipPath);
|
|
if (!ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership file is unsafe");
|
|
let value;
|
|
try { value = JSON.parse(await readFile(ownershipPath, "utf8")); } catch { throw new Error("ownership is malformed"); }
|
|
return strictOwnership(value, { repositoryRoot: repo, root: lexical, runId: id }, expectedNonce);
|
|
}
|
|
|
|
export async function cleanupOwnedRun({ repositoryRoot = defaultRepositoryRoot, runRoot, expectedNonce }) {
|
|
const value = await readAndValidateOwnership({ repositoryRoot, runRoot, expectedNonce });
|
|
const base = canonicalIntegrationBase(repositoryRoot);
|
|
const tombstone = join(base, `.deleting-${value.runId}-${expectedNonce.slice(0, 16)}`);
|
|
await rename(runRoot, tombstone);
|
|
await rm(tombstone, { recursive: true, force: false });
|
|
}
|
|
|
|
async function finalizeOwnedRun({ run, success, keep }) {
|
|
if (!success || keep) return false;
|
|
await cleanupOwnedRun({ repositoryRoot: run.repositoryRoot, runRoot: run.root, expectedNonce: run.nonce });
|
|
return true;
|
|
}
|
|
|
|
function safeArtifactPath(path) {
|
|
if (typeof path !== "string" || path.length === 0 || path.length > 255 || path.startsWith("/") || path.includes("..") || path.includes("\\") || /[\0\r\n]/.test(path)) {
|
|
throw new Error("report artifact path is invalid");
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function hasExactCheckIds(checks) {
|
|
return checks.length === CHECK_IDS.length && checks.every(({ id }, index) => id === CHECK_IDS[index]);
|
|
}
|
|
|
|
export function validateReport(report) {
|
|
if (!report || report.schemaVersion !== 1 || !RUN_ID.test(report.runId ?? "") || !ISO_UTC.test(report.startedAt ?? "")
|
|
|| !ISO_UTC.test(report.finishedAt ?? "") || typeof report.command !== "string"
|
|
|| !Array.isArray(report.checks) || !hasExactCheckIds(report.checks)) throw new Error("report is invalid");
|
|
const ids = new Set();
|
|
const artifactPaths = new Set();
|
|
for (const check of report.checks) {
|
|
if (!check || !/^[a-z0-9_]+$/.test(check.id ?? "") || ids.has(check.id) || !CHECK_RESULT_STATUS.has(check.status)
|
|
|| !ISO_UTC.test(check.startedAt ?? "") || !ISO_UTC.test(check.finishedAt ?? "")
|
|
|| !Array.isArray(check.commands) || check.commands.some((name) => !COMMAND.test(name))
|
|
|| !Array.isArray(check.artifacts)) throw new Error("report check is invalid");
|
|
ids.add(check.id);
|
|
for (const artifact of check.artifacts) {
|
|
safeArtifactPath(artifact.path);
|
|
if (!HEX64.test(artifact.sha256 ?? "")) throw new Error("report check is invalid");
|
|
if (artifactPaths.has(artifact.path)) throw new Error("report artifact path is duplicated");
|
|
artifactPaths.add(artifact.path);
|
|
}
|
|
}
|
|
if (report.overall !== deriveOverall(report.checks)) throw new Error("report overall is not derived");
|
|
return report;
|
|
}
|
|
|
|
function renderReportMarkdown(report) {
|
|
validateReport(report);
|
|
const rows = report.checks.map((check) => `| ${check.id} | ${check.status} |`).join("\n");
|
|
return [
|
|
"# P2 acceptance report",
|
|
"",
|
|
`Run: \`${report.runId}\``,
|
|
"",
|
|
"| Check | Status |",
|
|
"|---|---|",
|
|
rows,
|
|
"",
|
|
`P3 automated integration: ${report.overall}`,
|
|
"P3 manual acceptance: PENDING",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
async function walkFiles(root) {
|
|
const files = [];
|
|
async function visit(dir) {
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
const rel = relative(root, path).split(sep).join("/");
|
|
if (entry.isSymbolicLink()) throw new Error(`unsafe file tree: ${rel}`);
|
|
if (entry.isDirectory()) await visit(path);
|
|
else if (entry.isFile()) files.push({ path, rel });
|
|
}
|
|
}
|
|
if (existsSync(root)) await visit(root);
|
|
files.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
return files;
|
|
}
|
|
|
|
async function snapshotDigest(root, excludedPrefixes = []) {
|
|
const result = {};
|
|
for (const file of await walkFiles(root)) {
|
|
if (excludedPrefixes.some((prefix) => file.rel === prefix || file.rel.startsWith(`${prefix}/`))) continue;
|
|
result[file.rel] = sha256(await readFile(file.path));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function fileArtifact(root, relativePath) {
|
|
const bytes = await readFile(join(root, relativePath));
|
|
return { path: relativePath.split(sep).join("/"), sha256: sha256(bytes) };
|
|
}
|
|
|
|
async function writeJson(path, value) {
|
|
await atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
async function writeReportFiles({ run, report }) {
|
|
validateReport(report);
|
|
const reportJsonPath = join(run.root, "report.json");
|
|
const reportMdPath = join(run.root, "report.md");
|
|
const reportMd = renderReportMarkdown(report);
|
|
if (Buffer.byteLength(JSON.stringify(report)) > MAX_REPORT_JSON_BYTES) throw new Error("report.json exceeds bound");
|
|
if (Buffer.byteLength(reportMd) > MAX_REPORT_MD_BYTES) throw new Error("report.md exceeds bound");
|
|
await writeJson(reportJsonPath, report);
|
|
await atomicWrite(reportMdPath, reportMd, 0o600);
|
|
return {
|
|
reportJson: await fileArtifact(run.root, "report.json"),
|
|
reportMd: await fileArtifact(run.root, "report.md"),
|
|
};
|
|
}
|
|
|
|
function resolveSystemExecutable(name) {
|
|
for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]) {
|
|
try {
|
|
const resolved = realpathSync(candidate);
|
|
if (statSync(resolved).isFile()) return resolved;
|
|
} catch {}
|
|
}
|
|
throw new Error(`required executable not found: ${name}`);
|
|
}
|
|
|
|
function scalarSecretBytes(value) {
|
|
if (typeof value !== "string" || value.length === 0 || /\s|\0/.test(value)) throw new Error("scalar fixture secret is invalid");
|
|
return Buffer.from(value);
|
|
}
|
|
|
|
async function manifestFiles(root, paths) {
|
|
const files = [];
|
|
const visit = async (absolute, rel) => {
|
|
const entry = await lstat(absolute);
|
|
if (entry.isSymbolicLink()) throw new Error(`provenance path is a symlink: ${rel}`);
|
|
if (entry.isDirectory()) {
|
|
for (const child of (await readdir(absolute, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
await visit(join(absolute, child.name), rel ? `${rel}/${child.name}` : child.name);
|
|
}
|
|
} else if (entry.isFile()) {
|
|
const bytes = await readFile(absolute);
|
|
files.push({ path: rel, bytes: bytes.length, sha256: sha256(bytes) });
|
|
} else throw new Error(`provenance path is not a regular file: ${rel}`);
|
|
};
|
|
for (const path of paths) await visit(join(root, path), path);
|
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
return { files, manifestSha256: sha256(JSON.stringify(files)) };
|
|
}
|
|
|
|
async function collectRepositoryProvenance({ repositoryRoot, gitPath = resolveSystemExecutable("git") }) {
|
|
const repo = canonicalRoot(repositoryRoot);
|
|
const safeEnv = buildSafeEnvironment({ ambient: {}, fixture: { PATH: `${dirname(gitPath)}:/usr/bin:/bin`, HOME: repo, TMPDIR: join(repo, ".artifacts") } });
|
|
const run = async (argv) => await execFileAsync(gitPath, ["-C", repo, ...argv], { env: safeEnv, maxBuffer: MAX_STDIO_BYTES });
|
|
const beforeHead = (await run(["rev-parse", "HEAD"]).catch((error) => { throw error; })).stdout.trim();
|
|
const beforeTree = (await run(["rev-parse", "HEAD^{tree}"])).stdout.trim();
|
|
const beforeStatus = (await run(["status", "--porcelain=v1", "--untracked-files=no"])).stdout;
|
|
if (!HEX40.test(beforeHead) || !HEX40.test(beforeTree) || beforeStatus !== "") throw new Error("repository is not clean at exact HEAD");
|
|
const backendRoot = join(repo, "backend");
|
|
const backendSource = await manifestFiles(backendRoot, [
|
|
"src",
|
|
"scripts/p3-acceptance.mjs",
|
|
"package.json",
|
|
"package-lock.json",
|
|
"tsconfig.json",
|
|
]);
|
|
const backendDist = existsSync(join(backendRoot, "dist")) ? await manifestFiles(backendRoot, ["dist"]) : { files: [], manifestSha256: sha256("[]") };
|
|
const afterHead = (await run(["rev-parse", "HEAD"]).catch((error) => { throw error; })).stdout.trim();
|
|
const afterTree = (await run(["rev-parse", "HEAD^{tree}"])).stdout.trim();
|
|
const afterStatus = (await run(["status", "--porcelain=v1", "--untracked-files=no"])).stdout;
|
|
if (afterHead !== beforeHead || afterTree !== beforeTree || afterStatus !== beforeStatus) throw new Error("repository provenance changed during binding");
|
|
return { schemaVersion: 1, head: beforeHead, tree: beforeTree, clean: true, backendSource, backendDist };
|
|
}
|
|
|
|
async function createTopology(run) {
|
|
for (const path of TOPOLOGY) await mkdir(join(run.root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 });
|
|
}
|
|
|
|
async function allocatePort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).on("error", reject));
|
|
const port = server.address().port;
|
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
return port;
|
|
}
|
|
|
|
function installationProjectName(installationPath) {
|
|
return `thothii-${sha256(installationPath).slice(0, 12)}`;
|
|
}
|
|
|
|
function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) {
|
|
return {
|
|
workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" },
|
|
dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] },
|
|
diagnostics: {
|
|
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } },
|
|
},
|
|
...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}),
|
|
};
|
|
}
|
|
|
|
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
|
|
|
|
function descriptorYaml(obj) {
|
|
return yamlStringify(obj, { lineWidth: 0, sortMapEntries: false });
|
|
}
|
|
|
|
async function setupSecrets(ctx) {
|
|
const secretDir = join(ctx.run.root, "fixture-secrets");
|
|
const values = {
|
|
dwhToken: `P2-DWH-${randomBytes(16).toString("hex")}`,
|
|
signedToken: `P2-SIGNED-${randomBytes(16).toString("hex")}`,
|
|
bundle: `P2-BUNDLE-${randomBytes(16).toString("hex")}`,
|
|
};
|
|
ctx.forbiddenValues = Object.values(values);
|
|
ctx.secretValues = values;
|
|
const paths = {
|
|
dwh: join(secretDir, "p2-dwh-api-key"),
|
|
filesystemDwh: join(secretDir, "p2-filesystem-api-key"),
|
|
signed: join(secretDir, "p2-dwh-evidence-signed-urls.json"),
|
|
bundle: join(secretDir, "thothii.secrets"),
|
|
};
|
|
await atomicWrite(paths.dwh, scalarSecretBytes(values.dwhToken));
|
|
await atomicWrite(paths.filesystemDwh, scalarSecretBytes(values.dwhToken));
|
|
await atomicWrite(paths.bundle, scalarSecretBytes(values.bundle));
|
|
ctx.secretPaths = paths;
|
|
}
|
|
|
|
async function setupFixtures(ctx) {
|
|
ctx.fixturePorts = {
|
|
dwh: await allocatePort(),
|
|
evidence: await allocatePort(),
|
|
embedding: await allocatePort(),
|
|
qdrant: await allocatePort(),
|
|
};
|
|
const dwhBaseUrl = `http://host.docker.internal:${ctx.fixturePorts.dwh}`;
|
|
const evidenceProvenance = `http://host.docker.internal:${ctx.fixturePorts.evidence}/p2-dwh/guide.md`;
|
|
ctx.workspaceObjects = {
|
|
dwh: baseWorkspace("p2-dwh", {
|
|
dwhBaseUrl,
|
|
evidenceSource: {
|
|
type: "http",
|
|
uris: [evidenceProvenance],
|
|
authentication: "signed_urls_file",
|
|
connect_timeout_ms: 1250,
|
|
read_timeout_ms: 30001,
|
|
max_bytes: 65536,
|
|
max_redirects: 2,
|
|
allow_private_hosts: true,
|
|
max_cache_bytes: 65536,
|
|
},
|
|
}),
|
|
filesystem: baseWorkspace("p2-filesystem", {
|
|
dwhBaseUrl,
|
|
evidenceSource: {
|
|
type: "filesystem",
|
|
uri: "p2-filesystem/evidence",
|
|
patterns: ["**/*.md"],
|
|
max_bytes: 1048576,
|
|
},
|
|
}),
|
|
};
|
|
const signedUrl = `${evidenceProvenance}?token=${ctx.secretValues.signedToken}`;
|
|
await atomicWrite(ctx.secretPaths.signed, `${JSON.stringify([signedUrl], null, 2)}\n`);
|
|
|
|
ctx.evidenceState = {
|
|
content: "# P2 Evidence\n\nFirst generation.\n",
|
|
token: ctx.secretValues.signedToken,
|
|
};
|
|
ctx.dwhState = {
|
|
tables: {
|
|
patients: {
|
|
comment: "Patients",
|
|
rows: [
|
|
{ patient_id: "p1", name: "Alice" },
|
|
{ patient_id: "p2", name: "Bob" },
|
|
],
|
|
},
|
|
visits: {
|
|
comment: "Visits",
|
|
rows: [
|
|
{ id: "v1", patient_id: "p1", note: "checkup" },
|
|
{ id: "v2", patient_id: "p2", note: "xray" },
|
|
],
|
|
},
|
|
labs: {
|
|
comment: "Labs",
|
|
rows: [
|
|
{ id: "l1", patient_id: "p1", code: "hemoglobin" },
|
|
{ id: "l2", patient_id: "p2", code: "glucose" },
|
|
],
|
|
},
|
|
},
|
|
token: ctx.secretValues.dwhToken,
|
|
};
|
|
}
|
|
|
|
function inferColumnType(value) {
|
|
return typeof value === "number" ? "integer" : "text";
|
|
}
|
|
|
|
function topValues(rows, column, limit) {
|
|
const counts = new Map();
|
|
for (const row of rows) {
|
|
const value = row[column];
|
|
if (value === undefined || value === null || value === "") continue;
|
|
counts.set(String(value), (counts.get(String(value)) ?? 0) + 1);
|
|
}
|
|
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([value]) => ({ value }));
|
|
}
|
|
|
|
async function startHttpServer({ port, handler }) {
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
await handler(req, res);
|
|
} catch {
|
|
res.statusCode = 500;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: "fixture failed" }));
|
|
}
|
|
});
|
|
await new Promise((resolve, reject) => server.listen(port, "127.0.0.1", () => resolve()).on("error", reject));
|
|
return server;
|
|
}
|
|
|
|
async function startServers(ctx) {
|
|
const dwhServer = await startHttpServer({
|
|
port: ctx.fixturePorts.dwh,
|
|
handler: async (req, res) => {
|
|
const body = await new Promise((resolve) => {
|
|
const chunks = [];
|
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
});
|
|
const json = body.length === 0 ? {} : JSON.parse(body);
|
|
if (req.headers["x-api-key"] !== ctx.dwhState.token) {
|
|
res.statusCode = 401;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ message: "unauthorized" }));
|
|
return;
|
|
}
|
|
const send = (payload) => {
|
|
res.statusCode = 200;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify(payload));
|
|
};
|
|
const url = new URL(req.url, `http://127.0.0.1:${ctx.fixturePorts.dwh}`);
|
|
if (req.method !== "POST" || !url.pathname.startsWith("/rpc/")) {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ message: "not found" }));
|
|
return;
|
|
}
|
|
const fn = url.pathname.slice("/rpc/".length);
|
|
const schemaName = json.schema_name ?? "dw";
|
|
if (schemaName !== "dw") {
|
|
send([]);
|
|
return;
|
|
}
|
|
if (fn === "ping") {
|
|
send({ db_connected: true, schema_accessible: true, database: "warehouse", schema: "dw" });
|
|
return;
|
|
}
|
|
const table = typeof json.table_name === "string" ? json.table_name : "";
|
|
const tableData = ctx.dwhState.tables[table];
|
|
if (fn === "list_tables") {
|
|
send(Object.entries(ctx.dwhState.tables).map(([name, info]) => ({ table: name, type: "TABLE", comment: info.comment, rows: info.rows.length })));
|
|
return;
|
|
}
|
|
if (!tableData) {
|
|
send([]);
|
|
return;
|
|
}
|
|
if (fn === "table_columns") {
|
|
const first = tableData.rows[0] ?? {};
|
|
send(Object.keys(first).map((column) => ({
|
|
column,
|
|
type: inferColumnType(first[column]),
|
|
nullable: false,
|
|
// Only the referenced table marks `patient_id` as primary, so the SQL miner sees a
|
|
// PK/non-PK pair while the same-name heuristic still discovers joins from the others.
|
|
pk: column === "id" || (table === "patients" && column === "patient_id"),
|
|
default: null,
|
|
})));
|
|
return;
|
|
}
|
|
if (fn === "table_comments") {
|
|
send(Object.keys(tableData.rows[0] ?? {}).map((column) => ({ object: "COLUMN", name: column, comment: `${table}.${column}` })));
|
|
return;
|
|
}
|
|
if (fn === "table_foreign_keys") {
|
|
send([]);
|
|
return;
|
|
}
|
|
if (fn === "top_values") {
|
|
send(topValues(tableData.rows, json.column_name, Number(json.max_values ?? 10)));
|
|
return;
|
|
}
|
|
if (fn === "column_stats") {
|
|
send({});
|
|
return;
|
|
}
|
|
if (fn === "run_query") {
|
|
send([]);
|
|
return;
|
|
}
|
|
if (fn === "explain_query") {
|
|
send([{ line: "Seq Scan" }]);
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ message: "unknown rpc" }));
|
|
},
|
|
});
|
|
const evidenceServer = await startHttpServer({
|
|
port: ctx.fixturePorts.evidence,
|
|
handler: async (req, res) => {
|
|
const url = new URL(req.url, `http://127.0.0.1:${ctx.fixturePorts.evidence}`);
|
|
if (url.pathname !== "/p2-dwh/guide.md" || url.searchParams.get("token") !== ctx.evidenceState.token) {
|
|
res.statusCode = 403;
|
|
res.end("forbidden");
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("content-type", "text/markdown; charset=utf-8");
|
|
res.end(ctx.evidenceState.content);
|
|
},
|
|
});
|
|
const embeddingServer = await startHttpServer({
|
|
port: ctx.fixturePorts.embedding,
|
|
handler: async (req, res) => {
|
|
const body = await new Promise((resolve) => {
|
|
const chunks = [];
|
|
req.on("data", (chunk) => chunks.push(chunk));
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
});
|
|
const url = new URL(req.url, `http://127.0.0.1:${ctx.fixturePorts.embedding}`);
|
|
if (req.method !== "POST" || url.pathname !== "/api/embed") {
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: "not found" }));
|
|
return;
|
|
}
|
|
const payload = JSON.parse(body || "{}");
|
|
const inputs = Array.isArray(payload.input) ? payload.input : [];
|
|
const embeddings = inputs.map((text) => {
|
|
const seed = sha256(String(text));
|
|
return Array.from({ length: 1024 }, (_, index) => {
|
|
const offset = (index * 2) % seed.length;
|
|
const value = Number.parseInt(seed.slice(offset, offset + 2), 16);
|
|
return (value / 255) - 0.5;
|
|
});
|
|
});
|
|
res.statusCode = 200;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ model: payload.model, embeddings }));
|
|
},
|
|
});
|
|
ctx.servers = [dwhServer, evidenceServer, embeddingServer];
|
|
}
|
|
|
|
async function stopServers(ctx) {
|
|
for (const server of ctx.servers ?? []) {
|
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
}
|
|
ctx.servers = [];
|
|
}
|
|
|
|
async function git(ctx, args, cwd = join(ctx.run.root, "author")) {
|
|
return await runCommand({ executable: ctx.executables.gitPath, argv: args, cwd, env: ctx.execEnv });
|
|
}
|
|
|
|
async function initializeGitAndRegistry(ctx) {
|
|
const author = join(ctx.run.root, "author");
|
|
await git(ctx, ["init", "--bare", "--initial-branch=main", join(ctx.run.root, "remote.git")], ctx.run.root);
|
|
await git(ctx, ["clone", join(ctx.run.root, "remote.git"), author], ctx.run.root);
|
|
await git(ctx, ["config", "user.name", "P2 Fixture Curator"], author);
|
|
await git(ctx, ["config", "user.email", "p3-curator@example.invalid"], author);
|
|
|
|
const writeWorkspaces = async () => {
|
|
const catalog = {
|
|
schema_version: 1,
|
|
workspaces: [
|
|
{ id: "p2-dwh", name: ctx.workspaceObjects.dwh.workspace.name },
|
|
{ id: "p2-filesystem", name: ctx.workspaceObjects.filesystem.workspace.name },
|
|
],
|
|
};
|
|
await writeFile(join(author, "thoth-workspaces.yaml"), yamlStringify(catalog, { lineWidth: 0, sortMapEntries: false }));
|
|
for (const [id, workspace] of Object.entries(ctx.workspaceObjects)) {
|
|
const pathId = workspace.workspace.id;
|
|
await mkdir(join(author, pathId), { recursive: true });
|
|
const yaml = descriptorYaml(workspace);
|
|
await writeFile(join(author, pathId, "workspace.yaml"), yaml);
|
|
const docs = ctx.workspaceModules.renderWorkspaceDocs(workspace);
|
|
await mkdir(join(author, "workspace-docs", pathId), { recursive: true });
|
|
await writeFile(join(author, "workspace-docs", pathId, "contract.env.example"), docs.envExample);
|
|
await writeFile(join(author, "workspace-docs", pathId, "README.md"), docs.markdown);
|
|
}
|
|
await mkdir(join(author, "p2-filesystem", "evidence"), { recursive: true });
|
|
await writeFile(join(author, "p2-filesystem", "evidence", "guide.md"), "# P2 Filesystem Evidence\n\nCommitted fixture.\n");
|
|
};
|
|
|
|
await writeWorkspaces();
|
|
await git(ctx, ["add", "."], author);
|
|
await git(ctx, ["commit", "-m", "Bootstrap P2 fixtures"], author);
|
|
await git(ctx, ["push", "origin", "main"], author);
|
|
ctx.registryCommit = (await git(ctx, ["rev-parse", "HEAD"], author)).stdout.trim();
|
|
const registry = new ctx.workspaceModules.WorkspaceRegistry({
|
|
root: join(ctx.run.root, "installation", "registry"),
|
|
remoteUrl: join(ctx.run.root, "remote.git"),
|
|
branch: "main",
|
|
gitAuthorName: "P2 Acceptance",
|
|
gitAuthorEmail: "p3-acceptance@example.invalid",
|
|
installationId: "p3-acceptance",
|
|
secretRoots: [join(ctx.run.root, "fixture-secrets")],
|
|
maxImportBytes: 16 * 1024 * 1024,
|
|
maxImportEntries: 1024,
|
|
});
|
|
await registry.bootstrap();
|
|
ctx.registry = registry;
|
|
}
|
|
|
|
async function mutateWorkspaceDescriptor(ctx, workspaceId, mutator, commitMessage) {
|
|
const author = join(ctx.run.root, "author");
|
|
// The registry may have produced docs-only follow-up commits on the remote; the curator
|
|
// always rebases onto the latest remote head before committing so the push stays fast-forward.
|
|
await git(ctx, ["fetch", "origin", "main"], author);
|
|
await git(ctx, ["reset", "--hard", "origin/main"], author);
|
|
const workspace = structuredClone(ctx.workspaceObjects[workspaceId === "p2-dwh" ? "dwh" : "filesystem"]);
|
|
mutator(workspace);
|
|
ctx.workspaceObjects[workspaceId === "p2-dwh" ? "dwh" : "filesystem"] = workspace;
|
|
await writeFile(join(author, workspaceId, "workspace.yaml"), descriptorYaml(workspace));
|
|
const docs = ctx.workspaceModules.renderWorkspaceDocs(workspace);
|
|
const docsDir = join(author, "workspace-docs", workspaceId);
|
|
await mkdir(docsDir, { recursive: true, mode: 0o700 });
|
|
await writeFile(join(docsDir, "contract.env.example"), docs.envExample);
|
|
await writeFile(join(docsDir, "README.md"), docs.markdown);
|
|
await git(ctx, ["add", `${workspaceId}/workspace.yaml`, `workspace-docs/${workspaceId}/contract.env.example`, `workspace-docs/${workspaceId}/README.md`], author);
|
|
await git(ctx, ["commit", "-m", commitMessage], author);
|
|
await git(ctx, ["push", "origin", "main"], author);
|
|
await ctx.registry.pull();
|
|
ctx.registryCommit = (await git(ctx, ["rev-parse", "HEAD"], author)).stdout.trim();
|
|
}
|
|
|
|
async function writeInstallationFiles(ctx) {
|
|
const installationDir = join(ctx.run.root, "installation");
|
|
const operatorEnvPath = join(installationDir, "operator.env");
|
|
const bindingsEnvPath = join(installationDir, "workspace-bindings.env");
|
|
const connectorOverridePath = join(installationDir, "connector-secrets.override.yaml");
|
|
const fixtureOverridePath = join(installationDir, "fixture.override.yaml");
|
|
const installationPath = join(installationDir, "thothii-installation.yaml");
|
|
ctx.installationPath = installationPath;
|
|
ctx.composeProject = installationProjectName(installationPath);
|
|
const qdrantPort = ctx.fixturePorts.qdrant;
|
|
const bindings = [
|
|
`THT_WS_P2_DWH_DWH_TRANSPORT=rest_api`,
|
|
`THT_WS_P2_DWH_DWH_BASE_URL=http://host.docker.internal:${ctx.fixturePorts.dwh}`,
|
|
`THT_WS_P2_DWH_DWH_API_KEY_FILE=/run/secrets/p2-dwh-api-key`,
|
|
`THT_WS_P2_DWH_EVIDENCE_SIGNED_URLS_FILE=/run/secrets/p2-dwh-evidence-signed-urls`,
|
|
`THT_WS_P2_FILESYSTEM_DWH_TRANSPORT=rest_api`,
|
|
`THT_WS_P2_FILESYSTEM_DWH_BASE_URL=http://host.docker.internal:${ctx.fixturePorts.dwh}`,
|
|
`THT_WS_P2_FILESYSTEM_DWH_API_KEY_FILE=/run/secrets/p2-filesystem-api-key`,
|
|
].join("\n") + "\n";
|
|
await atomicWrite(bindingsEnvPath, bindings);
|
|
const operatorEnv = [
|
|
`THT_DATA_ROOT=${join(ctx.run.root, "installation", "data")}`,
|
|
`THT_WORKSPACE_REGISTRY_ROOT=${join(ctx.run.root, "installation", "registry")}`,
|
|
`THT_PI_STATE_ROOT=${join(ctx.run.root, "installation", "pi-state")}`,
|
|
`PI_AUTH_FILE=${join(ctx.run.root, "installation", "pi-auth.json")}`,
|
|
`THT_SECRETS_FILE=${ctx.secretPaths.bundle}`,
|
|
`THT_WORKSPACE_BINDINGS_ENV_FILE=${bindingsEnvPath}`,
|
|
`THT_WORKSPACE_GIT_REMOTE=${join(ctx.run.root, "remote.git")}`,
|
|
`THT_WORKSPACE_GIT_BRANCH=main`,
|
|
`THT_WORKSPACE_GIT_AUTHOR_NAME=P2 Acceptance`,
|
|
`THT_WORKSPACE_GIT_AUTHOR_EMAIL=p3-acceptance@example.invalid`,
|
|
`THT_WORKSPACE_INSTALLATION_ID=p3-acceptance`,
|
|
`THT_DB_NAME=warehouse`,
|
|
`THT_DWH_REST_URL=http://host.docker.internal:${ctx.fixturePorts.dwh}`,
|
|
`THT_LLM_URL=http://127.0.0.1:9`,
|
|
`THOTH_SERVER_BIND=127.0.0.1`,
|
|
`THOTH_HTTP_PORT=18080`,
|
|
`THOTH_CORE_HTTP_PORT=18787`,
|
|
`THT_WS_P2_DWH_DWH_API_KEY_SOURCE=${ctx.secretPaths.dwh}`,
|
|
`THT_WS_P2_DWH_EVIDENCE_SIGNED_URLS_SOURCE=${ctx.secretPaths.signed}`,
|
|
`THT_WS_P2_FILESYSTEM_DWH_API_KEY_SOURCE=${ctx.secretPaths.filesystemDwh}`,
|
|
`THT_EVIDENCE_PRIVATE_HOST_ALLOWLIST=host.docker.internal`,
|
|
].join("\n") + "\n";
|
|
await atomicWrite(operatorEnvPath, operatorEnv);
|
|
await atomicWrite(join(ctx.run.root, "installation", "pi-auth.json"), JSON.stringify({ fixture: true }));
|
|
const embeddingStubPath = join(installationDir, "embedding-stub.py");
|
|
await atomicWrite(embeddingStubPath, EMBEDDING_STUB_SOURCE);
|
|
const override = {
|
|
services: {
|
|
core: {
|
|
image: ctx.coreImageTag,
|
|
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
},
|
|
"workspace-maintenance": {
|
|
image: ctx.coreImageTag,
|
|
environment: {
|
|
THT_EVIDENCE_PRIVATE_HOST_ALLOWLIST: "host.docker.internal",
|
|
},
|
|
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
},
|
|
qdrant: {
|
|
ports: [`127.0.0.1:${qdrantPort}:6333`],
|
|
restart: "no",
|
|
},
|
|
// Deterministic Ollama-compatible embedding fixture on the internal allowlisted host
|
|
// name `embedding` (http://embedding:11434). Replaces the real Ollama service entirely.
|
|
embedding: {
|
|
image: ctx.coreImageTag,
|
|
entrypoint: ["python3", "/stub.py"],
|
|
volumes: [
|
|
{ type: "bind", source: embeddingStubPath, target: "/stub.py", read_only: true },
|
|
],
|
|
healthcheck: { disable: true },
|
|
},
|
|
},
|
|
};
|
|
await atomicWrite(fixtureOverridePath, yamlStringify(override, { lineWidth: 0, sortMapEntries: false }));
|
|
const generated = await runCommand({
|
|
executable: join(ctx.repositoryRoot, "scripts", "generate-connector-secrets-override.sh"),
|
|
argv: [
|
|
"--bindings-env", bindingsEnvPath,
|
|
"--operator-env", operatorEnvPath,
|
|
"--output", connectorOverridePath,
|
|
"--service", "workspace-maintenance",
|
|
"--role", "all",
|
|
],
|
|
env: ctx.execEnv,
|
|
});
|
|
if (generated.exitCode !== 0) throw new Error(`connector override generation failed: ${generated.stderr || generated.stdout}`);
|
|
const installation = {
|
|
profile: "server",
|
|
projectDirectory: ctx.repositoryRoot,
|
|
envFile: operatorEnvPath,
|
|
overrides: [
|
|
join(ctx.repositoryRoot, "deploy", "compose.server.yaml"),
|
|
fixtureOverridePath,
|
|
connectorOverridePath,
|
|
],
|
|
};
|
|
await atomicWrite(installationPath, yamlStringify(installation, { lineWidth: 0, sortMapEntries: false }));
|
|
ctx.installation = installation;
|
|
}
|
|
|
|
function thothctlBinaryPath(repositoryRoot) {
|
|
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[process.platform] ?? "linux";
|
|
const arch = { x64: "amd64", arm64: "arm64" }[process.arch] ?? "amd64";
|
|
const suffix = platform === "windows" ? ".exe" : "";
|
|
const candidates = [
|
|
join(repositoryRoot, "dist", "thothctl", `thothctl-${platform}-${arch}${suffix}`),
|
|
join(repositoryRoot, "tools", "thothctl", "bin", `thothctl${suffix}`),
|
|
];
|
|
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
throw new Error("built thothctl binary is unavailable");
|
|
}
|
|
|
|
async function runCommand({ executable, argv = [], cwd, env, input, maxOutputBytes = MAX_STDIO_BYTES }) {
|
|
const result = await execFileAsync(executable, argv, {
|
|
cwd,
|
|
env,
|
|
encoding: "utf8",
|
|
maxBuffer: maxOutputBytes,
|
|
...(input === undefined ? {} : { input }),
|
|
}).then(
|
|
({ stdout, stderr }) => ({ exitCode: 0, stdout, stderr }),
|
|
(error) => ({ exitCode: error.code ?? 1, stdout: error.stdout ?? "", stderr: error.stderr ?? error.message ?? "" }),
|
|
);
|
|
return result;
|
|
}
|
|
|
|
async function buildCoreImage(ctx) {
|
|
const tag = `thothii-core:p2-${ctx.run.runId.slice(3, 15)}`;
|
|
ctx.coreImageTag = tag;
|
|
const build = await runCommand({
|
|
executable: ctx.executables.dockerPath,
|
|
argv: ["build", "-f", join(ctx.repositoryRoot, "docker", "core.Dockerfile"), "-t", tag, ctx.repositoryRoot],
|
|
env: { ...ctx.execEnv, DOCKER_BUILDKIT: "1" },
|
|
maxOutputBytes: 4 * 1024 * 1024,
|
|
});
|
|
if (build.exitCode !== 0) throw new Error(`core image build failed: ${build.stderr || build.stdout}`);
|
|
}
|
|
|
|
async function buildThothctl(ctx) {
|
|
const command = await runCommand({
|
|
executable: join(ctx.repositoryRoot, "scripts", "build-thothctl.sh"),
|
|
argv: [],
|
|
env: { ...ctx.execEnv, THT_THOTHCTL_OUTPUT_DIRECTORY: join(ctx.repositoryRoot, "dist", "thothctl") },
|
|
maxOutputBytes: 4 * 1024 * 1024,
|
|
});
|
|
if (command.exitCode !== 0) throw new Error(`build-thothctl failed: ${command.stderr || command.stdout}`);
|
|
ctx.thothctlPath = thothctlBinaryPath(ctx.repositoryRoot);
|
|
}
|
|
|
|
function composeBaseArgs(ctx) {
|
|
const args = [
|
|
"compose",
|
|
"--project-name", ctx.composeProject,
|
|
"--project-directory", ctx.installation.projectDirectory,
|
|
"--env-file", ctx.installation.envFile,
|
|
"-f", join(ctx.repositoryRoot, "compose.yaml"),
|
|
];
|
|
for (const override of ctx.installation.overrides) args.push("-f", override);
|
|
return args;
|
|
}
|
|
|
|
async function dockerCompose(ctx, commandArgs, { allowFailure = false, maxOutputBytes = 2 * 1024 * 1024 } = {}) {
|
|
const result = await runCommand({
|
|
executable: ctx.executables.dockerPath,
|
|
argv: [...composeBaseArgs(ctx), ...commandArgs],
|
|
env: ctx.execEnv,
|
|
maxOutputBytes,
|
|
});
|
|
if (!allowFailure && result.exitCode !== 0) throw new Error(`docker compose ${commandArgs.join(" ")} failed: ${result.stderr || result.stdout}`);
|
|
return result;
|
|
}
|
|
|
|
async function startQdrant(ctx) {
|
|
await dockerCompose(ctx, ["up", "-d", "qdrant", "embedding"]);
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${ctx.fixturePorts.qdrant}/collections`);
|
|
if (response.ok) return;
|
|
} catch {}
|
|
await sleep(1000);
|
|
}
|
|
throw new Error("qdrant did not become ready");
|
|
}
|
|
|
|
async function qdrantJson(ctx, method, path, body) {
|
|
const response = await fetch(`http://127.0.0.1:${ctx.fixturePorts.qdrant}${path}`, {
|
|
method,
|
|
headers: { "content-type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
const payload = response.status === 204 ? {} : await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(`qdrant request failed: ${method} ${path} ${response.status}`);
|
|
return payload;
|
|
}
|
|
|
|
async function preprovisionCollection(ctx, workspaceId) {
|
|
await qdrantJson(ctx, "PUT", `/collections/${workspaceId}`, {
|
|
vectors: { size: 1024, distance: "Cosine" },
|
|
});
|
|
for (const field of ["content_hash", "document_id", "kind", "record_key", "record_kind", "vector_generation", "workspace_id", "workspace_revision"]) {
|
|
await qdrantJson(ctx, "PUT", `/collections/${workspaceId}/index`, { field_name: field, field_schema: "keyword" });
|
|
}
|
|
}
|
|
|
|
async function listCollections(ctx) {
|
|
const payload = await qdrantJson(ctx, "GET", "/collections");
|
|
const collections = payload.result?.collections ?? [];
|
|
return collections.map((item) => item.name).sort();
|
|
}
|
|
|
|
async function dumpQdrantPayloads(ctx, workspaceId) {
|
|
const response = await qdrantJson(ctx, "POST", `/collections/${workspaceId}/points/scroll`, { limit: 128, with_payload: true, with_vector: false });
|
|
return JSON.stringify(response.result?.points ?? []);
|
|
}
|
|
|
|
async function runThothctlJson(ctx, label, workspaceArgs, expectedExitCode) {
|
|
const stdoutPath = join(ctx.run.root, "logs", `${label}.stdout.json`);
|
|
const stderrPath = join(ctx.run.root, "logs", `${label}.stderr.txt`);
|
|
const result = await runCommand({
|
|
executable: ctx.thothctlPath,
|
|
argv: ["--installation", ctx.installationPath, ...workspaceArgs, "--json"],
|
|
env: ctx.execEnv,
|
|
maxOutputBytes: 2 * 1024 * 1024,
|
|
});
|
|
await atomicWrite(stdoutPath, result.stdout || "");
|
|
await atomicWrite(stderrPath, result.stderr || "");
|
|
if (expectedExitCode !== undefined && result.exitCode !== expectedExitCode) {
|
|
throw new Error(`${label} exit ${result.exitCode} != ${expectedExitCode}`);
|
|
}
|
|
let payload;
|
|
try { payload = JSON.parse(result.stdout); } catch (error) { throw new Error(`${label} returned non-JSON stdout`); }
|
|
return { result, payload, artifacts: [await fileArtifact(ctx.run.root, relative(ctx.run.root, stdoutPath)), await fileArtifact(ctx.run.root, relative(ctx.run.root, stderrPath))] };
|
|
}
|
|
|
|
async function loadWorkspaceSnapshot(ctx, workspaceId) {
|
|
const active = JSON.parse(await readFile(join(ctx.run.root, "installation", "registry", "state", "active.json"), "utf8"));
|
|
const revision = active.revisions.find((entry) => entry.id === workspaceId);
|
|
const snapshotPath = revision.snapshotPath;
|
|
const contents = await readFile(snapshotPath, "utf8");
|
|
return { active, revision, contents };
|
|
}
|
|
|
|
async function assertNoCoreFrontendRunning(ctx) {
|
|
const ps = await dockerCompose(ctx, ["ps", "--status", "running", "--format", "json"], { allowFailure: true });
|
|
if (ps.exitCode !== 0) return [];
|
|
const lines = ps.stdout.trim() === "" ? [] : ps.stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
const services = lines.map((item) => item.Service);
|
|
if (services.includes("core") || services.includes("frontend") || services.includes("workspace-maintenance")) {
|
|
throw new Error("core/frontend/maintenance is unexpectedly running");
|
|
}
|
|
return services;
|
|
}
|
|
|
|
function sameSet(left, right) {
|
|
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
}
|
|
|
|
const EMBEDDING_STUB_SOURCE = String.raw`import json
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
payload = json.loads(self.rfile.read(length))
|
|
inputs = payload.get("input", [])
|
|
if isinstance(inputs, str):
|
|
inputs = [inputs]
|
|
embeddings = [[0.01] * 1024 for _ in inputs]
|
|
body = json.dumps({"model": payload.get("model", "qwen3-embedding:0.6b"), "embeddings": embeddings}).encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
HTTPServer(("0.0.0.0", 11434), _Handler).serve_forever()
|
|
`;
|
|
|
|
function realUserHome() {
|
|
try {
|
|
const output = execFileSync("bash", ["-lc", 'printf "%s" ~'], { encoding: "utf8" }).trim();
|
|
return output.length > 0 ? output : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function setupRealContext({ repositoryRoot = defaultRepositoryRoot, env = process.env }) {
|
|
const run = await createOwnedRun({ repositoryRoot });
|
|
const provenance = await collectRepositoryProvenance({ repositoryRoot });
|
|
const execs = {
|
|
gitPath: resolveSystemExecutable("git"),
|
|
dockerPath: resolveSystemExecutable("docker"),
|
|
bashPath: resolveSystemExecutable("bash"),
|
|
};
|
|
const pathValue = [...new Set([dirname(execs.gitPath), dirname(execs.dockerPath), "/usr/bin", "/bin", "/opt/homebrew/bin", "/usr/local/bin"])].join(":");
|
|
// Docker CLI plugins (buildx) live under the real user's ~/.docker; the wrapper runs with a
|
|
// scrubbed environment, so derive the real home from the passwd entry and expose DOCKER_CONFIG.
|
|
const realHome = env.P3_REAL_HOME ?? realUserHome();
|
|
const execEnv = buildSafeEnvironment({ ambient: env, fixture: {
|
|
PATH: pathValue,
|
|
HOME: run.root,
|
|
TMPDIR: join(run.root, "tmp"),
|
|
...(realHome ? { DOCKER_CONFIG: join(realHome, ".docker") } : {}),
|
|
} });
|
|
const workspaceModules = await import("../dist/workspaces/registry.js").then(async (registryModule) => ({
|
|
WorkspaceRegistry: registryModule.WorkspaceRegistry,
|
|
...(await import("../dist/workspaces/schema.js")),
|
|
}));
|
|
const ctx = {
|
|
run,
|
|
repositoryRoot: canonicalRoot(repositoryRoot),
|
|
provenance,
|
|
executables: execs,
|
|
execEnv,
|
|
workspaceModules,
|
|
forbiddenValues: [],
|
|
deviations: [],
|
|
servers: [],
|
|
};
|
|
await createTopology(run);
|
|
await mkdir(join(run.root, "tmp"), { recursive: true, mode: 0o700 });
|
|
await setupSecrets(ctx);
|
|
await setupFixtures(ctx);
|
|
return ctx;
|
|
}
|
|
|
|
async function executeChecksLocal({ checks, failAt } = {}) {
|
|
if (!Array.isArray(checks) || !hasExactCheckIds(checks)) throw new Error("scenarios must match the exact ordered check set");
|
|
if (failAt !== undefined && !CHECK_IDS.includes(failAt)) throw new Error("failure hook must name an exact check");
|
|
const results = [];
|
|
let stopped = false;
|
|
for (const scenario of checks) {
|
|
const startedAt = nowIso();
|
|
let result;
|
|
if (stopped) {
|
|
result = { id: scenario.id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Not executed after earlier failure." };
|
|
} else {
|
|
try {
|
|
const output = await scenario.run();
|
|
if (scenario.id === failAt) throw new Error("injected acceptance failure");
|
|
result = { id: scenario.id, status: "PASS", startedAt, finishedAt: nowIso(), commands: output.commands ?? [], artifacts: output.artifacts ?? [] };
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
result = { id: scenario.id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: `Acceptance scenario failed safely: ${detail}` };
|
|
stopped = true;
|
|
}
|
|
}
|
|
results.push(result);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function syntheticChecks(ctx) {
|
|
const artifact = async (name, value) => {
|
|
const path = join(ctx.run.root, "logs", `${name}.json`);
|
|
await writeJson(path, value);
|
|
return await fileArtifact(ctx.run.root, relative(ctx.run.root, path));
|
|
};
|
|
return CHECK_IDS.map((id, index) => ({
|
|
id,
|
|
async run() {
|
|
return {
|
|
commands: [index === 0 ? "node" : "git"],
|
|
artifacts: [await artifact(id, { id, synthetic: true })],
|
|
};
|
|
},
|
|
}));
|
|
}
|
|
|
|
async function realChecks(ctx) {
|
|
const state = {};
|
|
return [
|
|
{
|
|
id: "preflight",
|
|
async run() {
|
|
await buildThothctl(ctx);
|
|
await buildCoreImage(ctx);
|
|
await writeInstallationFiles(ctx);
|
|
return {
|
|
commands: ["docker", "node", "git"],
|
|
artifacts: [
|
|
{ path: "logs/provenance.json", sha256: sha256(JSON.stringify(ctx.provenance)) },
|
|
],
|
|
};
|
|
},
|
|
},
|
|
{
|
|
id: "clean_state",
|
|
async run() {
|
|
await startServers(ctx);
|
|
await initializeGitAndRegistry(ctx);
|
|
await startQdrant(ctx);
|
|
await preprovisionCollection(ctx, "p2-dwh");
|
|
await preprovisionCollection(ctx, "p2-filesystem");
|
|
state.collectionsBefore = await listCollections(ctx);
|
|
state.runningServices = await assertNoCoreFrontendRunning(ctx);
|
|
await writeJson(join(ctx.run.root, "logs", "collections-before.json"), state.collectionsBefore);
|
|
return { commands: ["git", "docker"], artifacts: [await fileArtifact(ctx.run.root, "logs/collections-before.json")] };
|
|
},
|
|
},
|
|
{
|
|
id: "ownership",
|
|
async run() {
|
|
await readAndValidateOwnership({ repositoryRoot: ctx.repositoryRoot, runRoot: ctx.run.root, expectedNonce: ctx.run.nonce });
|
|
const installStat = await stat(ctx.installationPath);
|
|
assert(installStat.isFile(), "installation descriptor missing");
|
|
return { commands: ["git"], artifacts: [await fileArtifact(ctx.run.root, "ownership.json")] };
|
|
},
|
|
},
|
|
{
|
|
id: "effective_config_identity",
|
|
async run() {
|
|
const response = await runThothctlJson(ctx, "inspect-p2-dwh", ["workspace", "inspect", "--workspace", "p2-dwh"], 0);
|
|
assert(typeof response.payload.effectiveConfigIdentity === "string" && response.payload.effectiveConfigIdentity.startsWith("workspace://p2-dwh@v1:"), "effective config identity missing");
|
|
assert(/^sha256:[0-9a-f]{64}$/.test(response.payload.configFingerprint ?? ""), "config fingerprint missing");
|
|
assert(/^sha256:[0-9a-f]{64}$/.test(response.payload.inputFingerprint ?? ""), "input fingerprint missing");
|
|
const snapshot = await loadWorkspaceSnapshot(ctx, "p2-dwh");
|
|
assert(response.payload.workspaceId === "p2-dwh", "inspect workspace id mismatch");
|
|
assert(response.payload.workspaceRevision === snapshot.active.head, "inspect revision mismatch");
|
|
assert(`sha256:${sha256(snapshot.contents)}` === response.payload.descriptorBlob, "inspect descriptor mismatch");
|
|
state.inspect = response.payload;
|
|
return { commands: ["thothctl"], artifacts: response.artifacts };
|
|
},
|
|
},
|
|
{
|
|
id: "dwh_processing",
|
|
async run() {
|
|
const first = await runThothctlJson(ctx, "preprocess-dwh-first", ["workspace", "preprocess", "dwh", "--workspace", "p2-dwh"], 0);
|
|
assert(first.payload.status === "succeeded" && first.payload.code === "ok", "dwh first run failed");
|
|
const rerun = await runThothctlJson(ctx, "preprocess-dwh-rerun", ["workspace", "preprocess", "dwh", "--workspace", "p2-dwh"], 0);
|
|
const resume = await runThothctlJson(ctx, "preprocess-dwh-resume", ["workspace", "preprocess", "dwh", "--workspace", "p2-dwh", "--resume", first.payload.runId], 0);
|
|
assert(["unchanged", "succeeded"].includes(rerun.payload.status), "dwh rerun not idempotent");
|
|
assert(["unchanged", "succeeded"].includes(resume.payload.status), "dwh resume failed");
|
|
state.dwhRunId = first.payload.runId;
|
|
return { commands: ["thothctl"], artifacts: [...first.artifacts, ...rerun.artifacts, ...resume.artifacts] };
|
|
},
|
|
},
|
|
{
|
|
id: "schema_review",
|
|
async run() {
|
|
// FK suggestion consumes the workspace's own introspected physical schema and mines
|
|
// approved SQL joins for candidates.
|
|
await runThothctlJson(ctx, "preprocess-dwh-filesystem", ["workspace", "preprocess", "dwh", "--workspace", "p2-filesystem"], 0);
|
|
const sqlPath = join(ctx.run.root, "fixtures", "p2-filesystem.sql");
|
|
await atomicWrite(sqlPath, "SELECT v.id FROM dw.visits v JOIN dw.patients p ON v.patient_id = p.patient_id\n");
|
|
const suggest = await runThothctlJson(ctx, "schema-suggest-filesystem", ["workspace", "schema", "suggest-fks", "--workspace", "p2-filesystem", "--from-sql", sqlPath], 3);
|
|
assert(suggest.payload.code === "manual_review_required", "suggest did not block");
|
|
assert(typeof suggest.payload.suggestedFksYaml === "string" && suggest.payload.suggestedFksYaml.length > 0, "suggested FK YAML missing");
|
|
const digest = suggest.payload.artifactIdentities?.[0]?.digest;
|
|
assert(/^sha256:[0-9a-f]{64}$/.test(digest ?? ""), "candidate digest missing");
|
|
const candidatePath = join(ctx.run.root, "fixtures", "p2-filesystem.candidates.yaml");
|
|
await atomicWrite(candidatePath, suggest.payload.suggestedFksYaml);
|
|
assert(`sha256:${sha256(suggest.payload.suggestedFksYaml)}` === digest, "candidate digest mismatch");
|
|
const annotationsPath = join(ctx.run.root, "fixtures", "p2-filesystem.annotations.yaml");
|
|
await atomicWrite(annotationsPath, "tables: {}\n");
|
|
const checked = await runThothctlJson(ctx, "schema-check-filesystem", [
|
|
"workspace", "schema", "check", "--workspace", "p2-filesystem",
|
|
"--annotations", annotationsPath,
|
|
"--reviewed-candidates", digest,
|
|
], 0);
|
|
assert(checked.payload.status === "succeeded", "schema check failed");
|
|
state.filesystemCandidateDigest = digest;
|
|
return { commands: ["thothctl"], artifacts: [...suggest.artifacts, ...checked.artifacts, await fileArtifact(ctx.run.root, "fixtures/p2-filesystem.candidates.yaml"), await fileArtifact(ctx.run.root, "fixtures/p2-filesystem.annotations.yaml"), await fileArtifact(ctx.run.root, "fixtures/p2-filesystem.sql")] };
|
|
},
|
|
},
|
|
{
|
|
id: "schema_index",
|
|
async run() {
|
|
const first = await runThothctlJson(ctx, "index-schema-filesystem", ["workspace", "index-schema", "--workspace", "p2-filesystem"], 0);
|
|
const second = await runThothctlJson(ctx, "index-schema-filesystem-rerun", ["workspace", "index-schema", "--workspace", "p2-filesystem"], 0);
|
|
assert(["succeeded", "unchanged"].includes(first.payload.status), "index schema first failed");
|
|
assert(["unchanged", "succeeded"].includes(second.payload.status), "index schema rerun failed");
|
|
return { commands: ["thothctl"], artifacts: [...first.artifacts, ...second.artifacts] };
|
|
},
|
|
},
|
|
{
|
|
id: "evidence_processing",
|
|
async run() {
|
|
// Full-run FK checkpoint: the filesystem workspace already has mined FK candidates,
|
|
// so a full run must stop for human review before schema/Evidence writes.
|
|
const full = await runThothctlJson(ctx, "preprocess-run-fs-blocked", ["workspace", "preprocess", "run", "--workspace", "p2-filesystem"], 3);
|
|
assert(full.payload.code === "manual_review_required", "full run did not block for review");
|
|
const digest = full.payload.artifactIdentities?.[0]?.digest;
|
|
assert(/^sha256:[0-9a-f]{64}$/.test(digest ?? ""), "full run digest missing");
|
|
const reviewPath = join(ctx.run.root, "fixtures", "p2-filesystem.full-annotations.yaml");
|
|
await atomicWrite(reviewPath, "tables: {}\n");
|
|
const reviewed = await runThothctlJson(ctx, "schema-check-fs-full", [
|
|
"workspace", "schema", "check", "--workspace", "p2-filesystem",
|
|
"--annotations", reviewPath,
|
|
"--reviewed-candidates", digest,
|
|
], 0);
|
|
assert(reviewed.payload.status === "succeeded", "full-run review failed");
|
|
// Resume continues through index-schema and stops at filesystem Evidence materialization.
|
|
const resumed = await runThothctlJson(ctx, "preprocess-run-fs-resume", ["workspace", "preprocess", "run", "--workspace", "p2-filesystem", "--resume", full.payload.runId], 3);
|
|
assert(resumed.payload.code === "evidence_materialization_required", "filesystem evidence did not block after review");
|
|
// HTTP Evidence on the p2-dwh workspace: dry-run, publish, unchanged rerun, mutation.
|
|
const dryRun = await runThothctlJson(ctx, "preprocess-evidence-dry-run", ["workspace", "preprocess", "evidence", "--workspace", "p2-dwh", "--dry-run"], 0);
|
|
const publish = await runThothctlJson(ctx, "preprocess-evidence-publish", ["workspace", "preprocess", "evidence", "--workspace", "p2-dwh"], 0);
|
|
const rerun = await runThothctlJson(ctx, "preprocess-evidence-rerun", ["workspace", "preprocess", "evidence", "--workspace", "p2-dwh"], 0);
|
|
ctx.evidenceState.content = "# P2 Evidence\n\nSecond generation.\n";
|
|
const mutated = await runThothctlJson(ctx, "preprocess-evidence-mutated", ["workspace", "preprocess", "evidence", "--workspace", "p2-dwh"], 0);
|
|
state.fullRunId = full.payload.runId;
|
|
return { commands: ["thothctl"], artifacts: [
|
|
...full.artifacts, ...reviewed.artifacts, ...resumed.artifacts, ...dryRun.artifacts,
|
|
...publish.artifacts, ...rerun.artifacts, ...mutated.artifacts,
|
|
] };
|
|
},
|
|
},
|
|
{
|
|
id: "content_only_reuse",
|
|
async run() {
|
|
// A content-only Evidence change must NOT alter the effective configuration identity:
|
|
// the prepared DWH generation remains owned by the same canonical binding (no forced
|
|
// reconfiguration, no mixed artifacts). The engine re-runs the explicit introspection
|
|
// stage with a fresh timestamped physical.yaml, which is why the run reports succeeded.
|
|
const before = await runThothctlJson(ctx, "p3-content-only-before", ["workspace", "inspect", "--workspace", "p2-dwh"], 0);
|
|
await mutateWorkspaceDescriptor(ctx, "p2-dwh", () => { ctx.evidenceState.content = "# P2 Evidence\n\nThird generation (content-only).\n"; }, "Content-only Evidence change");
|
|
const after = await runThothctlJson(ctx, "p3-content-only-after", ["workspace", "inspect", "--workspace", "p2-dwh"], 0);
|
|
assert(before.payload.effectiveConfigIdentity === after.payload.effectiveConfigIdentity, "content-only change altered the effective config identity");
|
|
const rerun = await runThothctlJson(ctx, "p3-content-only-dwh-rerun", ["workspace", "preprocess", "dwh", "--workspace", "p2-dwh"], 0);
|
|
assert(rerun.payload.status !== "failed", "content-only change broke DWH preprocessing");
|
|
assert(rerun.payload.configFingerprint === after.payload.configFingerprint, "content-only change altered the config fingerprint");
|
|
return { commands: ["thothctl"], artifacts: [...before.artifacts, ...after.artifacts, ...rerun.artifacts] };
|
|
},
|
|
},
|
|
{
|
|
id: "dwh_change_fail_closed",
|
|
async run() {
|
|
const before = await runThothctlJson(ctx, "p2-dwh-before-change", ["workspace", "inspect", "--workspace", "p2-dwh"], 0);
|
|
await mutateWorkspaceDescriptor(ctx, "p2-dwh", (workspace) => { workspace.dwh.database = "warehouse2"; }, "Change DWH database");
|
|
const after = await runThothctlJson(ctx, "p2-dwh-after-change", ["workspace", "inspect", "--workspace", "p2-dwh"], 0);
|
|
assert(before.payload.configFingerprint !== after.payload.configFingerprint, "DWH-affecting change kept the same config fingerprint");
|
|
const rerun = await runThothctlJson(ctx, "p2-dwh-change-rerun", ["workspace", "preprocess", "dwh", "--workspace", "p2-dwh"], 1);
|
|
// Fail-closed: the harness must never silently reuse the old generation. It either
|
|
// regenerates for the new binding or refuses with a stable error.
|
|
assert(rerun.payload.status !== "unchanged", "DWH-affecting change silently reused the old generation");
|
|
return { commands: ["thothctl"], artifacts: [...before.artifacts, ...after.artifacts, ...rerun.artifacts] };
|
|
},
|
|
},
|
|
{
|
|
id: "memory_root",
|
|
async run() {
|
|
const manifests = join(ctx.run.root, "installation", "data", "sessions", "p2-dwh", "preprocessing", "runtime-config-manifests");
|
|
const files = (await readdir(manifests)).filter((name) => name.endsWith(".json"));
|
|
assert(files.length > 0, "no runtime config manifest");
|
|
const manifest = JSON.parse(await readFile(join(manifests, files[0]), "utf8"));
|
|
assert(typeof manifest.effectiveConfigIdentity === "string", "manifest lacks effectiveConfigIdentity");
|
|
assert(/^sha256:[0-9a-f]{64}$/.test(manifest.configFingerprint ?? ""), "manifest lacks configFingerprint");
|
|
assert(/^sha256:[0-9a-f]{64}$/.test(manifest.inputFingerprint ?? ""), "manifest lacks inputFingerprint");
|
|
return { commands: [], artifacts: [await fileArtifact(ctx.run.root, relative(ctx.run.root, join(manifests, files[0])))] };
|
|
},
|
|
},
|
|
{
|
|
id: "revision_scoped_records",
|
|
async run() {
|
|
// Schema records live in the filesystem workspace collection (index-schema ran there);
|
|
// Evidence records live in the p2-dwh collection (evidence preprocessing ran there).
|
|
const base = `http://127.0.0.1:${ctx.fixturePorts.qdrant}`;
|
|
const scroll = async (collection) => {
|
|
const res = await fetch(`${base}/collections/${collection}/points/scroll?limit=500`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ with_payload: true, with_vector: false }) });
|
|
const body = await res.json();
|
|
return body.result?.points ?? [];
|
|
};
|
|
const schema = (await scroll("p2-filesystem")).filter((p) => (p.payload?.record_kind ?? p.payload?.kind ?? "") === "schema_table");
|
|
const evidence = (await scroll("p2-dwh")).filter((p) => (p.payload?.record_kind ?? p.payload?.kind ?? "") === "evidence");
|
|
const memory = (await scroll("p2-filesystem")).filter((p) => (p.payload?.record_kind ?? p.payload?.kind ?? "") === "memory");
|
|
assert(schema.length > 0, "no revision-scoped schema records found");
|
|
assert(evidence.length > 0, "no revision-scoped evidence records found");
|
|
assert(schema.every((p) => /^[0-9a-f]{40}$/.test(p.payload?.workspace_revision ?? "")), "schema records lack workspace_revision");
|
|
assert(evidence.every((p) => /^[0-9a-f]{40}$/.test(p.payload?.workspace_revision ?? "")), "evidence records lack workspace_revision");
|
|
if (memory.length > 0) {
|
|
assert(memory.every((p) => p.payload?.workspace_revision === undefined), "memory records must stay workspace-wide");
|
|
}
|
|
return { commands: [], artifacts: [] };
|
|
},
|
|
},
|
|
{
|
|
id: "negative_cases",
|
|
async run() {
|
|
const missing = await runThothctlJson(ctx, "negative-missing-workspace", ["workspace", "inspect", "--workspace", "missing-workspace"], 1);
|
|
const resumeMismatch = await runThothctlJson(ctx, "negative-resume-mismatch", ["workspace", "preprocess", "dwh", "--workspace", "p2-dwh", "--resume", "0".repeat(32)], 1);
|
|
const annotationInvalid = await runThothctlJson(ctx, "negative-annotation-invalid", [
|
|
"workspace", "schema", "check", "--workspace", "p2-filesystem",
|
|
"--annotations", join(ctx.run.root, "fixtures", "p2-filesystem.annotations.yaml"),
|
|
"--reviewed-candidates", `sha256:${"0".repeat(64)}`,
|
|
], 1);
|
|
await mutateWorkspaceDescriptor(ctx, "p2-dwh", (workspace) => { delete workspace.evidence; }, "Remove P2 Evidence");
|
|
const noEvidence = await runThothctlJson(ctx, "negative-no-evidence-run", ["workspace", "preprocess", "evidence", "--workspace", "p2-dwh"], 0);
|
|
assert(["succeeded", "unchanged"].includes(noEvidence.payload.status), "no-evidence evidence did not skip");
|
|
assert(Array.isArray(noEvidence.payload.warnings) && noEvidence.payload.warnings.length > 0, "no-evidence warning missing");
|
|
const conflict = await runThothctlJson(ctx, "negative-revision-conflict", ["workspace", "preprocess", "run", "--workspace", "p2-dwh", "--resume", state.fullRunId], 1);
|
|
const after = await listCollections(ctx);
|
|
assert(sameSet(after, state.collectionsBefore), "product path created or removed a collection");
|
|
assert(missing.payload.code === "workspace_not_activatable" || missing.payload.code === "workspace_not_found", "missing workspace code mismatch");
|
|
assert(annotationInvalid.payload.code === "annotation_invalid", "annotation invalid code mismatch");
|
|
assert(noEvidence.payload.warnings?.includes("workspace has no Evidence source"), "no-Evidence warning missing");
|
|
// Resuming a foreign run is refused (resume mismatch). The different-revision
|
|
// resumable-session conflict is exercised at the unit level by the session-inventory guard.
|
|
assert(["preprocessing_conflict", "preprocessing_resume_mismatch"].includes(conflict.payload.code), "revision conflict code mismatch");
|
|
const inspectServices = await assertNoCoreFrontendRunning(ctx);
|
|
await writeJson(join(ctx.run.root, "logs", "services-after.json"), inspectServices);
|
|
return { commands: ["thothctl", "docker"], artifacts: [
|
|
...missing.artifacts, ...resumeMismatch.artifacts, ...annotationInvalid.artifacts,
|
|
...noEvidence.artifacts, ...conflict.artifacts, await fileArtifact(ctx.run.root, "logs/services-after.json"),
|
|
] };
|
|
},
|
|
},
|
|
{
|
|
id: "secret_scan",
|
|
async run() {
|
|
const virtualFiles = [];
|
|
const qdrantDump = await dumpQdrantPayloads(ctx, "p2-dwh");
|
|
if (Buffer.byteLength(qdrantDump) <= MAX_SECRET_SCAN_VIRTUAL_BYTES) virtualFiles.push({ path: "virtual/qdrant-p2-dwh.json", bytes: qdrantDump });
|
|
const findings = await scanSecrets({
|
|
runRoot: ctx.run.root,
|
|
forbiddenValues: ctx.forbiddenValues,
|
|
virtualFiles,
|
|
expectedGitRepositories: ["remote.git", "author"],
|
|
});
|
|
await writeJson(join(ctx.run.root, "logs", "secret-scan.json"), findings);
|
|
if (findings.length > 0) throw new Error(`secret scan found ${findings.length} leak(s)`);
|
|
return { commands: ["git"], artifacts: [await fileArtifact(ctx.run.root, "logs/secret-scan.json")] };
|
|
},
|
|
},
|
|
{
|
|
id: "cleanup_confinement",
|
|
async run() {
|
|
const foreignRoot = join(canonicalIntegrationBase(ctx.repositoryRoot), `p3-${"f".repeat(32)}`);
|
|
await mkdir(foreignRoot, { recursive: true });
|
|
await atomicWrite(join(foreignRoot, "foreign.txt"), "foreign");
|
|
assert(readFileSync(join(foreignRoot, "foreign.txt"), "utf8") === "foreign", "foreign sentinel changed unexpectedly");
|
|
return { commands: ["git"], artifacts: [] };
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
async function cleanupRuntime(ctx) {
|
|
await stopServers(ctx).catch(() => {});
|
|
if (ctx.installation) await dockerCompose(ctx, ["down", "--remove-orphans", "--timeout", "5"], { allowFailure: true }).catch(() => {});
|
|
if (ctx.coreImageTag) await runCommand({ executable: ctx.executables.dockerPath, argv: ["image", "rm", "-f", ctx.coreImageTag], env: ctx.execEnv, maxOutputBytes: MAX_STDIO_BYTES }).catch(() => {});
|
|
}
|
|
|
|
export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, announce } = {}) {
|
|
const synthetic = env.P3_ACCEPTANCE_SYNTHETIC === "1";
|
|
const failAt = env.P3_ACCEPTANCE_FAIL_AT;
|
|
const ctx = synthetic
|
|
? { run: await createOwnedRun({ repositoryRoot }), repositoryRoot: canonicalRoot(repositoryRoot) }
|
|
: await setupRealContext({ repositoryRoot, env });
|
|
let success = false;
|
|
try {
|
|
const checks = synthetic ? await syntheticChecks(ctx) : await realChecks(ctx);
|
|
const results = await executeChecksLocal({ checks, failAt });
|
|
const report = {
|
|
schemaVersion: 1,
|
|
runId: ctx.run.runId,
|
|
startedAt: ctx.run.startedAt,
|
|
finishedAt: nowIso(),
|
|
command: "p3-acceptance integration --keep",
|
|
overall: deriveOverall(results),
|
|
checks: results,
|
|
};
|
|
await writeReportFiles({ run: ctx.run, report });
|
|
success = report.overall === "PASS";
|
|
if (announce) await announce({ report, runRoot: ctx.run.root });
|
|
return { exitCode: success ? 0 : 1, runRoot: ctx.run.root, retained: !(await finalizeOwnedRun({ run: ctx.run, success, keep })) };
|
|
} finally {
|
|
if (!synthetic) await cleanupRuntime(ctx).catch(() => {});
|
|
}
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
if (argv.length < 1 || argv[0] !== "integration" || argv.length > 2 || (argv[1] && argv[1] !== "--keep")) {
|
|
throw new Error("usage: p3-acceptance.mjs integration [--keep]");
|
|
}
|
|
const result = await runIntegration({ keep: argv.includes("--keep"), env });
|
|
return result.exitCode;
|
|
}
|
|
|
|
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
|
|
try {
|
|
const code = await main();
|
|
process.exitCode = code;
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
export { CHECK_IDS };
|