feat: add core contracts

This commit is contained in:
chungyeong
2026-05-09 22:45:44 +09:00
parent 42f0fb193d
commit 44103839af
11 changed files with 377 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
ApprovalDecisionActionValues,
BackendValues,
CapabilityValues,
RiskLevelValues,
} from "./enums.js";
describe("core enums", () => {
it("keeps approval decisions separate from run pause controls", () => {
expect(ApprovalDecisionActionValues).toEqual(["approve", "reject", "request_changes", "abort"]);
expect(ApprovalDecisionActionValues).not.toContain("pause");
});
it("exports the locked backend, risk, and capability sets", () => {
expect(BackendValues).toEqual(["codex", "claude", "fake"]);
expect(RiskLevelValues).toEqual(["low", "medium", "high"]);
expect(CapabilityValues).toContain("test_first_development");
expect(CapabilityValues).toContain("backtest_run");
});
});

View File

@@ -3,3 +3,88 @@ import { z } from "zod";
export const BackendValues = ["codex", "claude", "fake"] as const; export const BackendValues = ["codex", "claude", "fake"] as const;
export const Backend = z.enum(BackendValues); export const Backend = z.enum(BackendValues);
export type Backend = z.infer<typeof Backend>; export type Backend = z.infer<typeof Backend>;
export const CapabilityValues = [
"spec_write",
"phase_planning",
"task_dag_planning",
"code_edit",
"test_first_development",
"code_review",
"evidence_check",
"command_execute",
"backtest_run",
"metric_extract",
"failure_mining",
"objective_eval",
"final_report_compose",
] as const;
export const Capability = z.enum(CapabilityValues);
export type Capability = z.infer<typeof Capability>;
export const RiskLevelValues = ["low", "medium", "high"] as const;
export const RiskLevel = z.enum(RiskLevelValues);
export type RiskLevel = z.infer<typeof RiskLevel>;
export const ApprovalDecisionActionValues = [
"approve",
"reject",
"request_changes",
"abort",
] as const;
export const ApprovalDecisionAction = z.enum(ApprovalDecisionActionValues);
export type ApprovalDecisionAction = z.infer<typeof ApprovalDecisionAction>;
export const ApprovalStateValues = [
"pending",
"approved",
"rejected",
"changes_requested",
"aborted",
"paused",
] as const;
export const ApprovalState = z.enum(ApprovalStateValues);
export type ApprovalState = z.infer<typeof ApprovalState>;
export const RunStateValues = [
"created",
"bound",
"planning",
"awaiting_approval",
"executing",
"paused",
"completed",
"failed",
"aborted",
] as const;
export const RunState = z.enum(RunStateValues);
export type RunState = z.infer<typeof RunState>;
export const RunPhaseStateValues = [
"pending",
"running",
"awaiting_artifact",
"validating",
"awaiting_approval",
"completed",
"failed",
"skipped",
] as const;
export const RunPhaseState = z.enum(RunPhaseStateValues);
export type RunPhaseState = z.infer<typeof RunPhaseState>;
export const SessionStateValues = [
"CREATED",
"BOOTSTRAPPING",
"READY",
"BUSY",
"WAITING_FOR_APPROVAL",
"ARTIFACT_TIMEOUT",
"HUNG",
"CRASHED",
"RESUMING",
"REBOOTSTRAPPED",
"FAILED_NEEDS_HUMAN",
] as const;
export const SessionState = z.enum(SessionStateValues);
export type SessionState = z.infer<typeof SessionState>;

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { DevflowError } from "./errors.js";
describe("DevflowError", () => {
it("carries stable classification metadata", () => {
const error = new DevflowError("blocked", {
class: "human_required",
code: "destructive_command_blocked",
recoveryHint: "Ask for approval before running rm -rf",
});
expect(error.class).toBe("human_required");
expect(error.code).toBe("destructive_command_blocked");
expect(error.recoveryHint).toContain("approval");
});
});

View File

@@ -0,0 +1,30 @@
export type ErrorClass = "recoverable" | "human_required" | "fatal";
export interface DevflowErrorOptions {
class: ErrorClass;
code: string;
runId?: string;
phaseId?: string;
recoveryHint?: string;
cause?: unknown;
}
export class DevflowError extends Error {
readonly class: ErrorClass;
readonly code: string;
readonly runId: string | undefined;
readonly phaseId: string | undefined;
readonly recoveryHint: string | undefined;
override readonly cause: unknown;
constructor(message: string, options: DevflowErrorOptions) {
super(message, { cause: options.cause });
this.name = "DevflowError";
this.class = options.class;
this.code = options.code;
this.runId = options.runId;
this.phaseId = options.phaseId;
this.recoveryHint = options.recoveryHint;
this.cause = options.cause;
}
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { canonicalize, hash } from "./hash.js";
describe("content hashing", () => {
it("canonicalizes object keys lexicographically while preserving array order", () => {
expect(canonicalize({ z: 1, a: [{ b: true, a: null }] })).toBe(
'{"a":[{"a":null,"b":true}],"z":1}',
);
});
it("hashes equivalent object key orders to the same sha256 hex", () => {
const left = hash({ z: 1, a: 2 });
const right = hash({ a: 2, z: 1 });
expect(left).toBe(right);
expect(left).toMatch(/^[a-f0-9]{64}$/);
});
it("rejects values that are not JSON-safe", () => {
expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow(
/non-plain object/,
);
expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/);
});
});

