Skip to content
Merged
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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
],
"scripts": {
"build": "node scripts/build.mjs",
"test": "node --test tests/dsl.test.mjs tests/contracts.test.mjs tests/compiler/*.test.mjs tests/when/*.test.mjs tests/runtime/*.test.mjs tests/cli/*.test.mjs tests/integrations/*.test.mjs tests/package/*.test.mjs",
"test": "node --test tests/dsl.test.mjs tests/contracts.test.mjs tests/compiler/*.test.mjs tests/interactive/*.test.mjs tests/when/*.test.mjs tests/runtime/*.test.mjs tests/cli/*.test.mjs tests/integrations/*.test.mjs tests/package/*.test.mjs",
"test:types": "node scripts/test-types.mjs",
"check:package": "node scripts/check-package.mjs",
"test:package": "node scripts/test-package.mjs",
Expand Down
1 change: 1 addition & 0 deletions scripts/package-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const packageFiles = [
['types/capability-graph.d.ts', 'dist/capability-graph.d.ts'],
...['contracts', 'control', 'skill-ref'].map(name => [`src/shared/${name}.mjs`, `dist/shared/${name}.mjs`]),
...['source', 'extract', 'validate', 'skill-check', 'analyze', 'graph-algorithms', 'graph', 'dataflow', 'values', 'paths', 'mermaid', 'emit-plan', 'helpers', 'emit', 'compile', 'api', 'output'].map(name => [`src/compiler/${name}.mjs`, `dist/compiler/${name}.mjs`]),
...['feature-gate', 'lower', 'validate-ir'].map(name => [`src/compiler/interactive/${name}.mjs`, `dist/compiler/interactive/${name}.mjs`]),
...['parser', 'evaluate'].map(name => [`src/when/${name}.mjs`, `dist/when/${name}.mjs`]),
...['context', 'execute', 'load', 'source', 'skills', 'skill-remote', 'run', 'aot', 'dump', 'dump-channel', 'dump-hook'].map(name => [`src/runtime/${name}.mjs`, `dist/runtime/${name}.mjs`]),
...['dsl', 'index', 'compiler-api'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]),
Expand Down
57 changes: 57 additions & 0 deletions src/compiler/interactive/feature-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* M1-only Interactive feature gate. The Standard analyzer remains the single
* authority for DSL syntax, graph, dataflow, and existing FK_C/FK_R codes.
* Runtime binding checks (including checkStep) are enforced at start in M4.
*/
export const IMPLEMENTED_INTERACTIVE_STAGE = 'P0-A';
const STAGES = Object.freeze(['P0-A', 'P0-B', 'P1']);
const ENABLED = new Set(['condition', 'terminal', 'agent']);

function interactiveDiagnostic(analysis, code, message, index, node) {
const location = index === undefined ? { line: 1, column: 1 }
: analysis.literal.locations.get('/nodes/' + index) ?? { line: 1, column: 1 };
return Object.freeze({
code, severity: 'error', message, file: analysis.snapshot.absFilename,
line: location.line, column: location.column,
...(node ? { nodeId: node.id } : {}),
});
}

/** Return the original analyzer diagnostics unchanged, appending only stage-gate errors. */
export function gateInteractiveFeatures(analysis, options = {}) {
if (!analysis || !analysis.snapshot || !Array.isArray(analysis.diagnostics)) {
throw new TypeError('A completed analyzeSource() result is required');
}
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('Interactive feature options must be an object');
}
const featureStage = options.featureStage ?? IMPLEMENTED_INTERACTIVE_STAGE;
const extras = [];
if (!STAGES.includes(featureStage)) {
extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH',
'Unknown Interactive feature stage: ' + String(featureStage)));
} else if (featureStage !== IMPLEMENTED_INTERACTIVE_STAGE) {
extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH',
'This build implements only P0-A; caller cannot enable ' + featureStage));
}

