-
Notifications
You must be signed in to change notification settings - Fork 0
FE-762: Petrinaut-format JSON export of the compiled net #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kostandinang
wants to merge
1
commit into
ka/fe-761-petri-petrinaut-semantics
Choose a base branch
from
ka/fe-762-petri-blueprint-export
base: ka/fe-761-petri-petrinaut-semantics
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:<id>: and epic:<id>: 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<runDir>/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:<id>:` / `epic:<id>:` 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: <UUID>, ...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:<id>:` / `epic:<id>:` 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<string, TokenSeed[]>(); | ||
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
src/orchestrator/src/engine.ts:38 — Because the
writeFileSyncis inside the maintry, any failure to writenet.json(missing/unwritablerunDir, permissions, disk issues) will halt the entire cook run even though compilation succeeded. If this is intended as best-effort integration output, it may be worth ensuring export failures don’t change the run’s success/failure semantics.Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.