Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/orchestrator/src/cook-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ export async function runCook(opts: CookOptions): Promise<void> {
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);
Expand Down
13 changes: 13 additions & 0 deletions src/orchestrator/src/engine.ts
Original file line number Diff line number Diff line change
@@ -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';

// ---------------------------------------------------------------------------
Expand All @@ -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 <runDir>/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`);
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 27, 2026

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 writeFileSync is inside the main try, any failure to write net.json (missing/unwritable runDir, 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

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

}

const net = wireHandlers(blueprint, input, ctx);
await net.run(firingPolicy, () => net.hasHaltToken());

Expand Down
187 changes: 187 additions & 0 deletions src/orchestrator/src/petrinaut-export.test.ts
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);
});
});
161 changes: 161 additions & 0 deletions src/orchestrator/src/petrinaut-export.ts
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;
}
7 changes: 7 additions & 0 deletions src/orchestrator/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ export type OrchestratorInput = {
* (`cook/<runId>/<sliceId>`). Unused in fixture mode.
*/
runId?: string;
/**
* Optional run directory (e.g. `<baseDir>/.cook/runs/<runId>/`). When set,
* the orchestrator writes the Petrinaut-format compiled net to
* `<runDir>/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 = {
Expand Down
Loading