// Never lower partially validated DSL, and never replace Standard diagnostics.
const standardValid = !!analysis.graph && !!analysis.literal && !!analysis.dataflow
&& !analysis.diagnostics.some(item => item.severity === 'error');
if (standardValid) {
analysis.literal.definition.nodes.forEach((node, index) => {
if (!ENABLED.has(node.type)) {
extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH',
'P0-A does not support node type ' + String(node.type), index, node));
} else if (node.type === 'agent' && node.verify != null) {
extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH',
'P0-A Agent verify requires P0-B; it must not be skipped', index, node));
}
});
}
const diagnostics = Object.freeze([...analysis.diagnostics, ...extras]);
return Object.freeze({
ok: standardValid && extras.length === 0,
featureStage, diagnostics,
});
}
94 changes: 94 additions & 0 deletions src/compiler/interactive/lower.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { gateInteractiveFeatures } from './feature-gate.mjs';
import { P0A_PHASES, interactiveBlockId, phaseContract, expectedBlockTargets, agentFrameRule, validateInteractiveIR } from './validate-ir.mjs';

function copyData(value) {
// Analyzer-owned DSL literals/when ASTs are JSON-safe. Preserve own __proto__
// keys as data and do not resolve any missing runtime input to null.
return JSON.parse(JSON.stringify(value));
}
function deepFreeze(value) {
if (value !== null && typeof value === 'object') {
for (const key of Object.keys(value)) deepFreeze(value[key]);
Object.freeze(value);
}
return value;
}

/**
* Lower only fully validated ORIGINAL FlowKit analysis, not Stage2's parser.
* Pure compile-time output: no filesystem writes, calls to models, or runtime IO.
* The M2 emitter will turn this explicit IR into Program ABI V1 JavaScript.
*/
export function lowerInteractive(analysis, options = {}) {
const gate = gateInteractiveFeatures(analysis, options);
if (!gate.ok) return Object.freeze({ ir: null, diagnostics: gate.diagnostics });
const definitions = analysis.literal.definition.nodes;
const nodes = Object.create(null);
for (const [index, node] of definitions.entries()) {
if (!analysis.graph.byId.has(node.id)) {
throw new Error('Validated graph is missing node ' + node.id);
}
let metadata;
if (node.type === 'condition') {
metadata = {
name: node.name,
branches: node.branches.map((branch, branchIndex) => {
const pointer = '/nodes/' + index + '/branches/' + branchIndex + '/when';
const ast = analysis.conditions.get(pointer);
if (!ast) throw new Error('Missing validated when AST: ' + pointer);
return { when: branch.when, ast: copyData(ast), next: branch.next };
}),
};
} else if (node.type === 'agent') {
metadata = {
name: node.name, next: node.next,
...(node.agent === undefined ? {} : { agent: node.agent }),
...(node.skill === undefined ? {} : { skill: node.skill }),
...(node.integrity === undefined ? {} : { integrity: node.integrity }),
inputs: node.inputs ?? [], output: node.output,
retries: node.retries ?? 2, overview: node.overview === true,
};
} else {
metadata = { name: node.name, status: node.status };
}
nodes[node.id] = { kind: node.type, metadata: copyData(metadata) };
}
const first = definitions[0].id;
const ir = {
schemaVersion: 1, featureStage: 'P0-A',
workflowId: analysis.literal.definition.id,
workflowVersion: analysis.literal.definition.version,
sourceHash: analysis.snapshot.sourceHash,
entryBlockId: interactiveBlockId(first, 'enter'),
nodes, blocks: [],
frameRules: definitions.filter(node => node.type === 'agent').map(node => agentFrameRule(node.id)),
// Preserve Standard dataflow as diagnostic evidence; it is not a second
// source of routing and it is not used to prefill missing Agent inputs.
dataflow: Object.fromEntries(definitions.map(node => [node.id, {
reachingKeys: [...(analysis.dataflow.inputs.get(node.id) ?? [])].sort(),
reachingKeysAfter: [...(analysis.dataflow.outputs.get(node.id) ?? [])].sort(),
}])),
graphEdges: analysis.graph.executionEdges.map(edge => ({
from: edge.from, to: edge.to, role: edge.role,
})),
};
for (const node of definitions) {
for (const phase of P0A_PHASES[node.type]) {
const contract = phaseContract(node.type, phase);
const block = {
id: interactiveBlockId(node.id, phase),
nodeId: node.id, phase,
legalTargets: expectedBlockTargets(ir, node.id, phase),
eventKinds: contract.eventKinds,
proposalKinds: contract.proposalKinds,
...(phase === 'awaiting' ? { acceptTarget: interactiveBlockId(node.id, 'validate') } : {}),
};
ir.blocks.push(block);
}
}
const checked = validateInteractiveIR(ir);
if (!checked.ok) {
return Object.freeze({ ir: null, diagnostics: Object.freeze([...gate.diagnostics, ...checked.diagnostics]) });
}
return Object.freeze({ ir: deepFreeze(ir), diagnostics: gate.diagnostics });
}
172 changes: 172 additions & 0 deletions src/compiler/interactive/validate-ir.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* P0-A resumable IR phase matrix. The lowerer and its validator share this
* table; the Coordinator must NOT reconstruct graph routing from source DSL.
*/
export const P0A_PHASES = Object.freeze({
condition: Object.freeze(['enter', 'route']),
terminal: Object.freeze(['enter', 'route']),
agent: Object.freeze(['enter', 'prepare', 'dispatch-ready', 'awaiting', 'validate', 'commit', 'route']),
});

const RULES = Object.freeze({
condition: Object.freeze({
enter: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']],
route: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']],
}),
terminal: Object.freeze({
enter: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']],
route: [['internal-step', 'deterministic-failure'], ['terminal', 'raise-error']],
}),
agent: Object.freeze({
enter: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']],
prepare: [['internal-step', 'pending-prepared', 'deterministic-failure'], ['prepare-pending', 'raise-error']],
'dispatch-ready': [['pending-prepared', 'deterministic-failure'], ['publish-pending', 'raise-error']],
awaiting: [[], []], // Coordinator accepts a receipt transaction; advance() is never called.
validate: [
['agent-result', 'agent-call-failed', 'validation-passed', 'validation-rejected', 'deterministic-failure'],
['retry-agent', 'commit-candidate', 'raise-error'],
],
commit: [['internal-step', 'deterministic-failure'], ['commit-state', 'raise-error']],
route: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']],
}),
});

export const interactiveBlockId = (nodeId, phase) => nodeId + '::' + phase;

// A static rule, not an actual persisted ControlFrame (dynamic values belong to M4).
const AGENT_FRAME_FIELDS = Object.freeze(['kind', 'nodeId', 'activation', 'attempt', 'phase', 'reasons', 'overviewStep']);
export function agentFrameRule(nodeId) {
return {kind: 'agent', nodeId, pushBlockId: interactiveBlockId(nodeId, 'enter'),
popBlockId: interactiveBlockId(nodeId, 'route'), initialAttempt: 1,
fields: [...AGENT_FRAME_FIELDS]};
}

export function phaseContract(kind, phase) {
const record = RULES[kind]?.[phase];
if (!record) return null;
return { eventKinds: [...record[0]], proposalKinds: [...record[1]] };
}

/** This derives edges from already-lowered node metadata, not from the DSL parser. */
export function expectedBlockTargets(ir, nodeId, phase) {
const record = ir.nodes?.[nodeId];
if (!record) return null;
const metadata = record.metadata;
const target = (id, p) => interactiveBlockId(id, p);
if (phase === 'enter') return [target(nodeId, record.kind === 'agent' ? 'prepare' : 'route')];
if (record.kind === 'agent') {
if (phase === 'prepare') return [target(nodeId, 'dispatch-ready')];
if (phase === 'dispatch-ready') return [target(nodeId, 'awaiting')];
if (phase === 'awaiting') return [target(nodeId, 'validate')];
if (phase === 'validate') return [target(nodeId, 'prepare'), target(nodeId, 'commit')];
if (phase === 'commit') return [target(nodeId, 'route')];
if (phase === 'route') return [target(metadata.next, 'enter')];
}
if (record.kind === 'condition' && phase === 'route') {
if (!Array.isArray(metadata.branches)) return null; // reject corrupt IR without throwing
return [...new Set(metadata.branches.map(branch => target(branch?.next, 'enter')))];
}
if (record.kind === 'terminal' && phase === 'route') return [];
return null;
}

function sameArray(actual, expected) {
return Array.isArray(actual) && actual.length === expected.length
&& actual.every((value, index) => value === expected[index]);
}

