diff --git a/backend/scripts/p5-acceptance.mjs b/backend/scripts/p5-acceptance.mjs new file mode 100644 index 00000000..eb601db5 --- /dev/null +++ b/backend/scripts/p5-acceptance.mjs @@ -0,0 +1,1364 @@ +#!/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 = /^p5-[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", + "activation_sync", + "accept_happy_path", + "revision_isolation", + "accept_negatives", + "continuation_gate", + "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", "p5-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: "p5-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 ?? `p5-${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 !== "p5-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, + "", + `P5 automated integration: ${report.overall}`, + "P5 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/p5-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: 3, id, name: `P2 ${id}`, language: "en" }, + dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] }, + semantic_index: { + vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" }, + embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 }, + }, + diagnostics: { + dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, + ...(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.curatedAnnotations = { + "p2-dwh": "tables: {}\n", + "p2-filesystem": "tables: {}\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", "p5-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); + } + for (const [id, workspace] of Object.entries(ctx.workspaceObjects)) { + const pathId = workspace.workspace.id; + await mkdir(join(author, pathId, "schema"), { recursive: true }); + await writeFile(join(author, pathId, "schema", "annotations.yaml"), ctx.curatedAnnotations[pathId]); + } + 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: "p5-acceptance@example.invalid", + installationId: "p5-acceptance", + secretRoots: [join(ctx.run.root, "fixture-secrets")], + maxImportBytes: 16 * 1024 * 1024, + maxImportEntries: 1024, + dataRoot: join(ctx.run.root, "installation", "data"), + }); + 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 mutateWorkspaceAnnotations(ctx, workspaceId, contents, commitMessage) { + const author = join(ctx.run.root, "author"); + await git(ctx, ["fetch", "origin", "main"], author); + await git(ctx, ["reset", "--hard", "origin/main"], author); + const annotationsPath = join(author, workspaceId, "schema", "annotations.yaml"); + await mkdir(dirname(annotationsPath), { recursive: true }); + await writeFile(annotationsPath, contents); + ctx.curatedAnnotations[workspaceId] = contents; + await git(ctx, ["add", `${workspaceId}/schema/annotations.yaml`], 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=p5-acceptance@example.invalid`, + `THT_WORKSPACE_INSTALLATION_ID=p5-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 runThothctlRaw(ctx, label, workspaceArgs) { + const stdoutPath = join(ctx.run.root, "logs", `${label}.stdout.txt`); + const stderrPath = join(ctx.run.root, "logs", `${label}.stderr.txt`); + const result = await runCommand({ + executable: ctx.thothctlPath, + argv: ["--installation", ctx.installationPath, ...workspaceArgs], + env: ctx.execEnv, + maxOutputBytes: 2 * 1024 * 1024, + }); + await atomicWrite(stdoutPath, result.stdout || ""); + await atomicWrite(stderrPath, result.stderr || ""); + return { + result, + 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.P5_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 = {}; + const syncedRoot = (workspaceId, commit) => join(ctx.run.root, "installation", "data", "sessions", workspaceId, "revisions", commit, "artifacts"); + const syncedAnnotations = (workspaceId, commit) => join(syncedRoot(workspaceId, commit), "mschema", "annotations.yaml"); + const syncedManifest = (workspaceId, commit) => join(syncedRoot(workspaceId, commit), "mschema", "annotations.ownership.json"); + const activeCommit = async (workspaceId) => (await loadWorkspaceSnapshot(ctx, workspaceId)).revision.commit; + + 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); + state.initialCommit = await activeCommit("p2-filesystem"); + 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: "activation_sync", + async run() { + // The initial curated blob must have been synchronized on activation with a verified + // ownership manifest at the exact revision-qualified runtime root. + const commit = state.initialCommit; + const annotationsPath = syncedAnnotations("p2-filesystem", commit); + const manifestPath = syncedManifest("p2-filesystem", commit); + assert(readFileSync(annotationsPath, "utf8") === "tables: {}\n", "synced annotations content mismatch"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + assert(manifest.workspace === "p2-filesystem", "ownership manifest workspace mismatch"); + assert(manifest.commit === commit, "ownership manifest commit mismatch"); + assert(/^[0-9a-f]{40}$/.test(manifest.blobId ?? ""), "ownership manifest blobId missing"); + assert(manifest.contentDigest === `sha256:${sha256("tables: {}\n")}`, "ownership manifest digest mismatch"); + assert(manifest.destination === annotationsPath, "ownership manifest destination mismatch"); + return { commands: [], artifacts: [await fileArtifact(ctx.run.root, relative(ctx.run.root, annotationsPath)), await fileArtifact(ctx.run.root, relative(ctx.run.root, manifestPath))] }; + }, + }, + { + id: "accept_happy_path", + async run() { + // 1) introspect the physical schema, then mine FK candidates from SQL. + 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"); + assert(`sha256:${sha256(suggest.payload.suggestedFksYaml)}` === digest, "candidate digest mismatch"); + state.runId = suggest.payload.runId; + assert(/^[0-9a-f]{32}$/.test(state.runId ?? ""), "suggest run id missing"); + + // 2) The curator's reviewed blob is already the committed empty set (no approved FKs); the + // operator records the human review with the explicit accept command. + const accepted = await runThothctlJson(ctx, "schema-accept-filesystem", ["workspace", "schema", "accept", "--workspace", "p2-filesystem", "--run", state.runId, "--yes"], 0); + assert(accepted.payload.status === "succeeded" && accepted.payload.code === "ok", "schema accept failed"); + assert(Array.isArray(accepted.payload.artifactIdentities), "accept artifact identities missing"); + + // 3) Same-revision resume continues through the FK gate and stops at filesystem Evidence. + const resumed = await runThothctlJson(ctx, "preprocess-run-fs-resume", ["workspace", "preprocess", "run", "--workspace", "p2-filesystem", "--resume", state.runId], 3); + assert(resumed.payload.code === "evidence_materialization_required", "resume did not pass the FK gate and block on filesystem Evidence"); + state.acceptedCommit = state.initialCommit; + return { commands: ["thothctl"], artifacts: [...suggest.artifacts, ...accepted.artifacts, ...resumed.artifacts, await fileArtifact(ctx.run.root, "fixtures/p2-filesystem.sql")] }; + }, + }, + { + id: "revision_isolation", + async run() { + // A new annotations revision writes a distinct immutable runtime root. + await mutateWorkspaceAnnotations(ctx, "p2-filesystem", "tables: {}\n# revision B\n", "Curate reviewed FK annotations (revision B)"); + const revisionB = await activeCommit("p2-filesystem"); + assert(revisionB !== state.initialCommit, "annotations commit did not change the revision"); + assert(existsSync(syncedAnnotations("p2-filesystem", revisionB)), "revision B annotations not synced"); + assert(syncedAnnotations("p2-filesystem", revisionB) !== syncedAnnotations("p2-filesystem", state.initialCommit), "revision roots are not isolated"); + state.revisionB = revisionB; + return { commands: ["git"], artifacts: [await fileArtifact(ctx.run.root, relative(ctx.run.root, syncedAnnotations("p2-filesystem", revisionB)))] }; + }, + }, + { + id: "accept_negatives", + async run() { + // Grammar: --yes is required (exit 2, host-side, no JSON result). + const missingYes = await runThothctlRaw(ctx, "neg-missing-yes", ["workspace", "schema", "accept", "--workspace", "p2-filesystem", "--run", state.runId]); + assert(missingYes.result.exitCode === 2, `missing --yes exit ${missingYes.result.exitCode} != 2`); + + // Unknown run fails closed without recording a review. + const unknown = await runThothctlJson(ctx, "neg-unknown-run", ["workspace", "schema", "accept", "--workspace", "p2-filesystem", "--run", "e".repeat(32), "--yes"], 1); + assert(unknown.payload.code === "annotation_invalid", "unknown run code mismatch"); + + return { commands: ["thothctl"], artifacts: [...missingYes.artifacts, ...unknown.artifacts] }; + }, + }, + { + id: "continuation_gate", + async run() { + // A run accepted at revision A must not be silently resumed after the annotations revision + // changed to B: the pinned job no longer matches the active revision. + const stale = await runThothctlJson(ctx, "stale-resume-after-revision-change", ["workspace", "preprocess", "run", "--workspace", "p2-filesystem", "--resume", state.runId], 1); + assert(["preprocessing_resume_mismatch", "preprocessing_conflict"].includes(stale.payload.code), `stale resume code mismatch: ${stale.payload.code}`); + return { commands: ["thothctl"], artifacts: [...stale.artifacts] }; + }, + }, + { + id: "secret_scan", + async run() { + const virtualFiles = []; + const qdrantDump = await dumpQdrantPayloads(ctx, "p2-filesystem"); + if (Buffer.byteLength(qdrantDump) <= MAX_SECRET_SCAN_VIRTUAL_BYTES) virtualFiles.push({ path: "virtual/qdrant-p2-filesystem.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), `p5-${"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.P5_ACCEPTANCE_SYNTHETIC === "1"; + const failAt = env.P5_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: "p5-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: p5-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 }; diff --git a/backend/scripts/p5-acceptance.test.mjs b/backend/scripts/p5-acceptance.test.mjs new file mode 100644 index 00000000..93959c74 --- /dev/null +++ b/backend/scripts/p5-acceptance.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + CHECK_IDS, + canonicalIntegrationBase, + cleanupOwnedRun, + createOwnedRun, + readAndValidateOwnership, + runIntegration, + validateReport, + validateRunRoot, +} from "./p5-acceptance.mjs"; + +const roots = []; +async function fakeRepository() { + const root = await mkdtemp(join(tmpdir(), "p5-acceptance-repo-")); + roots.push(root); + await mkdir(join(root, ".artifacts", "p5-integration"), { recursive: true }); + await mkdir(join(root, ".artifacts", "p2-integration"), { recursive: true }); + await mkdir(join(root, ".artifacts", "p1-integration"), { recursive: true }); + await mkdir(join(root, ".artifacts", "manual-acceptance", "p11"), { recursive: true }); + return root; +} + +test.afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test("run roots are only canonical direct p5 integration children", async () => { + const repositoryRoot = await fakeRepository(); + const base = canonicalIntegrationBase(repositoryRoot); + const id = `p5-${"a".repeat(32)}`; + assert.equal(validateRunRoot(repositoryRoot, join(base, id), id), join(base, id)); + for (const candidate of [ + base, + join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"), + join(repositoryRoot, ".artifacts", "p1-integration", id), + join(repositoryRoot, ".artifacts", "p2-integration", id), + join(base, id, "nested"), + join(base, "foreign"), + ]) { + assert.throws(() => validateRunRoot(repositoryRoot, candidate, id)); + } + assert.throws(() => validateRunRoot(repositoryRoot, join(base, `p5-${"A".repeat(32)}`), `p5-${"A".repeat(32)}`)); +}); + +test("cleanup refuses p1, p2, p11, manual, sibling, and wrong-nonce roots", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + for (const bad of [ + join(repositoryRoot, ".artifacts", "p1-integration", `p1-${"b".repeat(32)}`), + join(repositoryRoot, ".artifacts", "p2-integration", `p2-${"c".repeat(32)}`), + join(repositoryRoot, ".artifacts", "manual-acceptance", "p11"), + join(canonicalIntegrationBase(repositoryRoot), `p5-${"d".repeat(32)}`), + ]) { + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: bad, expectedNonce: run.nonce })); + } + await assert.rejects(cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: "0".repeat(64) })); +}); + +test("cleanup removes exactly one owned p5 root", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + const sibling = join(canonicalIntegrationBase(repositoryRoot), `p5-${"e".repeat(32)}`); + await mkdir(sibling); + await writeFile(join(sibling, "sentinel"), "foreign"); + await cleanupOwnedRun({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + await assert.rejects(readFile(join(run.root, "ownership.json"))); + assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign"); +}); + +function resultFor(id) { + return { + id, + status: "PASS", + startedAt: "2026-08-12T00:00:00.000Z", + finishedAt: "2026-08-12T00:00:01.000Z", + commands: ["node"], + artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }], + }; +} + +test("report validation requires exact p5 identity, check order, and unique artifacts", () => { + const report = { + schemaVersion: 1, + runId: `p5-${"f".repeat(32)}`, + startedAt: "2026-08-12T00:00:00.000Z", + finishedAt: "2026-08-12T00:00:10.000Z", + command: "p5-acceptance integration --keep", + overall: "PASS", + checks: CHECK_IDS.map(resultFor), + }; + assert.doesNotThrow(() => validateReport(report)); + const invalid = structuredClone(report); + invalid.runId = `p2-${"f".repeat(32)}`; + assert.throws(() => validateReport(invalid)); + const duplicate = structuredClone(report); + duplicate.checks[1].artifacts[0].path = duplicate.checks[0].artifacts[0].path; + assert.throws(() => validateReport(duplicate), /duplicated/); + const reordered = structuredClone(report); + reordered.checks.reverse(); + reordered.overall = "FAIL"; + assert.throws(() => validateReport(reordered)); +}); + +test("public wrapper uses a strict empty environment", async () => { + const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p5-acceptance.sh"), "utf8"); + assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/); + assert.doesNotMatch(wrapper, /LANG|LC_ALL|TZ/); + assert.doesNotMatch(wrapper, /P5_ACCEPTANCE_FAIL_AT/); +}); + +test("synthetic integration cleans up successful non-kept runs", async () => { + const repositoryRoot = await fakeRepository(); + const result = await runIntegration({ repositoryRoot, keep: false, env: { P5_ACCEPTANCE_SYNTHETIC: "1" } }); + assert.equal(result.exitCode, 0); + assert.equal(result.retained, false); + await assert.rejects(readFile(join(result.runRoot, "ownership.json"))); +}); + +test("synthetic integration retains kept runs with bounded reports", async () => { + const repositoryRoot = await fakeRepository(); + const result = await runIntegration({ repositoryRoot, keep: true, env: { P5_ACCEPTANCE_SYNTHETIC: "1" } }); + assert.equal(result.exitCode, 0); + assert.equal(result.retained, true); + const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8")); + assert.equal(report.overall, "PASS"); + const reportMd = await readFile(join(result.runRoot, "report.md"), "utf8"); + assert.match(reportMd, /P5 automated integration: PASS/); + assert.match(reportMd, /P5 manual acceptance: PENDING/); + const reportJsonStat = await stat(join(result.runRoot, "report.json")); + const reportMdStat = await stat(join(result.runRoot, "report.md")); + assert.ok(reportJsonStat.size <= 64 * 1024, `report.json too large: ${reportJsonStat.size}`); + assert.ok(reportMdStat.size <= 32 * 1024, `report.md too large: ${reportMdStat.size}`); +}); + +test("synthetic injected failure retains the owned run and records a single failed report", async () => { + const repositoryRoot = await fakeRepository(); + const result = await runIntegration({ + repositoryRoot, + keep: false, + env: { P5_ACCEPTANCE_SYNTHETIC: "1", P5_ACCEPTANCE_FAIL_AT: CHECK_IDS[2] }, + }); + assert.equal(result.exitCode, 1); + assert.equal(result.retained, true); + const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8")); + assert.equal(report.overall, "FAIL"); + const failed = report.checks.find((check) => check.id === CHECK_IDS[2]); + assert.equal(failed.status, "FAIL"); + const roots = await readFile(join(result.runRoot, "ownership.json"), "utf8"); + assert.match(roots, /p5-acceptance/); +}); diff --git a/scripts/p5-acceptance.sh b/scripts/p5-acceptance.sh new file mode 100755 index 00000000..48b0b66c --- /dev/null +++ b/scripts/p5-acceptance.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env -S -i PATH=/usr/bin:/bin /bin/bash +set -euo pipefail +script_path=${BASH_SOURCE[0]} +script_dir=${script_path%/*} +[[ "$script_dir" != "$script_path" ]] || script_dir=. +repo_root="$(cd -P -- "$script_dir/.." && pwd)" +if [[ $# -lt 1 || "$1" != "integration" || $# -gt 2 || ( $# -eq 2 && "$2" != "--keep" ) ]]; then + printf 'usage: %s integration [--keep] +' "$0" >&2 + exit 2 +fi + +canonical_file() { + local path=$1 target parent leaf + [[ "$path" = /* ]] || return 1 + while [[ -L "$path" ]]; do + target=$(/usr/bin/readlink "$path") || return 1 + if [[ "$target" = /* ]]; then path=$target; else path="${path%/*}/$target"; fi + done + parent=${path%/*}; leaf=${path##*/} + parent=$(cd -P -- "$parent" && pwd) || return 1 + printf '%s/%s +' "$parent" "$leaf" +} + +node_path= npm_path= toolchain_prefix= +for pair in "/usr/bin/node|/usr/bin/npm|/usr" "/opt/homebrew/bin/node|/opt/homebrew/bin/npm|/opt/homebrew" "/usr/local/bin/node|/usr/local/bin/npm|/usr/local"; do + node_candidate=${pair%%|*}; remainder=${pair#*|}; npm_candidate=${remainder%%|*}; prefix=${remainder##*|} + [[ -e "$node_candidate" && -e "$npm_candidate" ]] || continue + resolved_node=$(canonical_file "$node_candidate") || continue + resolved_npm=$(canonical_file "$npm_candidate") || continue + [[ -f "$resolved_node" && ! -L "$resolved_node" && -x "$resolved_node" ]] || continue + [[ -f "$resolved_npm" && ! -L "$resolved_npm" ]] || continue + [[ "${resolved_npm##*/}" = "npm-cli.js" ]] || continue + node_path=$resolved_node; npm_path=$resolved_npm; toolchain_prefix=$prefix + break +done +[[ -n "$node_path" && -n "$npm_path" && -n "$toolchain_prefix" ]] || { + printf 'trusted fixed Node/npm toolchain is unavailable +' >&2 + exit 127 +} + +wrapper_root=$(/usr/bin/mktemp -d /tmp/thoth-p5-wrapper.XXXXXXXX) +trap '/bin/rm -rf -- "$wrapper_root"' EXIT HUP INT TERM +/bin/mkdir -m 700 "$wrapper_root/home" "$wrapper_root/tmp" +owned_path="${node_path%/*}:/usr/bin:/bin" +build_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR=$wrapper_root/tmp") +/bin/rm -rf -- "$repo_root/backend/dist" +"${build_env[@]}" "$node_path" "$npm_path" --prefix "$repo_root/backend" run build + +p5_real_home=$(/bin/bash -lc 'printf "%s" ~' 2>/dev/null || true) +safe_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR=$wrapper_root/tmp" + "P5_REAL_HOME=${p5_real_home:-}" "THT_BIN=$repo_root/harness/.venv/bin/tht" "P5_ACCEPTANCE_NODE_PATH=$node_path" "P5_ACCEPTANCE_NPM_PATH=$npm_path") +set +e +"${safe_env[@]}" "$node_path" "$repo_root/backend/scripts/p5-acceptance.mjs" "$@" +status=$? +set -e +exit "$status" diff --git a/scripts/test-p5-acceptance.sh b/scripts/test-p5-acceptance.sh new file mode 100755 index 00000000..6e698e1c --- /dev/null +++ b/scripts/test-p5-acceptance.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +bash -n "$repo_root/scripts/p5-acceptance.sh" "$repo_root/scripts/test-p5-acceptance.sh" +node --check "$repo_root/backend/scripts/p5-acceptance.mjs" +node --check "$repo_root/backend/scripts/p5-acceptance.test.mjs" +npm --prefix "$repo_root/backend" run build +node --test "$repo_root/backend/scripts/p5-acceptance.test.mjs"