@relayflows/surface 2.0.53 → 2.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ /** The longest hold Cloud's signal GET accepts, and the default. */
2
+ export declare const SIGNAL_MAX_WAIT_MS = 25000;
3
+ /** Cloud's run ID for this hosted run (not the engine's `RELAYFLOW_RUN_ID`). */
4
+ export declare const HOSTED_RUN_ID_ENV = "RELAYFLOW_V2_CLOUD_RUN_ID";
5
+ /** The run-bound, GET-only `workflow:signals:read` bearer Cloud gives the authored process. */
6
+ export declare const HOSTED_SIGNAL_READ_TOKEN_ENV = "RELAYFLOW_V2_SIGNAL_READ_TOKEN";
7
+ /** The Cloud application base URL the Flows CLI uses. */
8
+ export declare const CLOUD_URL_ENV = "FLOWS_CLOUD_URL";
9
+ export declare const DEFAULT_CLOUD_URL = "https://agentrelay.com/cloud";
10
+ export type SignalErrorCode = "configuration" | "invalid_input" | "invalid_response" | "http_error" | "transport_error" | "transient_error";
11
+ /** Set only for `configuration`: which local setting is missing or wrong. */
12
+ export type SignalConfigurationReason = "run_id_missing" | "auth_missing" | "url_invalid" | "timeout_invalid";
13
+ export declare class SignalError extends Error {
14
+ readonly code: SignalErrorCode;
15
+ readonly status?: number | undefined;
16
+ reason?: SignalConfigurationReason;
17
+ constructor(code: SignalErrorCode, message: string, status?: number | undefined);
18
+ }
19
+ export interface WaitForSignalOptions {
20
+ /** Hold per request, 0–25000 ms. Defaults to 25000. */
21
+ waitMs?: number;
22
+ /** Cancels the wait; the caller's abort reason is rethrown unchanged. */
23
+ signal?: AbortSignal;
24
+ /** Defaults to `process.env.RELAYFLOW_V2_CLOUD_RUN_ID`. */
25
+ runId?: string;
26
+ /** Defaults to `process.env.FLOWS_CLOUD_URL`, then https://agentrelay.com/cloud. */
27
+ apiUrl?: string;
28
+ /** Defaults to `process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN`. */
29
+ token?: string;
30
+ /** Bounds each held request; must exceed `waitMs`. Defaults to 30000. */
31
+ requestTimeoutMs?: number;
32
+ }
33
+ /** Builds the error a caller sees; lets the SDK keep throwing `CloudFlowError`. */
34
+ export type SignalErrorFactory = (code: Exclude<SignalErrorCode, "configuration">, message: string, status?: number) => Error;
35
+ /**
36
+ * The seams `@relayflows/sdk` supplies to share this implementation. Not part
37
+ * of the authored-flow surface; use {@link waitForSignal} in a flow.
38
+ */
39
+ export interface SignalWaitHooks {
40
+ /** Resolved before every held request so a rotated credential is picked up. */
41
+ connection(): Promise<{
42
+ baseUrl: string;
43
+ token: string;
44
+ }> | {
45
+ baseUrl: string;
46
+ token: string;
47
+ };
48
+ error?: SignalErrorFactory;
49
+ /** Abort-aware sleep used for the fast-204 backoff. */
50
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>;
51
+ }
52
+ /** Validates both path segments, run ID first, before any request. */
53
+ export declare function signalPath(runId: unknown, name: unknown, error?: SignalErrorFactory): string;
54
+ /**
55
+ * The shared held-GET loop. A normally held 204 re-arms immediately; a fast
56
+ * 204 (under one second) backs off 250 ms doubling to a 5 s cap, so a proxy or
57
+ * broken server cannot turn the wait into a hot loop. The backoff resets after
58
+ * a normally held 204.
59
+ *
60
+ * `@relayflows/sdk` calls this with its own connection resolver and error
61
+ * class; authored flows should call {@link waitForSignal}.
62
+ */
63
+ export declare function holdSignalWait(runId: unknown, name: unknown, options: Pick<WaitForSignalOptions, "waitMs" | "signal" | "requestTimeoutMs">, hooks: SignalWaitHooks): Promise<unknown>;
64
+ /**
65
+ * Wait for this hosted run's completion signal and return its JSON payload.
66
+ *
67
+ * ```ts
68
+ * import { waitForSignal } from "@relayflows/surface/signals";
69
+ * const done = await waitForSignal("native_done");
70
+ * ```
71
+ *
72
+ * Call it in the authored flow body, not inside an `f.run` command: only the
73
+ * authored process holds the run-bound read token. The wait has no overall
74
+ * deadline; pass `signal` (e.g. `AbortSignal.timeout(ms)`) to bound it.
75
+ */
76
+ export declare function waitForSignal(name: string, options?: WaitForSignalOptions): Promise<unknown>;
77
+ //# sourceMappingURL=signals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signals.d.ts","sourceRoot":"","sources":["../src/signals.ts"],"names":[],"mappings":"AAeA,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,QAAS,CAAC;AACzC,gFAAgF;AAChF,eAAO,MAAM,iBAAiB,8BAA8B,CAAC;AAC7D,+FAA+F;AAC/F,eAAO,MAAM,4BAA4B,mCAAmC,CAAC;AAC7E,yDAAyD;AACzD,eAAO,MAAM,aAAa,oBAAoB,CAAC;AAC/C,eAAO,MAAM,iBAAiB,iCAAiC,CAAC;AAUhE,MAAM,MAAM,eAAe,GACvB,eAAe,GACf,eAAe,GACf,kBAAkB,GAClB,YAAY,GACZ,iBAAiB,GACjB,iBAAiB,CAAC;AAEtB,6EAA6E;AAC7E,MAAM,MAAM,yBAAyB,GACjC,gBAAgB,GAChB,cAAc,GACd,aAAa,GACb,iBAAiB,CAAC;AAEtB,qBAAa,WAAY,SAAQ,KAAK;IAIlC,QAAQ,CAAC,IAAI,EAAE,eAAe;IAE9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM;IAL1B,MAAM,CAAC,EAAE,yBAAyB,CAAC;gBAGxB,IAAI,EAAE,eAAe,EAC9B,OAAO,EAAE,MAAM,EACN,MAAM,CAAC,EAAE,MAAM,YAAA;CAK3B;AAED,MAAM,WAAW,oBAAoB;IACnC,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,mFAAmF;AACnF,MAAM,MAAM,kBAAkB,GAAG,CAC/B,IAAI,EAAE,OAAO,CAAC,eAAe,EAAE,eAAe,CAAC,EAC/C,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,KACZ,KAAK,CAAC;AAEX;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,UAAU,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/F,KAAK,CAAC,EAAE,kBAAkB,CAAC;IAC3B,uDAAuD;IACvD,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAChE;AAeD,sEAAsE;AACtE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,GAAE,kBAAgC,GAAG,MAAM,CASzG;AAyBD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,OAAO,EACd,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,GAAG,QAAQ,GAAG,kBAAkB,CAAC,EAC7E,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,OAAO,CAAC,CA4DlB;AAqBD;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,OAAO,CAAC,CAOtG"}
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Hosted completion signals: `@relayflows/surface/signals`.
3
+ *
4
+ * Hosted Cloud runs materialize only `@relayflows/surface` next to the flow
5
+ * source, so this module must stay importable on its own: no imports beyond
6
+ * Node built-ins, and Cloud is reached through the global `fetch`. It is the
7
+ * single implementation of the held-GET signal wait; `@relayflows/sdk`'s
8
+ * `waitForCloudSignal` delegates to {@link holdSignalWait}.
9
+ *
10
+ * Call {@link waitForSignal} from the authored flow body. The run-bound read
11
+ * credential lives only in that process; `f.run` children and agents never
12
+ * receive it.
13
+ */
14
+ import { setTimeout as delay } from "node:timers/promises";
15
+ /** The longest hold Cloud's signal GET accepts, and the default. */
16
+ export const SIGNAL_MAX_WAIT_MS = 25_000;
17
+ /** Cloud's run ID for this hosted run (not the engine's `RELAYFLOW_RUN_ID`). */
18
+ export const HOSTED_RUN_ID_ENV = "RELAYFLOW_V2_CLOUD_RUN_ID";
19
+ /** The run-bound, GET-only `workflow:signals:read` bearer Cloud gives the authored process. */
20
+ export const HOSTED_SIGNAL_READ_TOKEN_ENV = "RELAYFLOW_V2_SIGNAL_READ_TOKEN";
21
+ /** The Cloud application base URL the Flows CLI uses. */
22
+ export const CLOUD_URL_ENV = "FLOWS_CLOUD_URL";
23
+ export const DEFAULT_CLOUD_URL = "https://agentrelay.com/cloud";
24
+ const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u;
25
+ const RUN_ID = /^[A-Za-z0-9_-]{1,128}$/u;
26
+ const MAX_TIMER_MS = 2_147_483_647;
27
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
28
+ const FAST_204_MS = 1_000;
29
+ const FAST_204_INITIAL_BACKOFF_MS = 250;
30
+ const FAST_204_MAX_BACKOFF_MS = 5_000;
31
+ export class SignalError extends Error {
32
+ code;
33
+ status;
34
+ reason;
35
+ constructor(code, message, status) {
36
+ super(message);
37
+ this.code = code;
38
+ this.status = status;
39
+ this.name = "SignalError";
40
+ }
41
+ }
42
+ const signalError = (code, message, status) => new SignalError(code, message, status);
43
+ function configurationError(reason, message) {
44
+ const error = new SignalError("configuration", message);
45
+ error.reason = reason;
46
+ return error;
47
+ }
48
+ function isRecord(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+ /** Validates both path segments, run ID first, before any request. */
52
+ export function signalPath(runId, name, error = signalError) {
53
+ // Defensive path-segment constraint; accepting a new server ID format needs a Surface change.
54
+ if (typeof runId !== "string" || !RUN_ID.test(runId)) {
55
+ throw error("invalid_response", "Cloud run ID must contain only letters, digits, underscores, or hyphens.");
56
+ }
57
+ if (typeof name !== "string" || !SIGNAL_NAME.test(name)) {
58
+ throw error("invalid_input", "Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters).");
59
+ }
60
+ return `/api/v1/workflows/runs/${runId}/signals/${name}`;
61
+ }
62
+ /** Same classification as the SDK's Cloud transport: retryable network faults are transient. */
63
+ function transportError(cause, error) {
64
+ const inner = cause instanceof Error ? cause.cause : undefined;
65
+ const code = isRecord(inner) ? inner.code : undefined;
66
+ const transient = (cause instanceof Error && cause.name === "TimeoutError")
67
+ || ["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ETIMEDOUT",
68
+ "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET"].includes(String(code));
69
+ return transient
70
+ ? error("transient_error", "Cloud transport temporarily unavailable.")
71
+ : error("transport_error", "Cloud transport failed; check TLS and the configured URL (redirects are refused).");
72
+ }
73
+ function normalizeCloudBaseUrl(raw) {
74
+ try {
75
+ const url = new URL(raw);
76
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash
77
+ || !/^\/[A-Za-z0-9/_-]*$/u.test(url.pathname))
78
+ return undefined;
79
+ return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ /**
86
+ * The shared held-GET loop. A normally held 204 re-arms immediately; a fast
87
+ * 204 (under one second) backs off 250 ms doubling to a 5 s cap, so a proxy or
88
+ * broken server cannot turn the wait into a hot loop. The backoff resets after
89
+ * a normally held 204.
90
+ *
91
+ * `@relayflows/sdk` calls this with its own connection resolver and error
92
+ * class; authored flows should call {@link waitForSignal}.
93
+ */
94
+ export async function holdSignalWait(runId, name, options, hooks) {
95
+ const error = hooks.error ?? signalError;
96
+ const sleep = hooks.sleep ?? ((ms, signal) => delay(ms, undefined, { signal }));
97
+ const path = signalPath(runId, name, error);
98
+ const waitMs = options.waitMs ?? SIGNAL_MAX_WAIT_MS;
99
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > SIGNAL_MAX_WAIT_MS) {
100
+ throw error("invalid_input", "Signal waitMs must be an integer from 0 to 25000.");
101
+ }
102
+ const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
103
+ if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= waitMs) {
104
+ throw error("invalid_input", `Signal requestTimeoutMs must exceed ${waitMs / 1000} seconds.`);
105
+ }
106
+ let fast204BackoffMs = FAST_204_INITIAL_BACKOFF_MS;
107
+ for (;;) {
108
+ options.signal?.throwIfAborted();
109
+ const { baseUrl, token } = await hooks.connection();
110
+ const deadline = AbortSignal.timeout(requestTimeout);
111
+ const requestStartedAt = performance.now();
112
+ let response;
113
+ try {
114
+ response = await fetch(`${baseUrl}${path}?waitMs=${waitMs}`, {
115
+ method: "GET",
116
+ headers: { authorization: `Bearer ${token}` },
117
+ signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
118
+ // A redirect must never carry the credential to a different origin.
119
+ redirect: "error",
120
+ });
121
+ }
122
+ catch (cause) {
123
+ options.signal?.throwIfAborted();
124
+ throw transportError(deadline.aborted ? deadline.reason : cause, error);
125
+ }
126
+ if (response.status === 204) {
127
+ if (performance.now() - requestStartedAt < FAST_204_MS) {
128
+ try {
129
+ await sleep(fast204BackoffMs, options.signal);
130
+ }
131
+ catch (cause) {
132
+ options.signal?.throwIfAborted();
133
+ throw cause;
134
+ }
135
+ fast204BackoffMs = Math.min(fast204BackoffMs * 2, FAST_204_MAX_BACKOFF_MS);
136
+ }
137
+ else {
138
+ fast204BackoffMs = FAST_204_INITIAL_BACKOFF_MS;
139
+ }
140
+ continue;
141
+ }
142
+ // Do not echo server response bodies: they may contain credentials or source.
143
+ if (!response.ok)
144
+ throw error("http_error", `Cloud signal wait failed with HTTP ${response.status}.`, response.status);
145
+ let body;
146
+ try {
147
+ body = await response.json();
148
+ }
149
+ catch (cause) {
150
+ options.signal?.throwIfAborted();
151
+ if (!(cause instanceof SyntaxError))
152
+ throw transportError(deadline.aborted ? deadline.reason : cause, error);
153
+ throw error("invalid_response", "Cloud returned an invalid signal.");
154
+ }
155
+ if (!isRecord(body) || !Object.hasOwn(body, "payload") || typeof body.acceptedAt !== "number") {
156
+ throw error("invalid_response", "Cloud returned an invalid signal.");
157
+ }
158
+ return body.payload;
159
+ }
160
+ }
161
+ function hostedConnection(options) {
162
+ const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
163
+ if (requestTimeout > MAX_TIMER_MS) {
164
+ throw configurationError("timeout_invalid", "requestTimeoutMs must be a positive 32-bit integer.");
165
+ }
166
+ const rawToken = options.token ?? process.env[HOSTED_SIGNAL_READ_TOKEN_ENV];
167
+ const token = rawToken?.trim();
168
+ if (!token || /[\r\n]/u.test(rawToken) || /^(?:rk|ot)_live_/u.test(token)) {
169
+ 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.`);
170
+ }
171
+ const raw = options.apiUrl ?? process.env[CLOUD_URL_ENV] ?? DEFAULT_CLOUD_URL;
172
+ const baseUrl = normalizeCloudBaseUrl(raw);
173
+ if (baseUrl === undefined) {
174
+ throw configurationError("url_invalid", "Cloud URL must be an absolute HTTPS base URL with a plain path.");
175
+ }
176
+ return { baseUrl, token };
177
+ }
178
+ /**
179
+ * Wait for this hosted run's completion signal and return its JSON payload.
180
+ *
181
+ * ```ts
182
+ * import { waitForSignal } from "@relayflows/surface/signals";
183
+ * const done = await waitForSignal("native_done");
184
+ * ```
185
+ *
186
+ * Call it in the authored flow body, not inside an `f.run` command: only the
187
+ * authored process holds the run-bound read token. The wait has no overall
188
+ * deadline; pass `signal` (e.g. `AbortSignal.timeout(ms)`) to bound it.
189
+ */
190
+ export async function waitForSignal(name, options = {}) {
191
+ const runId = options.runId ?? process.env[HOSTED_RUN_ID_ENV];
192
+ if (runId === undefined || runId === "") {
193
+ 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.`);
194
+ }
195
+ return holdSignalWait(runId, name, options, { connection: () => hostedConnection(options) });
196
+ }
197
+ //# sourceMappingURL=signals.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signals.js","sourceRoot":"","sources":["../src/signals.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayflows/surface",
3
- "version": "2.0.53",
3
+ "version": "2.0.54",
4
4
  "description": "TypeScript authoring surface for Relayflows.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,6 +25,11 @@
25
25
  "types": "./dist/triggers/*.d.ts",
26
26
  "import": "./dist/triggers/*.js",
27
27
  "require": "./dist/triggers/*.js"
28
+ },
29
+ "./signals": {
30
+ "types": "./dist/signals.d.ts",
31
+ "import": "./dist/signals.js",
32
+ "require": "./dist/signals.js"
28
33
  }
29
34
  },
30
35
  "files": [
package/src/signals.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Hosted completion signals: `@relayflows/surface/signals`.
3
+ *
4
+ * Hosted Cloud runs materialize only `@relayflows/surface` next to the flow
5
+ * source, so this module must stay importable on its own: no imports beyond
6
+ * Node built-ins, and Cloud is reached through the global `fetch`. It is the
7
+ * single implementation of the held-GET signal wait; `@relayflows/sdk`'s
8
+ * `waitForCloudSignal` delegates to {@link holdSignalWait}.
9
+ *
10
+ * Call {@link waitForSignal} from the authored flow body. The run-bound read
11
+ * credential lives only in that process; `f.run` children and agents never
12
+ * receive it.
13
+ */
14
+ import { setTimeout as delay } from "node:timers/promises";
15
+
16
+ /** The longest hold Cloud's signal GET accepts, and the default. */
17
+ export const SIGNAL_MAX_WAIT_MS = 25_000;
18
+ /** Cloud's run ID for this hosted run (not the engine's `RELAYFLOW_RUN_ID`). */
19
+ export const HOSTED_RUN_ID_ENV = "RELAYFLOW_V2_CLOUD_RUN_ID";
20
+ /** The run-bound, GET-only `workflow:signals:read` bearer Cloud gives the authored process. */
21
+ export const HOSTED_SIGNAL_READ_TOKEN_ENV = "RELAYFLOW_V2_SIGNAL_READ_TOKEN";
22
+ /** The Cloud application base URL the Flows CLI uses. */
23
+ export const CLOUD_URL_ENV = "FLOWS_CLOUD_URL";
24
+ export const DEFAULT_CLOUD_URL = "https://agentrelay.com/cloud";
25
+
26
+ const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u;
27
+ const RUN_ID = /^[A-Za-z0-9_-]{1,128}$/u;
28
+ const MAX_TIMER_MS = 2_147_483_647;
29
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
30
+ const FAST_204_MS = 1_000;
31
+ const FAST_204_INITIAL_BACKOFF_MS = 250;
32
+ const FAST_204_MAX_BACKOFF_MS = 5_000;
33
+
34
+ export type SignalErrorCode =
35
+ | "configuration"
36
+ | "invalid_input"
37
+ | "invalid_response"
38
+ | "http_error"
39
+ | "transport_error"
40
+ | "transient_error";
41
+
42
+ /** Set only for `configuration`: which local setting is missing or wrong. */
43
+ export type SignalConfigurationReason =
44
+ | "run_id_missing"
45
+ | "auth_missing"
46
+ | "url_invalid"
47
+ | "timeout_invalid";
48
+
49
+ export class SignalError extends Error {
50
+ reason?: SignalConfigurationReason;
51
+
52
+ constructor(
53
+ readonly code: SignalErrorCode,
54
+ message: string,
55
+ readonly status?: number,
56
+ ) {
57
+ super(message);
58
+ this.name = "SignalError";
59
+ }
60
+ }
61
+
62
+ export interface WaitForSignalOptions {
63
+ /** Hold per request, 0–25000 ms. Defaults to 25000. */
64
+ waitMs?: number;
65
+ /** Cancels the wait; the caller's abort reason is rethrown unchanged. */
66
+ signal?: AbortSignal;
67
+ /** Defaults to `process.env.RELAYFLOW_V2_CLOUD_RUN_ID`. */
68
+ runId?: string;
69
+ /** Defaults to `process.env.FLOWS_CLOUD_URL`, then https://agentrelay.com/cloud. */
70
+ apiUrl?: string;
71
+ /** Defaults to `process.env.RELAYFLOW_V2_SIGNAL_READ_TOKEN`. */
72
+ token?: string;
73
+ /** Bounds each held request; must exceed `waitMs`. Defaults to 30000. */
74
+ requestTimeoutMs?: number;
75
+ }
76
+
77
+ /** Builds the error a caller sees; lets the SDK keep throwing `CloudFlowError`. */
78
+ export type SignalErrorFactory = (
79
+ code: Exclude<SignalErrorCode, "configuration">,
80
+ message: string,
81
+ status?: number,
82
+ ) => Error;
83
+
84
+ /**
85
+ * The seams `@relayflows/sdk` supplies to share this implementation. Not part
86
+ * of the authored-flow surface; use {@link waitForSignal} in a flow.
87
+ */
88
+ export interface SignalWaitHooks {
89
+ /** Resolved before every held request so a rotated credential is picked up. */
90
+ connection(): Promise<{ baseUrl: string; token: string }> | { baseUrl: string; token: string };
91
+ error?: SignalErrorFactory;
92
+ /** Abort-aware sleep used for the fast-204 backoff. */
93
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>;
94
+ }
95
+
96
+ const signalError: SignalErrorFactory = (code, message, status) =>
97
+ new SignalError(code, message, status);
98
+
99
+ function configurationError(reason: SignalConfigurationReason, message: string): SignalError {
100
+ const error = new SignalError("configuration", message);
101
+ error.reason = reason;
102
+ return error;
103
+ }
104
+
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === "object" && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ /** Validates both path segments, run ID first, before any request. */
110
+ export function signalPath(runId: unknown, name: unknown, error: SignalErrorFactory = signalError): string {
111
+ // Defensive path-segment constraint; accepting a new server ID format needs a Surface change.
112
+ if (typeof runId !== "string" || !RUN_ID.test(runId)) {
113
+ throw error("invalid_response", "Cloud run ID must contain only letters, digits, underscores, or hyphens.");
114
+ }
115
+ if (typeof name !== "string" || !SIGNAL_NAME.test(name)) {
116
+ throw error("invalid_input", "Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters).");
117
+ }
118
+ return `/api/v1/workflows/runs/${runId}/signals/${name}`;
119
+ }
120
+
121
+ /** Same classification as the SDK's Cloud transport: retryable network faults are transient. */
122
+ function transportError(cause: unknown, error: SignalErrorFactory): Error {
123
+ const inner = cause instanceof Error ? cause.cause : undefined;
124
+ const code = isRecord(inner) ? inner.code : undefined;
125
+ const transient = (cause instanceof Error && cause.name === "TimeoutError")
126
+ || ["ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ETIMEDOUT",
127
+ "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET"].includes(String(code));
128
+ return transient
129
+ ? error("transient_error", "Cloud transport temporarily unavailable.")
130
+ : error("transport_error", "Cloud transport failed; check TLS and the configured URL (redirects are refused).");
131
+ }
132
+
133
+ function normalizeCloudBaseUrl(raw: string): string | undefined {
134
+ try {
135
+ const url = new URL(raw);
136
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash
137
+ || !/^\/[A-Za-z0-9/_-]*$/u.test(url.pathname)) return undefined;
138
+ return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The shared held-GET loop. A normally held 204 re-arms immediately; a fast
146
+ * 204 (under one second) backs off 250 ms doubling to a 5 s cap, so a proxy or
147
+ * broken server cannot turn the wait into a hot loop. The backoff resets after
148
+ * a normally held 204.
149
+ *
150
+ * `@relayflows/sdk` calls this with its own connection resolver and error
151
+ * class; authored flows should call {@link waitForSignal}.
152
+ */
153
+ export async function holdSignalWait(
154
+ runId: unknown,
155
+ name: unknown,
156
+ options: Pick<WaitForSignalOptions, "waitMs" | "signal" | "requestTimeoutMs">,
157
+ hooks: SignalWaitHooks,
158
+ ): Promise<unknown> {
159
+ const error = hooks.error ?? signalError;
160
+ const sleep = hooks.sleep ?? ((ms: number, signal?: AbortSignal) => delay(ms, undefined, { signal }));
161
+ const path = signalPath(runId, name, error);
162
+ const waitMs = options.waitMs ?? SIGNAL_MAX_WAIT_MS;
163
+ if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > SIGNAL_MAX_WAIT_MS) {
164
+ throw error("invalid_input", "Signal waitMs must be an integer from 0 to 25000.");
165
+ }
166
+ const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
167
+ if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= waitMs) {
168
+ throw error("invalid_input", `Signal requestTimeoutMs must exceed ${waitMs / 1000} seconds.`);
169
+ }
170
+ let fast204BackoffMs = FAST_204_INITIAL_BACKOFF_MS;
171
+ for (;;) {
172
+ options.signal?.throwIfAborted();
173
+ const { baseUrl, token } = await hooks.connection();
174
+ const deadline = AbortSignal.timeout(requestTimeout);
175
+ const requestStartedAt = performance.now();
176
+ let response: Response;
177
+ try {
178
+ response = await fetch(`${baseUrl}${path}?waitMs=${waitMs}`, {
179
+ method: "GET",
180
+ headers: { authorization: `Bearer ${token}` },
181
+ signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
182
+ // A redirect must never carry the credential to a different origin.
183
+ redirect: "error",
184
+ });
185
+ } catch (cause) {
186
+ options.signal?.throwIfAborted();
187
+ throw transportError(deadline.aborted ? deadline.reason : cause, error);
188
+ }
189
+ if (response.status === 204) {
190
+ if (performance.now() - requestStartedAt < FAST_204_MS) {
191
+ try {
192
+ await sleep(fast204BackoffMs, options.signal);
193
+ } catch (cause) {
194
+ options.signal?.throwIfAborted();
195
+ throw cause;
196
+ }
197
+ fast204BackoffMs = Math.min(fast204BackoffMs * 2, FAST_204_MAX_BACKOFF_MS);
198
+ } else {
199
+ fast204BackoffMs = FAST_204_INITIAL_BACKOFF_MS;
200
+ }
201
+ continue;
202
+ }
203
+ // Do not echo server response bodies: they may contain credentials or source.
204
+ if (!response.ok) throw error("http_error", `Cloud signal wait failed with HTTP ${response.status}.`, response.status);
205
+ let body: unknown;
206
+ try {
207
+ body = await response.json();
208
+ } catch (cause) {
209
+ options.signal?.throwIfAborted();
210
+ if (!(cause instanceof SyntaxError)) throw transportError(deadline.aborted ? deadline.reason : cause, error);
211
+ throw error("invalid_response", "Cloud returned an invalid signal.");
212
+ }
213
+ if (!isRecord(body) || !Object.hasOwn(body, "payload") || typeof body.acceptedAt !== "number") {
214
+ throw error("invalid_response", "Cloud returned an invalid signal.");
215
+ }
216
+ return body.payload;
217
+ }
218
+ }
219
+
220
+ function hostedConnection(options: WaitForSignalOptions): { baseUrl: string; token: string } {
221
+ const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
222
+ if (requestTimeout > MAX_TIMER_MS) {
223
+ throw configurationError("timeout_invalid", "requestTimeoutMs must be a positive 32-bit integer.");
224
+ }
225
+ const rawToken = options.token ?? process.env[HOSTED_SIGNAL_READ_TOKEN_ENV];
226
+ const token = rawToken?.trim();
227
+ if (!token || /[\r\n]/u.test(rawToken!) || /^(?:rk|ot)_live_/u.test(token)) {
228
+ throw configurationError("auth_missing",
229
+ `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.`);
230
+ }
231
+ const raw = options.apiUrl ?? process.env[CLOUD_URL_ENV] ?? DEFAULT_CLOUD_URL;
232
+ const baseUrl = normalizeCloudBaseUrl(raw);
233
+ if (baseUrl === undefined) {
234
+ throw configurationError("url_invalid", "Cloud URL must be an absolute HTTPS base URL with a plain path.");
235
+ }
236
+ return { baseUrl, token };
237
+ }
238
+
239
+ /**
240
+ * Wait for this hosted run's completion signal and return its JSON payload.
241
+ *
242
+ * ```ts
243
+ * import { waitForSignal } from "@relayflows/surface/signals";
244
+ * const done = await waitForSignal("native_done");
245
+ * ```
246
+ *
247
+ * Call it in the authored flow body, not inside an `f.run` command: only the
248
+ * authored process holds the run-bound read token. The wait has no overall
249
+ * deadline; pass `signal` (e.g. `AbortSignal.timeout(ms)`) to bound it.
250
+ */
251
+ export async function waitForSignal(name: string, options: WaitForSignalOptions = {}): Promise<unknown> {
252
+ const runId = options.runId ?? process.env[HOSTED_RUN_ID_ENV];
253
+ if (runId === undefined || runId === "") {
254
+ throw configurationError("run_id_missing",
255
+ `waitForSignal needs a Cloud run ID: pass { runId }, or call it from a hosted run, where ${HOSTED_RUN_ID_ENV} is set.`);
256
+ }
257
+ return holdSignalWait(runId, name, options, { connection: () => hostedConnection(options) });
258
+ }