Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 28 additions & 74 deletions packages/sdk/src/cloud-signal.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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<unknown> {
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;
}
},
});
}
5 changes: 5 additions & 0 deletions packages/surface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading
Loading