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
34 changes: 30 additions & 4 deletions packages/sdk/src/cli/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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/<name>@sha256:<hex>/` 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) {
Expand Down
66 changes: 66 additions & 0 deletions packages/sdk/src/cli/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckExecution> {
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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named gates refuse under deferred probes

Medium Severity

The build gate drops only probe_failed refusals from deferred probes, but probeNamedGate fail-closes a thrown command probe as gate_command_missing. Any named-gate flow therefore fails checkBuildableFlow even when flows check is green, because the deferred probe always throws.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ea74cd7. Configure here.

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,
Expand Down
100 changes: 100 additions & 0 deletions packages/sdk/tests/build-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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/<name>@sha256:<hex>/` — 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');
});
});
3 changes: 2 additions & 1 deletion packages/sdk/tsconfig.tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading