diff --git a/scripts/pack-release.mjs b/scripts/pack-release.mjs index fbe78108..fcc3c6dc 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/publish.test.mjs b/scripts/publish.test.mjs index 85e1b8d9..1ce35d18 100644 --- a/scripts/publish.test.mjs +++ b/scripts/publish.test.mjs @@ -93,6 +93,13 @@ test('actual npm tarballs reject missing dist and local dependencies, then accep for (const file of ['index.js', 'index.d.ts', 'runtime.js', 'runtime.d.ts']) { writeFileSync(join(dist, file), 'export {};\n'); } + // The published Surface must also ship the hosted signal helper (#677). + const noSignals = run(); + assert.notEqual(noSignals.status, 0); + assert.match(noSignals.stderr, /missing package\/dist\/signals\.js/); + for (const file of ['signals.js', 'signals.d.ts']) { + writeFileSync(join(dist, file), 'export {};\n'); + } const path = join(root, 'packages/surface/package.json'); const pkg = read(path); pkg.dependencies = { external: 'file:../external' }; diff --git a/scripts/surface-package-gate.sh b/scripts/surface-package-gate.sh index e6cbd0f0..71015208 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;