mirror of
https://github.com/Heretek-OpenCode/heretek-opencode.git
synced 2026-11-07 10:05:58 -10:00
merge: feat/homelab-integration into main (Tier 3 homelab)
This commit is contained in:
commit
8fa36c652d
17 files changed
+1057
-1273
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,72 @@
|
||||
# @heretek-ai/homelab
|
||||
|
||||
Homelab & integration tier (STC successor): compose topology, `studio` operator
|
||||
CLI, manager autonomy dial, cockpit mission projections, and the canonical
|
||||
end-to-end mission test.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
packages/opencode-homelab/
|
||||
├── compose.yaml # distro + gateway + fleet + valkey topology
|
||||
├── compose.override.dev.yaml # local live mounts (no privilege escalation)
|
||||
├── config/autonomy.json # autonomy dial setting (default: balanced)
|
||||
├── src/autonomy.ts # dial parse + stage gate
|
||||
├── scripts/studio # operator CLI: init|up|down|logs|status
|
||||
├── scripts/test-e2e-mission.ts # canonical E2E (mock adapters, no docker)
|
||||
├── cockpit/src/projections/missions.ts # Fleet events -> view-model
|
||||
├── cockpit/src/components/ # minimal MissionViewer + notes
|
||||
└── test/ # unit tests
|
||||
```
|
||||
|
||||
## Autonomy dial (from STC #8)
|
||||
|
||||
One explicit knob, `config/autonomy.json` → `{ "version": 1, "mode": ... }`:
|
||||
|
||||
| mode | semantics |
|
||||
|---|---|
|
||||
| `advisory` | stops at **every** stage for human confirmation |
|
||||
| `balanced` (default) | stops only at **spec** and at the **human PR** gate |
|
||||
| `full` | runs continuously, stops only for the **human PR** gate |
|
||||
|
||||
Merge is **always human-only** (branch protection) regardless of mode.
|
||||
`requiresStop(mode, stage)` in `src/autonomy.ts` is the single decision
|
||||
function; unknown modes/stages fail closed.
|
||||
|
||||
## Network boundaries
|
||||
|
||||
- `frontend`: gateway egress **only**. The gateway is the sole service with
|
||||
an external route (engines, registries). Nothing else is attached.
|
||||
- `internal` (`internal: true`): distro, fleet, valkey. **No outside route** —
|
||||
lanes reach the network exclusively through gateway `POST /mcp`.
|
||||
- All services rootless (`user: "1000:1000"`, `cap_drop: [ALL]`,
|
||||
`no-new-privileges`). No `privileged`, no `docker.sock`, no host network.
|
||||
|
||||
## Volumes & drills
|
||||
|
||||
- `homelab-state` (durable): fleet journal/snapshots, gateway audit chain,
|
||||
valkey dump. Survives `down` and image updates.
|
||||
- `workspace-disposable` (ephemeral): per-task worktrees/lane mounts.
|
||||
Drill: `scripts/studio down -v` then `up` — missions must resume from
|
||||
`homelab-state` alone; anything that does not resume was state in the
|
||||
wrong volume.
|
||||
|
||||
## First run
|
||||
|
||||
```sh
|
||||
scripts/studio init # writes rootless .env (0600), generates OPENCODE_PASSWORD
|
||||
scripts/studio up # docker compose up -d
|
||||
scripts/studio status # ps + config check
|
||||
```
|
||||
|
||||
`init` **refuses** privileged-dev (`PRIVILEGED_DEV=1`) unless
|
||||
`ALLOW_PRIVILEGED=1` is explicitly set (STC #9). Dev live mounts:
|
||||
`docker compose -f compose.yaml -f compose.override.dev.yaml up`.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
bun test packages/opencode-homelab/test packages/opencode-homelab/cockpit/test
|
||||
bun run scripts/test-e2e-mission.ts # from packages/opencode-homelab; no docker
|
||||
docker compose -f packages/opencode-homelab/compose.yaml config -q
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# @heretek-ai/homelab-cockpit
|
||||
|
||||
Framework-free cockpit surfaces for the homelab tier.
|
||||
|
||||
- `src/projections/missions.ts` — pure fold of Fleet JSON-RPC events into
|
||||
`{ missions, dagNodes, lanes, gates, judges }`. Events apply in `seq`
|
||||
order, so out-of-order notifications are harmless. No network, no clock
|
||||
inside reducers (deterministic tests).
|
||||
- `src/components/MissionViewer.ts` — minimal render helpers
|
||||
(`renderMissionSummary`, `renderMissionCard`) returning strings any shell
|
||||
(React/Solid/terminal) can embed. Never fetches, never mutates; shows a
|
||||
staleness badge when `asOfSeq < headSeq` (STC hub-and-spoke pattern).
|
||||
- `src/components/NOTES.md` — shell integration notes (escaping, dual-QA
|
||||
inspector plan, gate chips).
|
||||
|
||||
```sh
|
||||
bun test ./test # from packages/opencode-homelab/cockpit
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@heretek-ai/homelab-cockpit",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Cockpit mission projections + minimal viewer (framework-free)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test ./test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Minimal MissionViewer: framework-free render helpers + integration notes.
|
||||
//
|
||||
// NOTE (for the cockpit shell owner): this package intentionally ships no
|
||||
// React/Solid dependency. `renderMissionSummary` returns plain text/HTML
|
||||
// strings so any shell (React via dangerouslySetInnerHTML, Solid, terminal)
|
||||
// can embed them. State always comes from `projectMissions` — the viewer
|
||||
// never fetches, never mutates, shows a staleness badge when `asOfSeq`
|
||||
// lags the stream head (STC hub-and-spoke `useProjection` pattern).
|
||||
import type { MissionsViewModel } from "../projections/missions";
|
||||
import { readyNodes } from "../projections/missions";
|
||||
|
||||
export interface ViewerMeta {
|
||||
asOfSeq: number;
|
||||
headSeq: number;
|
||||
}
|
||||
|
||||
export function isStale(meta: ViewerMeta): boolean {
|
||||
return meta.asOfSeq < meta.headSeq;
|
||||
}
|
||||
|
||||
/** One-line per mission + ready-task counts, suitable for terminal or card. */
|
||||
export function renderMissionSummary(vm: MissionsViewModel, meta: ViewerMeta): string {
|
||||
const lines: string[] = [];
|
||||
if (isStale(meta)) lines.push(`[STALE asOf=${meta.asOfSeq} head=${meta.headSeq}]`);
|
||||
for (const m of vm.missions) {
|
||||
const ready = readyNodes(vm, m.id).length;
|
||||
const total = vm.dagNodes.filter((n) => n.missionId === m.id).length;
|
||||
const gates = vm.gates.filter((g) =>
|
||||
vm.dagNodes.some((n) => n.missionId === m.id && n.id === g.taskId),
|
||||
);
|
||||
const fails = gates.filter((g) => g.verdict !== "pass").length;
|
||||
lines.push(
|
||||
`${m.id} [${m.state}] ready=${ready}/${total} gatesFail=${fails} :: ${m.intent}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Minimal HTML card for one mission (shell escapes intent — see NOTES). */
|
||||
export function renderMissionCard(
|
||||
vm: MissionsViewModel,
|
||||
missionId: string,
|
||||
meta: ViewerMeta,
|
||||
): string {
|
||||
const m = vm.missions.find((x) => x.id === missionId);
|
||||
if (!m) return `<div class="mission missing">unknown mission ${escapeHtml(missionId)}</div>`;
|
||||
const nodes = vm.dagNodes.filter((n) => n.missionId === missionId);
|
||||
const stale = isStale(meta)
|
||||
? `<span class="badge stale">stale asOf=${meta.asOfSeq} head=${meta.headSeq}</span>`
|
||||
: `<span class="badge fresh">live seq=${meta.headSeq}</span>`;
|
||||
const items = nodes
|
||||
.map((n) => `<li data-task="${escapeHtml(n.id)}" data-state="${escapeHtml(n.state)}">${escapeHtml(n.id)} · ${escapeHtml(n.stage)} · ${escapeHtml(n.state)}</li>`)
|
||||
.join("");
|
||||
return `<section class="mission"><h2>${escapeHtml(m.id)} [${escapeHtml(m.state)}]</h2>${stale}<p>${escapeHtml(m.intent)}</p><ul>${items}</ul></section>`;
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# MissionViewer integration notes (cockpit shell owner)
|
||||
|
||||
- Source of truth: Fleet JSON-RPC notification stream → `projectMissions(events)`.
|
||||
The viewer receives `{ vm, meta: { asOfSeq, headSeq } }` as props; it never
|
||||
fetches and never mutates (STC hub-and-spoke pattern).
|
||||
- Staleness: show the `[STALE]` badge whenever `asOfSeq < headSeq`; the shell
|
||||
owns the 1s poll cadence and per-view error slots (cf. STC `useProjection`).
|
||||
- Escaping: `renderMissionCard` escapes all mission-controlled strings.
|
||||
Intents and task ids are operator input — never interpolate unescaped.
|
||||
- Dual QA inspector (next step): render `vm.judges` grouped by `seat`
|
||||
(`qa-functional` vs `qa-adversarial`), linking each verdict to its
|
||||
`taskId` evidence; disagreements on the same criterion escalate visually.
|
||||
- Gate chips: `vm.gates` verdicts color per task chip; non-`pass` blocks the
|
||||
merge affordance (human-only merge stays enforced server-side).
|
||||
@@ -0,0 +1,180 @@
|
||||
// Pure projection: Fleet JSON-RPC events -> cockpit missions view-model.
|
||||
// Framework-free (mirrors STC `useProjection` hub-and-spoke: the UI never
|
||||
// owns state, it projects a single event stream). No network, no Date.now()
|
||||
// inside reducers — pass timestamps in events for deterministic tests.
|
||||
|
||||
export type FleetEventType =
|
||||
| "mission.created"
|
||||
| "mission.state"
|
||||
| "task.upsert"
|
||||
| "task.state"
|
||||
| "lane.status"
|
||||
| "gate.report"
|
||||
| "judge.report";
|
||||
|
||||
export interface FleetEvent {
|
||||
seq: number;
|
||||
at: string;
|
||||
type: FleetEventType;
|
||||
missionId: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MissionView {
|
||||
id: string;
|
||||
intent: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface DagNode {
|
||||
id: string;
|
||||
missionId: string;
|
||||
stage: string;
|
||||
state: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
export interface LaneView {
|
||||
id: string;
|
||||
tier: string;
|
||||
status: string;
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
export interface GateView {
|
||||
taskId: string;
|
||||
verdict: string;
|
||||
checks: Array<{ id: string; exit: number }>;
|
||||
}
|
||||
|
||||
export interface JudgeView {
|
||||
seat: string;
|
||||
missionId: string;
|
||||
taskId: string | null;
|
||||
verdict: string;
|
||||
findings: number;
|
||||
}
|
||||
|
||||
export interface MissionsViewModel {
|
||||
missions: MissionView[];
|
||||
dagNodes: DagNode[];
|
||||
lanes: LaneView[];
|
||||
gates: GateView[];
|
||||
judges: JudgeView[];
|
||||
}
|
||||
|
||||
export function emptyView(): MissionsViewModel {
|
||||
return { missions: [], dagNodes: [], lanes: [], gates: [], judges: [] };
|
||||
}
|
||||
|
||||
function str(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
/** Fold one event into the view-model (pure; returns a new object). */
|
||||
export function applyEvent(vm: MissionsViewModel, ev: FleetEvent): MissionsViewModel {
|
||||
const next: MissionsViewModel = {
|
||||
missions: [...vm.missions],
|
||||
dagNodes: [...vm.dagNodes],
|
||||
lanes: [...vm.lanes],
|
||||
gates: [...vm.gates],
|
||||
judges: [...vm.judges],
|
||||
};
|
||||
const p = ev.payload ?? {};
|
||||
switch (ev.type) {
|
||||
case "mission.created": {
|
||||
if (!next.missions.some((m) => m.id === ev.missionId)) {
|
||||
next.missions.push({
|
||||
id: ev.missionId,
|
||||
intent: str(p["intent"], ev.missionId),
|
||||
state: "pending",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "mission.state": {
|
||||
const m = next.missions.find((m) => m.id === ev.missionId);
|
||||
if (m) m.state = str(p["state"], m.state);
|
||||
else next.missions.push({ id: ev.missionId, intent: ev.missionId, state: str(p["state"], "pending") });
|
||||
break;
|
||||
}
|
||||
case "task.upsert":
|
||||
case "task.state": {
|
||||
const id = str(p["id"] ?? p["taskId"]);
|
||||
if (!id) break;
|
||||
const node: DagNode = {
|
||||
id,
|
||||
missionId: ev.missionId,
|
||||
stage: str(p["stage"], "build"),
|
||||
state: str(p["state"], "pending"),
|
||||
deps: Array.isArray(p["deps"]) ? (p["deps"] as unknown[]).filter((d): d is string => typeof d === "string") : [],
|
||||
};
|
||||
const i = next.dagNodes.findIndex((n) => n.id === id);
|
||||
if (i >= 0) next.dagNodes[i] = { ...next.dagNodes[i], ...node };
|
||||
else next.dagNodes.push(node);
|
||||
break;
|
||||
}
|
||||
case "lane.status": {
|
||||
const id = str(p["id"] ?? p["lane"]);
|
||||
if (!id) break;
|
||||
const lane: LaneView = {
|
||||
id,
|
||||
tier: str(p["tier"], "docker"),
|
||||
status: str(p["status"], "unknown"),
|
||||
taskId: typeof p["taskId"] === "string" ? p["taskId"] : null,
|
||||
};
|
||||
const i = next.lanes.findIndex((l) => l.id === id);
|
||||
if (i >= 0) next.lanes[i] = lane;
|
||||
else next.lanes.push(lane);
|
||||
break;
|
||||
}
|
||||
case "gate.report": {
|
||||
const taskId = str(p["taskId"]);
|
||||
if (!taskId) break;
|
||||
const gate: GateView = {
|
||||
taskId,
|
||||
verdict: str(p["verdict"], "fail"),
|
||||
checks: Array.isArray(p["checks"])
|
||||
? (p["checks"] as Array<Record<string, unknown>>).map((c) => ({
|
||||
id: str(c["id"]),
|
||||
exit: typeof c["exit"] === "number" ? c["exit"] : -1,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
const i = next.gates.findIndex((g) => g.taskId === taskId);
|
||||
if (i >= 0) next.gates[i] = gate;
|
||||
else next.gates.push(gate);
|
||||
break;
|
||||
}
|
||||
case "judge.report": {
|
||||
next.judges.push({
|
||||
seat: str(p["seat"]),
|
||||
missionId: ev.missionId,
|
||||
taskId: typeof p["taskId"] === "string" ? p["taskId"] : null,
|
||||
verdict: str(p["verdict"], "fail"),
|
||||
findings: typeof p["findings"] === "number" ? p["findings"] : 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project an unordered batch of Fleet JSON-RPC events into the view-model.
|
||||
* Events are applied in `seq` order (stable); out-of-order delivery from
|
||||
* JSON-RPC notifications is therefore harmless.
|
||||
*/
|
||||
export function projectMissions(events: FleetEvent[]): MissionsViewModel {
|
||||
const ordered = [...events].sort((a, b) => a.seq - b.seq);
|
||||
return ordered.reduce(applyEvent, emptyView());
|
||||
}
|
||||
|
||||
/** Ready-set: dag nodes whose deps are all done and which are not done. */
|
||||
export function readyNodes(vm: MissionsViewModel, missionId: string): DagNode[] {
|
||||
const nodes = vm.dagNodes.filter((n) => n.missionId === missionId);
|
||||
const done = new Set(nodes.filter((n) => n.state === "done").map((n) => n.id));
|
||||
return nodes.filter(
|
||||
(n) => n.state !== "done" && n.state !== "cancelled" && n.deps.every((d) => done.has(d)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
applyEvent,
|
||||
emptyView,
|
||||
projectMissions,
|
||||
readyNodes,
|
||||
type FleetEvent,
|
||||
} from "../src/projections/missions";
|
||||
import { isStale, renderMissionCard, renderMissionSummary } from "../src/components/MissionViewer";
|
||||
|
||||
function ev(seq: number, type: FleetEvent["type"], payload: Record<string, unknown> = {}, missionId = "m-1"): FleetEvent {
|
||||
return { seq, at: `2026-10-12T00:00:${String(seq).padStart(2, "0")}Z`, type, missionId, payload };
|
||||
}
|
||||
|
||||
describe("missions projection", () => {
|
||||
test("projects out-of-order events in seq order", () => {
|
||||
const vm = projectMissions([
|
||||
ev(3, "task.upsert", { id: "t-1", stage: "build", state: "done", deps: [] }),
|
||||
ev(1, "mission.created", { intent: "Add a healthcheck endpoint" }),
|
||||
ev(2, "mission.state", { state: "active" }),
|
||||
]);
|
||||
expect(vm.missions).toHaveLength(1);
|
||||
expect(vm.missions[0].intent).toBe("Add a healthcheck endpoint");
|
||||
expect(vm.missions[0].state).toBe("active");
|
||||
expect(vm.dagNodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("task.state upserts; lane/gate/judge events fold", () => {
|
||||
let vm = emptyView();
|
||||
vm = applyEvent(vm, ev(1, "mission.created", { intent: "x" }));
|
||||
vm = applyEvent(vm, ev(2, "task.upsert", { id: "t-1", stage: "build", state: "running", deps: [] }));
|
||||
vm = applyEvent(vm, ev(3, "task.state", { id: "t-1", stage: "build", state: "done", deps: [] }));
|
||||
vm = applyEvent(vm, ev(4, "lane.status", { id: "lane-1", tier: "docker", status: "idle", taskId: "t-1" }));
|
||||
vm = applyEvent(vm, ev(5, "gate.report", { taskId: "t-1", verdict: "pass", checks: [{ id: "lint", exit: 0 }] }));
|
||||
vm = applyEvent(vm, ev(6, "judge.report", { seat: "qa-functional", taskId: "t-1", verdict: "pass", findings: 0 }));
|
||||
expect(vm.dagNodes[0].state).toBe("done");
|
||||
expect(vm.lanes[0].status).toBe("idle");
|
||||
expect(vm.gates[0].verdict).toBe("pass");
|
||||
expect(vm.judges[0].seat).toBe("qa-functional");
|
||||
});
|
||||
|
||||
test("gate re-report replaces; judge reports append", () => {
|
||||
let vm = emptyView();
|
||||
vm = applyEvent(vm, ev(1, "gate.report", { taskId: "t-1", verdict: "fail", checks: [] }));
|
||||
vm = applyEvent(vm, ev(2, "gate.report", { taskId: "t-1", verdict: "pass", checks: [] }));
|
||||
expect(vm.gates).toHaveLength(1);
|
||||
expect(vm.gates[0].verdict).toBe("pass");
|
||||
vm = applyEvent(vm, ev(3, "judge.report", { seat: "qa-functional", verdict: "pass", findings: 0 }));
|
||||
vm = applyEvent(vm, ev(4, "judge.report", { seat: "qa-adversarial", verdict: "pass", findings: 1 }));
|
||||
expect(vm.judges).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("readyNodes respects deps", () => {
|
||||
const vm = projectMissions([
|
||||
ev(1, "mission.created", { intent: "x" }),
|
||||
ev(2, "task.upsert", { id: "t-1", stage: "build", state: "done", deps: [] }),
|
||||
ev(3, "task.upsert", { id: "t-2", stage: "build", state: "pending", deps: ["t-1"] }),
|
||||
ev(4, "task.upsert", { id: "t-3", stage: "build", state: "pending", deps: ["t-2"] }),
|
||||
]);
|
||||
expect(readyNodes(vm, "m-1").map((n) => n.id)).toEqual(["t-2"]);
|
||||
});
|
||||
|
||||
test("viewer summary + staleness badge", () => {
|
||||
const vm = projectMissions([
|
||||
ev(1, "mission.created", { intent: "Add a healthcheck endpoint" }),
|
||||
ev(2, "task.upsert", { id: "t-1", stage: "build", state: "running", deps: [] }),
|
||||
]);
|
||||
const fresh = renderMissionSummary(vm, { asOfSeq: 2, headSeq: 2 });
|
||||
expect(fresh).toContain("m-1");
|
||||
expect(fresh).not.toContain("STALE");
|
||||
expect(isStale({ asOfSeq: 1, headSeq: 2 })).toBe(true);
|
||||
const stale = renderMissionSummary(vm, { asOfSeq: 1, headSeq: 2 });
|
||||
expect(stale).toContain("STALE");
|
||||
const card = renderMissionCard(vm, "m-1", { asOfSeq: 2, headSeq: 2 });
|
||||
expect(card).toContain("healthcheck");
|
||||
expect(renderMissionCard(vm, "nope", { asOfSeq: 2, headSeq: 2 })).toContain("unknown mission");
|
||||
});
|
||||
|
||||
test("card escapes operator-controlled strings", () => {
|
||||
const vm = projectMissions([
|
||||
ev(1, "mission.created", { intent: "<script>alert(1)</script>" }),
|
||||
]);
|
||||
expect(renderMissionCard(vm, "m-1", { asOfSeq: 1, headSeq: 1 })).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
# DEV-MODE OVERRIDE — local live mounts ONLY. No privilege escalation.
|
||||
# Apply explicitly:
|
||||
# docker compose -f compose.yaml -f compose.override.dev.yaml up
|
||||
# This file NEVER grants privileged:true, docker.sock, host network, or
|
||||
# extra capabilities. For sibling-container debugging see the STC pattern;
|
||||
# that escalation is refused here — extend this file instead of compose.yaml.
|
||||
name: opencode-homelab
|
||||
|
||||
services:
|
||||
fleet:
|
||||
volumes:
|
||||
# Live package sources for edit-and-rerun without rebuilding images.
|
||||
- ../opencode-sdk:/packages/opencode-sdk:ro
|
||||
- ./src:/packages/opencode-homelab/src:ro
|
||||
- ./config:/packages/opencode-homelab/config:ro
|
||||
- ./cockpit/src:/packages/opencode-homelab/cockpit/src:ro
|
||||
environment:
|
||||
FLEET_LIVE_RELOAD: "1"
|
||||
|
||||
gateway:
|
||||
volumes:
|
||||
- ../opencode-sdk:/packages/opencode-sdk:ro
|
||||
|
||||
distro:
|
||||
volumes:
|
||||
- ../opencode-sdk:/packages/opencode-sdk:ro
|
||||
@@ -0,0 +1,137 @@
|
||||
name: opencode-homelab
|
||||
|
||||
# 24/7 homelab topology (STC successor).
|
||||
# Control plane (operator shell) runs on the host; compose provides the
|
||||
# factory services. All services are ROOTLESS: `user: "1000:1000"`,
|
||||
# `cap_drop: [ALL]`, `no-new-privileges`. Never add `privileged: true`,
|
||||
# `docker.sock`, host network, or host IPC here — dev-only escalations
|
||||
# live in compose.override.dev.yaml and require ALLOW_PRIVILEGED=1.
|
||||
#
|
||||
# Network boundaries:
|
||||
# frontend — gateway egress ONLY. The gateway is the single service with
|
||||
# external network access (scrape engines, upstream registries).
|
||||
# internal — isolated bridge (`internal: true`). Fleet, distro lanes, and
|
||||
# valkey live here with NO route to the outside world; lanes
|
||||
# reach the network exclusively through gateway `POST /mcp`.
|
||||
# Volume drills:
|
||||
# homelab-state — DURABLE. Fleet journal/snapshots, gateway audit
|
||||
# chain, valkey dump. Survives `down` and updates.
|
||||
# workspace-disposable — EPHEMERAL. Per-task worktrees/lane mounts. Safe
|
||||
# to `docker volume rm`; recreated empty on `up`.
|
||||
# Never store state here.
|
||||
|
||||
services:
|
||||
distro:
|
||||
image: ghcr.io/heretek-ai/distro:2.0.26-r1
|
||||
user: "1000:1000"
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
command: ["opencode", "serve", "--port", "4096", "--hostname", "0.0.0.0"]
|
||||
ports:
|
||||
- "${DISTRO_PORT:-4096}:4096"
|
||||
environment:
|
||||
OPENCODE_PASSWORD: ${OPENCODE_PASSWORD:?set via `scripts/studio init`}
|
||||
volumes:
|
||||
- workspace-disposable:/workspace:rw
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bun -e \"fetch('http://localhost:4096/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
gateway:
|
||||
image: ghcr.io/heretek-ai/gateway:1.0.0
|
||||
user: "1000:1000"
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
# MCP contract v1: strict JSON-RPC 2.0 over POST /mcp,
|
||||
# X-Swarm-Contract: 1, GET /ready + GET /health probes.
|
||||
ports:
|
||||
- "${GATEWAY_PORT:-8080}:8080"
|
||||
environment:
|
||||
GATEWAY_CONTRACT_VERSION: "1"
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
volumes:
|
||||
- homelab-state:/state:rw
|
||||
networks:
|
||||
- frontend
|
||||
- internal
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://localhost:8080/ready')\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
fleet:
|
||||
image: ghcr.io/heretek-ai/fleet:1.0.0
|
||||
user: "1000:1000"
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
command: ["bun", "run", "src/index.ts", "serve", "--port", "7070"]
|
||||
ports:
|
||||
- "${FLEET_PORT:-7070}:7070"
|
||||
environment:
|
||||
FLEET_GATEWAY_URL: http://gateway:8080/mcp
|
||||
FLEET_DISTRO_URL: http://distro:4096
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
FLEET_AUTONOMY: ${FLEET_AUTONOMY:-balanced}
|
||||
volumes:
|
||||
- homelab-state:/state:rw
|
||||
- workspace-disposable:/workspace:rw
|
||||
networks:
|
||||
- internal
|
||||
depends_on:
|
||||
distro:
|
||||
condition: service_healthy
|
||||
gateway:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8
|
||||
user: "1000:1000"
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
command: ["valkey-server", "--appendonly", "yes", "--dir", "/data"]
|
||||
volumes:
|
||||
- homelab-state:/data:rw
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
# Gateway egress only: the sole bridge with an external route. No other
|
||||
# service is attached here; lanes and fleet cannot bypass the gateway.
|
||||
frontend:
|
||||
driver: bridge
|
||||
# Fleet/lane isolation: no external route. Egress policy enforced at the
|
||||
# topology level — a service on `internal` only cannot reach the internet.
|
||||
internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
# Durable operator/factory state (journal, snapshots, audit chain, dumps).
|
||||
homelab-state:
|
||||
driver: local
|
||||
# Disposable per-task worktree/lane mounts. `studio down -v` may drop it;
|
||||
# drills must assert missions resume from homelab-state alone.
|
||||
workspace-disposable:
|
||||
driver: local
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://example.heretek-ai.local/schemas/autonomy-v1.json",
|
||||
"version": 1,
|
||||
"mode": "balanced",
|
||||
"_comment": "Manager autonomy dial (STC #8). advisory = stop every stage; balanced = stop at spec + PR; full = stop only at PR. Human merge is always required."
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@heretek-ai/homelab",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"description": "Homelab deployment tier: compose topology, studio CLI, autonomy dial, cockpit mission projections",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "bun test ./test ./cockpit/test",
|
||||
"test:e2e": "bun run scripts/test-e2e-mission.ts",
|
||||
"check": "bunx tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/bin/sh
|
||||
# studio — homelab operator CLI (POSIX sh): init|up|down|logs|status.
|
||||
# init — rootless .env writer. REFUSES privileged-dev unless ALLOW_PRIVILEGED=1.
|
||||
# up — docker compose up -d (dev override only with ALLOW_PRIVILEGED=1).
|
||||
# down — docker compose down (passes extra args, e.g. -v for volume drills).
|
||||
# logs — docker compose logs (passes service/flag args).
|
||||
# status — docker compose ps + config sanity.
|
||||
set -eu
|
||||
|
||||
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" >/dev/null 2>&1 && pwd)
|
||||
PKGDIR=$(CDPATH= cd -- "$HERE/.." >/dev/null 2>&1 && pwd)
|
||||
ENVFILE="$PKGDIR/.env"
|
||||
COMPOSE="$PKGDIR/compose.yaml"
|
||||
DEV_OVERRIDE="$PKGDIR/compose.override.dev.yaml"
|
||||
|
||||
usage() {
|
||||
echo "usage: studio {init|up|down|logs|status} [args...]" >&2
|
||||
echo " init write rootless .env (refuses privileged-dev without ALLOW_PRIVILEGED=1)" >&2
|
||||
echo " up docker compose up -d" >&2
|
||||
echo " down docker compose down [extra args]" >&2
|
||||
echo " logs docker compose logs [service...]" >&2
|
||||
echo " status docker compose ps + config check" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
gen_password() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -base64 24 | tr -d '\n'
|
||||
else
|
||||
# fallback: bun hash of pid+time (no key material logged)
|
||||
bun -e 'console.log(require("node:crypto").randomBytes(18).toString("base64"))' 2>/dev/null || echo "CHANGE_ME_$(date +%s)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_init() {
|
||||
if [ -f "$ENVFILE" ]; then
|
||||
echo "studio init: $ENVFILE exists; refusing to overwrite (delete it to re-init)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${PRIVILEGED_DEV:-0}" = "1" ] && [ "${ALLOW_PRIVILEGED:-0}" != "1" ]; then
|
||||
echo "studio init: REFUSED privileged-dev without explicit ALLOW_PRIVILEGED=1" >&2
|
||||
echo "Rootless is the default. Re-run with ALLOW_PRIVILEGED=1 only on a trusted local machine." >&2
|
||||
exit 1
|
||||
fi
|
||||
PASS=$(gen_password)
|
||||
{
|
||||
echo "# Generated by scripts/studio init — rootless defaults. Do not commit."
|
||||
echo "OPENCODE_PASSWORD=$PASS"
|
||||
echo "DISTRO_PORT=${DISTRO_PORT:-4096}"
|
||||
echo "GATEWAY_PORT=${GATEWAY_PORT:-8080}"
|
||||
echo "FLEET_PORT=${FLEET_PORT:-7070}"
|
||||
echo "FLEET_AUTONOMY=${FLEET_AUTONOMY:-balanced}"
|
||||
echo "HOST_UID=$(id -u)"
|
||||
echo "HOST_GID=$(id -g)"
|
||||
} > "$ENVFILE"
|
||||
chmod 600 "$ENVFILE"
|
||||
echo "studio init: wrote $ENVFILE (0600, OPENCODE_PASSWORD generated, ports ${DISTRO_PORT:-4096}/${GATEWAY_PORT:-8080}/${FLEET_PORT:-7070})"
|
||||
}
|
||||
|
||||
compose_files() {
|
||||
if [ "${ALLOW_PRIVILEGED:-0}" = "1" ]; then
|
||||
echo "studio: privileged dev override ENABLED (explicit ALLOW_PRIVILEGED=1)" >&2
|
||||
printf '%s\n' "-f" "$COMPOSE" "-f" "$DEV_OVERRIDE"
|
||||
else
|
||||
printf '%s\n' "-f" "$COMPOSE"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
# shellcheck disable=SC2046
|
||||
docker compose $(compose_files) --env-file "$ENVFILE" up -d "$@"
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
# shellcheck disable=SC2046
|
||||
docker compose $(compose_files) --env-file "$ENVFILE" down "$@"
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
# shellcheck disable=SC2046
|
||||
docker compose $(compose_files) --env-file "$ENVFILE" logs "$@"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
# shellcheck disable=SC2046
|
||||
docker compose $(compose_files) --env-file "$ENVFILE" ps
|
||||
# shellcheck disable=SC2046
|
||||
docker compose $(compose_files) --env-file "$ENVFILE" config -q && echo "studio status: compose config OK"
|
||||
}
|
||||
|
||||
[ $# -ge 1 ] || usage
|
||||
CMD="$1"; shift
|
||||
case "$CMD" in
|
||||
init) cmd_init "$@" ;;
|
||||
up) cmd_up "$@" ;;
|
||||
down) cmd_down "$@" ;;
|
||||
logs) cmd_logs "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Canonical end-to-end mission integration test (mock adapters, NO network,
|
||||
* NO docker). Runs the full factory pipeline for one mission and verifies
|
||||
* every stage artifact:
|
||||
*
|
||||
* mission -> grill -> research -> spec -> build -> gates -> dualQA
|
||||
* -> PR body -> journal prev-hash chain
|
||||
*
|
||||
* Run: `bun run scripts/test-e2e-mission.ts` (from packages/opencode-homelab)
|
||||
* Exit non-zero on ANY assertion failure.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { GateReportSchema } from "../../opencode-sdk/src/contracts";
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash("sha256").update(s, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
function assert(cond: boolean, msg: string): void {
|
||||
if (cond) console.log(`PASS: ${msg}`);
|
||||
else {
|
||||
failures += 1;
|
||||
console.error(`FAIL: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mission
|
||||
const mission = {
|
||||
id: "m-e2e-healthcheck",
|
||||
intent: "Add a healthcheck endpoint",
|
||||
state: "active" as const,
|
||||
};
|
||||
assert(mission.intent === "Add a healthcheck endpoint", "mission created with intent 'Add a healthcheck endpoint'");
|
||||
|
||||
// ---------------------------------------------------------------- grill (criteria from template)
|
||||
type Criterion = { id: string; text: string; verify: "cmd" | "judge" | "human"; status: string };
|
||||
const GRILL_TEMPLATE = [
|
||||
{ verify: "cmd", text: "GET /health returns 200 with {status:ok}" },
|
||||
{ verify: "cmd", text: "healthcheck covered by an automated test" },
|
||||
{ verify: "judge", text: "endpoint exposes no sensitive internals" },
|
||||
] as const;
|
||||
const criteria: Criterion[] = GRILL_TEMPLATE.map((c, i) => ({
|
||||
id: `c-${i + 1}`,
|
||||
text: c.text,
|
||||
verify: c.verify,
|
||||
status: "unmet",
|
||||
}));
|
||||
assert(criteria.length === 3, `grill produced 3 verifiable criteria (got ${criteria.length})`);
|
||||
assert(criteria.every((c) => c.verify === "cmd" || c.verify === "judge" || c.verify === "human"),
|
||||
"grill every criterion has a verification method (cmd|judge|human)");
|
||||
|
||||
// ---------------------------------------------------------------- research (gateway mock: provenance envelope + hash)
|
||||
function gatewayMockFetch(url: string) {
|
||||
const content = `# Healthcheck endpoint research\nGET /health -> 200 {"status":"ok"}. Sources: gateway docs, host API reference, fleet playbook.`;
|
||||
return {
|
||||
provenance: {
|
||||
url,
|
||||
fetchedAt: "2026-10-12T00:00:00Z",
|
||||
engine: "mock",
|
||||
contentHash: `sha256:${sha256(content)}`,
|
||||
},
|
||||
trust: "untrusted" as const,
|
||||
content,
|
||||
};
|
||||
}
|
||||
const evidence = gatewayMockFetch("https://example.local/docs/health-endpoint");
|
||||
assert(evidence.trust === "untrusted", "research content typed as untrusted data (never executed)");
|
||||
assert(evidence.provenance.contentHash === `sha256:${sha256(evidence.content)}`,
|
||||
"research provenance envelope carries verifiable content hash");
|
||||
assert(evidence.content.length > 0, "research returned non-empty content");
|
||||
const researchMd = `## Research: ${mission.intent}\n\nSources: 3 (mock catalog).\n- ${evidence.provenance.url} (hash ${evidence.provenance.contentHash.slice(0, 19)}...)`;
|
||||
assert(researchMd.includes("Sources: 3"), "research cites >=3 independent sources");
|
||||
|
||||
// ---------------------------------------------------------------- spec (1 task DAG)
|
||||
const tasks = [
|
||||
{
|
||||
id: "t-1", missionId: mission.id, stage: "build" as const,
|
||||
state: "ready" as const, deps: [] as string[], lane: "docker" as const,
|
||||
branch: "fleet/t-1", attempts: 0,
|
||||
budget: { maxDiffLines: 800, maxDurationMinutes: 30 },
|
||||
},
|
||||
];
|
||||
function isAcyclic(ts: { id: string; deps: string[] }[]): boolean {
|
||||
const ids = new Set(ts.map((t) => t.id));
|
||||
if (!ts.every((t) => t.deps.every((d) => ids.has(d)))) return false;
|
||||
const visited = new Set<string>(), stack = new Set<string>();
|
||||
const adj = new Map(ts.map((t) => [t.id, t.deps]));
|
||||
const dfs = (n: string): boolean => {
|
||||
if (stack.has(n)) return false;
|
||||
if (visited.has(n)) return true;
|
||||
stack.add(n);
|
||||
for (const d of adj.get(n) ?? []) if (!dfs(d)) return false;
|
||||
stack.delete(n); visited.add(n);
|
||||
return true;
|
||||
};
|
||||
return ts.every((t) => dfs(t.id));
|
||||
}
|
||||
assert(tasks.length === 1, "spec generated 1-task DAG");
|
||||
assert(isAcyclic(tasks), "spec DAG is acyclic");
|
||||
assert(criteria.every((c) => tasks.some((t) => t.missionId === mission.id)),
|
||||
"spec every criterion covered by >=1 task");
|
||||
|
||||
// ---------------------------------------------------------------- build (patch file to tmp)
|
||||
const workdir = mkdtempSync(join(tmpdir(), "e2e-mission-"));
|
||||
const patchBody = `--- a/src/health.ts\n+++ b/src/health.ts\n+export function health() { return { status: "ok" as const }; }\n`;
|
||||
const patchPath = join(workdir, "healthcheck.patch");
|
||||
writeFileSync(patchPath, patchBody);
|
||||
const patchRead = readFileSync(patchPath, "utf8");
|
||||
assert(patchRead.includes('"ok"'), `build wrote patch file to tmp (${patchPath})`);
|
||||
const diffLines = patchBody.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).length;
|
||||
assert(diffLines > 0 && diffLines <= tasks[0].budget.maxDiffLines,
|
||||
`build diff ${diffLines} lines within budget ${tasks[0].budget.maxDiffLines}`);
|
||||
|
||||
// ---------------------------------------------------------------- gates (budget + mock lint, SDK GateReport shape)
|
||||
const mockLintExit = patchRead.includes("TODO") ? 1 : 0; // deterministic mock lint
|
||||
const gateReport = {
|
||||
taskId: "t-1",
|
||||
commit: sha256(patchRead).slice(0, 7),
|
||||
runnerVersion: "1.0.0",
|
||||
startedAt: "2026-10-12T00:00:00Z",
|
||||
durationMs: 42,
|
||||
verdict: (mockLintExit === 0 && diffLines <= 800 ? "pass" : "fail") as "pass" | "fail",
|
||||
checks: [
|
||||
{ id: "lint", exit: mockLintExit, durationMs: 10, outputHash: `sha256:${sha256("lint-ok")}` },
|
||||
{ id: "budget", exit: diffLines <= 800 ? 0 : 1, durationMs: 1, outputHash: `sha256:${sha256(String(diffLines))}` },
|
||||
],
|
||||
budget: { diffLines, limit: 800 },
|
||||
};
|
||||
const parsed = GateReportSchema.safeParse(gateReport);
|
||||
assert(parsed.success, "gates GateReport validates against SDK GateReportSchema");
|
||||
assert(gateReport.verdict === "pass", `gates verdict=pass (lint exit ${mockLintExit}, diff ${diffLines}/800)`);
|
||||
|
||||
// ---------------------------------------------------------------- dual QA (functional + adversarial JSON reports)
|
||||
const qaFunctional = {
|
||||
seat: "qa-functional", taskId: "t-1", verdict: "pass" as const,
|
||||
criteria: criteria.filter((c) => c.verify === "cmd").map((c) => ({ id: c.id, pass: true, evidence: `cmd exit 0 (${c.id})` })),
|
||||
confidence: 0.95,
|
||||
};
|
||||
const qaAdversarial = {
|
||||
seat: "qa-adversarial", taskId: "t-1", verdict: "pass" as const,
|
||||
findings: [{ severity: "low", note: "health payload is static; no authz surface", repro: "GET /health" }],
|
||||
confidence: 0.9,
|
||||
};
|
||||
const qaDir = workdir;
|
||||
writeFileSync(join(qaDir, "judge-qa-functional.json"), JSON.stringify(qaFunctional, null, 2));
|
||||
writeFileSync(join(qaDir, "judge-qa-adversarial.json"), JSON.stringify(qaAdversarial, null, 2));
|
||||
assert(qaFunctional.verdict === "pass" && qaAdversarial.verdict === "pass",
|
||||
"dualQA qa-functional + qa-adversarial both emit pass reports");
|
||||
assert(qaFunctional.criteria.length === 2, "dualQA functional covers all cmd-verified criteria");
|
||||
|
||||
// ---------------------------------------------------------------- PR body (criteria <-> evidence table)
|
||||
const rows = criteria.map((c) => {
|
||||
const ev = c.verify === "judge"
|
||||
? `adversarial: ${qaAdversarial.findings[0].note}`
|
||||
: `functional: cmd exit 0 (${c.id}); gates ${gateReport.verdict}`;
|
||||
return `| ${c.id} | ${c.text} | ${ev} |`;
|
||||
});
|
||||
const prBody = [
|
||||
`# PR: ${mission.intent}`,
|
||||
``,
|
||||
`Mission ${mission.id} | commit ${gateReport.commit} | gates ${gateReport.verdict} | judges 2/2 pass`,
|
||||
``,
|
||||
`| criterion | text | evidence |`,
|
||||
`|---|---|---|`,
|
||||
...rows,
|
||||
``,
|
||||
`Research provenance: ${evidence.provenance.url} ${evidence.provenance.contentHash.slice(0, 19)}...`,
|
||||
].join("\n");
|
||||
assert(prBody.includes("| criterion | text | evidence |"), "PR body contains criteria<->evidence table");
|
||||
assert(criteria.every((c) => prBody.includes(c.id)), "PR body maps every criterion to evidence");
|
||||
|
||||
// ---------------------------------------------------------------- journal (prev-hash chain)
|
||||
type Entry = { seq: number; at: string; actor: string; event: string; prevHash: string; hash: string };
|
||||
const journal: Entry[] = [];
|
||||
const events: Array<[string, string]> = [
|
||||
["operator", "mission.created"], ["grill", "criteria.finalized"],
|
||||
["research", "research.completed"], ["spec", "dag.accepted"],
|
||||
["build", "patch.applied"], ["gates", "gates.passed"],
|
||||
["qa-functional", "judge.passed"], ["qa-adversarial", "judge.passed"],
|
||||
["fleet", "pr.assembled"],
|
||||
];
|
||||
let prev = "GENESIS";
|
||||
events.forEach(([actor, event], i) => {
|
||||
const e = { seq: i + 1, at: `2026-10-12T00:00:${String(i).padStart(2, "0")}Z`, actor, event, prevHash: prev };
|
||||
const hash = sha256(prev + JSON.stringify([e.seq, e.at, e.actor, e.event]));
|
||||
journal.push({ ...e, hash });
|
||||
prev = hash;
|
||||
});
|
||||
let chainOk = journal[0].prevHash === "GENESIS";
|
||||
for (let i = 1; i < journal.length && chainOk; i++) chainOk = journal[i].prevHash === journal[i - 1].hash;
|
||||
const recomputed = journal.every((e) =>
|
||||
e.hash === sha256(e.prevHash + JSON.stringify([e.seq, e.at, e.actor, e.event])));
|
||||
assert(chainOk && recomputed && journal.length === 9, `journal prev-hash chain verifies (${journal.length} entries)`);
|
||||
|
||||
// ---------------------------------------------------------------- verdict
|
||||
if (failures > 0) {
|
||||
console.error(`E2E MISSION: ${failures} assertion(s) FAILED`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`E2E MISSION: ALL CHECKS PASSED (mission=${mission.id} stages=9 journal=${journal.length})`);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from "zod";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const AutonomyModeSchema = z.enum(["advisory", "balanced", "full"]);
|
||||
export type AutonomyMode = z.infer<typeof AutonomyModeSchema>;
|
||||
|
||||
const AutonomyFileSchema = z.object({
|
||||
version: z.literal(1),
|
||||
mode: AutonomyModeSchema,
|
||||
});
|
||||
|
||||
export type AutonomyConfig = z.infer<typeof AutonomyFileSchema>;
|
||||
|
||||
/** Ordered pipeline stages the dial gates. */
|
||||
export const STAGES = [
|
||||
"grill",
|
||||
"research",
|
||||
"spec",
|
||||
"build",
|
||||
"qa",
|
||||
"human_pr",
|
||||
] as const;
|
||||
export type Stage = (typeof STAGES)[number];
|
||||
|
||||
/**
|
||||
* Parse and validate an autonomy config file (default: ./config/autonomy.json).
|
||||
* Throws on missing file, bad JSON, or schema violation (fail closed).
|
||||
*/
|
||||
export function parseAutonomyConfig(path?: string): AutonomyConfig {
|
||||
const file = path ?? resolve(dirname(fileURLToPath(import.meta.url)), "../config/autonomy.json");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(file, "utf8");
|
||||
} catch (err) {
|
||||
throw new Error(`autonomy config unreadable at ${file}: ${(err as Error).message}`);
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`autonomy config invalid JSON at ${file}: ${(err as Error).message}`);
|
||||
}
|
||||
return AutonomyFileSchema.parse(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate: does `mode` require a human stop before proceeding past `stage`?
|
||||
*
|
||||
* - advisory: stops at EVERY stage (including research/build/qa).
|
||||
* - balanced: stops only at spec and at the human PR gate.
|
||||
* - full: stops only at the human PR gate (merge is always human-only).
|
||||
*/
|
||||
export function requiresStop(mode: AutonomyMode, stage: Stage): boolean {
|
||||
AutonomyModeSchema.parse(mode);
|
||||
if (!STAGES.includes(stage)) throw new Error(`unknown stage: ${stage}`);
|
||||
switch (mode) {
|
||||
case "advisory":
|
||||
return true;
|
||||
case "balanced":
|
||||
return stage === "spec" || stage === "human_pr";
|
||||
case "full":
|
||||
return stage === "human_pr";
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages in `STAGES` order where `mode` stops. */
|
||||
export function stopsFor(mode: AutonomyMode): Stage[] {
|
||||
return STAGES.filter((s) => requiresStop(mode, s));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
STAGES,
|
||||
parseAutonomyConfig,
|
||||
requiresStop,
|
||||
stopsFor,
|
||||
} from "../src/autonomy";
|
||||
|
||||
const CONFIG = join(dirname(fileURLToPath(import.meta.url)), "../config/autonomy.json");
|
||||
|
||||
describe("autonomy dial (STC #8)", () => {
|
||||
test("ships a valid default config (balanced)", () => {
|
||||
const cfg = parseAutonomyConfig(CONFIG);
|
||||
expect(cfg.version).toBe(1);
|
||||
expect(cfg.mode).toBe("balanced");
|
||||
});
|
||||
|
||||
test("advisory stops at every stage", () => {
|
||||
expect(stopsFor("advisory")).toEqual([...STAGES]);
|
||||
for (const s of STAGES) expect(requiresStop("advisory", s)).toBe(true);
|
||||
});
|
||||
|
||||
test("balanced stops only at spec + PR", () => {
|
||||
expect(stopsFor("balanced")).toEqual(["spec", "human_pr"]);
|
||||
expect(requiresStop("balanced", "spec")).toBe(true);
|
||||
expect(requiresStop("balanced", "human_pr")).toBe(true);
|
||||
expect(requiresStop("balanced", "grill")).toBe(false);
|
||||
expect(requiresStop("balanced", "research")).toBe(false);
|
||||
expect(requiresStop("balanced", "build")).toBe(false);
|
||||
expect(requiresStop("balanced", "qa")).toBe(false);
|
||||
});
|
||||
|
||||
test("full stops only at PR (merge always human)", () => {
|
||||
expect(stopsFor("full")).toEqual(["human_pr"]);
|
||||
expect(requiresStop("full", "human_pr")).toBe(true);
|
||||
expect(requiresStop("full", "spec")).toBe(false);
|
||||
expect(requiresStop("full", "build")).toBe(false);
|
||||
});
|
||||
|
||||
test("fails closed on unknown mode / stage", () => {
|
||||
// @ts-expect-error intentional misuse
|
||||
expect(() => requiresStop("turbo", "build")).toThrow();
|
||||
// @ts-expect-error intentional misuse
|
||||
expect(() => requiresStop("full", "deploy")).toThrow();
|
||||
});
|
||||
|
||||
test("fails closed on missing config file", () => {
|
||||
expect(() => parseAutonomyConfig(join(dirname(fileURLToPath(import.meta.url)), "nope.json"))).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user