/**
* Internal M1 check, not a security boundary for untrusted compiled programs.
* M2 additionally validates the Program ABI and identity at load and execution.
*/
export function validateInteractiveIR(ir) {
const errors = [];
const bad = message => errors.push(Object.freeze({
code: 'FK_I_INVALID_TRANSITION', severity: 'error', message,
}));
if (!ir || typeof ir !== 'object' || ir.schemaVersion !== 1 || ir.featureStage !== 'P0-A'
|| !Array.isArray(ir.blocks) || !ir.nodes || typeof ir.nodes !== 'object') {
return { ok: false, diagnostics: Object.freeze([{ code: 'FK_I_INVALID_TRANSITION', severity: 'error', message: 'Invalid IR envelope' }]) };
}
const ids = Object.keys(ir.nodes);
if (!ids.length || ids.length > 500 || ir.blocks.length > 3500) bad('Invalid P0-A IR size');
if (ir.entryBlockId !== interactiveBlockId(ids[0], 'enter')) bad('Invalid entry block');
if (!/^[a-f0-9]{64}$/.test(ir.sourceHash ?? '')) bad('Invalid source hash');
const expectedFrames = ids.filter(id => ir.nodes[id]?.kind === 'agent').map(agentFrameRule);
if (!Array.isArray(ir.frameRules) || ir.frameRules.length !== expectedFrames.length) {
bad('Invalid control-frame rule count');
} else {
for (const [index, expected] of expectedFrames.entries()) {
const actual = ir.frameRules[index];
if (!actual || actual.kind !== expected.kind || actual.nodeId !== expected.nodeId
|| actual.pushBlockId !== expected.pushBlockId || actual.popBlockId !== expected.popBlockId
|| actual.initialAttempt !== expected.initialAttempt || !sameArray(actual.fields, expected.fields)) {
bad('Invalid control-frame rule for Agent ' + expected.nodeId);
}
}
}
const table = new Map();
for (const block of ir.blocks) {
if (!block || typeof block.id !== 'string') { bad('Malformed block'); continue; }
if (table.has(block.id)) bad('Duplicate block: ' + block.id);
table.set(block.id, block);
}
let expectedCount = 0;
for (const id of ids) {
const record = ir.nodes[id];
if (!record || !Object.hasOwn(P0A_PHASES, record.kind)) {
bad('Unsupported node kind: ' + id); continue;
}
const phases = P0A_PHASES[record.kind];
expectedCount += phases.length;
const metadata = record.metadata;
if (!metadata || typeof metadata !== 'object') { bad('Missing metadata: ' + id); continue; }
if (record.kind === 'condition') {
if (!Array.isArray(metadata.branches) || !metadata.branches.length
|| metadata.branches.some(branch => !branch || typeof branch.when !== 'string'
|| !branch.ast || typeof branch.ast.kind !== 'string' || typeof branch.next !== 'string')) {
bad('Malformed condition branches: ' + id);
}
}
if (record.kind === 'agent' && (!Array.isArray(metadata.inputs) || !metadata.output
|| !Number.isInteger(metadata.retries) || metadata.retries < 0 || metadata.retries > 10)) {
bad('Malformed Agent metadata: ' + id);
}
if (record.kind === 'terminal' && !['succeeded', 'failed'].includes(metadata.status)) {
bad('Invalid terminal status: ' + id);
}
for (const phase of phases) {
const blockId = interactiveBlockId(id, phase);
const block = table.get(blockId);
const contract = phaseContract(record.kind, phase);
const targets = expectedBlockTargets(ir, id, phase);
if (!block) { bad('Missing block: ' + blockId); continue; }
if (block.nodeId !== id || block.phase !== phase || block.id !== blockId
|| !sameArray(block.eventKinds, contract.eventKinds)
|| !sameArray(block.proposalKinds, contract.proposalKinds)
|| !targets || !sameArray(block.legalTargets, targets)) {
bad('Illegal phase contract or edge: ' + blockId);
}
const acceptTarget = phase === 'awaiting' ? interactiveBlockId(id, 'validate') : undefined;
if (block.acceptTarget !== acceptTarget) bad('Invalid receipt accept target: ' + blockId);
}
}
if (ir.blocks.length !== expectedCount) bad('Unexpected block count');
for (const block of ir.blocks) {
if (!block || !Array.isArray(block.legalTargets)) continue;
for (const target of block.legalTargets) {
if (!table.has(target)) bad('Unknown block target: ' + target);
}
}
// A reachable Program is required: no hidden phases may bypass the entry.
const reached = new Set();
const pending = [ir.entryBlockId];
while (pending.length) {
const next = pending.pop();
if (reached.has(next)) continue;
reached.add(next);
for (const target of table.get(next)?.legalTargets ?? []) if (table.has(target)) pending.push(target);
}
for (const id of table.keys()) if (!reached.has(id)) bad('Unreachable phase: ' + id);
return Object.freeze({ ok: errors.length === 0, diagnostics: Object.freeze(errors) });
}
Loading