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
22 changes: 22 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,28 @@ The herdr model: first-party helpers are just plugins that ship in the box; the
- **Preflight is part of the contract:** a plugin declares what must be provable before a run using it starts (credentials present, server reachable, scope grantable). A plugin that can't state its preflight doesn't load. Covenant 2 extends to the ecosystem by construction.
- Receipts, budget attribution, and identity scoping apply to plugin verbs exactly as to first-party ones — they come from the compile target, so a plugin can't opt out.

### Initial plugin manifest and runtime

`flows add helper-datadog` installs `@flows/helper-datadog` in the nearest
`flows.json` project. See `testdata/plugins/helper-datadog/flows-plugin.json`
for the manifest and `packages/sdk/src/plugin-manifest.ts` for validation.
Successful installation records the package in `flows.json.plugins`; optional
`flows-plugin.d.ts` augments `@relayflows/surface`'s `Ctx` and is added to the
project's `tsconfig.json` include list (currently plain JSON configs).

This first slice supports `lowersTo: "effect"`. A plugin supplies an ESM
`src/index.js` exporting `execute(namespace, method, input, { idempotencyKey })`.
The SDK snapshots arguments, validates their JSON Schema, and executes the
provider through the existing journal-backed agent effect protocol. Providers
must honor the supplied kernel effect key. Credentials and HTTP(S) HEAD probes
run before authored flow bodies, including `flows check`.

Follow-ups: other primitive targets, trigger/gate dispatch (currently refused
with `plugin_unsupported`), manifest-driven type generation, JSONC tsconfigs,
plugin code bundling/pinning, declarative-flow plugin preflight, and restart
recovery of an interrupted plugin effect. Plugin effects currently inherit the
internal authored executor's child-run lifecycle, not a resumable authored root.

## 4. Build: the immutable bundle

`flows build` seals a flow into a content-addressed, immutable bundle: canonical spec JSON, compiled TS with pinned deps, helper/plugin lockfile, assets, preflight declaration, identity signature — `flow@sha256:…`, pushed to a bucket/registry. `flows deploy` points a trigger at a digest; `flows run flow@sha256:…` executes from the bucket on any cell, no checkout. Preflight runs at build time for everything build-provable and again at deploy time for environment facts (credentials, workers, MCP servers). The working tree is for authoring; **production only ever runs digests.**
Expand Down
139 changes: 139 additions & 0 deletions docs/evidence/spec-J-plugin-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Plugin registry minimal slice evidence

Ships npm installation, effect-only manifests/runtime, authored preflight, and
optional module augmentation. Follow-ups are listed in SURFACE §3.
No kernel source changes. No new skipped tests.

Dependency setup: `npm ci --ignore-scripts` in packages/sdk, followed by copying
this worktree's built surface `dist` into its installed surface package. Bun was
added to PATH and the kernel was built from this worktree for the final SDK run.
An earlier SDK run used an older shared daemon and lacked Bun; final results below
supersede that environment run. The last receipt metadata edit was covered by the
focused rerun and both typechecks after the full suite.

## Focused checks

From packages/sdk:
```
RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/2930744996/debug/relayflowd ./node_modules/.bin/vitest run tests/preflight.test.ts tests/plugin-add.test.ts tests/plugin-loader.test.ts
```
Captured output:
```

RUN v2.1.9 /Users/khaliqgant/flows-spec-J-plugin/packages/sdk

✓ tests/preflight.test.ts (27 tests) 35ms
✓ tests/plugin-loader.test.ts (5 tests) 146ms
✓ tests/plugin-add.test.ts (7 tests) 772ms
✓ installs a real offline npm fixture and includes declarations 369ms
✓ typechecks the augmented verb and rejects unknown namespaces 392ms

Test Files 3 passed (3)
Tests 39 passed (39)
Start at 19:12:07
Duration 1.31s (transform 281ms, setup 0ms, collect 960ms, tests 953ms, environment 0ms, prepare 98ms)

```

## Types

From packages/sdk: `npm run typecheck && npm run typecheck:tests` (exit 0).
Captured output:
```

> @relayflows/sdk@2.0.8 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json


> @relayflows/sdk@2.0.8 typecheck:tests
> tsc -p tsconfig.tests.json

```

