Skip to content

Commit 1cbdbb5

Browse files
authored
feat: define runtime state schema (#230)
Co-authored-by: devkade <devkade@users.noreply.github.com>
1 parent adcddad commit 1cbdbb5

3 files changed

Lines changed: 178 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ Phase presets serialize as a versioned `schemaVersion: 1` catalog. Legacy arrays
154154

155155
Agent execution stays adapter-neutral. The `AgentAdapterContract` describes required launch/send/capture/health/readiness/report/substrate behavior for Pi, Codex, and Claude Code compatible workers without coupling the domain layer to any one CLI. Readiness requires a nonce-equivalent proof, worker reports require `taskId`, `status`, `evidenceRefs`, and `summary`, and health can be supported or best-effort but not absent.
156156

157+
The runtime state schema is separately versioned as `RuntimeState.schemaVersion: 1`. It defines additive boundaries for RunObjective, PolicySelection, TaskGraph refs, WorkerState, EvidenceRef, EvaluationResult, RewardRecord, and IntegrationCandidate data; unknown newer versions fail closed, and RunContract-facing artifact refs expose only objective, policy-selection, and evaluation artifacts.
158+
157159
The domain boundary is explicit: `Decomposer` creates the concrete graph, `Scheduler` computes ready tasks, `WorkerRuntime` dispatches and heartbeats work, `Verifier` validates evidence refs, and `GateEngine` decides transitions.
158160

159161
## Workflow Map

src/domain/runtime-state.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
export const RUNTIME_STATE_SCHEMA_VERSION = 1;
2+
3+
export type RuntimeStatus = "draft" | "active" | "blocked" | "failed" | "sealed";
4+
export type RuntimeTaskStatus = "pending" | "ready" | "claimed" | "verifying" | "completed" | "blocked" | "failed" | "repair_required";
5+
export type RuntimeWorkerStatus = "ready" | "busy" | "unhealthy" | "completed-retained" | "safe-to-close" | "stale-registry";
6+
export type RuntimeArtifactKind = "run-objective" | "policy-selection" | "task-graph" | "worker-state" | "evidence" | "evaluation" | "reward" | "integration-candidate" | "final-report";
7+
export type EvaluationVerdict = "pass" | "fail" | "repair" | "blocked";
8+
export type IntegrationCandidateStatus = "pending" | "accepted" | "rejected" | "repair_required";
9+
10+
export interface RuntimeArtifactRef { kind: RuntimeArtifactKind; artifactId: string; path: string }
11+
export interface RunObjectiveSchema { id: string; goal: string; successCriteria: string[]; constraints: string[] }
12+
export interface PolicySelectionSchema { id: string; selectedPolicyId: string; rationale: string; artifactRefs: RuntimeArtifactRef[] }
13+
export interface RuntimeTaskSchema { id: string; title: string; status: RuntimeTaskStatus; dependsOn: string[]; evidenceRefs: RuntimeArtifactRef[]; evaluationRefs: RuntimeArtifactRef[] }
14+
export interface RuntimeWorkerSchema { id: string; adapterId: string; status: RuntimeWorkerStatus; readinessNonce?: string; lastHeartbeatAt?: string }
15+
export interface EvaluationResultSchema { id: string; taskId?: string; verdict: EvaluationVerdict; evidenceRefs: RuntimeArtifactRef[] }
16+
export interface RewardRecordSchema { id: string; objectiveId: string; score: number; evidenceRefs: RuntimeArtifactRef[] }
17+
export interface IntegrationCandidateSchema { id: string; taskIds: string[]; status: IntegrationCandidateStatus; evidenceRefs: RuntimeArtifactRef[] }
18+
19+
export interface RuntimeState {
20+
schemaVersion: typeof RUNTIME_STATE_SCHEMA_VERSION;
21+
runId: string;
22+
status: RuntimeStatus;
23+
createdAt: string;
24+
updatedAt: string;
25+
objective?: RunObjectiveSchema;
26+
policySelection?: PolicySelectionSchema;
27+
tasks: RuntimeTaskSchema[];
28+
workers: RuntimeWorkerSchema[];
29+
evaluations: EvaluationResultSchema[];
30+
rewards: RewardRecordSchema[];
31+
integrationCandidates: IntegrationCandidateSchema[];
32+
artifactRefs: RuntimeArtifactRef[];
33+
}
34+
35+
export type RuntimeStateParseResult = { ok: true; state: RuntimeState } | { ok: false; reason: "malformed" | "unsupported-newer-schema"; issues: string[] };
36+
37+
const runtimeStatuses = new Set<RuntimeStatus>(["draft", "active", "blocked", "failed", "sealed"]);
38+
const taskStatuses = new Set<RuntimeTaskStatus>(["pending", "ready", "claimed", "verifying", "completed", "blocked", "failed", "repair_required"]);
39+
const workerStatuses = new Set<RuntimeWorkerStatus>(["ready", "busy", "unhealthy", "completed-retained", "safe-to-close", "stale-registry"]);
40+
const artifactKinds = new Set<RuntimeArtifactKind>(["run-objective", "policy-selection", "task-graph", "worker-state", "evidence", "evaluation", "reward", "integration-candidate", "final-report"]);
41+
const evaluationVerdicts = new Set<EvaluationVerdict>(["pass", "fail", "repair", "blocked"]);
42+
const integrationStatuses = new Set<IntegrationCandidateStatus>(["pending", "accepted", "rejected", "repair_required"]);
43+
44+
export function createRuntimeState(input: { runId: string; now: string }): RuntimeState {
45+
requireText(input.runId, "runId");
46+
if (!isTimestamp(input.now)) throw new Error("now must be a timestamp");
47+
return { schemaVersion: 1, runId: input.runId, status: "draft", createdAt: input.now, updatedAt: input.now, tasks: [], workers: [], evaluations: [], rewards: [], integrationCandidates: [], artifactRefs: [] };
48+
}
49+
50+
export function parseRuntimeState(input: unknown): RuntimeStateParseResult {
51+
if (!isRecord(input)) return { ok: false, reason: "malformed", issues: ["runtime state must be an object"] };
52+
if (typeof input.schemaVersion !== "number" || !Number.isInteger(input.schemaVersion) || input.schemaVersion < 1) return { ok: false, reason: "malformed", issues: ["schemaVersion must be a positive integer"] };
53+
if (input.schemaVersion > RUNTIME_STATE_SCHEMA_VERSION) return { ok: false, reason: "unsupported-newer-schema", issues: [`unsupported runtime state schemaVersion ${input.schemaVersion}`] };
54+
const issues: string[] = [];
55+
needText(input.runId, "runId", issues);
56+
needEnum(input.status, runtimeStatuses, "status", issues);
57+
needTimestamp(input.createdAt, "createdAt", issues);
58+
needTimestamp(input.updatedAt, "updatedAt", issues);
59+
if (input.objective !== undefined) validateObjective(input.objective, "objective", issues);
60+
if (input.policySelection !== undefined) validatePolicy(input.policySelection, "policySelection", issues);
61+
eachRecord(input.tasks, "tasks", issues, (item, label) => validateTask(item, label, issues));
62+
eachRecord(input.workers, "workers", issues, (item, label) => validateWorker(item, label, issues));
63+
eachRecord(input.evaluations, "evaluations", issues, (item, label) => validateEvaluation(item, label, issues));
64+
eachRecord(input.rewards, "rewards", issues, (item, label) => validateReward(item, label, issues));
65+
eachRecord(input.integrationCandidates, "integrationCandidates", issues, (item, label) => validateIntegration(item, label, issues));
66+
eachRecord(input.artifactRefs, "artifactRefs", issues, (item, label) => validateArtifactRef(item, label, issues));
67+
return issues.length ? { ok: false, reason: "malformed", issues } : { ok: true, state: input as unknown as RuntimeState };
68+
}
69+
70+
export function buildRunContractRuntimeRefs(state: RuntimeState): RuntimeArtifactRef[] {
71+
return state.artifactRefs.filter((ref) => ref.kind === "run-objective" || ref.kind === "policy-selection" || ref.kind === "evaluation");
72+
}
73+
74+
function validateObjective(value: unknown, label: string, issues: string[]): void {
75+
if (!isRecord(value)) { issues.push(`${label} must be an object`); return; }
76+
needText(value.id, `${label}.id`, issues); needText(value.goal, `${label}.goal`, issues); needStringArray(value.successCriteria, `${label}.successCriteria`, issues); needStringArray(value.constraints, `${label}.constraints`, issues);
77+
}
78+
function validatePolicy(value: unknown, label: string, issues: string[]): void {
79+
if (!isRecord(value)) { issues.push(`${label} must be an object`); return; }
80+
needText(value.id, `${label}.id`, issues); needText(value.selectedPolicyId, `${label}.selectedPolicyId`, issues); needText(value.rationale, `${label}.rationale`, issues); eachRecord(value.artifactRefs, `${label}.artifactRefs`, issues, (item, itemLabel) => validateArtifactRef(item, itemLabel, issues));
81+
}
82+
function validateTask(value: Record<string, unknown>, label: string, issues: string[]): void {
83+
needText(value.id, `${label}.id`, issues); needText(value.title, `${label}.title`, issues); needEnum(value.status, taskStatuses, `${label}.status`, issues); needStringArray(value.dependsOn, `${label}.dependsOn`, issues); eachRecord(value.evidenceRefs, `${label}.evidenceRefs`, issues, (item, itemLabel) => validateArtifactRef(item, itemLabel, issues)); eachRecord(value.evaluationRefs, `${label}.evaluationRefs`, issues, (item, itemLabel) => validateArtifactRef(item, itemLabel, issues));
84+
}
85+
function validateWorker(value: Record<string, unknown>, label: string, issues: string[]): void {
86+
needText(value.id, `${label}.id`, issues); needText(value.adapterId, `${label}.adapterId`, issues); needEnum(value.status, workerStatuses, `${label}.status`, issues); if (value.readinessNonce !== undefined) needText(value.readinessNonce, `${label}.readinessNonce`, issues); if (value.lastHeartbeatAt !== undefined) needTimestamp(value.lastHeartbeatAt, `${label}.lastHeartbeatAt`, issues);
87+
}
88+
function validateEvaluation(value: Record<string, unknown>, label: string, issues: string[]): void {
89+
needText(value.id, `${label}.id`, issues); if (value.taskId !== undefined) needText(value.taskId, `${label}.taskId`, issues); needEnum(value.verdict, evaluationVerdicts, `${label}.verdict`, issues); eachRecord(value.evidenceRefs, `${label}.evidenceRefs`, issues, (item, itemLabel) => validateArtifactRef(item, itemLabel, issues));
90+
}
91+
function validateReward(value: Record<string, unknown>, label: string, issues: string[]): void {
92+
needText(value.id, `${label}.id`, issues); needText(value.objectiveId, `${label}.objectiveId`, issues); if (typeof value.score !== "number" || !Number.isFinite(value.score)) issues.push(`${label}.score must be finite`); eachRecord(value.evidenceRefs, `${label}.evidenceRefs`, issues, (item, itemLabel) => validateArtifactRef(item, itemLabel, issues));
93+
}
94+
function validateIntegration(value: Record<string, unknown>, label: string, issues: string[]): void {
95+
needText(value.id, `${label}.id`, issues); needStringArray(value.taskIds, `${label}.taskIds`, issues); needEnum(value.status, integrationStatuses, `${label}.status`, issues); eachRecord(value.evidenceRefs, `${label}.evidenceRefs`, issues, (item, itemLabel) => validateArtifactRef(item, itemLabel, issues));
96+
}
97+
function validateArtifactRef(value: Record<string, unknown>, label: string, issues: string[]): void {
98+
needEnum(value.kind, artifactKinds, `${label}.kind`, issues); needText(value.artifactId, `${label}.artifactId`, issues); needText(value.path, `${label}.path`, issues);
99+
}
100+
101+
function eachRecord(value: unknown, label: string, issues: string[], visit: (record: Record<string, unknown>, label: string) => void): void {
102+
if (!Array.isArray(value)) { issues.push(`${label} must be an array`); return; }
103+
value.forEach((item, index) => isRecord(item) ? visit(item, `${label}[${index}]`) : issues.push(`${label}[${index}] must be an object`));
104+
}
105+
function needStringArray(value: unknown, label: string, issues: string[]): void {
106+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) issues.push(`${label} must be a string array`);
107+
}
108+
function requireText(value: unknown, label: string): void {
109+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
110+
}
111+
function needText(value: unknown, label: string, issues: string[]): void {
112+
if (typeof value !== "string" || !value.trim()) issues.push(`${label} is required`);
113+
}
114+
function needEnum<T extends string>(value: unknown, values: Set<T>, label: string, issues: string[]): void {
115+
if (typeof value !== "string" || !values.has(value as T)) issues.push(`${label} is invalid`);
116+
}
117+
function needTimestamp(value: unknown, label: string, issues: string[]): void {
118+
if (typeof value !== "string" || !isTimestamp(value)) issues.push(`${label} must be a timestamp`);
119+
}
120+
function isTimestamp(value: string): boolean { return Number.isFinite(Date.parse(value)); }
121+
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }

test/runtime-state.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { buildRunContractRuntimeRefs, createRuntimeState, parseRuntimeState, type RuntimeArtifactRef, type RuntimeState } from "../src/domain/runtime-state.js";
4+
5+
const now = "2026-01-01T00:00:00.000Z";
6+
7+
function state(): RuntimeState {
8+
return createRuntimeState({ runId: "run-1", now });
9+
}
10+
11+
test("runtime state schema initializes versioned additive state", () => {
12+
assert.deepEqual(state(), { schemaVersion: 1, runId: "run-1", status: "draft", createdAt: now, updatedAt: now, tasks: [], workers: [], evaluations: [], rewards: [], integrationCandidates: [], artifactRefs: [] });
13+
assert.equal(parseRuntimeState(state()).ok, true);
14+
});
15+
16+
test("runtime state parser fails closed on unknown newer schemas and malformed refs", () => {
17+
const newer = parseRuntimeState({ ...state(), schemaVersion: 99 });
18+
assert.equal(newer.ok, false);
19+
assert.equal(newer.reason, "unsupported-newer-schema");
20+
21+
const malformed = parseRuntimeState({ ...state(), updatedAt: "not-a-date", artifactRefs: [{ kind: "evidence", artifactId: "", path: "verify.md" }] });
22+
assert.equal(malformed.ok, false);
23+
assert.equal(malformed.reason, "malformed");
24+
assert.ok(malformed.issues.includes("updatedAt must be a timestamp"));
25+
assert.ok(malformed.issues.includes("artifactRefs[0].artifactId is required"));
26+
const zero = parseRuntimeState({ ...state(), schemaVersion: 0 });
27+
assert.equal(zero.ok, false);
28+
assert.equal(zero.reason, "malformed");
29+
assert.throws(() => createRuntimeState({ runId: "", now }), /runId is required/);
30+
const nested = parseRuntimeState({ ...state(), status: "done", tasks: [{ id: "t", title: "T", status: "bad", dependsOn: [], evidenceRefs: [{ kind: "mystery", artifactId: "e", path: "e.json" }], evaluationRefs: [] }], workers: [{ id: "w", adapterId: "a", status: "lost", readinessNonce: 123 }], evaluations: [{ id: "e", taskId: {}, verdict: "pass", evidenceRefs: [] }] });
31+
assert.equal(nested.ok, false);
32+
assert.ok(nested.issues.includes("status is invalid"));
33+
assert.ok(nested.issues.includes("tasks[0].status is invalid"));
34+
assert.ok(nested.issues.includes("tasks[0].evidenceRefs[0].kind is invalid"));
35+
assert.ok(nested.issues.includes("workers[0].status is invalid"));
36+
assert.ok(nested.issues.includes("workers[0].readinessNonce is required"));
37+
assert.ok(nested.issues.includes("evaluations[0].taskId is required"));
38+
});
39+
40+
test("run contract runtime refs expose objective, policy, and evaluation artifacts only", () => {
41+
const refs: RuntimeArtifactRef[] = [
42+
{ kind: "run-objective", artifactId: "objective", path: "runtime/objective.json" },
43+
{ kind: "policy-selection", artifactId: "policy", path: "runtime/policy.json" },
44+
{ kind: "task-graph", artifactId: "graph", path: "runtime/task-graph.json" },
45+
{ kind: "evaluation", artifactId: "evaluation", path: "runtime/evaluation.json" },
46+
];
47+
assert.deepEqual(buildRunContractRuntimeRefs({ ...state(), artifactRefs: refs }), [refs[0], refs[1], refs[3]]);
48+
});
49+
50+
test("runtime state can represent successful and repair-required examples", () => {
51+
const success: RuntimeState = { ...state(), status: "sealed", objective: { id: "objective", goal: "ship MVP", successCriteria: ["tests pass"], constraints: ["no destructive cleanup"] }, tasks: [{ id: "build", title: "Build slice", status: "completed", dependsOn: [], evidenceRefs: [{ kind: "evidence", artifactId: "verify", path: "verify.md" }], evaluationRefs: [] }] };
52+
const repair: RuntimeState = { ...state(), status: "blocked", tasks: [{ id: "build", title: "Build slice", status: "repair_required", dependsOn: [], evidenceRefs: [], evaluationRefs: [{ kind: "evaluation", artifactId: "eval", path: "evaluation.json" }] }], integrationCandidates: [{ id: "candidate", taskIds: ["build"], status: "repair_required", evidenceRefs: [] }] };
53+
assert.equal(parseRuntimeState(success).ok, true);
54+
assert.equal(parseRuntimeState(repair).ok, true);
55+
});

0 commit comments

Comments
 (0)