mirror of
https://github.com/Heretek-OpenCode/heretek-opencode.git
synced 2026-11-07 10:05:58 -10:00
merge: feat/distro-sdk into main (Tier 0+1 distro & SDK)
This commit is contained in:
commit
aadb39ffbc
17 files changed
+920
-1
No files matched your search
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[]) : [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user