## Kernel

From kernel:
```
PATH=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH sh ../ops/cargo.sh test --workspace
```
Exit 0. Captured result lines (full local log: /private/tmp/spec-J-kernel.log):
```
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 37.52s
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.55s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.58s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.59s
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
test result: ok. 28 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
```

## Full SDK suite

From packages/sdk:
```
PATH=/Users/khaliqgant/.bun/bin:$PATH RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/2930744996/debug/relayflowd ./node_modules/.bin/vitest run
```
Exit 1: the real Claude analyzer readiness probe is unavailable. This is an
unpassed acceptance test, not a green full suite. Existing opt-in adapter tests
remain skipped; no skip flags were enabled. Captured final output excerpt
(full local log: /private/tmp/spec-J-sdk-final.log):
```
✓ built flows CLI against live relayflowd > preflights before journaling and names an unreachable socket 1972ms
✓ built flows CLI against live relayflowd > starts exactly one daemon when two runs race for one empty data dir 967ms
✓ surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once 1845ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

FAIL tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI
Error: LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/flows-spec-J-plugin/testdata/preflight/analyze-story-claude-cli auth status" exited 1: analyze-story-claude-cli: "claude -p --model claude-haiku-4-5-20251001" exited 1: — failing because gate-2 acceptance requires the real analyzer to execute. Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is not gate evidence.
❯ tests/live-kernel.test.ts:1223:15
1221| const notice = `LIVE_ANALYZER_UNAVAILABLE: ${readiness.detail}`;
1222| if (process.env['RELAYFLOWS_ALLOW_ANALYZER_SKIP'] !== '1') {
1223| throw new Error(
| ^
1224| `${notice} — failing because gate-2 acceptance requires the …
1225| + 'Set RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 only if this run is …

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

Test Files 1 failed | 69 passed | 1 skipped (71)
Tests 1 failed | 1220 passed | 3 skipped (1224)
Start at 19:11:02
Duration 57.03s (transform 1.25s, setup 0ms, collect 8.68s, tests 214.59s, environment 6ms, prepare 1.97s)

```

## CLI smoke

Scratch project used a fixture-only npm shim that forwards to real npm's offline
local-package install, rather than contacting the public npm registry.
```
Command: node packages/sdk/dist/cli.js add helper-datadog (scratch project, npm stand-in installs local offline fixture)
Added @flows/helper-datadog
exit=0
{
"plugins": [
"@flows/helper-datadog"
]
}

