@relayflows/sdk 2.0.52 → 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.
@@ -3,10 +3,11 @@ import { loadPinnedAuthoredSource } from './authored-source-authority.js';
3
3
  import { assertAuthoredRuntimeAvailable, runAuthoredInNode } from './authored-node-runner.js';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { readFile } from 'node:fs/promises';
6
+ import { pathToFileURL } from 'node:url';
6
7
  import { canonicalize } from './canonical.js';
7
8
  import { compileSpec, toKernelSpec } from './compile.js';
8
9
  import { executeAuthoredFlow, type AuthoredFlowExecutionResult, type AuthoredFlowSuspendedResult } from './authored-flow-executor.js';
9
- import { AUTHORED_PARALLEL_ROOT_KIND, AUTHORED_ROOT_KIND } from './authored-verdict.js';
10
+ import { AUTHORED_PARALLEL_ROOT_KIND, AUTHORED_PARALLEL_ROOT_KIND_V3, AUTHORED_ROOT_KIND } from './authored-verdict.js';
10
11
  import {
11
12
  loadAuthoredFlow,
12
13
  type LoadedAuthoredFlow,
@@ -43,7 +44,7 @@ export interface AuthoredRootExtension {
43
44
  }
44
45
 
45
46
  export interface AuthoredRootMetadata {
46
- readonly kind: typeof ROOT_KIND | typeof AUTHORED_PARALLEL_ROOT_KIND;
47
+ readonly kind: typeof ROOT_KIND | typeof AUTHORED_PARALLEL_ROOT_KIND | typeof AUTHORED_PARALLEL_ROOT_KIND_V3;
47
48
  readonly flowName: string;
48
49
  readonly flowPath: string;
49
50
  readonly sourceSha256: string;
@@ -95,18 +96,34 @@ export async function executeDurableAuthoredFlow(
95
96
  })));
96
97
  const definition = loaded.getDefinition(loaded.handle);
97
98
  let parallelEligible = false;
