diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index 498d1c0..a5ab734 100644 --- a/scripts/package-files.mjs +++ b/scripts/package-files.mjs @@ -9,6 +9,8 @@ export const packageFiles = [ ...['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`]), + ['src/compiler/interactive/emit.mjs', 'dist/compiler/interactive/emit.mjs'], + ...['compile', 'identity', 'load'].map(name => [`src/interactive/${name}.mjs`, `dist/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`]), diff --git a/src/compiler/interactive/emit.mjs b/src/compiler/interactive/emit.mjs new file mode 100644 index 0000000..94b03cb --- /dev/null +++ b/src/compiler/interactive/emit.mjs @@ -0,0 +1,153 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { evaluateWhenAst } from '../../when/evaluate.mjs'; +import { validateInteractiveIR } from './validate-ir.mjs'; + +const compilerVersion = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).version; +const SHA = value => 'sha256:' + createHash('sha256').update(value).digest('hex'); + +// The generated program includes the original Standard evaluator *implementation*. +// Only requireBoolean is a tiny standalone wrapper for its existing free binding. +function requireBoolean(value) { + if (typeof value !== 'boolean') throw Object.assign(new Error('When operand and final result must be boolean'), { code: 'FK_R002' }); + return value; +} + +/** Emits executable, synchronous JavaScript. No application code is run while compiling. */ +function advance(view, event) { + if (!view || !event || event.schemaVersion !== 1 || view.blockId !== event.forBlockId) { + throw new TypeError('Event does not target the current checkpoint block'); + } + const blockId = view.blockId; + const phase = view.phase; + const block = programTable.blocks.find(item => item.id === blockId); + if (!block || block.phase !== phase || !block.eventKinds.includes(event.kind)) { + throw new TypeError('Uncertified event or invalid stage'); + } + const nodeId = block.nodeId; + const record = programTable.nodes[nodeId]; + const none = { kind: 'none' }; + const proposal = (kind, toBlockId, stackEffect = none, mergeIntent = none) => + ({ kind, fromBlockId: blockId, nodeId, toBlockId, stackEffect, mergeIntent }); + const fail = code => ({ + kind: 'raise-error', fromBlockId: blockId, nodeId, + causeCode: code || 'FK_R002', stackEffect: none, mergeIntent: none, + }); + if (event.kind === 'deterministic-failure') return fail(event.error?.code); + const to = (node, name) => node + '::' + name; + if (phase === 'enter') { + if (record.kind === 'agent') { + if (!Number.isSafeInteger(event.activation) || event.activation < 1) { + throw new TypeError('Missing certified Agent activation'); + } + const frame = { + kind: 'agent', nodeId, activation: event.activation, + attempt: 1, phase: 'prepare', reasons: [], overviewStep: event.activation, + }; + return proposal('move', to(nodeId, 'prepare'), {kind: 'push', frame}); + } + return proposal('move', to(nodeId, 'route')); + } + if (record.kind === 'condition' && phase === 'route') { + try { + for (const branch of record.metadata.branches) { + if (evaluateWhenAst(branch.ast, view.state)) { + return proposal('move', to(branch.next, 'enter')); + } + } + } catch (error) { + if (error?.code === 'FK_R002') return fail('FK_R002'); + throw error; // trusted Coordinator classifies unexpected program failures + } + return fail('FK_R002'); + } + if (record.kind === 'terminal' && phase === 'route') { + return { + kind: 'terminal', fromBlockId: blockId, nodeId, + terminal: {status: record.metadata.status, nodeId}, + stackEffect: none, mergeIntent: none, + }; + } + if (record.kind !== 'agent') throw new TypeError('Unsupported P0-A block'); + const frame = view.controlStack[view.controlStack.length - 1]; + if (!frame || frame.kind !== 'agent' || frame.nodeId !== nodeId) { + throw new TypeError('Missing certified Agent control frame'); + } + if (phase === 'prepare') return proposal('prepare-pending', to(nodeId, 'dispatch-ready')); + if (phase === 'dispatch-ready') return proposal('publish-pending', to(nodeId, 'awaiting')); + if (phase === 'validate') { + if (event.kind === 'validation-passed') { + if (view.candidate?.kind !== 'agent') throw new TypeError('Verified Agent candidate not committed'); + return proposal('commit-candidate', to(nodeId, 'commit')); + } + if (event.kind === 'validation-rejected' || event.kind === 'agent-call-failed') { + const attempts = record.metadata.retries + 1; + if (frame.attempt >= attempts) return fail('FK_R004'); + return proposal('retry-agent', to(nodeId, 'prepare'), + {kind: 'update', expectedKind: 'agent', expectedNodeId: nodeId, + field: 'attempt', value: frame.attempt + 1}); + } + throw new TypeError('Agent validation event is not ready for Program advancement'); + } + if (phase === 'commit') { + if (view.candidate?.kind !== 'agent') throw new TypeError('Missing committed Agent candidate'); + return proposal('commit-state', to(nodeId, 'route'), none, + {kind:'commit-candidate', requestId:view.candidate.requestId}); + } + if (phase === 'route') { + return proposal('move', to(record.metadata.next, 'enter'), + {kind:'pop', expectedKind:'agent', expectedNodeId:nodeId}); + } + throw new TypeError('Illegal Program advancement at awaiting/unknown phase'); +} +function freezeData(value) { + if (value && typeof value === 'object') { + for (const key of Object.keys(value)) freezeData(value[key]); + Object.freeze(value); + } + return value; +} + +export function emitInteractive(ir, options = {}) { + const validation = validateInteractiveIR(ir); + if (!validation.ok) { + return Object.freeze({ code:null, programIdentity:null, diagnostics:validation.diagnostics }); + } + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError('Emit options must be an object'); + } + const manifest = { + artifactFormat:'FlowkitInteractiveProgramV1', + identity:{ + workflowId: ir.workflowId, workflowVersion:ir.workflowVersion, + sourceHash: 'sha256:' + ir.sourceHash, compilerVersion, + programAbiVersion:1, semanticsVersion:'interactive-state-machine-v1', + checkpointSchemaVersion:1, canonicalizationVersion:'jcs-rfc8785-v1', + implementedStage:'P0-A', + }, + }; + const agents = Object.values(ir.nodes).some(node => node.kind === 'agent'); + const bindings = agents ? [ + {id:'runtime.validateResult',version:'1',mode:'pure-local',kind:'agent-validation'}, + {id:'runtime.skillResolver',version:'1',mode:'trusted-read',kind:'skill'}, + ] : []; + const table = { + schemaVersion:1, entryBlockId:ir.entryBlockId, blocks:ir.blocks, + nodes:ir.nodes, requiredBindings:bindings, + }; + const code = [ + '// @generated by FlowKit Interactive — P0-A / Program ABI V1', + '// This trusted compiler output MUST be loaded from verified immutable bytes.', + requireBoolean.toString(), + evaluateWhenAst.toString(), + freezeData.toString(), + 'export const interactiveProgram = freezeData(' + JSON.stringify(manifest) + ');', + 'export const programTable = freezeData(' + JSON.stringify(table) + ');', + 'export ' + advance.toString(), + '', + ].join('\n'); + const programIdentity = Object.freeze({ + ...manifest.identity, artifactHash:SHA(Buffer.from(code, 'utf8')), + }); + return Object.freeze({code, programIdentity, diagnostics:Object.freeze([])}); +} diff --git a/src/interactive/compile.mjs b/src/interactive/compile.mjs new file mode 100644 index 0000000..2d23021 --- /dev/null +++ b/src/interactive/compile.mjs @@ -0,0 +1,58 @@ +import {readFile} from 'node:fs/promises'; +import {mkdir,open,rename,unlink} from 'node:fs/promises'; +import {randomUUID} from 'node:crypto'; +import path from 'node:path'; +import {analyzeSource} from '../compiler/analyze.mjs'; +import {lowerInteractive} from '../compiler/interactive/lower.mjs'; +import {emitInteractive} from '../compiler/interactive/emit.mjs'; + +/** Internal M2 compile API; the exported SDK subpath is introduced in M4. */ +export async function compileInteractive(file, options) { + if (typeof file !== 'string' || !/\.workflow\.(js|ts)$/.test(file)) { + throw new TypeError('Expected .workflow.js or .workflow.ts file'); + } + if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Compile options object required'); + const known = new Set(['output','outDir','featureStage','targetVersion']); + for (const key of Reflect.ownKeys(options)) { + if (typeof key !== 'string' || !known.has(key)) throw new TypeError('Unknown Interactive compile option'); + if (!Object.hasOwn(Object.getOwnPropertyDescriptor(options,key),'value')) throw new TypeError('Compile option accessors forbidden'); + } + const snapshotOptions = Object.fromEntries(Object.keys(options).map(key=>[key,options[key]])); + if (!['memory','file'].includes(snapshotOptions.output)) throw new TypeError('Invalid compile output'); + if (snapshotOptions.featureStage !== 'P0-A') throw new TypeError('Only P0-A is implemented'); + if (snapshotOptions.targetVersion !== undefined && snapshotOptions.targetVersion !== 'interactive-state-machine-v1') { + throw new TypeError('Unsupported semantics version'); + } + if (snapshotOptions.outDir !== undefined && typeof snapshotOptions.outDir !== 'string') throw new TypeError('outDir must be a string'); + const cwd = process.cwd(); + const filename = path.resolve(cwd,file); + const outDir = snapshotOptions.outDir === undefined ? path.dirname(filename) : path.resolve(cwd,snapshotOptions.outDir); + const snapshot = await readFile(filename); // original source bytes, never imported + const analysis = await analyzeSource(snapshot,{ + filename, projectRoot:path.dirname(filename), + }); + const lowered = lowerInteractive(analysis,{featureStage:options.featureStage}); + if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]}; + const emitted = emitInteractive(lowered.ir); + if (emitted.diagnostics.some(d => d.severity === 'error')) { + return {code:'',diagnostics:[...lowered.diagnostics,...emitted.diagnostics]}; + } + const result = {code:emitted.code,diagnostics:[...lowered.diagnostics],programIdentity:emitted.programIdentity}; + if (snapshotOptions.output === 'file') { + const stem = path.basename(filename).replace(/\.workflow\.(js|ts)$/,''); + const artifactPath = path.join(outDir,stem + '.interactive.mjs'); + await mkdir(outDir,{recursive:true}); + const temporary = path.join(outDir,'.' + stem + '.' + randomUUID() + '.tmp'); + const handle = await open(temporary,'wx'); + try { + await handle.writeFile(emitted.code,'utf8'); await handle.sync(); await handle.close(); + await rename(temporary,artifactPath); + } catch(e) { + await handle.close().catch(()=>{}); + await unlink(temporary).catch(()=>{}); + throw e; + } + result.artifactPath = artifactPath; + } + return result; +} diff --git a/src/interactive/identity.mjs b/src/interactive/identity.mjs new file mode 100644 index 0000000..a541ba1 --- /dev/null +++ b/src/interactive/identity.mjs @@ -0,0 +1,13 @@ +import {createHash} from 'node:crypto'; + +export function sha256Bytes(bytes) { + if (!(bytes instanceof Uint8Array)) throw new TypeError('Expected immutable program byte snapshot'); + return 'sha256:' + createHash('sha256').update(bytes).digest('hex'); +} + +export function assertSha256(hash) { + if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) { + throw new TypeError('Invalid SHA-256 identity'); + } + return hash; +} diff --git a/src/interactive/load.mjs b/src/interactive/load.mjs new file mode 100644 index 0000000..1945e08 --- /dev/null +++ b/src/interactive/load.mjs @@ -0,0 +1,99 @@ +import {parse} from 'acorn'; +import {sha256Bytes,assertSha256} from './identity.mjs'; +import {validateInteractiveIR,agentFrameRule} from '../compiler/interactive/validate-ir.mjs'; + +/** A verified program is trusted compiler output, NOT an untrusted JavaScript sandbox. */ +function ensureNoImports(ast) { + const pending = [ast]; + while (pending.length) { + const node = pending.pop(); + if (!node || typeof node !== 'object') continue; + if (['ImportDeclaration','ImportExpression','ExportAllDeclaration'].includes(node.type)) { + throw new TypeError('Interactive Program imports are forbidden'); + } + if (node.type === 'ExportNamedDeclaration' && node.source) { + throw new TypeError('Re-exports are forbidden'); + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) if (item?.type) pending.push(item); + } else if (value?.type) { + pending.push(value); + } + } + } +} +function ownJSON(value, depth = 0, seen = new Set()) { + if (depth > 64) throw new TypeError('Excessive Program metadata depth'); + if (value === null || ['string','number','boolean'].includes(typeof value)) { + if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value,-0))) throw new TypeError('Invalid number'); + return value; + } + if (!value || typeof value !== 'object' || seen.has(value)) throw new TypeError('Invalid Program metadata'); + const plain = Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null; + if (!plain) throw new TypeError('Untrusted Program metadata prototype'); + seen.add(value); + const result = Array.isArray(value) ? [] : Object.create(null); + const props = Reflect.ownKeys(value); + for (const key of props) { + if (typeof key !== 'string') throw new TypeError('Program metadata must have string keys'); + const descriptor = Object.getOwnPropertyDescriptor(value,key); + if (!descriptor || !Object.hasOwn(descriptor,'value')) throw new TypeError('Program metadata has accessor'); + if (Array.isArray(value) && key === 'length') continue; + if (Array.isArray(value) && !/^(0|[1-9]\d*)$/.test(key)) throw new TypeError('Invalid Program metadata array key'); + if (!Array.isArray(value) && (key === 'constructor' || key === 'prototype')) throw new TypeError('Unsafe metadata key'); + const own = ownJSON(descriptor.value, depth+1,seen); + Object.defineProperty(result,key,{value:own,enumerable:true,writable:false,configurable:false}); + } + if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new TypeError('Sparse Program metadata array'); + seen.delete(value); + return Object.freeze(result); +} + +/** Loads exactly the verified byte snapshot, regardless of file-name swaps. */ +export async function loadInteractiveProgram(bytes, expectedArtifactHash) { + if (!(bytes instanceof Uint8Array)) throw new TypeError('Program bytes must be Uint8Array'); + assertSha256(expectedArtifactHash); + const immutable = Buffer.from(bytes); + if (immutable.byteLength > 4 * 1024 * 1024) throw new TypeError('Oversize Interactive Program'); + if (sha256Bytes(immutable) !== expectedArtifactHash) throw new Error('FK_I_PROGRAM_MISMATCH: artifact hash differs'); + const code = new TextDecoder('utf-8', {fatal:true,ignoreBOM:true}).decode(immutable); + const ast = parse(code,{ecmaVersion:'latest',sourceType:'module'}); + ensureNoImports(ast); + const advances = ast.body.filter(n => n.type === 'ExportNamedDeclaration' && n.declaration?.type === 'FunctionDeclaration' && n.declaration.id.name === 'advance'); + if (advances.length !== 1) throw new TypeError('Exactly one exported advance function required'); + const originalBody = code.slice(advances[0].declaration.start,advances[0].declaration.end); + const module = await import('data:text/javascript;base64,' + immutable.toString('base64')); + if (typeof module.advance !== 'function' || module.advance.constructor.name === 'AsyncFunction' + || Function.prototype.toString.call(module.advance) !== originalBody) { + throw new TypeError('Loaded advance function differs from verified bytes'); + } + if (Object.keys(module).sort().join(',') !== 'advance,interactiveProgram,programTable') { + throw new TypeError('Unexpected Interactive Program exports'); + } + const manifest = ownJSON(module.interactiveProgram); + const table = ownJSON(module.programTable); + if (manifest.artifactFormat !== 'FlowkitInteractiveProgramV1' + || manifest.identity.programAbiVersion !== 1 + || manifest.identity.semanticsVersion !== 'interactive-state-machine-v1' + || manifest.identity.implementedStage !== 'P0-A' + || table.schemaVersion !== 1 || !Array.isArray(table.blocks) + || !table.blocks.length || !table.nodes || !Array.isArray(table.requiredBindings)) { + throw new TypeError('Invalid Interactive Program ABI V1'); + } + // Validate the ProgramTable as a self-contained compiler contract. + // No source files or runtime lowering are consulted. + if (!/^sha256:[0-9a-f]{64}$/.test(manifest.identity.sourceHash)) throw new TypeError('Invalid Program source identity'); + const staticIR = { + schemaVersion:1,featureStage:'P0-A',sourceHash:manifest.identity.sourceHash.slice(7), + entryBlockId:table.entryBlockId,blocks:table.blocks,nodes:table.nodes, + frameRules:Object.keys(table.nodes).filter(id=>table.nodes[id].kind==='agent').map(id=>agentFrameRule(id)), + }; + const validated = validateInteractiveIR(staticIR); + if (!validated.ok) throw new TypeError('FK_I_INVALID_TRANSITION: invalid ProgramTable'); + return Object.freeze({ + artifactHash:expectedArtifactHash, + interactiveProgram:manifest,programTable:table, + advance:module.advance, + }); +} diff --git a/tests/interactive/emitter.test.mjs b/tests/interactive/emitter.test.mjs new file mode 100644 index 0000000..2e2c2de --- /dev/null +++ b/tests/interactive/emitter.test.mjs @@ -0,0 +1,134 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {analyzeSource} from '../../src/compiler/analyze.mjs'; +import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; +import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; +import {loadInteractiveProgram} from '../../src/interactive/load.mjs'; +import {compileInteractive} from '../../src/interactive/compile.mjs'; +import {evaluateWhenAst} from '../../src/when/evaluate.mjs'; + +const importDSL = "import {defineWorkflow,condition,node,terminal} from '@devcodex-labs/flowkit/dsl';"; +function source(branches="[ {when:'ready && review.approved',next:'worker'}, {when:'true',next:'failed'} ]") { + return importDSL + '\nexport default defineWorkflow({id:"interactive-test",version:"1.0.0",name:"Test",nodes:[' + + "condition('gate',{name:'Gate',branches:" + branches + "})," + + "node('worker',{name:'Worker',agent:'writer',inputs:['missing'],output:{type:'object',properties:{answer:{type:'string'}}},retries:2,next:'done'})," + + "terminal('done',{name:'Done',status:'succeeded'})," + + "terminal('failed',{name:'Failed',status:'failed'})]});"; +} +async function compile(text=source()) { + const analysis = await analyzeSource(text, {filename:'interactive.workflow.js', projectRoot:process.cwd(),skillCheck:false}); + assert.equal(analysis.diagnostics.some(d=>d.severity==='error'),false,JSON.stringify(analysis.diagnostics)); + const result=lowerInteractive(analysis); + assert.ok(result.ir,JSON.stringify(result.diagnostics)); + const emitted=emitInteractive(result.ir); + assert.ok(emitted.code,JSON.stringify(emitted.diagnostics)); + const loaded=await loadInteractiveProgram(Buffer.from(emitted.code,'utf8'),emitted.programIdentity.artifactHash); + return {analysis,ir:result.ir,...emitted,loaded}; +} +const ev=(blockId,kind='internal-step',extra={})=>({schemaVersion:1,forBlockId:blockId,kind,eventId:'e',eventDigest:'sha256:'+'0'.repeat(64),...extra}); +const cv=(blockId,state={},controlStack=[],candidate=null)=>({schemaVersion:1,namespace:'ns',runId:'r',revision:1,blockId,phase:blockId.split('::')[1],state,controlStack,candidate}); + +test('M2 emits deterministic standalone JS with exact hash, ABI V1, zero imports and required bindings',async()=>{ + const a=await compile();const b=await compile(); + assert.equal(a.code,b.code); + assert.equal(a.programIdentity.artifactHash,b.programIdentity.artifactHash); + assert.equal(a.programIdentity.artifactHash,'sha256:'+createHash('sha256').update(Buffer.from(a.code,'utf8')).digest('hex')); + assert.equal(a.loaded.programTable.entryBlockId,'gate::enter'); + assert.equal(a.loaded.interactiveProgram.identity.sourceHash,a.programIdentity.sourceHash); + assert.equal(a.loaded.interactiveProgram.identity.implementedStage,'P0-A'); + assert.equal(a.loaded.programTable.requiredBindings.length,2); + assert.equal(a.loaded.programTable.nodes.worker.metadata.retries,2); + assert.equal(Object.isFrozen(a.loaded.programTable),true); + assert.equal(Object.isFrozen(a.loaded.programTable.nodes),true); + assert.equal(a.code.includes('export function advance'),true); + assert.equal(a.code.includes('import '),false); +}); + +test('generated JS routes Condition and Terminal, preserving original when own-data semantics',async()=>{ + const a=await compile(); + const p=a.loaded.advance; + const enter=p(cv('gate::enter'),ev('gate::enter')); + assert.equal(enter.toBlockId,'gate::route'); + assert.equal(enter.kind,'move'); + const t=p(cv('gate::route',{ready:true,review:{approved:true}}),ev('gate::route')); + assert.equal(t.toBlockId,'worker::enter'); + const f=p(cv('gate::route',{ready:false}),ev('gate::route')); + assert.equal(f.toBlockId,'failed::enter'); + const invalid=p(cv('gate::route',{ready:true}),ev('gate::route')); + assert.equal(invalid.kind,'raise-error'); + assert.equal(invalid.causeCode,'FK_R002'); + const term=p(cv('done::route'),ev('done::route')); + assert.deepEqual(term.terminal,{status:'succeeded',nodeId:'done'}); + for(const state of [{ready:false},{ready:true,review:{approved:true}},{ready:true}]) { + const ast=a.ir.nodes.gate.metadata.branches[0].ast; + let expected; + try { expected=evaluateWhenAst(ast,state); } catch(e){expected=e.code} + const q=p(cv('gate::route',state),ev('gate::route')); + assert.equal(q.kind==='raise-error'?'FK_R002':q.toBlockId==='worker::enter',expected); + } +}); + +test('Agent generated JS emits certified frame, Pending publish, retry, commit and pop decisions',async()=>{ + const {loaded}=await compile(); + const advance=loaded.advance; + const enter=advance(cv('worker::enter'),ev('worker::enter','internal-step',{activation:7})); + assert.equal(enter.stackEffect.kind,'push'); + assert.equal(enter.stackEffect.frame.activation,7); + assert.equal(enter.stackEffect.frame.attempt,1); + const frame=enter.stackEffect.frame; + assert.equal(advance(cv('worker::prepare',{},[frame]),ev('worker::prepare')).kind,'prepare-pending'); + assert.equal(advance(cv('worker::dispatch-ready',{},[frame]),ev('worker::dispatch-ready','pending-prepared')).kind,'publish-pending'); + const retry=advance(cv('worker::validate',{},[frame]),ev('worker::validate','validation-rejected',{reasons:['bad output']})); + assert.equal(retry.kind,'retry-agent'); + assert.equal(retry.stackEffect.value,2); + const exhausted=advance(cv('worker::validate',{},[{...frame,attempt:3}]),ev('worker::validate','validation-rejected')); + assert.equal(exhausted.kind,'raise-error'); + assert.equal(exhausted.causeCode,'FK_R004'); + const candidate={kind:'agent',requestId:'req-1',result:{answer:'ok'}}; + assert.equal(advance(cv('worker::validate',{},[frame],candidate),ev('worker::validate','validation-passed')).kind,'commit-candidate'); + const commit=advance(cv('worker::commit',{},[frame],candidate),ev('worker::commit')); + assert.deepEqual(commit.mergeIntent,{kind:'commit-candidate',requestId:'req-1'}); + assert.equal(commit.toBlockId,'worker::route'); + const route=advance(cv('worker::route',{},[frame]),ev('worker::route')); + assert.deepEqual(route.stackEffect,{kind:'pop',expectedKind:'agent',expectedNodeId:'worker'}); + assert.equal(route.toBlockId,'done::enter'); + assert.throws(()=>advance(cv('worker::enter'),ev('worker::enter')),TypeError); + assert.throws(()=>advance(cv('worker::awaiting',{},[frame]),ev('worker::awaiting')),TypeError); +}); + +test('loader rejects wrong digest, dynamic imports and mismatched Program ABI',async()=>{ + const {code,programIdentity}=await compile(); + const bytes=Buffer.from(code,'utf8'); + await assert.rejects(loadInteractiveProgram(Buffer.from(code+'\n','utf8'),programIdentity.artifactHash),/PROGRAM_MISMATCH/); + await assert.rejects(loadInteractiveProgram(bytes,'sha256:bad'),/Invalid SHA/); + const imp="import 'node:fs';\n"+code; + const hash=s=>'sha256:'+createHash('sha256').update(s).digest('hex'); + await assert.rejects(loadInteractiveProgram(Buffer.from(imp),hash(imp)),/imports are forbidden/); + const dynamic=code+'\nfunction bad(){ return import("node:fs"); }\n'; + await assert.rejects(loadInteractiveProgram(Buffer.from(dynamic),hash(dynamic)),/imports are forbidden/); + const different=code.replace('FlowkitInteractiveProgramV1','MaliciousProgram'); + await assert.rejects(loadInteractiveProgram(Buffer.from(different),hash(different)),/Invalid Interactive Program ABI/); + const tampered=code.replace('worker::enter','alien::enter'); + await assert.rejects(loadInteractiveProgram(Buffer.from(tampered),hash(tampered)),/INVALID_TRANSITION/); +}); + +test('public-facing compiler can emit memory/file without importing DSL source, and only uses original analyzer',async t=>{ + const dir=await mkdtemp(path.join(tmpdir(),'fk-m2-')); + t.after(()=>rm(dir,{recursive:true,force:true})); + const file=path.join(dir,'example.workflow.js'); + await writeFile(file,importDSL+"\nexport default defineWorkflow({id:'only-terminal',version:'1.0.0',name:'Only',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});"); + const memory=await compileInteractive(file,{output:'memory',featureStage:'P0-A'}); + assert.ok(memory.code.length>500); + assert.equal(memory.artifactPath,undefined); + const disk=await compileInteractive(file,{output:'file',featureStage:'P0-A',outDir:dir}); + assert.equal(await readFile(disk.artifactPath,'utf8'),memory.code); + const loaded=await loadInteractiveProgram(Buffer.from(disk.code),disk.programIdentity.artifactHash); + assert.equal(loaded.advance(cv('done::route'),ev('done::route')).terminal.status,'succeeded'); + await assert.rejects(compileInteractive(file,{output:'memory',featureStage:'P0-B'}),/Only P0-A/); + await assert.rejects(compileInteractive(file,{output:'memory',featureStage:'P0-A',surprise:true}),/Unknown Interactive/); + await assert.rejects(compileInteractive(file,{output:'file',featureStage:'P0-A',outDir:1}),/outDir must/); +});