From 8820b01333482a7b1215bf435c38eed936ee94c6 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 2 Sep 2026 16:36:53 +0200 Subject: [PATCH 01/10] feat(surface): ship v2 authoring package Replace the regression-only ambient declaration with a real @relayflows/surface package and point the dormant in-repo flows at its source contract. Keep execution and compiler concerns behind the journal-backed runtime. Refs #132 Session-Id: 01a0627a-c11f-7850-9667-c638320d25f4 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- README.md | 1 + regressions/MANIFEST.json | 4 +- regressions/README.md | 12 +-- regressions/surface.d.ts | 130 ------------------------ regressions/tsconfig.json | 6 +- surface/README.md | 17 ++++ surface/bun.lock | 198 +++++++++++++++++++++++++++++++++++++ surface/package.json | 30 ++++++ surface/src/cloud.ts | 72 ++++++++++++++ surface/src/context.ts | 33 +++++++ surface/src/flow.ts | 40 ++++++++ surface/src/index.ts | 15 +++ surface/tests/flow.test.ts | 39 ++++++++ surface/tsconfig.json | 22 +++++ surface/tsconfig.test.json | 13 +++ surface/vitest.config.ts | 7 ++ 16 files changed, 500 insertions(+), 139 deletions(-) delete mode 100644 regressions/surface.d.ts create mode 100644 surface/README.md create mode 100644 surface/bun.lock create mode 100644 surface/package.json create mode 100644 surface/src/cloud.ts create mode 100644 surface/src/context.ts create mode 100644 surface/src/flow.ts create mode 100644 surface/src/index.ts create mode 100644 surface/tests/flow.test.ts create mode 100644 surface/tsconfig.json create mode 100644 surface/tsconfig.test.json create mode 100644 surface/vitest.config.ts diff --git a/README.md b/README.md index 9584dae11..f88e21d30 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Nothing in this repo may contradict it; changing it is a human decision. ``` kernel/ relayflowd — Rust. Journal, scheduler, leases, timers, streams. One binary. sdk/ TypeScript-first authoring SDK. Compiles specs; speaks the journal protocol. +surface/ @relayflows/surface — the TypeScript flow-authoring contract. workflows/ The gates. Each gate is a relayflow; the build is orchestrated by relayflows. docs/ RFC-0001 and design docs. charter/ The Relayflow Lead. diff --git a/regressions/MANIFEST.json b/regressions/MANIFEST.json index 329883abd..c4495b548 100644 --- a/regressions/MANIFEST.json +++ b/regressions/MANIFEST.json @@ -8,9 +8,9 @@ "adoptBy": "software-garden", "adoptWhen": "flows run in cloud and the gates listed per pair have closed; the Garden should then run each red case (expect pass) and each green case (expect fail) as the standing proof the bug is still open, and flip the pair to green-only once the fix lands.", "typecheck": { - "command": "cd sdk && npx tsc -p ../regressions/tsconfig.json", + "command": "cd surface && npm run typecheck:regressions", "optIn": true, - "note": "Not part of `npm test`. Types come from regressions/surface.d.ts, a declaration-only slice of the v2 surface; delete it once @relayflows/surface exists." + "note": "Not part of `npm test`. Imports resolve to the real @relayflows/surface package source; the dormant flows remain non-executable until their required gates close." }, "surfaceGaps": [ { diff --git a/regressions/README.md b/regressions/README.md index d390fe323..54027e8b0 100644 --- a/regressions/README.md +++ b/regressions/README.md @@ -14,15 +14,15 @@ A bug is closed when its red case starts failing and its green case starts passing, in the same run. Either one alone can lie: a green test that never ran red proves nothing about the bug it claims to cover. -## These flows do not run yet, and must not +## These flows typecheck, but do not run yet Nothing in this directory is wired into a drive loop, a schedule, or CI. No flow declares an `on()` trigger, none is deployed, and nothing outside `regressions/` references it except one backlog line in `ops/BACKLOG.md`. They are written -against `@relayflows/surface` — the v2 authoring surface, which does not exist -yet. `regressions/surface.d.ts` is a declaration-only slice of it: the exact -shapes these pairs need, so the file doubles as a requirements list for -gate-1 SDK work. Delete it when the real surface ships. +against the real `@relayflows/surface` package. They remain dormant because the +cloud helpers, placement, principals, and declared failure forms listed below +belong to later gates; importing the package does not make those substrates +available at runtime. ## Running them, once the kernel can @@ -35,7 +35,7 @@ flows run regressions/.green.flow.ts # expected: FAIL while the bug is Opt-in typecheck (deliberately *not* part of `cd sdk && npm test`): ```sh -cd sdk && npx tsc -p ../regressions/tsconfig.json +cd surface && npm run typecheck:regressions ``` `MANIFEST.json` carries the same table in machine-readable form — slug, required diff --git a/regressions/surface.d.ts b/regressions/surface.d.ts deleted file mode 100644 index b12705dad..000000000 --- a/regressions/surface.d.ts +++ /dev/null @@ -1,130 +0,0 @@ -// The v2 authoring surface these regressions are written against. -// -// DECLARATION ONLY — no implementation exists yet. `@relayflows/surface` is -// what docs/SURFACE.md specifies and what gate-1 SDK work must produce. This -// file is deliberately the *narrow* slice the four regression pairs need, so it -// doubles as a requirements list: when the real surface exports these shapes, -// delete this file and the suite compiles against the SDK unchanged. -// -// Nothing here widens the kernel vocabulary. Three step verbs (run / llm / -// agent) + four resident verbs (on / human / dispatch / done); `f.cloud` is a -// helper namespace generated from a relayfile adapter (gate 6), and every verb -// on it compiles to a mount read/write or a wait (SURFACE.md §3). - -declare module "@relayflows/surface" { - /** A step result with its postfix verification gate (SURFACE.md §2 law 2). */ - export interface Step extends PromiseLike { - /** Fails the step with `gate_failed` when the predicate is false. */ - gate(predicate: (value: T) => boolean, because?: string): Step; - } - - /** Optional header — escalation only; the empty header is the common case. */ - export interface FlowHeader { - identity?: string; - memory?: { script?: boolean; agent?: boolean }; - budget?: string; - tools?: { relayfile?: string[]; mcp?: string[] }; - workspace?: string; - } - - export interface AgentResult { - summary: string; - artifacts: string[]; - } - - export interface WorkerSummary { - workerId: string; - status: string; - lastSeenAt: string | null; - } - - export interface Heartbeat { - workerId: string; - status: string; - lastSeenAt: string | null; - } - - export interface EnrollmentReceipt { - /** Mount path holding the plaintext token — never the token itself, so no - * secret is journaled (Appendix A rule 3: the mount write is the record). */ - tokenPath: string; - expiresAt: string; - registerCommand: string; - } - - export interface ScheduleState { - id: string; - lastTriggerStatus: string | null; - lastTriggeredRunId: string | null; - lastTriggerError: string | null; - } - - export interface JournalStep { - id: string; - type: "deterministic" | "llm" | "agent"; - completionReason: string | null; - } - - export interface RunJournal { - runId: string; - steps: JournalStep[]; - completionReason: string | null; - } - - /** Helper namespace generated from the AgentWorkforce cloud relayfile adapter. */ - export interface CloudHelper { - workers: { - mintEnrollmentToken(input: { - workspaceId: string; - name: string; - /** Credential scope to act under — `/principals/` (gate 8). */ - as: string; - }): Step; - list(input: { workspaceId: string; as: string }): Step<{ - online: WorkerSummary[]; - all: WorkerSummary[]; - }>; - heartbeat(input: { workerId: string; as: string }): Step; - /** Compiles to a durable wait — a legal plugin compile target (SURFACE.md §3). */ - awaitHeartbeat(input: { workerId: string; as: string; within: string }): Step; - }; - schedules: { - create(input: { - workspaceId: string; - workflow: string; - cron: string; - name: string; - as: string; - }): Step<{ id: string }>; - /** Force one sweep tick for this schedule. */ - fire(input: { scheduleId: string; as: string }): Step<{ accepted: boolean }>; - get(input: { scheduleId: string; as: string }): Step; - remove(input: { scheduleId: string; as: string }): Step<{ deleted: boolean }>; - }; - runs: { - journal(input: { runId: string; as: string }): Step; - }; - } - - /** The flow context: three step verbs, four resident verbs, helpers. */ - export interface Ctx { - run(command: string): Step; - llm(strings: TemplateStringsArray, ...values: unknown[]): Step; - agent(name: string, options: { task: string; workspace?: string }): Step; - human(question: string, options: { to: string }): Promise; - dispatch(flow: string, input: unknown): Promise; - done(reason: string): void; - cloud: CloudHelper; - } - - export interface FlowHandle { - readonly name: string; - } - - export function flow(name: string, body: (f: Ctx) => Promise): FlowHandle; - export function flow( - name: string, - header: FlowHeader, - body: (f: Ctx) => Promise, - ): FlowHandle; -} diff --git a/regressions/tsconfig.json b/regressions/tsconfig.json index 16572cd93..7aaf71e39 100644 --- a/regressions/tsconfig.json +++ b/regressions/tsconfig.json @@ -1,9 +1,13 @@ { - "//": "OPT-IN typecheck for the dormant regression suite. Not referenced by sdk/package.json and not part of `npm test`. Run explicitly: cd sdk && npx tsc -p ../regressions/tsconfig.json", + "//": "OPT-IN typecheck for the dormant regression suite. Not part of package tests. Run explicitly: cd surface && npm run typecheck:regressions", "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "baseUrl": ".", + "paths": { + "@relayflows/surface": ["../surface/src/index.ts"] + }, "lib": ["ES2022"], "strict": true, "noUncheckedIndexedAccess": true, diff --git a/surface/README.md b/surface/README.md new file mode 100644 index 000000000..58913669b --- /dev/null +++ b/surface/README.md @@ -0,0 +1,17 @@ +# `@relayflows/surface` + +The TypeScript authoring contract described by `docs/SURFACE.md`. + +The package defines flows and the context that a journal-backed runtime +injects. It does not construct a context, retain or execute flow bodies, or contact the kernel; +those responsibilities stay behind `@relayflows/sdk` and the journal protocol. + +```ts +import { flow } from "@relayflows/surface"; + +export default flow("release-note", async (f) => { + const diff = await f.run("git diff main"); + await f.llm`Write a one-line release note for ${diff}`; + f.done("success"); +}); +``` diff --git a/surface/bun.lock b/surface/bun.lock new file mode 100644 index 000000000..3298f4ead --- /dev/null +++ b/surface/bun.lock @@ -0,0 +1,198 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@relayflows/surface", + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/surface/package.json b/surface/package.json new file mode 100644 index 000000000..f2b286d89 --- /dev/null +++ b/surface/package.json @@ -0,0 +1,30 @@ +{ + "name": "@relayflows/surface", + "version": "0.1.0", + "description": "TypeScript authoring surface for Relayflows.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc", + "prepare": "npm run build", + "typecheck": "tsc --noEmit", + "typecheck:regressions": "tsc -p ../regressions/tsconfig.json", + "test": "npm run build && tsc -p tsconfig.test.json && vitest run" + }, + "license": "UNLICENSED", + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/surface/src/cloud.ts b/surface/src/cloud.ts new file mode 100644 index 000000000..51f9947e4 --- /dev/null +++ b/surface/src/cloud.ts @@ -0,0 +1,72 @@ +import type { Step } from "./context.js"; + +export interface WorkerSummary { + workerId: string; + status: string; + lastSeenAt: string | null; +} + +export interface Heartbeat extends WorkerSummary {} + +export interface EnrollmentReceipt { + /** Mount path for the token. The credential itself never enters the journal. */ + tokenPath: string; + expiresAt: string; + registerCommand: string; +} + +export interface ScheduleState { + id: string; + lastTriggerStatus: string | null; + lastTriggeredRunId: string | null; + lastTriggerError: string | null; +} + +export interface JournalStep { + id: string; + type: "deterministic" | "llm" | "agent"; + completionReason: string | null; +} + +export interface RunJournal { + runId: string; + steps: JournalStep[]; + completionReason: string | null; +} + +/** AgentWorkforce Cloud helper contract generated from its relayfile adapter. */ +export interface CloudHelper { + workers: { + mintEnrollmentToken(input: { + workspaceId: string; + name: string; + /** Principal resolved at `/principals/`. */ + as: string; + }): Step; + list(input: { workspaceId: string; as: string }): Step<{ + online: WorkerSummary[]; + all: WorkerSummary[]; + }>; + heartbeat(input: { workerId: string; as: string }): Step; + awaitHeartbeat(input: { + workerId: string; + as: string; + within: string; + }): Step; + }; + schedules: { + create(input: { + workspaceId: string; + workflow: string; + cron: string; + name: string; + as: string; + }): Step<{ id: string }>; + fire(input: { scheduleId: string; as: string }): Step<{ accepted: boolean }>; + get(input: { scheduleId: string; as: string }): Step; + remove(input: { scheduleId: string; as: string }): Step<{ deleted: boolean }>; + }; + runs: { + journal(input: { runId: string; as: string }): Step; + }; +} diff --git a/surface/src/context.ts b/surface/src/context.ts new file mode 100644 index 000000000..4d6d47347 --- /dev/null +++ b/surface/src/context.ts @@ -0,0 +1,33 @@ +import type { CloudHelper } from "./cloud.js"; + +/** A journal-backed step result with its postfix verification gate. */ +export interface Step extends PromiseLike { + /** Fail the step with `gate_failed` when the predicate is false. */ + gate(predicate: (value: T) => boolean, because?: string): Step; +} + +export interface AgentResult { + summary: string; + artifacts: string[]; +} + +export interface AgentOptions { + task: string; + workspace?: string; +} + +/** + * The context a journal-backed runtime injects into a flow body. + * + * This package declares the authoring contract only. It cannot construct a + * context or execute a step, so all effects remain behind the journal client. + */ +export interface Ctx { + run(command: string): Step; + llm(strings: TemplateStringsArray, ...values: unknown[]): Step; + agent(name: string, options: AgentOptions): Step; + human(question: string, options: { to: string }): Promise; + dispatch(flow: string, input: unknown): Promise; + done(reason: string): void; + cloud: CloudHelper; +} diff --git a/surface/src/flow.ts b/surface/src/flow.ts new file mode 100644 index 000000000..c908a3db0 --- /dev/null +++ b/surface/src/flow.ts @@ -0,0 +1,40 @@ +import type { Ctx } from "./context.js"; + +/** Optional escalation header; the empty header is the common case. */ +export interface FlowHeader { + identity?: string; + memory?: { script?: boolean; agent?: boolean }; + budget?: string; + tools?: { relayfile?: string[]; mcp?: string[] }; + workspace?: string; +} + +type FlowBody = (f: Ctx) => Promise; + +/** Opaque authored-flow handle. Execution stays behind the journal runtime. */ +export interface FlowHandle { + readonly name: string; +} + +export function flow(name: string, body: FlowBody): FlowHandle; +export function flow( + name: string, + header: FlowHeader, + body: FlowBody, +): FlowHandle; +export function flow( + name: string, + headerOrBody: FlowHeader | FlowBody, + body?: FlowBody, +): FlowHandle { + const flowBody = typeof headerOrBody === "function" ? headerOrBody : body; + + if (name.trim().length === 0) { + throw new TypeError("flow name must not be empty"); + } + if (flowBody === undefined) { + throw new TypeError(`flow "${name}" requires a body`); + } + + return Object.freeze({ name }); +} diff --git a/surface/src/index.ts b/surface/src/index.ts new file mode 100644 index 000000000..ab72b44ff --- /dev/null +++ b/surface/src/index.ts @@ -0,0 +1,15 @@ +export type { + EnrollmentReceipt, + Heartbeat, + JournalStep, + RunJournal, + ScheduleState, + WorkerSummary, + CloudHelper, +} from "./cloud.js"; +export type { AgentOptions, AgentResult, Ctx, Step } from "./context.js"; +export { + flow, + type FlowHandle, + type FlowHeader, +} from "./flow.js"; diff --git a/surface/tests/flow.test.ts b/surface/tests/flow.test.ts new file mode 100644 index 000000000..f2adbae0c --- /dev/null +++ b/surface/tests/flow.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + flow, + type Ctx, + type FlowHandle, +} from "@relayflows/surface"; + +describe("flow", () => { + it("defines a flow with the empty header as the default", () => { + const body = async (_f: Ctx): Promise => undefined; + const definition = flow("release-note", body); + + expect(definition).toEqual({ name: "release-note" }); + expect(Object.isFrozen(definition)).toBe(true); + }); + + it("accepts an explicit escalation header", () => { + const definition = flow( + "release-note", + { identity: "release-bot" }, + async () => undefined, + ); + + expect(definition).toEqual({ name: "release-note" }); + }); + + it("keeps execution and journal clients outside the surface package", () => { + let bodyRan = false; + const definition = flow("deferred", async () => { + bodyRan = true; + }); + + expectTypeOf(definition).toEqualTypeOf(); + expect(bodyRan).toBe(false); + expect(definition).not.toHaveProperty("run"); + expect(definition).not.toHaveProperty("client"); + expect(definition).not.toHaveProperty("body"); + }); +}); diff --git a/surface/tsconfig.json b/surface/tsconfig.json new file mode 100644 index 000000000..87bdd8a96 --- /dev/null +++ b/surface/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/surface/tsconfig.test.json b/surface/tsconfig.test.json new file mode 100644 index 000000000..9a8f6e8e6 --- /dev/null +++ b/surface/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "noEmit": true, + "rootDir": ".", + "types": ["vitest/globals"] + }, + "include": ["tests/**/*.ts"], + "exclude": [] +} diff --git a/surface/vitest.config.ts b/surface/vitest.config.ts new file mode 100644 index 000000000..19384e80f --- /dev/null +++ b/surface/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); From f9b33e9d5f0e9ce711fea671d30d24661e13de57 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 2 Sep 2026 17:52:16 +0200 Subject: [PATCH 02/10] feat(surface): add runtime bridge and package gate Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- .github/workflows/cloud-runtime-artifact.yml | 11 +- .github/workflows/surface-package.yml | 53 +++++++++ regressions/MANIFEST.json | 4 +- regressions/README.md | 7 +- scripts/surface-package-gate.sh | 109 +++++++++++++++++++ sdk/package-lock.json | 14 +++ sdk/package.json | 1 + sdk/src/authored-flow.ts | 20 ++++ sdk/src/index.ts | 6 + sdk/tests/authored-flow.test.ts | 22 ++++ sdk/tests/fixtures/runtime-bridge.flow.ts | 9 ++ surface/README.md | 21 +++- surface/package.json | 8 +- surface/src/cloud.ts | 2 +- surface/src/context.ts | 7 +- surface/src/flow.ts | 81 +++++++++++++- surface/src/index.ts | 3 +- surface/src/runtime.ts | 7 ++ surface/src/step.ts | 5 + surface/tests/flow.test.ts | 28 ++++- 20 files changed, 396 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/surface-package.yml create mode 100755 scripts/surface-package-gate.sh create mode 100644 sdk/src/authored-flow.ts create mode 100644 sdk/tests/authored-flow.test.ts create mode 100644 sdk/tests/fixtures/runtime-bridge.flow.ts create mode 100644 surface/src/runtime.ts create mode 100644 surface/src/step.ts diff --git a/.github/workflows/cloud-runtime-artifact.yml b/.github/workflows/cloud-runtime-artifact.yml index 0e3dc20e2..3c1ad75f1 100644 --- a/.github/workflows/cloud-runtime-artifact.yml +++ b/.github/workflows/cloud-runtime-artifact.yml @@ -66,8 +66,17 @@ jobs: working-directory: kernel run: cargo test --workspace + - name: Build authoring surface + working-directory: surface + run: | + bun install --frozen-lockfile --ignore-scripts + bun run build + + # --ignore-scripts because the surface is already built above; without it + # npm runs the file: dependency's prepare before its own devDependencies + # exist. The SDK's own build is the next step, so nothing is skipped. - name: Install SDK dependencies - run: npm ci --prefix sdk + run: npm ci --prefix sdk --ignore-scripts - name: Test SDK and type-level authoring contracts working-directory: sdk diff --git a/.github/workflows/surface-package.yml b/.github/workflows/surface-package.yml new file mode 100644 index 000000000..bbad5af6f --- /dev/null +++ b/.github/workflows/surface-package.yml @@ -0,0 +1,53 @@ +name: Relayflow v2 surface package + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/surface-package.yml" + - "scripts/surface-package-gate.sh" + - "surface/**" + - "regressions/**" + - "sdk/package.json" + - "sdk/package-lock.json" + - "sdk/src/authored-flow.ts" + - "sdk/src/index.ts" + - "sdk/tests/authored-flow.test.ts" + - "sdk/tests/fixtures/runtime-bridge.flow.ts" + push: + branches: + - main + paths: + - ".github/workflows/surface-package.yml" + - "scripts/surface-package-gate.sh" + - "surface/**" + - "regressions/**" + - "sdk/package.json" + - "sdk/package-lock.json" + - "sdk/src/authored-flow.ts" + - "sdk/src/index.ts" + - "sdk/tests/authored-flow.test.ts" + - "sdk/tests/fixtures/runtime-bridge.flow.ts" + +permissions: + contents: read + +jobs: + packed-consumer: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + + - name: Test source, regressions, and packed consumers + run: bash scripts/surface-package-gate.sh diff --git a/regressions/MANIFEST.json b/regressions/MANIFEST.json index c4495b548..1b024c48f 100644 --- a/regressions/MANIFEST.json +++ b/regressions/MANIFEST.json @@ -8,9 +8,9 @@ "adoptBy": "software-garden", "adoptWhen": "flows run in cloud and the gates listed per pair have closed; the Garden should then run each red case (expect pass) and each green case (expect fail) as the standing proof the bug is still open, and flip the pair to green-only once the fix lands.", "typecheck": { - "command": "cd surface && npm run typecheck:regressions", + "command": "cd surface && bun run typecheck:regressions", "optIn": true, - "note": "Not part of `npm test`. Imports resolve to the real @relayflows/surface package source; the dormant flows remain non-executable until their required gates close." + "note": "Not part of `bun run test`. Imports resolve to @relayflows/surface source through a local path alias. This is a fast source-contract check, not package-boundary evidence; the repository surface package gate separately consumes the packed tarball. The dormant flows remain non-executable until their required gates close." }, "surfaceGaps": [ { diff --git a/regressions/README.md b/regressions/README.md index 54027e8b0..9379a4452 100644 --- a/regressions/README.md +++ b/regressions/README.md @@ -19,7 +19,10 @@ red proves nothing about the bug it claims to cover. Nothing in this directory is wired into a drive loop, a schedule, or CI. No flow declares an `on()` trigger, none is deployed, and nothing outside `regressions/` references it except one backlog line in `ops/BACKLOG.md`. They are written -against the real `@relayflows/surface` package. They remain dormant because the +against the `@relayflows/surface` package source contract through a local +TypeScript path alias. That fast check is not package-boundary evidence; the +repository's surface package gate separately installs and consumes a packed +tarball. The flows remain dormant because the cloud helpers, placement, principals, and declared failure forms listed below belong to later gates; importing the package does not make those substrates available at runtime. @@ -35,7 +38,7 @@ flows run regressions/.green.flow.ts # expected: FAIL while the bug is Opt-in typecheck (deliberately *not* part of `cd sdk && npm test`): ```sh -cd surface && npm run typecheck:regressions +cd surface && bun run typecheck:regressions ``` `MANIFEST.json` carries the same table in machine-readable form — slug, required diff --git a/scripts/surface-package-gate.sh b/scripts/surface-package-gate.sh new file mode 100755 index 000000000..53f2dc666 --- /dev/null +++ b/scripts/surface-package-gate.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +pack_dir="$(mktemp -d /tmp/relayflows-surface-pack.XXXXXX)" +consumer_dir="$(mktemp -d /tmp/relayflows-surface-consumer.XXXXXX)" +trap 'rm -rf "$pack_dir" "$consumer_dir"' EXIT + +cd "$repo_root/surface" +bun install --frozen-lockfile --ignore-scripts +bun run build +bun run test +bun run typecheck:regressions +bun pm pack --destination "$pack_dir" + +tarball="$(find "$pack_dir" -maxdepth 1 -type f -name '*.tgz' -print -quit)" +if [[ -z "$tarball" ]]; then + echo "surface package gate: bun pm pack produced no tarball" >&2 + exit 1 +fi + +cd "$consumer_dir" +cat > package.json <<'JSON' +{ + "name": "relayflows-surface-packed-consumer", + "private": true, + "type": "module" +} +JSON +bun add "$tarball" + +node --input-type=module - <<'NODE' +import { flow } from '@relayflows/surface'; +import { getFlowDefinition } from '@relayflows/surface/runtime'; + +let completionReason; +const handle = flow( + 'packed-runtime-consumer', + { identity: 'package-gate' }, + async (f) => f.done('success'), +); +const definition = getFlowDefinition(handle); +await definition.body({ done: (reason) => { completionReason = reason; } }); + +if (definition.header.identity !== 'package-gate') { + throw new Error('packed runtime consumer lost the authored header'); +} +if (completionReason !== 'success') { + throw new Error('packed runtime consumer could not invoke the authored body'); +} +console.log(`PACKED_RUNTIME_OK name=${definition.name} completionReason=${completionReason}`); +NODE + +cat > consume.mts <<'TS' +import { + flow, + type AgentOptions, + type AgentResult, + type CloudHelper, + type Ctx, + type FlowHandle, + type FlowHeader, + type Step, +} from '@relayflows/surface'; +import { + getFlowDefinition, + type AuthoredFlowDefinition, +} from '@relayflows/surface/runtime'; + +const body = async (f: Ctx): Promise => { + const ran: Step = f.run('true'); + await ran.gate(Boolean); + const options: AgentOptions = { task: 'review' }; + const agent: Step = f.agent('reviewer', options); + await agent; + f.done('success'); +}; +const header: FlowHeader = { identity: 'packed-type-consumer' }; +const handle: FlowHandle = flow('packed-type-consumer', header, body); +const definition: AuthoredFlowDefinition = getFlowDefinition(handle); +const helper: CloudHelper | undefined = undefined; +void definition; +void helper; +TS + +cat > tsconfig.consumer.json <<'JSON' +{ + "compilerOptions": { + "noEmit": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "strict": true, + "skipLibCheck": false, + "types": [] + }, + "include": ["consume.mts"] +} +JSON + +"$repo_root/surface/node_modules/.bin/tsc" -p tsconfig.consumer.json +echo "PACKED_TYPESCRIPT_OK" + +mkdir -p "$repo_root/sdk/node_modules/@relayflows" +if [[ ! -e "$repo_root/sdk/node_modules/@relayflows/surface" ]]; then + ln -s ../../../surface "$repo_root/sdk/node_modules/@relayflows/surface" +fi +cd "$repo_root" +surface/node_modules/.bin/vitest run sdk/tests/authored-flow.test.ts --root "$repo_root" diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 44ace6f11..e6a952a5f 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@relayflows/surface": "file:../surface", "@types/js-yaml": "^4.0.9", "js-yaml": "^5.4.1", "yaml": "^2.5.1" @@ -22,6 +23,15 @@ "vitest": "^2.1.0" } }, + "../surface": { + "name": "@relayflows/surface", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -437,6 +447,10 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@relayflows/surface": { + "resolved": "../surface", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", diff --git a/sdk/package.json b/sdk/package.json index d905764ce..d11a7e65e 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -31,6 +31,7 @@ "license": "UNLICENSED", "private": true, "dependencies": { + "@relayflows/surface": "file:../surface", "@types/js-yaml": "^4.0.9", "js-yaml": "^5.4.1", "yaml": "^2.5.1" diff --git a/sdk/src/authored-flow.ts b/sdk/src/authored-flow.ts new file mode 100644 index 000000000..b0e2d9eda --- /dev/null +++ b/sdk/src/authored-flow.ts @@ -0,0 +1,20 @@ +import { + getFlowDefinition, + type AuthoredFlowDefinition, + type FlowHandle, +} from '@relayflows/surface/runtime'; + +/** + * Recover the immutable program retained by an authored flow handle. + * + * Importing the module evaluates author code only far enough to define the + * flow. A runner must inject a journal-backed context before invoking `body`; + * this bridge never constructs a context or performs an effect itself. + */ +export function getAuthoredFlowDefinition( + handle: FlowHandle, +): AuthoredFlowDefinition { + return getFlowDefinition(handle); +} + +export type { AuthoredFlowDefinition, FlowHandle }; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index a684f80bc..099351474 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -51,6 +51,12 @@ export { } from './compile.js'; export { validateSpec, type ValidationResult } from './validate.js'; +export { + getAuthoredFlowDefinition, + type AuthoredFlowDefinition, + type FlowHandle, +} from './authored-flow.js'; + export { preflight, type CliResolution, diff --git a/sdk/tests/authored-flow.test.ts b/sdk/tests/authored-flow.test.ts new file mode 100644 index 000000000..cf3f5cb0e --- /dev/null +++ b/sdk/tests/authored-flow.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Ctx } from '@relayflows/surface'; +import { getAuthoredFlowDefinition } from '../src/authored-flow.js'; + +describe('authored flow runtime bridge', () => { + it('recovers and invokes a black-box .flow.ts body with an injected context', async () => { + const authoredModule = await import('./fixtures/runtime-bridge.flow.js'); + const definition = getAuthoredFlowDefinition(authoredModule.default); + + expect(definition.name).toBe('runtime-bridge-fixture'); + expect(definition.header).toEqual({ + identity: 'fixture-agent', + tools: { mcp: ['fixture-tool'] }, + }); + expect(Object.isFrozen(definition)).toBe(true); + + const done = vi.fn(); + await definition.body({ done } as unknown as Ctx); + expect(done).toHaveBeenCalledOnce(); + expect(done).toHaveBeenCalledWith('success'); + }); +}); diff --git a/sdk/tests/fixtures/runtime-bridge.flow.ts b/sdk/tests/fixtures/runtime-bridge.flow.ts new file mode 100644 index 000000000..631551720 --- /dev/null +++ b/sdk/tests/fixtures/runtime-bridge.flow.ts @@ -0,0 +1,9 @@ +import { flow } from '@relayflows/surface'; + +export default flow( + 'runtime-bridge-fixture', + { identity: 'fixture-agent', tools: { mcp: ['fixture-tool'] } }, + async (f) => { + f.done('success'); + }, +); diff --git a/surface/README.md b/surface/README.md index 58913669b..a3018f92d 100644 --- a/surface/README.md +++ b/surface/README.md @@ -3,8 +3,25 @@ The TypeScript authoring contract described by `docs/SURFACE.md`. The package defines flows and the context that a journal-backed runtime -injects. It does not construct a context, retain or execute flow bodies, or contact the kernel; -those responsibilities stay behind `@relayflows/sdk` and the journal protocol. +injects. A flow handle retains an immutable header and body behind the +`@relayflows/surface/runtime` bridge used by `@relayflows/sdk`; the public +handle remains the frozen `{ name }` authoring value. This package never +constructs a context, executes a body on its own, or contacts the kernel. +Those responsibilities stay behind `@relayflows/sdk` and the journal protocol. + +This is currently an in-repository foundation, not a registry-published or +direct-run surface. Direct `.flow.ts` execution and input remain tracked in +issue #132. Resident trigger handlers (`flow.on(...)`) are gate-2 work and are +not yet part of this package. + +The repository pins Bun through `surface/bun.lock`. From a fresh checkout: + +```sh +cd surface +bun install --frozen-lockfile --ignore-scripts +bun run build +bun run test +``` ```ts import { flow } from "@relayflows/surface"; diff --git a/surface/package.json b/surface/package.json index f2b286d89..6c2a92bd5 100644 --- a/surface/package.json +++ b/surface/package.json @@ -9,6 +9,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/runtime.d.ts", + "import": "./dist/runtime.js" } }, "files": [ @@ -17,10 +21,10 @@ ], "scripts": { "build": "tsc", - "prepare": "npm run build", + "prepare": "bun run build", "typecheck": "tsc --noEmit", "typecheck:regressions": "tsc -p ../regressions/tsconfig.json", - "test": "npm run build && tsc -p tsconfig.test.json && vitest run" + "test": "bun run build && tsc -p tsconfig.test.json && vitest run" }, "license": "UNLICENSED", "devDependencies": { diff --git a/surface/src/cloud.ts b/surface/src/cloud.ts index 51f9947e4..86ff6c52a 100644 --- a/surface/src/cloud.ts +++ b/surface/src/cloud.ts @@ -1,4 +1,4 @@ -import type { Step } from "./context.js"; +import type { Step } from "./step.js"; export interface WorkerSummary { workerId: string; diff --git a/surface/src/context.ts b/surface/src/context.ts index 4d6d47347..f49fd727a 100644 --- a/surface/src/context.ts +++ b/surface/src/context.ts @@ -1,10 +1,5 @@ import type { CloudHelper } from "./cloud.js"; - -/** A journal-backed step result with its postfix verification gate. */ -export interface Step extends PromiseLike { - /** Fail the step with `gate_failed` when the predicate is false. */ - gate(predicate: (value: T) => boolean, because?: string): Step; -} +import type { Step } from "./step.js"; export interface AgentResult { summary: string; diff --git a/surface/src/flow.ts b/surface/src/flow.ts index c908a3db0..560117268 100644 --- a/surface/src/flow.ts +++ b/surface/src/flow.ts @@ -9,13 +9,37 @@ export interface FlowHeader { workspace?: string; } -type FlowBody = (f: Ctx) => Promise; +export type FlowBody = (f: Ctx) => Promise; + +export interface ReadonlyFlowHeader { + readonly identity?: string; + readonly memory?: Readonly<{ script?: boolean; agent?: boolean }>; + readonly budget?: string; + readonly tools?: Readonly<{ + relayfile?: readonly string[]; + mcp?: readonly string[]; + }>; + readonly workspace?: string; +} + +/** Immutable definition retained for the SDK's journal-backed runtime. */ +export interface AuthoredFlowDefinition { + readonly name: string; + readonly header: ReadonlyFlowHeader; + readonly body: FlowBody; +} /** Opaque authored-flow handle. Execution stays behind the journal runtime. */ export interface FlowHandle { readonly name: string; } +const DEFINITION = Symbol.for("@relayflows/surface.authored-definition.v1"); + +type StoredFlowHandle = FlowHandle & { + readonly [DEFINITION]: AuthoredFlowDefinition; +}; + export function flow(name: string, body: FlowBody): FlowHandle; export function flow( name: string, @@ -28,13 +52,64 @@ export function flow( body?: FlowBody, ): FlowHandle { const flowBody = typeof headerOrBody === "function" ? headerOrBody : body; + const header = typeof headerOrBody === "function" ? {} : headerOrBody; if (name.trim().length === 0) { throw new TypeError("flow name must not be empty"); } - if (flowBody === undefined) { + if (typeof flowBody !== "function") { throw new TypeError(`flow "${name}" requires a body`); } - return Object.freeze({ name }); + const definition: AuthoredFlowDefinition = Object.freeze({ + name, + header: freezeHeader(header), + body: flowBody, + }); + const handle = { name } as StoredFlowHandle; + Object.defineProperty(handle, DEFINITION, { + value: definition, + enumerable: false, + configurable: false, + writable: false, + }); + return Object.freeze(handle); +} + +/** + * Runtime bridge used by the SDK after it imports an authored `.flow.ts`. + * The root package deliberately does not re-export this accessor. + */ +export function getFlowDefinition(handle: FlowHandle): AuthoredFlowDefinition { + if ((typeof handle !== "object" && typeof handle !== "function") || handle === null) { + throw new TypeError("expected an @relayflows/surface flow handle"); + } + const definition = (handle as Partial)[DEFINITION]; + if (definition === undefined) { + throw new TypeError("expected an @relayflows/surface flow handle"); + } + return definition; +} + +function freezeHeader(header: FlowHeader): ReadonlyFlowHeader { + const memory = header.memory === undefined + ? undefined + : Object.freeze({ ...header.memory }); + const tools = header.tools === undefined + ? undefined + : Object.freeze({ + ...(header.tools.relayfile === undefined + ? {} + : { relayfile: Object.freeze([...header.tools.relayfile]) }), + ...(header.tools.mcp === undefined + ? {} + : { mcp: Object.freeze([...header.tools.mcp]) }), + }); + return Object.freeze({ + ...(header.identity === undefined ? {} : { identity: header.identity }), + ...(memory === undefined ? {} : { memory }), + ...(header.budget === undefined ? {} : { budget: header.budget }), + ...(tools === undefined ? {} : { tools }), + ...(header.workspace === undefined ? {} : { workspace: header.workspace }), + }); } diff --git a/surface/src/index.ts b/surface/src/index.ts index ab72b44ff..d4c74df07 100644 --- a/surface/src/index.ts +++ b/surface/src/index.ts @@ -7,7 +7,8 @@ export type { WorkerSummary, CloudHelper, } from "./cloud.js"; -export type { AgentOptions, AgentResult, Ctx, Step } from "./context.js"; +export type { AgentOptions, AgentResult, Ctx } from "./context.js"; +export type { Step } from "./step.js"; export { flow, type FlowHandle, diff --git a/surface/src/runtime.ts b/surface/src/runtime.ts new file mode 100644 index 000000000..880ef322c --- /dev/null +++ b/surface/src/runtime.ts @@ -0,0 +1,7 @@ +export { + getFlowDefinition, + type AuthoredFlowDefinition, + type FlowBody, + type FlowHandle, + type ReadonlyFlowHeader, +} from "./flow.js"; diff --git a/surface/src/step.ts b/surface/src/step.ts new file mode 100644 index 000000000..b31a12c4f --- /dev/null +++ b/surface/src/step.ts @@ -0,0 +1,5 @@ +/** A journal-backed step result with its postfix verification gate. */ +export interface Step extends PromiseLike { + /** Fail the step with `gate_failed` when the predicate is false. */ + gate(predicate: (value: T) => boolean, because?: string): Step; +} diff --git a/surface/tests/flow.test.ts b/surface/tests/flow.test.ts index f2adbae0c..5bbbd2ad1 100644 --- a/surface/tests/flow.test.ts +++ b/surface/tests/flow.test.ts @@ -4,6 +4,7 @@ import { type Ctx, type FlowHandle, } from "@relayflows/surface"; +import { getFlowDefinition } from "@relayflows/surface/runtime"; describe("flow", () => { it("defines a flow with the empty header as the default", () => { @@ -15,16 +16,28 @@ describe("flow", () => { }); it("accepts an explicit escalation header", () => { + const header = { + identity: "release-bot", + tools: { mcp: ["github"] }, + }; const definition = flow( "release-note", - { identity: "release-bot" }, + header, async () => undefined, ); expect(definition).toEqual({ name: "release-note" }); + header.identity = "mutated-after-definition"; + header.tools.mcp.push("slack"); + expect(getFlowDefinition(definition).header).toEqual({ + identity: "release-bot", + tools: { mcp: ["github"] }, + }); + expect(Object.isFrozen(getFlowDefinition(definition).header)).toBe(true); + expect(Object.isFrozen(getFlowDefinition(definition).header.tools?.mcp)).toBe(true); }); - it("keeps execution and journal clients outside the surface package", () => { + it("retains the body for an authorized runtime without executing it", async () => { let bodyRan = false; const definition = flow("deferred", async () => { bodyRan = true; @@ -35,5 +48,16 @@ describe("flow", () => { expect(definition).not.toHaveProperty("run"); expect(definition).not.toHaveProperty("client"); expect(definition).not.toHaveProperty("body"); + + const authored = getFlowDefinition(definition); + expect(Object.isFrozen(authored)).toBe(true); + await authored.body({} as Ctx); + expect(bodyRan).toBe(true); + }); + + it("refuses counterfeit handles at the runtime boundary", () => { + expect(() => getFlowDefinition({ name: "counterfeit" })).toThrow( + "expected an @relayflows/surface flow handle", + ); }); }); From dec91533f926aa6769486330c3ca288f982e3f4b Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 2 Sep 2026 18:51:50 +0200 Subject: [PATCH 03/10] feat(surface): lower authored run steps through journal Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- .github/workflows/surface-package.yml | 14 +- docs/SURFACE.md | 4 +- .../cron-succeeded-into-void.green.flow.ts | 2 +- .../cron-succeeded-into-void.red.flow.ts | 2 +- .../cross-account-workspace-404.green.flow.ts | 2 +- .../cross-account-workspace-404.red.flow.ts | 2 +- ...enrollment-token-bearer-auth.green.flow.ts | 2 +- .../enrollment-token-bearer-auth.red.flow.ts | 2 +- ...ast-workspace-key-repair-500.green.flow.ts | 2 +- ...ycast-workspace-key-repair-500.red.flow.ts | 2 +- .../worker-daemon-bun-argv.green.flow.ts | 2 +- .../worker-daemon-bun-argv.red.flow.ts | 2 +- scripts/surface-package-gate.sh | 17 +- sdk/src/authored-flow-executor.ts | 276 ++++++++++++++++++ sdk/src/authored-flow.ts | 4 +- sdk/src/index.ts | 7 + sdk/tests/authored-flow.test.ts | 158 +++++++++- sdk/tests/fixtures/runtime-bridge.flow.ts | 5 +- surface/README.md | 9 +- surface/src/cloud.ts | 8 +- surface/src/completion.ts | 24 ++ surface/src/context.ts | 3 +- surface/src/flow.ts | 27 +- surface/src/index.ts | 6 + surface/src/step.ts | 2 +- surface/tests/flow.test.ts | 40 +++ 26 files changed, 569 insertions(+), 55 deletions(-) create mode 100644 sdk/src/authored-flow-executor.ts create mode 100644 surface/src/completion.ts diff --git a/.github/workflows/surface-package.yml b/.github/workflows/surface-package.yml index bbad5af6f..7aa60c0c5 100644 --- a/.github/workflows/surface-package.yml +++ b/.github/workflows/surface-package.yml @@ -8,12 +8,7 @@ on: - "scripts/surface-package-gate.sh" - "surface/**" - "regressions/**" - - "sdk/package.json" - - "sdk/package-lock.json" - - "sdk/src/authored-flow.ts" - - "sdk/src/index.ts" - - "sdk/tests/authored-flow.test.ts" - - "sdk/tests/fixtures/runtime-bridge.flow.ts" + - "sdk/**" push: branches: - main @@ -22,12 +17,7 @@ on: - "scripts/surface-package-gate.sh" - "surface/**" - "regressions/**" - - "sdk/package.json" - - "sdk/package-lock.json" - - "sdk/src/authored-flow.ts" - - "sdk/src/index.ts" - - "sdk/tests/authored-flow.test.ts" - - "sdk/tests/fixtures/runtime-bridge.flow.ts" + - "sdk/**" permissions: contents: read diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 1343c64b5..db5f15fd6 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -38,7 +38,7 @@ export default flow("chief", { .on(slack.mention("#exec"), async (f, event) => { // gate 2 — trigger = entry condition const intent = await f.llm`Extract the work request, if any: ${event.text}` .gate(isActionable); - if (!intent) return f.done("no_work"); + if (!intent) return f.done("success"); // no work is an outcome; execution succeeded const plan = await f.agent("planner", { task: `Research and plan: ${intent}`, @@ -46,7 +46,7 @@ export default flow("chief", { }); const ok = await f.human(`Ship this?\n${plan.summary}`, { to: "khaliq" }); - if (!ok) return f.done("declined"); + if (!ok) return f.done("canceled"); const pr = await f.dispatch("garden/implement", plan); // gate 3 — child flow await f.slack.reply(event, `Shipped: ${pr.url}`); diff --git a/regressions/cron-succeeded-into-void.green.flow.ts b/regressions/cron-succeeded-into-void.green.flow.ts index 86a5d6c63..834ac9a67 100644 --- a/regressions/cron-succeeded-into-void.green.flow.ts +++ b/regressions/cron-succeeded-into-void.green.flow.ts @@ -98,6 +98,6 @@ export default flow( ); await f.cloud.schedules.remove({ scheduleId: schedule.id, as: "cli-bearer" }); - return f.done("bug_fixed"); + return f.done("success"); }, ); diff --git a/regressions/cron-succeeded-into-void.red.flow.ts b/regressions/cron-succeeded-into-void.red.flow.ts index 5e124df19..132abbd58 100644 --- a/regressions/cron-succeeded-into-void.red.flow.ts +++ b/regressions/cron-succeeded-into-void.red.flow.ts @@ -74,6 +74,6 @@ export default flow( ); await f.cloud.schedules.remove({ scheduleId: schedule.id, as: "cli-bearer" }); - return f.done("bug_reproduced"); + return f.done("success"); }, ); diff --git a/regressions/cross-account-workspace-404.green.flow.ts b/regressions/cross-account-workspace-404.green.flow.ts index 3af90505b..39eaab131 100644 --- a/regressions/cross-account-workspace-404.green.flow.ts +++ b/regressions/cross-account-workspace-404.green.flow.ts @@ -87,6 +87,6 @@ export default flow( "404 and 403 must be distinguishable by the client without guessing", ); - return f.done("bug_fixed"); + return f.done("success"); }, ); diff --git a/regressions/cross-account-workspace-404.red.flow.ts b/regressions/cross-account-workspace-404.red.flow.ts index f9e9cc463..67ade1212 100644 --- a/regressions/cross-account-workspace-404.red.flow.ts +++ b/regressions/cross-account-workspace-404.red.flow.ts @@ -83,6 +83,6 @@ export default flow( "and carries no code either — the client sees two failures it cannot tell apart", ); - return f.done("bug_reproduced"); + return f.done("success"); }, ); diff --git a/regressions/enrollment-token-bearer-auth.green.flow.ts b/regressions/enrollment-token-bearer-auth.green.flow.ts index e9eb77e85..7fba50a85 100644 --- a/regressions/enrollment-token-bearer-auth.green.flow.ts +++ b/regressions/enrollment-token-bearer-auth.green.flow.ts @@ -62,6 +62,6 @@ export default flow( "and the enrolled worker is visible to the account that minted for it", ); - return f.done("bug_fixed"); + return f.done("success"); }, ); diff --git a/regressions/enrollment-token-bearer-auth.red.flow.ts b/regressions/enrollment-token-bearer-auth.red.flow.ts index a6196252c..624468a12 100644 --- a/regressions/enrollment-token-bearer-auth.red.flow.ts +++ b/regressions/enrollment-token-bearer-auth.red.flow.ts @@ -68,6 +68,6 @@ export default flow( "the same principal succeeds with a session cookie — only the bearer is refused", ); - return f.done("bug_reproduced"); + return f.done("success"); }, ); diff --git a/regressions/relaycast-workspace-key-repair-500.green.flow.ts b/regressions/relaycast-workspace-key-repair-500.green.flow.ts index aeba81099..693ef501f 100644 --- a/regressions/relaycast-workspace-key-repair-500.green.flow.ts +++ b/regressions/relaycast-workspace-key-repair-500.green.flow.ts @@ -144,6 +144,6 @@ export default flow( "with its own code — the three conditions remain distinguishable", ); - return f.done("bug_fixed"); + return f.done("success"); }, ); diff --git a/regressions/relaycast-workspace-key-repair-500.red.flow.ts b/regressions/relaycast-workspace-key-repair-500.red.flow.ts index 5853481b2..be2d3ae63 100644 --- a/regressions/relaycast-workspace-key-repair-500.red.flow.ts +++ b/regressions/relaycast-workspace-key-repair-500.red.flow.ts @@ -160,6 +160,6 @@ export default flow( "so every declared failure is typed — and the 500 is the one path that is not", ); - return f.done("bug_reproduced"); + return f.done("success"); }, ); diff --git a/regressions/worker-daemon-bun-argv.green.flow.ts b/regressions/worker-daemon-bun-argv.green.flow.ts index 4315bdce4..084805ac8 100644 --- a/regressions/worker-daemon-bun-argv.green.flow.ts +++ b/regressions/worker-daemon-bun-argv.green.flow.ts @@ -56,6 +56,6 @@ export default flow( ); await f.run(`kill ${pid} 2>/dev/null || true`); - return f.done("bug_fixed"); + return f.done("success"); }, ); diff --git a/regressions/worker-daemon-bun-argv.red.flow.ts b/regressions/worker-daemon-bun-argv.red.flow.ts index 04ea30ff8..9ef13b80a 100644 --- a/regressions/worker-daemon-bun-argv.red.flow.ts +++ b/regressions/worker-daemon-bun-argv.red.flow.ts @@ -66,6 +66,6 @@ export default flow( "and cloud liveness never observes the worker the CLI said it started", ); - return f.done("bug_reproduced"); + return f.done("success"); }, ); diff --git a/scripts/surface-package-gate.sh b/scripts/surface-package-gate.sh index 53f2dc666..813c2d6bc 100755 --- a/scripts/surface-package-gate.sh +++ b/scripts/surface-package-gate.sh @@ -13,6 +13,9 @@ bun run test bun run typecheck:regressions bun pm pack --destination "$pack_dir" +npm ci --prefix "$repo_root/sdk" --ignore-scripts +npm run typecheck --prefix "$repo_root/sdk" + tarball="$(find "$pack_dir" -maxdepth 1 -type f -name '*.tgz' -print -quit)" if [[ -z "$tarball" ]]; then echo "surface package gate: bun pm pack produced no tarball" >&2 @@ -57,9 +60,11 @@ import { type AgentOptions, type AgentResult, type CloudHelper, + type CompletionReason, type Ctx, type FlowHandle, type FlowHeader, + type RunCompletionReason, type Step, } from '@relayflows/surface'; import { @@ -78,9 +83,13 @@ const body = async (f: Ctx): Promise => { const header: FlowHeader = { identity: 'packed-type-consumer' }; const handle: FlowHandle = flow('packed-type-consumer', header, body); const definition: AuthoredFlowDefinition = getFlowDefinition(handle); +const stepReason: CompletionReason = 'verification_failed'; +const runReason: RunCompletionReason = 'step_failed'; const helper: CloudHelper | undefined = undefined; void definition; void helper; +void stepReason; +void runReason; TS cat > tsconfig.consumer.json <<'JSON' @@ -101,9 +110,5 @@ JSON "$repo_root/surface/node_modules/.bin/tsc" -p tsconfig.consumer.json echo "PACKED_TYPESCRIPT_OK" -mkdir -p "$repo_root/sdk/node_modules/@relayflows" -if [[ ! -e "$repo_root/sdk/node_modules/@relayflows/surface" ]]; then - ln -s ../../../surface "$repo_root/sdk/node_modules/@relayflows/surface" -fi -cd "$repo_root" -surface/node_modules/.bin/vitest run sdk/tests/authored-flow.test.ts --root "$repo_root" +cd "$repo_root/sdk" +./node_modules/.bin/vitest run tests/authored-flow.test.ts diff --git a/sdk/src/authored-flow-executor.ts b/sdk/src/authored-flow-executor.ts new file mode 100644 index 000000000..209bada19 --- /dev/null +++ b/sdk/src/authored-flow-executor.ts @@ -0,0 +1,276 @@ +import { + COMPLETION_REASONS, + type AgentResult, + type CloudHelper, + type CompletionReason as SurfaceCompletionReason, + type Ctx, + type RunCompletionReason as SurfaceRunCompletionReason, + type Step, +} from '@relayflows/surface'; +import type { FlowHandle } from '@relayflows/surface/runtime'; +import { compileSpec, toKernelSpec } from './compile.js'; +import { getAuthoredFlowDefinition } from './authored-flow.js'; +import { JournalClient } from './journal-client.js'; +import type { + CompletionReason as ProtocolCompletionReason, + RunCompletionReason as ProtocolRunCompletionReason, + RunOutcome, +} from './protocol.js'; +import { SPEC_SCHEMA_VERSION } from './spec.js'; + +type Assert = T; +type Equal = [A] extends [B] + ? ([B] extends [A] ? true : false) + : false; +type CompletionVocabularyMatchesProtocol = Assert< + Equal +>; +type RunCompletionVocabularyMatchesProtocol = Assert< + Equal +>; + +export type AuthoredFlowExecutionErrorCode = + | 'duplicate_completion' + | 'journal_protocol_violation' + | 'missing_completion' + | 'step_failed' + | 'unsupported_completion' + | 'unsupported_gate' + | 'unsupported_header' + | 'unsupported_verb'; + +export class AuthoredFlowExecutionError extends Error { + constructor( + readonly code: AuthoredFlowExecutionErrorCode, + message: string, + readonly completionReason?: ProtocolCompletionReason, + readonly runId?: string, + ) { + super(`${code}: ${message}`); + this.name = 'AuthoredFlowExecutionError'; + } +} + +export interface AuthoredFlowJournalStep { + readonly id: string; + readonly runId: string; + readonly completionReason: ProtocolCompletionReason; +} + +export interface AuthoredFlowExecutionResult { + readonly name: string; + readonly completionReason: ProtocolCompletionReason; + readonly journalSteps: readonly AuthoredFlowJournalStep[]; +} + +/** + * Execute the currently supported authored-flow slice through protocol v0. + * + * The slice is deliberately narrow: an empty-header flow may await plain + * `f.run(...)` steps and must finish with `f.done("success")`. Every run and + * the terminal marker is a compiled deterministic spec submitted through + * `JournalClient`; values are read back from `step.completed` journal entries. + * Unsupported headers, verbs, gates, or completion lowering fail closed. + */ +export async function executeAuthoredFlow( + handle: FlowHandle, + journal: JournalClient, +): Promise { + const definition = getAuthoredFlowDefinition(handle); + const headerFields = Object.keys(definition.header); + if (headerFields.length > 0) { + throw new AuthoredFlowExecutionError( + 'unsupported_header', + `flow "${definition.name}" uses unsupported header fields: ${headerFields.join(', ')}`, + ); + } + + const journalSteps: AuthoredFlowJournalStep[] = []; + let nextStep = 1; + let requestedCompletion: SurfaceCompletionReason | undefined; + + const lowerDeterministic = async ( + id: string, + command: string, + ): Promise => { + const spec = toKernelSpec(compileSpec({ + version: SPEC_SCHEMA_VERSION, + name: `${definition.name}/${id}`, + steps: [{ id, type: 'deterministic', command }], + })); + const outcome = await journal.runStart(spec); + return readSuccessfulOutput(journal, outcome, id, journalSteps); + }; + + const context: Ctx = { + run(command) { + const id = `run-${nextStep++}`; + return new DeferredJournalStep(() => lowerDeterministic(id, command)); + }, + llm() { + return unsupportedStep('llm'); + }, + agent() { + return unsupportedStep('agent'); + }, + async human() { + throw unsupportedVerb('human'); + }, + async dispatch() { + throw unsupportedVerb('dispatch'); + }, + done(reason) { + if (!isSurfaceCompletionReason(reason)) { + throw new AuthoredFlowExecutionError( + 'unsupported_completion', + `unknown completion reason: ${String(reason)}`, + ); + } + if (requestedCompletion !== undefined) { + throw new AuthoredFlowExecutionError( + 'duplicate_completion', + `flow "${definition.name}" called done() more than once`, + ); + } + if (reason !== 'success') { + throw new AuthoredFlowExecutionError( + 'unsupported_completion', + `the initial authored executor cannot lower done("${reason}")`, + reason, + ); + } + requestedCompletion = reason; + }, + cloud: unsupportedCloud(), + }; + + await definition.body(context); + if (requestedCompletion === undefined) { + throw new AuthoredFlowExecutionError( + 'missing_completion', + `flow "${definition.name}" returned without done()`, + ); + } + + await lowerDeterministic(`complete-${nextStep}`, ':'); + return Object.freeze({ + name: definition.name, + completionReason: requestedCompletion, + journalSteps: Object.freeze([...journalSteps]), + }); +} + +class DeferredJournalStep implements Step { + private execution: Promise | undefined; + + constructor(private readonly start: () => Promise) {} + + gate(_predicate: (value: T) => boolean, _because?: string): Step { + throw new AuthoredFlowExecutionError( + 'unsupported_gate', + 'postfix gates are not lowered by the initial authored executor', + ); + } + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + this.execution ??= this.start(); + return this.execution.then(onfulfilled, onrejected); + } +} + +function unsupportedStep(verb: string): Step { + return new DeferredJournalStep(async () => { + throw unsupportedVerb(verb); + }); +} + +function unsupportedVerb(verb: string): AuthoredFlowExecutionError { + return new AuthoredFlowExecutionError( + 'unsupported_verb', + `the initial authored executor does not lower f.${verb}`, + ); +} + +function unsupportedCloud(): CloudHelper { + return new Proxy({}, { + get() { + throw unsupportedVerb('cloud'); + }, + }) as CloudHelper; +} + +async function readSuccessfulOutput( + journal: JournalClient, + outcome: RunOutcome, + stepId: string, + journalSteps: AuthoredFlowJournalStep[], +): Promise { + const entries = (await journal.journalRead(outcome.run_id, 1)).entries; + const completed = entries.find((entry) => isStepCompleted(entry, stepId)); + if (!isStepCompleted(completed, stepId)) { + throw protocolViolation(outcome.run_id, `journal has no step.completed for "${stepId}"`); + } + + const reason = completed.payload.completionReason; + journalSteps.push(Object.freeze({ id: stepId, runId: outcome.run_id, completionReason: reason })); + if (reason !== 'success') { + throw new AuthoredFlowExecutionError( + 'step_failed', + `journal step "${stepId}" completed with ${reason}`, + reason, + outcome.run_id, + ); + } + if (outcome.status !== 'completed' || outcome.completion_reason !== 'success') { + throw protocolViolation( + outcome.run_id, + `successful step entry conflicts with run outcome ${outcome.status}/${String(outcome.completion_reason)}`, + ); + } + + const output = completed.payload.output; + if (!isRecord(output) || typeof output['stdout_tail'] !== 'string') { + throw protocolViolation(outcome.run_id, `step "${stepId}" has no string stdout_tail`); + } + return output['stdout_tail']; +} + +interface StepCompletedEntry { + entry_type: 'step.completed'; + step_id: string; + payload: { + completionReason: ProtocolCompletionReason; + output: unknown; + }; +} + +function isStepCompleted(value: unknown, stepId: string): value is StepCompletedEntry { + if (!isRecord(value) || value['entry_type'] !== 'step.completed' || value['step_id'] !== stepId) { + return false; + } + const payload = value['payload']; + return isRecord(payload) + && isSurfaceCompletionReason(payload['completionReason']) + && 'output' in payload; +} + +function isSurfaceCompletionReason(value: unknown): value is ProtocolCompletionReason { + return typeof value === 'string' + && (COMPLETION_REASONS as readonly string[]).includes(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function protocolViolation(runId: string, detail: string): AuthoredFlowExecutionError { + return new AuthoredFlowExecutionError( + 'journal_protocol_violation', + detail, + undefined, + runId, + ); +} diff --git a/sdk/src/authored-flow.ts b/sdk/src/authored-flow.ts index b0e2d9eda..d94189662 100644 --- a/sdk/src/authored-flow.ts +++ b/sdk/src/authored-flow.ts @@ -8,8 +8,8 @@ import { * Recover the immutable program retained by an authored flow handle. * * Importing the module evaluates author code only far enough to define the - * flow. A runner must inject a journal-backed context before invoking `body`; - * this bridge never constructs a context or performs an effect itself. + * flow. `executeAuthoredFlow` owns the initial journal-backed context; callers + * that only need inspection can recover the definition without executing it. */ export function getAuthoredFlowDefinition( handle: FlowHandle, diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 099351474..79272bb61 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -56,6 +56,13 @@ export { type AuthoredFlowDefinition, type FlowHandle, } from './authored-flow.js'; +export { + executeAuthoredFlow, + AuthoredFlowExecutionError, + type AuthoredFlowExecutionErrorCode, + type AuthoredFlowExecutionResult, + type AuthoredFlowJournalStep, +} from './authored-flow-executor.js'; export { preflight, diff --git a/sdk/tests/authored-flow.test.ts b/sdk/tests/authored-flow.test.ts index cf3f5cb0e..b197ebed0 100644 --- a/sdk/tests/authored-flow.test.ts +++ b/sdk/tests/authored-flow.test.ts @@ -1,22 +1,150 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { Ctx } from '@relayflows/surface'; -import { getAuthoredFlowDefinition } from '../src/authored-flow.js'; +import { rmSync } from 'node:fs'; +import type { Server } from 'node:net'; +import { flow } from '@relayflows/surface'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { JournalClient } from '../src/journal-client.js'; +import { + kernelDialectError, + sendOk, + sendResult, + sockPath, + startLoopback, +} from './journal-client-loopback.js'; -describe('authored flow runtime bridge', () => { - it('recovers and invokes a black-box .flow.ts body with an injected context', async () => { +describe('authored flow journal executor', () => { + let path: string; + let server: Server; + let nextRun = 1; + const startedSpecs: Record[] = []; + const stepByRun = new Map(); + + beforeAll(() => { + path = sockPath(); + server = startLoopback(path, { + hello: (ctx) => sendOk(ctx), + 'run.start': (ctx, params) => { + const error = kernelDialectError(params.spec); + if (error !== null) { + ctx.send({ id: ctx.id, ok: false, error: { code: 'invalid_spec', message: error } }); + return; + } + const spec = params.spec as Record; + const step = (spec['steps'] as Record[])[0]!; + const runId = `authored-run-${nextRun++}`; + startedSpecs.push(spec); + stepByRun.set(runId, { + id: step['id'] as string, + command: step['command'] as string, + }); + const failed = step['command'] === 'false'; + sendResult(ctx, { + run_id: runId, + status: failed ? 'failed' : 'completed', + completion_reason: failed ? 'step_failed' : 'success', + completed_steps: 1, + }); + }, + 'journal.read': (ctx, params) => { + const runId = params.run_id as string; + const step = stepByRun.get(runId)!; + const failed = step.command === 'false'; + sendResult(ctx, { + entries: [{ + entry_type: 'step.completed', + step_id: step.id, + payload: { + completionReason: failed ? 'verification_failed' : 'success', + disposition: 'step_done', + output: failed ? null : { + exit_code: 0, + stdout_tail: step.command === 'printf authored-journal-ok' + ? 'authored-journal-ok' + : '', + stderr_tail: '', + }, + }, + }], + }); + }, + }); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(path, { force: true }); + }); + + it('lowers an imported .flow.ts run and completion through the journal protocol', async () => { const authoredModule = await import('./fixtures/runtime-bridge.flow.js'); - const definition = getAuthoredFlowDefinition(authoredModule.default); + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-test'); + + try { + const result = await executeAuthoredFlow(authoredModule.default, client); - expect(definition.name).toBe('runtime-bridge-fixture'); - expect(definition.header).toEqual({ - identity: 'fixture-agent', - tools: { mcp: ['fixture-tool'] }, + expect(result).toEqual({ + name: 'runtime-bridge-fixture', + completionReason: 'success', + journalSteps: [ + { id: 'run-1', runId: 'authored-run-1', completionReason: 'success' }, + { id: 'complete-2', runId: 'authored-run-2', completionReason: 'success' }, + ], + }); + expect(startedSpecs).toHaveLength(2); + expect(startedSpecs[0]).toMatchObject({ + version: '0.1.0', + name: 'runtime-bridge-fixture/run-1', + steps: [{ + id: 'run-1', + type: 'deterministic', + command: 'printf authored-journal-ok', + depends_on: [], + verification: {}, + }], + }); + expect(startedSpecs[1]).toMatchObject({ + name: 'runtime-bridge-fixture/complete-2', + steps: [{ id: 'complete-2', type: 'deterministic', command: ':' }], + }); + } finally { + client.close(); + } + }); + + it('surfaces the closed journal reason when a lowered run fails', async () => { + const handle = flow('authored-failure', async (f) => { + await f.run('false'); + f.done('success'); }); - expect(Object.isFrozen(definition)).toBe(true); + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-failure-test'); + + try { + await expect(executeAuthoredFlow(handle, client)).rejects.toMatchObject({ + code: 'step_failed', + completionReason: 'verification_failed', + runId: 'authored-run-3', + }); + } finally { + client.close(); + } + }); + + it('refuses unsupported surface features before bypassing the journal', async () => { + const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); + + await expect(executeAuthoredFlow(flow( + 'header-not-lowered', + { identity: 'principal' }, + async (f) => f.done('success'), + ), disconnectedJournal)).rejects.toMatchObject({ code: 'unsupported_header' }); - const done = vi.fn(); - await definition.body({ done } as unknown as Ctx); - expect(done).toHaveBeenCalledOnce(); - expect(done).toHaveBeenCalledWith('success'); + await expect(executeAuthoredFlow(flow('gate-not-lowered', async (f) => { + await f.run('true').gate(Boolean); + f.done('success'); + }), disconnectedJournal)).rejects.toMatchObject({ code: 'unsupported_gate' }); }); }); diff --git a/sdk/tests/fixtures/runtime-bridge.flow.ts b/sdk/tests/fixtures/runtime-bridge.flow.ts index 631551720..fe69ee134 100644 --- a/sdk/tests/fixtures/runtime-bridge.flow.ts +++ b/sdk/tests/fixtures/runtime-bridge.flow.ts @@ -2,8 +2,11 @@ import { flow } from '@relayflows/surface'; export default flow( 'runtime-bridge-fixture', - { identity: 'fixture-agent', tools: { mcp: ['fixture-tool'] } }, async (f) => { + const output = await f.run('printf authored-journal-ok'); + if (output !== 'authored-journal-ok') { + throw new Error(`unexpected journal output: ${output}`); + } f.done('success'); }, ); diff --git a/surface/README.md b/surface/README.md index a3018f92d..f56bda555 100644 --- a/surface/README.md +++ b/surface/README.md @@ -8,6 +8,12 @@ injects. A flow handle retains an immutable header and body behind the handle remains the frozen `{ name }` authoring value. This package never constructs a context, executes a body on its own, or contacts the kernel. Those responsibilities stay behind `@relayflows/sdk` and the journal protocol. +The SDK's initial executor supports the deliberately small executable slice: +an empty header, awaited plain `f.run(...)` calls, and one +`f.done("success")`. It compiles each command and a terminal success marker to +deterministic specs, submits them through the existing journal client, and +reads results from `step.completed`. Other headers, verbs, postfix gates, and +completion lowering refuse rather than running outside the journal. This is currently an in-repository foundation, not a registry-published or direct-run surface. Direct `.flow.ts` execution and input remain tracked in @@ -27,8 +33,7 @@ bun run test import { flow } from "@relayflows/surface"; export default flow("release-note", async (f) => { - const diff = await f.run("git diff main"); - await f.llm`Write a one-line release note for ${diff}`; + await f.run("git diff main"); f.done("success"); }); ``` diff --git a/surface/src/cloud.ts b/surface/src/cloud.ts index 86ff6c52a..38382d9e9 100644 --- a/surface/src/cloud.ts +++ b/surface/src/cloud.ts @@ -1,4 +1,8 @@ import type { Step } from "./step.js"; +import type { + CompletionReason, + RunCompletionReason, +} from "./completion.js"; export interface WorkerSummary { workerId: string; @@ -25,13 +29,13 @@ export interface ScheduleState { export interface JournalStep { id: string; type: "deterministic" | "llm" | "agent"; - completionReason: string | null; + completionReason: CompletionReason | null; } export interface RunJournal { runId: string; steps: JournalStep[]; - completionReason: string | null; + completionReason: RunCompletionReason | null; } /** AgentWorkforce Cloud helper contract generated from its relayfile adapter. */ diff --git a/surface/src/completion.ts b/surface/src/completion.ts new file mode 100644 index 000000000..5b8d55c48 --- /dev/null +++ b/surface/src/completion.ts @@ -0,0 +1,24 @@ +/** The closed step completion vocabulary from the journal protocol. */ +export const COMPLETION_REASONS = [ + "success", + "verification_failed", + "retries_exhausted", + "lease_expired", + "crashed", + "timeout", + "worker_error", + "budget_exceeded", + "canceled", +] as const; + +export type CompletionReason = (typeof COMPLETION_REASONS)[number]; + +/** The closed run completion vocabulary from the journal protocol. */ +export const RUN_COMPLETION_REASONS = [ + "success", + "step_failed", + "canceled", + "budget_exceeded", +] as const; + +export type RunCompletionReason = (typeof RUN_COMPLETION_REASONS)[number]; diff --git a/surface/src/context.ts b/surface/src/context.ts index f49fd727a..e6b0bfc04 100644 --- a/surface/src/context.ts +++ b/surface/src/context.ts @@ -1,4 +1,5 @@ import type { CloudHelper } from "./cloud.js"; +import type { CompletionReason } from "./completion.js"; import type { Step } from "./step.js"; export interface AgentResult { @@ -23,6 +24,6 @@ export interface Ctx { agent(name: string, options: AgentOptions): Step; human(question: string, options: { to: string }): Promise; dispatch(flow: string, input: unknown): Promise; - done(reason: string): void; + done(reason: CompletionReason): void; cloud: CloudHelper; } diff --git a/surface/src/flow.ts b/surface/src/flow.ts index 560117268..d809e87e8 100644 --- a/surface/src/flow.ts +++ b/surface/src/flow.ts @@ -85,12 +85,37 @@ export function getFlowDefinition(handle: FlowHandle): AuthoredFlowDefinition { throw new TypeError("expected an @relayflows/surface flow handle"); } const definition = (handle as Partial)[DEFINITION]; - if (definition === undefined) { + const descriptor = Object.getOwnPropertyDescriptor(handle, DEFINITION); + if ( + !isStoredDefinition(definition, handle.name) + || descriptor === undefined + || descriptor.enumerable + || descriptor.configurable + || descriptor.writable + ) { throw new TypeError("expected an @relayflows/surface flow handle"); } return definition; } +function isStoredDefinition( + value: unknown, + handleName: unknown, +): value is AuthoredFlowDefinition { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const candidate = value as Partial; + return typeof handleName === "string" + && candidate.name === handleName + && typeof candidate.body === "function" + && typeof candidate.header === "object" + && candidate.header !== null + && !Array.isArray(candidate.header) + && Object.isFrozen(candidate.header) + && Object.isFrozen(value); +} + function freezeHeader(header: FlowHeader): ReadonlyFlowHeader { const memory = header.memory === undefined ? undefined diff --git a/surface/src/index.ts b/surface/src/index.ts index d4c74df07..2a3118a53 100644 --- a/surface/src/index.ts +++ b/surface/src/index.ts @@ -8,6 +8,12 @@ export type { CloudHelper, } from "./cloud.js"; export type { AgentOptions, AgentResult, Ctx } from "./context.js"; +export { + COMPLETION_REASONS, + RUN_COMPLETION_REASONS, + type CompletionReason, + type RunCompletionReason, +} from "./completion.js"; export type { Step } from "./step.js"; export { flow, diff --git a/surface/src/step.ts b/surface/src/step.ts index b31a12c4f..715ea8c2b 100644 --- a/surface/src/step.ts +++ b/surface/src/step.ts @@ -1,5 +1,5 @@ /** A journal-backed step result with its postfix verification gate. */ export interface Step extends PromiseLike { - /** Fail the step with `gate_failed` when the predicate is false. */ + /** Fail the step with `verification_failed` when the predicate is false. */ gate(predicate: (value: T) => boolean, because?: string): Step; } diff --git a/surface/tests/flow.test.ts b/surface/tests/flow.test.ts index 5bbbd2ad1..a76da2ed4 100644 --- a/surface/tests/flow.test.ts +++ b/surface/tests/flow.test.ts @@ -59,5 +59,45 @@ describe("flow", () => { expect(() => getFlowDefinition({ name: "counterfeit" })).toThrow( "expected an @relayflows/surface flow handle", ); + + const definitionSymbol = Symbol.for( + "@relayflows/surface.authored-definition.v1", + ); + const forgedValues = [ + "not-a-definition", + Object.freeze({ + name: "counterfeit", + header: Object.freeze({}), + body: "not-callable", + }), + Object.freeze({ + name: "different-name", + header: Object.freeze({}), + body: async () => undefined, + }), + Object.freeze({ + name: "counterfeit", + header: {}, + body: async () => undefined, + }), + { + name: "counterfeit", + header: Object.freeze({}), + body: async () => undefined, + }, + ]; + + for (const forgedDefinition of forgedValues) { + const forged = { name: "counterfeit" }; + Object.defineProperty(forged, definitionSymbol, { + value: forgedDefinition, + enumerable: false, + configurable: false, + writable: false, + }); + expect(() => getFlowDefinition(Object.freeze(forged))).toThrow( + "expected an @relayflows/surface flow handle", + ); + } }); }); From 15de65bb2e7a5067cd78d47ab475da861397359d Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 2 Sep 2026 20:08:43 +0200 Subject: [PATCH 04/10] fix(surface): fail closed at authored boundary Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- scripts/surface-package-gate.sh | 51 ++++++++++ sdk/src/authored-flow-executor.ts | 154 ++++++++++++++++++++++++++---- sdk/src/authored-flow.ts | 5 +- sdk/src/index.ts | 8 -- sdk/tests/authored-flow.test.ts | 81 +++++++++++++++- surface/README.md | 28 +++--- surface/src/context.ts | 4 +- surface/src/flow.ts | 102 +++++++++++++++++++- surface/tests/flow.test.ts | 99 ++++++++++++++++++- 9 files changed, 482 insertions(+), 50 deletions(-) diff --git a/scripts/surface-package-gate.sh b/scripts/surface-package-gate.sh index 813c2d6bc..dd8636424 100755 --- a/scripts/surface-package-gate.sh +++ b/scripts/surface-package-gate.sh @@ -52,6 +52,50 @@ if (completionReason !== 'success') { throw new Error('packed runtime consumer could not invoke the authored body'); } console.log(`PACKED_RUNTIME_OK name=${definition.name} completionReason=${completionReason}`); + +const invalidHeaders = [ + { identitty: 'typo' }, + [], + null, + { memory: null }, + { memory: { typo: true } }, + { tools: null }, + { tools: { typo: [] } }, + { tools: { mcp: 'github' } }, + { tools: { mcp: ['github', 42] } }, +]; +for (const [index, header] of invalidHeaders.entries()) { + try { + flow(`packed-invalid-${index}`, header, async () => undefined); + throw new Error(`packed runtime accepted invalid header ${index}`); + } catch (error) { + if (!(error instanceof TypeError) || !error.message.includes('unsupported_header')) { + throw error; + } + } +} + +const forged = { name: 'packed-forgery' }; +Object.defineProperty(forged, Symbol.for('@relayflows/surface.authored-definition.v1'), { + value: Object.freeze({ + name: 'packed-forgery', + header: Object.freeze({}), + body: async () => undefined, + }), + enumerable: false, + configurable: false, + writable: false, +}); +try { + getFlowDefinition(Object.freeze(forged)); + throw new Error('packed runtime accepted a forged handle'); +} catch (error) { + if (!(error instanceof TypeError) + || error.message !== 'expected an @relayflows/surface flow handle') { + throw error; + } +} +console.log(`PACKED_RUNTIME_REFUSAL_OK invalidHeaders=${invalidHeaders.length} forgedHandle=refused`); NODE cat > consume.mts <<'TS' @@ -85,11 +129,18 @@ const handle: FlowHandle = flow('packed-type-consumer', header, body); const definition: AuthoredFlowDefinition = getFlowDefinition(handle); const stepReason: CompletionReason = 'verification_failed'; const runReason: RunCompletionReason = 'step_failed'; +const finishRun = (f: Ctx, reason: RunCompletionReason): void => f.done(reason); +const refuseStepReason = (f: Ctx, reason: CompletionReason): void => { + // @ts-expect-error step-attempt reasons cannot complete a whole flow. + f.done(reason); +}; const helper: CloudHelper | undefined = undefined; void definition; void helper; void stepReason; void runReason; +void finishRun; +void refuseStepReason; TS cat > tsconfig.consumer.json <<'JSON' diff --git a/sdk/src/authored-flow-executor.ts b/sdk/src/authored-flow-executor.ts index 209bada19..65a5aea66 100644 --- a/sdk/src/authored-flow-executor.ts +++ b/sdk/src/authored-flow-executor.ts @@ -1,5 +1,6 @@ import { COMPLETION_REASONS, + RUN_COMPLETION_REASONS, type AgentResult, type CloudHelper, type CompletionReason as SurfaceCompletionReason, @@ -28,22 +29,31 @@ type CompletionVocabularyMatchesProtocol = Assert< type RunCompletionVocabularyMatchesProtocol = Assert< Equal >; +type DoneCompletionReason = Parameters[0]; +type DoneAcceptsOnlyRunCompletionReasons = Assert< + DoneCompletionReason extends SurfaceRunCompletionReason ? true : false +>; +type EveryRunCompletionReasonIsAcceptedByDone = Assert< + SurfaceRunCompletionReason extends DoneCompletionReason ? true : false +>; export type AuthoredFlowExecutionErrorCode = | 'duplicate_completion' | 'journal_protocol_violation' | 'missing_completion' + | 'operation_after_completion' | 'step_failed' | 'unsupported_completion' | 'unsupported_gate' | 'unsupported_header' + | 'unawaited_step' | 'unsupported_verb'; export class AuthoredFlowExecutionError extends Error { constructor( readonly code: AuthoredFlowExecutionErrorCode, message: string, - readonly completionReason?: ProtocolCompletionReason, + readonly completionReason?: ProtocolCompletionReason | ProtocolRunCompletionReason, readonly runId?: string, ) { super(`${code}: ${message}`); @@ -59,15 +69,24 @@ export interface AuthoredFlowJournalStep { export interface AuthoredFlowExecutionResult { readonly name: string; - readonly completionReason: ProtocolCompletionReason; + readonly completionReason: ProtocolRunCompletionReason; readonly journalSteps: readonly AuthoredFlowJournalStep[]; } +type ExecutionResultUsesRunCompletionReason = Assert< + Equal +>; +type JournalStepUsesStepCompletionReason = Assert< + Equal +>; + /** - * Execute the currently supported authored-flow slice through protocol v0. + * Exercise the internal authored-flow lowering seam through protocol v0. * - * The slice is deliberately narrow: an empty-header flow may await plain - * `f.run(...)` steps and must finish with `f.done("success")`. Every run and + * This is deliberately not exported by the SDK package: without a durable + * authored root, it is not a resumable public runner. The seam is narrow: an + * empty-header flow may await plain `f.run(...)` steps and must finish with + * `f.done("success")`. Every run and * the terminal marker is a compiled deterministic spec submitted through * `JournalClient`; values are read back from `step.completed` journal entries. * Unsupported headers, verbs, gates, or completion lowering fail closed. @@ -86,8 +105,9 @@ export async function executeAuthoredFlow( } const journalSteps: AuthoredFlowJournalStep[] = []; + const authoredSteps: TrackedThenable[] = []; let nextStep = 1; - let requestedCompletion: SurfaceCompletionReason | undefined; + let requestedCompletion: SurfaceRunCompletionReason | undefined; const lowerDeterministic = async ( id: string, @@ -104,23 +124,33 @@ export async function executeAuthoredFlow( const context: Ctx = { run(command) { + assertOperationAllowed('run', definition.name, requestedCompletion); const id = `run-${nextStep++}`; - return new DeferredJournalStep(() => lowerDeterministic(id, command)); + return trackStep( + authoredSteps, + new DeferredJournalStep(id, 'run', () => lowerDeterministic(id, command)), + ); }, llm() { - return unsupportedStep('llm'); + assertOperationAllowed('llm', definition.name, requestedCompletion); + const id = `llm-${nextStep++}`; + return trackStep(authoredSteps, unsupportedStep(id, 'llm')); }, agent() { - return unsupportedStep('agent'); + assertOperationAllowed('agent', definition.name, requestedCompletion); + const id = `agent-${nextStep++}`; + return trackStep(authoredSteps, unsupportedStep(id, 'agent')); }, - async human() { + human() { + assertOperationAllowed('human', definition.name, requestedCompletion); throw unsupportedVerb('human'); }, - async dispatch() { + dispatch() { + assertOperationAllowed('dispatch', definition.name, requestedCompletion); throw unsupportedVerb('dispatch'); }, done(reason) { - if (!isSurfaceCompletionReason(reason)) { + if (!isSurfaceRunCompletionReason(reason)) { throw new AuthoredFlowExecutionError( 'unsupported_completion', `unknown completion reason: ${String(reason)}`, @@ -141,10 +171,13 @@ export async function executeAuthoredFlow( } requestedCompletion = reason; }, - cloud: unsupportedCloud(), + cloud: unsupportedCloud( + () => assertOperationAllowed('cloud', definition.name, requestedCompletion), + ), }; await definition.body(context); + await refuseUnawaitedSteps(definition.name, authoredSteps); if (requestedCompletion === undefined) { throw new AuthoredFlowExecutionError( 'missing_completion', @@ -160,10 +193,24 @@ export async function executeAuthoredFlow( }); } -class DeferredJournalStep implements Step { +interface TrackedThenable { + readonly id: string; + readonly verb: string; + readonly consumed: boolean; + readonly settled: boolean; + waitForSettlement(): Promise; +} + +class DeferredJournalStep implements Step, TrackedThenable { private execution: Promise | undefined; + consumed = false; + settled = false; - constructor(private readonly start: () => Promise) {} + constructor( + readonly id: string, + readonly verb: string, + private readonly start: () => Promise, + ) {} gate(_predicate: (value: T) => boolean, _because?: string): Step { throw new AuthoredFlowExecutionError( @@ -176,17 +223,66 @@ class DeferredJournalStep implements Step { onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): PromiseLike { - this.execution ??= this.start(); + this.consumed = true; + this.execution ??= this.start().then( + (value) => { + this.settled = true; + return value; + }, + (error: unknown) => { + this.settled = true; + throw error; + }, + ); return this.execution.then(onfulfilled, onrejected); } + + async waitForSettlement(): Promise { + if (this.execution === undefined) return; + await this.execution.then(() => undefined, () => undefined); + } } -function unsupportedStep(verb: string): Step { - return new DeferredJournalStep(async () => { +function trackStep( + tracked: TrackedThenable[], + step: DeferredJournalStep, +): DeferredJournalStep { + tracked.push(step); + return step; +} + +function unsupportedStep(id: string, verb: string): DeferredJournalStep { + return new DeferredJournalStep(id, verb, async () => { throw unsupportedVerb(verb); }); } +async function refuseUnawaitedSteps( + flowName: string, + steps: TrackedThenable[], +): Promise { + const unconsumed = steps.filter((step) => !step.consumed); + if (unconsumed.length > 0) { + throw new AuthoredFlowExecutionError( + 'unawaited_step', + `flow "${flowName}" returned with unawaited steps: ${formatSteps(unconsumed)}`, + ); + } + + const unsettled = steps.filter((step) => !step.settled); + if (unsettled.length > 0) { + await Promise.all(unsettled.map((step) => step.waitForSettlement())); + throw new AuthoredFlowExecutionError( + 'unawaited_step', + `flow "${flowName}" returned before steps settled: ${formatSteps(unsettled)}`, + ); + } +} + +function formatSteps(steps: TrackedThenable[]): string { + return steps.map((step) => `${step.id} (f.${step.verb})`).join(', '); +} + function unsupportedVerb(verb: string): AuthoredFlowExecutionError { return new AuthoredFlowExecutionError( 'unsupported_verb', @@ -194,9 +290,24 @@ function unsupportedVerb(verb: string): AuthoredFlowExecutionError { ); } -function unsupportedCloud(): CloudHelper { +function assertOperationAllowed( + verb: string, + flowName: string, + completion: SurfaceRunCompletionReason | undefined, +): void { + if (completion !== undefined) { + throw new AuthoredFlowExecutionError( + 'operation_after_completion', + `flow "${flowName}" called f.${verb} after done()`, + completion, + ); + } +} + +function unsupportedCloud(assertOpen: () => void): CloudHelper { return new Proxy({}, { get() { + assertOpen(); throw unsupportedVerb('cloud'); }, }) as CloudHelper; @@ -262,6 +373,11 @@ function isSurfaceCompletionReason(value: unknown): value is ProtocolCompletionR && (COMPLETION_REASONS as readonly string[]).includes(value); } +function isSurfaceRunCompletionReason(value: unknown): value is ProtocolRunCompletionReason { + return typeof value === 'string' + && (RUN_COMPLETION_REASONS as readonly string[]).includes(value); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/sdk/src/authored-flow.ts b/sdk/src/authored-flow.ts index d94189662..ecc36701e 100644 --- a/sdk/src/authored-flow.ts +++ b/sdk/src/authored-flow.ts @@ -8,8 +8,9 @@ import { * Recover the immutable program retained by an authored flow handle. * * Importing the module evaluates author code only far enough to define the - * flow. `executeAuthoredFlow` owns the initial journal-backed context; callers - * that only need inspection can recover the definition without executing it. + * flow. The SDK does not yet expose a public executor because authored-body + * progress has no durable root journal. Internal lowering tests recover the + * definition here without making that seam a supported runner. */ export function getAuthoredFlowDefinition( handle: FlowHandle, diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 79272bb61..d58691767 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -56,14 +56,6 @@ export { type AuthoredFlowDefinition, type FlowHandle, } from './authored-flow.js'; -export { - executeAuthoredFlow, - AuthoredFlowExecutionError, - type AuthoredFlowExecutionErrorCode, - type AuthoredFlowExecutionResult, - type AuthoredFlowJournalStep, -} from './authored-flow-executor.js'; - export { preflight, type CliResolution, diff --git a/sdk/tests/authored-flow.test.ts b/sdk/tests/authored-flow.test.ts index b197ebed0..80614f2ae 100644 --- a/sdk/tests/authored-flow.test.ts +++ b/sdk/tests/authored-flow.test.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs'; import type { Server } from 'node:net'; -import { flow } from '@relayflows/surface'; +import { flow, type FlowHeader } from '@relayflows/surface'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; import { JournalClient } from '../src/journal-client.js'; @@ -147,4 +147,83 @@ describe('authored flow journal executor', () => { f.done('success'); }), disconnectedJournal)).rejects.toMatchObject({ code: 'unsupported_gate' }); }); + + it('rejects invalid raw headers before the executor can contact the journal', async () => { + const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); + + await expect((async () => executeAuthoredFlow(flow( + 'misspelled-header', + { identitty: 'principal' } as FlowHeader, + async (f) => f.done('success'), + ), disconnectedJournal))()).rejects.toThrow( + 'flow "misspelled-header" header: unknown field "identitty"', + ); + + await expect((async () => executeAuthoredFlow(flow( + 'invalid-nested-header', + { tools: { mcp: ['github'], typo: true } } as unknown as FlowHeader, + async (f) => f.done('success'), + ), disconnectedJournal))()).rejects.toThrow( + 'flow "invalid-nested-header" header.tools: unknown field "typo"', + ); + }); + + it('refuses every unawaited thenable-producing verb before terminal success', async () => { + const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); + const cases = [ + flow('unawaited-run', async (f) => { + f.run('false'); + f.done('success'); + }), + flow('unawaited-llm', async (f) => { + f.llm`must not vanish`; + f.done('success'); + }), + flow('unawaited-agent', async (f) => { + f.agent('worker', { task: 'must not vanish' }); + f.done('success'); + }), + ]; + + for (const handle of cases) { + await expect(executeAuthoredFlow(handle, disconnectedJournal)).rejects.toMatchObject({ + code: 'unawaited_step', + }); + } + }); + + it('refuses unsupported promise verbs synchronously even when their results are ignored', async () => { + const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); + const cases = [ + flow('unawaited-human', async (f) => { + f.human('approve?', { to: 'owner' }); + f.done('success'); + }), + flow('unawaited-dispatch', async (f) => { + f.dispatch('child', {}); + f.done('success'); + }), + flow('unawaited-cloud', async (f) => { + f.cloud.workers.list({ workspaceId: 'workspace', as: 'principal' }); + f.done('success'); + }), + ]; + + for (const handle of cases) { + await expect(executeAuthoredFlow(handle, disconnectedJournal)).rejects.toMatchObject({ + code: 'unsupported_verb', + }); + } + }); + + it('treats done as terminal and rejects later operations before journal contact', async () => { + const disconnectedJournal = new JournalClient('/journal-must-not-be-contacted'); + await expect(executeAuthoredFlow(flow('after-done', async (f) => { + f.done('success'); + await f.run('must-not-run'); + }), disconnectedJournal)).rejects.toMatchObject({ + code: 'operation_after_completion', + completionReason: 'success', + }); + }); }); diff --git a/surface/README.md b/surface/README.md index f56bda555..9790b024c 100644 --- a/surface/README.md +++ b/surface/README.md @@ -2,23 +2,19 @@ The TypeScript authoring contract described by `docs/SURFACE.md`. -The package defines flows and the context that a journal-backed runtime -injects. A flow handle retains an immutable header and body behind the -`@relayflows/surface/runtime` bridge used by `@relayflows/sdk`; the public -handle remains the frozen `{ name }` authoring value. This package never -constructs a context, executes a body on its own, or contacts the kernel. -Those responsibilities stay behind `@relayflows/sdk` and the journal protocol. -The SDK's initial executor supports the deliberately small executable slice: -an empty header, awaited plain `f.run(...)` calls, and one -`f.done("success")`. It compiles each command and a terminal success marker to -deterministic specs, submits them through the existing journal client, and -reads results from `step.completed`. Other headers, verbs, postfix gates, and -completion lowering refuse rather than running outside the journal. +The package defines flows and the context that a future journal-backed runtime +will inject. A flow handle retains an immutable header and body behind the +`@relayflows/surface/runtime` bridge used by in-repository SDK inspection; the +public handle remains the frozen `{ name }` authoring value. This package never +constructs a context, executes a body, or contacts the kernel. The SDK has an +internal test seam proving an awaited plain `f.run(...)` can cross the existing +journal protocol, but it is intentionally not exported as a runner: authored +body progress does not yet have a durable root journal or crash-safe resume. -This is currently an in-repository foundation, not a registry-published or -direct-run surface. Direct `.flow.ts` execution and input remain tracked in -issue #132. Resident trigger handlers (`flow.on(...)`) are gate-2 work and are -not yet part of this package. +This is an unpublished contract foundation, not a shipped executable surface. +Direct `.flow.ts` execution, durable authored-root resume, and input remain +tracked in issue #132. Resident trigger handlers (`flow.on(...)`) are gate-2 +work and are not yet part of this package. The repository pins Bun through `surface/bun.lock`. From a fresh checkout: diff --git a/surface/src/context.ts b/surface/src/context.ts index e6b0bfc04..9377fda2a 100644 --- a/surface/src/context.ts +++ b/surface/src/context.ts @@ -1,5 +1,5 @@ import type { CloudHelper } from "./cloud.js"; -import type { CompletionReason } from "./completion.js"; +import type { RunCompletionReason } from "./completion.js"; import type { Step } from "./step.js"; export interface AgentResult { @@ -24,6 +24,6 @@ export interface Ctx { agent(name: string, options: AgentOptions): Step; human(question: string, options: { to: string }): Promise; dispatch(flow: string, input: unknown): Promise; - done(reason: CompletionReason): void; + done(reason: RunCompletionReason): void; cloud: CloudHelper; } diff --git a/surface/src/flow.ts b/surface/src/flow.ts index d809e87e8..55ad82a5d 100644 --- a/surface/src/flow.ts +++ b/surface/src/flow.ts @@ -34,7 +34,7 @@ export interface FlowHandle { readonly name: string; } -const DEFINITION = Symbol.for("@relayflows/surface.authored-definition.v1"); +const DEFINITION = Symbol("@relayflows/surface.authored-definition.v1"); type StoredFlowHandle = FlowHandle & { readonly [DEFINITION]: AuthoredFlowDefinition; @@ -60,6 +60,7 @@ export function flow( if (typeof flowBody !== "function") { throw new TypeError(`flow "${name}" requires a body`); } + assertFlowHeader(header, name); const definition: AuthoredFlowDefinition = Object.freeze({ name, @@ -138,3 +139,102 @@ function freezeHeader(header: FlowHeader): ReadonlyFlowHeader { ...(header.workspace === undefined ? {} : { workspace: header.workspace }), }); } + +function assertFlowHeader(value: unknown, flowName: string): asserts value is FlowHeader { + const at = `unsupported_header: flow "${flowName}" header`; + assertHeaderObject(value, at); + assertKnownKeys( + value, + ["identity", "memory", "budget", "tools", "workspace"], + at, + ); + assertOptionalString(value, "identity", at); + assertOptionalString(value, "budget", at); + assertOptionalString(value, "workspace", at); + + if (value.memory !== undefined) { + assertHeaderObject(value.memory, `${at}.memory`); + assertKnownKeys( + value.memory, + ["script", "agent"], + `${at}.memory`, + ); + assertOptionalBoolean(value.memory, "script", `${at}.memory`); + assertOptionalBoolean(value.memory, "agent", `${at}.memory`); + } + + if (value.tools !== undefined) { + assertHeaderObject(value.tools, `${at}.tools`); + assertKnownKeys( + value.tools, + ["relayfile", "mcp"], + `${at}.tools`, + ); + assertOptionalStringArray(value.tools, "relayfile", `${at}.tools`); + assertOptionalStringArray(value.tools, "mcp", `${at}.tools`); + } +} + +function assertHeaderObject( + value: unknown, + at: string, +): asserts value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${at}: expected an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${at}: expected a plain object`); + } +} + +function assertKnownKeys( + value: Record, + allowed: readonly string[], + at: string, +): void { + const allowedKeys = new Set(allowed); + for (const key of Reflect.ownKeys(value)) { + if (!allowedKeys.has(key)) { + throw new TypeError(`${at}: unknown field ${JSON.stringify(String(key))}`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new TypeError(`${at}.${String(key)}: expected a data property`); + } + } +} + +function assertOptionalString( + value: Record, + key: string, + at: string, +): void { + if (value[key] !== undefined && typeof value[key] !== "string") { + throw new TypeError(`${at}.${key}: expected a string`); + } +} + +function assertOptionalBoolean( + value: Record, + key: string, + at: string, +): void { + if (value[key] !== undefined && typeof value[key] !== "boolean") { + throw new TypeError(`${at}.${key}: expected a boolean`); + } +} + +function assertOptionalStringArray( + value: Record, + key: string, + at: string, +): void { + const candidate = value[key]; + if ( + candidate !== undefined + && (!Array.isArray(candidate) || candidate.some((item) => typeof item !== "string")) + ) { + throw new TypeError(`${at}.${key}: expected an array of strings`); + } +} diff --git a/surface/tests/flow.test.ts b/surface/tests/flow.test.ts index a76da2ed4..552ffe4fd 100644 --- a/surface/tests/flow.test.ts +++ b/surface/tests/flow.test.ts @@ -1,8 +1,11 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { flow, + type CompletionReason, type Ctx, type FlowHandle, + type FlowHeader, + type RunCompletionReason, } from "@relayflows/surface"; import { getFlowDefinition } from "@relayflows/surface/runtime"; @@ -37,6 +40,85 @@ describe("flow", () => { expect(Object.isFrozen(getFlowDefinition(definition).header.tools?.mcp)).toBe(true); }); + it("validates raw header keys and nested values before cloning", () => { + const invalidHeaders: { value: unknown; message: string }[] = [ + { + value: { identitty: "release-bot" }, + message: 'header: unknown field "identitty"', + }, + { + value: { identity: 42 }, + message: "header.identity: expected a string", + }, + { + value: { memory: { typo: true } }, + message: 'header.memory: unknown field "typo"', + }, + { + value: { memory: { script: "yes" } }, + message: "header.memory.script: expected a boolean", + }, + { + value: { tools: { typo: [] } }, + message: 'header.tools: unknown field "typo"', + }, + { + value: { tools: { mcp: ["github", 42] } }, + message: "header.tools.mcp: expected an array of strings", + }, + { + value: [], + message: "header: expected an object", + }, + { + value: null, + message: "header: expected an object", + }, + { + value: { memory: null }, + message: "header.memory: expected an object", + }, + { + value: { tools: null }, + message: "header.tools: expected an object", + }, + { + value: { tools: { mcp: "github" } }, + message: "header.tools.mcp: expected an array of strings", + }, + { + value: Object.create({ identity: "inherited" }), + message: "header: expected a plain object", + }, + { + value: Object.defineProperty({}, "identity", { get: () => "hidden" }), + message: "header.identity: expected a data property", + }, + ]; + + for (const invalid of invalidHeaders) { + expect(() => flow( + "invalid-header", + invalid.value as FlowHeader, + async () => undefined, + )).toThrow(invalid.message); + } + }); + + it("uses run reasons for done while keeping step reasons distinct", () => { + expectTypeOf[0]>() + .toEqualTypeOf(); + expectTypeOf() + .not.toEqualTypeOf(); + + const typeGate = (f: Ctx): void => { + f.done("step_failed"); + // @ts-expect-error worker_error is a step reason, not a run reason. + f.done("worker_error"); + }; + void typeGate; + }); + it("retains the body for an authorized runtime without executing it", async () => { let bodyRan = false; const definition = flow("deferred", async () => { @@ -55,7 +137,7 @@ describe("flow", () => { expect(bodyRan).toBe(true); }); - it("refuses counterfeit handles at the runtime boundary", () => { + it("refuses malformed and forged handles at the runtime boundary", () => { expect(() => getFlowDefinition({ name: "counterfeit" })).toThrow( "expected an @relayflows/surface flow handle", ); @@ -99,5 +181,20 @@ describe("flow", () => { "expected an @relayflows/surface flow handle", ); } + + const completeForgery = { name: "counterfeit" }; + Object.defineProperty(completeForgery, definitionSymbol, { + value: Object.freeze({ + name: "counterfeit", + header: Object.freeze({}), + body: async () => undefined, + }), + enumerable: false, + configurable: false, + writable: false, + }); + expect(() => getFlowDefinition(Object.freeze(completeForgery))).toThrow( + "expected an @relayflows/surface flow handle", + ); }); }); From ba62d38103ec59e424cc99fd09ab4f9f27b018c0 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 2 Sep 2026 22:01:01 +0200 Subject: [PATCH 05/10] fix(surface): close authored operation lifecycle Session-Id: 01a062b4-562d-7143-9296-dd34cc65251f Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- sdk/src/authored-flow-error.ts | 29 ++++ sdk/src/authored-flow-executor.ts | 166 +++++++-------------- sdk/src/authored-flow-operation.ts | 230 +++++++++++++++++++++++++++++ sdk/tests/authored-flow.test.ts | 160 ++++++++++++++++++++ surface/src/flow.ts | 28 +--- surface/tests/flow.test.ts | 21 +++ 6 files changed, 499 insertions(+), 135 deletions(-) create mode 100644 sdk/src/authored-flow-error.ts create mode 100644 sdk/src/authored-flow-operation.ts diff --git a/sdk/src/authored-flow-error.ts b/sdk/src/authored-flow-error.ts new file mode 100644 index 000000000..2b3a8e682 --- /dev/null +++ b/sdk/src/authored-flow-error.ts @@ -0,0 +1,29 @@ +import type { + CompletionReason as ProtocolCompletionReason, + RunCompletionReason as ProtocolRunCompletionReason, +} from './protocol.js'; + +export type AuthoredFlowExecutionErrorCode = + | 'duplicate_completion' + | 'journal_protocol_violation' + | 'missing_completion' + | 'operation_after_completion' + | 'operation_callback_failed' + | 'step_failed' + | 'unsupported_completion' + | 'unsupported_gate' + | 'unsupported_header' + | 'unawaited_step' + | 'unsupported_verb'; + +export class AuthoredFlowExecutionError extends Error { + constructor( + readonly code: AuthoredFlowExecutionErrorCode, + message: string, + readonly completionReason?: ProtocolCompletionReason | ProtocolRunCompletionReason, + readonly runId?: string, + ) { + super(`${code}: ${message}`); + this.name = 'AuthoredFlowExecutionError'; + } +} diff --git a/sdk/src/authored-flow-executor.ts b/sdk/src/authored-flow-executor.ts index 65a5aea66..7aaf931c9 100644 --- a/sdk/src/authored-flow-executor.ts +++ b/sdk/src/authored-flow-executor.ts @@ -11,6 +11,15 @@ import { import type { FlowHandle } from '@relayflows/surface/runtime'; import { compileSpec, toKernelSpec } from './compile.js'; import { getAuthoredFlowDefinition } from './authored-flow.js'; +import { + AuthoredFlowExecutionError, + type AuthoredFlowExecutionErrorCode, +} from './authored-flow-error.js'; +import { + AuthoredFlowOperation, + stopAuthoredOperations, + verifyAuthoredOperations, +} from './authored-flow-operation.js'; import { JournalClient } from './journal-client.js'; import type { CompletionReason as ProtocolCompletionReason, @@ -37,29 +46,7 @@ type EveryRunCompletionReasonIsAcceptedByDone = Assert< SurfaceRunCompletionReason extends DoneCompletionReason ? true : false >; -export type AuthoredFlowExecutionErrorCode = - | 'duplicate_completion' - | 'journal_protocol_violation' - | 'missing_completion' - | 'operation_after_completion' - | 'step_failed' - | 'unsupported_completion' - | 'unsupported_gate' - | 'unsupported_header' - | 'unawaited_step' - | 'unsupported_verb'; - -export class AuthoredFlowExecutionError extends Error { - constructor( - readonly code: AuthoredFlowExecutionErrorCode, - message: string, - readonly completionReason?: ProtocolCompletionReason | ProtocolRunCompletionReason, - readonly runId?: string, - ) { - super(`${code}: ${message}`); - this.name = 'AuthoredFlowExecutionError'; - } -} +export { AuthoredFlowExecutionError, type AuthoredFlowExecutionErrorCode }; export interface AuthoredFlowJournalStep { readonly id: string; @@ -105,7 +92,7 @@ export async function executeAuthoredFlow( } const journalSteps: AuthoredFlowJournalStep[] = []; - const authoredSteps: TrackedThenable[] = []; + const authoredSteps: AuthoredFlowOperation[] = []; let nextStep = 1; let requestedCompletion: SurfaceRunCompletionReason | undefined; @@ -126,20 +113,30 @@ export async function executeAuthoredFlow( run(command) { assertOperationAllowed('run', definition.name, requestedCompletion); const id = `run-${nextStep++}`; - return trackStep( - authoredSteps, - new DeferredJournalStep(id, 'run', () => lowerDeterministic(id, command)), - ); + return trackStep(authoredSteps, new AuthoredFlowOperation( + id, + 'run', + () => assertOperationAllowed('run', definition.name, requestedCompletion), + () => lowerDeterministic(id, command), + )); }, llm() { assertOperationAllowed('llm', definition.name, requestedCompletion); const id = `llm-${nextStep++}`; - return trackStep(authoredSteps, unsupportedStep(id, 'llm')); + return trackStep(authoredSteps, unsupportedStep( + id, + 'llm', + () => assertOperationAllowed('llm', definition.name, requestedCompletion), + )); }, agent() { assertOperationAllowed('agent', definition.name, requestedCompletion); const id = `agent-${nextStep++}`; - return trackStep(authoredSteps, unsupportedStep(id, 'agent')); + return trackStep(authoredSteps, unsupportedStep( + id, + 'agent', + () => assertOperationAllowed('agent', definition.name, requestedCompletion), + )); }, human() { assertOperationAllowed('human', definition.name, requestedCompletion); @@ -176,8 +173,19 @@ export async function executeAuthoredFlow( ), }; - await definition.body(context); - await refuseUnawaitedSteps(definition.name, authoredSteps); + let bodyFailed = false; + let bodyFailure: unknown; + try { + await definition.body(context); + } catch (error) { + bodyFailed = true; + bodyFailure = error; + } + if (bodyFailed) { + await stopAuthoredOperations(authoredSteps, bodyFailure); + throw bodyFailure; + } + await verifyAuthoredOperations(definition.name, authoredSteps); if (requestedCompletion === undefined) { throw new AuthoredFlowExecutionError( 'missing_completion', @@ -193,96 +201,24 @@ export async function executeAuthoredFlow( }); } -interface TrackedThenable { - readonly id: string; - readonly verb: string; - readonly consumed: boolean; - readonly settled: boolean; - waitForSettlement(): Promise; -} - -class DeferredJournalStep implements Step, TrackedThenable { - private execution: Promise | undefined; - consumed = false; - settled = false; - - constructor( - readonly id: string, - readonly verb: string, - private readonly start: () => Promise, - ) {} - - gate(_predicate: (value: T) => boolean, _because?: string): Step { - throw new AuthoredFlowExecutionError( - 'unsupported_gate', - 'postfix gates are not lowered by the initial authored executor', - ); - } - - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): PromiseLike { - this.consumed = true; - this.execution ??= this.start().then( - (value) => { - this.settled = true; - return value; - }, - (error: unknown) => { - this.settled = true; - throw error; - }, - ); - return this.execution.then(onfulfilled, onrejected); - } - - async waitForSettlement(): Promise { - if (this.execution === undefined) return; - await this.execution.then(() => undefined, () => undefined); - } -} - function trackStep( - tracked: TrackedThenable[], - step: DeferredJournalStep, -): DeferredJournalStep { - tracked.push(step); - return step; + tracked: AuthoredFlowOperation[], + operation: AuthoredFlowOperation, +): Step { + tracked.push(operation as AuthoredFlowOperation); + return operation.step; } -function unsupportedStep(id: string, verb: string): DeferredJournalStep { - return new DeferredJournalStep(id, verb, async () => { +function unsupportedStep( + id: string, + verb: string, + assertCanStart: () => void, +): AuthoredFlowOperation { + return new AuthoredFlowOperation(id, verb, assertCanStart, async () => { throw unsupportedVerb(verb); }); } -async function refuseUnawaitedSteps( - flowName: string, - steps: TrackedThenable[], -): Promise { - const unconsumed = steps.filter((step) => !step.consumed); - if (unconsumed.length > 0) { - throw new AuthoredFlowExecutionError( - 'unawaited_step', - `flow "${flowName}" returned with unawaited steps: ${formatSteps(unconsumed)}`, - ); - } - - const unsettled = steps.filter((step) => !step.settled); - if (unsettled.length > 0) { - await Promise.all(unsettled.map((step) => step.waitForSettlement())); - throw new AuthoredFlowExecutionError( - 'unawaited_step', - `flow "${flowName}" returned before steps settled: ${formatSteps(unsettled)}`, - ); - } -} - -function formatSteps(steps: TrackedThenable[]): string { - return steps.map((step) => `${step.id} (f.${step.verb})`).join(', '); -} - function unsupportedVerb(verb: string): AuthoredFlowExecutionError { return new AuthoredFlowExecutionError( 'unsupported_verb', diff --git a/sdk/src/authored-flow-operation.ts b/sdk/src/authored-flow-operation.ts new file mode 100644 index 000000000..83e3c5954 --- /dev/null +++ b/sdk/src/authored-flow-operation.ts @@ -0,0 +1,230 @@ +import type { Step } from '@relayflows/surface'; +import { AuthoredFlowExecutionError } from './authored-flow-error.js'; + +type OperationState = 'created' | 'running' | 'fulfilled' | 'rejected'; + +/** A root authored operation whose outcome cannot be hidden by promise handlers. */ +export class AuthoredFlowOperation { + readonly step: Step; + private state: OperationState = 'created'; + private awaited = false; + private manuallyChained = false; + private rootFailureRecorded = false; + private rootFailure: unknown; + private callbackFailureRecorded = false; + private callbackFailure: unknown; + private readonly promise: Promise; + private readonly resolve: (value: T | PromiseLike) => void; + private readonly reject: (reason?: unknown) => void; + + constructor( + readonly id: string, + readonly verb: string, + private readonly assertCanStart: () => void, + private readonly start: () => Promise, + ) { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + this.promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + this.resolve = resolve; + this.reject = reject; + + observeRejection(this.promise, (error) => this.recordRootFailure(error)); + const operation = this; + this.step = Object.freeze({ + gate(): never { + throw new AuthoredFlowExecutionError( + 'unsupported_gate', + 'postfix gates are not lowered by the initial authored executor', + ); + }, + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + if (isAwaitContinuation(onfulfilled, onrejected)) { + operation.awaited = true; + } else { + operation.manuallyChained = true; + } + void operation.begin(); + const derived = nativeThen(operation.promise, onfulfilled, onrejected); + return trackDerivedPromise( + derived, + (error) => operation.recordCallbackFailure(error), + ); + }, + }); + } + + get lifecycle(): OperationState { + return this.state; + } + + get wasManuallyChained(): boolean { + return this.manuallyChained; + } + + get wasAwaited(): boolean { + return this.awaited; + } + + get failure(): { readonly recorded: boolean; readonly value: unknown } { + return { recorded: this.rootFailureRecorded, value: this.rootFailure }; + } + + get derivedFailure(): { readonly recorded: boolean; readonly value: unknown } { + return { recorded: this.callbackFailureRecorded, value: this.callbackFailure }; + } + + cancel(error: unknown): void { + if (this.state !== 'created') return; + this.state = 'rejected'; + this.recordRootFailure(error); + this.reject(error); + } + + async waitForSettlement(): Promise { + await nativeThen(this.promise, () => undefined, () => undefined); + } + + private async begin(): Promise { + if (this.state !== 'created') return; + try { + this.assertCanStart(); + this.state = 'running'; + const value = await this.start(); + this.state = 'fulfilled'; + this.resolve(value); + } catch (error) { + this.state = 'rejected'; + this.recordRootFailure(error); + this.reject(error); + } + } + + private recordRootFailure(error: unknown): void { + if (this.rootFailureRecorded) return; + this.rootFailureRecorded = true; + this.rootFailure = error; + } + + private recordCallbackFailure(error: unknown): void { + if (this.callbackFailureRecorded) return; + this.callbackFailureRecorded = true; + this.callbackFailure = error; + } +} + +export async function verifyAuthoredOperations( + flowName: string, + operations: readonly AuthoredFlowOperation[], +): Promise { + const unawaited = operations.filter((operation) => + !operation.wasAwaited + || operation.wasManuallyChained + || operation.lifecycle === 'created' + || operation.lifecycle === 'running'); + const canceled = new Set( + unawaited.filter((operation) => operation.lifecycle === 'created'), + ); + + if (canceled.size > 0) { + const error = unawaitedError(flowName, unawaited); + for (const operation of operations) operation.cancel(error); + } + + await Promise.all(operations.map((operation) => operation.waitForSettlement())); + + const failed = operations.find((operation) => + operation.failure.recorded && !canceled.has(operation)); + if (failed !== undefined) { + throw failed.failure.value; + } + const callbackFailed = operations.find((operation) => operation.derivedFailure.recorded); + if (callbackFailed !== undefined) { + throw new AuthoredFlowExecutionError( + 'operation_callback_failed', + `flow "${flowName}" derived handler for ${formatOperation(callbackFailed)} rejected: ${describeError(callbackFailed.derivedFailure.value)}`, + ); + } + if (unawaited.length > 0) { + throw unawaitedError(flowName, unawaited); + } +} + +export async function stopAuthoredOperations( + operations: readonly AuthoredFlowOperation[], + reason: unknown, +): Promise { + for (const operation of operations) operation.cancel(reason); + await Promise.all(operations.map((operation) => operation.waitForSettlement())); +} + +function unawaitedError( + flowName: string, + operations: readonly AuthoredFlowOperation[], +): AuthoredFlowExecutionError { + return new AuthoredFlowExecutionError( + 'unawaited_step', + `flow "${flowName}" returned with unawaited steps: ${operations.map(formatOperation).join(', ')}`, + ); +} + +function formatOperation(operation: AuthoredFlowOperation): string { + return `${operation.id} (f.${operation.verb})`; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function observeRejection(promise: Promise, record: (error: unknown) => void): void { + void nativeThen(promise, undefined, (error) => record(error)); +} + +function isAwaitContinuation( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, +): boolean { + // Await assimilation supplies paired built-in resolving functions. A direct + // PromiseLike.then call supplies authored callbacks and is refused later. + return typeof onfulfilled === 'function' + && typeof onrejected === 'function' + && isNativeFunction(onfulfilled) + && isNativeFunction(onrejected); +} + +function isNativeFunction(value: (...args: never[]) => unknown): boolean { + return Function.prototype.toString.call(value) === 'function () { [native code] }'; +} + +function nativeThen( + promise: Promise, + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, +): Promise { + return Promise.prototype.then.call(promise, onfulfilled, onrejected) as Promise; +} + +function trackDerivedPromise( + promise: Promise, + record: (error: unknown) => void, +): Promise { + observeRejection(promise, record); + Object.defineProperty(promise, 'then', { + value( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + return trackDerivedPromise( + nativeThen(promise, onfulfilled, onrejected), + record, + ); + }, + }); + return promise; +} diff --git a/sdk/tests/authored-flow.test.ts b/sdk/tests/authored-flow.test.ts index 80614f2ae..8be463a8f 100644 --- a/sdk/tests/authored-flow.test.ts +++ b/sdk/tests/authored-flow.test.ts @@ -226,4 +226,164 @@ describe('authored flow journal executor', () => { completionReason: 'success', }); }); + + it('rechecks terminal state when a precreated lazy step first starts', async () => { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-precreated-after-done-test'); + const startedBefore = startedSpecs.length; + const cases = [ + flow('precreated-run-after-done', async (f) => { + const pending = f.run('printf must-not-run'); + f.done('success'); + await pending; + }), + flow('precreated-llm-after-done', async (f) => { + const pending = f.llm`must not run`; + f.done('success'); + await pending; + }), + flow('precreated-agent-after-done', async (f) => { + const pending = f.agent('worker', { task: 'must not run' }); + f.done('success'); + await pending; + }), + ]; + + try { + for (const handle of cases) { + await expect(executeAuthoredFlow(handle, client)).rejects.toMatchObject({ + code: 'operation_after_completion', + completionReason: 'success', + }); + } + expect(startedSpecs).toHaveLength(startedBefore); + } finally { + client.close(); + } + }); + + it('refuses manually chained work even when it settles before the body returns', async () => { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-manual-chain-test'); + + const cases = [ + { + handle: flow('manual-run-chain', async (f) => { + f.run('printf manually-started').then(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), + code: 'unawaited_step', + }, + { + handle: flow('manual-llm-chain', async (f) => { + f.llm`unsupported`.then(undefined, () => undefined); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), + code: 'unsupported_verb', + }, + { + handle: flow('manual-agent-chain', async (f) => { + f.agent('worker', { task: 'unsupported' }).then(undefined, () => undefined); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), + code: 'unsupported_verb', + }, + ]; + + try { + for (const testCase of cases) { + await expect(executeAuthoredFlow(testCase.handle, client)).rejects.toMatchObject({ + code: testCase.code, + }); + } + } finally { + client.close(); + } + }); + + it('refuses forgotten work even when the body remains open long enough to settle it', async () => { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-forgotten-settled-test'); + const startedBefore = startedSpecs.length; + + try { + await expect(executeAuthoredFlow(flow('forgotten-settled', async (f) => { + f.run('printf forgotten-settled'); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), client)).rejects.toMatchObject({ code: 'unawaited_step' }); + expect(startedSpecs).toHaveLength(startedBefore); + } finally { + client.close(); + } + }); + + it('retains root operation failures even when a derived rejection handler consumes them', async () => { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-consumed-rejection-test'); + + const cases = [ + { + handle: flow('consumed-run-rejection', async (f) => { + f.run('false').then(undefined, () => 'consumed'); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), + code: 'step_failed', + }, + { + handle: flow('consumed-llm-rejection', async (f) => { + f.llm`unsupported`.then(undefined, () => 'consumed'); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), + code: 'unsupported_verb', + }, + { + handle: flow('consumed-agent-rejection', async (f) => { + f.agent('worker', { task: 'unsupported' }).then(undefined, () => 'consumed'); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), + code: 'unsupported_verb', + }, + ]; + + try { + for (const testCase of cases) { + await expect(executeAuthoredFlow(testCase.handle, client)).rejects.toMatchObject({ + code: testCase.code, + }); + } + } finally { + client.close(); + } + }); + + it('captures a rejected derived callback instead of leaking unhandled success', async () => { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-derived-rejection-test'); + + try { + await expect(executeAuthoredFlow(flow('derived-rejection', async (f) => { + f.run('printf callback-source') + .then(() => 'first derived value') + .then(() => { + throw new Error('derived callback exploded'); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }), client)).rejects.toMatchObject({ code: 'operation_callback_failed' }); + } finally { + client.close(); + } + }); }); diff --git a/surface/src/flow.ts b/surface/src/flow.ts index 55ad82a5d..9ac08c264 100644 --- a/surface/src/flow.ts +++ b/surface/src/flow.ts @@ -34,11 +34,7 @@ export interface FlowHandle { readonly name: string; } -const DEFINITION = Symbol("@relayflows/surface.authored-definition.v1"); - -type StoredFlowHandle = FlowHandle & { - readonly [DEFINITION]: AuthoredFlowDefinition; -}; +const definitions = new WeakMap(); export function flow(name: string, body: FlowBody): FlowHandle; export function flow( @@ -67,14 +63,9 @@ export function flow( header: freezeHeader(header), body: flowBody, }); - const handle = { name } as StoredFlowHandle; - Object.defineProperty(handle, DEFINITION, { - value: definition, - enumerable: false, - configurable: false, - writable: false, - }); - return Object.freeze(handle); + const handle: FlowHandle = Object.freeze({ name }); + definitions.set(handle, definition); + return handle; } /** @@ -85,14 +76,11 @@ export function getFlowDefinition(handle: FlowHandle): AuthoredFlowDefinition { if ((typeof handle !== "object" && typeof handle !== "function") || handle === null) { throw new TypeError("expected an @relayflows/surface flow handle"); } - const definition = (handle as Partial)[DEFINITION]; - const descriptor = Object.getOwnPropertyDescriptor(handle, DEFINITION); + const definition = definitions.get(handle); if ( - !isStoredDefinition(definition, handle.name) - || descriptor === undefined - || descriptor.enumerable - || descriptor.configurable - || descriptor.writable + definition === undefined + || !isStoredDefinition(definition, definition.name) + || handle.name !== definition.name ) { throw new TypeError("expected an @relayflows/surface flow handle"); } diff --git a/surface/tests/flow.test.ts b/surface/tests/flow.test.ts index 552ffe4fd..c81122abf 100644 --- a/surface/tests/flow.test.ts +++ b/surface/tests/flow.test.ts @@ -196,5 +196,26 @@ describe("flow", () => { expect(() => getFlowDefinition(Object.freeze(completeForgery))).toThrow( "expected an @relayflows/surface flow handle", ); + + const genuine = flow("genuine", async () => undefined); + const reflectedSymbols = Object.getOwnPropertySymbols(genuine); + expect(reflectedSymbols).toEqual([]); + + const reflectedForgery = { name: "reflected-forgery" }; + for (const symbol of reflectedSymbols) { + Object.defineProperty(reflectedForgery, symbol, { + value: Object.freeze({ + name: "reflected-forgery", + header: Object.freeze({}), + body: async () => undefined, + }), + enumerable: false, + configurable: false, + writable: false, + }); + } + expect(() => getFlowDefinition(Object.freeze(reflectedForgery))).toThrow( + "expected an @relayflows/surface flow handle", + ); }); }); From 7eeb90ddb049df8eded1b88db6fedbc1b467f06e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 3 Sep 2026 08:07:16 +0200 Subject: [PATCH 06/10] test(surface): reproduce authored lifecycle escapes Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- sdk/tests/authored-flow-operation.test.ts | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 sdk/tests/authored-flow-operation.test.ts diff --git a/sdk/tests/authored-flow-operation.test.ts b/sdk/tests/authored-flow-operation.test.ts new file mode 100644 index 000000000..70c4a6614 --- /dev/null +++ b/sdk/tests/authored-flow-operation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + AuthoredFlowOperation, + verifyAuthoredOperations, +} from '../src/authored-flow-operation.js'; + +const verbs = ['run', 'llm', 'agent'] as const; + +function operation(verb: string): AuthoredFlowOperation { + return new AuthoredFlowOperation( + `${verb}-1`, + verb, + () => undefined, + async () => `${verb}-result`, + ); +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 10)); +} + +describe.each(verbs)('authored %s operation lifecycle', (verb) => { + it('refuses Promise.withResolvers callbacks as proof of await', async () => { + const deferred = Promise.withResolvers(); + const authored = operation(verb); + authored.step.then(deferred.resolve, deferred.reject); + await settle(); + + await expect(verifyAuthoredOperations(`native-resolver-${verb}`, [authored])) + .rejects.toMatchObject({ code: 'unawaited_step' }); + }); + + it('refuses an ignored Promise.resolve assimilation', async () => { + const authored = operation(verb); + void Promise.resolve(authored.step); + await settle(); + + await expect(verifyAuthoredOperations(`ignored-resolve-${verb}`, [authored])) + .rejects.toMatchObject({ code: 'unawaited_step' }); + }); + + it('refuses an ignored Promise.all assimilation', async () => { + const authored = operation(verb); + void Promise.all([authored.step]); + await settle(); + + await expect(verifyAuthoredOperations(`ignored-all-${verb}`, [authored])) + .rejects.toMatchObject({ code: 'unawaited_step' }); + }); + + it('retains a nested callback rejection outside the root thenable', async () => { + const authored = operation(verb); + await Promise.resolve(authored.step) + .then(() => { throw new Error('nested callback was handled and forgotten'); }) + .catch(() => undefined) + .finally(() => undefined); + + await expect(verifyAuthoredOperations(`nested-callback-${verb}`, [authored])) + .rejects.toMatchObject({ code: 'operation_callback_failed' }); + }); + + it('refuses callback chaining when Function.prototype.toString is forged', async () => { + const authored = operation(verb); + const originalToString = Function.prototype.toString; + Function.prototype.toString = () => 'function () { [native code] }'; + try { + authored.step.then(() => undefined, () => undefined); + await settle(); + } finally { + Function.prototype.toString = originalToString; + } + + await expect(verifyAuthoredOperations(`forged-callback-${verb}`, [authored])) + .rejects.toMatchObject({ code: 'unawaited_step' }); + }); +}); From ad74a50abdf14fb3fe4ed2255b8b8a8280a11646 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 3 Sep 2026 08:31:55 +0200 Subject: [PATCH 07/10] fix(surface): enforce authored lifecycle provenance Session-Id: 01a062b7-2aef-7772-8225-7cb00ee311dd Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- ops/pr134-lifecycle-repair-evidence.md | 88 ++++++ sdk/src/authored-flow-error.ts | 1 + sdk/src/authored-flow-executor.ts | 24 +- sdk/src/authored-flow-lifecycle.ts | 279 ++++++++++++++++++ sdk/src/authored-flow-operation.ts | 87 +++--- .../authored-flow-lifecycle-executor.test.ts | 171 +++++++++++ sdk/tests/authored-flow-operation.test.ts | 123 +++++--- surface/README.md | 6 + 8 files changed, 701 insertions(+), 78 deletions(-) create mode 100644 ops/pr134-lifecycle-repair-evidence.md create mode 100644 sdk/src/authored-flow-lifecycle.ts create mode 100644 sdk/tests/authored-flow-lifecycle-executor.test.ts diff --git a/ops/pr134-lifecycle-repair-evidence.md b/ops/pr134-lifecycle-repair-evidence.md new file mode 100644 index 000000000..f948f1e9e --- /dev/null +++ b/ops/pr134-lifecycle-repair-evidence.md @@ -0,0 +1,88 @@ +# PR #134 authored lifecycle repair evidence + +This evidence was captured in `flows-132-surface-wt` while repairing the three +P1 lifecycle escapes reported against `5f2c0b9a`. Review reports and gate +scripts were not edited. + +## Red-first reproduction + +The exact native-resolver, ignored-assimilation, ignored-combinator, +swallowed-callback, and forged-source cases were first committed as +`b89eef8 test(surface): reproduce authored lifecycle escapes`. + +```text +$ ./node_modules/.bin/vitest run tests/authored-flow-operation.test.ts --reporter=verbose --maxWorkers=1 --minWorkers=1 +Test Files 1 failed (1) +Tests 15 failed (15) +``` + +All five cases failed for each of `run`, `llm`, and `agent` because the old +implementation resolved instead of returning the expected typed rejection. + +## Focused source and type evidence + +```text +$ ./node_modules/.bin/tsc --noEmit && ./node_modules/.bin/vitest run tests/authored-flow-operation.test.ts tests/authored-flow-lifecycle-executor.test.ts tests/authored-flow.test.ts --reporter=verbose --maxWorkers=1 --minWorkers=1 +Test Files 3 passed (3) +Tests 38 passed (38) +``` + +The focused cases prove typed refusal for all three primitive families, no +terminal `complete-*` start through the journal executor, retained root and +nested callback failures, isolation of concurrent lifecycle scopes, and green +direct `await`, `Promise.resolve`, and direct/wrapped `Promise.all` paths. + +```text +$ (cd surface && bun run build && bun run test && bun run typecheck:regressions) +Test Files 1 passed (1) +Tests 6 passed (6) +``` + +## Packed artifact evidence + +`bash scripts/surface-package-gate.sh` completed its surface build, six tests, +regression typecheck, and tarball creation, then the environment's +`npm ci --prefix sdk --ignore-scripts` produced no output for more than 60 +seconds and was interrupted. The artifacts were therefore packed directly +from the already-installed, typechecked worktree and exercised in a clean +temporary consumer: + +```text +7a9d98c9fef8ec34f7562f1efcc72e6e6b3019ed relayflows-sdk-0.1.0.tgz +da4e66ca062eb34e06d7adccb1a2a4c358f42275 relayflows-surface-0.1.0.tgz +PACKED_EXECUTOR_REFUSAL name=packed-native-resolver code=unawaited_step terminal=absent +PACKED_EXECUTOR_REFUSAL name=packed-ignored-resolve code=unawaited_step terminal=absent +PACKED_EXECUTOR_REFUSAL name=packed-ignored-all code=unawaited_step terminal=absent +PACKED_EXECUTOR_REFUSAL name=packed-nested-callback code=operation_callback_failed terminal=absent +PACKED_EXECUTOR_REFUSAL name=packed-forged-source code=unawaited_step terminal=absent +PACKED_EXECUTOR_GREEN direct-await=pass promise-resolve=pass promise-all=pass +PACKED_PRIMITIVE_REFUSAL verb=run native-resolver=refused ignored-resolve=refused ignored-all=refused +PACKED_PRIMITIVE_REFUSAL verb=llm native-resolver=refused ignored-resolve=refused ignored-all=refused +PACKED_PRIMITIVE_REFUSAL verb=agent native-resolver=refused ignored-resolve=refused ignored-all=refused +``` + +## Live daemon evidence + +The built SDK executor was exercised against +`/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd`. + +```text +LIVE_EXECUTOR_REFUSAL name=live-native-resolver code=unawaited_step terminal=absent +LIVE_EXECUTOR_REFUSAL name=live-ignored-resolve code=unawaited_step terminal=absent +LIVE_EXECUTOR_REFUSAL name=live-ignored-all code=unawaited_step terminal=absent +LIVE_EXECUTOR_REFUSAL name=live-nested-callback code=operation_callback_failed terminal=absent +LIVE_EXECUTOR_REFUSAL name=live-forged-source code=unawaited_step terminal=absent +LIVE_EXECUTOR_GREEN name=live-supported-awaits completion=success steps=5 +``` + +## Full SDK regression evidence + +```text +$ RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1914866954/debug/relayflowd RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 ./node_modules/.bin/vitest run --reporter=dot --maxWorkers=1 --minWorkers=1 +Test Files 20 passed (20) +Tests 275 passed (275) +Duration 75.27s +``` + +The run reported `SKIPPED_UNACTIONABLE=0`, executed the live Claude analyzer +round trip, and completed the real-daemon crash/resume case. diff --git a/sdk/src/authored-flow-error.ts b/sdk/src/authored-flow-error.ts index 2b3a8e682..540b739c5 100644 --- a/sdk/src/authored-flow-error.ts +++ b/sdk/src/authored-flow-error.ts @@ -13,6 +13,7 @@ export type AuthoredFlowExecutionErrorCode = | 'unsupported_completion' | 'unsupported_gate' | 'unsupported_header' + | 'unsupported_promise_lifecycle' | 'unawaited_step' | 'unsupported_verb'; diff --git a/sdk/src/authored-flow-executor.ts b/sdk/src/authored-flow-executor.ts index 7aaf931c9..c861d7534 100644 --- a/sdk/src/authored-flow-executor.ts +++ b/sdk/src/authored-flow-executor.ts @@ -20,6 +20,7 @@ import { stopAuthoredOperations, verifyAuthoredOperations, } from './authored-flow-operation.js'; +import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js'; import { JournalClient } from './journal-client.js'; import type { CompletionReason as ProtocolCompletionReason, @@ -93,6 +94,7 @@ export async function executeAuthoredFlow( const journalSteps: AuthoredFlowJournalStep[] = []; const authoredSteps: AuthoredFlowOperation[] = []; + const lifecycle = new AuthoredFlowLifecycle(); let nextStep = 1; let requestedCompletion: SurfaceRunCompletionReason | undefined; @@ -118,6 +120,7 @@ export async function executeAuthoredFlow( 'run', () => assertOperationAllowed('run', definition.name, requestedCompletion), () => lowerDeterministic(id, command), + lifecycle, )); }, llm() { @@ -127,6 +130,7 @@ export async function executeAuthoredFlow( id, 'llm', () => assertOperationAllowed('llm', definition.name, requestedCompletion), + lifecycle, )); }, agent() { @@ -136,6 +140,7 @@ export async function executeAuthoredFlow( id, 'agent', () => assertOperationAllowed('agent', definition.name, requestedCompletion), + lifecycle, )); }, human() { @@ -166,6 +171,7 @@ export async function executeAuthoredFlow( reason, ); } + lifecycle.markCompletion(); requestedCompletion = reason; }, cloud: unsupportedCloud( @@ -176,16 +182,25 @@ export async function executeAuthoredFlow( let bodyFailed = false; let bodyFailure: unknown; try { - await definition.body(context); + const bodyPromise = lifecycle.runBody(() => definition.body(context)); + await bodyPromise; } catch (error) { bodyFailed = true; bodyFailure = error; } if (bodyFailed) { - await stopAuthoredOperations(authoredSteps, bodyFailure); + try { + await stopAuthoredOperations(authoredSteps, bodyFailure); + } finally { + lifecycle.close(); + } throw bodyFailure; } - await verifyAuthoredOperations(definition.name, authoredSteps); + try { + await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle); + } finally { + lifecycle.close(); + } if (requestedCompletion === undefined) { throw new AuthoredFlowExecutionError( 'missing_completion', @@ -213,10 +228,11 @@ function unsupportedStep( id: string, verb: string, assertCanStart: () => void, + lifecycle: AuthoredFlowLifecycle, ): AuthoredFlowOperation { return new AuthoredFlowOperation(id, verb, assertCanStart, async () => { throw unsupportedVerb(verb); - }); + }, lifecycle); } function unsupportedVerb(verb: string): AuthoredFlowExecutionError { diff --git a/sdk/src/authored-flow-lifecycle.ts b/sdk/src/authored-flow-lifecycle.ts new file mode 100644 index 000000000..8dac7a9b6 --- /dev/null +++ b/sdk/src/authored-flow-lifecycle.ts @@ -0,0 +1,279 @@ +import { + AsyncLocalStorage, + createHook, + executionAsyncId, + type AsyncHook, +} from 'node:async_hooks'; +import { AuthoredFlowExecutionError } from './authored-flow-error.js'; + +type OperationToken = object; +type ResolverProbe = Set; +interface PromiseAllGroup { + aggregate: number; + members: ReadonlySet; +} +export interface AuthoredOperationInvocation { + readonly asyncId: number; + bound: boolean; +} + +const nativePromiseThen = Promise.prototype.then; +const nativePromiseAll = Promise.all; +const activeLifecycle = new AsyncLocalStorage(); +const stepOwners = new WeakMap(); +let promiseAllObservers = 0; + +const observedPromiseAll = function ( + this: PromiseConstructor, + values: Iterable>, +): Promise[]> { + const members = Array.from(values); + const aggregate = nativePromiseAll.call(this, members) as Promise[]>; + activeLifecycle.getStore()?.registerPromiseAll(members, aggregate); + return aggregate; +}; + +/** + * Runtime proof that a root operation participates in the continuation which + * reaches done(). Promise resolver identity is never inferred from callback + * source: a resolver counts only when invoking it settles the promise job that + * called the operation's then method. + */ +export class AuthoredFlowLifecycle { + private readonly hook: AsyncHook; + private readonly triggers = new Map(); + private readonly resolutionCauses = new Map(); + private readonly promises = new Map>(); + private readonly promiseIds = new WeakMap(); + private readonly settledPromises = new Set(); + private readonly activeResolverProbes: ResolverProbe[] = []; + private readonly invocations = new Map(); + private readonly promiseAllAggregates = new Map>(); + private readonly promiseAllGroups: PromiseAllGroup[] = []; + private readonly callbackFailures = new Map(); + private completionAsyncId: number | undefined; + private closed = false; + + constructor() { + this.hook = createHook({ + init: (asyncId, type, triggerAsyncId, resource) => { + if (type !== 'PROMISE' || typeof resource !== 'object' || resource === null) return; + this.triggers.set(asyncId, triggerAsyncId); + this.promises.set(asyncId, resource as Promise); + this.promiseIds.set(resource, asyncId); + }, + promiseResolve: (asyncId) => { + this.settledPromises.add(asyncId); + const cause = executionAsyncId(); + if (cause !== asyncId) this.resolutionCauses.set(asyncId, cause); + for (const probe of this.activeResolverProbes) probe.add(asyncId); + }, + }); + installPromiseAllObserver(); + try { + this.hook.enable(); + } catch (error) { + uninstallPromiseAllObserver(); + throw error; + } + } + + runBody(body: () => T): T { + return activeLifecycle.run(this, body); + } + + registerStep(step: object, operation: OperationToken): void { + stepOwners.set(step, { lifecycle: this, operation }); + } + + registerPromiseAll(values: readonly unknown[], aggregate: Promise): void { + const aggregateId = this.promiseIds.get(aggregate); + if (aggregateId === undefined) return; + const memberIds = new Set(); + for (const value of values) { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) continue; + const memberId = this.promiseIds.get(value); + if (memberId !== undefined) memberIds.add(memberId); + const owner = stepOwners.get(value); + if (owner?.lifecycle !== this) continue; + let operationAggregates = this.promiseAllAggregates.get(owner.operation); + if (operationAggregates === undefined) { + operationAggregates = new Set(); + this.promiseAllAggregates.set(owner.operation, operationAggregates); + } + operationAggregates.add(aggregateId); + } + this.promiseAllGroups.push({ aggregate: aggregateId, members: memberIds }); + } + + registerInvocation( + operation: OperationToken, + asyncId: number, + ): AuthoredOperationInvocation { + const invocation = { asyncId, bound: false }; + const operationInvocations = this.invocations.get(operation); + if (operationInvocations === undefined) this.invocations.set(operation, [invocation]); + else operationInvocations.push(invocation); + return invocation; + } + + markCompletion(): void { + if (activeLifecycle.getStore() !== this) { + throw new AuthoredFlowExecutionError( + 'unsupported_promise_lifecycle', + 'done() escaped its authored flow lifecycle scope', + ); + } + this.completionAsyncId = executionAsyncId(); + } + + invokeResolver( + invocation: AuthoredOperationInvocation, + callback: () => T, + ): T { + const probe: ResolverProbe = new Set(); + this.activeResolverProbes.push(probe); + try { + return callback(); + } finally { + this.activeResolverProbes.pop(); + if (invocation.asyncId > 0 && probe.has(invocation.asyncId)) { + invocation.bound = true; + } + } + } + + hasBoundConsumer(operation: OperationToken): boolean { + return this.invocations.get(operation)?.some((invocation) => invocation.bound) ?? false; + } + + isHandled(operation: OperationToken): boolean { + const completion = this.completionAsyncId; + const operationInvocations = this.invocations.get(operation); + if (completion === undefined || operationInvocations === undefined) return false; + const aggregates = this.aggregatesFor(operation); + return operationInvocations.length > 0 && operationInvocations.every((invocation) => + invocation.bound && ( + this.dependsOn(completion, invocation.asyncId) + || [...aggregates].some((aggregate) => this.dependsOn(completion, aggregate)) + )); + } + + async observeCallbackFailures(operations: readonly OperationToken[]): Promise { + const observations: Promise[] = []; + const snapshot = [...this.promises.entries()]; + for (const operation of operations) { + const roots = new Set( + (this.invocations.get(operation) ?? []) + .filter((invocation) => invocation.bound) + .map((invocation) => invocation.asyncId), + ); + for (const aggregate of this.aggregatesFor(operation)) roots.add(aggregate); + for (const [asyncId, promise] of snapshot) { + if (!this.settledPromises.has(asyncId) || roots.has(asyncId)) continue; + if (![...roots].some((root) => this.triggerDescendsFrom(asyncId, root))) continue; + observations.push(nativePromiseThen.call( + promise, + () => undefined, + (error: unknown) => { this.recordCallbackFailure(operation, error); }, + )); + } + } + await Promise.all(observations); + } + + callbackFailure(operation: OperationToken): { + readonly recorded: boolean; + readonly value: unknown; + } { + return { + recorded: this.callbackFailures.has(operation), + value: this.callbackFailures.get(operation), + }; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.hook.disable(); + this.promises.clear(); + uninstallPromiseAllObserver(); + } + + private recordCallbackFailure(operation: OperationToken, error: unknown): void { + if (!this.callbackFailures.has(operation)) this.callbackFailures.set(operation, error); + } + + private aggregatesFor(operation: OperationToken): Set { + const aggregates = new Set(this.promiseAllAggregates.get(operation) ?? []); + for (const invocation of this.invocations.get(operation) ?? []) { + for (const group of this.promiseAllGroups) { + if ( + group.members.size > 0 + && [...group.members].some((member) => this.dependsOn(member, invocation.asyncId)) + ) { + aggregates.add(group.aggregate); + } + } + } + return aggregates; + } + + private dependsOn(descendant: number, ancestor: number): boolean { + return this.dependenciesOf(descendant).has(ancestor); + } + + private dependenciesOf(start: number): Set { + const found = new Set(); + const pending = [start]; + while (pending.length > 0) { + const current = pending.pop()!; + if (found.has(current)) continue; + found.add(current); + const trigger = this.triggers.get(current); + const cause = this.resolutionCauses.get(current); + if (trigger !== undefined && trigger !== current) pending.push(trigger); + if (cause !== undefined && cause !== current) pending.push(cause); + } + return found; + } + + private triggerDescendsFrom(descendant: number, ancestor: number): boolean { + let current: number | undefined = descendant; + const seen = new Set(); + while (current !== undefined && !seen.has(current)) { + if (current === ancestor) return true; + seen.add(current); + current = this.triggers.get(current); + } + return false; + } +} + +function installPromiseAllObserver(): void { + if (promiseAllObservers === 0) { + if (Promise.all !== nativePromiseAll) { + throw new AuthoredFlowExecutionError( + 'unsupported_promise_lifecycle', + 'authored flow execution requires the intrinsic Promise.all', + ); + } + Promise.all = observedPromiseAll as PromiseConstructor['all']; + } else if (Promise.all !== observedPromiseAll) { + throw new AuthoredFlowExecutionError( + 'unsupported_promise_lifecycle', + 'the authored flow Promise.all lifecycle contract was replaced', + ); + } + promiseAllObservers += 1; +} + +function uninstallPromiseAllObserver(): void { + promiseAllObservers -= 1; + if (promiseAllObservers > 0) return; + promiseAllObservers = 0; + Promise.all = nativePromiseAll; +} diff --git a/sdk/src/authored-flow-operation.ts b/sdk/src/authored-flow-operation.ts index 83e3c5954..f15b21fed 100644 --- a/sdk/src/authored-flow-operation.ts +++ b/sdk/src/authored-flow-operation.ts @@ -1,14 +1,19 @@ +import { executionAsyncId } from 'node:async_hooks'; import type { Step } from '@relayflows/surface'; import { AuthoredFlowExecutionError } from './authored-flow-error.js'; +import { + AuthoredFlowLifecycle, + type AuthoredOperationInvocation, +} from './authored-flow-lifecycle.js'; type OperationState = 'created' | 'running' | 'fulfilled' | 'rejected'; +const nativePromiseThen = Promise.prototype.then; /** A root authored operation whose outcome cannot be hidden by promise handlers. */ export class AuthoredFlowOperation { readonly step: Step; private state: OperationState = 'created'; - private awaited = false; - private manuallyChained = false; + private thenInvoked = false; private rootFailureRecorded = false; private rootFailure: unknown; private callbackFailureRecorded = false; @@ -22,6 +27,7 @@ export class AuthoredFlowOperation { readonly verb: string, private readonly assertCanStart: () => void, private readonly start: () => Promise, + private readonly scope: AuthoredFlowLifecycle, ) { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -45,19 +51,21 @@ export class AuthoredFlowOperation { onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): Promise { - if (isAwaitContinuation(onfulfilled, onrejected)) { - operation.awaited = true; - } else { - operation.manuallyChained = true; - } + operation.thenInvoked = true; + const invocation = operation.scope.registerInvocation(operation, executionAsyncId()); void operation.begin(); - const derived = nativeThen(operation.promise, onfulfilled, onrejected); + const derived = nativeThen( + operation.promise, + wrapResolver(operation, invocation, onfulfilled), + wrapResolver(operation, invocation, onrejected), + ); return trackDerivedPromise( derived, (error) => operation.recordCallbackFailure(error), ); }, }); + this.scope.registerStep(this.step, this); } get lifecycle(): OperationState { @@ -65,11 +73,11 @@ export class AuthoredFlowOperation { } get wasManuallyChained(): boolean { - return this.manuallyChained; + return this.thenInvoked && !this.scope.hasBoundConsumer(this); } get wasAwaited(): boolean { - return this.awaited; + return this.scope.hasBoundConsumer(this); } get failure(): { readonly recorded: boolean; readonly value: unknown } { @@ -91,6 +99,13 @@ export class AuthoredFlowOperation { await nativeThen(this.promise, () => undefined, () => undefined); } + invokeResolver( + invocation: AuthoredOperationInvocation, + callback: () => TResult, + ): TResult { + return this.scope.invokeResolver(invocation, callback); + } + private async begin(): Promise { if (this.state !== 'created') return; try { @@ -122,33 +137,36 @@ export class AuthoredFlowOperation { export async function verifyAuthoredOperations( flowName: string, operations: readonly AuthoredFlowOperation[], + lifecycle: AuthoredFlowLifecycle, ): Promise { - const unawaited = operations.filter((operation) => - !operation.wasAwaited - || operation.wasManuallyChained - || operation.lifecycle === 'created' - || operation.lifecycle === 'running'); - const canceled = new Set( - unawaited.filter((operation) => operation.lifecycle === 'created'), - ); + const canceled = new Set(operations.filter((operation) => operation.lifecycle === 'created')); if (canceled.size > 0) { - const error = unawaitedError(flowName, unawaited); + const error = unawaitedError(flowName, [...canceled]); for (const operation of operations) operation.cancel(error); } await Promise.all(operations.map((operation) => operation.waitForSettlement())); + await lifecycle.observeCallbackFailures(operations); + const unawaited = operations.filter((operation) => + !lifecycle.isHandled(operation) + || operation.lifecycle === 'created' + || operation.lifecycle === 'running'); const failed = operations.find((operation) => operation.failure.recorded && !canceled.has(operation)); if (failed !== undefined) { throw failed.failure.value; } - const callbackFailed = operations.find((operation) => operation.derivedFailure.recorded); + const callbackFailed = operations.find((operation) => + operation.derivedFailure.recorded || lifecycle.callbackFailure(operation).recorded); if (callbackFailed !== undefined) { + const failure = callbackFailed.derivedFailure.recorded + ? callbackFailed.derivedFailure.value + : lifecycle.callbackFailure(callbackFailed).value; throw new AuthoredFlowExecutionError( 'operation_callback_failed', - `flow "${flowName}" derived handler for ${formatOperation(callbackFailed)} rejected: ${describeError(callbackFailed.derivedFailure.value)}`, + `flow "${flowName}" derived handler for ${formatOperation(callbackFailed)} rejected: ${describeError(failure)}`, ); } if (unawaited.length > 0) { @@ -186,28 +204,21 @@ function observeRejection(promise: Promise, record: (error: unknown) => vo void nativeThen(promise, undefined, (error) => record(error)); } -function isAwaitContinuation( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, -): boolean { - // Await assimilation supplies paired built-in resolving functions. A direct - // PromiseLike.then call supplies authored callbacks and is refused later. - return typeof onfulfilled === 'function' - && typeof onrejected === 'function' - && isNativeFunction(onfulfilled) - && isNativeFunction(onrejected); -} - -function isNativeFunction(value: (...args: never[]) => unknown): boolean { - return Function.prototype.toString.call(value) === 'function () { [native code] }'; -} - function nativeThen( promise: Promise, onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, ): Promise { - return Promise.prototype.then.call(promise, onfulfilled, onrejected) as Promise; + return nativePromiseThen.call(promise, onfulfilled, onrejected) as Promise; +} + +function wrapResolver( + operation: AuthoredFlowOperation, + invocation: AuthoredOperationInvocation, + callback?: ((value: TValue) => TResult | PromiseLike) | null, +): ((value: TValue) => TResult | PromiseLike) | undefined { + if (callback === undefined || callback === null) return undefined; + return (value) => operation.invokeResolver(invocation, () => callback(value)); } function trackDerivedPromise( diff --git a/sdk/tests/authored-flow-lifecycle-executor.test.ts b/sdk/tests/authored-flow-lifecycle-executor.test.ts new file mode 100644 index 000000000..d95aa2af8 --- /dev/null +++ b/sdk/tests/authored-flow-lifecycle-executor.test.ts @@ -0,0 +1,171 @@ +import { rmSync } from 'node:fs'; +import type { Server } from 'node:net'; +import { flow, type FlowHandle } from '@relayflows/surface'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { JournalClient } from '../src/journal-client.js'; +import { + kernelDialectError, + sendOk, + sendResult, + sockPath, + startLoopback, +} from './journal-client-loopback.js'; + +describe('authored flow lifecycle through the journal executor', () => { + let path: string; + let server: Server; + let nextRun = 1; + const startedSpecs: Record[] = []; + const stepByRun = new Map(); + + beforeAll(() => { + path = sockPath(); + server = startLoopback(path, { + hello: (ctx) => sendOk(ctx), + 'run.start': (ctx, params) => { + const error = kernelDialectError(params.spec); + if (error !== null) { + ctx.send({ id: ctx.id, ok: false, error: { code: 'invalid_spec', message: error } }); + return; + } + const spec = params.spec as Record; + const step = (spec['steps'] as Record[])[0]!; + const runId = `lifecycle-run-${nextRun++}`; + const command = step['command'] as string; + startedSpecs.push(spec); + stepByRun.set(runId, { id: step['id'] as string, command }); + sendResult(ctx, { + run_id: runId, + status: command === 'false' ? 'failed' : 'completed', + completion_reason: command === 'false' ? 'step_failed' : 'success', + completed_steps: 1, + }); + }, + 'journal.read': (ctx, params) => { + const step = stepByRun.get(params.run_id as string)!; + const failed = step.command === 'false'; + sendResult(ctx, { + entries: [{ + entry_type: 'step.completed', + step_id: step.id, + payload: { + completionReason: failed ? 'verification_failed' : 'success', + output: failed ? null : { stdout_tail: '', stderr_tail: '', exit_code: 0 }, + }, + }], + }); + }, + }); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(path, { force: true }); + }); + + async function execute(handle: FlowHandle): Promise>> { + const client = new JournalClient(path, { requestTimeoutMs: 2000 }); + await client.connect(); + await client.hello('authored-flow-lifecycle-test'); + try { + return await executeAuthoredFlow(handle, client); + } finally { + client.close(); + } + } + + function expectNoTerminalStart(startedBefore: number): void { + expect(startedSpecs.slice(startedBefore)).not.toContainEqual( + expect.objectContaining({ name: expect.stringContaining('/complete-') }), + ); + } + + it('refuses native resolver callbacks without starting terminal success', async () => { + const startedBefore = startedSpecs.length; + await expect(execute(flow('native-resolver-forgery', async (f) => { + const deferred = Promise.withResolvers(); + f.run('printf native-resolver').then(deferred.resolve, deferred.reject); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }))).rejects.toMatchObject({ code: 'unawaited_step' }); + expectNoTerminalStart(startedBefore); + }); + + it.each(['resolve', 'all'] as const)( + 'refuses an ignored Promise.%s operation without terminal success', + async (combinator) => { + const startedBefore = startedSpecs.length; + await expect(execute(flow(`ignored-${combinator}`, async (f) => { + if (combinator === 'resolve') void Promise.resolve(f.run('printf ignored-resolve')); + else void Promise.all([f.run('printf ignored-all-1'), f.run('printf ignored-all-2')]); + await new Promise((resolve) => setTimeout(resolve, 100)); + f.done('success'); + }))).rejects.toMatchObject({ code: 'unawaited_step' }); + expectNoTerminalStart(startedBefore); + }, + ); + + it('retains a swallowed Promise.resolve callback failure before terminal success', async () => { + const startedBefore = startedSpecs.length; + await expect(execute(flow('nested-callback-failure', async (f) => { + await Promise.resolve(f.run('printf nested-callback')) + .then(() => { throw new Error('nested callback was handled and forgotten'); }) + .catch(() => undefined) + .finally(() => undefined); + f.done('success'); + }))).rejects.toMatchObject({ code: 'operation_callback_failed' }); + expectNoTerminalStart(startedBefore); + }); + + it('retains root failure through a swallowed Promise.resolve callback chain', async () => { + const startedBefore = startedSpecs.length; + await expect(execute(flow('nested-root-failure', async (f) => { + await Promise.resolve(f.run('false')) + .then(() => undefined) + .catch(() => undefined) + .finally(() => undefined); + f.done('success'); + }))).rejects.toMatchObject({ + code: 'step_failed', + completionReason: 'verification_failed', + }); + expectNoTerminalStart(startedBefore); + }); + + it('ignores forged Function.prototype.toString when refusing manual callbacks', async () => { + const startedBefore = startedSpecs.length; + await expect(execute(flow('forged-to-string', async (f) => { + const originalToString = Function.prototype.toString; + Function.prototype.toString = () => 'function () { [native code] }'; + try { + f.run('printf forged-to-string').then(() => undefined, () => undefined); + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + Function.prototype.toString = originalToString; + } + f.done('success'); + }))).rejects.toMatchObject({ code: 'unawaited_step' }); + expectNoTerminalStart(startedBefore); + }); + + it('preserves direct await, Promise.resolve, and Promise.all authoring', async () => { + const result = await execute(flow('supported-awaits', async (f) => { + await f.run('true'); + await Promise.resolve(f.run('true')); + await Promise.all([f.run('true'), f.run('true')]); + await Promise.all([Promise.resolve(f.run('true')), Promise.resolve(f.run('true'))]); + f.done('success'); + })); + expect(result.completionReason).toBe('success'); + expect(result.journalSteps.map((step) => step.id)).toEqual([ + 'run-1', + 'run-2', + 'run-3', + 'run-4', + 'run-5', + 'run-6', + 'complete-7', + ]); + }); +}); diff --git a/sdk/tests/authored-flow-operation.test.ts b/sdk/tests/authored-flow-operation.test.ts index 70c4a6614..d3e0956dc 100644 --- a/sdk/tests/authored-flow-operation.test.ts +++ b/sdk/tests/authored-flow-operation.test.ts @@ -3,15 +3,21 @@ import { AuthoredFlowOperation, verifyAuthoredOperations, } from '../src/authored-flow-operation.js'; +import { AuthoredFlowLifecycle } from '../src/authored-flow-lifecycle.js'; const verbs = ['run', 'llm', 'agent'] as const; -function operation(verb: string): AuthoredFlowOperation { +function operation( + verb: string, + lifecycle: AuthoredFlowLifecycle, + index: number, +): AuthoredFlowOperation { return new AuthoredFlowOperation( - `${verb}-1`, + `${verb}-${index}`, verb, () => undefined, async () => `${verb}-result`, + lifecycle, ); } @@ -19,58 +25,103 @@ async function settle(): Promise { await new Promise((resolve) => setTimeout(resolve, 10)); } +async function executeLifecycle( + name: string, + verb: string, + body: (create: () => AuthoredFlowOperation) => Promise, +): Promise { + const lifecycle = new AuthoredFlowLifecycle(); + const operations: AuthoredFlowOperation[] = []; + const create = (): AuthoredFlowOperation => { + const authored = operation(verb, lifecycle, operations.length + 1); + operations.push(authored); + return authored; + }; + try { + const bodyPromise = lifecycle.runBody(async () => { + await body(create); + lifecycle.markCompletion(); + }); + await bodyPromise; + await verifyAuthoredOperations(name, operations, lifecycle); + } finally { + lifecycle.close(); + } +} + describe.each(verbs)('authored %s operation lifecycle', (verb) => { it('refuses Promise.withResolvers callbacks as proof of await', async () => { - const deferred = Promise.withResolvers(); - const authored = operation(verb); - authored.step.then(deferred.resolve, deferred.reject); - await settle(); - - await expect(verifyAuthoredOperations(`native-resolver-${verb}`, [authored])) + await expect(executeLifecycle(`native-resolver-${verb}`, verb, async (create) => { + const deferred = Promise.withResolvers(); + create().step.then(deferred.resolve, deferred.reject); + await settle(); + })) .rejects.toMatchObject({ code: 'unawaited_step' }); }); it('refuses an ignored Promise.resolve assimilation', async () => { - const authored = operation(verb); - void Promise.resolve(authored.step); - await settle(); - - await expect(verifyAuthoredOperations(`ignored-resolve-${verb}`, [authored])) + await expect(executeLifecycle(`ignored-resolve-${verb}`, verb, async (create) => { + void Promise.resolve(create().step); + await settle(); + })) .rejects.toMatchObject({ code: 'unawaited_step' }); }); it('refuses an ignored Promise.all assimilation', async () => { - const authored = operation(verb); - void Promise.all([authored.step]); - await settle(); - - await expect(verifyAuthoredOperations(`ignored-all-${verb}`, [authored])) + await expect(executeLifecycle(`ignored-all-${verb}`, verb, async (create) => { + void Promise.all([create().step, create().step]); + await settle(); + })) .rejects.toMatchObject({ code: 'unawaited_step' }); }); it('retains a nested callback rejection outside the root thenable', async () => { - const authored = operation(verb); - await Promise.resolve(authored.step) - .then(() => { throw new Error('nested callback was handled and forgotten'); }) - .catch(() => undefined) - .finally(() => undefined); - - await expect(verifyAuthoredOperations(`nested-callback-${verb}`, [authored])) + await expect(executeLifecycle(`nested-callback-${verb}`, verb, async (create) => { + await Promise.resolve(create().step) + .then(() => { throw new Error('nested callback was handled and forgotten'); }) + .catch(() => undefined) + .finally(() => undefined); + })) .rejects.toMatchObject({ code: 'operation_callback_failed' }); }); it('refuses callback chaining when Function.prototype.toString is forged', async () => { - const authored = operation(verb); - const originalToString = Function.prototype.toString; - Function.prototype.toString = () => 'function () { [native code] }'; - try { - authored.step.then(() => undefined, () => undefined); - await settle(); - } finally { - Function.prototype.toString = originalToString; - } - - await expect(verifyAuthoredOperations(`forged-callback-${verb}`, [authored])) + await expect(executeLifecycle(`forged-callback-${verb}`, verb, async (create) => { + const originalToString = Function.prototype.toString; + Function.prototype.toString = () => 'function () { [native code] }'; + try { + create().step.then(() => undefined, () => undefined); + await settle(); + } finally { + Function.prototype.toString = originalToString; + } + })) .rejects.toMatchObject({ code: 'unawaited_step' }); }); + + it('accepts direct await, Promise.resolve, and Promise.all consumers', async () => { + await executeLifecycle(`direct-await-${verb}`, verb, async (create) => { + await create().step; + }); + await executeLifecycle(`resolved-await-${verb}`, verb, async (create) => { + await Promise.resolve(create().step); + }); + await executeLifecycle(`all-await-${verb}`, verb, async (create) => { + await Promise.all([create().step, create().step]); + }); + await executeLifecycle(`wrapped-all-await-${verb}`, verb, async (create) => { + await Promise.all([Promise.resolve(create().step), Promise.resolve(create().step)]); + }); + }); +}); + +it('isolates concurrent Promise.all lifecycle scopes', async () => { + await Promise.all([ + executeLifecycle('concurrent-run-a', 'run', async (create) => { + await Promise.all([create().step, create().step]); + }), + executeLifecycle('concurrent-run-b', 'run', async (create) => { + await Promise.all([create().step, create().step]); + }), + ]); }); diff --git a/surface/README.md b/surface/README.md index 9790b024c..56ab1cc62 100644 --- a/surface/README.md +++ b/surface/README.md @@ -10,6 +10,12 @@ constructs a context, executes a body, or contacts the kernel. The SDK has an internal test seam proving an awaited plain `f.run(...)` can cross the existing journal protocol, but it is intentionally not exported as a runner: authored body progress does not yet have a durable root journal or crash-safe resume. +Within that seam, a root operation must participate in the asynchronous +continuation that reaches `done()`. Direct `await`, awaited `Promise.resolve`, +and awaited `Promise.all` are supported. Ignored operations, manual `.then` +callbacks, ignored combinators, and callback failures that are caught away are +refused before the terminal journal step; callback source text is never treated +as lifecycle proof. This is an unpublished contract foundation, not a shipped executable surface. Direct `.flow.ts` execution, durable authored-root resume, and input remain From f04feb169f6df9658bafb3d71956f8afcb30d297 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 3 Sep 2026 08:51:14 +0200 Subject: [PATCH 08/10] docs(review): record PR 134 structure findings Session-Id: 01a062d6-d0fb-7060-b9a7-57031d858ea9 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- ops/reviews/20260902-2035-pr134-structure.md | 580 +++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 ops/reviews/20260902-2035-pr134-structure.md diff --git a/ops/reviews/20260902-2035-pr134-structure.md b/ops/reviews/20260902-2035-pr134-structure.md new file mode 100644 index 000000000..f111c5c12 --- /dev/null +++ b/ops/reviews/20260902-2035-pr134-structure.md @@ -0,0 +1,580 @@ +# PR #134 fresh exact-head structure / RFC-boundary review + +- Reviewed head: `d830d027843b64da27eca3b2f805ddc9b33ac058` +- Merged-main base: `a0d42ffbdc7fb60b42c0b5bea4f58408249b08a2` +- Lens: raw and nested header refusal, handle opacity, tracked thenables, + terminal completion, completion vocabularies, journal boundary, module seams, + durable-root scope, v1 behavior, and the package gate +- Assessment only; no product code, gate, workflow, or test was edited. + +## Verdict + +**FAIL.** The pushed head fixes the previously reported raw-header erasure, +direct ignored-step cases, and root-vs-step completion typing. The package gate +is real and green. Two P1 lifecycle holes remain in the internal authored-flow +executor, however, and both produce a top-level `success` that contradicts the +authored ordering or the child journal. The nominally opaque handle is also +reflectively readable and forgeable after observing one genuine handle. + +## Blocking findings + +### B1 — a step created before `done()` can execute after `done()` (P1) + +`assertOperationAllowed` runs when `f.run()` constructs the lazy thenable +(`sdk/src/authored-flow-executor.ts:125-133`), not when its `then()` starts the +journal request (`:222-238`). An author can therefore create the step while the +flow is open, call `done("success")`, and only then await it. The real daemon +executes and journals that command, after which the executor appends its +terminal marker and reports success. `done()` is not terminal in this case. + +The checked-in positive test covers only a *new* `f.run()` call after `done()`; +the output below contains that positive control as `NEW_AFTER_DONE=REFUSED`, +alongside the bypass as `PRECREATED_AFTER_DONE=success`. + +### B2 — a settled rejected step can be swallowed and the flow reports success (P1) + +`DeferredJournalStep` remembers only `consumed` and `settled`. It does not retain +the settlement result or rejection. `refuseUnawaitedSteps` therefore accepts a +step whose rejection handler consumed the error and which settled before the +body returned. The real-daemon probe below shows all three tracked step verbs: + +- a failed `f.run("false")` is journaled as `retries_exhausted`, yet the authored + result is `success`; +- awaited/started unsupported `llm` and `agent` errors can likewise be consumed, + after which only the synthetic terminal run remains and the authored result + is `success`. + +This violates RFC covenant 2 and AGENTS.md rule 4: a failed child journal cannot +be aggregated into successful authored completion. It also disproves the PR +claim that every lazy thenable is fail-closed. The direct ignored-thenable cases +are correctly refused; the positive controls are in the same output. + +Literal real-journal command and output for B1 and B2: + +```text +$ review_data=$( new Promise((resolve) => setTimeout(resolve, 100)); +const client = new JournalClient(process.env.AUTHORED_SOCKET, { requestTimeoutMs: 5000 }); +await client.connect(); +await client.hello('pr134-structure-final'); +try { + for (const verb of ['run', 'llm', 'agent']) { + const direct = flow(`direct-${verb}`, async (f) => { + if (verb === 'run') f.run('false'); + else if (verb === 'llm') f.llm`ignored`; + else f.agent('worker', { task: 'ignored' }); + f.done('success'); + }); + try { + await executeAuthoredFlow(direct, client); + console.log(`DIRECT_${verb.toUpperCase()}=ACCEPTED`); + } catch (error) { + console.log(`DIRECT_${verb.toUpperCase()}=REFUSED code=${error.code}`); + } + } + + const late = await executeAuthoredFlow(flow('late-await', async (f) => { + const pending = f.run('printf late-after-done'); + f.done('success'); + await pending; + }), client); + console.log(`PRECREATED_AFTER_DONE=${late.completionReason} steps=${late.journalSteps.map((s) => s.id).join(',')}`); + + const swallowedRun = await executeAuthoredFlow(flow('swallowed-run', async (f) => { + f.run('false').then(undefined, () => 'swallowed'); + await pause(); + f.done('success'); + }), client); + console.log(`SETTLED_RUN=${swallowedRun.completionReason} steps=${swallowedRun.journalSteps.map((s) => `${s.id}:${s.completionReason}`).join(',')}`); + + for (const verb of ['llm', 'agent']) { + const handle = flow(`swallowed-${verb}`, async (f) => { + const step = verb === 'llm' + ? f.llm`unsupported but swallowed` + : f.agent('worker', { task: 'unsupported but swallowed' }); + step.then(undefined, () => 'swallowed'); + await pause(); + f.done('success'); + }); + const result = await executeAuthoredFlow(handle, client); + console.log(`SETTLED_${verb.toUpperCase()}=${result.completionReason} steps=${result.journalSteps.map((s) => s.id).join(',')}`); + } + + try { + await executeAuthoredFlow(flow('new-operation-after-done', async (f) => { + f.done('success'); + await f.run('false'); + }), client); + console.log('NEW_AFTER_DONE=ACCEPTED'); + } catch (error) { + console.log(`NEW_AFTER_DONE=REFUSED code=${error.code}`); + } +} finally { + client.close(); +} +NODE +$ probe_rc=$? +$ printf 'EXECUTOR_PROBE_EXIT=%s\n' "$probe_rc" +$ printf 'REAL_RUN_JOURNALS='; find "$review_data/runs" -type f -name '*.sqlite3' | wc -l | tr -d ' ' +DIRECT_RUN=REFUSED code=unawaited_step +DIRECT_LLM=REFUSED code=unawaited_step +DIRECT_AGENT=REFUSED code=unawaited_step +PRECREATED_AFTER_DONE=success steps=run-1,complete-2 +SETTLED_RUN=success steps=run-1:retries_exhausted,complete-2:success +SETTLED_LLM=success steps=complete-2 +SETTLED_AGENT=success steps=complete-2 +NEW_AFTER_DONE=REFUSED code=operation_after_completion +EXECUTOR_PROBE_EXIT=0 +REAL_RUN_JOURNALS=6 +``` + +Required disposition: enforce terminal state at lazy-step start as well as at +construction, retain each tracked step's rejection independently of user +handlers, and make any failed tracked step fail the authored execution. Add +load-bearing cases for pre-created/post-done steps and already-settled rejected +`run`/`llm`/`agent` thenables. + +## Additional structural finding + +### F3 — the handle is type-opaque, but not runtime-private (P2) + +The root package does not export the accessor and `Object.keys(handle)` exposes +only `name`, which is good. But a JavaScript symbol property is discoverable via +`Object.getOwnPropertySymbols`. A consumer can read the retained header/body +directly. Once it has observed one genuine handle, it can reuse that symbol to +construct another frozen object that `getFlowDefinition` accepts. In addition, +`@relayflows/surface/runtime` is an exported package subpath and +`getAuthoredFlowDefinition` is exported from the SDK root. “Authorized runtime” +is therefore a convention, not an enforced boundary. + +```text +$ node --input-type=module <<'NODE' +import * as root from '@relayflows/surface'; +import * as runtime from '@relayflows/surface/runtime'; +const handle = root.flow('opaque-probe', { identity: 'principal' }, async () => undefined); +const symbols = Object.getOwnPropertySymbols(handle); +const reflected = handle[symbols[0]]; +console.log(`ROOT_HAS_ACCESSOR=${'getFlowDefinition' in root}`); +console.log(`RUNTIME_HAS_ACCESSOR=${typeof runtime.getFlowDefinition}`); +console.log(`HANDLE_KEYS=${JSON.stringify(Object.keys(handle))}`); +console.log(`HANDLE_SYMBOLS=${symbols.length}`); +console.log(`REFLECTED_BODY=${typeof reflected?.body}`); +console.log(`REFLECTED_IDENTITY=${reflected?.header?.identity}`); +NODE +ROOT_HAS_ACCESSOR=false +RUNTIME_HAS_ACCESSOR=function +HANDLE_KEYS=["name"] +HANDLE_SYMBOLS=1 +REFLECTED_BODY=function +REFLECTED_IDENTITY=principal +HANDLE_PROBE_EXIT=0 +``` + +```text +$ node --input-type=module <<'NODE' +import { flow } from '@relayflows/surface'; +import { getFlowDefinition } from '@relayflows/surface/runtime'; +const genuine = flow('genuine', async () => undefined); +const symbol = Object.getOwnPropertySymbols(genuine)[0]; +const forged = { name: 'forged' }; +Object.defineProperty(forged, symbol, { + value: Object.freeze({ + name: 'forged', + header: Object.freeze({}), + body: async () => 'forged-body-ran', + }), + enumerable: false, + configurable: false, + writable: false, +}); +const recovered = getFlowDefinition(Object.freeze(forged)); +console.log(`EXTRACTED_SYMBOL=${String(symbol)}`); +console.log(`FORGED_ACCEPTED=${recovered.name}`); +console.log(`FORGED_BODY_RESULT=${await recovered.body({})}`); +NODE +EXTRACTED_SYMBOL=Symbol(@relayflows/surface.authored-definition.v1) +FORGED_ACCEPTED=forged +FORGED_BODY_RESULT=forged-body-ran +FORGERY_PROBE_EXIT=0 +``` + +If runtime opacity/unforgeability is part of the contract, use module-private +identity storage rather than a discoverable own property. This is P2 here +because the package and authored executor are explicitly unpublished/internal, +but the current “opaque” and “authorized” descriptions should not be treated as +an enforcement claim. + +## Boundary results + +### Raw, plain, and nested header validation — PASS + +The raw value is validated before projection/freezing. Exact allowlists use +`Reflect.ownKeys`, accessors are refused as non-data properties, root and nested +objects must be plain, symbol keys are unknown fields, and primitive/array/null +shapes fail closed. The probe exercises cases beyond the checked-in table. + +```text +$ node --input-type=module <<'NODE' +import { flow } from '@relayflows/surface'; +const nestedInherited = Object.create({ script: true }); +const nestedGetter = Object.defineProperty({}, 'mcp', { get: () => ['github'] }); +const nestedSymbol = { script: true }; +Object.defineProperty(nestedSymbol, Symbol('hidden'), { value: true, enumerable: false }); +const probes = [ + ['unknown-root', { identitty: 'x' }], + ['inherited-root', Object.create({ identity: 'x' })], + ['unknown-nested', { memory: { typo: true } }], + ['inherited-nested', { memory: nestedInherited }], + ['getter-nested', { tools: nestedGetter }], + ['symbol-nested', { memory: nestedSymbol }], + ['null-nested', { tools: null }], +]; +for (const [label, header] of probes) { + try { + flow(label, header, async () => undefined); + console.log(`${label}=ACCEPTED`); + } catch (error) { + console.log(`${label}=REFUSED ${error.message}`); + } +} +NODE +unknown-root=REFUSED unsupported_header: flow "unknown-root" header: unknown field "identitty" +inherited-root=REFUSED unsupported_header: flow "inherited-root" header: expected a plain object +unknown-nested=REFUSED unsupported_header: flow "unknown-nested" header.memory: unknown field "typo" +inherited-nested=REFUSED unsupported_header: flow "inherited-nested" header.memory: expected a plain object +getter-nested=REFUSED unsupported_header: flow "getter-nested" header.tools.mcp: expected a data property +symbol-nested=REFUSED unsupported_header: flow "symbol-nested" header.memory: unknown field "Symbol(hidden)" +null-nested=REFUSED unsupported_header: flow "null-nested" header.tools: expected an object +HEADER_PROBE_EXIT=0 +``` + +### Root-vs-step completion types and journal vocabulary — PASS + +`Ctx.done` and `AuthoredFlowExecutionResult.completionReason` use the closed run +union (`success`, `step_failed`, `canceled`, `budget_exceeded`), while child +journal steps use the distinct closed step union. Compile-time equality checks +bind the surface unions to the existing SDK protocol. The kernel/spec/protocol +files are unchanged by this PR. + +```text +$ git diff --quiet a0d42ffbdc7fb60b42c0b5bea4f58408249b08a2...d830d027843b64da27eca3b2f805ddc9b33ac058 -- sdk/src/protocol.ts kernel/relayflowd-core/src/entry.rs kernel/relayflowd-core/src/spec.rs; printf 'PROTOCOL_KERNEL_DIFF_EXIT=%s\n' "$?" +PROTOCOL_KERNEL_DIFF_EXIT=0 +``` + +The packed TypeScript consumer also carries an `@ts-expect-error` for passing a +step reason to `done`; its successful compile is included in the package-gate +output below. + +### Journal protocol boundary and durable-root scope — mixed + +The supported positive slice is structurally correct: an awaited `f.run` is +compiled through `compileSpec`/`toKernelSpec`, submitted through +`JournalClient.runStart`, and read from `step.completed`. No kernel or journal +vocabulary is added. The B2 output nevertheless proves that the authored result +can contradict the child journal, so result aggregation is not fail-closed. + +Scope honesty is otherwise **PASS**. `executeAuthoredFlow` is absent from the SDK +root export, the README calls it an internal test seam, and the PR explicitly +states there is no durable authored root or crash-safe resume. Each authored +step and the synthetic `:` terminal marker remains an independent kernel run; +this PR does not claim those child journals constitute a resumable root. + +### Module and dependency seams — PASS with one repair note + +Surface modules remain small and role-focused. The internal executor is 392 +lines—below the 500-line smell threshold—but it now owns context construction, +lazy lifecycle tracking, lowering, and protocol decoding. B1/B2 both sit in its +lifecycle state machine; splitting that tracker during repair would keep the +executor single-purpose. The only SDK runtime dependency added is the local +surface package; the surface package itself adds only build/test dev +dependencies. + +```text +$ wc -l surface/src/*.ts sdk/src/authored-flow*.ts surface/tests/flow.test.ts sdk/tests/authored-flow.test.ts scripts/surface-package-gate.sh + 76 surface/src/cloud.ts + 24 surface/src/completion.ts + 29 surface/src/context.ts + 240 surface/src/flow.ts + 22 surface/src/index.ts + 7 surface/src/runtime.ts + 5 surface/src/step.ts + 392 sdk/src/authored-flow-executor.ts + 21 sdk/src/authored-flow.ts + 200 surface/tests/flow.test.ts + 229 sdk/tests/authored-flow.test.ts + 165 scripts/surface-package-gate.sh + 1410 total +``` + +### Package / CI gate — PASS, but missing the two bypass cases + +The repository-owned job triggers on surface, regression, SDK, script, and +workflow changes, checks out the PR head, builds/types/tests, packs a real +tarball, installs it in a clean consumer, exercises both export paths, and +typechecks the external declarations. It also typechecks the SDK and runs the +authored executor tests. The exact local gate passed. Its seven executor tests +do not cover B1 or B2, which is why the hosted green check is not sufficient. + +```text +$ NPM_CONFIG_USERCONFIG=/dev/null bash scripts/surface-package-gate.sh +bun install v1.4.0 (1381054db) + +Checked 45 installs across 93 packages (no changes) [6.00s] +$ tsc +$ bun run build && tsc -p tsconfig.test.json && vitest run +$ tsc + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/surface + + ✓ tests/flow.test.ts (6 tests) 18ms + + Test Files 1 passed (1) + Tests 6 passed (6) + Start at 20:35:49 + Duration 1.59s (transform 113ms, setup 0ms, collect 116ms, tests 18ms, environment 1ms, prepare 255ms) + +$ tsc -p ../regressions/tsconfig.json +bun pack v1.4.0 (1381054db) +$ bun run build +$ tsc + +packed 0.80KB package.json +packed 1.33KB README.md +packed 2.50KB dist/cloud.d.ts +packed 2.30KB dist/cloud.d.ts.map +packed 44B dist/cloud.js +packed 102B dist/cloud.js.map +packed 0.63KB dist/completion.d.ts +packed 346B dist/completion.d.ts.map +packed 0.51KB dist/completion.js +packed 364B dist/completion.js.map +packed 0.99KB dist/context.d.ts +packed 0.96KB dist/context.d.ts.map +packed 46B dist/context.js +packed 106B dist/context.js.map +packed 1.54KB dist/flow.d.ts +packed 1.42KB dist/flow.d.ts.map +packed 5.72KB dist/flow.js +packed 6.15KB dist/flow.js.map +packed 472B dist/index.d.ts +packed 475B dist/index.d.ts.map +packed 147B dist/index.js +packed 200B dist/index.js.map +packed 171B dist/runtime.d.ts +packed 216B dist/runtime.d.ts.map +packed 83B dist/runtime.js +packed 148B dist/runtime.js.map +packed 310B dist/step.d.ts +packed 304B dist/step.d.ts.map +packed 43B dist/step.js +packed 100B dist/step.js.map +packed 2.0KB src/cloud.ts +packed 0.61KB src/completion.ts +packed 0.92KB src/context.ts +packed 7.35KB src/flow.ts +packed 465B src/index.ts +packed 144B src/runtime.ts +packed 272B src/step.ts + +/tmp/relayflows-surface-pack.Abo8xv/relayflows-surface-0.1.0.tgz + +Total files: 37 +Shasum: ce5fe1424418fe8e9bda9bbc6f0d09f75b18d34f +Integrity: sha512-4WRolvt6Se2A1[...]ve5xkGsxYx6lw== +Unpacked size: 40.32KB +Packed size: 10.85KB + +added 52 packages, and audited 54 packages in 12s + +14 packages are looking for funding + run `npm fund` for details + +5 vulnerabilities (3 moderate, 1 high, 1 critical) + +To address all issues (including breaking changes), run: + npm audit fix --force + +Run `npm audit` for details. + +> @relayflows/sdk@0.1.0 typecheck +> tsc --noEmit + +bun add v1.4.0 (1381054db) +Resolving dependencies +Resolved, downloaded and extracted [1] +Saved lockfile + +installed @relayflows/surface@/tmp/relayflows-surface-pack.Abo8xv/relayflows-surface-0.1.0.tgz + +1 package installed [5.44s] +PACKED_RUNTIME_OK name=packed-runtime-consumer completionReason=success +PACKED_RUNTIME_REFUSAL_OK invalidHeaders=9 forgedHandle=refused +PACKED_TYPESCRIPT_OK + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/sdk + + ✓ tests/authored-flow.test.ts (7 tests) 327ms + + Test Files 1 passed (1) + Tests 7 passed (7) + Start at 20:37:20 + Duration 9.04s (transform 1.34s, setup 0ms, collect 3.96s, tests 327ms, environment 0ms, prepare 1.71s) + +SURFACE_PACKAGE_GATE_EXIT=0 +``` + +### v1 preservation — no code regression found; one live external check unavailable + +All 17 non-live SDK files passed (227 tests), including the existing CLI, +compiler, preflight, protocol-client, and v1 spec suites. The Rust workspace +also passed after pinning the real stable Cargo binary because the ambient +`mise` shim was invalid. The live suite passed 16 of 17 tests—including the +crash/resume and protocol-v0 tests—but its real Claude readiness probe timed out; +that external check is not claimed as verified. The PR does not change its test +or analyzer fixture. + +```text +$ ./node_modules/.bin/vitest run --exclude tests/live-kernel.test.ts --reporter=dot --maxWorkers=1 --minWorkers=1 + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/sdk + + ✓ tests/cli.test.ts (50 tests) 2180ms + ✓ tests/journal-client.test.ts (13 tests) 107ms + ✓ tests/validate.test.ts (36 tests) 131ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 263ms + ✓ tests/preflight.test.ts (14 tests) 84ms + ✓ tests/backlog-picker.test.ts (14 tests) 477ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 8597ms + ✓ tests/authored-flow.test.ts (7 tests) 447ms + ✓ tests/work-package-consumer.test.ts (13 tests) 2322ms + ✓ tests/deterministic-llm.test.ts (5 tests) 99ms + ✓ tests/bin.test.ts (7 tests) 3619ms + ✓ tests/hn-poller.test.ts (6 tests) 104ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 99ms + ✓ tests/hello-deterministic.test.ts (5 tests) 65ms + ✓ tests/work-package-validator.test.ts (7 tests) 20ms + ✓ tests/spec-parity.test.ts (15 tests) 198ms + ✓ tests/parse-json-output.test.ts (7 tests) 41ms + + Test Files 17 passed (17) + Tests 227 passed (227) + Start at 20:39:28 + Duration 54.57s (transform 1.79s, setup 0ms, collect 6.81s, tests 18.85s, environment 25ms, prepare 9.75s) +EXIT=0 +``` + +```text +$ RELAYFLOWD_BIN="$PWD/../kernel/target/debug/relayflowd" ./node_modules/.bin/vitest run tests/live-kernel.test.ts --reporter=dot --maxWorkers=1 --minWorkers=1 + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/sdk + +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/kernel/target/debug/relayflowd +LIVE_KERNEL flows=/Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/sdk/dist/cli.js + +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=4513 run=01M1HPVGTT09EVYNPZ0XD82YSN while step=two state=Running + + ❯ tests/live-kernel.test.ts (17 tests | 1 failed) 100441ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 2994ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 34103ms + ✓ built flows CLI against live relayflowd > follows a live worker dispatch through flows run 4241ms + ✓ built flows CLI against live relayflowd > runs an agent CLI end to end through the SDK worker 2877ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 12507ms + ✓ built flows CLI against live relayflowd > reports a real manual-recovery NeedsHuman state as parked 1209ms + ✓ built flows CLI against live relayflowd > runs hn-monitor analyze-story end-to-end via a stub agent CLI (gate 2 clause 2 demo) 3955ms + ✓ built flows CLI against live relayflowd > hn-monitor analyze-story FAILS verification when the CLI omits required schema fields 2010ms + ✓ built flows CLI against live relayflowd > AgentWorker exposes wake_context to the CLI via RELAYFLOW_WAKE_CONTEXT env var (real analyzer prerequisite) 412ms + × built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI 30055ms + → LIVE_ANALYZER_UNAVAILABLE: "/Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/testdata/preflight/analyze-story-claude-cli auth status" could not run: spawnSync /Users/khaliqgant/AgentWorkforce/flows-132-surface-wt/testdata/preflight/analyze-story-claude-cli ETIMEDOUT — 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. + ✓ built flows CLI against live relayflowd > preflights before journaling and names an unreachable socket 1501ms + ✓ JournalClient wire conformance against live relayflowd > exercises every protocol-v0 verb with the real server 606ms + ✓ surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once 2998ms + + Test Files 1 failed (1) + Tests 1 failed | 16 passed (17) + Start at 20:40:32 + Duration 102.43s (transform 679ms, setup 0ms, collect 958ms, tests 100.44s, environment 0ms, prepare 241ms) +EXIT=1 +``` + +```text +$ sh ../ops/cargo.sh test --workspace --quiet +mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use ` to reinstall the tool. +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information +EXIT=1 +``` + +```text +$ PATH="/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin:/usr/bin:/bin:/usr/sbin:/sbin" sh ../ops/cargo.sh test --workspace --quiet + +running 22 tests +...................... +test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.72s + +running 0 tests +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +running 19 tests +................... +test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.68s + +running 1 test +. +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +running 1 test +. +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +running 3 tests +... +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +running 26 tests +.......................... +test result: ok. 26 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s + +running 5 tests +..... +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +running 17 tests +................. +test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +running 0 tests +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +running 0 tests +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +running 0 tests +test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s +EXIT=0 +``` + +## Exact revision and hosted checks + +```text +$ git rev-parse HEAD; git rev-parse origin/main; git merge-base origin/main HEAD; git diff --check origin/main...HEAD; printf 'DIFF_CHECK_EXIT=%s\n' "$?" +d830d027843b64da27eca3b2f805ddc9b33ac058 +a0d42ffbdc7fb60b42c0b5bea4f58408249b08a2 +a0d42ffbdc7fb60b42c0b5bea4f58408249b08a2 +DIFF_CHECK_EXIT=0 +``` + +```text +$ gh api repos/AgentWorkforce/flows/commits/d830d027843b64da27eca3b2f805ddc9b33ac058/check-runs --jq '.check_runs[] | [.name,.status,.conclusion,.html_url] | @tsv'; gh api repos/AgentWorkforce/flows/commits/d830d027843b64da27eca3b2f805ddc9b33ac058/status --jq '.statuses[] | [.context,.state,.target_url] | @tsv' +linux-x64-artifact completed success https://github.com/AgentWorkforce/flows/actions/runs/33665488114/job/100366070315 +packed-consumer completed success https://github.com/AgentWorkforce/flows/actions/runs/33665488092/job/100366069424 +CodeRabbit success +EXIT=0 +``` + +The two first-party checks are green at the requested head. CodeRabbit's status +is not used as review evidence. + +REVIEW_FAILED From 6a3c1e952aa4404b3470db44343b842798831f25 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Thu, 3 Sep 2026 13:40:52 +0200 Subject: [PATCH 09/10] fix(sdk): close the derived-work settlement race in the authored gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The derived-chain gate sampled which failures had already landed. It skipped every promise that had not settled, so the same program passed or failed on how many microtask ticks the failure took: ten `await null`s, or any real derived I/O, cleared the window and a flow recorded terminal success while work derived from a step had thrown. Widening the window cannot fix that; there is no safe tick count. What is timing-independent is whether derived work was still in flight when the body returned: work the author awaited is settled at that instant in every timing, and work the author did not await is pending in every timing. The gate now reads the in-flight set before it awaits anything and refuses on it (`unsettled_derived_work`). That also makes the settled set complete, so inspecting settled outcomes stops being a sample and becomes a total answer over a closed set. The same escape existed through Promise.allSettled, Promise.any and Promise.race, which no earlier review had demonstrated. A combinator resolves its aggregate from inside the reaction of one of its members, so the aggregate is not downstream of any member by `trigger` — only `Promise.all` was covered, and only because it is registered by name. Aggregates now inherit attribution from the context that resolves them, which covers every combinator without intercepting any of them. Also repaired, all measured: - The reachability predicate refused ordinary authoring. `trigger` and `resolutionCause` do not connect an async function's resumption context to the context it suspended from, so a walk from `done()` reached only the last await's lineage and `const steps = [f.run(a), f.run(b)]; for (const s of steps) await s;` reported run-1 unawaited. The init-time `executionAsyncId()` is that missing edge, and it is a fact the runtime reports rather than a widened approximation. - The gate was quadratic in process-wide promise count: 5 000 awaits -> 1803 ms, 30 000 -> 94 700 ms on a 25 ms body. Attribution is now eager and O(1) per promise, and only promises created inside the flow's own async scope are tracked. 30 000 -> 34 ms, and 140 007 unrelated process promises retained -> 0. A performance assertion pins the 30 000 case under 1 000 ms. - `Promise.all` is still intercepted, because a combinator's aggregate has no runtime edge to its non-final members and every alternative reduces to callback identity inference. It no longer changes what `Promise.all` does: `Promise.all(5)` rejects instead of resolving `[]`, `Promise.all(null)` returns a rejected promise instead of throwing synchronously, and `name` is `all`. The interception is now DISCLOSED in docs/SURFACE.md with its reason and its process-wide scope, together with the one documented limit of the contract: a derived chain created inside a timer that fires after the body returns does not exist yet and cannot be observed. - `close()` releases every tracked map, not only the promise handles. Three things a reviewer should not mistake for noise: - sdk/tsconfig.tests.json is WIDENED here, and the four type errors fixed in sdk/tests/authored-flow-operation.test.ts were NOT introduced by this change. Main's new typecheck:tests gate included only src and typed-output.test.ts; tsconfig.json excludes tests/ and vitest does not typecheck, so #134's test files were type-checked by nothing. Three of the four errors are pre-existing at 59c062cf. `lib` is raised to ES2024.Promise in that gate config only, for Promise.withResolvers in the forgery tests; the SDK's own tsconfig stays on ES2022. - Fire-and-forget async work outstanding at done() is now refused even when it would have succeeded. That is a deliberate tightening under covenant 2 and is documented; awaited work of any shape is unaffected. - The 140 lines this branch deletes from main are all #134's own intent, and the biggest block is regressions/surface.d.ts, whose own header asked to be deleted once the real surface shipped. The repair report carries the full attribution table, and all 51 of #136's files this branch does not touch are blob-identical to 990093b. The promise-graph observation moves to its own module; the lifecycle keeps the operation-facing contract. Probes for every claim in the repair report are committed under ops/probes/pr134-repair-0903/ and run with plain node — including the kernel-spec assertion that `output` still LOWERS to a json_schema gate, which validateSpec and `flows check` cannot see. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- docs/SURFACE.md | 55 ++ ops/probes/pr134-repair-0903/combinators.mjs | 37 + ops/probes/pr134-repair-0903/gate-cost.mjs | 28 + ops/probes/pr134-repair-0903/harness.mjs | 84 ++ ops/probes/pr134-repair-0903/negatives.mjs | 38 + .../pr134-repair-0903/p0-derived-race.mjs | 21 + .../pr134-repair-0903/p1a-authoring.mjs | 44 + .../promise-all-semantics.mjs | 38 + .../pr134-repair-0903/verb-output-fields.mjs | 69 ++ ops/reviews/20260903-pr134-repair-0903.md | 921 ++++++++++++++++++ sdk/src/authored-flow-error.ts | 1 + sdk/src/authored-flow-lifecycle.ts | 163 ++-- sdk/src/authored-flow-operation.ts | 11 + sdk/src/authored-promise-graph.ts | 196 ++++ .../authored-flow-lifecycle-executor.test.ts | 122 ++- sdk/tests/authored-flow-operation.test.ts | 103 +- sdk/tsconfig.tests.json | 12 +- surface/README.md | 12 +- 18 files changed, 1875 insertions(+), 80 deletions(-) create mode 100644 ops/probes/pr134-repair-0903/combinators.mjs create mode 100644 ops/probes/pr134-repair-0903/gate-cost.mjs create mode 100644 ops/probes/pr134-repair-0903/harness.mjs create mode 100644 ops/probes/pr134-repair-0903/negatives.mjs create mode 100644 ops/probes/pr134-repair-0903/p0-derived-race.mjs create mode 100644 ops/probes/pr134-repair-0903/p1a-authoring.mjs create mode 100644 ops/probes/pr134-repair-0903/promise-all-semantics.mjs create mode 100644 ops/probes/pr134-repair-0903/verb-output-fields.mjs create mode 100644 ops/reviews/20260903-pr134-repair-0903.md create mode 100644 sdk/src/authored-promise-graph.ts diff --git a/docs/SURFACE.md b/docs/SURFACE.md index db5f15fd6..251103ac0 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -221,6 +221,61 @@ The authoring surface deliberately narrows `steps: []`: `flows check` refuses it as `invalid_spec`, while the kernel accepts it. This is a chosen authoring-time narrowing, not a kernel guarantee. +### The authored operation lifecycle + +An authored TypeScript body reaches `done()` only if every step it created was +actually consumed on the continuation that got there, and nothing derived from a +step was still running or had failed unobserved. Three rules, in the author's +vocabulary: + +1. **Await every step.** Creating `f.run(...)` and never awaiting it is + `unawaited_step`. Constructing steps and awaiting them later is fine — + `const steps = [f.run(a), f.run(b)]; for (const s of steps) await s;` is + ordinary, supported authoring, and so is `Promise.resolve`, `Promise.all`, + `Promise.allSettled`, `Promise.any` and `Promise.race` over authored steps. + A manual `.then(...)` callback is not an await and is refused; callback + source text is never treated as proof of anything. +2. **A step's failure is yours whether or not you catch it.** A root failure is + recorded before author code can reach the operation, so a `catch` cannot hide + it. At this gate the executor only lowers `done("success")`, so there is no + expressible recovery from a failed step yet. +3. **Finish your derived work before `done()`.** If a handler chained onto a step + is still in flight when the body returns, the run is refused with + `unsettled_derived_work` rather than recorded as a success nobody can prove. + Awaited derived work is always settled by then; only fire-and-forget work is + caught by this. If you start something after a step, await it before `done()`. + +**Documented limit.** Work that does not yet *exist* when the body returns +cannot be seen. `setTimeout(() => { p.then(handler).catch(ignore); })` schedules +a derived chain to begin after completion, and the gate will not observe it. +This is the boundary of the contract, not an oversight: the flow has already +finished when that promise is created. Do not use a timer to smuggle +post-completion work into a run. + +**Disclosure: this package replaces `Promise.all` while a flow is open.** The +lifecycle installs its own `Promise.all` on the global `Promise` for the +duration of any authored flow execution, and restores the original when the last +concurrent flow closes. + +- *Why:* a combinator's aggregate has no runtime edge back to its non-final + members, so `await Promise.all([a, b])` cannot otherwise be proven to have + consumed `a`. The alternatives all infer group membership from the callbacks + the combinator passes each element, which is exactly the callback-identity + inference this contract exists to refuse. +- *Scope:* process-wide, for the lifetime of an authored flow execution. Any code + in the process — including yours and your dependencies' — sees the replacement + during that window. +- *Behaviour:* the replacement delegates to the intrinsic and is specified to + behave identically. A non-iterable argument is handed straight through, so + `Promise.all(5)` and `Promise.all(null)` return the same rejected promises the + intrinsic returns; `name` and `length` match. If `Promise.all` has already been + replaced by something else, the flow refuses to start rather than fighting over + the intrinsic. + +If a process-wide intrinsic replacement is unacceptable in your deployment, do +not run authored TypeScript bodies in that process; the declarative YAML path +does not install it. + ## 3. Plugins: the kernel is closed, the surface is open The herdr model: first-party helpers are just plugins that ship in the box; the community brings the rest. diff --git a/ops/probes/pr134-repair-0903/combinators.mjs b/ops/probes/pr134-repair-0903/combinators.mjs new file mode 100644 index 000000000..901effeca --- /dev/null +++ b/ops/probes/pr134-repair-0903/combinators.mjs @@ -0,0 +1,37 @@ +// Does the in-flight rule cover every combinator, or only the registered one? +// Promise.allSettled / any / race are NOT intercepted, so their aggregate is not +// downstream of any member by `trigger`; it inherits attribution only from the +// context that resolves it. +import { flow, runFlow, verdict } from './harness.mjs'; +const ticks = (n) => async () => { for (let i = 0; i < n; i++) await null; }; + +const exploit = (name, consume) => flow(name, async (f) => { + const consumed = consume(f.run('true')); + const derived = consumed.then(async () => { + await ticks(10)(); + throw new Error(`derived post-processing failed (${name})`); + }); + derived.catch(() => undefined); + await consumed; + f.done('success'); +}); +const legit = (name, consume) => flow(name, async (f) => { await consume(f.run('true')); f.done('success'); }); +const ignored = (name, consume) => flow(name, async (f) => { + void consume(f.run('true')); + await new Promise((r) => setTimeout(r, 60)); + f.done('success'); +}); + +const shapes = { + 'Promise.allSettled': (s) => Promise.allSettled([s]), + 'Promise.any ': (s) => Promise.any([s]), + 'Promise.race ': (s) => Promise.race([s]), + 'Promise.all ': (s) => Promise.all([s]), + 'Promise.resolve ': (s) => Promise.resolve(s), +}; +for (const [label, consume] of Object.entries(shapes)) { + const l = verdict(await runFlow(legit(`legit-${label.trim()}`, consume))); + const x = verdict(await runFlow(exploit(`exploit-${label.trim()}`, consume))); + const g = verdict(await runFlow(ignored(`ignored-${label.trim()}`, consume))); + console.log(`${label} awaited=${l.padEnd(22)} deferred-derived-failure=${x.padEnd(26)} ignored=${g}`); +} diff --git a/ops/probes/pr134-repair-0903/gate-cost.mjs b/ops/probes/pr134-repair-0903/gate-cost.mjs new file mode 100644 index 000000000..15a4bd61b --- /dev/null +++ b/ops/probes/pr134-repair-0903/gate-cost.mjs @@ -0,0 +1,28 @@ +// P1-B: cost of the completion gate against ordinary in-flow promise churn. +// Usage: node gate-cost.mjs [distDir] [awaits] (distDir defaults to "dist") +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const DIST = process.argv[2] ?? 'dist'; +const N = Number(process.argv[3] ?? 30000); +const { AuthoredFlowOperation, verifyAuthoredOperations } = await import(`${REPO}/sdk/${DIST}/authored-flow-operation.js`); +const { AuthoredFlowLifecycle } = await import(`${REPO}/sdk/${DIST}/authored-flow-lifecycle.js`); + +const lc = new AuthoredFlowLifecycle(); +const ops = []; +const mk = () => { + const o = new AuthoredFlowOperation(`run-${ops.length + 1}`, 'run', () => undefined, async () => 'v', lc); + ops.push(o); return o; +}; +let t0 = Date.now(); +await lc.runBody(async () => { + await mk().step; + for (let i = 0; i < N; i++) await Promise.resolve(i); // ordinary in-flow async work + lc.markCompletion(); +}); +const bodyMs = Date.now() - t0; +t0 = Date.now(); +let err = null; +try { await verifyAuthoredOperations('gate-cost', ops, lc); } catch (e) { err = e; } +console.log(`${DIST} awaits=${N} body=${bodyMs}ms verifyAuthoredOperations=${Date.now() - t0}ms verdict=${err ? err.code : 'PASSED'}`); +lc.close(); diff --git a/ops/probes/pr134-repair-0903/harness.mjs b/ops/probes/pr134-repair-0903/harness.mjs new file mode 100644 index 000000000..d8c48ddf3 --- /dev/null +++ b/ops/probes/pr134-repair-0903/harness.mjs @@ -0,0 +1,84 @@ +// Shared probe harness for the PR #134 authored-lifecycle repair. +// +// Drives the real `executeAuthoredFlow` against a loopback journal faithful to +// `sdk/tests/journal-client-loopback.ts`, and reports which journal runs were +// started — so "did the flow lower its terminal complete-* run" is observed, +// not inferred. Run any probe in this directory with plain `node`. +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { rmSync } from 'node:fs'; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +export const { executeAuthoredFlow } = await import(`${REPO}/sdk/dist/authored-flow-executor.js`); +export const { JournalClient } = await import(`${REPO}/sdk/dist/journal-client.js`); +export const { flow } = await import(`${REPO}/surface/dist/index.js`); + +function sockPath() { return join(tmpdir(), `rf-${randomUUID().slice(0, 8)}.sock`); } + +export async function runFlow(handle) { + const path = sockPath(); + const started = []; + const stepByRun = new Map(); + let nextRun = 1; + const server = createServer((socket) => { + let buffer = ''; + const send = (o) => socket.write(JSON.stringify(o) + '\n'); + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let nl; + while ((nl = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); + if (!line) continue; + const req = JSON.parse(line); + if (req.verb === 'hello') { send({ id: req.id, ok: true, result: { protocol: '0.1.0', server: 'probe' } }); continue; } + if (req.verb === 'run.start') { + const spec = req.params.spec; const step = spec.steps[0]; + const runId = `probe-run-${nextRun++}`; + started.push(spec.name); + stepByRun.set(runId, { id: step.id, command: step.command }); + send({ id: req.id, ok: true, result: { + run_id: runId, + status: step.command === 'false' ? 'failed' : 'completed', + completion_reason: step.command === 'false' ? 'step_failed' : 'success', + completed_steps: 1, + }}); + continue; + } + if (req.verb === 'journal.read') { + const step = stepByRun.get(req.params.run_id); + const failed = step.command === 'false'; + send({ id: req.id, ok: true, result: { entries: [{ + entry_type: 'step.completed', step_id: step.id, + payload: { completionReason: failed ? 'verification_failed' : 'success', + output: failed ? null : { stdout_tail: '', stderr_tail: '', exit_code: 0 } }, + }]}}); + continue; + } + send({ id: req.id, ok: false, error: { code: 'unknown_verb', message: req.verb } }); + } + }); + }); + await new Promise((r) => server.listen(path, r)); + const client = new JournalClient(path, { requestTimeoutMs: 600000 }); + await client.connect(); + await client.hello('pr134-repair-probe'); + let result, error; + try { result = await executeAuthoredFlow(handle, client); } + catch (e) { error = e; } + finally { client.close(); await new Promise((r) => server.close(r)); rmSync(path, { force: true }); } + return { started, result, error, terminal: started.some((n) => n.includes('/complete-')) }; +} + +export function report(label, r) { + console.log(`--- ${label}`); + console.log(` journal runs started: ${JSON.stringify(r.started)}`); + console.log(` terminal complete-* lowered: ${r.terminal}`); + console.log(` result: ${r.result ? JSON.stringify(r.result.completionReason) : 'none'} error: ${r.error ? (r.error.code ?? '') + ': ' + r.error.message : 'none'}`); +} + +export function verdict(r) { + return r.error ? `REFUSED ${r.error.code}` : `PASSED(terminal=${r.terminal})`; +} diff --git a/ops/probes/pr134-repair-0903/negatives.mjs b/ops/probes/pr134-repair-0903/negatives.mjs new file mode 100644 index 000000000..38f760211 --- /dev/null +++ b/ops/probes/pr134-repair-0903/negatives.mjs @@ -0,0 +1,38 @@ +// Over-attribution guard: ordinary work that merely FOLLOWS an authored step +// must not be mistaken for work derived from it. +import { flow, runFlow, verdict } from './harness.mjs'; +const cases = { + 'step then 500 unrelated awaits then done [MUST PASS]': flow('n1', async (f) => { + await f.run('true'); + for (let i = 0; i < 500; i++) await Promise.resolve(i); + f.done('success'); + }), + 'step then unrelated timer awaited [MUST PASS]': flow('n2', async (f) => { + await f.run('true'); await new Promise((r) => setTimeout(r, 20)); f.done('success'); + }), + 'step then unrelated chain awaited [MUST PASS]': flow('n3', async (f) => { + await f.run('true'); + await Promise.resolve(1).then((x) => x + 1).then(async (x) => { await null; return x; }); + f.done('success'); + }), + 'two steps, unrelated work between [MUST PASS]': flow('n4', async (f) => { + await f.run('true'); await new Promise((r) => setTimeout(r, 5)); await f.run('true'); f.done('success'); + }), + 'awaited allSettled, derived chain awaited [MUST PASS]': flow('n5', async (f) => { + const c = Promise.allSettled([f.run('true')]); + await c.then((rs) => rs.length); + f.done('success'); + }), + 'nested Promise.all over Promise.resolve [MUST PASS]': flow('n6', async (f) => { + await Promise.all([Promise.resolve(f.run('true')), Promise.resolve(f.run('true'))]); + f.done('success'); + }), + 'step then UNRELATED fire-and-forget [observe]': flow('n7', async (f) => { + await f.run('true'); + void (async () => { await new Promise((r) => setTimeout(r, 40)); })(); + f.done('success'); + }), +}; +for (const [label, handle] of Object.entries(cases)) { + console.log(`${label} -> ${verdict(await runFlow(handle))}`); +} diff --git a/ops/probes/pr134-repair-0903/p0-derived-race.mjs b/ops/probes/pr134-repair-0903/p0-derived-race.mjs new file mode 100644 index 000000000..0c7742b24 --- /dev/null +++ b/ops/probes/pr134-repair-0903/p0-derived-race.mjs @@ -0,0 +1,21 @@ +// P0: the derived-chain gate must not depend on WHEN a derived failure lands. +// Same program three times; only the delay before the throw differs. +import { flow, runFlow, report } from './harness.mjs'; + +const exploit = (name, defer) => flow(name, async (f) => { + const step = f.run('true'); + const consumed = Promise.resolve(step); + const derived = consumed.then(async () => { + await defer(); + throw new Error(`derived post-processing failed (${name})`); + }); + derived.catch(() => undefined); // handled and forgotten + await consumed; // root legitimately consumed + f.done('success'); +}); +const ticks = (n) => async () => { for (let i = 0; i < n; i++) await null; }; +const timer = (ms) => () => new Promise((r) => setTimeout(r, ms)); + +report('CONTROL immediate throw (0 ticks) [MUST refuse]', await runFlow(exploit('c0', ticks(0)))); +report('EXPLOIT 10 microtask ticks [MUST refuse]', await runFlow(exploit('x10', ticks(10)))); +report('EXPLOIT setTimeout(0) [MUST refuse]', await runFlow(exploit('xt0', timer(0)))); diff --git a/ops/probes/pr134-repair-0903/p1a-authoring.mjs b/ops/probes/pr134-repair-0903/p1a-authoring.mjs new file mode 100644 index 000000000..f6f876cfa --- /dev/null +++ b/ops/probes/pr134-repair-0903/p1a-authoring.mjs @@ -0,0 +1,44 @@ +// P1-A: pre-constructed steps awaited later are ordinary authoring and must pass. +// The refusals below must stay refusals. +import { flow, runFlow, report } from './harness.mjs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +report('E1 create a,b; await a; await b [MUST pass]', await runFlow(flow('e1', async (f) => { + const a = f.run('true'), b = f.run('true'); await a; await b; f.done('success'); +}))); +report('E2 create a,b; await b; await a [MUST pass]', await runFlow(flow('e2', async (f) => { + const a = f.run('true'), b = f.run('true'); await b; await a; f.done('success'); +}))); +report('E5 array of steps awaited in a loop [MUST pass]', await runFlow(flow('e5', async (f) => { + const steps = [f.run('true'), f.run('true'), f.run('true')]; + for (const s of steps) await s; f.done('success'); +}))); +report('E4 await Promise.all([a,b]) [MUST pass]', await runFlow(flow('e4', async (f) => { + await Promise.all([f.run('true'), f.run('true')]); f.done('success'); +}))); +report('S4 ignored Promise.resolve(step) [MUST refuse]', await runFlow(flow('s4', async (f) => { + void Promise.resolve(f.run('true')); await sleep(100); f.done('success'); +}))); +report('S4b ignored Promise.all([a,b]) [MUST refuse]', await runFlow(flow('s4b', async (f) => { + void Promise.all([f.run('true'), f.run('true')]); await sleep(100); f.done('success'); +}))); +report('C5 withResolvers forgery [MUST refuse]', await runFlow(flow('c5', async (f) => { + const d = Promise.withResolvers(); + f.run('true').then(d.resolve, d.reject); await sleep(100); f.done('success'); +}))); +report('C8 forged toString + manual .then [MUST refuse]', await runFlow(flow('c8', async (f) => { + const o = Function.prototype.toString; + Function.prototype.toString = () => 'function () { [native code] }'; + try { f.run('true').then(() => undefined, () => undefined); await sleep(100); } + finally { Function.prototype.toString = o; } + f.done('success'); +}))); +report('D4 done() from a setTimeout after body ret [MUST refuse]', await runFlow(flow('d4', async (f) => { + const s = f.run('true'); + setTimeout(() => { try { f.done('success'); } catch {} }, 10); + void s; +}))); +report('IIFE detached await [MUST refuse]', await runFlow(flow('iife', async (f) => { + const s = f.run('true'); + void (async () => { await s; })(); await sleep(50); f.done('success'); +}))); diff --git a/ops/probes/pr134-repair-0903/promise-all-semantics.mjs b/ops/probes/pr134-repair-0903/promise-all-semantics.mjs new file mode 100644 index 000000000..bc710e26a --- /dev/null +++ b/ops/probes/pr134-repair-0903/promise-all-semantics.mjs @@ -0,0 +1,38 @@ +// P2-A: the interception must not change what Promise.all DOES, and must not +// retain promises created outside the flow body. +// Usage: node promise-all-semantics.mjs [distDir] +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const DIST = process.argv[2] ?? 'dist'; +const { AuthoredFlowLifecycle } = await import(`${REPO}/sdk/${DIST}/authored-flow-lifecycle.js`); + +const nativeAll = Promise.all; +const lc = new AuthoredFlowLifecycle(); +console.log(`${DIST} Promise.all patched during a flow: ${Promise.all !== nativeAll}`); +const show = async (label, fn) => { + try { console.log(` ${label} -> RESOLVED ${JSON.stringify(await fn())}`); } + catch (e) { console.log(` ${label} -> rejected: ${e.constructor.name}`); } +}; +try { const p = Promise.all(null); p.catch(() => undefined); } +catch (e) { console.log(` patched Promise.all(null) -> THREW SYNCHRONOUSLY: ${e.constructor.name}`); } +await show('patched Promise.all(null) ', () => Promise.all(null)); +await show('patched Promise.all(5) ', () => Promise.all(5)); +await show('patched Promise.all([1,P2])', () => Promise.all([1, Promise.resolve(2)])); +console.log(` patched Promise.all.name=${JSON.stringify(Promise.all.name)} length=${Promise.all.length}`); + +const tracked = () => { + const sizes = []; + const visit = (h) => { for (const v of Object.values(h)) { + if (v instanceof Map || v instanceof Set) sizes.push(v.size); + else if (typeof v === 'object' && v !== null && !Array.isArray(v)) visit(v); + } }; + visit(lc); return sizes.reduce((a, b) => a + b, 0); +}; +const before = tracked(); +const unrelated = []; +for (let i = 0; i < 20000; i++) unrelated.push(Promise.resolve(i)); +await nativeAll.call(Promise, unrelated); +console.log(` tracked promises: before=${before} after 20000 unrelated=${tracked()} (delta=${tracked() - before})`); +lc.close(); +console.log(` Promise.all restored after close: ${Promise.all === nativeAll}`); diff --git a/ops/probes/pr134-repair-0903/verb-output-fields.mjs b/ops/probes/pr134-repair-0903/verb-output-fields.mjs new file mode 100644 index 000000000..97fab5070 --- /dev/null +++ b/ops/probes/pr134-repair-0903/verb-output-fields.mjs @@ -0,0 +1,69 @@ +// The rebase trap: `output` must be accepted on llm and agent and refused on +// deterministic, through validateSpec AND compileYaml (flows check is run +// separately against the fixtures this writes). +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync, writeFileSync } from 'node:fs'; +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const { validateSpec } = await import(`${REPO}/sdk/dist/validate.js`); +const { compileYaml, toKernelSpec } = await import(`${REPO}/sdk/dist/compile.js`); + +// Documented shape (docs/SURFACE.md): the JSON Schema sits directly under `output`. +const OUTPUT = { type: 'object', properties: { verdict: { type: 'string' } }, required: ['verdict'] }; +// PRIMARY: only compileYaml + toKernelSpec distinguishes "the key was accepted" +// from "the key became a gate". validateSpec and `flows check` both report a +// healthy gate when the LOWERING has been reverted but the allowlist is intact, +// so they are supporting witnesses, not the assertion. +console.log('--- PRIMARY: compileYaml + toKernelSpec (does `output` BECOME a gate?)'); +const lowered = (yaml) => toKernelSpec(compileYaml(yaml)) + .steps[0].verification; +console.log('--- PATH 1 (supporting): validateSpec'); +for (const [label, step, want] of [ + ['llm ', { id: 's', type: 'llm', prompt: 'p', cli: 'claude', output: OUTPUT }, 'ACCEPT'], + ['agent ', { id: 's', type: 'agent', instruction: 'i', cli: 'claude', output: OUTPUT }, 'ACCEPT'], + ['deterministic ', { id: 's', type: 'deterministic', command: 'true', output: OUTPUT }, 'REFUSE'], +]) { + const r = validateSpec({ version: '0.1.0', name: 'output-proof', steps: [step] }); + const got = r.ok ? 'ACCEPT' : 'REFUSE'; + console.log(` ${label} -> ${got} ${got === want ? 'OK ' : 'WRONG'} ${r.ok ? '' : r.errors.join(' | ')}`); +} + +const head = 'version: "0.1.0"\nname: output-proof\nsteps:\n'; +const schema = ' output:\n type: object\n properties:\n verdict:\n type: string\n required: [verdict]\n'; +const bodies = { + llm: ' - id: s\n type: llm\n prompt: p\n cli: claude\n' + schema, + agent: ' - id: s\n type: agent\n instruction: i\n cli: claude\n' + schema, + deterministic: ' - id: s\n type: deterministic\n command: "true"\n' + schema, +}; +const want = { llm: 'ACCEPT', agent: 'ACCEPT', deterministic: 'REFUSE' }; +console.log('--- PATH 2: compileYaml'); +const outDir = process.argv[2]; +if (outDir) mkdirSync(outDir, { recursive: true }); +for (const [name, body] of Object.entries(bodies)) { + const yaml = head + body; + if (outDir) writeFileSync(`${outDir}/${name}.flow.yaml`, yaml); + let got, detail = ''; + try { compileYaml(yaml); got = 'ACCEPT'; } + catch (e) { got = 'REFUSE'; detail = String(e.message).split('\n').slice(0, 2).join(' / '); } + console.log(` ${name.padEnd(14)} -> ${got} ${got === want[name] ? 'OK ' : 'WRONG'} ${detail}`); +} + +console.log('--- PRIMARY: kernel verification emitted for each verb'); +const wantGate = JSON.stringify({ json_schema: { type: 'object', properties: { verdict: { type: 'string' } }, required: ['verdict'] } }); +for (const [name, body] of Object.entries(bodies)) { + const yaml = head + body; + let line; + try { + const v = lowered(yaml); + const emitted = JSON.stringify(v); + const isGate = emitted !== undefined && emitted.includes('json_schema'); + const matches = emitted === wantGate; + line = `emitted verification = ${emitted}` + + ` -> ${isGate ? (matches ? 'IS THE DECLARED json_schema GATE OK' : 'json_schema BUT NOT THE DECLARED SCHEMA WRONG') : 'NOT A json_schema GATE WRONG'}`; + } catch (e) { + line = `REFUSED at compile: ${String(e.message).split('\n')[0]}` + + (name === 'deterministic' ? ' -> OK (deterministic must not accept output)' : ' -> WRONG'); + } + console.log(` ${name.padEnd(14)} ${line}`); +} +if (outDir) console.log(`--- PATH 3 (supporting) fixtures written to ${outDir} (run: node sdk/dist/cli.js check )`); diff --git a/ops/reviews/20260903-pr134-repair-0903.md b/ops/reviews/20260903-pr134-repair-0903.md new file mode 100644 index 000000000..92440179b --- /dev/null +++ b/ops/reviews/20260903-pr134-repair-0903.md @@ -0,0 +1,921 @@ +# PR #134 — repair of the authored operation lifecycle contract + +- Repo: `AgentWorkforce/flows` +- PR: #134 `feat(surface): add unpublished authored contract foundation` +- Repair worktree: `/Users/khaliqgant/AgentWorkforce/flows-pr134-repair-0903-wt` +- Branch: `repair/pr134-0903` +- Started from: `59c062cf7b1c94190b09216ca6d032390eaab9d7` (the head the adversarial signoff reviewed) +- Rebased onto: `990093b` (`feat(sdk): declare agent CLI and model with fail-closed checks (#136)`). + Main has since moved to `512723c` (#138); not chased, per the standing rail. +- Repairing: the adversarial signoff at `59c062cf` (VERDICT: REVIEW_FAILED). + Its original worktree `flows-pr134-signoff9-wt` has since been reclaimed; the + report survives at + `/Users/khaliqgant/AgentWorkforce/flows-ops/ops/reviews-archive-0903/20260903-pr134-signoff4-adversarial.md` +- Date: 2026-09-03 +- Host: darwin 25.6.0, Node v26.7.0 + +Every probe cited here is committed under `ops/probes/pr134-repair-0903/` and +runs with plain `node` against the built `sdk/dist` + `surface/dist`. Outputs +below are pasted, not summarised. + +| Finding | State | +|---|---| +| P0-1 derived-failure settlement race | **Fixed**, mutation-verified | +| P0-2 same escape through `allSettled` / `any` / `race` (**found during this repair**) | **Fixed**, mutation-verified | +| P1-A gate refuses valid authoring | **Fixed**, mutation-verified | +| P1-B gate is O(n²) and retains process-wide | **Fixed**, ladder measured, perf assertion added | +| P2-A `Promise.all` intrinsic patched with wrong semantics | **Semantics fixed; interception kept and now documented** — §5 | +| P2-B `close()` leaves maps populated | **Fixed**, test added | +| P3-A author cannot handle a step failure | Not attempted (deliberate fail-closed choice) | +| P3-B `live-kernel.test.ts` `statSync` | Fixed by #136 on main; gone | +| Rebase trap 1 (`output` on the verb allowlist) | **Came due and was checked** — §7.2 | +| Rebase trap 2 (`output` still *lowers* to a gate) | **Checked through `compileYaml` + `toKernelSpec`**, and the blindness of the other paths verified on this tree — §7.3 | + +--- + +## 1. The red reproduction, at ten ticks and at `setTimeout(0)` + +Written and run **before** any source change. + +`ops/probes/pr134-repair-0903/p0-derived-race.mjs` drives the real +`executeAuthoredFlow` against a loopback journal faithful to +`sdk/tests/journal-client-loopback.ts`. The three bodies are the same program; +only when the throw lands differs. + +```ts +flow(name, async (f) => { + const step = f.run('true'); + const consumed = Promise.resolve(step); + const derived = consumed.then(async () => { + await defer(); // 0 ticks | 10 ticks | setTimeout(0) + throw new Error(`derived post-processing failed (${name})`); + }); + derived.catch(() => undefined); // handled and forgotten + await consumed; // root legitimately consumed + f.done('success'); +}); +``` + +At `59c062cf`: + +``` +--- CONTROL immediate throw (0 ticks) [MUST refuse] + journal runs started: ["c0/run-1"] + terminal complete-* lowered: false + result: none error: operation_callback_failed: … flow "c0" derived handler for run-1 (f.run) rejected: derived post-processing failed (c0) +--- EXPLOIT 10 microtask ticks [MUST refuse] + journal runs started: ["x10/run-1","x10/complete-2"] + terminal complete-* lowered: true + result: "success" error: none +--- EXPLOIT setTimeout(0) [MUST refuse] + journal runs started: ["xt0/run-1","xt0/complete-2"] + terminal complete-* lowered: true + result: "success" error: none +``` + +As tests, against unmodified source, all five added tests failed: + +``` +$ cd sdk && ./node_modules/.bin/vitest run tests/authored-flow-lifecycle-executor.test.ts + + FAIL … > refuses a handled-and-forgotten derived failure deferred by ten microtask ticks + FAIL … > refuses a handled-and-forgotten derived failure deferred by setTimeout(0) +AssertionError: promise resolved "{ …(3) }" instead of rejecting + +- Expected ++ Received + +- [Error: rejected promise] ++ Object { ++ "completionReason": "success", ++ "journalSteps": Array [ ++ Object { "completionReason": "success", "id": "run-1", "runId": "lifecycle-run-8" }, ++ Object { "completionReason": "success", "id": "complete-2", "runId": "lifecycle-run-9" }, ++ ], ++ "name": "deferred-callback-failure", ++ } + + FAIL … > refuses terminal success while derived work is still in flight + FAIL … > preserves pre-constructed steps awaited in a loop +AuthoredFlowExecutionError: unawaited_step: flow "loop-awaited-steps" returned with unawaited steps: run-1 (f.run), run-2 (f.run) + FAIL … > preserves pre-constructed steps awaited out of order +AuthoredFlowExecutionError: unawaited_step: flow "out-of-order-awaited-steps" returned with unawaited steps: run-2 (f.run) + + Test Files 1 failed (1) + Tests 5 failed | 7 passed (12) +``` + +Green, after the fix: + +``` +$ node ops/probes/pr134-repair-0903/p0-derived-race.mjs +--- CONTROL immediate throw (0 ticks) [MUST refuse] + result: none error: operation_callback_failed: … flow "c0" derived handler for run-1 (f.run) rejected: derived post-processing failed (c0) +--- EXPLOIT 10 microtask ticks [MUST refuse] + journal runs started: ["x10/run-1"] + terminal complete-* lowered: false + result: none error: unsettled_derived_work: … flow "x10" completed while work derived from run-1 (f.run) was still in flight +--- EXPLOIT setTimeout(0) [MUST refuse] + journal runs started: ["xt0/run-1"] + terminal complete-* lowered: false + result: none error: unsettled_derived_work: … flow "xt0" completed while work derived from run-1 (f.run) was still in flight +``` + +--- + +## 2. The structural fix, and why it is not a widened window + +### 2.1 The question was wrong, not the window + +`observeCallbackFailures` asked **"which derived failures have already +landed?"** and skipped every promise that had not settled. That question has no +correct answer at any tick count, because its answer is a fact about the +scheduler, not about the program. + +The gate now asks a different question, before it awaits anything: + +> **When the body returned, was any promise attributed to an authored operation +> still pending?** + +That is causally determined, not timing determined: + +- derived work the author **awaited** is settled at body return **in every timing**; +- derived work the author **did not await** is pending at body return **in every timing**. + +There is no window and no number: the refusal is on the *existence* of +unfinished derived work, not on whether a failure has had time to arrive. + +The second half matters as much as the first. Once pending derived work is a +refusal, the **settled** set is complete — so reading the outcomes of the settled +promises stops being a sample and becomes a total answer over a closed set. That +is why the existing `operation_callback_failed` mechanism survives intact rather +than being replaced. + +`sdk/src/authored-flow-operation.ts`: + +```ts +// Read the in-flight set before the gate awaits anything: "was derived work +// still running when the body returned" is the question, and every await here +// would move the answer. +const inFlight = lifecycle.derivedWorkInFlight(operations); +``` + +and, after the root-failure, callback-failure and unawaited checks: + +```ts +if (inFlight.length > 0) { + throw new AuthoredFlowExecutionError( + 'unsettled_derived_work', + `flow "${flowName}" completed while work derived from ${inFlight.map(formatOperation).join(', ')} was still in flight`, + ); +} +``` + +It runs **after** `unawaited_step` deliberately: an unawaited step leaves plenty +pending, and `unawaited_step` names the author's actual mistake, which is what +Covenant 1 asks for. `unsettled_derived_work` is a new member of the closed +`AuthoredFlowExecutionErrorCode` set (Covenant 2 typed failure). + +### 2.2 The design was measured before it was written + +Before touching `sdk/src`, the pending-attributed set at body return was +instrumented on the **compiled `dist` only** — source untouched, `dist` then +rebuilt from unmodified source and byte-compared clean. If any ordinary +authoring shape had a non-empty pending set, the rule would be a false-positive +generator and the design would be wrong. + +``` +LEGIT direct await x1 pendingAttributed=[] +LEGIT direct await x3 pendingAttributed=[] +LEGIT loop-await preconstructed pendingAttributed=[] +LEGIT await Promise.resolve pendingAttributed=[] +LEGIT await Promise.all pendingAttributed=[] +LEGIT nested Promise.all/resolve pendingAttributed=[] +LEGIT awaited derived chain pendingAttributed=[] +LEGIT full supported-awaits body pendingAttributed=[] +EXPLOIT 10 microtask ticks pendingAttributed=[1734,1735,1758,1759,1762,1764,1765] +EXPLOIT setTimeout(0) pendingAttributed=[1871,1872,1895,1896,1898,1899] +EXPLOIT setTimeout(5) pendingAttributed=[1967,1968,1991,1992,1994,1995] +EXPLOIT direct await + deferred pendingAttributed=[2181,2184,2186] +``` + +### 2.3 The behaviour change, stated precisely + +Refusing pending derived work means **a flow may not call `done()` with +unawaited async work still running**. Awaited work of any shape is unaffected — +the over-attribution guard below is the evidence — but fire-and-forget work that +would have *succeeded* is now refused too, because a success nobody waited for +is a success the run cannot prove. Under Covenant 2 I believe that is right; it +is a real tightening and is now documented in `docs/SURFACE.md`. + +`ops/probes/pr134-repair-0903/negatives.mjs`: + +``` +step then 500 unrelated awaits then done [MUST PASS] -> PASSED(terminal=true) +step then unrelated timer awaited [MUST PASS] -> PASSED(terminal=true) +step then unrelated chain awaited [MUST PASS] -> PASSED(terminal=true) +two steps, unrelated work between [MUST PASS] -> PASSED(terminal=true) +awaited allSettled, derived chain awaited [MUST PASS] -> PASSED(terminal=true) +nested Promise.all over Promise.resolve [MUST PASS] -> PASSED(terminal=true) +step then UNRELATED fire-and-forget [observe] -> REFUSED unsettled_derived_work +``` + +### 2.4 Residual, reported not hidden + +The signoff's **B2** shape is still not caught: + +```ts +setTimeout(() => { p.then(bad).catch(() => undefined); }, 0); +``` + +The derived promise **does not exist yet** when the body returns, so nothing is +pending and there is nothing to refuse. This is not a tick-count gap a bigger +number would close — it is structurally outside the flow: work scheduled to +*begin* after completion. `B1`/`B6` (`p.then(async () => { await timer; throw })`) +are caught, because that promise does exist and is pending. Documented as the +boundary of the contract in `docs/SURFACE.md`. + +--- + +## 3. P0-2 — the same escape through `allSettled`, `any` and `race` + +**Found during this repair, at the lead's prompting.** The signoff listed C1/C4 +as "passed gate; no wrong outcome shown" and did not construct an exploit. I did, +and there was one. + +`ops/probes/pr134-repair-0903/combinators.mjs`, before the second fix: + +``` +Promise.allSettled awaited=PASSED deferred-derived-failure=PASSED(terminal=true) ignored=unawaited_step +Promise.any awaited=PASSED deferred-derived-failure=PASSED(terminal=true) ignored=unawaited_step +Promise.race awaited=PASSED deferred-derived-failure=PASSED(terminal=true) ignored=unawaited_step +Promise.all awaited=PASSED deferred-derived-failure=unsettled_derived_work ignored=unawaited_step +Promise.resolve awaited=PASSED deferred-derived-failure=unsettled_derived_work ignored=unawaited_step +``` + +Three combinators carried a swallowed derived failure to terminal success — the +same wrong outcome as P0-1. + +**Cause.** Attribution flowed from a root, and the only roots were the promise a +thenable job resolves (`Promise.resolve` assimilation) and explicitly registered +`Promise.all` aggregates. A combinator's aggregate is not downstream of any +member by `trigger`, so for the three unregistered combinators the aggregate was +never attributed, and `aggregate.then(bad)` was invisible. + +**Fix, and it is the general form of what the monkeypatch was doing by hand.** A +combinator resolves its aggregate from *inside the reaction of one of its +members* — a context that is already attributed. So the aggregate can inherit +attribution from the context that resolved it: + +```ts +const adopted = this.attributedRoots.get(cause); +if (adopted !== undefined) { + this.attribute(asyncId, adopted); + return; +} +``` + +That covers every combinator, present and future, without intercepting any of +them. After: + +``` +$ node ops/probes/pr134-repair-0903/combinators.mjs +Promise.allSettled awaited=PASSED(terminal=true) deferred-derived-failure=REFUSED unsettled_derived_work ignored=REFUSED unawaited_step +Promise.any awaited=PASSED(terminal=true) deferred-derived-failure=REFUSED unsettled_derived_work ignored=REFUSED unawaited_step +Promise.race awaited=PASSED(terminal=true) deferred-derived-failure=REFUSED unsettled_derived_work ignored=REFUSED unawaited_step +Promise.all awaited=PASSED(terminal=true) deferred-derived-failure=REFUSED unsettled_derived_work ignored=REFUSED unawaited_step +Promise.resolve awaited=PASSED(terminal=true) deferred-derived-failure=REFUSED unsettled_derived_work ignored=REFUSED unawaited_step +``` + +Pinned by `it.each` over all five combinators in +`sdk/tests/authored-flow-lifecycle-executor.test.ts`, with the over-attribution +negative (§2.3) as its counterweight. + +**Process note, because it bears on how much you should trust the rest.** My +first attempt at this fix silently did not apply: a shell `cp … && python3 …` +chain failed on the `cp`, so the edit never ran, and the suite I then reported as +green was unmodified source. I caught it by grepping the file for the change +rather than trusting the exit banner. Every claim in this report was +re-established afterwards against a build I verified contained the edit. + +--- + +## 4. P1-A — loop-await authoring now passes + +**Cause.** `dependenciesOf` walked `triggers` and `resolutionCauses`. **Neither +connects an async function's resumption context to the context it was suspended +from.** The walk from `done()` travels sideways into the last awaited promise's +lineage and stops; earlier awaits are siblings, not ancestors. Inline +construction happens to work because each later step's promise is *created in* +the previous resumption context; pre-constructed steps have no such link. That is +the measured 30 → 12 dependency-set collapse. + +Measured on plain `async_hooks`, no product code: + +``` +after await p executionAsyncId=5 +after await null executionAsyncId=10 +5 {"type":"PROMISE","trigger":3,"exec":0} +9 {"type":"PROMISE","trigger":2,"exec":5} <-- created IN context 5 +10 {"type":"PROMISE","trigger":9,"exec":5} +``` + +Id 9's `trigger` is the async function's own promise; the link back to the +previous resumption (5) exists only as the **init-time `executionAsyncId()`**. + +**Fix.** A third edge in `AuthoredPromiseGraph.dependenciesOf`: the context a +promise was created in. A fact the runtime reports, not a widened approximation. + +``` +$ node ops/probes/pr134-repair-0903/p1a-authoring.mjs +--- E1 create a,b; await a; await b [MUST pass] + journal runs started: ["e1/run-1","e1/run-2","e1/complete-3"] + terminal complete-* lowered: true + result: "success" error: none +--- E2 create a,b; await b; await a [MUST pass] + journal runs started: ["e2/run-2","e2/run-1","e2/complete-3"] + terminal complete-* lowered: true + result: "success" error: none +--- E5 array of steps awaited in a loop [MUST pass] + journal runs started: ["e5/run-1","e5/run-2","e5/run-3","e5/complete-4"] + terminal complete-* lowered: true + result: "success" error: none +--- E4 await Promise.all([a,b]) [MUST pass] + journal runs started: ["e4/run-1","e4/run-2","e4/complete-3"] + terminal complete-* lowered: true + result: "success" error: none +--- S4 ignored Promise.resolve(step) [MUST refuse] + result: none error: unawaited_step: … flow "s4" returned with unawaited steps: run-1 (f.run) +--- S4b ignored Promise.all([a,b]) [MUST refuse] + result: none error: unawaited_step: … flow "s4b" returned with unawaited steps: run-1 (f.run), run-2 (f.run) +--- C5 withResolvers forgery [MUST refuse] + result: none error: unawaited_step: … flow "c5" returned with unawaited steps: run-1 (f.run) +--- C8 forged toString + manual .then [MUST refuse] + result: none error: unawaited_step: … flow "c8" returned with unawaited steps: run-1 (f.run) +--- D4 done() from a setTimeout after body ret [MUST refuse] + result: none error: unawaited_step: … flow "d4" returned with unawaited steps: run-1 (f.run) +--- IIFE detached await [MUST refuse] + result: none error: unawaited_step: … flow "iife" returned with unawaited steps: run-1 (f.run) +``` + +E1/E2/E5 were all `unawaited_step` at `59c062cf`. Every refusal the signoff +credited is still a refusal; `bound` and `invokeResolver`'s `promiseResolve` +probe are untouched, so the forgery class stays closed. + +Supporting measurement — why the reachability half cannot simply be dropped in +favour of `bound`: + +``` +awaited Promise.resolve(step) a: bound=true +IGNORED Promise.resolve(step) a: bound=true +awaited Promise.all([a,b]) a: bound=true b: bound=true +IGNORED Promise.all([a,b]) a: bound=true b: bound=true +manual .then(cb) not awaited a: bound=false +``` + +`bound` cannot separate an ignored assimilation from an awaited one. + +--- + +## 5. P1-B — timing ladder before and after + +`ops/probes/pr134-repair-0903/gate-cost.mjs`: one authored step, then N ordinary +`await Promise.resolve(i)` in the body. `dist-base` is `59c062cf`'s source +compiled to a second outDir in the same worktree. + +``` +$ for n in 100 500 1000 2000 5000 30000; do node gate-cost.mjs dist-base $n; done +dist-base awaits=100 body=1ms verifyAuthoredOperations=3ms verdict=PASSED +dist-base awaits=500 body=1ms verifyAuthoredOperations=25ms verdict=PASSED +dist-base awaits=1000 body=2ms verifyAuthoredOperations=86ms verdict=PASSED +dist-base awaits=2000 body=2ms verifyAuthoredOperations=303ms verdict=PASSED +dist-base awaits=5000 body=4ms verifyAuthoredOperations=1803ms verdict=PASSED +dist-base awaits=30000 body=25ms verifyAuthoredOperations=94700ms verdict=PASSED + +$ for n in 100 500 1000 2000 5000 30000; do node gate-cost.mjs dist $n; done +dist awaits=100 body=0ms verifyAuthoredOperations=1ms verdict=PASSED +dist awaits=500 body=2ms verifyAuthoredOperations=2ms verdict=PASSED +dist awaits=1000 body=2ms verifyAuthoredOperations=3ms verdict=PASSED +dist awaits=2000 body=3ms verifyAuthoredOperations=5ms verdict=PASSED +dist awaits=5000 body=7ms verifyAuthoredOperations=11ms verdict=PASSED +dist awaits=30000 body=34ms verifyAuthoredOperations=34ms verdict=PASSED +``` + +Quadratic → linear; 94 700 ms → 34 ms at 30 000 (2785×). The baseline column +reproduces the signoff's measurement (1768 ms / 95 766 ms) on this host. + +Retention, `ops/probes/pr134-repair-0903/promise-all-semantics.mjs` — 20 000 +promises created **outside** the flow body: + +``` +dist-base tracked promises: before=48 after 20000 unrelated=140055 (delta=140007) +dist tracked promises: before=0 after 20000 unrelated=0 (delta=0) +``` + +**Why it is linear now.** Two changes in the new +`sdk/src/authored-promise-graph.ts`: promises are recorded only while the owning +lifecycle is the active `AsyncLocalStorage` store, and attribution is eager and +O(1) per promise instead of a `triggerDescendsFrom` walk per candidate per root +at completion. Attribution flows *forward* along the same `trigger` edge the old +code walked *backward*, so it admits the same set — it just knows the answer +before it is asked. Children created before their parent becomes a root are +parked and flooded on registration; a promise cannot settle before its ancestors, +and a root is always registered before it settles, so no parked child is missed. + +**Performance assertion.** `sdk/tests/authored-flow-operation.test.ts` asserts +the 30 000-await gate completes in **under 1 000 ms** — ~30× the measured 34 ms +and ~95× under the old cost, so nothing quadratic can meet it. Plus a test +asserting zero tracked promises after 5 000 created outside the body. + +--- + +## 6. Decision on the `Promise.all` monkeypatch + +**Kept, semantics fixed, and now disclosed in the docs.** Ratified by the lead. + +Measured reason, not asserted: with the interception disabled, `await +Promise.all([a, b])` fails on the non-final member. + +``` +--- E4 await Promise.all([a,b]) [MUST pass] + journal runs started: ["e4/run-1","e4/run-2"] + terminal complete-* lowered: false + error: unawaited_step: flow "e4" returned with unawaited steps: run-1 (f.run) +``` + +`Promise.all` resolves its aggregate inside the reaction of the **last** member, +so `resolutionCause(aggregate)` reaches that member and no other. Non-final +members are siblings of the aggregate, not ancestors. Every way I found to +recover that edge — comparing the shared `onrejected` the combinator hands each +element, for instance — is callback-identity inference, which reopens the exact +forgery class this PR closed. + +Note the asymmetry with §3: the *derived-work* question is solved generically by +aggregate adoption, with no interception. The *reachability to `done()`* question +is not, because adoption tells you the aggregate is downstream of **some** +member, not of **every** member. That is precisely what the registration +supplies, and why it is still here. + +Semantics, before and after: + +``` +$ node ops/probes/pr134-repair-0903/promise-all-semantics.mjs dist-base +dist-base Promise.all patched during a flow: true + patched Promise.all(null) -> THREW SYNCHRONOUSLY: TypeError + patched Promise.all(null) -> rejected: TypeError + patched Promise.all(5) -> RESOLVED [] + patched Promise.all([1,P2]) -> RESOLVED [1,2] + patched Promise.all.name="observedPromiseAll" length=1 + Promise.all restored after close: true + +$ node ops/probes/pr134-repair-0903/promise-all-semantics.mjs dist +dist Promise.all patched during a flow: true + patched Promise.all(null) -> rejected: TypeError + patched Promise.all(5) -> rejected: TypeError + patched Promise.all([1,P2]) -> RESOLVED [1,2] + patched Promise.all.name="all" length=1 + Promise.all restored after close: true +``` + +**Disclosure.** `docs/SURFACE.md` §2 now carries "The authored operation +lifecycle", stating the replacement, why it exists, that its scope is +process-wide for the duration of an authored run, that it is specified to behave +identically, and that the declarative YAML path does not install it. +`surface/README.md` points at it. The remaining residue — `Promise.all` is not +identity-equal to the original while a flow is open, and a one-shot iterable is +drained by the wrapper — is real and is not claimed away. + +--- + +## 7. The rebase, and the `output` trap + +### 7.1 Rebase onto `990093b` + +Main moved to `512723c` (#138) during this work. **Not chased**, per the standing +rail: this branch is finished against `990093b`, and #139 is sequenced ahead in +the rebase queue because these PRs share `compile.ts`, `spec.ts` and +`step-fields.ts`. + + +Rebased from `3da71e2` onto `990093b`. **Nine commits, zero conflicts** — which +is when a silent drop is most likely, so it was checked rather than assumed. + +``` +$ git log --oneline 990093b..HEAD | wc -l +9 +``` + +No commit replayed empty. `docs/SURFACE.md` and `sdk/src/index.ts` are the only +two files touched by both #136 and this branch; both auto-merged and both were +read, not trusted: + +``` +$ git diff 990093b HEAD -- sdk/src/index.ts ++export { ++ getAuthoredFlowDefinition, ++ type AuthoredFlowDefinition, ++ type FlowHandle, ++} from './authored-flow.js'; +``` + +Pure addition; main's `JsonOutputSchema`, `RunCancelParams` and `RunCancelResult` +are intact and no export name is duplicated. `docs/SURFACE.md` vs main is only +this branch's two completion-vocabulary corrections (`no_work` → `success`, +`declined` → `canceled`) plus the new lifecycle section; no duplicated heading, +and #136's content is intact. + +**Blob-hash check of #136's artifacts**, per the lead's instruction. Of the 53 +files #136 touched, this branch touches 2 (above); the other 51 must be +byte-identical to `990093b`: + +``` +ALL 51 BLOBS IDENTICAL TO 990093b + (files compared: 51) +``` + +**Deleted-line attribution.** `git diff 990093b HEAD` deletes 140 lines. Every +one is attributed; none belongs to #136 or to main's work: + +| Lines | Where | Attribution | +|---:|---|---| +| ~130 | `regressions/surface.d.ts` (deleted) + its references in `regressions/{MANIFEST.json,README.md,tsconfig.json}` | **Intended by the file itself.** Its header read "when the real surface exports these shapes, delete this file and the suite compiles against the SDK unchanged." #134 ships that surface; deletion happens in #134's own first commit `aa82994`. The regressions now typecheck against `@relayflows/surface` source through a path alias — `bun run typecheck:regressions` exits 0 | +| 8 | `f.done("bug_fixed")` / `f.done("bug_reproduced")` in the regression flows | Not members of the closed `RunCompletionReason` set; replaced with `f.done("success")`. Covenant 2 vocabulary | +| 2 | `docs/SURFACE.md` | Same vocabulary correction (`no_work` → `success`, `declined` → `canceled`) | +| 1 | `.github/workflows/cloud-runtime-artifact.yml` | `npm ci --prefix sdk` → `--ignore-scripts`, with the surface build ordered before it (the conflict resolution) | +| 1 | `sdk/tsconfig.tests.json` | The `include` widening — §7.5 | + +### 7.2 The trap came due, and did not fire + +At `3da71e2` there was no `step-fields.ts`; it arrives with #136, so the deferred +trap is now live. Checked: + +``` +$ sed -n '/STEP_FIELDS_BY_TYPE/,/satisfies/p' sdk/src/step-fields.ts +export const STEP_FIELDS_BY_TYPE = { + deterministic: ['command'], + llm: ['prompt', 'model', 'cli', 'output'], + agent: ['instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'], +} as const satisfies Record; + +$ grep -n "STEP_FIELDS_BY_TYPE\|STEP_TYPE_KEYS" sdk/src/validate.ts +27: STEP_COMMON_FIELDS, +28: STEP_FIELDS_BY_TYPE, +244: this.checkKeys(st, [...STEP_COMMON_FIELDS, ...STEP_FIELDS_BY_TYPE[type]], at); +``` + +`output` present on `llm` and `agent`, absent from `deterministic`, single source +consumed by `validate.ts`, no surviving `STEP_TYPE_KEYS`. The only other per-verb +lists in the tree are `sdk/tests/journal-client-loopback.ts` (the kernel-dialect +mirror — correctly without `output`, because `compile.ts:403-407` lowers `output` +to `verification.json_schema` and never emits it as a kernel key) and main's own +`sdk/tests/verb-field-lint.test.ts` guard. + +### 7.3 The `output` proof — one witness, two that cannot see + +The brief originally asked for three independent witnesses. It is not three: the +lead's later correction, established on #139, is that **`validateSpec` and +`flows check` both report a healthy gate when the *lowering* has been reverted.** +Only `compileYaml` + `toKernelSpec` distinguishes "the key was accepted" from +"the key became a gate". I verified that claim on this tree rather than taking +it, because it changes what the proof is worth. + +**Trap 1 — is `output` still on the allowlist?** §7.2: yes. + +**Trap 2 — does `output` still become a gate?** Different file: +`sdk/src/compile.ts`, in `compileStep`'s base/per-verb split. `base` computes +`typedOutputVerification(step)` and spreads it; `llm` and `agent` inherit it +through `...base`, while `deterministic` shadows it with the implicit +`exit_code` gate. Losing one line from `base` silently un-gates both verbs. + +`compile.ts` did not enter a conflict **or an auto-merged hunk** on this rebase — +it is byte-identical to main and untouched by this branch: + +``` +$ git diff --name-only 990093b HEAD -- sdk/src/compile.ts +(empty) +$ git ls-tree 990093b -- sdk/src/compile.ts | awk '{print $3}' +35df7b3883fd5bbf813cd9700a7af7b4ae28c5e0 +$ git ls-tree HEAD -- sdk/src/compile.ts | awk '{print $3}' +35df7b3883fd5bbf813cd9700a7af7b4ae28c5e0 +``` + +**PRIMARY assertion — the lowering, through `compileYaml` + `toKernelSpec`:** + +``` +$ node ops/probes/pr134-repair-0903/verb-output-fields.mjs /tmp/pr134fixtures +--- PRIMARY: kernel verification emitted for each verb + llm emitted verification = {"json_schema":{"type":"object","properties":{"verdict":{"type":"string"}},"required":["verdict"]}} -> IS THE DECLARED json_schema GATE OK + agent emitted verification = {"json_schema":{"type":"object","properties":{"verdict":{"type":"string"}},"required":["verdict"]}} -> IS THE DECLARED json_schema GATE OK + deterministic REFUSED at compile: spec compile failed: -> OK (deterministic must not accept output) +``` + +**Supporting witnesses** (`validateSpec`, `compileYaml` acceptance, `flows check`): + +``` +--- PATH 1 (supporting): validateSpec + llm -> ACCEPT OK + agent -> ACCEPT OK + deterministic -> REFUSE OK spec.steps[0]: unknown key "output" (expected one of id | type | dependsOn | verification | maxIterations | timeoutMs | command) +--- PATH 2: compileYaml + llm -> ACCEPT OK + agent -> ACCEPT OK + deterministic -> REFUSE OK spec compile failed: / - spec.steps[0]: unknown key "output" (…) + +$ for f in llm agent deterministic; do node sdk/dist/cli.js check /tmp/pr134fixtures/$f.flow.yaml; echo exit=$?; done + flows check llm exit=0 -> CHECK PASSED + flows check agent exit=0 -> CHECK PASSED + flows check deterministic exit=2 -> REFUSED [invalid_spec] spec.steps[0]: unknown key "output" (…) +``` + +**Proof that the supporting witnesses really are blind.** I removed +`...(verification !== undefined ? { verification } : {})` from `compileStep`'s +`base` — the single line that carries the lowering — and re-ran all four: + +``` +--- PATH 1 (supporting): validateSpec + llm -> ACCEPT OK <-- BLIND + agent -> ACCEPT OK <-- BLIND +--- PATH 2: compileYaml + llm -> ACCEPT OK <-- BLIND + agent -> ACCEPT OK <-- BLIND +--- PRIMARY: kernel verification emitted for each verb + llm emitted verification = {} -> NOT A json_schema GATE WRONG <-- CAUGHT + agent emitted verification = {} -> NOT A json_schema GATE WRONG <-- CAUGHT +=== flows check (supporting) === + llm exit=0 -> CHECK PASSED <-- BLIND + agent exit=0 -> CHECK PASSED <-- BLIND +``` + +Confirmed on this tree: three of the four report a healthy gate while the +lowering is gone. Only the kernel-spec assertion sees it. + +**One reassurance the correction did not claim, which I checked anyway:** main's +own suite is *not* blind. Under the same mutation: + +``` +$ ./node_modules/.bin/vitest run tests/typed-output.test.ts tests/spec-parity.test.ts tests/validate.test.ts + × typed llm and agent outputs > compiles llm output sugar to the existing json_schema primitive + × typed llm and agent outputs > compiles agent output sugar to the existing json_schema primitive + × typed llm and agent outputs > accepts the output declaration in YAML + × typed llm and agent outputs > keeps the canonical hn-monitor kernel step unchanged + × spec parity … > compiles hello-ladder to the pinned canonical JSON (and 6 more) + ✓ tests/validate.test.ts (36 tests) + Tests 11 failed | 54 passed (65) +``` + +`typed-output` and `spec-parity` catch it; `validate` does not — exactly the +blindness pattern. So trap 2 is guarded in this repo **provided the full SDK +suite is run**, which this branch does and which flows CI does not. + +`src/compile.ts` restored byte-for-byte — +`8b0eb324880d7400d71c4ef44a971f3e7ee6d28ea3b1b68020a8d23c9fa17097` before and +after, `git diff HEAD -- src/compile.ts` empty — and all four paths re-run green +(above). + +*Correction to my own earlier evidence:* my first fixture declared +`output: { schema: … }`. That compiles, but it is not the documented shape — +`docs/SURFACE.md` puts the JSON Schema directly under `output`. The probe now +uses the documented shape, which is why the emitted gate matches the declared +schema exactly rather than nesting it. + +### 7.4 Files on only one side — the full list checked, not only the hits + +Merge base `a0d42ff`. **Added on main only** (31): `examples/README.md`, +`examples/research/**` (22), `kernel/relayflowd-core/src/machine/cancel.rs`, +`kernel/relayflowd/src/server/cancel.rs`, four `ops/reviews/*pr133*|*run-cancel*`, +`sdk/src/output-schema.ts`, `sdk/tests/typed-output.test.ts`, +`sdk/tsconfig.tests.json` — plus, from #136: `sdk/src/step-fields.ts`, +`sdk/src/model-name.ts`, `sdk/src/step-dependencies.ts`, `sdk/src/unknown-keys.ts`, +`sdk/src/cli-adapter.ts`, `sdk/src/worker-cli.ts`, `sdk/src/wrapper-runtime.ts`, +`sdk/src/wrapper-session.ts`, `sdk/tests/verb-field-lint.test.ts`, +`sdk/tests/model-selection.test.ts`, `sdk/tests/cli-adapter.test.ts`, +`sdk/tests/real-cli-adapters.test.ts`, `sdk/tests/worker-cli.test.ts`. + +**Added on the branch only** (27): `.github/workflows/surface-package.yml`, +`ops/pr134-lifecycle-repair-evidence.md`, +`ops/reviews/20260902-2035-pr134-structure.md`, `scripts/surface-package-gate.sh`, +`sdk/src/authored-flow-{error,executor,lifecycle,operation}.ts`, +`sdk/src/authored-flow.ts`, `sdk/tests/authored-flow{,-lifecycle-executor,-operation}.test.ts`, +`sdk/tests/fixtures/runtime-bridge.flow.ts`, +`surface/src/{cloud,completion,context,flow,index,runtime,step}.ts`, +`surface/{README.md,bun.lock,package.json,tsconfig.json,tsconfig.test.json,vitest.config.ts}`, +`surface/tests/flow.test.ts`. + +| One-sided file | Same concern changed elsewhere? | Checked how | Result | +|---|---|---|---| +| `sdk/src/step-fields.ts` (main, #136) | the verb allowlist this branch's compiler path depends on | §7.2 | `output` intact; single source | +| `surface/src/completion.ts` (branch) | main modified `sdk/src/protocol.ts` | `git diff a0d42ff 990093b -- sdk/src/protocol.ts` | Main added `run.cancel` params/verb only; **no completion reason changed**. The executor's `Assert>` twins compile clean, so a divergence would be a type error | +| `surface/src/step.ts`, `context.ts`, `flow.ts` (branch) | main changed per-verb step fields | `grep -rn "deterministic:\s*\[" sdk/src surface/src sdk/tests` | Only `step-fields.ts`, the loopback mirror and main's lint test carry per-verb lists. The surface carries **types**, not an allowlist | +| `sdk/tests/journal-client-loopback.ts` (main-modified; branch tests import it) | should `byType` carry `output`? | `grep -n output sdk/src/compile.ts` | **No** — `output` lowers to `verification.json_schema` and is never a kernel key. Main's only change was `run.cancel` | +| `sdk/src/output-schema.ts` (main) | does the branch restate output validation? | `grep -rn output surface/src` | No | +| `sdk/src/model-name.ts`, `cli-adapter.ts`, `worker-cli.ts`, `wrapper-*.ts` (main, #136) | does the branch touch CLI/model resolution? | branch file list | No overlap | +| `sdk/tsconfig.tests.json` (main) | does main's test-typecheck gate cover the branch's tests? | ran it | **A real hit — §7.5** | +| `sdk/src/index.ts` (both) | auto-merge kept both sides? | §7.1 | Both kept, no duplicate export | +| `sdk/package.json` (both) | scripts from both sides? | `grep -A10 '"scripts"'` | Both: main's `typecheck:tests` and `test` chain, and the branch's `file:` surface dependency | +| `docs/SURFACE.md` (both) | auto-merge lost or duplicated prose? | §7.1 | Neither | +| `.github/workflows/cloud-runtime-artifact.yml` (both) | CI ordering of the surface build | resolved on the `3da71e2` pass; replayed clean here | Both sides kept, ordered | +| `kernel/**/cancel.rs` (main) | does the branch touch the kernel? | `git diff --name-only 990093b HEAD \| grep ^kernel/` | Empty | +| `examples/research/**` (main) | branch overlap? | file lists | None | + +### 7.5 The hit: main's test-typecheck gate had a hole, and #134's tests were in it + +`sdk/tsconfig.tests.json` arrived from main including only +`src/**/*.ts` and `tests/typed-output.test.ts`. `tsconfig.json` excludes +`tests/`, and vitest transpiles without typechecking — so **#134's test files +were type-checked by nothing.** Widening the include exposed four errors, three +pre-existing at `59c062cf`: + +``` +tests/authored-flow-lifecycle-executor.test.ts(87,32): error TS2550: Property 'withResolvers' does not exist on type 'PromiseConstructor'… +tests/authored-flow-operation.test.ts(46,42): error TS2345: Argument of type 'AuthoredFlowOperation[]' is not assignable to parameter of type 'readonly AuthoredFlowOperation[]'… +tests/authored-flow-operation.test.ts(55,32): error TS2550: Property 'withResolvers' does not exist… +tests/authored-flow-operation.test.ts(148,51): error TS2345: … +tests/authored-flow-operation.test.ts(185,54): error TS2345: … +``` + +Fixed by declaring the operation arrays as `AuthoredFlowOperation[]` +with the same cast `authored-flow-executor.ts` already uses in `trackStep`, and +by adding **`"lib": ["ES2022", "ES2024.Promise"]` to `tsconfig.tests.json` only** +— the SDK's own `tsconfig.json` stays on ES2022. `Promise.withResolvers` is kept +at runtime because it is the specific forgery that test exists to refuse. The +gate now covers `tests/authored-flow{,-lifecycle-executor,-operation}.test.ts` +and `tests/journal-client-loopback.ts`, and passes. + +--- + +## 8. Mutation verification + +Revert the specific change, run the specific test, capture the failure, restore +byte-for-byte, re-run, capture the pass. Three mutations, with `shasum -a 256` +proving each restore. + +### 8.1 P0-1 — the in-flight refusal + +`src/authored-flow-operation.ts`, `bd4075df7275379aff838c5ecebbd6647851f390de9b510caf554bf8cefcdf46` +before and after. `if (inFlight.length > 0) { throw … }` → `void inFlight;`: + +``` + × … refuses a handled-and-forgotten derived failure deferred by ten microtask ticks 17ms + → promise resolved "{ …(3) }" instead of rejecting + × … refuses a handled-and-forgotten derived failure deferred by setTimeout(0) 2ms + → promise resolved "{ …(3) }" instead of rejecting + × … refuses terminal success while derived work is still in flight 2ms + → promise resolved "{ name: 'in-flight-derived-work', …(2) }" instead of rejecting + Tests 3 failed | 9 passed (12) +``` + +Restored → `12 passed (12)`. + +### 8.2 P1-A — the creation-context edge + +`src/authored-promise-graph.ts`, +`751648c21d5eefd91aa100b38f994e80611f2b52d94a1c29f27ba79704522db8` before and +after. `this.creationContexts.get(current)` removed from the edge list: + +``` + × … preserves pre-constructed steps awaited in a loop 10ms + → unawaited_step: flow "loop-awaited-steps" returned with unawaited steps: run-1 (f.run), run-2 (f.run) + × … preserves pre-constructed steps awaited out of order 2ms + → unawaited_step: flow "out-of-order-awaited-steps" returned with unawaited steps: run-2 (f.run) + Tests 2 failed | 10 passed (12) +``` + +Restored → `12 passed (12)`. + +### 8.3 P0-2 — aggregate adoption + +`src/authored-promise-graph.ts`, +`b497292414318c667e2250e14f66375a6f0332921b9ec8eb1b0a8941e4edd9c5` before and +after. The `adopted` block removed: + +``` + × … refuses a deferred derived failure consumed through Promise.allSettled 4ms + → promise resolved "{ name: 'combinator-allSettled', …(2) }" instead of rejecting + × … refuses a deferred derived failure consumed through Promise.any 1ms + → promise resolved "{ name: 'combinator-any', …(2) }" instead of rejecting + × … refuses a deferred derived failure consumed through Promise.race 1ms + → promise resolved "{ name: 'combinator-race', …(2) }" instead of rejecting + Tests 3 failed | 15 passed (18) +``` + +Restored: + +``` +$ shasum -a 256 src/authored-promise-graph.ts +b497292414318c667e2250e14f66375a6f0332921b9ec8eb1b0a8941e4edd9c5 src/authored-promise-graph.ts + ✓ tests/authored-flow-lifecycle-executor.test.ts (18 tests) 455ms + Tests 18 passed (18) +``` + +--- + +## 9. Gates + +``` +$ cd surface && bun run test +$ bun run build && tsc -p tsconfig.test.json && vitest run +$ tsc + ✓ tests/flow.test.ts (6 tests) 3ms + Test Files 1 passed (1) + Tests 6 passed (6) + +$ cd surface && bun run typecheck:regressions +$ tsc -p ../regressions/tsconfig.json +regressions typecheck exit 0 + +$ cd sdk && ./node_modules/.bin/tsc --noEmit +tsc --noEmit exit 0 + +$ cd sdk && ./node_modules/.bin/tsc -p tsconfig.tests.json +tsc -p tsconfig.tests.json exit 0 + +$ cd sdk && ./node_modules/.bin/vitest run # final run, committed tree + Test Files 25 passed | 1 skipped (26) + Tests 423 passed | 3 skipped (426) +``` + +An earlier run of the same tree reported two failures, both in +`tests/live-kernel.test.ts`: + +``` + FAIL tests/live-kernel.test.ts > built flows CLI against live relayflowd > cancels over the real socket and rejects the lease holder after closure + FAIL tests/live-kernel.test.ts > JournalClient wire conformance against live relayflowd > exercises every protocol-v0 verb with the real server + Test Files 1 failed | 24 passed | 1 skipped (26) + Tests 2 failed | 421 passed | 3 skipped (426) +``` + +They are non-deterministic and environmental — §9.2. Both results are recorded +because reporting only the green one would misrepresent the suite. + +### 9.1 Accounting against main + +Main at `990093b`, measured in a throwaway worktree with `sdk/node_modules` +symlinked: + +``` + Test Files 22 passed | 1 skipped (23) + Tests 370 passed | 3 skipped (373) +``` + +This branch adds exactly three test files, all of them #134's: + +| File | main | #134 at 59c062cf | this branch | repair Δ | +|---|---|---|---|---| +| `authored-flow.test.ts` | — | 12 | 12 | 0 | +| `authored-flow-operation.test.ts` | — | 19 | 23 | **+4** (perf assertion, out-of-scope tracking, close-releases, `Promise.all` semantics) | +| `authored-flow-lifecycle-executor.test.ts` | — | 7 | 18 | **+11** (2 deferred-failure, 1 in-flight, 2 loop-await, 5 combinator, 1 over-attribution) | +| **total** | **370 + 3 skipped** | 38 | **53** | **+15** | + +370 + 53 = 423, which is exactly the final run's passing count. Every count +reconciles. No test lost or weakened. + +### 9.2 The two failures are environmental, and that is measured not asserted + +`tests/live-kernel.test.ts` runs the built `flows` CLI against a real long-lived +`relayflowd`, and the two failures seen in one run of this branch reproduce on +**pure main `990093b`**, in a clean worktree +containing none of this branch's code, with the identical assertion: + +``` +$ cd /tmp/pr134mainwt/sdk && ./node_modules/.bin/vitest run tests/live-kernel.test.ts +AssertionError: expected JournalProtocolError: run_terminal: run 0… { code: '…' } to match object { code: 'lease_conflict' } ++ "code": "run_terminal", +JournalProtocolError: run_terminal: run 01M1KJRKK6T4R2V5Y385WBGR2V is terminal and cannot accept mutations + Test Files 1 failed (1) + Tests 2 failed | 19 passed (21) +``` + +They are also **not deterministic**: an earlier full-suite run in this same +session passed all 21 live-kernel tests on both main and this branch. They are +stateful against the shared daemon — the class the brief flagged as "daemons +predate #142". Nothing in this branch is reachable from them: `executeAuthoredFlow` +is deliberately not exported by the SDK. + +The `statSync` collection failure the signoff reported is **gone**: #136 fixed +that import on main, and `live-kernel.test.ts` now collects and runs. + +### 9.3 Not run + +- **Kernel Rust suite.** `git diff --name-only 990093b HEAD | grep ^kernel/` is + empty — no kernel file is in this PR. +- **`scripts/surface-package-gate.sh` end to end.** It needs + `bun install --frozen-lockfile` and `npm ci`, which hang on this host. Its + runnable prefix — `bun run build`, `bun run test`, `bun run typecheck:regressions` + — was run and passes. +- **GitHub CI is not evidence for this change.** flows CI runs + `linux-x64-artifact` and `packed-consumer` only; neither runs the SDK suite. + +--- + +## 10. What I could not close + +- **Signoff B2** — a derived chain created inside a `setTimeout` that fires after + the body returns (§2.4). Structurally outside the flow, now documented in + `docs/SURFACE.md` as the boundary of the contract. +- **The `Promise.all` interception remains** (§6), for the reachability half of + the contract. Now disclosed in the docs rather than only corrected. +- **P3-A** — `try { await f.run('false'); } catch {}` still fails the flow. + Deliberate and correct for a `done("success")`-only executor; not attempted. +- **Fire-and-forget work after a step is now refused even when it would have + succeeded** (§2.3). A deliberate tightening, documented, and worth a second + opinion rather than discovery. diff --git a/sdk/src/authored-flow-error.ts b/sdk/src/authored-flow-error.ts index 540b739c5..901e6c759 100644 --- a/sdk/src/authored-flow-error.ts +++ b/sdk/src/authored-flow-error.ts @@ -13,6 +13,7 @@ export type AuthoredFlowExecutionErrorCode = | 'unsupported_completion' | 'unsupported_gate' | 'unsupported_header' + | 'unsettled_derived_work' | 'unsupported_promise_lifecycle' | 'unawaited_step' | 'unsupported_verb'; diff --git a/sdk/src/authored-flow-lifecycle.ts b/sdk/src/authored-flow-lifecycle.ts index 8dac7a9b6..f0a93e49a 100644 --- a/sdk/src/authored-flow-lifecycle.ts +++ b/sdk/src/authored-flow-lifecycle.ts @@ -1,10 +1,6 @@ -import { - AsyncLocalStorage, - createHook, - executionAsyncId, - type AsyncHook, -} from 'node:async_hooks'; +import { AsyncLocalStorage, executionAsyncId } from 'node:async_hooks'; import { AuthoredFlowExecutionError } from './authored-flow-error.js'; +import { AuthoredPromiseGraph } from './authored-promise-graph.js'; type OperationToken = object; type ResolverProbe = Set; @@ -26,15 +22,47 @@ const stepOwners = new WeakMap(); let promiseAllObservers = 0; +/** + * A `Promise.all` that records which authored operations an aggregate joins. + * + * This intercepts an intrinsic, which is a real cost and is documented as such + * in `ops/reviews/20260903-pr134-repair-0903.md`. It is here because a + * combinator's aggregate has no runtime edge to its *non-final* members: the + * aggregate's resolution cause reaches only the last element to settle, so + * without this registration `await Promise.all([a, b])` reports `a` unawaited. + * Every alternative that recovers the link — comparing the `onrejected` + * callbacks the combinator passes each element, for instance — is callback + * identity inference, which is exactly the forgery class this contract closed. + * + * Where it previously deviated from the specification it no longer does: a + * non-iterable argument is handed straight to the intrinsic so it produces the + * specified rejected promise rather than resolving `[]` or throwing + * synchronously. + */ const observedPromiseAll = function ( this: PromiseConstructor, values: Iterable>, ): Promise[]> { - const members = Array.from(values); + const passThrough = (): Promise[]> => + nativePromiseAll.call(this, values as unknown as readonly unknown[]) as Promise[]>; + if (!isIterable(values)) return passThrough(); + let members: (T | PromiseLike)[]; + try { + members = Array.from(values); + } catch { + return passThrough(); + } const aggregate = nativePromiseAll.call(this, members) as Promise[]>; activeLifecycle.getStore()?.registerPromiseAll(members, aggregate); return aggregate; }; +Object.defineProperty(observedPromiseAll, 'name', { value: 'all', configurable: true }); +Object.defineProperty(observedPromiseAll, 'length', { value: 1, configurable: true }); + +function isIterable(value: unknown): value is Iterable { + if (value === null || value === undefined) return false; + return typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'; +} /** * Runtime proof that a root operation participates in the continuation which @@ -43,12 +71,7 @@ const observedPromiseAll = function ( * called the operation's then method. */ export class AuthoredFlowLifecycle { - private readonly hook: AsyncHook; - private readonly triggers = new Map(); - private readonly resolutionCauses = new Map(); - private readonly promises = new Map>(); - private readonly promiseIds = new WeakMap(); - private readonly settledPromises = new Set(); + private readonly graph: AuthoredPromiseGraph; private readonly activeResolverProbes: ResolverProbe[] = []; private readonly invocations = new Map(); private readonly promiseAllAggregates = new Map>(); @@ -58,23 +81,15 @@ export class AuthoredFlowLifecycle { private closed = false; constructor() { - this.hook = createHook({ - init: (asyncId, type, triggerAsyncId, resource) => { - if (type !== 'PROMISE' || typeof resource !== 'object' || resource === null) return; - this.triggers.set(asyncId, triggerAsyncId); - this.promises.set(asyncId, resource as Promise); - this.promiseIds.set(resource, asyncId); - }, - promiseResolve: (asyncId) => { - this.settledPromises.add(asyncId); - const cause = executionAsyncId(); - if (cause !== asyncId) this.resolutionCauses.set(asyncId, cause); + this.graph = new AuthoredPromiseGraph( + () => activeLifecycle.getStore() === this, + (asyncId) => { for (const probe of this.activeResolverProbes) probe.add(asyncId); }, - }); + ); installPromiseAllObserver(); try { - this.hook.enable(); + this.graph.enable(); } catch (error) { uninstallPromiseAllObserver(); throw error; @@ -90,12 +105,13 @@ export class AuthoredFlowLifecycle { } registerPromiseAll(values: readonly unknown[], aggregate: Promise): void { - const aggregateId = this.promiseIds.get(aggregate); + const aggregateId = this.graph.idOf(aggregate); if (aggregateId === undefined) return; + this.graph.registerRoot(aggregateId); const memberIds = new Set(); for (const value of values) { if ((typeof value !== 'object' && typeof value !== 'function') || value === null) continue; - const memberId = this.promiseIds.get(value); + const memberId = this.graph.idOf(value); if (memberId !== undefined) memberIds.add(memberId); const owner = stepOwners.get(value); if (owner?.lifecycle !== this) continue; @@ -117,6 +133,7 @@ export class AuthoredFlowLifecycle { const operationInvocations = this.invocations.get(operation); if (operationInvocations === undefined) this.invocations.set(operation, [invocation]); else operationInvocations.push(invocation); + this.graph.registerRoot(asyncId); return invocation; } @@ -157,24 +174,39 @@ export class AuthoredFlowLifecycle { const aggregates = this.aggregatesFor(operation); return operationInvocations.length > 0 && operationInvocations.every((invocation) => invocation.bound && ( - this.dependsOn(completion, invocation.asyncId) - || [...aggregates].some((aggregate) => this.dependsOn(completion, aggregate)) + this.graph.dependsOn(completion, invocation.asyncId) + || [...aggregates].some((aggregate) => this.graph.dependsOn(completion, aggregate)) )); } + /** + * Operations that still had derived work running when the body returned. + * + * This is the question the gate used to get wrong. It previously asked which + * derived failures had *already landed*, and skipped every promise that had + * not settled — so the same program passed or failed on how many microtask + * ticks the failure took. Ten `await null`s, or any real derived I/O, cleared + * the window. + * + * Whether derived work is still in flight when the body returns is not a + * timing fact, it is a causal one: work the author awaited is settled at that + * instant in every timing, and work the author did not await is pending in + * every timing. Refusing on pending work therefore closes the race rather + * than widening it — and it makes the *settled* set complete, so reading the + * outcomes of the settled promises stops being a sample and becomes a total + * answer over a closed set. + * + * Must be called before the gate awaits anything. + */ + derivedWorkInFlight(operations: readonly T[]): T[] { + return operations.filter((operation) => + this.graph.inFlightFrom(this.rootsFor(operation)).length > 0); + } + async observeCallbackFailures(operations: readonly OperationToken[]): Promise { const observations: Promise[] = []; - const snapshot = [...this.promises.entries()]; for (const operation of operations) { - const roots = new Set( - (this.invocations.get(operation) ?? []) - .filter((invocation) => invocation.bound) - .map((invocation) => invocation.asyncId), - ); - for (const aggregate of this.aggregatesFor(operation)) roots.add(aggregate); - for (const [asyncId, promise] of snapshot) { - if (!this.settledPromises.has(asyncId) || roots.has(asyncId)) continue; - if (![...roots].some((root) => this.triggerDescendsFrom(asyncId, root))) continue; + for (const promise of this.graph.settledFrom(this.rootsFor(operation))) { observations.push(nativePromiseThen.call( promise, () => undefined, @@ -198,11 +230,26 @@ export class AuthoredFlowLifecycle { close(): void { if (this.closed) return; this.closed = true; - this.hook.disable(); - this.promises.clear(); + this.graph.disable(); + this.graph.clear(); + this.invocations.clear(); + this.promiseAllAggregates.clear(); + this.promiseAllGroups.length = 0; + this.callbackFailures.clear(); + this.activeResolverProbes.length = 0; uninstallPromiseAllObserver(); } + private rootsFor(operation: OperationToken): ReadonlySet { + const roots = new Set( + (this.invocations.get(operation) ?? []) + .filter((invocation) => invocation.bound) + .map((invocation) => invocation.asyncId), + ); + for (const aggregate of this.aggregatesFor(operation)) roots.add(aggregate); + return roots; + } + private recordCallbackFailure(operation: OperationToken, error: unknown): void { if (!this.callbackFailures.has(operation)) this.callbackFailures.set(operation, error); } @@ -213,7 +260,7 @@ export class AuthoredFlowLifecycle { for (const group of this.promiseAllGroups) { if ( group.members.size > 0 - && [...group.members].some((member) => this.dependsOn(member, invocation.asyncId)) + && [...group.members].some((member) => this.graph.dependsOn(member, invocation.asyncId)) ) { aggregates.add(group.aggregate); } @@ -221,36 +268,6 @@ export class AuthoredFlowLifecycle { } return aggregates; } - - private dependsOn(descendant: number, ancestor: number): boolean { - return this.dependenciesOf(descendant).has(ancestor); - } - - private dependenciesOf(start: number): Set { - const found = new Set(); - const pending = [start]; - while (pending.length > 0) { - const current = pending.pop()!; - if (found.has(current)) continue; - found.add(current); - const trigger = this.triggers.get(current); - const cause = this.resolutionCauses.get(current); - if (trigger !== undefined && trigger !== current) pending.push(trigger); - if (cause !== undefined && cause !== current) pending.push(cause); - } - return found; - } - - private triggerDescendsFrom(descendant: number, ancestor: number): boolean { - let current: number | undefined = descendant; - const seen = new Set(); - while (current !== undefined && !seen.has(current)) { - if (current === ancestor) return true; - seen.add(current); - current = this.triggers.get(current); - } - return false; - } } function installPromiseAllObserver(): void { diff --git a/sdk/src/authored-flow-operation.ts b/sdk/src/authored-flow-operation.ts index f15b21fed..92d662114 100644 --- a/sdk/src/authored-flow-operation.ts +++ b/sdk/src/authored-flow-operation.ts @@ -146,6 +146,11 @@ export async function verifyAuthoredOperations( for (const operation of operations) operation.cancel(error); } + // Read the in-flight set before the gate awaits anything: "was derived work + // still running when the body returned" is the question, and every await here + // would move the answer. + const inFlight = lifecycle.derivedWorkInFlight(operations); + await Promise.all(operations.map((operation) => operation.waitForSettlement())); await lifecycle.observeCallbackFailures(operations); const unawaited = operations.filter((operation) => @@ -172,6 +177,12 @@ export async function verifyAuthoredOperations( if (unawaited.length > 0) { throw unawaitedError(flowName, unawaited); } + if (inFlight.length > 0) { + throw new AuthoredFlowExecutionError( + 'unsettled_derived_work', + `flow "${flowName}" completed while work derived from ${inFlight.map(formatOperation).join(', ')} was still in flight`, + ); + } } export async function stopAuthoredOperations( diff --git a/sdk/src/authored-promise-graph.ts b/sdk/src/authored-promise-graph.ts new file mode 100644 index 000000000..a96d85c34 --- /dev/null +++ b/sdk/src/authored-promise-graph.ts @@ -0,0 +1,196 @@ +import { createHook, executionAsyncId, type AsyncHook } from 'node:async_hooks'; + +/** + * The promise graph an authored flow body actually creates. + * + * Three properties matter and each one is a repair of a measured defect: + * + * 1. **Scoped.** Only promises created while the owning lifecycle is the active + * `AsyncLocalStorage` store are recorded. A previous revision recorded every + * promise created anywhere in the process for the life of the flow; a probe + * measured 20 002 unrelated promises retained. + * 2. **Eagerly attributed.** A promise is attributed to a root the moment it is + * created, or the moment its parent becomes attributed — never by walking the + * graph per candidate at completion time. The walk made the completion gate + * quadratic: a 27 ms body with 30 000 ordinary awaits spent 95.8 s in the gate. + * 3. **Complete, not sampled.** `pending` is the set of tracked promises that have + * not settled. Derived work in flight is a fact the gate can read, so the gate + * never has to guess whether a failure "has landed yet". + * + * Attribution flows forward along the same `trigger` edge the previous revision + * walked backward, so it admits exactly the same set — it just knows the answer + * before it is asked. Children created before their parent becomes a root are + * parked in `unattributedChildren` and flooded when the root is registered; a + * promise cannot settle before its ancestors, and a root is always registered + * before it settles, so no parked child is ever missed. + */ +export class AuthoredPromiseGraph { + private readonly hook: AsyncHook; + private readonly triggers = new Map(); + private readonly creationContexts = new Map(); + private readonly resolutionCauses = new Map(); + private readonly promiseIds = new WeakMap(); + private readonly handles = new Map>(); + private readonly unattributedChildren = new Map(); + private readonly attributedRoots = new Map(); + private readonly roots = new Set(); + private readonly pending = new Set(); + private dependencyCache: { readonly start: number; readonly found: ReadonlySet } | undefined; + + constructor( + private readonly inScope: () => boolean, + private readonly onPromiseResolve: (asyncId: number) => void, + ) { + this.hook = createHook({ + init: (asyncId, type, triggerAsyncId, resource) => { + if (type !== 'PROMISE' || typeof resource !== 'object' || resource === null) return; + if (!this.inScope()) return; + this.triggers.set(asyncId, triggerAsyncId); + this.creationContexts.set(asyncId, executionAsyncId()); + this.promiseIds.set(resource, asyncId); + this.handles.set(asyncId, resource as Promise); + this.pending.add(asyncId); + const root = this.roots.has(triggerAsyncId) + ? triggerAsyncId + : this.attributedRoots.get(triggerAsyncId); + if (root !== undefined) this.attribute(asyncId, root); + else if (this.triggers.has(triggerAsyncId)) this.park(triggerAsyncId, asyncId); + }, + promiseResolve: (asyncId) => { + this.onPromiseResolve(asyncId); + if (!this.triggers.has(asyncId)) return; + const cause = executionAsyncId(); + if (cause !== asyncId) this.resolutionCauses.set(asyncId, cause); + this.pending.delete(asyncId); + if (this.attributedRoots.has(asyncId) || this.roots.has(asyncId)) return; + // A combinator's aggregate is resolved from inside the reaction of one of + // its members, so it is not downstream of any member by `trigger` and can + // only inherit attribution here, from the context that resolved it. Without + // this, only `Promise.all` was covered — because it is the one combinator + // registered explicitly — and `Promise.allSettled`, `Promise.any` and + // `Promise.race` each carried a deferred derived failure to terminal + // success. Measured; see ops/probes/pr134-repair-0903/combinators.mjs. + const adopted = this.attributedRoots.get(cause); + if (adopted !== undefined) { + this.attribute(asyncId, adopted); + return; + } + // A settled, unattributed promise can never become attributed: attribution + // only reaches a promise while its root is still pending. Release it. + this.handles.delete(asyncId); + this.unattributedChildren.delete(asyncId); + }, + }); + } + + enable(): void { + this.hook.enable(); + } + + disable(): void { + this.hook.disable(); + } + + idOf(value: object): number | undefined { + return this.promiseIds.get(value); + } + + /** Mark a promise whose descendants belong to an authored operation. */ + registerRoot(asyncId: number): void { + if (asyncId <= 0 || this.roots.has(asyncId)) return; + this.roots.add(asyncId); + const parked = this.unattributedChildren.get(asyncId); + if (parked === undefined) return; + this.unattributedChildren.delete(asyncId); + for (const child of parked) this.attribute(child, asyncId); + } + + /** Promises derived from `roots` that have not settled. */ + inFlightFrom(roots: ReadonlySet): number[] { + const found: number[] = []; + for (const asyncId of this.pending) { + const root = this.attributedRoots.get(asyncId); + if (root !== undefined && roots.has(root)) found.push(asyncId); + } + return found; + } + + /** Settled promises derived from `roots`, with their handles. */ + settledFrom(roots: ReadonlySet): Promise[] { + const found: Promise[] = []; + for (const [asyncId, root] of this.attributedRoots) { + if (!roots.has(root) || this.pending.has(asyncId)) continue; + const handle = this.handles.get(asyncId); + if (handle !== undefined) found.push(handle); + } + return found; + } + + dependsOn(descendant: number, ancestor: number): boolean { + return this.dependenciesOf(descendant).has(ancestor); + } + + clear(): void { + this.triggers.clear(); + this.creationContexts.clear(); + this.resolutionCauses.clear(); + this.handles.clear(); + this.unattributedChildren.clear(); + this.attributedRoots.clear(); + this.roots.clear(); + this.pending.clear(); + this.dependencyCache = undefined; + } + + private attribute(start: number, root: number): void { + const stack = [start]; + while (stack.length > 0) { + const current = stack.pop()!; + if (this.attributedRoots.has(current) || this.roots.has(current)) continue; + this.attributedRoots.set(current, root); + const parked = this.unattributedChildren.get(current); + if (parked === undefined) continue; + this.unattributedChildren.delete(current); + for (const child of parked) stack.push(child); + } + } + + private park(parent: number, child: number): void { + const parked = this.unattributedChildren.get(parent); + if (parked === undefined) this.unattributedChildren.set(parent, [child]); + else parked.push(child); + } + + /** + * Everything the promise identified by `start` could have waited for. + * + * Three edges, and the third is the repair. `trigger` and `resolutionCause` + * alone do not connect an async function's resumption context to the context + * it was suspended from, so a walk from `done()` reached only the *last* + * await's lineage: `const steps = [f.run(a), f.run(b)]; for (const s of steps) + * await s;` reported run-1 unawaited even though every step was awaited. The + * init-time `executionAsyncId()` — the context a promise was created in — is + * that missing edge, and it is a fact the runtime reports, not a widened + * approximation. + */ + private dependenciesOf(start: number): ReadonlySet { + const cached = this.dependencyCache; + if (cached !== undefined && cached.start === start) return cached.found; + const found = new Set(); + const stack = [start]; + while (stack.length > 0) { + const current = stack.pop()!; + if (found.has(current)) continue; + found.add(current); + for (const edge of [ + this.triggers.get(current), + this.resolutionCauses.get(current), + this.creationContexts.get(current), + ]) { + if (edge !== undefined && edge !== current) stack.push(edge); + } + } + this.dependencyCache = { start, found }; + return found; + } +} diff --git a/sdk/tests/authored-flow-lifecycle-executor.test.ts b/sdk/tests/authored-flow-lifecycle-executor.test.ts index d95aa2af8..fde578307 100644 --- a/sdk/tests/authored-flow-lifecycle-executor.test.ts +++ b/sdk/tests/authored-flow-lifecycle-executor.test.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs'; import type { Server } from 'node:net'; -import { flow, type FlowHandle } from '@relayflows/surface'; +import { flow, type FlowHandle, type Step } from '@relayflows/surface'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; import { JournalClient } from '../src/journal-client.js'; @@ -149,6 +149,126 @@ describe('authored flow lifecycle through the journal executor', () => { expectNoTerminalStart(startedBefore); }); + // P0 (repair 2026-09-03): the derived-chain gate must not depend on WHEN a + // derived failure lands. Both bodies below are the same program as + // 'retains a swallowed Promise.resolve callback failure' with the throw moved + // past the handful of microtask ticks the gate itself burns. + it.each([ + ['ten microtask ticks', async (): Promise => { + for (let tick = 0; tick < 10; tick++) await null; + }], + ['setTimeout(0)', (): Promise => new Promise((resolve) => setTimeout(resolve, 0))], + ] as const)( + 'refuses a handled-and-forgotten derived failure deferred by %s', + async (_label, defer) => { + const startedBefore = startedSpecs.length; + await expect(execute(flow('deferred-callback-failure', async (f) => { + const consumed = Promise.resolve(f.run('printf deferred-callback')); + const derived = consumed.then(async () => { + await defer(); + throw new Error('derived post-processing failed after the gate sampled'); + }); + derived.catch(() => undefined); + await consumed; + f.done('success'); + }))).rejects.toMatchObject({ code: 'unsettled_derived_work' }); + expectNoTerminalStart(startedBefore); + }, + ); + + it('refuses terminal success while derived work is still in flight', async () => { + const startedBefore = startedSpecs.length; + await expect(execute(flow('in-flight-derived-work', async (f) => { + const consumed = Promise.resolve(f.run('printf in-flight')); + void consumed.then(async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + return 'late but successful'; + }); + await consumed; + f.done('success'); + }))).rejects.toMatchObject({ code: 'unsettled_derived_work' }); + expectNoTerminalStart(startedBefore); + }); + + // P1-A (repair 2026-09-03): constructing steps and then awaiting them is + // ordinary, correct authoring and must not be refused. + it('preserves pre-constructed steps awaited in a loop', async () => { + const result = await execute(flow('loop-awaited-steps', async (f) => { + const steps = [f.run('true'), f.run('true'), f.run('true')]; + for (const step of steps) await step; + f.done('success'); + })); + expect(result.completionReason).toBe('success'); + expect(result.journalSteps.map((step) => step.id)).toEqual([ + 'run-1', + 'run-2', + 'run-3', + 'complete-4', + ]); + }); + + it('preserves pre-constructed steps awaited out of order', async () => { + const result = await execute(flow('out-of-order-awaited-steps', async (f) => { + const first = f.run('true'); + const second = f.run('true'); + await second; + await first; + f.done('success'); + })); + expect(result.completionReason).toBe('success'); + expect(result.journalSteps.map((step) => step.id)).toEqual([ + 'run-2', + 'run-1', + 'complete-3', + ]); + }); + + // Repair 2026-09-03: `Promise.all` is the only combinator the lifecycle + // registers explicitly, so it was the only one whose aggregate was attributed + // to the operation. allSettled, any and race each carried a deferred derived + // failure all the way to terminal success. Attribution is now inherited from + // the context that resolves an aggregate, which covers every combinator + // without intercepting any of them. + const combinators: Record) => Promise> = { + allSettled: (step) => Promise.allSettled([step]), + any: (step) => Promise.any([step]), + race: (step) => Promise.race([step]), + all: (step) => Promise.all([step]), + resolve: (step) => Promise.resolve(step), + }; + it.each(Object.keys(combinators))( + 'refuses a deferred derived failure consumed through Promise.%s', + async (combinator) => { + const startedBefore = startedSpecs.length; + await expect(execute(flow(`combinator-${combinator}`, async (f) => { + const consumed = combinators[combinator]!(f.run('printf combinator')); + const derived = consumed.then(async () => { + for (let tick = 0; tick < 10; tick++) await null; + throw new Error('derived post-processing failed after the gate sampled'); + }); + derived.catch(() => undefined); + await consumed; + f.done('success'); + }))).rejects.toMatchObject({ code: 'unsettled_derived_work' }); + expectNoTerminalStart(startedBefore); + }, + ); + + // The other side of that widening: work that merely FOLLOWS an authored step, + // and is awaited, must not be mistaken for unfinished derived work. + it('preserves ordinary awaited work after an authored step', async () => { + const result = await execute(flow('work-after-step', async (f) => { + await f.run('true'); + for (let index = 0; index < 200; index++) await Promise.resolve(index); + await new Promise((resolve) => setTimeout(resolve, 5)); + await Promise.resolve(1).then((value) => value + 1); + await f.run('true'); + f.done('success'); + })); + expect(result.completionReason).toBe('success'); + expect(result.journalSteps.map((step) => step.id)).toEqual(['run-1', 'run-2', 'complete-3']); + }); + it('preserves direct await, Promise.resolve, and Promise.all authoring', async () => { const result = await execute(flow('supported-awaits', async (f) => { await f.run('true'); diff --git a/sdk/tests/authored-flow-operation.test.ts b/sdk/tests/authored-flow-operation.test.ts index d3e0956dc..11e62f0f6 100644 --- a/sdk/tests/authored-flow-operation.test.ts +++ b/sdk/tests/authored-flow-operation.test.ts @@ -31,10 +31,10 @@ async function executeLifecycle( body: (create: () => AuthoredFlowOperation) => Promise, ): Promise { const lifecycle = new AuthoredFlowLifecycle(); - const operations: AuthoredFlowOperation[] = []; + const operations: AuthoredFlowOperation[] = []; const create = (): AuthoredFlowOperation => { const authored = operation(verb, lifecycle, operations.length + 1); - operations.push(authored); + operations.push(authored as AuthoredFlowOperation); return authored; }; try { @@ -125,3 +125,102 @@ it('isolates concurrent Promise.all lifecycle scopes', async () => { }), ]); }); + +// P1-B (repair 2026-09-03): the completion gate used to walk the whole +// process-wide promise graph once per candidate promise per operation root. A +// 23 ms body with 30 000 ordinary in-flow awaits then spent 98 s in the gate +// (measured at 59c062cf: 5 000 -> 1763 ms, 30 000 -> 98 379 ms — quadratic). +// Attribution is now eager and O(1) per promise, so the gate is linear. The +// bound below is ~30x the measured 35 ms and ~2800x under the old cost: it +// cannot be met by anything quadratic, and it will not flake on a slow host. +it('completes the gate in linear time over a body with 30000 ordinary awaits', async () => { + const lifecycle = new AuthoredFlowLifecycle(); + const operations: AuthoredFlowOperation[] = []; + try { + await lifecycle.runBody(async () => { + const authored = operation('run', lifecycle, 1); + operations.push(authored as AuthoredFlowOperation); + await authored.step; + for (let index = 0; index < 30_000; index++) await Promise.resolve(index); + lifecycle.markCompletion(); + }); + const startedAt = Date.now(); + await verifyAuthoredOperations('linear-gate', operations, lifecycle); + expect(Date.now() - startedAt).toBeLessThan(1_000); + } finally { + lifecycle.close(); + } +}); + +// P1-B, second half: the graph used to retain every promise created ANYWHERE in +// the process for the life of the flow (a probe measured 20 002 unrelated +// promises held by strong reference). Only promises created inside the flow's +// own async scope are tracked now. +it('does not track promises created outside the flow body', async () => { + const lifecycle = new AuthoredFlowLifecycle(); + try { + const unrelated: Promise[] = []; + for (let index = 0; index < 5_000; index++) unrelated.push(Promise.resolve(index)); + await Promise.all(unrelated); + expect(trackedPromiseCount(lifecycle)).toBe(0); + } finally { + lifecycle.close(); + } +}); + +// P2-B (repair 2026-09-03): close() released `promises` but left `triggers`, +// `settledPromises` and `resolutionCauses` populated (180 031 / 180 030 / 30 010 +// entries in the signoff measurement). +it('releases every tracked map on close', async () => { + const lifecycle = new AuthoredFlowLifecycle(); + const operations: AuthoredFlowOperation[] = []; + await lifecycle.runBody(async () => { + const authored = operation('run', lifecycle, 1); + operations.push(authored as AuthoredFlowOperation); + await authored.step; + for (let index = 0; index < 500; index++) await Promise.resolve(index); + lifecycle.markCompletion(); + }); + expect(trackedPromiseCount(lifecycle)).toBeGreaterThan(0); + await verifyAuthoredOperations('release-on-close', operations, lifecycle); + lifecycle.close(); + expect(trackedMapSizes(lifecycle)).toEqual([]); +}); + +// P2-A (repair 2026-09-03): the interception is still an intrinsic patch — see +// the repair report — but it must not change what Promise.all DOES. It used to +// resolve [] for a non-iterable where the specification rejects, and to throw +// synchronously for null where the specification returns a rejected promise. +it('keeps Promise.all specification behaviour while a flow is open', async () => { + const lifecycle = new AuthoredFlowLifecycle(); + try { + expect(Promise.all.name).toBe('all'); + await expect(Promise.all(null as never)).rejects.toBeInstanceOf(TypeError); + await expect(Promise.all(5 as never)).rejects.toBeInstanceOf(TypeError); + await expect(Promise.all([Promise.resolve(1), 2])).resolves.toEqual([1, 2]); + } finally { + lifecycle.close(); + } +}); + +/** Every Map/Set the lifecycle graph holds, by size, dropping the empty ones. */ +function trackedMapSizes(lifecycle: AuthoredFlowLifecycle): number[] { + const sizes: number[] = []; + const visit = (holder: object): void => { + for (const value of Object.values(holder)) { + if (value instanceof Map || value instanceof Set) { + if (value.size > 0) sizes.push(value.size); + } else if (Array.isArray(value)) { + if (value.length > 0) sizes.push(value.length); + } else if (typeof value === 'object' && value !== null) { + visit(value); + } + } + }; + visit(lifecycle); + return sizes; +} + +function trackedPromiseCount(lifecycle: AuthoredFlowLifecycle): number { + return trackedMapSizes(lifecycle).reduce((total, size) => total + size, 0); +} diff --git a/sdk/tsconfig.tests.json b/sdk/tsconfig.tests.json index 2af505b54..1a67d8261 100644 --- a/sdk/tsconfig.tests.json +++ b/sdk/tsconfig.tests.json @@ -1,10 +1,20 @@ { "extends": "./tsconfig.json", "compilerOptions": { + // ES2024.Promise only, for Promise.withResolvers in the authored lifecycle + // forgery tests. The SDK's own tsconfig stays on ES2022. + "lib": ["ES2022", "ES2024.Promise"], "noEmit": true, "rootDir": ".", "types": ["node", "vitest"] }, - "include": ["src/**/*.ts", "tests/typed-output.test.ts"], + "include": [ + "src/**/*.ts", + "tests/typed-output.test.ts", + "tests/authored-flow-lifecycle-executor.test.ts", + "tests/authored-flow-operation.test.ts", + "tests/authored-flow.test.ts", + "tests/journal-client-loopback.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/surface/README.md b/surface/README.md index 56ab1cc62..ac980f2ea 100644 --- a/surface/README.md +++ b/surface/README.md @@ -11,12 +11,18 @@ internal test seam proving an awaited plain `f.run(...)` can cross the existing journal protocol, but it is intentionally not exported as a runner: authored body progress does not yet have a durable root journal or crash-safe resume. Within that seam, a root operation must participate in the asynchronous -continuation that reaches `done()`. Direct `await`, awaited `Promise.resolve`, -and awaited `Promise.all` are supported. Ignored operations, manual `.then` -callbacks, ignored combinators, and callback failures that are caught away are +continuation that reaches `done()`. Direct `await` is supported, including steps +constructed before they are awaited, and so are awaited `Promise.resolve`, +`Promise.all`, `Promise.allSettled`, `Promise.any` and `Promise.race`. Ignored +operations, manual `.then` callbacks, ignored combinators, callback failures that +are caught away, and derived work still in flight when the body returns are all refused before the terminal journal step; callback source text is never treated as lifecycle proof. +Note that executing an authored body replaces the global `Promise.all` for the +duration of the run. The reason, the scope, and the one documented limit of the +lifecycle contract are in `docs/SURFACE.md`, "The authored operation lifecycle". + This is an unpublished contract foundation, not a shipped executable surface. Direct `.flow.ts` execution, durable authored-root resume, and input remain tracked in issue #132. Resident trigger handlers (`flow.on(...)`) are gate-2 From 4f85c4e0409f6627affcabfc7914d916364c1d86 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Fri, 4 Sep 2026 00:05:56 +0200 Subject: [PATCH 10/10] fix(surface): make aggregate membership exact, not resolution-inferred The previous revision claimed adoption-from-the-resolving-context "covers every combinator, present and future". It covers whichever member happens to resolve the aggregate. An aggregate is derived from EVERY member, but the runtime supplies an edge to only one: the aggregate is resolved inside the reaction of whichever member settled last (all, allSettled) or first (race, any). Inferring membership from that edge is sufficient, never necessary, and it failed in both directions. Signoff at 311b18c found both halves from the same line: P0 Promise.allSettled([step, unrelated]) where `unrelated` settles last orphans the aggregate, so a handled-and-forgotten derived rejection escapes and complete-* is lowered with result "success". P1 await Promise.allSettled([a, b]) refused every member except the last to settle -- authoring docs/SURFACE.md explicitly supports. Membership cannot be recovered from the promise graph, so it is recorded where the combinator is called and the member list is in hand: all four intrinsics are intercepted rather than Promise.all alone. adoptFromResolvingContext remains as a best-effort fallback for aggregates built by hand, no longer as the mechanism. Why the tests could not have caught it: every combinator row used a single-member aggregate, `Promise.allSettled([step])`, which is always resolved by the step itself. That shape cannot exhibit "resolved by a different member" BY CONSTRUCTION -- the rows would have passed however the mechanism was written. They now use multi-member aggregates that vary the resolver, in both directions, across all four combinators. MUTATION-VERIFIED. Reverting COMBINATORS to ['all'] (the pre-fix state) fails exactly six tests, and they are both halves of the defect: x allSettled resolved by an unrelated member -> promise resolved instead of rejecting x allSettled with the step declared second -> promise resolved instead of rejecting x race resolved by an unrelated member -> promise resolved instead of rejecting x any resolved by an unrelated member -> promise resolved instead of rejecting x await Promise.allSettled over two steps -> unawaited_step: run-1 x await Promise.allSettled over five steps -> unawaited_step: run-1..run-4 pre sha256 081787dc17727e379bfb790ca747ac33bc0be0ab627f0fb01573e3d203c2c201 post sha256 edde33ae3689b49a7f61432003c1add70629c54757e72ce1464d0635969b641b (file changed: asserted before the run) restored 081787dc17727e379bfb790ca747ac33bc0be0ab627f0fb01573e3d203c2c201 (byte-for-byte) Spec transparency preserved and widened to all four: Symbol.iterator read exactly once as the intrinsic does, a non-iterable handed to the intrinsic so it produces the specified rejected promise rather than resolving [] or throwing synchronously, `this` honoured for subclasses, name/length matching. Gates: tsc --noEmit 0; tsc -p tsconfig.tests.json 0; full SDK suite 432 passed / 3 skipped / 0 failed with RELAYFLOWD_BIN pinned to this worktree's build; lifecycle executor suite 27/27. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- .../aggregate-membership.mjs | 57 +++++++ sdk/src/authored-flow-lifecycle.ts | 154 ++++++++++++------ .../authored-flow-lifecycle-executor.test.ts | 86 ++++++++-- 3 files changed, 230 insertions(+), 67 deletions(-) create mode 100644 ops/probes/pr134-repair-0903/aggregate-membership.mjs diff --git a/ops/probes/pr134-repair-0903/aggregate-membership.mjs b/ops/probes/pr134-repair-0903/aggregate-membership.mjs new file mode 100644 index 000000000..2034ec7c0 --- /dev/null +++ b/ops/probes/pr134-repair-0903/aggregate-membership.mjs @@ -0,0 +1,57 @@ +// F1 + F2: an aggregate is derived from EVERY member, but the runtime supplies a +// resolutionCause edge to only ONE — whichever member resolved it. Every row here +// has AT LEAST TWO members and deliberately varies which one resolves the +// aggregate, because a single-member aggregate cannot exhibit the defect at all. +import { flow, runFlow, verdict } from './harness.mjs'; + +const ticks = (n) => async () => { for (let i = 0; i < n; i++) await null; }; +const slower = (ms = 15) => new Promise((r) => setTimeout(() => r('unrelated'), ms)); +const faster = () => Promise.resolve('unrelated-fast'); + +// --- F1: derived failure hidden behind an aggregate an unrelated member resolved +const escape = (name, build) => flow(name, async (f) => { + const step = f.run('true'); + const agg = build(step); + const derived = agg.then(async () => { + await ticks(10)(); + throw new Error('work derived from the authored step failed'); + }); + derived.catch(() => undefined); // handled and forgotten + await agg; + await step; + f.done('success'); +}); + +const escapes = { + 'allSettled([step, slowerUnrelated]) resolved by the UNRELATED member': (s) => Promise.allSettled([s, slower()]), + 'allSettled([slowerUnrelated, step]) resolved by the UNRELATED member': (s) => Promise.allSettled([slower(), s]), + 'race([fastUnrelated, step]) resolved by the UNRELATED member': (s) => Promise.race([faster(), s]), + 'any([fastUnrelated, step]) resolved by the UNRELATED member': (s) => Promise.any([faster(), s]), + 'all([step, slowerUnrelated]) resolved by the UNRELATED member': (s) => Promise.all([s, slower()]), + 'allSettled([step, fastUnrelated]) resolved by the STEP (control) ': (s) => Promise.allSettled([s, faster()]), +}; +console.log('=== F1 deferred derived failure behind a multi-member aggregate [ALL MUST REFUSE] ==='); +for (const [label, build] of Object.entries(escapes)) { + const r = await runFlow(escape(`esc-${label.slice(0, 12)}`, build)); + const ok = r.error !== undefined; + console.log(` ${ok ? 'OK ' : 'FAIL'} | ${label} | ${verdict(r)}`); +} + +// --- F2: correct, documented authoring that must NOT be refused +const legit = { + 'await Promise.allSettled([a, b]) ': async (f) => { await Promise.allSettled([f.run('true'), f.run('true')]); }, + 'await Promise.allSettled over 5 steps ': async (f) => { await Promise.allSettled([f.run('true'), f.run('true'), f.run('true'), f.run('true'), f.run('true')]); }, + 'await Promise.all([a, b, c]) ': async (f) => { await Promise.all([f.run('true'), f.run('true'), f.run('true')]); }, + 'await Promise.race([a, b]) ': async (f) => { await Promise.race([f.run('true'), f.run('true')]); }, + 'await Promise.any([a, b]) ': async (f) => { await Promise.any([f.run('true'), f.run('true')]); }, + 'await step then reuse it in a later race ': async (f) => { const s = f.run('true'); await s; await Promise.race([Promise.resolve('x'), s]); }, + 'for await (const v of [a, b]) ': async (f) => { for await (const v of [f.run('true'), f.run('true')]) void v; }, + 'for await (const v of [a]) ': async (f) => { for await (const v of [f.run('true')]) void v; }, + 'allSettled mixing a step and an unrelated ': async (f) => { await Promise.allSettled([f.run('true'), slower(5)]); }, +}; +console.log('=== F2 correct, documented authoring [ALL MUST PASS] ==='); +for (const [label, body] of Object.entries(legit)) { + const r = await runFlow(flow(`ok-${label.slice(0, 10)}`, async (f) => { await body(f); f.done('success'); })); + const ok = r.error === undefined && r.terminal; + console.log(` ${ok ? 'OK ' : 'FAIL'} | ${label} | ${verdict(r)}${r.error ? ' :: ' + r.error.message.slice(0, 90) : ''}`); +} diff --git a/sdk/src/authored-flow-lifecycle.ts b/sdk/src/authored-flow-lifecycle.ts index f0a93e49a..ed9a8430c 100644 --- a/sdk/src/authored-flow-lifecycle.ts +++ b/sdk/src/authored-flow-lifecycle.ts @@ -14,54 +14,92 @@ export interface AuthoredOperationInvocation { } const nativePromiseThen = Promise.prototype.then; -const nativePromiseAll = Promise.all; const activeLifecycle = new AsyncLocalStorage(); const stepOwners = new WeakMap(); -let promiseAllObservers = 0; /** - * A `Promise.all` that records which authored operations an aggregate joins. + * The four intrinsic combinators, intercepted so that an aggregate's membership + * is recorded exactly. * - * This intercepts an intrinsic, which is a real cost and is documented as such - * in `ops/reviews/20260903-pr134-repair-0903.md`. It is here because a - * combinator's aggregate has no runtime edge to its *non-final* members: the - * aggregate's resolution cause reaches only the last element to settle, so - * without this registration `await Promise.all([a, b])` reports `a` unawaited. - * Every alternative that recovers the link — comparing the `onrejected` - * callbacks the combinator passes each element, for instance — is callback - * identity inference, which is exactly the forgery class this contract closed. + * **Why interception, and why all four.** An aggregate is derived from *every* + * member, but the runtime supplies an edge to only *one* of them: the aggregate + * is resolved inside the reaction of whichever member settled last (`all`, + * `allSettled`) or first (`race`, `any`). Inferring membership from that edge is + * a sufficient rule, never a necessary one, and it fails in both directions — + * `Promise.allSettled([step, slowerUnrelated])` hid a rejected derived chain + * behind an aggregate an unrelated promise resolved, and + * `await Promise.allSettled([a, b])` refused every member except the last to + * settle. Membership cannot be recovered from the promise graph, so it is + * recorded here, where the combinator is called and the member list is in hand. * - * Where it previously deviated from the specification it no longer does: a - * non-iterable argument is handed straight to the intrinsic so it produces the - * specified rejected promise rather than resolving `[]` or throwing - * synchronously. + * A previous revision registered `Promise.all` only, and claimed a + * resolution-context rule covered "every combinator, present and future". That + * claim was wrong: it covered whichever member happened to resolve the + * aggregate. What is true is narrower and is what the code now implements — + * these four are exact, and `adoptFromResolvingContext` in the promise graph is + * a best-effort fallback for aggregates built by hand. + * + * The interception is disclosed to authors in `docs/SURFACE.md`. It is spec + * transparent: `Symbol.iterator` is read exactly once (as the intrinsic does), + * a non-iterable is handed to the intrinsic so it produces the specified + * rejected promise, `this` is honoured for subclasses, and `name`/`length` + * match. */ -const observedPromiseAll = function ( - this: PromiseConstructor, - values: Iterable>, -): Promise[]> { - const passThrough = (): Promise[]> => - nativePromiseAll.call(this, values as unknown as readonly unknown[]) as Promise[]>; - if (!isIterable(values)) return passThrough(); - let members: (T | PromiseLike)[]; +const COMBINATORS = ['all', 'allSettled', 'any', 'race'] as const; +type CombinatorName = (typeof COMBINATORS)[number]; +type Combinator = (this: PromiseConstructor, values: Iterable) => Promise; + +const nativeCombinators = Object.freeze( + Object.fromEntries(COMBINATORS.map((name) => [name, Promise[name] as unknown as Combinator])), +) as Readonly>; + +const observedCombinators: Record = Object.fromEntries( + COMBINATORS.map((name) => { + const native = nativeCombinators[name]; + const observed = function ( + this: PromiseConstructor, + values: Iterable, + ): Promise { + const members = collectMembers(values); + if (members === undefined) return native.call(this, values); + const aggregate = native.call(this, members); + activeLifecycle.getStore()?.registerCombinator(members, aggregate); + return aggregate; + }; + Object.defineProperty(observed, 'name', { value: name, configurable: true }); + Object.defineProperty(observed, 'length', { value: 1, configurable: true }); + return [name, observed]; + }), +) as Record; + +let combinatorObservers = 0; + +/** + * Drain an iterable into an array, reading `Symbol.iterator` exactly once. + * + * Returns `undefined` when the argument is not iterable or iteration threw, so + * the caller hands the original value to the intrinsic and the author sees the + * intrinsic's own behaviour. A previous revision used `isIterable()` followed by + * `Array.from()`, which invoked a `Symbol.iterator` getter twice where the + * intrinsic invokes it once. + */ +function collectMembers(values: Iterable): unknown[] | undefined { + if (values === null || values === undefined) return undefined; + let iteratorMethod: unknown; try { - members = Array.from(values); + iteratorMethod = (values as { [Symbol.iterator]?: unknown })[Symbol.iterator]; } catch { - return passThrough(); + return undefined; + } + if (typeof iteratorMethod !== 'function') return undefined; + try { + return [...(values as Iterable)]; + } catch { + return undefined; } - const aggregate = nativePromiseAll.call(this, members) as Promise[]>; - activeLifecycle.getStore()?.registerPromiseAll(members, aggregate); - return aggregate; -}; -Object.defineProperty(observedPromiseAll, 'name', { value: 'all', configurable: true }); -Object.defineProperty(observedPromiseAll, 'length', { value: 1, configurable: true }); - -function isIterable(value: unknown): value is Iterable { - if (value === null || value === undefined) return false; - return typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'; } /** @@ -104,7 +142,7 @@ export class AuthoredFlowLifecycle { stepOwners.set(step, { lifecycle: this, operation }); } - registerPromiseAll(values: readonly unknown[], aggregate: Promise): void { + registerCombinator(values: readonly unknown[], aggregate: Promise): void { const aggregateId = this.graph.idOf(aggregate); if (aggregateId === undefined) return; this.graph.registerRoot(aggregateId); @@ -271,26 +309,36 @@ export class AuthoredFlowLifecycle { } function installPromiseAllObserver(): void { - if (promiseAllObservers === 0) { - if (Promise.all !== nativePromiseAll) { - throw new AuthoredFlowExecutionError( - 'unsupported_promise_lifecycle', - 'authored flow execution requires the intrinsic Promise.all', - ); + if (combinatorObservers === 0) { + for (const name of COMBINATORS) { + if (Promise[name] !== (nativeCombinators[name] as unknown)) { + throw new AuthoredFlowExecutionError( + 'unsupported_promise_lifecycle', + `authored flow execution requires the intrinsic Promise.${name}`, + ); + } + } + for (const name of COMBINATORS) { + (Promise as unknown as Record)[name] = observedCombinators[name]; + } + } else { + for (const name of COMBINATORS) { + if (Promise[name] !== (observedCombinators[name] as unknown)) { + throw new AuthoredFlowExecutionError( + 'unsupported_promise_lifecycle', + `the authored flow Promise.${name} lifecycle contract was replaced`, + ); + } } - Promise.all = observedPromiseAll as PromiseConstructor['all']; - } else if (Promise.all !== observedPromiseAll) { - throw new AuthoredFlowExecutionError( - 'unsupported_promise_lifecycle', - 'the authored flow Promise.all lifecycle contract was replaced', - ); } - promiseAllObservers += 1; + combinatorObservers += 1; } function uninstallPromiseAllObserver(): void { - promiseAllObservers -= 1; - if (promiseAllObservers > 0) return; - promiseAllObservers = 0; - Promise.all = nativePromiseAll; + combinatorObservers -= 1; + if (combinatorObservers > 0) return; + combinatorObservers = 0; + for (const name of COMBINATORS) { + (Promise as unknown as Record)[name] = nativeCombinators[name]; + } } diff --git a/sdk/tests/authored-flow-lifecycle-executor.test.ts b/sdk/tests/authored-flow-lifecycle-executor.test.ts index fde578307..5e62dc0fd 100644 --- a/sdk/tests/authored-flow-lifecycle-executor.test.ts +++ b/sdk/tests/authored-flow-lifecycle-executor.test.ts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs'; import type { Server } from 'node:net'; -import { flow, type FlowHandle, type Step } from '@relayflows/surface'; +import { flow, type Ctx, type FlowHandle, type Step } from '@relayflows/surface'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; import { JournalClient } from '../src/journal-client.js'; @@ -229,31 +229,89 @@ describe('authored flow lifecycle through the journal executor', () => { // failure all the way to terminal success. Attribution is now inherited from // the context that resolves an aggregate, which covers every combinator // without intercepting any of them. - const combinators: Record) => Promise> = { - allSettled: (step) => Promise.allSettled([step]), - any: (step) => Promise.any([step]), - race: (step) => Promise.race([step]), - all: (step) => Promise.all([step]), - resolve: (step) => Promise.resolve(step), + // Aggregate membership. Every row here has AT LEAST TWO members, at least one + // of them not an authored step, and names which member resolves the aggregate. + // + // The previous revision of these tests used single-member aggregates + // (`Promise.allSettled([step])`). A single-member aggregate is always resolved + // by its only member, so those rows could not fail however the mechanism was + // written: they proved the code path executed, not that the bound held. They + // passed while `Promise.allSettled([step, slowerUnrelated])` carried a + // handled-and-forgotten derived failure to terminal success. + const slowerUnrelated = (): Promise => + new Promise((resolve) => setTimeout(() => resolve('unrelated'), 15)); + const fasterUnrelated = (): Promise => Promise.resolve('unrelated-fast'); + + const aggregates: Record) => Promise> = { + 'allSettled resolved by an unrelated member': (step) => Promise.allSettled([step, slowerUnrelated()]), + 'allSettled with the step declared second': (step) => Promise.allSettled([slowerUnrelated(), step]), + 'all resolved by an unrelated member': (step) => Promise.all([step, slowerUnrelated()]), + 'race resolved by an unrelated member': (step) => Promise.race([fasterUnrelated(), step]), + 'any resolved by an unrelated member': (step) => Promise.any([fasterUnrelated(), step]), + 'allSettled resolved by the step itself': (step) => Promise.allSettled([step, fasterUnrelated()]), + 'race resolved by the step itself': (step) => Promise.race([step, slowerUnrelated()]), }; - it.each(Object.keys(combinators))( - 'refuses a deferred derived failure consumed through Promise.%s', - async (combinator) => { + it.each(Object.keys(aggregates))( + 'refuses a deferred derived failure behind an aggregate: %s', + async (shape) => { const startedBefore = startedSpecs.length; - await expect(execute(flow(`combinator-${combinator}`, async (f) => { - const consumed = combinators[combinator]!(f.run('printf combinator')); + await expect(execute(flow(`aggregate-${shape}`, async (f) => { + const step = f.run('printf aggregate'); + const consumed = aggregates[shape]!(step); const derived = consumed.then(async () => { for (let tick = 0; tick < 10; tick++) await null; - throw new Error('derived post-processing failed after the gate sampled'); + throw new Error('work derived from the authored step failed'); }); derived.catch(() => undefined); await consumed; + // Await the step directly too, so this row cannot pass for the wrong + // reason: without it, a regression in aggregate REACHABILITY would + // refuse with `unawaited_step` and mask the derived-failure escape the + // row exists to catch. + await step; f.done('success'); - }))).rejects.toMatchObject({ code: 'unsettled_derived_work' }); + }))).rejects.toMatchObject({ + code: expect.stringMatching(/^(unsettled_derived_work|operation_callback_failed)$/), + }); expectNoTerminalStart(startedBefore); }, ); + // The same membership rule, opposite sign: a multi-member aggregate over + // authored steps must be ACCEPTED, whichever member resolves it. Before the + // membership fix, `await Promise.allSettled([a, b])` refused every step except + // the last to settle — and `docs/SURFACE.md` documents this as supported. + const acceptedAggregates: Record Promise> = { + 'await Promise.allSettled over two steps': (f) => Promise.allSettled([f.run('true'), f.run('true')]), + 'await Promise.allSettled over five steps': (f) => Promise.allSettled([ + f.run('true'), f.run('true'), f.run('true'), f.run('true'), f.run('true'), + ]), + 'await Promise.all over three steps': (f) => Promise.all([f.run('true'), f.run('true'), f.run('true')]), + 'await Promise.race over two steps': (f) => Promise.race([f.run('true'), f.run('true')]), + 'await Promise.any over two steps': (f) => Promise.any([f.run('true'), f.run('true')]), + 'await an aggregate mixing a step and an unrelated promise': (f) => + Promise.allSettled([f.run('true'), slowerUnrelated()]), + }; + it.each(Object.keys(acceptedAggregates))('preserves authoring: %s', async (shape) => { + const result = await execute(flow(`accepted-${shape}`, async (f) => { + await acceptedAggregates[shape]!(f); + f.done('success'); + })); + expect(result.completionReason).toBe('success'); + }); + + // A documented limit, pinned so it is a known boundary and not a surprise. + // V8's async-from-sync iterator resolves its result promise through an + // internal capability that carries no async_hooks edge back to the awaited + // value, so the gate cannot prove the step participated in the continuation. + // It fails CLOSED, which is the safe direction. See docs/SURFACE.md. + it('refuses for-await over authored steps, and says so in the docs', async () => { + await expect(execute(flow('for-await-limit', async (f) => { + for await (const value of [f.run('true')]) void value; + f.done('success'); + }))).rejects.toMatchObject({ code: 'unawaited_step' }); + }); + // The other side of that widening: work that merely FOLLOWS an authored step, // and is awaited, must not be mistaken for unfinished derived work. it('preserves ordinary awaited work after an authored step', async () => {