From 95614de2d5cf9ed81fc9460d89f5de60f6e88385 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 19 Oct 2026 09:59:42 +0200 Subject: [PATCH 1/2] feat(surface): dependency-free hosted completion-signal helper (2.0.54) Hosted Cloud runs materialize only the pinned @relayflows/surface next to the flow source, so the SDK's waitForCloudSignal cannot be imported there. - Add @relayflows/surface/signals: waitForSignal(name, opts?) with hosted defaults (RELAYFLOW_V2_CLOUD_RUN_ID, RELAYFLOW_V2_SIGNAL_READ_TOKEN, FLOWS_CLOUD_URL else https://agentrelay.com/cloud). Imports only node:timers/promises and uses global fetch. - Move the held-GET wait (path validation, waitMs <= 25000, fast-204 backoff 250ms->5s with reset, error classification) into the Surface as holdSignalWait; the SDK's waitForCloudSignal now delegates to it with its own credential resolution, hosted-token fallback, and CloudFlowError. - Tests: Surface unit tests mirroring the SDK suite plus hosted defaults; packed-consumer gate imports the subpath from the tarball and asserts it imports only Node built-ins; a replica of Cloud's Bun compatibility envelope shows the subpath needs a companion Cloud change. - Docs: SURFACE.md usage for hosted flows. Co-Authored-By: Claude Opus 5.5 --- docs/SURFACE.md | 59 ++++ packages/sdk/src/cloud-signal.ts | 102 ++----- packages/surface/package.json | 5 + packages/surface/src/signals.ts | 258 +++++++++++++++++ .../tests/fixtures/cloud-surface-envelope.ts | 62 ++++ .../surface/tests/signals-envelope.test.ts | 95 ++++++ packages/surface/tests/signals.test.ts | 270 ++++++++++++++++++ scripts/pack-release.mjs | 2 +- scripts/surface-package-gate.sh | 46 +++ 9 files changed, 824 insertions(+), 75 deletions(-) create mode 100644 packages/surface/src/signals.ts create mode 100644 packages/surface/tests/fixtures/cloud-surface-envelope.ts create mode 100644 packages/surface/tests/signals-envelope.test.ts create mode 100644 packages/surface/tests/signals.test.ts diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 3ba3ca331..0dbd303aa 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -2066,6 +2066,65 @@ is derived from the run id alone, so it is present even when the evidence could not be read or the failure shape was not recognised — a failure the CLI cannot explain still says where the record is rather than ending the trail. +### Hosted completion signals: `@relayflows/surface/signals` + +```ts +import { flow } from "@relayflows/surface"; +import { waitForSignal } from "@relayflows/surface/signals"; + +export default flow("native-build", async (f) => { + await f.run("./scripts/kick-off-native-build.sh"); + const done = (await waitForSignal("native_done")) as { artifactUrl: string }; + await f.run(`curl -fsSL '${done.artifactUrl}' -o native.zip`); + f.done("success"); +}); +``` + +`waitForSignal(name, options?)` waits on a hosted run's completion signal and +returns the JSON `payload` some other system posted for that name (for +example CI calling the SDK's `postCloudSignal` with a `workflow:invoke:write` +token). It lives in Surface, not the SDK, because a hosted Cloud run +materializes only the pinned `@relayflows/surface` next to the flow source; +the subpath imports nothing beyond Node built-ins and calls Cloud with the +global `fetch`. + +Call it **in the authored body**. Cloud gives only the authored process the +run-bound, GET-only signal-read token; `f.run` children, the daemon, and +agents never receive it, so a command that tries to wait from inside a step +has no credential. Defaults, each overridable through `options`: + +| Option | Default | +|---|---| +| `runId` | `RELAYFLOW_V2_CLOUD_RUN_ID`, Cloud's run ID (not the engine's `RELAYFLOW_RUN_ID`) | +| `token` | `RELAYFLOW_V2_SIGNAL_READ_TOKEN` | +| `apiUrl` | `FLOWS_CLOUD_URL`, else `https://agentrelay.com/cloud` | +| `waitMs` | `25000` (per held request; 0–25000) | +| `requestTimeoutMs` | `30000` (must exceed `waitMs`) | +| `signal` | none: the wait has no overall deadline; pass `AbortSignal.timeout(ms)` to bound it | + +The wire behaviour is the one `@relayflows/sdk`'s `waitForCloudSignal` uses, +which now delegates here: names match `^[A-Za-z0-9_-]{1,80}$` and run IDs +`^[A-Za-z0-9_-]{1,128}$` before any request; each GET is held for `waitMs`; a +`204` re-arms, after a 250 ms backoff doubling to a 5 s cap when Cloud answers +in under a second (reset after a normally held `204`). Failures are a +`SignalError` with a `code`: the caller's abort reason is rethrown unchanged; +the per-request deadline and connection resets are `transient_error`; other +network failures `transport_error`; a non-2xx is `http_error` (response bodies +are never echoed); malformed JSON or a body without `payload` and a numeric +`acceptedAt` is `invalid_response`; a missing run ID, token, or bad URL is +`configuration`. + +The wait is not a journaled step. On resume the body calls it again; a read +does not consume the signal, so Cloud answers with the same stored payload +until the signal expires. A module +that imports anything besides `{ flow }` from `@relayflows/surface` is not +certified for parallel `Promise.all` branches, so a flow that imports this +subpath runs its `f.run` groups on the serial scheduler. + +Hosted availability needs a Cloud runtime that pins a Surface with this +subpath and whose Bun compatibility envelope exports `./signals`; locally, the +helper works anywhere you pass `runId` and `token`. + ## 6. The gate contract, and remaining open surface questions The data/code split is settled: Relayflows does not have a serializable diff --git a/packages/sdk/src/cloud-signal.ts b/packages/sdk/src/cloud-signal.ts index 9c8737337..d2061c75a 100644 --- a/packages/sdk/src/cloud-signal.ts +++ b/packages/sdk/src/cloud-signal.ts @@ -1,18 +1,15 @@ import { setTimeout as delay } from 'node:timers/promises'; -import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, resolveCloudConnection, - transportError, type CloudConnectionOptions } from './cloud-http.js'; +import { HOSTED_SIGNAL_READ_TOKEN_ENV, holdSignalWait, signalPath as sharedSignalPath, + type SignalErrorFactory } from '@relayflows/surface/signals'; +import { CloudFlowError, cloudFetch, isCloudRecord, resolveCloudConnection, + type CloudConnectionOptions } from './cloud-http.js'; -const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u; const MAX_BYTES = 4 * 1024; -const HELD_GET_MS = 25_000; -const HOSTED_SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN'; + +const cloudSignalError: SignalErrorFactory = (code, message, status) => new CloudFlowError(code, message, status); function signalPath(runId: string, name: string): string { - cloudRunId(runId); - if (!SIGNAL_NAME.test(name)) { - throw new CloudFlowError('invalid_input', 'Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters).'); - } - return `/api/v1/workflows/runs/${runId}/signals/${name}`; + return sharedSignalPath(runId, name, cloudSignalError); } /** Store a run-scoped completion. Retrying the same JSON payload is safe. */ @@ -38,73 +35,30 @@ export async function postCloudSignal( } /** - * Wait on Cloud's held GET. A normally held 204 re-arms immediately. A fast - * 204 backs off so a proxy or broken server cannot turn this into a hot loop. + * Wait on Cloud's held GET. The wire behaviour (validation, held GET, fast-204 + * backoff, error classification) is `@relayflows/surface/signals`' single + * implementation, so the SDK and hosted flows cannot drift. The SDK keeps its + * own credential resolution and throws `CloudFlowError`. */ export async function waitForCloudSignal( runId: string, name: string, options: CloudConnectionOptions = {}, ): Promise { - const path = signalPath(runId, name); - const requestTimeout = options.requestTimeoutMs ?? 30_000; - if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= HELD_GET_MS) { - throw new CloudFlowError('invalid_input', 'Signal requestTimeoutMs must exceed 25 seconds.'); - } - let fast204BackoffMs = 250; - for (;;) { - options.signal?.throwIfAborted(); - let connection: { baseUrl: string; token: string }; - try { - connection = await resolveCloudConnection(options); - } catch (error) { - // The hosted run's read-only credential is only a fallback for waits. - // Explicit Cloud credentials and stored logins retain precedence, and - // postCloudSignal never sees this token. - const hostedToken = process.env[HOSTED_SIGNAL_READ_TOKEN_ENV]; - if (!(error instanceof CloudFlowError) || error.code !== 'configuration' - || error.reason !== 'auth_missing' || options.token !== undefined - || process.env['FLOWS_CLOUD_TOKEN'] !== undefined || hostedToken === undefined) throw error; - connection = await resolveCloudConnection({ ...options, token: hostedToken }); - } - const { baseUrl, token } = connection; - const deadline = AbortSignal.timeout(requestTimeout); - const requestStartedAt = performance.now(); - let response: Response; - try { - response = await fetch(`${baseUrl}${path}?waitMs=${HELD_GET_MS}`, { - method: 'GET', - headers: { authorization: `Bearer ${token}` }, - signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline, - redirect: 'error', - }); - } catch (error) { - options.signal?.throwIfAborted(); - throw transportError(deadline.aborted ? deadline.reason : error); - } - if (response.status === 204) { - if (performance.now() - requestStartedAt < 1_000) { - try { - await delay(fast204BackoffMs, undefined, { signal: options.signal }); - } catch (error) { - options.signal?.throwIfAborted(); - throw error; - } - fast204BackoffMs = Math.min(fast204BackoffMs * 2, 5_000); - } else { - fast204BackoffMs = 250; + return holdSignalWait(runId, name, { signal: options.signal, requestTimeoutMs: options.requestTimeoutMs }, { + error: cloudSignalError, + sleep: (ms, signal) => delay(ms, undefined, { signal }), + connection: async () => { + try { + return await resolveCloudConnection(options); + } catch (error) { + // The hosted run's read-only credential is only a fallback for waits. + // Explicit Cloud credentials and stored logins retain precedence, and + // postCloudSignal never sees this token. + const hostedToken = process.env[HOSTED_SIGNAL_READ_TOKEN_ENV]; + if (!(error instanceof CloudFlowError) || error.code !== 'configuration' + || error.reason !== 'auth_missing' || options.token !== undefined + || process.env['FLOWS_CLOUD_TOKEN'] !== undefined || hostedToken === undefined) throw error; + return resolveCloudConnection({ ...options, token: hostedToken }); } - continue; - } - if (!response.ok) throw new CloudFlowError('http_error', `Cloud signal wait failed with HTTP ${response.status}.`, response.status); - let body: unknown; - try { body = await response.json(); } - catch (error) { - options.signal?.throwIfAborted(); - if (!(error instanceof SyntaxError)) throw transportError(deadline.aborted ? deadline.reason : error); - throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.'); - } - if (!isCloudRecord(body) || !Object.hasOwn(body, 'payload') || typeof body.acceptedAt !== 'number') { - throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.'); - } - return body.payload; - } + }, + }); } diff --git a/packages/surface/package.json b/packages/surface/package.json index 28a5e1daf..2e9b3d1c3 100644 --- a/packages/surface/package.json +++ b/packages/surface/package.json @@ -25,6 +25,11 @@ "types": "./dist/triggers/*.d.ts", "import": "./dist/triggers/*.js", "require": "./dist/triggers/*.js" + }, + "./signals": { + "types": "./dist/signals.d.ts", + "import": "./dist/signals.js", + "require": "./dist/signals.js" } }, "files": [ diff --git a/packages/surface/src/signals.ts b/packages/surface/src/signals.ts new file mode 100644 index 000000000..9938f464d --- /dev/null +++ b/packages/surface/src/signals.ts @@ -0,0 +1,258 @@ +/** + * Hosted completion signals: `@relayflows/surface/signals`. + * + * Hosted Cloud runs materialize only `@relayflows/surface` next to the flow + * source, so this module must stay importable on its own: no imports beyond + * Node built-ins, and Cloud is reached through the global `fetch`. It is the + * single implementation of the held-GET signal wait; `@relayflows/sdk`'s + * `waitForCloudSignal` delegates to {@link holdSignalWait}. + * + * Call {@link waitForSignal} from the authored flow body. The run-bound read + * credential lives only in that process; `f.run` children and agents never + * receive it. + */ +import { setTimeout as delay } from "node:timers/promises"; + +/** The longest hold Cloud's signal GET accepts, and the default. */ +export const SIGNAL_MAX_WAIT_MS = 25_000; +/** Cloud's run ID for this hosted run (not the engine's `RELAYFLOW_RUN_ID`). */ +export const HOSTED_RUN_ID_ENV = "RELAYFLOW_V2_CLOUD_RUN_ID"; +/** The run-bound, GET-only `workflow:signals:read` bearer Cloud gives the authored process. */ +export const HOSTED_SIGNAL_READ_TOKEN_ENV = "RELAYFLOW_V2_SIGNAL_READ_TOKEN"; +/** The Cloud application base URL the Flows CLI uses. */ +export const CLOUD_URL_ENV = "FLOWS_CLOUD_URL"; +export const DEFAULT_CLOUD_URL = "https://agentrelay.com/cloud"; + +const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u; +const RUN_ID = /^[A-Za-z0-9_-]{1,128}$/u; +const MAX_TIMER_MS = 2_147_483_647; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const FAST_204_MS = 1_000; +const FAST_204_INITIAL_BACKOFF_MS = 250; +const FAST_204_MAX_BACKOFF_MS = 5_000; + +export type SignalErrorCode = + | "configuration" + | "invalid_input" + | "invalid_response" + | "http_error" + | "transport_error" + | "transient_error"; + +/** Set only for `configuration`: which local setting is missing or wrong. */ +export type SignalConfigurationReason = + | "run_id_missing" + | "auth_missing" + | "url_invalid" + | "timeout_invalid"; + +export class SignalError extends Error { + reason?: SignalConfigurationReason; + + constructor( + readonly code: SignalErrorCode, + message: string, + readonly status?: number, + ) { + super(message); + this.name = "SignalError"; + } +} + +export interface WaitForSignalOptions { + /** Hold per request, 0–25000 ms. Defaults to 25000. */ + waitMs?: number; + /** Cancels the wait; the caller's abort reason is rethrown unchanged. */ + signal?: AbortSignal; + /** Defaults to `process.env.RELAYFLOW_V2_CLOUD_RUN_ID`. */ + runId?: string; + /** Defaults to `process.env.FLOWS_CLOUD_URL`, then https://agentrelay.com/cloud. */ + apiUrl?: string; + /** Defaults to `process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN`. */ + token?: string; + /** Bounds each held request; must exceed `waitMs`. Defaults to 30000. */ + requestTimeoutMs?: number; +} + +/** Builds the error a caller sees; lets the SDK keep throwing `CloudFlowError`. */ +export type SignalErrorFactory = ( + code: Exclude, + message: string, + status?: number, +) => Error; + +/** + * The seams `@relayflows/sdk` supplies to share this implementation. Not part + * of the authored-flow surface; use {@link waitForSignal} in a flow. + */ +export interface SignalWaitHooks { + /** Resolved before every held request so a rotated credential is picked up. */ + connection(): Promise<{ baseUrl: string; token: string }> | { baseUrl: string; token: string }; + error?: SignalErrorFactory; + /** Abort-aware sleep used for the fast-204 backoff. */ + sleep?: (ms: number, signal?: AbortSignal) => Promise; +} + +const signalError: SignalErrorFactory = (code, message, status) => + new SignalError(code, message, status); + +function configurationError(reason: SignalConfigurationReason, message: string): SignalError { + const error = new SignalError("configuration", message); + error.reason = reason; + return error; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Validates both path segments, run ID first, before any request. */ +export function signalPath(runId: unknown, name: unknown, error: SignalErrorFactory = signalError): string { + // Defensive path-segment constraint; accepting a new server ID format needs a Surface change. + if (typeof runId !== "string" || !RUN_ID.test(runId)) { + throw error("invalid_response", "Cloud run ID must contain only letters, digits, underscores, or hyphens."); + } + if (typeof name !== "string" || !SIGNAL_NAME.test(name)) { + throw error("invalid_input", "Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters)."); + } + return `/api/v1/workflows/runs/${runId}/signals/${name}`; +} + +/** Same classification as the SDK's Cloud transport: retryable network faults are transient. */ +function transportError(cause: unknown, error: SignalErrorFactory): Error { + const inner = cause instanceof Error ? cause.cause : undefined; + const code = isRecord(inner) ? inner.code : undefined; + const transient = (cause instanceof Error && cause.name === "TimeoutError") + || ["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ETIMEDOUT", + "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET"].includes(String(code)); + return transient + ? error("transient_error", "Cloud transport temporarily unavailable.") + : error("transport_error", "Cloud transport failed; check TLS and the configured URL (redirects are refused)."); +} + +function normalizeCloudBaseUrl(raw: string): string | undefined { + try { + const url = new URL(raw); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash + || !/^\/[A-Za-z0-9/_-]*$/u.test(url.pathname)) return undefined; + return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`; + } catch { + return undefined; + } +} + +/** + * The shared held-GET loop. A normally held 204 re-arms immediately; a fast + * 204 (under one second) backs off 250 ms doubling to a 5 s cap, so a proxy or + * broken server cannot turn the wait into a hot loop. The backoff resets after + * a normally held 204. + * + * `@relayflows/sdk` calls this with its own connection resolver and error + * class; authored flows should call {@link waitForSignal}. + */ +export async function holdSignalWait( + runId: unknown, + name: unknown, + options: Pick, + hooks: SignalWaitHooks, +): Promise { + const error = hooks.error ?? signalError; + const sleep = hooks.sleep ?? ((ms: number, signal?: AbortSignal) => delay(ms, undefined, { signal })); + const path = signalPath(runId, name, error); + const waitMs = options.waitMs ?? SIGNAL_MAX_WAIT_MS; + if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > SIGNAL_MAX_WAIT_MS) { + throw error("invalid_input", "Signal waitMs must be an integer from 0 to 25000."); + } + const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= waitMs) { + throw error("invalid_input", `Signal requestTimeoutMs must exceed ${waitMs / 1000} seconds.`); + } + let fast204BackoffMs = FAST_204_INITIAL_BACKOFF_MS; + for (;;) { + options.signal?.throwIfAborted(); + const { baseUrl, token } = await hooks.connection(); + const deadline = AbortSignal.timeout(requestTimeout); + const requestStartedAt = performance.now(); + let response: Response; + try { + response = await fetch(`${baseUrl}${path}?waitMs=${waitMs}`, { + method: "GET", + headers: { authorization: `Bearer ${token}` }, + signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline, + // A redirect must never carry the credential to a different origin. + redirect: "error", + }); + } catch (cause) { + options.signal?.throwIfAborted(); + throw transportError(deadline.aborted ? deadline.reason : cause, error); + } + if (response.status === 204) { + if (performance.now() - requestStartedAt < FAST_204_MS) { + try { + await sleep(fast204BackoffMs, options.signal); + } catch (cause) { + options.signal?.throwIfAborted(); + throw cause; + } + fast204BackoffMs = Math.min(fast204BackoffMs * 2, FAST_204_MAX_BACKOFF_MS); + } else { + fast204BackoffMs = FAST_204_INITIAL_BACKOFF_MS; + } + continue; + } + // Do not echo server response bodies: they may contain credentials or source. + if (!response.ok) throw error("http_error", `Cloud signal wait failed with HTTP ${response.status}.`, response.status); + let body: unknown; + try { + body = await response.json(); + } catch (cause) { + options.signal?.throwIfAborted(); + if (!(cause instanceof SyntaxError)) throw transportError(deadline.aborted ? deadline.reason : cause, error); + throw error("invalid_response", "Cloud returned an invalid signal."); + } + if (!isRecord(body) || !Object.hasOwn(body, "payload") || typeof body.acceptedAt !== "number") { + throw error("invalid_response", "Cloud returned an invalid signal."); + } + return body.payload; + } +} + +function hostedConnection(options: WaitForSignalOptions): { baseUrl: string; token: string } { + const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + if (requestTimeout > MAX_TIMER_MS) { + throw configurationError("timeout_invalid", "requestTimeoutMs must be a positive 32-bit integer."); + } + const rawToken = options.token ?? process.env[HOSTED_SIGNAL_READ_TOKEN_ENV]; + const token = rawToken?.trim(); + if (!token || /[\r\n]/u.test(rawToken!) || /^(?:rk|ot)_live_/u.test(token)) { + throw configurationError("auth_missing", + `waitForSignal needs a Cloud signal-read token: pass { token }, or call it from a hosted run's authored body, where ${HOSTED_SIGNAL_READ_TOKEN_ENV} is set.`); + } + const raw = options.apiUrl ?? process.env[CLOUD_URL_ENV] ?? DEFAULT_CLOUD_URL; + const baseUrl = normalizeCloudBaseUrl(raw); + if (baseUrl === undefined) { + throw configurationError("url_invalid", "Cloud URL must be an absolute HTTPS base URL with a plain path."); + } + return { baseUrl, token }; +} + +/** + * Wait for this hosted run's completion signal and return its JSON payload. + * + * ```ts + * import { waitForSignal } from "@relayflows/surface/signals"; + * const done = await waitForSignal("native_done"); + * ``` + * + * Call it in the authored flow body, not inside an `f.run` command: only the + * authored process holds the run-bound read token. The wait has no overall + * deadline; pass `signal` (e.g. `AbortSignal.timeout(ms)`) to bound it. + */ +export async function waitForSignal(name: string, options: WaitForSignalOptions = {}): Promise { + const runId = options.runId ?? process.env[HOSTED_RUN_ID_ENV]; + if (runId === undefined || runId === "") { + throw configurationError("run_id_missing", + `waitForSignal needs a Cloud run ID: pass { runId }, or call it from a hosted run, where ${HOSTED_RUN_ID_ENV} is set.`); + } + return holdSignalWait(runId, name, options, { connection: () => hostedConnection(options) }); +} diff --git a/packages/surface/tests/fixtures/cloud-surface-envelope.ts b/packages/surface/tests/fixtures/cloud-surface-envelope.ts new file mode 100644 index 000000000..b49d375c8 --- /dev/null +++ b/packages/surface/tests/fixtures/cloud-surface-envelope.ts @@ -0,0 +1,62 @@ +// Replica of Cloud's hosted Surface materialization (AgentWorkforce/cloud +// packages/core/src/bootstrap/lib/relayflow-v2-executor.ts, +// materializeRelayflowV2SurfacePackage, at cloud main 5b31e1016: the +// supportedExports shape check ~3988-4024 and the Bun compatibility envelope +// ~4040-4092). `withSignals: false` is Cloud as shipped; `withSignals: true` +// is the companion change `@relayflows/surface/signals` needs. +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const LEGACY_EXPORTS = { + '.': { types: './dist/index.d.ts', import: './dist/index.js', require: './dist/index.js' }, + './runtime': { types: './dist/runtime.d.ts', import: './dist/runtime.js', require: './dist/runtime.js' }, + './triggers': { types: './dist/triggers/index.d.ts', import: './dist/triggers/index.js', require: './dist/triggers/index.js' }, + './triggers/*': { types: './dist/triggers/*.d.ts', import: './dist/triggers/*.js', require: './dist/triggers/*.js' }, +}; +const SIGNALS_EXPORT = { + './signals': { types: './dist/signals.d.ts', import: './dist/signals.js', require: './dist/signals.js' }, +}; + +export interface EnvelopeOptions { withSignals: boolean } + +/** Cloud's exact-match export check; `false` is Cloud's "unsupported export shape" refusal. */ +export function cloudAcceptsSurfaceManifest(manifest: Record, options: EnvelopeOptions): boolean { + const shapes = [LEGACY_EXPORTS, ...(options.withSignals ? [{ ...LEGACY_EXPORTS, ...SIGNALS_EXPORT }] : [])]; + return manifest.type === 'module' && manifest.main === './dist/index.js' + && shapes.some(shape => JSON.stringify(manifest.exports) === JSON.stringify(shape)); +} + +/** Rewrites an installed Surface in place the way Cloud does before the authored body loads. */ +export function applyCloudSurfaceEnvelope(packageRoot: string, options: EnvelopeOptions): void { + const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) as Record; + const hasSignals = options.withSignals + && Object.hasOwn(manifest.exports as Record, './signals'); + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: manifest.name, + version: manifest.version, + private: true, + type: 'module', + types: './index.d.ts', + exports: { + '.': './index.js', + './runtime': './runtime.js', + './triggers': './triggers/index.js', + './triggers/*': './triggers/*.js', + ...(hasSignals ? { './signals': './signals.js' } : {}), + }, + })); + writeFileSync(join(packageRoot, 'index.js'), "export * from './dist/index.js';\n", { flag: 'wx' }); + writeFileSync(join(packageRoot, 'runtime.js'), "export { getFlowDefinition } from './dist/flow.js';\n", { flag: 'wx' }); + writeFileSync(join(packageRoot, 'index.d.ts'), "export * from './dist/index.js';\n", { flag: 'wx' }); + writeFileSync(join(packageRoot, 'runtime.d.ts'), "export { getFlowDefinition } from './dist/flow.js';\n", { flag: 'wx' }); + if (hasSignals) { + writeFileSync(join(packageRoot, 'signals.js'), "export * from './dist/signals.js';\n", { flag: 'wx' }); + writeFileSync(join(packageRoot, 'signals.d.ts'), "export * from './dist/signals.js';\n", { flag: 'wx' }); + } + mkdirSync(join(packageRoot, 'triggers')); + for (const entry of readdirSync(join(packageRoot, 'dist/triggers')).filter(name => /^[a-z0-9-]+\.js$/u.test(name)).sort()) { + writeFileSync(join(packageRoot, 'triggers', entry), `export * from '../dist/triggers/${entry}';\n`, { flag: 'wx' }); + writeFileSync(join(packageRoot, 'triggers', entry.replace(/\.js$/u, '.d.ts')), + `export * from '../dist/triggers/${entry}';\n`, { flag: 'wx' }); + } +} diff --git a/packages/surface/tests/signals-envelope.test.ts b/packages/surface/tests/signals-envelope.test.ts new file mode 100644 index 000000000..031b3708d --- /dev/null +++ b/packages/surface/tests/signals-envelope.test.ts @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; +import { applyCloudSurfaceEnvelope, cloudAcceptsSurfaceManifest } from './fixtures/cloud-surface-envelope.js'; + +// Runs against the built package (`bun run test` builds first), the payload +// Cloud copies next to hosted flow source. +const surfaceRoot = fileURLToPath(new URL('..', import.meta.url)); +const manifest = JSON.parse(readFileSync(join(surfaceRoot, 'package.json'), 'utf8')) as Record; +const legacyManifest = { ...manifest, exports: Object.fromEntries( + Object.entries(manifest.exports as Record).filter(([key]) => key !== './signals')) }; +const directories: string[] = []; +afterAll(() => { for (const directory of directories) rmSync(directory, { recursive: true, force: true }); }); + +const consumer = ` +globalThis.fetch = async (url, init) => { + console.log('URL ' + url); + console.log('AUTH ' + new Headers(init.headers).get('authorization')); + return Response.json({ payload: { done: true }, acceptedAt: 1 }); +}; +let signals; +try { signals = await import('@relayflows/surface/signals'); } +catch (error) { console.log('IMPORT_FAILED ' + (error.code ?? error.name) + ' ' + error.message); process.exit(0); } +const { getFlowDefinition } = await import('@relayflows/surface/runtime'); +if (typeof getFlowDefinition !== 'function') throw new Error('runtime export lost'); +console.log('PAYLOAD ' + JSON.stringify(await signals.waitForSignal('native_done'))); +`; + +const fixtures = new Map(); +function hostedSource(withSignals: boolean, packageManifest = manifest) { + const key = `${withSignals}:${packageManifest === manifest}`; + const existing = fixtures.get(key); + if (existing) return existing; + const directory = mkdtempSync(join(tmpdir(), 'surface-signals-envelope-')); + directories.push(directory); + const packageRoot = join(directory, 'node_modules/@relayflows/surface'); + mkdirSync(packageRoot, { recursive: true }); + cpSync(join(surfaceRoot, 'dist'), join(packageRoot, 'dist'), { recursive: true }); + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify(packageManifest)); + applyCloudSurfaceEnvelope(packageRoot, { withSignals }); + writeFileSync(join(directory, 'package.json'), '{"type":"module"}'); + writeFileSync(join(directory, 'consumer.mjs'), consumer); + fixtures.set(key, { directory, packageRoot }); + return { directory, packageRoot }; +} + +function run(command: string, directory: string) { + const result = spawnSync(command, ['consumer.mjs'], { + cwd: directory, encoding: 'utf8', timeout: 30_000, + env: { PATH: process.env['PATH'], RELAYFLOW_V2_CLOUD_RUN_ID: 'cloud-run-1', + RELAYFLOW_V2_SIGNAL_READ_TOKEN: 'hosted-read', RELAYFLOW_RUN_ID: 'engine-run' }, + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; +} + +const runtimes = [['node', process.execPath] as const, + ...(spawnSync('bun', ['--version']).status === 0 ? [['bun', 'bun'] as const] : [])]; + +// Each fixture copies the built dist and spawns a runtime; slow disks need headroom. +describe("Cloud's hosted Surface envelope and @relayflows/surface/signals", { timeout: 120_000 }, () => { + it('publishes ./signals, so Cloud as shipped refuses this manifest before the envelope', () => { + expect(Object.keys(manifest.exports as object)).toEqual(['.', './runtime', './triggers', './triggers/*', './signals']); + expect(existsSync(join(surfaceRoot, 'dist/signals.js'))).toBe(true); + // relayflow-v2-executor.ts: "Relayflow v2 authored Surface package has an unsupported export shape". + expect(cloudAcceptsSurfaceManifest(manifest, { withSignals: false })).toBe(false); + expect(cloudAcceptsSurfaceManifest(legacyManifest, { withSignals: false })).toBe(true); + }); + + it.each(runtimes)('the shipped envelope does not export ./signals to the authored body (%s)', (_name, command) => { + const { directory } = hostedSource(false); + expect(run(command, directory)).toContain('IMPORT_FAILED'); + }); + + it.each(runtimes)('the companion envelope resolves ./signals with hosted defaults (%s)', (_name, command) => { + expect(cloudAcceptsSurfaceManifest(manifest, { withSignals: true })).toBe(true); + const { directory } = hostedSource(true); + const output = run(command, directory); + expect(output).toContain('URL https://agentrelay.com/cloud/api/v1/workflows/runs/cloud-run-1/signals/native_done?waitMs=25000'); + expect(output).toContain('AUTH Bearer hosted-read'); + expect(output).toContain('PAYLOAD {"done":true}'); + }); + + it('the companion envelope keeps a pre-signals Surface byte-identical', () => { + expect(cloudAcceptsSurfaceManifest(legacyManifest, { withSignals: true })).toBe(true); + const shipped = hostedSource(false, legacyManifest); + const companion = hostedSource(true, legacyManifest); + expect(readFileSync(join(companion.packageRoot, 'package.json'), 'utf8')) + .toBe(readFileSync(join(shipped.packageRoot, 'package.json'), 'utf8')); + expect(existsSync(join(companion.packageRoot, 'signals.js'))).toBe(false); + }); +}); diff --git a/packages/surface/tests/signals.test.ts b/packages/surface/tests/signals.test.ts new file mode 100644 index 000000000..264a8304b --- /dev/null +++ b/packages/surface/tests/signals.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { holdSignalWait, SignalError, waitForSignal } from '../src/signals.js'; + +const clock = vi.hoisted(() => ({ + now: 0, + sleep: null as null | ((ms: number, signal?: AbortSignal) => Promise), +})); +vi.mock('node:timers/promises', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + setTimeout: (ms: number, value?: unknown, options?: { signal?: AbortSignal }) => + clock.sleep ? clock.sleep(ms, options?.signal).then(() => value) : actual.setTimeout(ms, value, options), + }; +}); + +const options = { runId: 'run_1', apiUrl: 'https://cloud.example', token: 'scoped-token' }; +const path = '/api/v1/workflows/runs/run_1/signals/render_done'; +afterEach(() => { clock.sleep = null; clock.now = 0; vi.restoreAllMocks(); vi.unstubAllEnvs(); }); + +describe('@relayflows/surface/signals waitForSignal', () => { + it('holds one GET with waitMs=25000 until the signal lands', async () => { + let release!: () => void; + const written = new Promise(resolve => { release = resolve; }); + const calls: Array<{ method: string; path: string; auth: string | null; redirect?: string }> = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = new URL(String(input)); + calls.push({ method: init?.method ?? 'GET', path: url.pathname, + auth: new Headers(init?.headers).get('authorization'), redirect: init?.redirect }); + expect(url.searchParams.get('waitMs')).toBe('25000'); + await written; + return Response.json({ payload: { complete: true }, acceptedAt: 123 }); + }); + const start = performance.now(); + const waiter = waitForSignal('render_done', options); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(calls).toHaveLength(1); + release(); + expect(await waiter).toEqual({ complete: true }); + expect(performance.now() - start).toBeLessThan(1000); + expect(calls).toEqual([{ method: 'GET', path, auth: 'Bearer scoped-token', redirect: 'error' }]); + }); + + it('defaults runId, token, and base URL from the hosted environment', async () => { + vi.stubEnv('RELAYFLOW_V2_CLOUD_RUN_ID', 'cloud-run-uuid'); + vi.stubEnv('RELAYFLOW_RUN_ID', 'engine-run-id'); + vi.stubEnv('RELAYFLOW_V2_SIGNAL_READ_TOKEN', 'hosted-read-secret'); + vi.stubEnv('FLOWS_CLOUD_URL', 'https://staging.example/cloud/'); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ payload: 'ok', acceptedAt: 1 })); + expect(await waitForSignal('native_done')).toBe('ok'); + expect(String(fetch.mock.calls[0]![0])) + .toBe('https://staging.example/cloud/api/v1/workflows/runs/cloud-run-uuid/signals/native_done?waitMs=25000'); + expect(new Headers(fetch.mock.calls[0]![1]?.headers).get('authorization')).toBe('Bearer hosted-read-secret'); + }); + + it('falls back to https://agentrelay.com/cloud and lets explicit options win over the environment', async () => { + vi.stubEnv('RELAYFLOW_V2_CLOUD_RUN_ID', 'env-run'); + vi.stubEnv('RELAYFLOW_V2_SIGNAL_READ_TOKEN', 'env-token'); + vi.stubEnv('FLOWS_CLOUD_URL', undefined); + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ payload: 1, acceptedAt: 1 })); + await waitForSignal('native_done'); + expect(String(fetch.mock.calls[0]![0])) + .toBe('https://agentrelay.com/cloud/api/v1/workflows/runs/env-run/signals/native_done?waitMs=25000'); + vi.stubEnv('FLOWS_CLOUD_URL', 'https://ignored.example'); + await waitForSignal('native_done', { runId: 'arg_run', token: 'arg-token', apiUrl: 'https://cloud.example', waitMs: 500 }); + expect(String(fetch.mock.calls[1]![0])) + .toBe('https://cloud.example/api/v1/workflows/runs/arg_run/signals/native_done?waitMs=500'); + expect(new Headers(fetch.mock.calls[1]![1]?.headers).get('authorization')).toBe('Bearer arg-token'); + }); + + it('refuses a missing run ID or token as configuration, before any request, without echoing a token', async () => { + vi.stubEnv('RELAYFLOW_V2_CLOUD_RUN_ID', undefined); + vi.stubEnv('RELAYFLOW_V2_SIGNAL_READ_TOKEN', undefined); + vi.stubEnv('RELAYFLOW_RUN_ID', 'engine-run-id'); + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ payload: 1, acceptedAt: 1 })); + await expect(waitForSignal('native_done', { token: 't' })) + .rejects.toMatchObject({ name: 'SignalError', code: 'configuration', reason: 'run_id_missing' }); + await expect(waitForSignal('native_done', { runId: 'run_1' })) + .rejects.toMatchObject({ code: 'configuration', reason: 'auth_missing' }); + for (const token of [' ', 'line\nbreak', 'rk_live_relay-workspace-key']) { + const refusal = await waitForSignal('native_done', { runId: 'run_1', token }).catch((error: unknown) => error); + expect(refusal).toMatchObject({ code: 'configuration', reason: 'auth_missing' }); + expect(String((refusal as Error).message)).not.toContain(token.trim() || '\u0000'); + } + await expect(waitForSignal('native_done', { ...options, apiUrl: 'http://cloud.example' })) + .rejects.toMatchObject({ code: 'configuration', reason: 'url_invalid' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects malformed names, run IDs, waitMs, and timeouts before an HTTP request', async () => { + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ payload: 1, acceptedAt: 1 })); + await expect(waitForSignal('bad/name', options)).rejects.toMatchObject({ code: 'invalid_input' }); + await expect(waitForSignal('x'.repeat(81), options)).rejects.toMatchObject({ code: 'invalid_input' }); + await expect(waitForSignal('render_done', { ...options, runId: '../run' })) + .rejects.toMatchObject({ code: 'invalid_response' }); + for (const waitMs of [-1, 25_001, 1.5, Number.NaN]) { + await expect(waitForSignal('render_done', { ...options, waitMs })).rejects.toMatchObject({ code: 'invalid_input' }); + } + await expect(waitForSignal('render_done', { ...options, requestTimeoutMs: 25_000 })) + .rejects.toMatchObject({ code: 'invalid_input', message: 'Signal requestTimeoutMs must exceed 25 seconds.' }); + await expect(waitForSignal('render_done', { ...options, requestTimeoutMs: 2_147_483_648 })) + .rejects.toMatchObject({ code: 'configuration', reason: 'timeout_invalid' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('backs off a fast 204 before re-arming', async () => { + let reads = 0; + const started = performance.now(); + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + reads += 1; + return reads === 1 ? new Response(null, { status: 204 }) : Response.json({ payload: 7, acceptedAt: 123 }); + }); + expect(await waitForSignal('render_done', options)).toBe(7); + expect(reads).toBe(2); + expect(performance.now() - started).toBeGreaterThanOrEqual(200); + }); + + it('bounds instant 204 retries and lets abort interrupt the backoff', async () => { + const controller = new AbortController(); + let reads = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + reads += 1; + if (reads > 8) throw new Error('instant-204 hot loop'); + return new Response(null, { status: 204 }); + }); + const abort = setTimeout(() => controller.abort(new Error('cancel wait')), 30); + try { + await expect(waitForSignal('render_done', { ...options, signal: controller.signal })) + .rejects.toThrow('cancel wait'); + expect(reads).toBeLessThanOrEqual(2); + } finally { + clearTimeout(abort); + } + }); + + it('caps repeated fast 204 backoff at five seconds on a controlled clock', async () => { + clock.sleep = async (ms, signal) => { signal?.throwIfAborted(); clock.now += ms; }; + vi.spyOn(performance, 'now').mockImplementation(() => clock.now); + const reads: number[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + reads.push(performance.now()); + return reads.length <= 7 ? new Response(null, { status: 204 }) + : Response.json({ payload: 7, acceptedAt: 123 }); + }); + expect(await waitForSignal('render_done', options)).toBe(7); + expect(reads).toHaveLength(8); + expect(reads.slice(1).map((value, index) => value - reads[index]!)) + .toEqual([250, 500, 1_000, 2_000, 4_000, 5_000, 5_000]); + }); + + it('resets the fast-204 backoff after a held response', async () => { + clock.sleep = async (ms, signal) => { signal?.throwIfAborted(); clock.now += ms; }; + vi.spyOn(performance, 'now').mockImplementation(() => clock.now); + const reads: number[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + reads.push(performance.now()); + if (reads.length === 3) { + clock.now += 25_000; + } + return reads.length <= 4 ? new Response(null, { status: 204 }) + : Response.json({ payload: 7, acceptedAt: 123 }); + }); + expect(await waitForSignal('render_done', options)).toBe(7); + expect(reads).toHaveLength(5); + expect(reads[1]! - reads[0]!).toBe(250); + expect(reads[2]! - reads[1]!).toBe(500); + expect(reads[3]! - reads[2]!).toBe(25_000); + expect(reads[4]! - reads[3]!).toBe(250); + }); + + it('rethrows caller abort while reading the signal body', async () => { + const controller = new AbortController(); + const reason = new Error('stop waiting'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => ({ + ok: true, status: 200, + json: () => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); + }), + } as Response)); + const waiting = waitForSignal('render_done', { ...options, signal: controller.signal }); + const result = expect(waiting).rejects.toBe(reason); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledOnce()); + controller.abort(reason); + await result; + }); + + it('rethrows an already-aborted caller signal without a request', async () => { + const reason = new Error('already gone'); + const fetch = vi.spyOn(globalThis, 'fetch'); + await expect(waitForSignal('render_done', { ...options, signal: AbortSignal.abort(reason) })).rejects.toBe(reason); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('treats the per-request deadline during body read as transient', async () => { + const deadline = new AbortController(); + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => ({ + ok: true, status: 200, + json: () => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); + }), + } as Response)); + const waiting = waitForSignal('render_done', options); + const result = expect(waiting).rejects.toMatchObject({ code: 'transient_error' }); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledOnce()); + deadline.abort(new DOMException('deadline', 'TimeoutError')); + await result; + }); + + it('classifies a reset connection during fetch as transient', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new TypeError('fetch failed', { cause: { code: 'ECONNRESET' } }), + ); + await expect(waitForSignal('render_done', options)).rejects.toMatchObject({ code: 'transient_error' }); + }); + + it('classifies a reset connection during body read as transient', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, status: 200, + json: () => Promise.reject(new TypeError('read failed', { cause: { code: 'ECONNRESET' } })), + } as Response); + await expect(waitForSignal('render_done', options)).rejects.toMatchObject({ code: 'transient_error' }); + }); + + it('classifies other fetch failures as transport_error and HTTP refusals as http_error', async () => { + vi.spyOn(globalThis, 'fetch') + .mockRejectedValueOnce(new TypeError('fetch failed', { cause: { code: 'CERT_HAS_EXPIRED' } })) + .mockResolvedValueOnce(new Response('{"token":"leak"}', { status: 403 })); + await expect(waitForSignal('render_done', options)).rejects.toMatchObject({ code: 'transport_error' }); + const refusal = await waitForSignal('render_done', options).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(SignalError); + expect(refusal).toMatchObject({ code: 'http_error', status: 403 }); + expect(String(refusal)).not.toContain('leak'); + }); + + it('reserves invalid_response for malformed JSON and signal shape', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('bad JSON')) } as Response) + .mockResolvedValueOnce(Response.json({ payload: 1 })) + .mockResolvedValueOnce(Response.json({ acceptedAt: 1 })) + .mockResolvedValueOnce(Response.json([1])); + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(waitForSignal('render_done', options)).rejects.toMatchObject({ code: 'invalid_response' }); + } + expect(fetch).toHaveBeenCalledTimes(4); + }); + + it('returns a null payload as a delivered signal', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(Response.json({ payload: null, acceptedAt: 1 })); + expect(await waitForSignal('render_done', options)).toBeNull(); + }); +}); + +describe('holdSignalWait shared engine', () => { + it('re-resolves the connection per held request and maps errors through the caller factory', async () => { + class Custom extends Error { constructor(readonly code: string, message: string, readonly status?: number) { super(message); } } + let resolved = 0; + vi.spyOn(globalThis, 'fetch') + .mockImplementationOnce(async () => { clock.now += 25_000; return new Response(null, { status: 204 }); }) + .mockResolvedValueOnce(new Response(null, { status: 500 })); + vi.spyOn(performance, 'now').mockImplementation(() => clock.now); + const refusal = await holdSignalWait('run_1', 'render_done', {}, { + connection: () => ({ baseUrl: 'https://cloud.example', token: `t${++resolved}` }), + error: (code, message, status) => new Custom(code, message, status), + }).catch((error: unknown) => error); + expect(resolved).toBe(2); + expect(refusal).toBeInstanceOf(Custom); + expect(refusal).toMatchObject({ code: 'http_error', status: 500 }); + }); +}); diff --git a/scripts/pack-release.mjs b/scripts/pack-release.mjs index fbe78108c..fcc3c6dc7 100644 --- a/scripts/pack-release.mjs +++ b/scripts/pack-release.mjs @@ -39,7 +39,7 @@ try { : name === 'relayflows' ? ['bin/flows.js'] : ['dist/index.js', 'dist/index.d.ts']; - if (name === 'surface') required.push('dist/runtime.js', 'dist/runtime.d.ts'); + if (name === 'surface') required.push('dist/runtime.js', 'dist/runtime.d.ts', 'dist/signals.js', 'dist/signals.d.ts'); if (name === 'sdk') required.push('dist/cli.js'); for (const file of required) { assert(packed.files.some((entry) => entry.path === file), `missing package/${file}`); diff --git a/scripts/surface-package-gate.sh b/scripts/surface-package-gate.sh index e6cbd0f0a..71015208b 100755 --- a/scripts/surface-package-gate.sh +++ b/scripts/surface-package-gate.sh @@ -107,6 +107,42 @@ try { console.log(`PACKED_RUNTIME_REFUSAL_OK invalidHeaders=${invalidHeaders.length} forgedHandle=refused`); NODE +# Hosted flows get only this package, so the signal helper must resolve from +# the tarball alone and import nothing but Node built-ins. +RELAYFLOW_V2_CLOUD_RUN_ID=packed-run RELAYFLOW_V2_SIGNAL_READ_TOKEN=packed-read RELAYFLOW_RUN_ID=engine-run \ + node --input-type=module - <<'NODE' +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { waitForSignal, SignalError } from '@relayflows/surface/signals'; + +const require = createRequire(import.meta.url); +const entry = require.resolve('@relayflows/surface/signals'); +// Statement-level only: the module's doc comment shows an example import. +const specifiers = [...readFileSync(entry, 'utf8') + .matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']/gm)] + .map((match) => match[1] ?? match[2]); +if (specifiers.length === 0 || specifiers.some((specifier) => !specifier.startsWith('node:'))) { + throw new Error(`packed signals helper imports beyond Node built-ins: ${specifiers.join(', ')}`); +} +let requested; +globalThis.fetch = async (url, init) => { + requested = { url: String(url), auth: new Headers(init.headers).get('authorization') }; + return Response.json({ payload: { done: true }, acceptedAt: 1 }); +}; +const payload = await waitForSignal('native_done'); +if (requested.url !== 'https://agentrelay.com/cloud/api/v1/workflows/runs/packed-run/signals/native_done?waitMs=25000' + || requested.auth !== 'Bearer packed-read' || payload?.done !== true) { + throw new Error(`packed signals helper used the wrong hosted defaults: ${JSON.stringify(requested)}`); +} +try { + await waitForSignal('bad/name'); + throw new Error('packed signals helper accepted an invalid name'); +} catch (error) { + if (!(error instanceof SignalError) || error.code !== 'invalid_input') throw error; +} +console.log(`PACKED_SIGNALS_OK imports=${specifiers.join(',')}`); +NODE + cat > consume.mts <<'TS' import { flow, @@ -126,6 +162,11 @@ import { getFlowDefinition, type AuthoredFlowDefinition, } from '@relayflows/surface/runtime'; +import { + waitForSignal, + type SignalError, + type WaitForSignalOptions, +} from '@relayflows/surface/signals'; const body = async (f: Ctx): Promise => { const ran: Step = f.run('true'); @@ -137,6 +178,9 @@ const body = async (f: Ctx): Promise => { const post: Step = slack.post('#test', 'hi'); await post; await f.slack.post('#test', 'typed'); + const signalOptions: WaitForSignalOptions = { waitMs: 25_000 }; + const signalled: unknown = await waitForSignal('native_done', signalOptions); + void signalled; f.done('success'); }; const header: FlowHeader = { identity: 'packed-type-consumer' }; @@ -150,6 +194,8 @@ const refuseStepReason = (f: Ctx, reason: CompletionReason): void => { f.done(reason); }; const helper: CloudHelper | undefined = undefined; +const signalFailure: SignalError['code'] = 'transient_error'; +void signalFailure; void definition; void helper; void stepReason; From da059aa3264056c48d2283d4b8b34173e59920a7 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 19 Oct 2026 12:21:15 +0200 Subject: [PATCH 2/2] Move release-gate checks out of the implementation PR Flows AGENTS.md: never edit a gate that judges your own work. The surface-package-gate.sh and pack-release.mjs assertions for ./signals move to a follow-up PR that lands after this one. Co-Authored-By: Claude Opus 5.5 --- scripts/pack-release.mjs | 2 +- scripts/surface-package-gate.sh | 46 --------------------------------- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/scripts/pack-release.mjs b/scripts/pack-release.mjs index fcc3c6dc7..fbe78108c 100644 --- a/scripts/pack-release.mjs +++ b/scripts/pack-release.mjs @@ -39,7 +39,7 @@ try { : name === 'relayflows' ? ['bin/flows.js'] : ['dist/index.js', 'dist/index.d.ts']; - if (name === 'surface') required.push('dist/runtime.js', 'dist/runtime.d.ts', 'dist/signals.js', 'dist/signals.d.ts'); + if (name === 'surface') required.push('dist/runtime.js', 'dist/runtime.d.ts'); if (name === 'sdk') required.push('dist/cli.js'); for (const file of required) { assert(packed.files.some((entry) => entry.path === file), `missing package/${file}`); diff --git a/scripts/surface-package-gate.sh b/scripts/surface-package-gate.sh index 71015208b..e6cbd0f0a 100755 --- a/scripts/surface-package-gate.sh +++ b/scripts/surface-package-gate.sh @@ -107,42 +107,6 @@ try { console.log(`PACKED_RUNTIME_REFUSAL_OK invalidHeaders=${invalidHeaders.length} forgedHandle=refused`); NODE -# Hosted flows get only this package, so the signal helper must resolve from -# the tarball alone and import nothing but Node built-ins. -RELAYFLOW_V2_CLOUD_RUN_ID=packed-run RELAYFLOW_V2_SIGNAL_READ_TOKEN=packed-read RELAYFLOW_RUN_ID=engine-run \ - node --input-type=module - <<'NODE' -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { waitForSignal, SignalError } from '@relayflows/surface/signals'; - -const require = createRequire(import.meta.url); -const entry = require.resolve('@relayflows/surface/signals'); -// Statement-level only: the module's doc comment shows an example import. -const specifiers = [...readFileSync(entry, 'utf8') - .matchAll(/^\s*(?:import|export)\b[^;]*?\bfrom\s+["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']/gm)] - .map((match) => match[1] ?? match[2]); -if (specifiers.length === 0 || specifiers.some((specifier) => !specifier.startsWith('node:'))) { - throw new Error(`packed signals helper imports beyond Node built-ins: ${specifiers.join(', ')}`); -} -let requested; -globalThis.fetch = async (url, init) => { - requested = { url: String(url), auth: new Headers(init.headers).get('authorization') }; - return Response.json({ payload: { done: true }, acceptedAt: 1 }); -}; -const payload = await waitForSignal('native_done'); -if (requested.url !== 'https://agentrelay.com/cloud/api/v1/workflows/runs/packed-run/signals/native_done?waitMs=25000' - || requested.auth !== 'Bearer packed-read' || payload?.done !== true) { - throw new Error(`packed signals helper used the wrong hosted defaults: ${JSON.stringify(requested)}`); -} -try { - await waitForSignal('bad/name'); - throw new Error('packed signals helper accepted an invalid name'); -} catch (error) { - if (!(error instanceof SignalError) || error.code !== 'invalid_input') throw error; -} -console.log(`PACKED_SIGNALS_OK imports=${specifiers.join(',')}`); -NODE - cat > consume.mts <<'TS' import { flow, @@ -162,11 +126,6 @@ import { getFlowDefinition, type AuthoredFlowDefinition, } from '@relayflows/surface/runtime'; -import { - waitForSignal, - type SignalError, - type WaitForSignalOptions, -} from '@relayflows/surface/signals'; const body = async (f: Ctx): Promise => { const ran: Step = f.run('true'); @@ -178,9 +137,6 @@ const body = async (f: Ctx): Promise => { const post: Step = slack.post('#test', 'hi'); await post; await f.slack.post('#test', 'typed'); - const signalOptions: WaitForSignalOptions = { waitMs: 25_000 }; - const signalled: unknown = await waitForSignal('native_done', signalOptions); - void signalled; f.done('success'); }; const header: FlowHeader = { identity: 'packed-type-consumer' }; @@ -194,8 +150,6 @@ const refuseStepReason = (f: Ctx, reason: CompletionReason): void => { f.done(reason); }; const helper: CloudHelper | undefined = undefined; -const signalFailure: SignalError['code'] = 'transient_error'; -void signalFailure; void definition; void helper; void stepReason;