98
- if (loaded.graph.length === 1 && (loaded.extensions?.length ?? 0) === 0
99
- && definition.header.budget === undefined && parallelPromiseIntrinsicsIntact()) {
99
+ let parallelIneligibleReason: string | undefined;
100
+ if (loaded.graph.length !== 1) parallelIneligibleReason = 'source_graph';
101
+ else if ((loaded.extensions?.length ?? 0) !== 0) parallelIneligibleReason = 'extensions';
102
+ else if (definition.header.budget !== undefined) parallelIneligibleReason = 'shared_budget';
103
+ else if (!parallelPromiseIntrinsicsIntact()) parallelIneligibleReason = 'runtime_intrinsics';
104
+ else {
100
105
  // The parser is needed only for an authored run, never for an ordinary
101
106
  // CLI check or watch reload. Recheck intrinsics after the async import;
102
107
  // author code could have replaced them while it was loading.
103
- const { parallelRunEligible } = await import('./authored-parallel-eligibility.js');
108
+ const { parallelLoadedBodyEligible, parallelPinnedSourceEligible } = await import('./authored-parallel-eligibility.js');
104
109
  parallelEligible = parallelPromiseIntrinsicsIntact()
105
- && parallelRunEligible(definition.body, source.toString('utf8'));
110
+ && (process.versions['bun'] === undefined
111
+ // Native imports strip by default, or transform with Node's opt-in
112
+ // flag. Require exact identity with one of those loader outputs.
113
+ ? await parallelLoadedBodyEligible(definition.body, source.toString('utf8'), pathToFileURL(loaded.sourcePath).href, 'strip')
114
+ || await parallelLoadedBodyEligible(definition.body, source.toString('utf8'), pathToFileURL(loaded.sourcePath).href, 'transform')
115
+ : parallelPinnedSourceEligible(source.toString('utf8')));
116
+ if (!parallelEligible) parallelIneligibleReason = parallelPromiseIntrinsicsIntact()
117
+ ? 'source_certificate' : 'runtime_intrinsics';
118
+ }
119
+ // Opt-in operator diagnostic only: changing root metadata would change its
120
+ // admission spec hash and could disrupt a 2.0.52 in-flight serial root.
121
+ if (parallelIneligibleReason !== undefined && process.env['FLOWS_PARALLEL_DEBUG'] === '1') {
122
+ process.stderr.write(`[flows] parallel f.run admission: serial (${parallelIneligibleReason})\n`);
106
123
  }
107
124
  const executionMode = parallelEligible ? 'parallel' : 'serial';
108
125
  const metadata: AuthoredRootMetadata = Object.freeze({
109
- kind: executionMode === 'parallel' ? AUTHORED_PARALLEL_ROOT_KIND : ROOT_KIND,
126
+ kind: executionMode === 'parallel' ? AUTHORED_PARALLEL_ROOT_KIND_V3 : ROOT_KIND,
110
127
  flowName: definition.name,
111
128
  flowPath: loaded.sourcePath,
112
129
  sourceSha256: sha256(source),
@@ -648,8 +665,9 @@ function isRootMetadata(value: unknown): value is AuthoredRootMetadata {
648
665
  const root = value as Partial<AuthoredRootMetadata>;
649
666
  const surface = root.surface as Partial<SurfaceModuleAuthority> | undefined;
650
667
  const sources = root.sources;
651
- return (root.kind === ROOT_KIND || root.kind === AUTHORED_PARALLEL_ROOT_KIND)
652
- && (root.kind !== AUTHORED_PARALLEL_ROOT_KIND
668
+ return (root.kind === ROOT_KIND || root.kind === AUTHORED_PARALLEL_ROOT_KIND
669
+ || root.kind === AUTHORED_PARALLEL_ROOT_KIND_V3)
670
+ && (root.kind === ROOT_KIND
653
671
  || (root.identityVersion === 2 && root.executionMode === 'parallel'))
654
672
  && typeof root.flowName === 'string'
655
673
  && typeof root.flowPath === 'string' && /^[a-f0-9]{64}$/.test(root.sourceSha256 ?? '')
@@ -21,8 +21,10 @@ import type { JournalEvent } from './journal-reader.js';
21
21
 
22
22
  /** The `instruction` discriminator `authored-root.ts` writes into the root step. */
23
23
  export const AUTHORED_ROOT_KIND = 'relayflows.authored-root.v1';
24
- /** Older CLIs do not recognize this root and must refuse a parallel rollback. */
24
+ /** Parallel roots admitted before 2.0.53; the 2.0.52 CLI recognizes these. */
25
25
  export const AUTHORED_PARALLEL_ROOT_KIND = 'relayflows.authored-root.parallel.v2';
26
+ /** Newly admitted parallel roots must be refused by the 2.0.52 CLI on rollback. */
27
+ export const AUTHORED_PARALLEL_ROOT_KIND_V3 = 'relayflows.authored-root.parallel.v3';
26
28
 
27
29
  export interface AuthoredVerdict {
28
30
  readonly reason: LoweredCompletionReason;
@@ -79,7 +81,8 @@ export function declaresAuthoredRoot(spawned: JournalEvent): boolean {
79
81
  if (typeof step['instruction'] !== 'string') return false;
80
82
  try {
81
83
  const kind = record(JSON.parse(step['instruction']))?.['kind'];
82
- return kind === AUTHORED_ROOT_KIND || kind === AUTHORED_PARALLEL_ROOT_KIND;
84
+ return kind === AUTHORED_ROOT_KIND || kind === AUTHORED_PARALLEL_ROOT_KIND
85
+ || kind === AUTHORED_PARALLEL_ROOT_KIND_V3;
83
86
  } catch {
84
87
  return false;
85
88
  }
@@ -1,18 +1,15 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
- import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, resolveCloudConnection,
3
- transportError, type CloudConnectionOptions } from './cloud-http.js';
2
+ import { HOSTED_SIGNAL_READ_TOKEN_ENV, holdSignalWait, signalPath as sharedSignalPath,
3
+ type SignalErrorFactory } from '@relayflows/surface/signals';
4
+ import { CloudFlowError, cloudFetch, isCloudRecord, resolveCloudConnection,
5
+ type CloudConnectionOptions } from './cloud-http.js';
4
6
 
5
- const SIGNAL_NAME = /^[A-Za-z0-9_-]{1,80}$/u;
6
7
  const MAX_BYTES = 4 * 1024;
7
- const HELD_GET_MS = 25_000;
8
- const HOSTED_SIGNAL_READ_TOKEN_ENV = 'RELAYFLOW_V2_SIGNAL_READ_TOKEN';
8
+
9
+ const cloudSignalError: SignalErrorFactory = (code, message, status) => new CloudFlowError(code, message, status);
9
10
 
10
11
  function signalPath(runId: string, name: string): string {
11
- cloudRunId(runId);
12
- if (!SIGNAL_NAME.test(name)) {
13
- throw new CloudFlowError('invalid_input', 'Signal name must contain only letters, digits, underscores, or hyphens (1–80 characters).');
14
- }
15
- return `/api/v1/workflows/runs/${runId}/signals/${name}`;
12
+ return sharedSignalPath(runId, name, cloudSignalError);
16
13
  }
17
14
 
18
15
  /** Store a run-scoped completion. Retrying the same JSON payload is safe. */
@@ -38,73 +35,30 @@ export async function postCloudSignal(
38
35
  }
39
36
 
40
37
  /**
41
- * Wait on Cloud's held GET. A normally held 204 re-arms immediately. A fast
42
- * 204 backs off so a proxy or broken server cannot turn this into a hot loop.
38
+ * Wait on Cloud's held GET. The wire behaviour (validation, held GET, fast-204
39
+ * backoff, error classification) is `@relayflows/surface/signals`' single
40
+ * implementation, so the SDK and hosted flows cannot drift. The SDK keeps its
41
+ * own credential resolution and throws `CloudFlowError`.
43
42
  */
44
43
  export async function waitForCloudSignal(
45
44
  runId: string, name: string, options: CloudConnectionOptions = {},
46
45
  ): Promise<unknown> {
47
- const path = signalPath(runId, name);
48
- const requestTimeout = options.requestTimeoutMs ?? 30_000;
49
- if (!Number.isSafeInteger(requestTimeout) || requestTimeout <= HELD_GET_MS) {
50
- throw new CloudFlowError('invalid_input', 'Signal requestTimeoutMs must exceed 25 seconds.');
51
- }
52
- let fast204BackoffMs = 250;
53
- for (;;) {
54
- options.signal?.throwIfAborted();
55
- let connection: { baseUrl: string; token: string };
56
- try {
57
- connection = await resolveCloudConnection(options);
58
- } catch (error) {
59
- // The hosted run's read-only credential is only a fallback for waits.
60
- // Explicit Cloud credentials and stored logins retain precedence, and
61
- // postCloudSignal never sees this token.
62
- const hostedToken = process.env[HOSTED_SIGNAL_READ_TOKEN_ENV];
63
- if (!(error instanceof CloudFlowError) || error.code !== 'configuration'
64
- || error.reason !== 'auth_missing' || options.token !== undefined
65
- || process.env['FLOWS_CLOUD_TOKEN'] !== undefined || hostedToken === undefined) throw error;
66
- connection = await resolveCloudConnection({ ...options, token: hostedToken });
67
- }
68
- const { baseUrl, token } = connection;
69
- const deadline = AbortSignal.timeout(requestTimeout);
70
- const requestStartedAt = performance.now();
71
- let response: Response;
72
- try {
73
- response = await fetch(`${baseUrl}${path}?waitMs=${HELD_GET_MS}`, {
74
- method: 'GET',
75
- headers: { authorization: `Bearer ${token}` },
76
- signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
77
- redirect: 'error',
78
- });
79
- } catch (error) {
80
- options.signal?.throwIfAborted();
81
- throw transportError(deadline.aborted ? deadline.reason : error);
82
- }
83
- if (response.status === 204) {
84
- if (performance.now() - requestStartedAt < 1_000) {
85
- try {
86
- await delay(fast204BackoffMs, undefined, { signal: options.signal });
87
- } catch (error) {
88
- options.signal?.throwIfAborted();
89
- throw error;
90
- }
91
- fast204BackoffMs = Math.min(fast204BackoffMs * 2, 5_000);
92
- } else {
93
- fast204BackoffMs = 250;
46
+ return holdSignalWait(runId, name, { signal: options.signal, requestTimeoutMs: options.requestTimeoutMs }, {
47
+ error: cloudSignalError,
48
+ sleep: (ms, signal) => delay(ms, undefined, { signal }),
49
+ connection: async () => {
50
+ try {
51
+ return await resolveCloudConnection(options);
52
+ } catch (error) {
53
+ // The hosted run's read-only credential is only a fallback for waits.
54
+ // Explicit Cloud credentials and stored logins retain precedence, and
55
+ // postCloudSignal never sees this token.
56
+ const hostedToken = process.env[HOSTED_SIGNAL_READ_TOKEN_ENV];
57
+ if (!(error instanceof CloudFlowError) || error.code !== 'configuration'
58
+ || error.reason !== 'auth_missing' || options.token !== undefined
59
+ || process.env['FLOWS_CLOUD_TOKEN'] !== undefined || hostedToken === undefined) throw error;
60
+ return resolveCloudConnection({ ...options, token: hostedToken });
94
61
  }
95
- continue;
96
- }
97
- if (!response.ok) throw new CloudFlowError('http_error', `Cloud signal wait failed with HTTP ${response.status}.`, response.status);
98
- let body: unknown;
99
- try { body = await response.json(); }
100
- catch (error) {
101
- options.signal?.throwIfAborted();
102
- if (!(error instanceof SyntaxError)) throw transportError(deadline.aborted ? deadline.reason : error);
103
- throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.');
104
- }
105
- if (!isCloudRecord(body) || !Object.hasOwn(body, 'payload') || typeof body.acceptedAt !== 'number') {
106
- throw new CloudFlowError('invalid_response', 'Cloud returned an invalid signal.');
107
- }
108
- return body.payload;
109
- }
62
+ },
63
+ });
110
64
  }