feat: add core contracts
This commit is contained in:
22
packages/core/src/enums.test.ts
Normal file
22
packages/core/src/enums.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,88 @@ import { z } from "zod";
|
||||
export const BackendValues = ["codex", "claude", "fake"] as const;
|
||||
export const Backend = z.enum(BackendValues);
|
||||
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>;
|
||||
|
||||
17
packages/core/src/errors.test.ts
Normal file
17
packages/core/src/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
30
packages/core/src/errors.ts
Normal file
30
packages/core/src/errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
packages/core/src/hash.test.ts
Normal file
26
packages/core/src/hash.test.ts
Normal 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
74
packages/core/src/hash.ts
Normal 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}`);
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from "./config.js";
|
||||
export * from "./enums.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./hash.js";
|
||||
export * from "./prompt-envelope.js";
|
||||
export * from "./run-event.js";
|
||||
|
||||
25
packages/core/src/prompt-envelope.test.ts
Normal file
25
packages/core/src/prompt-envelope.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
31
packages/core/src/prompt-envelope.ts
Normal file
31
packages/core/src/prompt-envelope.ts
Normal 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");
|
||||
}
|
||||
9
packages/core/src/run-event.test.ts
Normal file
9
packages/core/src/run-event.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
54
packages/core/src/run-event.ts
Normal file
54
packages/core/src/run-event.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user