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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
# Type rejection fixtures retain diagnostics in JSON evidence.
echo "::remove-matcher owner=tsc::"
npm run verify
- name: Measure repeated Interactive Program loads (diagnostic, Ubuntu Node 24)
if: ${{ matrix.os == 'ubuntu-24.04' && matrix.node == '24.22.1' }}
run: node scripts/bench-interactive-load.mjs
- name: Consume candidate package through all documentation examples
env:
FLOWKIT_DOCS_EVIDENCE: .tmp/docs/candidate.json
Expand Down
134 changes: 134 additions & 0 deletions docs/interactive-stage2-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# FlowKit Interactive — Stage 2 integration contracts

> Internal / P0-A only. This document is about static compilation and Host binding
> compatibility, not a new Context module, artifact store, model service or Stage extension.

## Project roots and Skill static validation

`compileInteractive(file, {output:'memory'|'file',featureStage:'P0-A'})`
reuses the **original Standard analyzer** for static diagnostics. Its
project root is now discovered from the nearest ancestor `package.json`, as in
Standard `compile` and `check`. A Host may explicitly override this with
`projectRoot`, resolved against the process working directory.

The Interactive compile options are deliberately restricted to the relevant
subset of the Standard static configuration contract:

| Option | Behavior |
| --- | --- |
| `projectRoot` | Nearest ancestor `package.json` by default, else explicit root |
| `skillsRoot` | Default `skills`; used for `agent` → local Skill reference |
| `skillCheck` | Default true; false skips local file-existence checks only |
| `remoteSkillHosts` | Default empty allowlist for HTTPS Skill references |
| `outDir` | Optional output location for Interactive compiled Program |
| `output` | `memory` or `file` |
| `featureStage` | Strictly `P0-A` |
| `targetVersion` | Optional `interactive-state-machine-v1` |

No remote Skill is downloaded by the compiler. Integrity/allowlist diagnostics
are inherited directly from Standard's static Skill checker. Options that
have no Interactive compile-time meaning (such as Standard tracing,
`mermaid`, live `fetch` callbacks) are rejected instead of silently ignored.
`flowkit.config.mjs` is still a **CLI-level** configuration convention:
the SDK does not silently execute a project configuration file.

Example:

```js
const program = await compileInteractive('workflows/review.workflow.js', {
output: 'memory',
featureStage: 'P0-A',
skillsRoot: 'skills',
remoteSkillHosts: ['skills.example.test'],
});
```

A Host can also set `projectRoot`, or disable local existence checking in
environments where the trusted Host resolves Skills outside the compile machine.
Disabling `skillCheck` never authorizes unknown remote hosts or missing
Integrity requirements.

## Host callback binding identity (existing policyHash contract)

Interactive P0-A supports two Agent runtime bindings:
`runtime.skillResolver` and synchronous, pure `runtime.validateResult`.
These are Host code, not serialized inside the Program.

The Coordinator already fingerprints the **entire Host-supplied `policy`**
as `policyHash`, persists that fingerprint at start, and rejects any
`resume` / `complete` / `load` under a different policy **before a
Checkpoint CAS**. No new SDK API or additional runtime is needed for binding
versions. The recommended Host policy is:

```js
const policy = {
deploymentId: 'approved-host-2026-11',
bindingVersions: {
'runtime.skillResolver': 'writer-package@2.3.0/sha256-<approved-digest>',
'runtime.validateResult': 'validator@1.5.0/sha256-<approved-digest>',
},
};
const coordinator=createInteractiveCoordinator({store,runtime,policy});
```

**Host obligations:**

1. Derive each declared version from an approved deployment or immutable
resource manifest, including relevant Skill bytes, validator logic,
dependencies and externally loaded configuration.
2. Keep the actual implementation and referenced bytes consistent with the
declared version for the lifetime of an active Run. If a dependency changes
in a way that could change execution, bump its binding version **before**
the new Host attempts to resume.
3. Keep the original Host policy/implementation accessible until that Run
completes, or explicitly arrange a reviewed and audited migration.
4. Never treat an unchanged string version as cryptographic proof of unchanged
behavior. FlowKit does **not** introspect arbitrary JS closures, network
dependencies, or mutable files to discover their semantic identity.
5. `policy.bindingVersions` is **recommended and Host-maintained**, not yet
mandatory for legacy P0-A runs. Introducing a strict mandatory flag would
break already deployed Agent runs; it requires a separate compatibility
decision. P0-A relies on the Host to supply truthful version identities.

Negative tests exercise changed version → `FK_I_POLICY_MISMATCH`, with the
Checkpoint and Journal unchanged, and the original version → successful
resume. It protects against **declared** policy drift, not undocumented
in-place changes in a Host callback.

## Shared checkStep callback

A generic Standard Host may expose `runtime.checkStep`. Presence alone
is harmless when the verified P0-A Program has **no Agent bindings** (for
example Condition → Terminal); the callback is never invoked.

For an Agent Program, P0-A still rejects a non-undefined `checkStep`
rather than silently skipping a validation function it does not implement.
Agent verification and external `checkStep` remain reserved for P0-B.
The error is `FK_I_UNSUPPORTED_GRAPH` and no Run is created.

## Program-load cost and bounded verified cache

The initial sampled benchmark on Ubuntu Node 24.22.1 measured a 15,908-byte
immutable Program across 16 Condition nodes: repeated verified loader
median **5.386 ms**, p90 **9.068 ms** (28 warm samples), with **34**
artifact reads and **147.468 ms** full Run time. These are
single-run diagnostic samples, **not a production SLA**.

Because per-phase loader re-parsing is meaningful relative to the overall
execution time, the Coordinator now maintains a **per-instance, 4-entry LRU**
of *already-verified immutable Program objects*. It never stores the Program
in an external or global cache. At **every stage, including cache hits**,
FlowKit still reads the Host-owned Store, verifies the full Program byte hash
and manifest identity, checks the current policy, runtime binding requirements,
and legal Checkpoint phase. A changed/missing Store artifact rejects even
with a warm cache; this is covered by explicit negative tests.

`node scripts/bench-interactive-load.mjs` measures the same workflow without
latency thresholds. The precise Stage 2 PR CI evidence records both before
and after results; timings vary by OS/Node and must not be treated as SLA.

## Exit gate

Keep all Standard six root API functions, Program ABI V1, signed M5 provenance,
S07 Checkpoint/receipt guarantees and stage gates unchanged. M6's S13 real
model Host execution remains separate and blocked until native evidence exists.
12 changes: 12 additions & 0 deletions docs/interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
- **Not implemented in P0-A**: Human, Action, Verification, Loop, Parallel, Agent verify or external checkStep, automatic `runAgent` invocation, trusted Human provenance bridge, background service, P0-B/P1/P2 extensions.
- The Coordinator and the Store are **owned and invoked by the Host**, not by the model. A model must not have direct filesystem/database access or direct `complete()` authority.

## Static configuration and Host binding version consistency

The Interactive compiler now reuses Standard's project-root discovery and
Skill static validation options (`projectRoot`, `skillsRoot`, `skillCheck`,
`remoteSkillHosts`). Versioned Host validation/Skill bindings are already
pinned through the existing `policyHash` when the Host declares
`policy.bindingVersions`. Pure Condition/Terminal Programs tolerate an unused
shared `checkStep` callback; Agent P0-A still rejects one.

See [Stage 2 integration contracts](./interactive-stage2-contract.md) for
Host obligations, compatibility limits and verified Program cache behavior.

## Low-level Host-only example: compile, start, resolve pending, resume

The source is a validated `*.workflow.js` or `*.workflow.ts` using
Expand Down
90 changes: 90 additions & 0 deletions scripts/bench-interactive-load.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Diagnostic-only benchmark, not a correctness/latency gate.
* Measures repeated exact-byte Program loads and a P0-A multi-stage Run.
* Never modifies the generated Program, security, or checkpoint semantics.
*
* Run after npm ci: node scripts/bench-interactive-load.mjs
*/
import {performance} from 'node:perf_hooks';
import {mkdtemp,rm} from 'node:fs/promises';
import path from 'node:path';
import {tmpdir} from 'node:os';
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 {createSqliteCheckpointStore} from '../src/interactive/sqlite-store.mjs';
import {createInteractiveCoordinator} from '../src/interactive/coordinator.mjs';

const N=16;
const conditions=[];
for(let i=0;i<N;i++){
conditions.push("condition('gate"+i+"',{name:'Gate "+i+"',branches:["+
"{when:'true',next:'"+(i===N-1?'done':'gate'+(i+1))+"'}]})");
}
conditions.push("terminal('done',{name:'Done',status:'succeeded'})");
const source=[
"import {defineWorkflow,condition,terminal} from '@devcodex-labs/flowkit/dsl';",
"export default defineWorkflow({id:'bench-interactive',version:'1.0.0',name:'Benchmark',nodes:["+
conditions.join(',')+"]});",
].join('\n');
const analysis=await analyzeSource(source,{filename:'bench.workflow.js',
projectRoot:process.cwd(),skillCheck:false});
if(analysis.diagnostics.some(x=>x.severity==='error')) throw new Error(
JSON.stringify(analysis.diagnostics));
const ir=lowerInteractive(analysis);
if(!ir.ir) throw new Error(JSON.stringify(ir.diagnostics));
const emitted=emitInteractive(ir.ir);
if(!emitted.code) throw new Error(JSON.stringify(emitted.diagnostics));
const bytes=Buffer.from(emitted.code);
const hash=emitted.programIdentity.artifactHash;
const samples=[];
for(let i=0;i<32;i++){
const start=performance.now();
await loadInteractiveProgram(bytes,hash);
if(i>=4)samples.push(performance.now()-start);
}
samples.sort((a,b)=>a-b);
const temp=await mkdtemp(path.join(tmpdir(),'fk-stage2-bench-'));
let store;
try{
store=createSqliteCheckpointStore(path.join(temp,'run.sqlite'),{namespace:'stage2-bench'});
let artifactReads=0;
let artifactReadMs=0;
const decorated={
...store,
getArtifact(digest){
const start=performance.now();
const result=store.getArtifact(digest);
artifactReads++;
artifactReadMs+=performance.now()-start;
return result;
},
};
const api=createInteractiveCoordinator({store:decorated,runtime:{}});
const startedAt=performance.now();
const completed=await api.start({
programBytes:bytes,programIdentity:emitted.programIdentity,
runId:'stage2-bench-1',startKey:'stage2-bench-1',state:{ready:true},
});
const totalMs=performance.now()-startedAt;
if(completed.status!=='succeeded' || completed.state.ready!==true) {
throw new Error('Benchmark workflow did not reach successful terminal');
}
const revisions=store.listJournal(completed.runId).length;
console.log('STAGE2_PROGRAM_LOAD_BENCH '+JSON.stringify({
schemaVersion:1,nodeVersion:process.version,os:process.platform,
programBytes:bytes.length,conditionNodes:N,
loadSamples:samples.length,
loadMedianMs:Number(samples[Math.floor(samples.length/2)].toFixed(3)),
loadP90Ms:Number(samples[Math.floor(samples.length*.9)].toFixed(3)),
totalRunMs:Number(totalMs.toFixed(3)),
artifactReads,
artifactReadTotalMs:Number(artifactReadMs.toFixed(3)),
journalRevisions:revisions,
finalStatus:completed.status,
note:'single-run sampled diagnostics; no production SLA or cache change',
}));
}finally{
try{store?.close()}finally{await rm(temp,{force:true,recursive:true})}
}
14 changes: 12 additions & 2 deletions src/interactive/compile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 {normalizeOptions,resolveProjectRoot} from '../compiler/source.mjs';
import {lowerInteractive} from '../compiler/interactive/lower.mjs';
import {emitInteractive} from '../compiler/interactive/emit.mjs';

Expand All @@ -12,12 +13,20 @@ export async function compileInteractive(file, options) {
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']);
// Match the Standard static analyzer's project and Skill discovery rules.
// Do NOT silently accept unrelated Standard runtime/emit options.
const known = new Set(['output','outDir','featureStage','targetVersion',
'projectRoot','skillsRoot','skillCheck','remoteSkillHosts']);
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]]));
const analyzerOptions=normalizeOptions(Object.fromEntries(
['projectRoot','skillsRoot','skillCheck','remoteSkillHosts']
.filter(key=>Object.hasOwn(snapshotOptions,key))
.map(key=>[key,snapshotOptions[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') {
Expand All @@ -27,9 +36,10 @@ export async function compileInteractive(file, options) {
const cwd = process.cwd();
const filename = path.resolve(cwd,file);
const outDir = snapshotOptions.outDir === undefined ? path.dirname(filename) : path.resolve(cwd,snapshotOptions.outDir);
const projectRoot=resolveProjectRoot(filename,analyzerOptions.projectRoot,cwd);
const snapshot = await readFile(filename); // original source bytes, never imported
const analysis = await analyzeSource(snapshot,{
filename, projectRoot:path.dirname(filename),
...analyzerOptions,filename,projectRoot,
});
const lowered = lowerInteractive(analysis,{featureStage:options.featureStage});
if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]};
Expand Down
47 changes: 37 additions & 10 deletions src/interactive/coordinator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,12 @@ function assertRuntime(program,runtime) {
if (typeof runtime !== 'object' || !runtime || Array.isArray(runtime)) {
throw new TypeError('InteractiveRuntime must be an object');
}
if (runtime.checkStep !== undefined) {
throw failure('FK_I_UNSUPPORTED_GRAPH','P0-A cannot silently skip checkStep');
// A Host may expose shared Standard runtime callbacks. Ignore an unused
// checkStep only when the verified Program has no Agent/validation bindings.
// Agent P0-A still fails closed: we must not silently skip its checkStep.
if (runtime.checkStep !== undefined
&& program.programTable.requiredBindings.length > 0) {
throw failure('FK_I_UNSUPPORTED_GRAPH','P0-A cannot silently skip Agent checkStep');
}
for (const binding of program.programTable.requiredBindings) {
if (binding.id === 'runtime.validateResult' && typeof runtime.validateResult !== 'function'
Expand Down Expand Up @@ -233,21 +237,44 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt
const policySnapshot = copy(policy);
const policyHash = fingerprint(policySnapshot);
const stageBudget = validStageCount(maxStagesPerCall);
// Only verified immutable Program objects are cached, never Store records.
// Even on a cache hit every stage re-reads the Host's Store, checks exact
// Program bytes and identity, then verifies the current Run phase/policy.
// Small fixed LRU avoids a broad, implicit process-global cache.
const verifiedPrograms = new Map();
const MAX_VERIFIED_PROGRAMS = 4;

async function loadPinned(cp) {
if (cp.namespace !== store.namespace || cp.policyHash !== policyHash) {
throw failure('FK_I_POLICY_MISMATCH','Run policy and namespace must match');
}
assertSHA256(cp.artifactHash);
const saved = store.getArtifact(cp.artifactHash);
if (!saved || saved.programIdentity.artifactHash !== cp.artifactHash) {
throw failure('FK_I_PROGRAM_MISMATCH','Missing compiled Program bytes');
}
const program = await loadInteractiveProgram(saved.bytes,cp.artifactHash);
const declared = {...program.interactiveProgram.identity,artifactHash:cp.artifactHash};
if (canonicalBytes(declared).toString('hex')
!== canonicalBytes(saved.programIdentity).toString('hex')) {
throw failure('FK_I_PROGRAM_MISMATCH','Stored Program manifest mismatch');
if (!saved || saved.programIdentity?.artifactHash !== cp.artifactHash
|| !(saved.bytes instanceof Uint8Array)
|| sha256Bytes(saved.bytes) !== cp.artifactHash) {
throw failure('FK_I_PROGRAM_MISMATCH','Missing or modified compiler Program bytes');
}
const identityBytes=canonicalBytes(saved.programIdentity);
const cached=verifiedPrograms.get(cp.artifactHash);
let program;
if (cached && cached.identityBytes.equals(identityBytes)) {
program=cached.program;
// Update LRU order; no change to the frozen Program or Store.
verifiedPrograms.delete(cp.artifactHash);
verifiedPrograms.set(cp.artifactHash,cached);
} else {
// Never trust stale cached ABI/identity after a Store metadata change.
program=await loadInteractiveProgram(saved.bytes,cp.artifactHash);
const declared={...program.interactiveProgram.identity,artifactHash:cp.artifactHash};
if (!canonicalBytes(declared).equals(identityBytes)) {
throw failure('FK_I_PROGRAM_MISMATCH','Stored Program manifest mismatch');
}
verifiedPrograms.delete(cp.artifactHash);
verifiedPrograms.set(cp.artifactHash,{program,identityBytes});
if (verifiedPrograms.size>MAX_VERIFIED_PROGRAMS) {
verifiedPrograms.delete(verifiedPrograms.keys().next().value);
}
}
assertRuntime(program,execution);
confirmCurrentBlock(cp,program);
Expand Down
Loading