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 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>;
|
||||||
|
|||||||
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 "./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";
|
||||||
|
|||||||
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