diff --git a/packages/sdk/src/cli/build.ts b/packages/sdk/src/cli/build.ts index e0defc4f..7bff80a7 100644 --- a/packages/sdk/src/cli/build.ts +++ b/packages/sdk/src/cli/build.ts @@ -6,28 +6,44 @@ import { canonicalize } from '../canonical.js'; import { compileSpec, toKernelSpec } from '../compile.js'; import { preflight } from '../preflight.js'; import { buildTypescript } from '../bundle-typescript.js'; -import { readProjectConfig } from './check.js'; +import { checkBuildableFlow, readProjectConfig, type CheckReport } from './check.js'; import type { CliIo } from '../cli.js'; import type { FlowSpec } from '../spec.js'; -export interface BuildArgs { command: 'build'; value: string; out?: string; verify: boolean } +export interface BuildArgs { command: 'build'; value: string; out?: string; verify: boolean; json: boolean } export function parseBuildArgs(args: readonly string[]): BuildArgs | undefined { if (args[0] === '--verify') { return args.length === 2 && !args[1]!.startsWith('-') - ? { command: 'build', value: args[1]!, verify: true } : undefined; + ? { command: 'build', value: args[1]!, verify: true, json: false } : undefined; } let out: string | undefined; let value: string | undefined; + let json = false; for (let i = 0; i < args.length; i++) { const arg = args[i]!; if (arg === '--out') { if (out !== undefined || args[i + 1] === undefined || args[i + 1]!.startsWith('-')) return undefined; out = args[++i]; + } else if (arg === '--json') { + if (json) return undefined; + json = true; } else if (arg.startsWith('-') || value !== undefined) return undefined; else value = arg; } - return value === undefined ? undefined : { command: 'build', value, out, verify: false }; + return value === undefined ? undefined : { command: 'build', value, out, verify: false, json }; +} + +/** + * Emit a check report in the same shape `flows check` uses so build-time + * refusals are consumable by the same tooling. + */ +function emitBuildCheckReport(report: CheckReport, json: boolean, io: CliIo): void { + for (const diagnostic of report.diagnostics) { + if (diagnostic.severity !== 'refusal') continue; + io.stderr(`REFUSED [${diagnostic.kind}] ${diagnostic.message}`); + } + if (json) io.stdout(JSON.stringify(report)); } export async function runBuild(args: BuildArgs, io: CliIo): Promise<0 | 2> { @@ -36,6 +52,16 @@ export async function runBuild(args: BuildArgs, io: CliIo): Promise<0 | 2> { io.stdout(`VERIFIED sha256:${await verifyBundle(args.value)}`); return 0; } + // Gate the build on the same preflight pipeline `flows check` uses. + // Refusals never leave partial artifacts — no file capture, no canonical + // spec write, no digest computation, no bundle directory creation. A + // previous `dist/flows/@sha256:/` directory from an earlier + // successful build is not touched (buildFlow is never called). + const gate = await checkBuildableFlow(args.value); + if (!gate.report.ok) { + emitBuildCheckReport(gate.report, args.json, io); + return 2; + } io.stdout(await buildFlow(args.value, args.out ?? 'dist/flows', io.stderr)); return 0; } catch (error) { diff --git a/packages/sdk/src/cli/check.ts b/packages/sdk/src/cli/check.ts index 0228710e..b96e435c 100644 --- a/packages/sdk/src/cli/check.ts +++ b/packages/sdk/src/cli/check.ts @@ -143,6 +143,72 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf } } +/** + * Build-time gate that runs the same preflight pipeline as `checkFlow` + * but with deferred probes — a build machine is not the deployment target, + * so `cli`/`command`/`executor` existence is checked at run-time, not here. + * + * "Build-provable" refusals (unknown model, `use:` unresolved, invalid + * verification, missing bundle assets, budget syntax, etc.) still surface, + * because they are properties of the flow spec, not of the build host. + */ +export async function checkBuildableFlow(path: string): Promise { + const absolutePath = resolve(path); + try { + const authored = /\.(?:[cm]?[jt]s)$/.test(path); + if (authored) { + // TS flows are gated by `buildFlow` itself, which runs + // `buildTypescript` to compile the authored spec and then invokes + // preflight with deferred probes at the same refusal threshold as + // this gate. Running `checkTypeScriptFlow` here would need + // `@relayflows/surface` to be resolvable from the flow's directory, + // which is not a build-time invariant. Return an ok report so the + // gate delegates to `buildFlow`'s inline preflight. + return { + report: { ok: true, path, gates: [], resolutions: [], diagnostics: [] }, + }; + } + const source = readFlowSource(absolutePath); + const authoring: FlowSpec = readFlow(source, absolutePath); + const config = readProjectConfig(dirname(absolutePath)); + const deferred: PreflightProbes = { + cli: () => { throw new Error('deferred to deployment'); }, + executor: () => { throw new Error('deferred to deployment'); }, + command: () => { throw new Error('deferred to deployment'); }, + }; + const result = preflight(authoring, { + ...(config.cli !== undefined ? { projectCli: config.cli } : {}), + ...(config.path !== undefined ? { projectConfigPath: config.path } : {}), + projectSearchStart: dirname(absolutePath), + models: config.models, + ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}), + probes: deferred, + }); + // Refusals rooted in build-machine environment probes (`probe_failed`) + // are excluded from the build gate — the build host is not the + // deployment target, and those checks are re-run at `flows run`. + const diagnostics = result.diagnostics.filter( + (d) => !(d.severity === 'refusal' && d.kind === 'probe_failed'), + ); + const ok = !diagnostics.some((d) => d.severity === 'refusal'); + return { + report: { + ok, + path, + ...(config.path !== undefined ? { projectConfigPath: config.path } : {}), + gates: result.gates, + resolutions: result.resolutions, + diagnostics, + }, + }; + } catch (error) { + const failure = error instanceof CheckFailure + ? error + : new CheckFailure('invalid_spec', `Flow "${path}" could not be checked as a Relayflow spec.`); + return { report: inputFailureReport(failure, path) }; + } +} + export function inputFailureReport( failure: { kind: CheckFailureKind; message: string }, path?: string, diff --git a/packages/sdk/tests/build-gate.test.ts b/packages/sdk/tests/build-gate.test.ts new file mode 100644 index 00000000..2fa346eb --- /dev/null +++ b/packages/sdk/tests/build-gate.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const sdk = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repo = resolve(sdk, '../..'); +const cli = join(sdk, 'dist/cli.js'); +const key = Buffer.alloc(32, 7).toString('base64'); +const temporary: string[] = []; + +async function temp(): Promise { + const path = await mkdtemp(join(tmpdir(), 'flows-build-gate-test-')); + temporary.push(path); + return path; +} + +function invoke(args: string[], cwd = repo, env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, [cli, 'build', ...args], { + cwd, encoding: 'utf8', timeout: 120_000, + env: { PATH: process.env['PATH'], FLOWS_BUILD_KEY: key, ...env }, + }); +} + +afterEach(async () => { + await Promise.all(temporary.splice(0).map(path => rm(path, { force: true, recursive: true }))); +}); + +describe('flows build gates on flows check green (#318)', () => { + it('refuses a flow with an unresolvable named-agent CLI and leaves no artifacts', async () => { + const cwd = await temp(); + await writeFile(join(cwd, 'bad.yaml'), JSON.stringify({ + version: '0.1.0', name: 'unbuildable', + steps: [{ id: 'ask', type: 'agent', instruction: 'hello' }], + })); + const out = join(cwd, 'dist/flows'); + const result = invoke(['--out', out, 'bad.yaml'], cwd); + expect(result.status).toBe(2); + expect(result.stderr).toContain('cli_unresolved'); + // No `dist/flows/@sha256:/` — the refusal path never leaves + // partial artifacts. + expect(existsSync(out)).toBe(false); + }); + + it('--json emits one CheckReport object on stdout on refusal, exits 2, no artifacts', async () => { + const cwd = await temp(); + await writeFile(join(cwd, 'bad.yaml'), JSON.stringify({ + version: '0.1.0', name: 'unbuildable-json', + steps: [{ id: 'ask', type: 'agent', instruction: 'hello' }], + })); + const out = join(cwd, 'dist/flows'); + const result = invoke(['--json', '--out', out, 'bad.yaml'], cwd); + expect(result.status).toBe(2); + // Exactly one JSON object on stdout — same shape `flows check --json` emits. + const trimmed = result.stdout.trim(); + expect(() => JSON.parse(trimmed)).not.toThrow(); + const report = JSON.parse(trimmed) as { + ok: boolean; + diagnostics: Array<{ severity: string; kind: string }>; + gates: unknown[]; + resolutions: unknown[]; + }; + expect(report.ok).toBe(false); + expect(Array.isArray(report.diagnostics)).toBe(true); + expect(report.diagnostics.some(d => d.severity === 'refusal' && d.kind === 'cli_unresolved')).toBe(true); + expect(Array.isArray(report.gates)).toBe(true); + expect(Array.isArray(report.resolutions)).toBe(true); + expect(existsSync(out)).toBe(false); + }); + + it('builds the bundle on success (regression: gate must not block valid flows)', async () => { + const cwd = await temp(); + // Deterministic-only YAML flow — no agent, no CLI required. Uses the + // same shape as the fixture at testdata/hello-deterministic.flow.yaml + // but keeps this test self-contained. + await writeFile(join(cwd, 'script.sh'), '#!/bin/sh\necho hello\n'); + await writeFile(join(cwd, 'good.yaml'), JSON.stringify({ + version: '0.1.0', name: 'buildable', + steps: [{ id: 'run', type: 'deterministic', command: './script.sh' }], + })); + const out = join(cwd, 'out'); + const result = invoke(['--out', out, 'good.yaml'], cwd); + expect(result.status).toBe(0); + const bundle = result.stdout.trim(); + expect(bundle).toContain('buildable@sha256:'); + const files = await readdir(bundle); + // Full bundle emitted: canonical spec, preflight, manifest, identity. + expect(files).toContain('spec.canonical.json'); + expect(files).toContain('preflight.json'); + expect(files).toContain('manifest.json'); + expect(files).toContain('identity.json'); + // Bundle name shape matches the digest-addressing convention. + expect(basename(bundle).startsWith('buildable@sha256:')).toBe(true); + // Sanity: assets captured from build-time file references. + expect(await readFile(join(bundle, 'assets/script.sh'), 'utf8')).toContain('echo hello'); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 5ef64519..7e8adf11 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -31,7 +31,8 @@ "tests/close-pr-flow.test.ts", "tests/direct-input.test.ts", "tests/fixtures/needs-human.flow.ts", - "tests/named-gates.test.ts" + "tests/named-gates.test.ts", + "tests/build-gate.test.ts" ], "exclude": ["node_modules", "dist"] }