74
packages/core/src/hash.ts Normal file
View File

@@ -0,0 +1,74 @@
import { createHash } from "node:crypto";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
export function canonicalize(value: unknown): string {
return renderCanonical(assertJsonValue(value));
}
export function hash(value: unknown): string {
return createHash("sha256").update(canonicalize(value)).digest("hex");
}
function renderCanonical(value: JsonValue): string {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => renderCanonical(item)).join(",")}]`;
}
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${renderCanonical(value[key] as JsonValue)}`)
.join(",")}}`;
}
function assertJsonValue(value: unknown): JsonValue {
if (
value === null ||
typeof value === "boolean" ||
typeof value === "string" ||
Array.isArray(value)
) {
if (Array.isArray(value)) {
return value.map((item) => assertJsonValue(item));
}
return value;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
throw new TypeError("Cannot canonicalize non-finite numbers");
}
return value;
}
if (typeof value === "object") {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("Cannot canonicalize non-plain object");
}
const objectValue: Record<string, JsonValue> = {};
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
if (childValue === undefined) {
throw new TypeError(`Cannot canonicalize undefined at key ${key}`);
}
objectValue[key] = assertJsonValue(childValue);
}
return objectValue;
}
throw new TypeError(`Cannot canonicalize ${typeof value}`);
}

View File

@@ -1,2 +1,6 @@
export * from "./config.js"; export * from "./config.js";
export * from "./enums.js"; export * from "./enums.js";
export * from "./errors.js";
export * from "./hash.js";
export * from "./prompt-envelope.js";
export * from "./run-event.js";

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { PromptEnvelope, renderPromptEnvelope } from "./prompt-envelope.js";
describe("prompt envelope", () => {
it("validates and renders the locked wire markers", () => {
const envelope = PromptEnvelope.parse({
uuid: "00000000-0000-4000-8000-000000000000",
runId: "11111111-1111-4111-8111-111111111111",
roleId: "planner",
phaseKey: "plan",
attempt: 0,
expectedArtifact: "/tmp/devflow/spec.json",
expectedSchema: "dev/spec@1",
dedupKey: "a".repeat(64),
instructions: "Write the spec.",
});
expect(renderPromptEnvelope(envelope)).toContain(
"DEVFLOW_PROMPT_BEGIN 00000000-0000-4000-8000-000000000000",
);
expect(renderPromptEnvelope(envelope)).toContain("Phase: plan");
expect(renderPromptEnvelope(envelope)).toContain("DEVFLOW_PROMPT_END");
});
});

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
export const PromptEnvelope = z.object({
uuid: z.string().uuid(),
runId: z.string().uuid(),
roleId: z.string().min(1),
phaseKey: z.string().min(1),
attempt: z.number().int().nonnegative(),
expectedArtifact: z.string().min(1),
expectedSchema: z.string().min(1),
dedupKey: z.string().regex(/^[a-f0-9]{64}$/),
instructions: z.string(),
});
export type PromptEnvelope = z.infer<typeof PromptEnvelope>;
export function renderPromptEnvelope(envelope: PromptEnvelope): string {
return [
`DEVFLOW_PROMPT_BEGIN ${envelope.uuid}`,
`Run: ${envelope.runId}`,
`Role: ${envelope.roleId}`,
`Phase: ${envelope.phaseKey}`,
`Attempt: ${envelope.attempt}`,
`Expected artifact: ${envelope.expectedArtifact}`,
`Expected schema: ${envelope.expectedSchema}`,
`Dedup-Key: ${envelope.dedupKey}`,
"Instructions:",
envelope.instructions,
`DEVFLOW_PROMPT_END ${envelope.uuid}`,
].join("\n");
}

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { RunEventPayloadSchemas, RunEventTypeValues } from "./run-event.js";
describe("run events", () => {
it("keeps a payload schema for every closed run event type", () => {
expect(Object.keys(RunEventPayloadSchemas).sort()).toEqual([...RunEventTypeValues].sort());
});
});

View File

@@ -0,0 +1,54 @@
import { z } from "zod";
export const RunEventTypeValues = [
"run.created",
"run.started",
"run.paused",
"run.resumed",
"run.completed",
"run.failed",
"run.aborted",
"phase.started",
"phase.completed",
"phase.failed",
"phase.skipped",
"prompt.sent",
"prompt.repaired",
"artifact.expected",
"artifact.validated",
"artifact.invalid",
"artifact.timeout",
"approval.requested",
"approval.resolved",
"session.created",
"session.ready",
"session.busy",
"session.idle",
"session.crashed",
"session.recovered",
"session.failed",
"command.started",
"command.completed",
"command.failed",
"review.batch_recorded",
"finding.verifier_resolved",
"backtest.iteration_started",
"backtest.iteration_completed",
"backtest.objective_evaluated",
] as const;
export const RunEventType = z.enum(RunEventTypeValues);
export type RunEventType = z.infer<typeof RunEventType>;
const payloadSchema = z.record(z.unknown());
export const RunEventPayloadSchemas = Object.freeze(
Object.fromEntries(RunEventTypeValues.map((type) => [type, payloadSchema])),
) as Readonly<Record<RunEventType, typeof payloadSchema>>;
export const RunEvent = z.object({
type: RunEventType,
payload: payloadSchema,
});
export type RunEvent = z.infer<typeof RunEvent>;