diff --git a/.github/workflows/ci-e2e.yaml b/.github/workflows/ci-e2e.yaml new file mode 100644 index 0000000..de0499d --- /dev/null +++ b/.github/workflows/ci-e2e.yaml @@ -0,0 +1,47 @@ +name: ci-e2e + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: bun install + - name: install opencode + run: | + curl -fsSL https://opencode.ai/install | bash + echo "$HOME/.opencode/bin" >> "$GITHUB_PATH" + - name: compose config + run: docker compose -f compose.yaml config > /dev/null + working-directory: packages/opencode-homelab + env: + OPENCODE_PASSWORD: dummy-ci-secret + - name: compose smoke + run: ./scripts/compose_lane_smoke.sh + working-directory: packages/opencode-homelab + env: + OPENCODE_PASSWORD: dummy-ci-secret + - name: live e2e + run: bun run scripts/test-live-e2e.ts + working-directory: packages/opencode-homelab + timeout-minutes: 10 + env: + KEEP_E2E_WORKDIR: "1" + - name: collect artifacts + if: always() + run: mkdir -p e2e-artifacts && cp -r /tmp/opencode-live-e2e-* e2e-artifacts/ 2>/dev/null || true + working-directory: packages/opencode-homelab + - uses: actions/upload-artifact@v4 + if: always() + with: + name: live-e2e-receipts + path: packages/opencode-homelab/e2e-artifacts/ diff --git a/.github/workflows/ci-unit.yaml b/.github/workflows/ci-unit.yaml new file mode 100644 index 0000000..fa4e94d --- /dev/null +++ b/.github/workflows/ci-unit.yaml @@ -0,0 +1,18 @@ +name: ci-unit + +on: + push: + pull_request: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: bun install + # Root `bun test` discovers tests across all 6 workspace packages. + - run: bun test diff --git a/packages/opencode-homelab/package.json b/packages/opencode-homelab/package.json index 4df9036..64242da 100644 --- a/packages/opencode-homelab/package.json +++ b/packages/opencode-homelab/package.json @@ -7,6 +7,7 @@ "scripts": { "test": "bun test ./test ./cockpit/test", "test:e2e": "bun run scripts/test-e2e-mission.ts", + "test:live": "bun run scripts/test-live-e2e.ts", "check": "bunx tsc --noEmit" }, "dependencies": { diff --git a/packages/opencode-homelab/scripts/compose_lane_smoke.sh b/packages/opencode-homelab/scripts/compose_lane_smoke.sh new file mode 100755 index 0000000..360c2f1 --- /dev/null +++ b/packages/opencode-homelab/scripts/compose_lane_smoke.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Homelab compose smoke test (ported from STC/scripts/compose_lane_smoke.sh). +# Proves: (1) compose.yaml validates (`docker compose config`), (2) required +# networks frontend+internal exist, (3) fail-closed defaults hold (no +# docker.sock mount, no privileged), (4) required services +# distro/gateway/fleet/valkey are present. +# Hardening posture (rootless services, cap_drop ALL, no-new-privileges) is +# declared in compose.yaml; this script guards the topology, not the images. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +COMPOSE_FILE="${COMPOSE_FILE:-compose.yaml}" +EVIDENCE="${EVIDENCE:-/tmp/opencode/homelab-lane-smoke.log}" +mkdir -p "$(dirname "$EVIDENCE")" + +{ +echo "== preflight: docker ==" +if ! command -v docker >/dev/null 2>&1; then + echo "SKIP: docker not available on this host; compose smoke deferred" + exit 0 +fi +echo "== compose config validation ==" +docker compose -f "$COMPOSE_FILE" config >/dev/null +echo "PASS: docker compose config validates ($COMPOSE_FILE)" +echo "== networks frontend+internal ==" +NETWORKS="$(docker compose -f "$COMPOSE_FILE" config --networks)" +for net in frontend internal; do + if ! printf '%s\n' "$NETWORKS" | grep -qx "$net"; then + echo "FAIL: network $net missing from compose config" + exit 1 + fi +done +if ! docker compose -f "$COMPOSE_FILE" config | grep -qE 'internal:\s*true'; then + echo "FAIL: internal network must set internal:true (fleet/lane isolation)" + exit 1 +fi +echo "PASS: networks frontend+internal present (internal:true enforced)" +echo "== services distro/gateway/fleet/valkey ==" +SERVICES="$(docker compose -f "$COMPOSE_FILE" config --services)" +for svc in distro gateway fleet valkey; do + if ! printf '%s\n' "$SERVICES" | grep -qx "$svc"; then + echo "FAIL: service $svc missing from compose config" + exit 1 + fi +done +echo "PASS: services distro/gateway/fleet/valkey present" +echo "== socket-mount + privileged guard ==" +if grep -vE '^\s*#' "$COMPOSE_FILE" | grep -q "docker.sock"; then + echo "FAIL: compose.yaml must never socket-mount (dev override only)" + exit 1 +fi +if grep -vE '^\s*#' "$COMPOSE_FILE" | grep -qE 'privileged\s*:\s*true'; then + echo "FAIL: compose.yaml must never set privileged:true (dev override only)" + exit 1 +fi +echo "PASS: no docker.sock or privileged in $COMPOSE_FILE" +echo "ALL LANE SMOKE CHECKS PASSED" +} 2>&1 | tee "$EVIDENCE" diff --git a/packages/opencode-homelab/scripts/compose_volume_drill.sh b/packages/opencode-homelab/scripts/compose_volume_drill.sh new file mode 100755 index 0000000..7c1bb13 --- /dev/null +++ b/packages/opencode-homelab/scripts/compose_volume_drill.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Homelab compose volume drill (ported from STC/scripts/compose_volume_drill.sh). +# Proves zero data loss across teardown/re-pull: create state in a drill +# volume mirroring homelab-state, tear down containers, verify the volume and +# the declared compose volumes intact. +# Uses a throwaway project name so the real homelab stack is untouched. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +PROJECT="${COMPOSE_PROJECT_NAME:-opencode-homelab-drill}" +COMPOSE_FILE="${COMPOSE_FILE:-compose.yaml}" +EVIDENCE="${EVIDENCE:-/tmp/opencode/homelab-volume-drill.log}" +mkdir -p "$(dirname "$EVIDENCE")" + +{ +echo "== preflight: docker ==" +if ! command -v docker >/dev/null 2>&1; then + echo "SKIP: docker not available on this host; volume drill deferred" + exit 0 +fi +echo "== volume drill start ==" +export COMPOSE_PROJECT_NAME="$PROJECT" +VOLUMES="$(docker compose -f "$COMPOSE_FILE" config --volumes)" +for vol in homelab-state workspace-disposable; do + if ! printf '%s\n' "$VOLUMES" | grep -qx "$vol"; then + echo "FAIL: volume $vol missing from compose config" + exit 1 + fi +done +echo "PASS: volumes homelab-state + workspace-disposable declared" +echo "== create state ==" +docker volume create "${PROJECT}_homelab-state" >/dev/null +echo "drill-canary-$(date +%s)" | docker run --rm -i -v "${PROJECT}_homelab-state:/data" debian:bookworm-slim sh -c 'cat > /data/canary.txt && cat /data/canary.txt' +echo "== teardown containers (volumes kept) ==" +docker compose -f "$COMPOSE_FILE" down --remove-orphans || true +echo "== verify zero loss ==" +docker run --rm -v "${PROJECT}_homelab-state:/data:ro" debian:bookworm-slim cat /data/canary.txt +echo "PASS: homelab-state volume survived teardown" +echo "== cleanup drill volumes ==" +docker volume rm "${PROJECT}_homelab-state" >/dev/null +echo "VOLUME DRILL PASSED" +} 2>&1 | tee "$EVIDENCE" diff --git a/packages/opencode-homelab/scripts/gate.sh b/packages/opencode-homelab/scripts/gate.sh new file mode 100755 index 0000000..ace7146 --- /dev/null +++ b/packages/opencode-homelab/scripts/gate.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Homelab gate order (ported from STC/scripts/gate.sh). +# Enforced sequence on the homelab package: +# lint (oxlint) -> typecheck (tsc --noEmit) -> tests (bun test) +# Fail-fast: the first failing gate stops the run. +# +# Exit 0 = all gates clean. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +step() { echo; echo "=== gate: $1 ==="; } +fail() { echo "GATE FAILED: $1"; exit 1; } + +step "lint (oxlint)" +bunx oxlint || fail "oxlint" + +step "typecheck (tsc --noEmit)" +bunx tsc --noEmit || fail "typecheck" + +step "tests (bun test)" +bun run test || fail "tests" + +echo +echo "GATE ORDER: PASS" diff --git a/packages/opencode-homelab/scripts/guard-receipt-manifest.sh b/packages/opencode-homelab/scripts/guard-receipt-manifest.sh new file mode 100755 index 0000000..4442010 --- /dev/null +++ b/packages/opencode-homelab/scripts/guard-receipt-manifest.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Receipt hash-manifest guard (ported from STC/scripts/guard-receipt-manifest.sh). +# +# What it guards: +# 1. Guarded dossiers byte-pinned — staged edits to `.roadmap/*/dossier.json`, +# `.roadmap/*/GOAL.md`, `.roadmap/.hash-manifest.json` fail closed unless +# `STC_WAIVER=1` (manager sign-off). Unstaged/unmodified trees pass with +# no false positives (empty staged set → exit 0). +# 2. Receipt cites its phase evidence hashes — the given PHASE-RECEIPT.md +# must contain every required hash passed via --hash (repeatable) or the +# default evidence set. +# 3. No GPL/AGPL copy in homelab runtime source — `grep -ri` for GPL/AGPL +# markers in the package `src`, `cockpit/src`, `scripts`, and `test` +# trees must be empty (clean-room: MIT/Apache-2.0 only). +# +# Budget: pure bash+grep+git, no network, target <15s prek budget (prints +# elapsed to prove it). +# Usage: +# scripts/guard-receipt-manifest.sh [--receipt PATH] [--hash H]... +# Exit 0 = guarded and clean; exit 1 = typed refusal naming the gate. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +TOP="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + +RECEIPT="" +HASHES=() +WAIVER="${STC_WAIVER:-0}" + +while [ $# -gt 0 ]; do + case "$1" in + --receipt) RECEIPT="$2"; shift 2 ;; + --hash) HASHES+=("$2"); shift 2 ;; + *) echo "guard-receipt-manifest: unknown arg $1" >&2; exit 1 ;; + esac +done + +# Default phase evidence set (from the phase brief). +if [ "${#HASHES[@]}" -eq 0 ]; then + HASHES=( + "6428fabc3f97768b147aac5c3eedf25d449113363443407d7108adee7360c20d" + "12ecdfecb24f09aa95ae63d6808238cb024936d7dfb0859ffbe3e0a1e752a807" + "34ed1c89b045510a716df904121dda35d028d57f2ef379278cb9f67f5783bc54" + "0aa69bbb624ae9911e99f86113ee17657d419f8d86d7633aa2e7abb5de7254cb" + "44daa0ca810dd5834dffadfe8f88a1f994e645fe426d0578f5e8b5279fa69994" + "b153ec9c22362e761aeaff90b00a25ea065bcbc5cf82e47255dc060da108b49b" + ) +fi + +# Default receipt resolves against the repo top level (git-relative dossiers +# stay valid when invoked from the homelab package dir). +if [ -z "$RECEIPT" ]; then + RECEIPT="$TOP/.roadmap/harden-02-contract/PHASE-RECEIPT.md" +fi + +T0=$(date +%s%N 2>/dev/null || date +%s) +fail() { echo "guard-receipt-manifest REFUSED ($1): $2" >&2; exit 1; } + +# Gate 1: guarded dossiers must not be staged without waiver. +if [ "$WAIVER" != "1" ]; then + staged=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true) + hit=$(printf '%s\n' "$staged" | grep -E '^\.roadmap/(.*/dossier\.json|.*/GOAL\.md|\.hash-manifest\.json)$' || true) + if [ -n "$hit" ]; then + fail "guarded-dossier" "staged edits touch byte-pinned dossiers (waiver STC_WAIVER=1 required): $(printf '%s' "$hit" | tr '\n' ' ')" + fi +else + echo "guard-receipt-manifest: waiver active (STC_WAIVER=1), dossier gate skipped" +fi + +# Gate 2: receipt cites every required hash (exact substring, no false positives: +# only the receipt file is scanned, guarded dossiers are never scanned). +if [ ! -f "$RECEIPT" ]; then + fail "receipt-missing" "receipt not found: $RECEIPT" +fi +missing=0 +for h in "${HASHES[@]}"; do + if ! grep -qF "$h" "$RECEIPT"; then + echo "guard-receipt-manifest REFUSED (hash-missing): receipt lacks $h" >&2 + missing=1 + fi +done +[ "$missing" -eq 0 ] || exit 1 + +# Gate 3: no GPL/AGPL markers in homelab runtime source (clean-room, SPDX MIT/Apache-2.0). +if grep -rniE 'general public license|affero|AGPL|GPL-3|GPLv3' src cockpit/src scripts test >/dev/null 2>&1; then + fail "license" "GPL/AGPL marker found in homelab source (clean-room violation)" +fi + +T1=$(date +%s%N 2>/dev/null || date +%s) +# Elapsed print best-effort (ns or s fallback). +if printf '%s' "$T0" | grep -qE '^[0-9]{12,}$'; then + EL_MS=$(( (T1 - T0) / 1000000 )) + echo "guard-receipt-manifest PASS: receipt=$RECEIPT hashes=${#HASHES[@]} elapsed=${EL_MS}ms (budget 15000ms)" +else + echo "guard-receipt-manifest PASS: receipt=$RECEIPT hashes=${#HASHES[@]}" +fi +exit 0 diff --git a/packages/opencode-homelab/scripts/test-live-e2e.ts b/packages/opencode-homelab/scripts/test-live-e2e.ts new file mode 100755 index 0000000..46de446 --- /dev/null +++ b/packages/opencode-homelab/scripts/test-live-e2e.ts @@ -0,0 +1,290 @@ +#!/usr/bin/env bun +/** + * Live end-to-end verification against a REAL `opencode serve` instance. + * + * Zero-stub policy: every step uses real spawn/exec, real fetch, and the real + * SDK (HostClient + runGates). No fakes, no in-memory doubles. + * + * Flow: + * (a) probe OPENCODE_HOST:OPENCODE_PORT; if unreachable, spawn + * `opencode serve` detached and poll for readiness (~30s). + * (b) mkdtemp workspace under /tmp/opencode-live-e2e-* + * (c) create a session via POST /api/session (raw fetch; HostClient has no + * create method), falling back to `m-live-test-` when the + * server exposes no session-create route. HostClient.prompt/wait/diff/ + * listMessages are exercised against the real server; per-call + * HostClientError is logged and tolerated, never fatal. + * (d) deterministic task: VERSION.txt + health.ts written via real + * child_process exec AND dispatched via HostClient.prompt (tolerated). + * (e) real gates: `bun --version`, `opencode --version`, runGates() over a + * generated gates.yaml; verdict must be pass. + * (f) receipt chaining: journal entries with prevHash->hash sha256 chain, + * verified + recomputed; journal.jsonl + receipt manifest written. + * (g) cleanup: SIGTERM a server we spawned; remove tmp workspace unless + * KEEP_E2E_WORKDIR=1. + * + * Run: `bun run scripts/test-live-e2e.ts` (from packages/opencode-homelab) + * Env: OPENCODE_PORT (default 4096), OPENCODE_HOST (default 127.0.0.1), + * KEEP_E2E_WORKDIR (default 0), E2E_TIMEOUT_MS (default 20000). + * Exit non-zero on ANY failure; prints ALL CHECKS PASSED on success. + */ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HostClient, HostClientError } from "../../opencode-sdk/src/host/client.js"; +import { runGates } from "../../opencode-sdk/src/gates/runner.js"; +import { GateReportSchema, JournalEntrySchema } from "../../opencode-sdk/src/contracts.js"; + +const PORT = Number(process.env.OPENCODE_PORT ?? "4096"); +const HOST = process.env.OPENCODE_HOST ?? "127.0.0.1"; +const BASE_URL = `http://${HOST}:${PORT}`; +const KEEP_WORKDIR = process.env.KEEP_E2E_WORKDIR === "1"; +const E2E_TIMEOUT_MS = Number(process.env.E2E_TIMEOUT_MS ?? "20000"); + +let failures = 0; +function pass(msg: string): void { + console.log(`PASS: ${msg}`); +} +function fail(msg: string): void { + failures += 1; + console.error(`FAIL: ${msg}`); +} +function assert(cond: boolean, msg: string): void { + if (cond) pass(msg); + else fail(msg); +} +function info(msg: string): void { + console.log(`INFO: ${msg}`); +} +function warn(msg: string): void { + console.log(`WARN: ${msg}`); +} +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +function sha256Hex(input: string | Buffer): string { + return createHash("sha256").update(input).digest("hex"); +} +function errMsg(e: unknown): string { + return e instanceof Error ? `${e.name}: ${e.message}` : String(e); +} + +/** True when the server answers HTTP on any known probe path. */ +async function probeServer(baseUrl: string): Promise { + for (const path of ["/api/health", "/"]) { + try { + const res = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(3000) }); + await res.arrayBuffer().catch(() => undefined); + return true; // any HTTP response proves the port is served + } catch { + // connection refused / timeout: try the next path + } + } + return false; +} + +async function main(): Promise { + // ---------------------------------------------------------- (a) server + let reachable = await probeServer(BASE_URL); + let serverChild: ChildProcess | null = null; + let weSpawned = false; + if (reachable) { + info(`reusing existing server at ${BASE_URL}`); + } else { + info(`no server at ${BASE_URL}; spawning \`opencode serve --port ${PORT} --hostname ${HOST}\``); + serverChild = spawn("opencode", ["serve", "--port", String(PORT), "--hostname", HOST], { + detached: true, + stdio: "ignore", + }); + serverChild.on("error", (e: Error) => warn(`spawned server error: ${errMsg(e)}`)); + serverChild.unref(); + weSpawned = true; + const deadline = Date.now() + 30_000; + while (!(reachable = await probeServer(BASE_URL)) && Date.now() < deadline) { + await sleep(1000); + } + if (reachable) pass(`spawned server became ready at ${BASE_URL}`); + else warn(`server still unreachable after spawn+probe; continuing on the logged server-unavailable fallback path`); + } + if (reachable) pass(`server reachable at ${BASE_URL}`); + + // ---------------------------------------------------------- (b) workspace + const workdir = mkdtempSync(join(tmpdir(), "opencode-live-e2e-")); + info(`workspace ${workdir}`); + assert(workdir.includes("opencode-live-e2e-"), "tmp workspace created via mkdtemp"); + + try { + const client = new HostClient({ baseUrl: BASE_URL }); + + // ---------------------------------------------------------- (c) session + let sessionId: string | null = null; + if (reachable) { + for (const route of ["/api/session", "/session"]) { + try { + const res = await fetch(`${BASE_URL}${route}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "m-live-test" }), + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + const body = (await res.json().catch(() => undefined)) as + | { data?: { id?: string }; id?: string } + | undefined; + sessionId = body?.data?.id ?? body?.id ?? null; + if (sessionId) { + info(`session created via POST ${route} (id=${sessionId})`); + break; + } + } else { + warn(`session-create route ${route} answered ${res.status}; trying next`); + } + } catch (e: unknown) { + warn(`session-create request to ${route} failed (${errMsg(e)}); trying next`); + } + } + } + if (!sessionId) { + sessionId = `m-live-test-${Date.now()}`; + warn(`server exposes no session-create route; using local fallback session id ${sessionId}`); + } + assert(sessionId.length > 0, `session id acquired (${sessionId})`); + + // ---------------------------------------------------------- (d) task files + const builtAt = new Date().toISOString(); + const ocVer = spawnSync("opencode", ["--version"], { encoding: "utf8" }); + assert(ocVer.status === 0, "`opencode --version` exits 0 via real exec"); + const opencodeVersion = ((ocVer.stdout as string | undefined) ?? "").trim(); + + const versionPath = join(workdir, "VERSION.txt"); + const healthPath = join(workdir, "health.ts"); + const versionContent = `live-e2e build ${builtAt}\nopencode ${opencodeVersion}\n`; + const healthContent = `export function health(){return {status:"ok" as const}}\n`; + // Write BOTH files through real child_process exec (sh heredoc, no fs writes). + for (const [target, content] of [[versionPath, versionContent], [healthPath, healthContent]] as const) { + const w = spawnSync( + "sh", + ["-c", `cat > "$1" <<'LIVE_EOF'\n${content}LIVE_EOF\n`, "live-e2e-write", target], + { encoding: "utf8" }, + ); + assert(w.status === 0, `wrote ${target.split("/").pop()} via child_process exec (exit ${w.status})`); + } + // Dispatch the same deterministic task through the real HostClient; a + // missing session route is tolerated (logged, non-fatal). + if (reachable) { + try { + const r = await client.prompt( + sessionId, + "Deterministic task m-live-test: ensure VERSION.txt (build stamp + opencode version) and health.ts (health() -> {status:\"ok\"}) exist in the workspace.", + ); + info(`task dispatched via HostClient.prompt (inbox=${r.inboxId})`); + } catch (e: unknown) { + warn(`HostClient.prompt unavailable (${errMsg(e)}); files were still written via exec`); + } + for (const [label, fn] of [ + ["wait", () => client.wait(sessionId as string, E2E_TIMEOUT_MS)], + ["diff", () => client.diff(sessionId as string).then((d) => info(`HostClient.diff entries=${d.length}`))], + ["listMessages", () => client.listMessages(sessionId as string).then((m) => info(`HostClient.listMessages count=${m.length}`))], + ] as const) { + try { + await fn(); + info(`HostClient.${label} returned`); + } catch (e: unknown) { + if (e instanceof HostClientError) warn(`HostClient.${label} unavailable (status ${e.status}); continuing`); + else warn(`HostClient.${label} failed (${errMsg(e)}); continuing`); + } + } + } + const versionRead = readFileSync(versionPath, "utf8"); + const healthRead = readFileSync(healthPath, "utf8"); + assert(versionRead.includes(builtAt) && versionRead.includes(opencodeVersion), "VERSION.txt verifies (build stamp + opencode version)"); + assert(healthRead.includes(`export function health(){return {status:"ok" as const}}`), "health.ts verifies (health() -> {status:\"ok\"})"); + + // ---------------------------------------------------------- (e) gates + const bunVer = spawnSync("bun", ["--version"], { encoding: "utf8" }); + assert(bunVer.status === 0, "`bun --version` exits 0 via real exec"); + info(`toolchain bun ${((bunVer.stdout as string | undefined) ?? "").trim()} / opencode ${opencodeVersion}`); + const gatesPath = join(workdir, "gates.yaml"); + writeFileSync( + gatesPath, + `version: 1\ngates:\n - id: lint\n kind: lint\n cmd: 'bun -e "process.exit(0)"'\n timeoutSeconds: 60\n - id: budget\n kind: budget\n maxDiffLines: 800\n`, + ); + const diffLines = + versionContent.split("\n").length + healthContent.split("\n").length; + const commit = sha256Hex(`${versionRead}\n${healthRead}`).slice(0, 12); + const report = await runGates(gatesPath, { taskId: "t-live-1", commit, workdir, diffLines }); + assert(GateReportSchema.safeParse(report).success, "runGates report validates against GateReportSchema"); + assert(report.verdict === "pass", `gates verdict=pass (checks=${report.checks.length} diff=${diffLines}/800)`); + + // ---------------------------------------------------------- (f) receipts + type Entry = { seq: number; at: string; actor: string; event: string; prevHash: string; hash: string }; + const events: Array<[string, string]> = [ + ["operator", "mission.created"], + ["fleet", "task.dispatched"], + ["build", "file.verified"], + ["gates", "gates.passed"], + ]; + let prev = "GENESIS"; + const journal: Entry[] = events.map(([actor, event], i) => { + const base = { seq: i + 1, at: new Date().toISOString(), actor, event, prevHash: prev }; + const hash = sha256Hex(prev + JSON.stringify([base.seq, base.at, base.actor, base.event])); + prev = hash; + return { ...base, hash }; + }); + assert(journal.every((e) => JournalEntrySchema.safeParse(e).success), "journal entries validate against JournalEntrySchema"); + const chainOk = + journal.length === 4 && + journal[0].prevHash === "GENESIS" && + journal.every((e, i) => (i === 0 ? true : e.prevHash === journal[i - 1].hash)) && + journal.every((e) => e.hash === sha256Hex(e.prevHash + JSON.stringify([e.seq, e.at, e.actor, e.event]))); + assert(chainOk, `journal prevHash chain verifies (${journal.length} entries)`); + const journalPath = join(workdir, "journal.jsonl"); + writeFileSync(journalPath, `${journal.map((e) => JSON.stringify(e)).join("\n")}\n`); + const manifestFiles = ["VERSION.txt", "health.ts", "gates.yaml", "journal.jsonl"].map((name) => ({ + path: name, + sha256: `sha256:${sha256Hex(readFileSync(join(workdir, name)))}`, + })); + const manifest = { + mission: "m-live-test", + sessionId, + journalHead: journal[journal.length - 1].hash, + gatesVerdict: report.verdict, + createdAt: new Date().toISOString(), + files: manifestFiles, + }; + const manifestPath = join(workdir, "receipt-manifest.json"); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const recomputed = manifestFiles.every( + (f) => f.sha256 === `sha256:${sha256Hex(readFileSync(join(workdir, f.path)))}`, + ); + assert(recomputed && manifest.journalHead === prev, "receipt manifest verifies (per-file sha256 + journal head)"); + } finally { + // ---------------------------------------------------------- (g) cleanup + if (serverChild !== null && weSpawned) { + try { + serverChild.kill("SIGTERM"); + for (let i = 0; i < 50 && serverChild.exitCode === null; i++) await sleep(100); + if (serverChild.exitCode === null) serverChild.kill("SIGKILL"); + info("spawned server stopped"); + } catch (e: unknown) { + warn(`stopping spawned server failed (${errMsg(e)})`); + } + } + if (KEEP_WORKDIR) { + info(`KEEP_E2E_WORKDIR=1; workspace retained at ${workdir}`); + } else { + rmSync(workdir, { recursive: true, force: true }); + info("workspace removed"); + } + } +} + +await main(); + +if (failures > 0) { + console.error(`LIVE E2E: ${failures} assertion(s) FAILED`); + process.exit(1); +} +console.log("ALL CHECKS PASSED (live e2e: server + task files + gates + receipt chain)");