feat(sdk): make f.agent real, fix cross-package flow-handle identity, TS quickstart

Merged
KH
2mo ago
+530-46

Summary

Three findings from actually trying to write and run a relayflow as an external user would (prompted by tightening up the README's Quickstart), chased down one at a time.

1. Every externally-authored .flow.ts refused to load at all. "expected an @relayflows/surface flow handle" — a real Node dual-package-instance hazard, not specific to f.agent. flow()'s handle validation is a WeakMap keyed by module-scoped object identity inside @relayflows/surface itself; an author's own project resolves its own separate installed copy of that package, so the CLI's internal copy's WeakMap never has the entry the author's copy wrote.

The fix is not a new identity mechanism on the handle — packages/surface/tests/flow.test.ts's "refuses malformed and forged handles at the runtime boundary" already pre-empts exactly that path (a Symbol.for()-tagged handle is globally guessable, so it's forgeable — I tried it, broke that test for the right reason, reverted, kept the WeakMap). The real fix: authored-flow-loader.ts now dynamically resolves @relayflows/surface/runtime from the flow file's own location (the same anchor its own import already used), so both sides read the same WeakMap instance. That needed @relayflows/surface's exports map to also carry a require condition alongside the existing ESM-only import, purely so createRequire(...).resolve() can find the file path — .resolve() never executes the file, so the package stays ESM-only in practice.

2. f.agent threw unsupported_verb unconditionally. The kernel already has real, tested agent-step dispatch — this morning's daemon-lifecycle workflow used it directly. The gap was authored-flow-executor.ts never lowering f.agent into a kernel AgentStepSpec at all. It now does, and reuses checkAuthoredFlow's existing preflight pipeline (cli/check.ts) to resolve a real CLI from the project's flows.json — the same resolution a declarative type: agent YAML step already gets, which an authored TS flow had never gone through.

Getting this working end-to-end against a real attached worker (not a mock) surfaced one more real bug: readCompletedStepOutput read the journal exactly once, immediately after runStart — fine for a deterministic step, which the kernel drives to completion inline, but an agent step's completion depends on an external worker actually running a real CLI process. Added a bounded poll, and made the run-outcome cross-check re-fetch fresh status only when polling was actually needed (never on the synchronous path), so the existing mock-journal unit tests keep working unmodified.

3. README's Quickstart now shows a real, verified TypeScript flow (f.run + f.done) instead of YAML, plus an honest note on what's real now (f.run, f.agent) vs. still design-only (f.llm, f.human, f.dispatch, f.cloud).

Test plan

  • Full SDK suite: 804 passed, 3 skipped (gate-2 analyzer skip, expected)
  • Surface suite: 7 passed, including the forged-handle security test unchanged
  • New live test (tests/live-kernel.test.ts): f.agent through a real attached AgentWorker running a real spawned CLI process — not a mock, proves the full round trip (lowering → preflight CLI resolution → dispatch → async completion wait → AgentResult mapping)
  • Verified the dual-package fix for real: built a separate npm project outside this repo, ran npm install @relayflows/surface from the real registry, wrote a flow, ran it through the built CLI — confirmed the exact failure without the fix, confirmed success with it

Not in scope

f.llm, f.human, f.dispatch, f.cloud remain unimplemented — f.human specifically needs a new kernel primitive (no fourth StepType exists for a pause-for-human-input step), which is real, separate, foundational work.

🤖 Generated with Claude Code

https://claude.ai/code/session_013Y8uLRUXqKSZsqeeUMNaS2


Summary by cubic

Fixes a dual-package identity bug that rejected every externally authored .flow.ts ("expected an @relayflows/surface flow handle"), makes f.agent dispatch a real kernel agent step instead of throwing unsupported_verb, and updates the README quickstart to a working TypeScript flow.

Changes

  • @relayflows/surface adds a require export condition so createRequire().resolve() can locate it from the flow file's own path (still ESM-only since .resolve() never executes), plus an engines floor for CJS consumers.
  • The loader resolves getFlowDefinition from the flow file's own copy of @relayflows/surface, so both sides share the same handle WeakMap.
  • f.agent lowers to a kernel AgentStepSpec and resolves its CLI through the same preflight as declarative type: agent steps; completion waits via classifyOutcome, following the worker's lease instead of a fixed deadline, reporting a parked run as exit 3, and removing a step.completed/terminal-state race.
  • Workspace strings with a : readonly / : readwrite annotation are refused (unsupported_workspace_permission) because nothing enforces them; it and agent_cli_unresolved report as exit-2 refusals like flows check.
  • The default flowPath anchors on cwd (join(cwd, 'flow.ts')) so CLI resolution searches cwd, not its parent.
  • README quickstart also marks which verbs are real (f.run, f.agent) vs. still design-only.

Follow-up

  • packages/sdk's lockfile still pins published @relayflows/surface@2.0.6, which lacks the require condition; a clean npm ci needs a fixed surface publish plus a dependency/lockfile bump.