From ff3504c095ab3856db1d8c015321e3548d503e14 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Thu, 28 May 2026 00:54:11 +0200 Subject: [PATCH] FE-762: Petrinaut JSON export of the compiled NetBlueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializes a (refactored, Petri-net-faithful) NetBlueprint into Petrinaut's expected JSON format and writes it to /net.json on every cook run, so the Petrinaut team can render the compiled topology and pressure-test the integration end-to-end. What landed: - New module src/orchestrator/src/petrinaut-export.ts: pure serializer serializeBlueprint(blueprint, { runId, tokenIdFn? }) -> PetrinautNet. - New types: PetrinautNet { schemaVersion, runId, places, transitions, initialMarking }, PetrinautPlace { id, label }, PetrinautTransition { id, label, lane, kind, actor, guard, inputs, outputs }, PetrinautToken { id: , sliceId?, epicId?, retryCount?, reworkCount? }, PetrinautMarking { place, tokens[] }. - engine.ts: when input.runDir is present, writes the serialized net to /net.json after compileTopology() returns. Library callers that omit runDir (tests) are unaffected. - cook-cli.ts: passes the existing runDir through to the orchestrator. - types.ts: OrchestratorInput gains optional runDir field. Cross-team payload shape (2026-05-26 alignment): - Token shape is { id: , ...payload }. UUIDs generated at serialization time. (Token UUID lifecycle across consume->emit is a FE-763 open coordination item; v1 generates fresh per token.) - schemaVersion '0.1.0' on the envelope for forward-compat. - Place label strips slice:: / epic:: prefix for short visual names; full internal id preserved as place.id. Open coordination items (tracked on FE-762): - exact JSON envelope per Petrinaut's loader (pending team) - string vs uuid discrete type for semantic IDs (pending H-6518/H-6519) - place naming convention (full ids vs short labels) — v1 emits both Tests: 10 new tests in petrinaut-export.test.ts (envelope, places, transitions, initial marking, round-trip, golden counts pinned for simplePlan and depPlan). All 125 orchestrator tests pass; npm run fix / check / build all green. Co-authored-by: Amp --- src/orchestrator/src/cook-cli.ts | 2 + src/orchestrator/src/engine.ts | 13 ++ src/orchestrator/src/petrinaut-export.test.ts | 187 ++++++++++++++++++ src/orchestrator/src/petrinaut-export.ts | 161 +++++++++++++++ src/orchestrator/src/types.ts | 7 + 5 files changed, 370 insertions(+) create mode 100644 src/orchestrator/src/petrinaut-export.test.ts create mode 100644 src/orchestrator/src/petrinaut-export.ts diff --git a/src/orchestrator/src/cook-cli.ts b/src/orchestrator/src/cook-cli.ts index 476afd08..a630f089 100644 --- a/src/orchestrator/src/cook-cli.ts +++ b/src/orchestrator/src/cook-cli.ts @@ -163,6 +163,8 @@ export async function runCook(opts: CookOptions): Promise { policy: { maxRetries: opts.maxRetries }, sandboxMode: resolved.mode === 'codebase' ? 'codebase' : 'fixture', runId, + // FE-762: engine writes Petrinaut net.json into the run directory. + runDir, }); const duration = fmtDuration(Date.now() - runStart); diff --git a/src/orchestrator/src/engine.ts b/src/orchestrator/src/engine.ts index adeaa587..ca6c9d4f 100644 --- a/src/orchestrator/src/engine.ts +++ b/src/orchestrator/src/engine.ts @@ -1,5 +1,9 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { compileTopology, wireHandlers } from './net-compiler.js'; import type { FiringPolicy } from './petri-net.js'; +import { serializeBlueprint } from './petrinaut-export.js'; import type { Orchestrator, OrchestratorInput, OrchestratorResult, RunCtx } from './types.js'; // --------------------------------------------------------------------------- @@ -25,6 +29,15 @@ export function createOrchestrator(firingPolicy: FiringPolicy): Orchestrator { try { const blueprint = compileTopology(input.plan, input.policy); + + // FE-762: write the Petrinaut-format compiled net to /net.json + // so the Petrinaut team can render the topology of this cook run. + // Skipped when runDir is absent (library callers / tests). + if (input.runDir) { + const net = serializeBlueprint(blueprint, { runId: input.runId ?? 'unknown' }); + writeFileSync(join(input.runDir, 'net.json'), `${JSON.stringify(net, null, 2)}\n`); + } + const net = wireHandlers(blueprint, input, ctx); await net.run(firingPolicy, () => net.hasHaltToken()); diff --git a/src/orchestrator/src/petrinaut-export.test.ts b/src/orchestrator/src/petrinaut-export.test.ts new file mode 100644 index 00000000..8b8dd497 --- /dev/null +++ b/src/orchestrator/src/petrinaut-export.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import { compileTopology } from './net-compiler.js'; +import { PETRINAUT_NET_SCHEMA_VERSION, serializeBlueprint, type PetrinautNet } from './petrinaut-export.js'; +import type { Plan } from './types.js'; + +const simplePlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-1', + epic_id: 'epic-1', + definition: 'D', + depends_on: [], + verification: [{ kind: 'unit-test', target: 't' }], + }, + ], +}; + +const depPlan: Plan = { + epics: [{ id: 'epic-1', summary: 'E', depends_on: [], verification: [] }], + slices: [ + { + id: 'slice-a', + epic_id: 'epic-1', + definition: 'A', + depends_on: [], + verification: [{ kind: 'unit-test', target: 'ta' }], + }, + { + id: 'slice-b', + epic_id: 'epic-1', + definition: 'B', + depends_on: ['slice-a'], + verification: [{ kind: 'unit-test', target: 'tb' }], + }, + ], +}; + +/** Deterministic token id generator for snapshot stability in tests. */ +function deterministicTokenId(): () => string { + let n = 0; + return () => `tok-${++n}`; +} + +describe('serializeBlueprint — envelope', () => { + it('emits schemaVersion and runId at the top level', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + expect(net.schemaVersion).toBe(PETRINAUT_NET_SCHEMA_VERSION); + expect(net.runId).toBe('run-1'); + }); + + it('round-trips through JSON.parse(JSON.stringify)', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + const roundTripped = JSON.parse(JSON.stringify(net)) as PetrinautNet; + expect(roundTripped).toEqual(net); + }); +}); + +describe('serializeBlueprint — places', () => { + it('emits one place entry per blueprint place', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + expect(net.places).toHaveLength(blueprint.places.length); + expect(new Set(net.places.map((p) => p.id))).toEqual(new Set(blueprint.places)); + }); + + it('strips slice:: and epic:: prefixes for the short label', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + const specReady = net.places.find((p) => p.id === 'slice:slice-1:spec-ready')!; + expect(specReady.label).toBe('spec-ready'); + const epicDone = net.places.find((p) => p.id === 'epic:epic-1:done')!; + expect(epicDone.label).toBe('done'); + // Non-prefixed places (e.g. pools) keep their id as label. + const pool = net.places.find((p) => p.id === 'pool:test-agent')!; + expect(pool.label).toBe('pool:test-agent'); + }); +}); + +describe('serializeBlueprint — transitions', () => { + it('emits one transition entry per blueprint transition with arcs and contract metadata', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + expect(net.transitions).toHaveLength(blueprint.transitions.length); + + // FE-761 Slice 4: dispatch transition exists with the right shape. + const evalDispatch = net.transitions.find((t) => t.id === 'slice-1:evaluate:dispatch')!; + expect(evalDispatch).toBeDefined(); + expect(evalDispatch.lane).toBe('mechanical'); + expect(evalDispatch.kind).toBe('structural'); + expect(evalDispatch.inputs).toEqual(['slice:slice-1:spec-ready', 'pool:test-agent']); + expect(evalDispatch.outputs).toEqual(['slice:slice-1:evaluate:running']); + + // Complete transition carries the action descriptor; outputs include + // the report-bearing intermediate and the agent pool return. + const evalComplete = net.transitions.find((t) => t.id === 'slice-1:evaluate:complete')!; + expect(evalComplete).toBeDefined(); + expect(evalComplete.kind).toBe('mechanical'); + expect(evalComplete.actor).toBe('evaluator'); + expect(evalComplete.inputs).toEqual(['slice:slice-1:evaluate:running']); + expect(evalComplete.outputs).toEqual(['pool:test-agent', 'slice:slice-1:evaluate:reported'].sort()); + }); + + it('every transition output appears as a declared place', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + const placeIds = new Set(net.places.map((p) => p.id)); + for (const t of net.transitions) { + for (const out of t.outputs) { + expect(placeIds.has(out), `transition ${t.id} emits to undeclared place ${out}`).toBe(true); + } + } + }); +}); + +describe('serializeBlueprint — initial marking', () => { + it('groups initial tokens by place with a fresh UUID per token', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + + // simplePlan seeds: + // pool:test-agent × 1, pool:code-agent × 1 (agentPoolSize defaults to slice count = 1) + // slice:slice-1:semantic-budget × 1, slice:slice-1:retry-budget × 1, + // slice:slice-1:eligible × 1 + const places = net.initialMarking.map((m) => m.place).sort(); + expect(places).toEqual( + [ + 'pool:code-agent', + 'pool:test-agent', + 'slice:slice-1:eligible', + 'slice:slice-1:retry-budget', + 'slice:slice-1:semantic-budget', + ].sort(), + ); + + // Every token has an id. + for (const marking of net.initialMarking) { + for (const tok of marking.tokens) { + expect(typeof tok.id).toBe('string'); + expect(tok.id.length).toBeGreaterThan(0); + } + } + + // Semantic budget token carries reworkCount: 0; retry-budget carries retryCount: 0. + const semBudget = net.initialMarking.find((m) => m.place === 'slice:slice-1:semantic-budget')!; + expect(semBudget.tokens[0]!.reworkCount).toBe(0); + expect(semBudget.tokens[0]!.sliceId).toBe('slice-1'); + expect(semBudget.tokens[0]!.epicId).toBe('epic-1'); + + const retryBudget = net.initialMarking.find((m) => m.place === 'slice:slice-1:retry-budget')!; + expect(retryBudget.tokens[0]!.retryCount).toBe(0); + + // Pool tokens have no sliceId / epicId (shared pool). + const pool = net.initialMarking.find((m) => m.place === 'pool:test-agent')!; + expect(pool.tokens[0]!.sliceId).toBeUndefined(); + expect(pool.tokens[0]!.epicId).toBeUndefined(); + }); + + it('emits distinct token ids for every initial token', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + const ids = net.initialMarking.flatMap((m) => m.tokens.map((t) => t.id)); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe('serializeBlueprint — golden counts pinned per fixture', () => { + it('simplePlan: 22 places, 19 transitions, 5 places hold initial tokens', () => { + const blueprint = compileTopology(simplePlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-1', tokenIdFn: deterministicTokenId() }); + expect(net.places.length).toBe(22); + expect(net.transitions.length).toBe(19); + expect(net.initialMarking.length).toBe(5); + }); + + it('depPlan: 42 places, 37 transitions, 8 places hold initial tokens', () => { + const blueprint = compileTopology(depPlan, { maxRetries: 3 }); + const net = serializeBlueprint(blueprint, { runId: 'run-2', tokenIdFn: deterministicTokenId() }); + expect(net.places.length).toBe(42); + expect(net.transitions.length).toBe(37); + // 2 pools + 2 slices × 3 per-slice seeded places (semantic-budget, retry-budget, eligible) + expect(net.initialMarking.length).toBe(8); + }); +}); diff --git a/src/orchestrator/src/petrinaut-export.ts b/src/orchestrator/src/petrinaut-export.ts new file mode 100644 index 00000000..652e1bd0 --- /dev/null +++ b/src/orchestrator/src/petrinaut-export.ts @@ -0,0 +1,161 @@ +// --------------------------------------------------------------------------- +// FE-762 — Petrinaut JSON export of the compiled NetBlueprint. +// +// Serializes a (refactored, Petri-net-faithful) blueprint into Petrinaut's +// expected JSON format so cook runs can write `/net.json` for the +// Petrinaut team to render and pressure-test. +// +// Pure function: no filesystem side effects. The cook entry point writes +// the result to disk; tests consume the value directly. +// +// Open coordination items (tracked on FE-762): +// - exact JSON envelope per Petrinaut's loader (pending team) +// - string vs uuid discrete type for semantic IDs (pending H-6518/H-6519) +// - place naming convention (full internal IDs vs short labels) — for v1 +// both are emitted (`id` is the internal ID, `label` strips the +// `slice::` / `epic::` prefix for a short visual label) +// --------------------------------------------------------------------------- + +import { randomUUID } from 'node:crypto'; + +import { enumerateCandidateOutputs } from './net-blueprint.js'; +import type { NetBlueprint, TokenSeed } from './net-blueprint.js'; + +/** + * Schema version of the exported JSON. Bump on any breaking shape change so + * Petrinaut loaders can refuse incompatible runs early. + */ +export const PETRINAUT_NET_SCHEMA_VERSION = '0.1.0'; + +/** + * Per-instance Petrinaut token. Cross-team-agreed shape (2026-05-26): + * `{ id: , ...payload }` where `id` is the per-instance visual + * identity and the rest are semantic payload fields from the orchestrator's + * internal Token. UUIDs are generated at serialization time; the question + * of whether they persist across consume→emit (token lineage tracing) is a + * FE-763 open coordination item. + */ +export type PetrinautToken = { + id: string; + sliceId?: string; + epicId?: string; + retryCount?: number; + reworkCount?: number; +}; + +export type PetrinautPlace = { + /** Internal place ID (e.g. `slice:slice-1:spec-ready`). */ + id: string; + /** Short visual label with the `slice::` / `epic::` prefix stripped. */ + label: string; +}; + +export type PetrinautTransition = { + /** Internal transition ID (e.g. `slice-1:evaluate:dispatch`). */ + id: string; + /** Same as id for v1 — Petrinaut may want a short label later. */ + label: string; + /** Subnet lane (`mechanical` | `semantic` | `epic`). */ + lane?: string; + /** Transition classification (`mechanical` | `semantic` | `structural`). */ + kind: string; + /** What entity fires this transition (when meaningful). */ + actor?: string; + /** Human-readable guard description. */ + guard?: string; + /** Input arcs: places this transition consumes from. */ + inputs: string[]; + /** Output arcs: places this transition may emit to (full reachable set). */ + outputs: string[]; +}; + +export type PetrinautMarking = { + place: string; + tokens: PetrinautToken[]; +}; + +export type PetrinautNet = { + schemaVersion: string; + runId: string; + places: PetrinautPlace[]; + transitions: PetrinautTransition[]; + initialMarking: PetrinautMarking[]; +}; + +export type SerializeBlueprintOpts = { + runId: string; + /** Override the per-token UUID generator (tests use a deterministic stub). */ + tokenIdFn?: () => string; +}; + +/** + * Serialize a compiled NetBlueprint into Petrinaut JSON shape. + * + * Topology (places + transitions + arcs) comes directly from the blueprint; + * the candidate output set for each transition is computed via + * `enumerateCandidateOutputs`. Initial marking is grouped by place and each + * token gets a fresh UUID. + */ +export function serializeBlueprint(blueprint: NetBlueprint, opts: SerializeBlueprintOpts): PetrinautNet { + const tokenId = opts.tokenIdFn ?? randomUUID; + + const places: PetrinautPlace[] = blueprint.places.map((id) => ({ + id, + label: shortPlaceLabel(id), + })); + + const transitions: PetrinautTransition[] = blueprint.transitions.map((t) => { + const outs = enumerateCandidateOutputs(t); + return { + id: t.id, + label: t.id, + kind: t.contract.kind, + ...(t.contract.lane !== undefined ? { lane: t.contract.lane } : {}), + ...(t.contract.actor !== undefined ? { actor: t.contract.actor } : {}), + ...(t.contract.guard !== undefined ? { guard: t.contract.guard } : {}), + inputs: [...t.inputs], + outputs: [...outs].sort(), + }; + }); + + // Group initial tokens by place, preserving declaration order within each place. + const byPlace = new Map(); + for (const { place, token } of blueprint.initialTokens) { + const list = byPlace.get(place) ?? []; + list.push(token); + byPlace.set(place, list); + } + + const initialMarking: PetrinautMarking[] = Array.from(byPlace.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([place, tokens]) => ({ + place, + tokens: tokens.map((seed) => seedToToken(seed, tokenId())), + })); + + return { + schemaVersion: PETRINAUT_NET_SCHEMA_VERSION, + runId: opts.runId, + places, + transitions, + initialMarking, + }; +} + +function seedToToken(seed: TokenSeed, id: string): PetrinautToken { + return { + id, + ...(seed.sliceId ? { sliceId: seed.sliceId } : {}), + ...(seed.epicId ? { epicId: seed.epicId } : {}), + ...(seed.retryCount !== undefined ? { retryCount: seed.retryCount } : {}), + ...(seed.reworkCount !== undefined ? { reworkCount: seed.reworkCount } : {}), + }; +} + +function shortPlaceLabel(placeId: string): string { + const sliceMatch = placeId.match(/^slice:[^:]+:(.+)$/); + if (sliceMatch) return sliceMatch[1]!; + const epicMatch = placeId.match(/^epic:[^:]+:(.+)$/); + if (epicMatch) return epicMatch[1]!; + return placeId; +} diff --git a/src/orchestrator/src/types.ts b/src/orchestrator/src/types.ts index ae0affe1..e1be280d 100644 --- a/src/orchestrator/src/types.ts +++ b/src/orchestrator/src/types.ts @@ -109,6 +109,13 @@ export type OrchestratorInput = { * (`cook//`). Unused in fixture mode. */ runId?: string; + /** + * Optional run directory (e.g. `/.cook/runs//`). When set, + * the orchestrator writes the Petrinaut-format compiled net to + * `/net.json` after `compileTopology` returns (FE-762). Tests and + * library callers that do not need on-disk export can omit it. + */ + runDir?: string; }; export type EpicOutcome = {