```
13 changes: 13 additions & 0 deletions packages/sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { pluginHelpers } from './plugin-loader.js';
import { runPluginEffect } from './authored-plugin-effect.js';
import { randomUUID } from 'node:crypto';
import { dirname } from 'node:path';
import { assertSlackCredentials, runSlackEffect } from './authored-slack-effect.js';
Expand Down Expand Up @@ -306,6 +308,17 @@ export async function executeAuthoredFlow<Input = undefined>(
),
};

Object.assign(context, pluginHelpers(checkedMcp.plugins ?? [], (plugin, verb, args) => {
const label = `${verb.namespace}.${verb.method}`;
assertOperationAllowed(label, definition.name, requestedCompletion);
const id = `plugin-${nextStep++}`;
return trackStep(authoredSteps, new AuthoredFlowOperation(
id, label, () => assertOperationAllowed(label, definition.name, requestedCompletion),
() => runPluginEffect(journal, definition.name, id, plugin, verb, args, journalSteps, budget),
lifecycle,
));
}));

let bodyFailed = false;
let bodyFailure: unknown;
try {
Expand Down
152 changes: 152 additions & 0 deletions packages/sdk/src/authored-plugin-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { createHash, randomUUID } from 'node:crypto';
import { compileSpec, toKernelSpec } from './compile.js';
import { invokePlugin, type LoadedPlugin } from './plugin-loader.js';
import type { PluginVerb } from './plugin-manifest.js';
import type { AuthoredBudget } from './authored-budget.js';
import { SPEC_SCHEMA_VERSION } from './spec.js';
import type { JournalClient } from './journal-client.js';
import type { StepDispatchEvent } from './protocol.js';
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
import { readCompletedStepOutput } from './authored-step-output.js';
import { withWorkerLease } from './worker-lease.js';

export class PluginStepError extends AuthoredFlowExecutionError {
constructor(readonly diagnostic: string, runId: string) {
super('step_failed', diagnostic, 'worker_error', runId);
}
}

/** Helpers lower to an existing agent effect, never a fourth kernel primitive.
* The Plugin receipt lives in step.completed.output: { type, input, output, ... }.
* A private stream routes the step to this short-lived SDK worker exclusively.
*/
export async function runPluginEffect(
journal: JournalClient, flowName: string, id: string, plugin: LoadedPlugin, verb: PluginVerb,
args: unknown, journalSteps: AuthoredFlowJournalStep[], budget: AuthoredBudget,
): Promise<unknown> {
const server = verb.namespace;
const tool = verb.method;
const idempotencyKey = `plugin:${server}:${tool}:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`;
const surfacePath = `/plugins/${pathPart(server)}/${pathPart(tool)}`;
const stream = `plugin-worker-${randomUUID()}`;
const instruction = JSON.stringify({ type: 'effect', server, tool, input: args });
const peer = journal.createPeer();
let diagnostic: string | undefined;
let settled!: () => void;
let failed!: (error: unknown) => void;
const completed = new Promise<void>((resolve, reject) => { settled = resolve; failed = reject; });
void completed.catch(() => undefined);
let timer: ReturnType<typeof setTimeout> | undefined;
let work: Promise<void> | undefined;
let dispatchExpired = false;
const dispatch = (event: StepDispatchEvent): void => {
if (dispatchExpired) return;
const dispatched = event.spec as { instruction?: string; surfaces?: { streams?: { stream: string }[] } };
if (event.step_id !== id || event.step_type !== 'agent' || work !== undefined
|| dispatched?.instruction !== instruction
|| !dispatched.surfaces?.streams?.some(pin => pin.stream === stream)) {
failed(new Error('Plugin worker received an unexpected dispatch'));
return;
}
clearTimeout(timer);
timer = undefined;
work = execute(event);
void work.then(settled, failed);
};
async function execute(event: StepDispatchEvent): Promise<void> {
const receipt = { type: 'effect' as const, plugin: plugin.manifest.name, version: plugin.manifest.version,
namespace: server, method: tool, input: args, idempotencyKey: event.idempotency_key };
let output: unknown;
let confirmed = false;
try {
// Renew the worker lease while the Plugin tool is in flight. Without this,
// a tool that runs longer than the initial lease loses ownership and
// the write-back path fails — see worker-lease.ts for the renewal contract.
confirmed = await withWorkerLease(peer, event, async () => {
return peer.performEffect({
runId: event.run_id, stepId: id, attempt: event.attempt, idempotencyKey: event.idempotency_key,
surfacePath, revisionBefore: 'pending', revisionAfter: idempotencyKey,
}, async () => {
try { output = await invokePlugin(plugin, verb, args, event.idempotency_key); }
catch { diagnostic = 'plugin_execution_failed'; throw new PluginExecutionFailure(); }
});
});
// A confirmed election without its receipt is an interrupted writeback,
// not a successful result we may invent or a call we may safely repeat.
if (!confirmed) throw new Error('plugin_result_unavailable');
} catch (error) {
if (error instanceof PluginExecutionFailure) diagnostic = 'plugin_execution_failed';
else if (error instanceof Error && error.message === 'plugin_result_unavailable') diagnostic = error.message;
else throw error; // Journal failures remain fail-closed, never provider errors.
}
await peer.stepComplete(event.run_id, id, event.attempt, event.idempotency_key,
diagnostic === undefined ? 'success' : 'worker_error', {
output: { ...receipt, output: output ?? null, ...(diagnostic ? { diagnostic } : {}) },
started_pins: event.pins, end_pins: event.pins,
...(diagnostic ? { trajectory_tail: { ...receipt, diagnostic } } : {}),
effects: confirmed ? [{ surface_path: surfacePath, idempotency_key: event.idempotency_key }] : [],
});
}
let childRunId: string | undefined;
async function cancelChildRun(): Promise<void> {
const runId = childRunId;
childRunId = undefined;
if (runId !== undefined) {
try { await journal.runCancel(runId); } catch { /* fail-open on cleanup */ }
}
}
const deadlineError = new Error('Plugin worker dispatch deadline exceeded');
let finishDeadline!: () => void;
const deadline = new Promise<void>((resolve, reject) => {
finishDeadline = resolve;
// One deadline covers connection, initialization, run creation and dispatch.
timer = setTimeout(() => {
dispatchExpired = true;
failed(deadlineError);
reject(deadlineError);
}, 30_000);
});
try {
await Promise.race([peer.connect(), deadline]);
await Promise.race([peer.hello('flows-plugin'), deadline]);
peer.on('step.dispatch', dispatch);
await Promise.race([peer.workerAttach(stream, ['agent'], { workspace: [], streams: [{ stream, read_offset: 0 }] }, 1), deadline]);
const spec = toKernelSpec(compileSpec({ version: SPEC_SCHEMA_VERSION, name: `${flowName}/${id}`,
steps: [{ id, type: 'agent', instruction,
surfaces: { streams: [{ stream }], external: [surfacePath] }, maxIterations: 1 }],
}));
return await Promise.race([budget.execute(journal, spec, async outcome => {
childRunId = outcome.run_id;
// run.start may answer after the deadline and the outer cleanup.
if (dispatchExpired) await cancelChildRun();
await completed;
Comment thread
cursor[bot] marked this conversation as resolved.
if (diagnostic !== undefined) {
try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
catch (error) {
if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
throw new PluginStepError(diagnostic, outcome.run_id);
}
}
const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
return receipt.output;
}), deadline]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deadline race abandons plugin execution

High Severity

The 30s dispatch timer starts at the beginning of runPluginEffect and is raced against budget.execute. When a flow has a budget, that wait includes the serialized queue behind other budgeted steps, so a concurrent f.agent/f.llm/f.run longer than 30s can fail the plugin with a spurious dispatch timeout. Winning the race also abandons the still-running budget.execute promise, which later rejects without a handler after run.start settles.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 512e31e. Configure here.

} finally {
clearTimeout(timer);
finishDeadline();
peer.off('step.dispatch', dispatch);
// If the dispatch deadline fired we started a child run that will never
// be worked -- cancel it so its journal doesn't stay indeterminate.
// Best-effort: the parent flow already surfaced the deadline via the
// rejected `completed` promise, so a cancel error here must not mask it.
if (dispatchExpired) await cancelChildRun();
peer.close();
await work?.catch(() => undefined);
}
}

function pathPart(value: string): string {
return encodeURIComponent(value).replaceAll('.', '%2E');
}

class PluginExecutionFailure extends Error {}
6 changes: 6 additions & 0 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { addPlugin } from './cli/add.js';
import { watchCheck } from './cli-watch.js';
import { checkHelperBody } from './cli/check-helper-body.js';

Expand Down Expand Up @@ -44,6 +45,7 @@ export interface CliIo {

type CliExitCode = 0 | 1 | 2 | 3;
type ParsedArgs =
| { command: 'add'; value: string }
| ReplayArgs
| BuildArgs
| DeployArgs
Expand All @@ -61,6 +63,7 @@ type ParsedArgs =
const DEFAULT_DATA_DIR = '.relayflowd';
const USAGE = [
'Usage:',
'flows add <helper-name|@flows/helper-name>',
'flows build [--out <dir>] <flow.yaml|flow.ts>',
'flows build --verify <bundle-dir>',
'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
Expand Down Expand Up @@ -108,6 +111,8 @@ export async function runCli(
return 2;
}

if (parsed.command === 'add') return addPlugin(parsed.value, io);

if (parsed.command === 'serve-webhook') return runServeWebhook(parsed, io);

if (parsed.command === 'cloud-run') return runCloudCli(parsed, io);
Expand Down Expand Up @@ -409,6 +414,7 @@ function emitWait(

function parseArgs(args: readonly string[]): ParsedArgs | undefined {
const command = args[0];
if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
if (command === 'replay') return parseReplayArgs(args.slice(1));
if (command === 'build') return parseBuildArgs(args.slice(1));
if (command === 'deploy') return parseDeployArgs(args.slice(1));
Expand Down
Loading
Loading