merge: feat/distro-sdk into main (Tier 0+1 distro & SDK)

This commit is contained in:
Heretek-OpenCode Coordinator committed 2026-10-12 13:11:05 -04:00
commit aadb39ffbc
17 files changed
+920 -1

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "heretek-opencode-monorepo",
"devDependencies": {
"@types/node": "^22.0.0",
"bun-types": "^1.4.3",
"oxlint": "^0.15.0",
"typescript": "^5.5.0",
"zod": "^3.23.8",
},
},
"packages/opencode-distro": {
"name": "@heretek-ai/distro",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0",
},
},
"packages/opencode-sdk": {
"name": "@heretek-ai/sdk",
"version": "1.0.0",
"dependencies": {
"zod": "^3.23.8",
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0",
},
},
},
"packages": {
"@heretek-ai/distro": ["@heretek-ai/distro@workspace:packages/opencode-distro"],
"@heretek-ai/sdk": ["@heretek-ai/sdk@workspace:packages/opencode-sdk"],
"@oxlint/darwin-arm64": ["@oxlint/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7GOyGM6D36lUhsOvavAVpF72SycPVG0Enunx0bzv8g0+9TklzOSFN3FJlZjLst14VPdZWujZMLgkQC7tOp+Rwg=="],
"@oxlint/darwin-x64": ["@oxlint/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-pbrnYFwMn/fuX0z3IeQ05Nvo/b1zGxjmmWgkrQSDwYHxBxP6NT41hk1pmqkcA+v53xk9wvOa/6vBBI/U30F8Ow=="],
"@oxlint/linux-arm64-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-QWjG3YVsDlIvDTBUPmtPiyqP34ZQpFJqQh2JO94pBih11lFxQ0IGVMEXDhmW3WdiSFPZSJsZGzWynalM9eg+RA=="],
"@oxlint/linux-arm64-musl": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-4W0YsmMSbNzzExOWhk+6zNfmJEmKFqSjFIn8CKLtYFvH8kF6KjoW4/0HNsDNYW5Fz+KOut/2JgkvxAiKH+r0zA=="],
"@oxlint/linux-x64-gnu": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-agP3e+eQ6tE5tqN6VI4Uukx2yvjwYFjtrDMcB19J7PmGOaFRwuMuT0sNWK/9guvhuS9aCINNZTi3kEhMy9Qgng=="],
"@oxlint/linux-x64-musl": ["@oxlint/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-L2qE9NhhUafsJOO4pofLx/0hW5IB0sfJa6bS85q0j+ySaI0f3CxMaAadrZLFSuqHWB3oF18B5yvzaPWsc2ohbQ=="],
"@oxlint/win32-arm64": ["@oxlint/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-B7f4VAS/E78n8zy6XZlNeyYOtWTel4BJn/22Ap2yEAlNzO34ot8dGfpLk6MqTUWJrRnARwVBVmc3wRVrsOT5yg=="],
"@oxlint/win32-x64": ["@oxlint/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-ZM9T3/OpaQ3qvrk/VuHO2EQmhNH4cOZdr/b/Ju9VKwBr+ahhqMn3W5srrplWQWxfsb0yd1yBj7iD0jdAps2iLg=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
"oxlint": ["[email protected]", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "0.15.15", "@oxlint/darwin-x64": "0.15.15", "@oxlint/linux-arm64-gnu": "0.15.15", "@oxlint/linux-arm64-musl": "0.15.15", "@oxlint/linux-x64-gnu": "0.15.15", "@oxlint/linux-x64-musl": "0.15.15", "@oxlint/win32-arm64": "0.15.15", "@oxlint/win32-x64": "0.15.15" }, "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-oQNc1mAHrrbKiXyKJMGs9VCZfwGfLy7YiQKa4qupi71X/u4xyWqOh36YKXqWOXnmm2y7vfWFpGZlhJPAa9tMqA=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}
+1
View File
@@ -11,6 +11,7 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"bun-types": "^1.4.3",
"oxlint": "^0.15.0",
"typescript": "^5.5.0",
"zod": "^3.23.8"
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# Heretek-OpenCode host distribution image (upstream OpenCode v2.0.26, pinned).
# Multi-arch: build with `docker buildx build --platform linux/amd64,linux/arm64`.
ARG BUN_VERSION=1.4.2
ARG HOST_VERSION=2.0.26
FROM --platform=$BUILDPLATFORM oven/bun:${BUN_VERSION} AS install
ARG HOST_VERSION
WORKDIR /app
# Install the pinned host CLI only; no source fork in this layer.
RUN bun add -g opencode-ai@${HOST_VERSION}
FROM oven/bun:${BUN_VERSION}-slim
ARG HOST_VERSION
LABEL org.heretek-ai.distro="opencode-distro" \
org.heretek-ai.host-version="${HOST_VERSION}"
ENV OPENCODE_PORT=4096
RUN adduser --disabled-password --gecos "" opencode \
&& mkdir -p /home/opencode/.local/share/opencode /workspace \
&& chown -R opencode:opencode /home/opencode /workspace
COPY --from=install /usr/local/bin/opencode /usr/local/bin/opencode
USER opencode
WORKDIR /workspace
EXPOSE 4096
# NOTE: upstream v2.0.26 exposes no /api/health; /api/info is the
# verified liveness probe (see openapi.json: 118 paths, /api/info GET).
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bun -e "const r=await fetch('http://127.0.0.1:4096/api/info');if(!r.ok)process.exit(1)"
CMD ["opencode", "serve", "--port", "4096"]
+34
View File
@@ -0,0 +1,34 @@
# @heretek-ai/distro
Pinned OpenCode host distribution wrapper. Upstream OpenCode **v2.0.26**
(`anomalyco/opencode`, branch `v2`) is treated as an external engine:
pinned, wrapped, never forked in-tree.
## Contents
| File | Role |
|---|---|
| `Dockerfile` | Multi-arch Bun image running `opencode serve --port 4096` as unprivileged `opencode` user; liveness probe `GET /api/info` |
| `compat.json` | Machine-readable compat matrix (`sdk`, `host{min,max,tested}`, `gatewayContract`) |
| `scripts/sync.ts` | Upstream sync check: compares vendor version against the pin, verifies the patch queue applies, emits a JSON drift report |
| `patches/` | Patch queue (empty at bootstrap) |
## Usage
```sh
# drift report (non-zero exit with --check when drifted)
bun scripts/sync.ts --upstream=/path/to/opencode
bun scripts/sync.ts --check --upstream=/path/to/opencode
# image (multi-arch)
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/heretek-ai/distro:2.0.26-r1 packages/opencode-distro
docker run -p 4096:4096 ghcr.io/heretek-ai/distro:2.0.26-r1
```
## Notes
- Upstream v2.0.26 exposes **no `/api/health`** (verified against
`packages/protocol/openapi.json`, 118 paths); the image healthcheck and
SDK probes use `GET /api/info`.
- `private: true` is staging-only; `publishConfig.access: public` is set for
the future npm publish.
+9
View File
@@ -0,0 +1,9 @@
{
"sdk": "1.0.0",
"host": {
"min": "2.0.26",
"max": "2.0.x",
"tested": ["2.0.26"]
},
"gatewayContract": "1.0.0"
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@heretek-ai/distro",
"version": "1.0.0",
"private": true,
"description": "Pinned OpenCode host distribution wrapper (host 2.0.26).",
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"sync": "bun scripts/sync.ts",
"sync:check": "bun scripts/sync.ts --check"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
/** Upstream sync check: verify pinned vendor version, emit drift report as JSON. */
const EXPECTED_HOST = "2.0.26";
const UPSTREAM_REPO = "anomalyco/opencode";
interface DriftReport {
expectedHost: string;
vendorVersion: string | null;
vendorCommit: string | null;
patchQueueApplies: boolean;
drift: boolean;
notes: string[];
}
async function readVendorVersion(upstreamRef: string): Promise<{ version: string | null; commit: string | null }> {
try {
const pkg = await Bun.file(`${upstreamRef}/package.json`).json() as { version?: string };
let commit: string | null = null;
const proc = Bun.spawnSync(["git", "-C", upstreamRef, "rev-parse", "--short", "HEAD"]);
if (proc.exitCode === 0) commit = proc.stdout.toString().trim() || null;
return { version: typeof pkg.version === "string" ? pkg.version : null, commit };
} catch {
return { version: null, commit: null };
}
}
async function checkPatchQueue(distroDir: string): Promise<boolean> {
// Empty patch queue applies trivially. If patches/ holds *.patch files and a
// vendor checkout exists, verify with `git apply --check`.
const glob = new Bun.Glob("patches/*.patch");
const patches: string[] = [];
for await (const f of glob.scan({ cwd: distroDir, absolute: true })) patches.push(f);
if (patches.length === 0) return true;
const proc = Bun.spawnSync(["git", "apply", "--check", ...patches]);
return proc.exitCode === 0;
}
export async function buildDriftReport(upstreamRef: string, distroDir: string): Promise<DriftReport> {
const notes: string[] = [];
const { version, commit } = await readVendorVersion(upstreamRef);
if (version === null) notes.push(`vendor package.json unreadable at ${upstreamRef}`);
else if (version !== EXPECTED_HOST) notes.push(`vendor version ${version} != pinned ${EXPECTED_HOST}`);
const patchQueueApplies: boolean = await checkPatchQueue(distroDir);
if (!patchQueueApplies) notes.push("patch queue does not apply cleanly");
const drift: boolean = version !== EXPECTED_HOST || !patchQueueApplies;
if (!drift) notes.push("in sync: vendor matches pin, patch queue applies");
return { expectedHost: EXPECTED_HOST, vendorVersion: version, vendorCommit: commit, patchQueueApplies, drift, notes };
}
const checkOnly: boolean = process.argv.includes("--check");
const upstreamRef: string = process.argv.find((a: string) => a.startsWith("--upstream="))?.split("=")[1]
?? `${import.meta.dir}/../../../opencode`;
const report: DriftReport = await buildDriftReport(upstreamRef, import.meta.dir);
console.log(JSON.stringify({ upstream: UPSTREAM_REPO, ...report }, null, 2));
if (checkOnly && report.drift) process.exit(1);
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@heretek-ai/sdk",
"version": "1.0.0",
"private": false,
"type": "module",
"description": "Shared contracts, host client, gate runner, and testkit for the Heretek-OpenCode ecosystem.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"check": "bunx tsc --noEmit",
"test": "bun test test/"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0"
}
}
+103
View File
@@ -38,8 +38,13 @@ export const GateReportSchema = z.object({
exit: z.number(),
durationMs: z.number(),
outputHash: z.string(),
timedOut: z.boolean().default(false),
})),
budget: z.object({ diffLines: z.number(), limit: z.number() }),
artifacts: z.array(z.object({
path: z.string(),
sha256: z.string(),
})).default([]),
});
export const MCPToolDefSchema = z.object({
@@ -48,8 +53,106 @@ export const MCPToolDefSchema = z.object({
inputSchema: z.record(z.any()),
});
// --- additions (blueprint §5.1.1, §5.1.3) ---
export const LaneSchema = z.object({
id: z.string(),
tier: z.enum(["bwrap", "docker", "dagger"]),
image: z.string(),
mounts: z.array(z.string()).default([]),
network: z.enum(["none", "gateway-only"]),
});
export const GateCheckSchema = z.object({
id: z.string(),
kind: z.enum(["lint", "typecheck", "tests-changed", "security", "budget", "custom"]),
cmd: z.string().optional(),
timeoutSeconds: z.number().default(300),
cacheKey: z.string().optional(),
maxDiffLines: z.number().optional(),
});
export const GatesConfigSchema = z.object({
version: z.literal(1),
gates: z.array(GateCheckSchema),
});
export const DiffBudgetSchema = z.object({
diffLines: z.number(),
limit: z.number(),
});
export const MCPToolResultSchema = z.object({
tool: z.string(),
content: z.string(),
structuredContent: z.record(z.any()).optional(),
contentHash: z.string(),
error: z.string().optional(),
});
export const PackManifestSchema = z.object({
id: z.string(),
version: z.string(),
hostCompat: z.string(),
laneRequires: z.array(z.object({
capability: z.string(),
image: z.string(),
})).default([]),
tools: z.array(z.object({
server: z.string(),
transport: z.enum(["stdio", "http"]).default("stdio"),
})).default([]),
agents: z.array(z.string()).default([]),
skills: z.array(z.string()).default([]),
commands: z.array(z.object({
name: z.string(),
handler: z.string(),
})).default([]),
});
export const ReadinessResultSchema = z.object({
packId: z.string(),
status: z.enum(["ready", "degraded"]),
reason: z.string().optional(),
});
export const JudgeReportSchema = z.object({
seat: z.enum(["qa-functional", "qa-adversarial"]),
verdict: z.enum(["pass", "fail"]),
criteria: z.array(z.object({
id: z.string(),
pass: z.boolean(),
evidence: z.string(),
})).default([]),
findings: z.array(z.object({
severity: z.enum(["low", "medium", "high", "critical"]),
summary: z.string(),
reproduction: z.string(),
})).default([]),
evidence: z.array(z.string()).default([]),
confidence: z.number().min(0).max(1),
});
export const JournalEntrySchema = z.object({
seq: z.number(),
at: z.string(),
actor: z.string(),
event: z.string(),
prevHash: z.string(),
hash: z.string(),
});
export type Criterion = z.infer<typeof CriterionSchema>;
export type Task = z.infer<typeof TaskSchema>;
export type Mission = z.infer<typeof MissionSchema>;
export type GateReport = z.infer<typeof GateReportSchema>;
export type MCPToolDef = z.infer<typeof MCPToolDefSchema>;
export type Lane = z.infer<typeof LaneSchema>;
export type GateCheck = z.infer<typeof GateCheckSchema>;
export type GatesConfig = z.infer<typeof GatesConfigSchema>;
export type DiffBudget = z.infer<typeof DiffBudgetSchema>;
export type MCPToolResult = z.infer<typeof MCPToolResultSchema>;
export type PackManifest = z.infer<typeof PackManifestSchema>;
export type ReadinessResult = z.infer<typeof ReadinessResultSchema>;
export type JudgeReport = z.infer<typeof JudgeReportSchema>;
export type JournalEntry = z.infer<typeof JournalEntrySchema>;
+130
View File
@@ -0,0 +1,130 @@
/** Mechanical gate runner: exec gates from gates.yaml, hash outputs, emit GateReport. */
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { GateReportSchema, GatesConfigSchema, type GateReport, type GatesConfig } from "../contracts.js";
export const RUNNER_VERSION = "1.0.0";
export interface GateTaskInfo {
taskId: string;
commit: string;
/** Working directory in which gate commands run. Defaults to process.cwd(). */
workdir?: string;
/** Diff line count for the budget check. Defaults to 0 (no diff measured). */
diffLines?: number;
}
interface CheckResult {
id: string;
exit: number;
durationMs: number;
outputHash: string;
timedOut: boolean;
}
function sha256Hex(input: string): string {
return `sha256:${createHash("sha256").update(input, "utf8").digest("hex")}`;
}
function parseGatesYaml(text: string): GatesConfig {
// Minimal YAML subset: version + list of {id, kind, cmd, timeoutSeconds, cacheKey, maxDiffLines}.
// Supports the canonical gates.yaml shape only; anything else throws.
const lines: string[] = text.split("\n");
let version = 0;
const gates: Record<string, string | number>[] = [];
let current: Record<string, string | number> | null = null;
const scalar = (v: string): string | number => {
const t: string = v.trim().replace(/^["']|["']$/g, "");
const n: number = Number(t);
return t !== "" && Number.isFinite(n) ? n : t;
};
for (const raw of lines) {
const line: string = raw.replace(/#.*$/, "").replace(/\t/g, " ");
if (line.trim() === "" || line.trim() === "gates:") continue;
const versionMatch: RegExpMatchArray | null = /^version:\s*(.+)$/.exec(line.trim());
if (versionMatch) {
version = Number(versionMatch[1].trim());
continue;
}
const itemMatch: RegExpMatchArray | null = /^\s*-\s*id:\s*(.+)$/.exec(line);
if (itemMatch) {
current = { id: String(scalar(itemMatch[1])) };
gates.push(current);
continue;
}
const kvMatch: RegExpMatchArray | null = /^\s{2,}(\w+):\s*(.+)$/.exec(line);
if (kvMatch && current) {
current[kvMatch[1]] = scalar(kvMatch[2]);
}
}
return GatesConfigSchema.parse({ version, gates });
}
function execWithTimeout(cmd: string, workdir: string, timeoutMs: number): Promise<{ exit: number; output: string; timedOut: boolean }> {
return new Promise((resolve) => {
const child = execFile("sh", ["-c", cmd], { cwd: workdir, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
const output: string = `${stdout ?? ""}${stderr ?? ""}`;
if (error && typeof error === "object" && "killed" in error && (error as { killed: boolean }).killed) {
resolve({ exit: 124, output, timedOut: true });
return;
}
const exit: number = error && typeof error === "object" && "code" in error
? Number((error as { code: unknown }).code) || 1
: 0;
resolve({ exit, output, timedOut: false });
});
child.on("error", (err: Error) => resolve({ exit: 127, output: String(err), timedOut: false }));
});
}
export async function runGates(configPath: string, taskInfo: GateTaskInfo): Promise<GateReport> {
const startedAt: string = new Date().toISOString();
const startMs: number = Date.now();
const workdir: string = taskInfo.workdir ?? process.cwd();
const text: string = await readFile(configPath, "utf8");
const config: GatesConfig = parseGatesYaml(text);
const checks: CheckResult[] = [];
let budgetLimit = 0;
for (const gate of config.gates) {
if (gate.kind === "budget") {
budgetLimit = gate.maxDiffLines ?? 0;
continue;
}
if (!gate.cmd) {
checks.push({ id: gate.id, exit: 1, durationMs: 0, outputHash: sha256Hex("missing cmd"), timedOut: false });
continue;
}
const t0: number = Date.now();
const result = await execWithTimeout(gate.cmd, workdir, gate.timeoutSeconds * 1000);
checks.push({
id: gate.id,
exit: result.exit,
durationMs: Date.now() - t0,
outputHash: sha256Hex(result.output),
timedOut: result.timedOut,
});
}
const diffLines: number = taskInfo.diffLines ?? 0;
const budgetFailed: boolean = budgetLimit > 0 && diffLines > budgetLimit;
const checksFailed: boolean = checks.some((c) => c.exit !== 0);
const timedOut: boolean = checks.some((c) => c.timedOut);
const verdict: "pass" | "fail" | "timeout" = timedOut && checksFailed && checks.every((c) => c.exit === 0 || c.timedOut)
? "timeout"
: checksFailed || budgetFailed ? "fail" : "pass";
const report = {
taskId: taskInfo.taskId,
commit: taskInfo.commit,
runnerVersion: RUNNER_VERSION,
startedAt,
durationMs: Date.now() - startMs,
verdict,
checks,
budget: { diffLines, limit: budgetLimit },
artifacts: [],
};
return GateReportSchema.parse(report);
}
+150
View File
@@ -0,0 +1,150 @@
/** Typed REST client for the OpenCode v2 host engine (pinned 2.0.26). */
export interface HostClientOptions {
baseUrl: string;
/** Injectable fetch for testability. Defaults to global fetch. */
fetchImpl?: typeof fetch;
/** Poll interval (ms) used by the wait() fallback path. */
pollIntervalMs?: number;
}
export interface PromptResult {
inboxId: string;
raw: unknown;
}
export interface FileDiff {
file: string;
patch: string;
additions: number;
deletions: number;
status: string;
}
export interface HostMessage {
id: string;
role: string;
[key: string]: unknown;
}
export class HostClientError extends Error {
public readonly status: number;
public readonly body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = "HostClientError";
this.status = status;
this.body = body;
}
}
const DEFAULT_POLL_MS = 1000;
export class HostClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly pollIntervalMs: number;
constructor(options: HostClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.fetchImpl = options.fetchImpl ?? fetch;
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_MS;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
const res: Response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers: body !== undefined ? { "content-type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return { status: 204, data: undefined as T };
const text: string = await res.text();
if (!res.ok) throw new HostClientError(`host ${method} ${path} failed: ${res.status}`, res.status, text);
const parsed: unknown = text.length > 0 ? (JSON.parse(text) as unknown) : undefined;
return { status: res.status, data: parsed as T };
}
/** Enqueue a prompt; returns the inbox item (async admission, not an answer). */
async prompt(sessionId: string, msg: string): Promise<PromptResult> {
const { data } = await this.request<{ data: { id?: string } & Record<string, unknown> }>(
"POST",
`/api/session/${encodeURIComponent(sessionId)}/prompt`,
{ text: msg },
);
const inbox = data?.data ?? {};
return { inboxId: typeof inbox.id === "string" ? inbox.id : "", raw: inbox };
}
/**
* Race-free idle barrier. Uses POST experimental wait; on 404/501
* (endpoint missing) falls back to polling session.message.list.
*/
async wait(sessionId: string, timeoutMs: number): Promise<void> {
const deadline: number = Date.now() + timeoutMs;
try {
const controller = new AbortController();
const timer: ReturnType<typeof setTimeout> = setTimeout(() => controller.abort(), timeoutMs);
try {
await this.fetchImpl(
`${this.baseUrl}/api/experimental/session/${encodeURIComponent(sessionId)}/wait`,
{ method: "POST", signal: controller.signal },
).then(async (res: Response) => {
if (res.status === 404 || res.status === 501) throw new HostClientError("wait unavailable", res.status, "");
if (res.status === 204) return;
if (!res.ok) throw new HostClientError(`wait failed: ${res.status}`, res.status, await res.text());
});
return;
} finally {
clearTimeout(timer);
}
} catch (err: unknown) {
if (!(err instanceof HostClientError) || (err.status !== 404 && err.status !== 501)) {
if (err instanceof HostClientError) throw err;
// AbortError means our own timeout elapsed: surface as timeout.
if (err instanceof Error && err.name === "AbortError") {
throw new HostClientError("wait timed out", 408, "");
}
}
// Fall through to polling fallback.
}
await this.pollUntilIdle(sessionId, deadline);
}
private async pollUntilIdle(sessionId: string, deadline: number): Promise<void> {
let lastCount = -1;
let stableRounds = 0;
for (;;) {
if (Date.now() >= deadline) throw new HostClientError("wait timed out", 408, "");
const messages: HostMessage[] = await this.listMessages(sessionId);
if (messages.length === lastCount) {
stableRounds += 1;
if (stableRounds >= 2) return;
} else {
stableRounds = 0;
lastCount = messages.length;
}
await new Promise<void>((resolve) => setTimeout(resolve, this.pollIntervalMs));
}
}
async diff(sessionId: string): Promise<FileDiff[]> {
const { data } = await this.request<{ data: FileDiff[] }>(
"GET",
`/api/session/${encodeURIComponent(sessionId)}/diff`,
);
return data?.data ?? [];
}
async interrupt(sessionId: string): Promise<void> {
await this.request<unknown>("POST", `/api/session/${encodeURIComponent(sessionId)}/interrupt`);
}
async listMessages(sessionId: string): Promise<HostMessage[]> {
const { data } = await this.request<{ data: HostMessage[] }>(
"GET",
`/api/session/${encodeURIComponent(sessionId)}/message`,
);
const items: unknown = data?.data;
return Array.isArray(items) ? (items as HostMessage[]) : [];
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./contracts.js";
export * from "./host/client.js";
export * from "./gates/runner.js";
export * as testkit from "./testkit/index.js";
@@ -0,0 +1,80 @@
/** Minimal testkit: mock host server, mock MCP server, tmp git repo helpers. */
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
export interface MockRoute {
method: string;
path: string;
status: number;
body: unknown;
}
export interface MockHost {
url: string;
requests: { method: string; path: string; body: unknown }[];
close: () => Promise<void>;
}
/** In-process mock HTTP server with canned routes (Bun.serve). */
export function startMockHost(routes: MockRoute[]): MockHost {
const requests: MockHost["requests"] = [];
const server = Bun.serve({
port: 0,
fetch: async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const method: string = req.method.toUpperCase();
let body: unknown = undefined;
try {
body = req.method === "GET" ? undefined : await req.json();
} catch {
body = undefined;
}
requests.push({ method, path: url.pathname, body });
const route: MockRoute | undefined = routes.find(
(r) => r.method.toUpperCase() === method && r.path === url.pathname,
);
if (!route) return new Response("not found", { status: 404 });
if (route.status === 204) return new Response(null, { status: 204 });
return Response.json(route.body, { status: route.status });
},
});
return {
url: `http://127.0.0.1:${server.port}`,
requests,
close: async (): Promise<void> => {
server.stop(true);
},
};
}
/** Minimal JSON-RPC 2.0 mock MCP server: responds to initialize + tools/list. */
export function startMockMcpServer(tools: { name: string; description: string }[]): MockHost {
return startMockHost([
{
method: "POST",
path: "/mcp",
status: 200,
body: { jsonrpc: "2.0", id: 1, result: { tools } },
},
]);
}
/** Create a temp git repo with one committed file; returns its path. */
export function makeTmpGitRepo(seedFiles?: Record<string, string>): string {
const dir: string = mkdtempSync(join(tmpdir(), "heretek-testkit-"));
const files: Record<string, string> = seedFiles ?? { "hello.txt": "hello\n" };
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(dir, name), content);
}
execFileSync("git", ["init", "-q"], { cwd: dir });
execFileSync("git", ["add", "."], { cwd: dir });
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed"], { cwd: dir });
return dir;
}
/** Current HEAD sha of a repo dir. */
export function gitHead(dir: string): string {
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir }).toString().trim();
}
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import {
CriterionSchema,
DiffBudgetSchema,
GateReportSchema,
JudgeReportSchema,
JournalEntrySchema,
LaneSchema,
GateCheckSchema,
GatesConfigSchema,
MCPToolDefSchema,
MCPToolResultSchema,
MissionSchema,
PackManifestSchema,
ReadinessResultSchema,
TaskSchema,
} from "../src/contracts.js";
describe("seed schemas (backwards-compatible)", () => {
test("Criterion round-trip", () => {
const c = { id: "c1", text: "works", verify: "cmd", status: "unmet" } as const;
expect(CriterionSchema.parse(c)).toEqual(c);
});
test("Task round-trip", () => {
const t = {
id: "t1", missionId: "m1", stage: "build", state: "pending",
deps: [], lane: "docker", branch: "fleet/t1", attempts: 0,
budget: { maxDiffLines: 800, maxDurationMinutes: 30 },
};
expect(TaskSchema.parse(t)).toMatchObject({ id: "t1" });
});
test("Mission round-trip", () => {
const m = { id: "m1", intent: "do", acceptance: [], state: "active" };
expect(MissionSchema.parse(m)).toMatchObject({ id: "m1" });
});
test("legacy GateReport (no artifacts/timedOut) still parses", () => {
const legacy = {
taskId: "t-014", commit: "9f3c1ab", runnerVersion: "1.2.0",
startedAt: "2026-10-12T00:00:00Z", durationMs: 100, verdict: "pass",
checks: [{ id: "lint", exit: 0, durationMs: 10, outputHash: "sha256:x" }],
budget: { diffLines: 214, limit: 800 },
};
const parsed = GateReportSchema.parse(legacy);
expect(parsed.artifacts).toEqual([]);
expect(parsed.checks[0]?.timedOut).toBe(false);
});
test("MCPToolDef round-trip", () => {
expect(MCPToolDefSchema.parse({ name: "n", description: "d", inputSchema: {} })).toMatchObject({ name: "n" });
});
});
describe("new schemas", () => {
test("Lane round-trip", () => {
const l = { id: "l1", tier: "docker" as const, image: "lane-base:1", mounts: [] as string[], network: "gateway-only" as const };
expect(LaneSchema.parse(l)).toEqual(l);
});
test("GateCheck defaults", () => {
const g = GateCheckSchema.parse({ id: "lint", kind: "lint", cmd: "true" });
expect(g.timeoutSeconds).toBe(300);
});
test("GatesConfig parses canonical gates.yaml subset", () => {
const cfg = GatesConfigSchema.parse({
version: 1,
gates: [{ id: "budget", kind: "budget", maxDiffLines: 800 }],
});
expect(cfg.gates).toHaveLength(1);
});
test("DiffBudget round-trip", () => {
expect(DiffBudgetSchema.parse({ diffLines: 1, limit: 2 })).toEqual({ diffLines: 1, limit: 2 });
});
test("MCPToolResult round-trip", () => {
const r = { tool: "search", content: "x", contentHash: "sha256:y" };
expect(MCPToolResultSchema.parse(r)).toMatchObject({ tool: "search" });
});
test("PackManifest defaults", () => {
const p = PackManifestSchema.parse({ id: "godot", version: "1.0.0", hostCompat: "2.0.x" });
expect(p.laneRequires).toEqual([]);
expect(p.tools).toEqual([]);
});
test("ReadinessResult round-trip", () => {
expect(ReadinessResultSchema.parse({ packId: "godot", status: "degraded", reason: "no binary" }).status).toBe("degraded");
});
test("JudgeReport confidence bounds enforced", () => {
expect(() => JudgeReportSchema.parse({ seat: "qa-functional", verdict: "pass", confidence: 2 })).toThrow();
expect(JudgeReportSchema.parse({ seat: "qa-adversarial", verdict: "fail", confidence: 0.5 }).verdict).toBe("fail");
});
test("JournalEntry round-trip", () => {
const e = { seq: 0, at: "t", actor: "op", event: "start", prevHash: "genesis", hash: "h" };
expect(JournalEntrySchema.parse(e)).toEqual(e);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runGates } from "../src/gates/runner.js";
import { GateReportSchema } from "../src/contracts.js";
function writeConfig(name: string, text: string): string {
const dir: string = mkdtempSync(join(tmpdir(), "gates-"));
const path: string = join(dir, name);
writeFileSync(path, text);
return path;
}
describe("gate runner", () => {
test("passing gates produce a valid GateReport", async () => {
const cfg: string = writeConfig("gates.yaml", "version: 1\ngates:\n - id: lint\n kind: lint\n cmd: \"echo ok\"\n");
const report = await runGates(cfg, { taskId: "t-1", commit: "abc", diffLines: 10 });
expect(() => GateReportSchema.parse(report)).not.toThrow();
expect(report.verdict).toBe("pass");
expect(report.checks).toHaveLength(1);
expect(report.checks[0]?.outputHash).toMatch(/^sha256:[0-9a-f]{64}$/);
});
test("failing command yields fail verdict", async () => {
const cfg: string = writeConfig("gates.yaml", "version: 1\ngates:\n - id: bad\n kind: custom\n cmd: \"exit 3\"\n");
const report = await runGates(cfg, { taskId: "t-2", commit: "abc" });
expect(report.verdict).toBe("fail");
expect(report.checks[0]?.exit).toBe(3);
expect(() => GateReportSchema.parse(report)).not.toThrow();
});
test("budget gate enforces maxDiffLines", async () => {
const cfg: string = writeConfig(
"gates.yaml",
"version: 1\ngates:\n - id: lint\n kind: lint\n cmd: \"echo ok\"\n - id: budget\n kind: budget\n maxDiffLines: 100\n",
);
const over = await runGates(cfg, { taskId: "t-3", commit: "abc", diffLines: 500 });
expect(over.verdict).toBe("fail");
expect(over.budget).toEqual({ diffLines: 500, limit: 100 });
const under = await runGates(cfg, { taskId: "t-3", commit: "abc", diffLines: 50 });
expect(under.verdict).toBe("pass");
});
test("timeout is a first-class outcome (exit 124, timedOut)", async () => {
const cfg: string = writeConfig(
"gates.yaml",
"version: 1\ngates:\n - id: slow\n kind: custom\n cmd: \"sleep 30\"\n timeoutSeconds: 1\n",
);
const report = await runGates(cfg, { taskId: "t-4", commit: "abc" });
expect(report.checks[0]?.timedOut).toBe(true);
expect(report.checks[0]?.exit).toBe(124);
expect(report.verdict).toBe("timeout");
expect(() => GateReportSchema.parse(report)).not.toThrow();
}, 15000);
});
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test } from "bun:test";
import { HostClient, HostClientError } from "../src/host/client.js";
import { startMockHost, type MockHost } from "../src/testkit/index.js";
let mock: MockHost | null = null;
afterEach(async () => {
if (mock) {
await mock.close();
mock = null;
}
});
describe("host client", () => {
test("prompt posts text and returns inbox id", async () => {
mock = startMockHost([
{ method: "POST", path: "/api/session/s1/prompt", status: 200, body: { data: { id: "inbox-1" } } },
]);
const client = new HostClient({ baseUrl: mock.url });
const res = await client.prompt("s1", "hello");
expect(res.inboxId).toBe("inbox-1");
expect(mock.requests[0]).toMatchObject({ method: "POST", path: "/api/session/s1/prompt" });
});
test("wait uses experimental endpoint when present", async () => {
mock = startMockHost([
{ method: "POST", path: "/api/experimental/session/s1/wait", status: 204, body: null },
]);
const client = new HostClient({ baseUrl: mock.url });
await client.wait("s1", 5000);
expect(mock.requests.map((r) => r.path)).toEqual(["/api/experimental/session/s1/wait"]);
});
test("wait falls back to polling message list when wait endpoint is missing", async () => {
const messages = [{ id: "m1", role: "user" }];
const fetchImpl: typeof fetch = (async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
const u = new URL(typeof url === "string" ? url : url instanceof URL ? url.toString() : (url as Request).url);
if (u.pathname === "/api/experimental/session/s1/wait") return new Response("nope", { status: 404 });
if (u.pathname === "/api/session/s1/message") {
return Response.json({ data: (init as unknown) ? messages : messages });
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
const client = new HostClient({ baseUrl: "http://x", fetchImpl, pollIntervalMs: 10 });
await client.wait("s1", 2000);
});
test("diff returns file diffs", async () => {
const diffs = [{ file: "a.ts", patch: "@@..", additions: 1, deletions: 0, status: "modified" }];
mock = startMockHost([{ method: "GET", path: "/api/session/s1/diff", status: 200, body: { data: diffs } }]);
const client = new HostClient({ baseUrl: mock.url });
expect(await client.diff("s1")).toEqual(diffs);
});
test("interrupt + listMessages", async () => {
mock = startMockHost([
{ method: "POST", path: "/api/session/s1/interrupt", status: 200, body: { data: true } },
{ method: "GET", path: "/api/session/s1/message", status: 200, body: { data: [{ id: "m1", role: "user" }] } },
]);
const client = new HostClient({ baseUrl: mock.url });
await client.interrupt("s1");
expect(await client.listMessages("s1")).toHaveLength(1);
});
test("HTTP errors surface as HostClientError", async () => {
mock = startMockHost([{ method: "GET", path: "/api/session/nope/diff", status: 404, body: { error: "gone" } }]);
const client = new HostClient({ baseUrl: mock.url });
await expect(client.diff("nope")).rejects.toBeInstanceOf(HostClientError);
});
});
+1 -1
View File
@@ -11,7 +11,7 @@
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"types": ["node"]
"types": ["node", "bun-types"]
},
"include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/opencode-packs/packs/*/src/**/*"],
"exclude": ["node_modules", "dist", "worktrees"]