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
25 changes: 25 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,31 @@ The herdr model: first-party helpers are just plugins that ship in the box; the

`flows build` seals a flow into a content-addressed, immutable bundle: canonical spec JSON, compiled TS with pinned deps, helper/plugin lockfile, assets, preflight declaration, identity signature — `flow@sha256:…`, pushed to a bucket/registry. `flows deploy` points a trigger at a digest; `flows run flow@sha256:…` executes from the bucket on any cell, no checkout. Preflight runs at build time for everything build-provable and again at deploy time for environment facts (credentials, workers, MCP servers). The working tree is for authoring; **production only ever runs digests.**

The first deployment slice supports `file://` buckets and self-contained
**declarative deterministic** bundles:

```text
flows deploy <name>@sha256:<64-hex-digest> --to file:///absolute/bucket
flows run <name>@sha256:<64-hex-digest> --bucket file:///absolute/bucket
```

Deploy reads `dist/flows/<name>@sha256:<digest>/` and publishes the complete
verified bundle at `<bucket>/<name>/sha256/<digest>/`. Re-deploy prints
`deploy_noop`; a partial copy is never published at the final path.
Run resolves `--bucket` before the nearest `flows.json`'s
`{"deploy":{"bucket":"file:///absolute/bucket"}}`. Verified payloads are cached
under `$XDG_CACHE_HOME/flows/bundles/<hex-digest>/`, falling back to
`~/.cache/flows/bundles/<hex-digest>/`. Every cache hit is verified before use.
The canonical spec goes through the existing journal run path with command
preflight before any daemon connection. Local authoring files are unnecessary.
Refusals exit 2; transport failure after copying starts exits 1 (`deploy_partial`).

**Remaining work for #333:** S3 transport, trigger digest binding and conflict
checks, authored TypeScript execution, assets and placement, agent/LLM environment
preflight, and separating build-provable checks from environment checks.
Unsupported bundle execution is refused with `bundle_unsupported`; this slice
does not claim to implement the full production-digest contract above.

The local build and verification commands are available now:

```text
Expand Down
23 changes: 23 additions & 0 deletions evidence/spec-Adep/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Adep minimal slice for #333

This branch implements file-bucket deploy and digest-run for self-contained
YAML/declarative deterministic flows. It does not close all of #333.

The new tests cover signed payload layout, idempotency, missing/unwritable
buckets, corruption before transport or journal creation, cache reuse and
corruption, bucket configuration/override, exact canonical spec submission,
and explicit refusal of asset execution. A real-kernel test deletes the local
source and build output before running the deployed digest to success.

`verification.txt` contains literal commands, output, and exit codes. The live
check uses an already-built local relayflowd via the recorded RELAYFLOWD_BIN;
this branch does not modify the kernel. The initial regression run exposed
local dependency drift, then Bun missing from PATH. The failed outputs and
the reruns after restoring dependencies and adding the installed Bun to PATH
are retained.

Follow-up: S3 transport; trigger digest pinning/conflict validation; authored TS,
assets and placement; agent/LLM environment checks; separate environment-only
preflight (this slice retains the existing checks). Deploy-time execution support
is deliberately refused for those bundle kinds. Linux artifact and packed-consumer
CI are left to the lead's PR workflow; they were not run on this node.
186 changes: 186 additions & 0 deletions evidence/spec-Adep/verification.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
Working directory: packages/sdk
RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/1475297677/debug/relayflowd

$ npm run typecheck

> @relayflows/sdk@2.0.8 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json

exit=0

$ npm run build

> @relayflows/sdk@2.0.8 build
> tsc && node scripts/make-cli-executable.mjs

exit=0

$ npm run typecheck:tests

> @relayflows/sdk@2.0.8 typecheck:tests
> tsc -p tsconfig.tests.json

exit=0

$ npx vitest run tests/deploy.test.ts tests/run-from-digest.test.ts tests/run-digest-live.test.ts tests/cli.test.ts tests/bundle.test.ts

RUN v2.1.9 /Users/khaliqgant/flows-spec-Adep-deploy/packages/sdk

✓ tests/run-digest-live.test.ts (1 test) 535ms
✓ executes a deployed digest on the real kernel after deleting the authoring tree 535ms
✓ tests/deploy.test.ts (8 tests) 1990ms
✓ flows deploy file buckets > publishes the full signed layout byte-for-byte and redeploys as a noop 451ms
✓ flows deploy file buckets > never labels a corrupt existing deployment as a noop 374ms
✓ tests/run-from-digest.test.ts (6 tests) 2192ms
✓ flows run digest input > resolves deploy.bucket from flows.json and honors explicit override 602ms
✓ flows run digest input > refuses an unconfigured bucket 382ms
✓ flows run digest input > refuses tampered spec.canonical.json before creating run data 375ms
✓ flows run digest input > refuses tampered identity.json before creating run data 361ms
❯ tests/bundle.test.ts (21 tests | 1 failed) 2849ms
✓ immutable bundles > builds and verifies the canonical YAML fixture through the compiled CLI 612ms
✓ immutable bundles > emits the ephemeral warning on CLI stderr and uses the default output directory 387ms
× immutable bundles > builds a standalone TS fixture twice with identical executable hashes 185ms
→ expected 'REFUSED [bundle_invalid] package-lock…' to be '' // Object.is equality
✓ tests/cli.test.ts (63 tests) 13407ms
✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 314ms
✓ flows check CLI > refuses a typo model before probing or contacting relayflowd 933ms
✓ flows check CLI > maps every input refusal path to its declared kind without raw exceptions 2535ms
✓ flows run/resume CLI over the journal protocol > parses run options, submits the kernel dialect, and exits 0 on success 684ms
✓ flows run/resume CLI over the journal protocol > exits 1 and emits the declared completionReason for a failed run 674ms
✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 759ms
✓ flows run/resume CLI over the journal protocol > reports a needs_human agent step as parked for human recovery 713ms
✓ flows run/resume CLI over the journal protocol > classifies a typed hello refusal as a protocol error, not an unreachable daemon 1011ms
✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 672ms
✓ flows run/resume CLI over the journal protocol > bounds a worker wait by its lease and reports what it is waiting for 659ms
✓ flows run/resume CLI over the journal protocol > resumes a parked run from snapshot step types without reading journal sequence one 678ms
✓ flows run/resume CLI over the journal protocol > maps only run_not_found resumes to exit 2 1948ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

FAIL tests/bundle.test.ts > immutable bundles > builds a standalone TS fixture twice with identical executable hashes
AssertionError: expected 'REFUSED [bundle_invalid] package-lock…' to be '' // Object.is equality

- Expected
+ Received

+ REFUSED [bundle_invalid] package-lock.json: node_modules/@jridgewell/sourcemap-codec does not match its pinned version; run npm ci before building
+

❯ tests/bundle.test.ts:210:27
208| it('builds a standalone TS fixture twice with identical executable h…
209| const result = invoke(['--out', await temp(), 'packages/sdk/tests/…
210| expect(result.stderr).toBe(''); expect(result.status).toBe(0);
| ^
211| const bundle = result.stdout.trim();
212| const second = invoke(['--out', await temp(), 'packages/sdk/tests/…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

Test Files 1 failed | 4 passed (5)
Tests 1 failed | 98 passed (99)
Start at 19:08:34
Duration 14.15s (transform 390ms, setup 0ms, collect 1.81s, tests 20.97s, environment 0ms, prepare 172ms)

exit=1

Dependency repair: npm ci in packages/sdk, then link only node_modules/@relayflows/surface to the built packages/surface (same version). No lockfile or dependency manifest changes.

$ npm run typecheck

> @relayflows/sdk@2.0.8 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json

exit=0

$ npm run build

> @relayflows/sdk@2.0.8 build
> tsc && node scripts/make-cli-executable.mjs

exit=0

$ npm run typecheck:tests

> @relayflows/sdk@2.0.8 typecheck:tests
> tsc -p tsconfig.tests.json

exit=0

$ npx vitest run tests/deploy.test.ts tests/run-from-digest.test.ts tests/run-digest-live.test.ts tests/cli.test.ts tests/bundle.test.ts

RUN v2.1.9 /Users/khaliqgant/flows-spec-Adep-deploy/packages/sdk

✓ tests/run-digest-live.test.ts (1 test) 556ms
✓ executes a deployed digest on the real kernel after deleting the authoring tree 556ms
✓ tests/deploy.test.ts (8 tests) 2154ms
✓ flows deploy file buckets > publishes the full signed layout byte-for-byte and redeploys as a noop 487ms
✓ flows deploy file buckets > never labels a corrupt existing deployment as a noop 395ms
✓ tests/run-from-digest.test.ts (6 tests) 2337ms
✓ flows run digest input > resolves deploy.bucket from flows.json and honors explicit override 666ms
✓ flows run digest input > refuses an unconfigured bucket 385ms
✓ flows run digest input > refuses tampered spec.canonical.json before creating run data 382ms
✓ flows run digest input > refuses tampered identity.json before creating run data 369ms
❯ tests/bundle.test.ts (21 tests | 1 failed) 3075ms
✓ immutable bundles > builds and verifies the canonical YAML fixture through the compiled CLI 682ms
✓ immutable bundles > emits the ephemeral warning on CLI stderr and uses the default output directory 408ms
× immutable bundles > builds a standalone TS fixture twice with identical executable hashes 218ms
→ expected 'REFUSED [bundle_invalid] TypeScript b…' to be '' // Object.is equality
✓ tests/cli.test.ts (63 tests) 12718ms
✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 319ms
✓ flows check CLI > uses the raw Claude adapter model flag instead of accepting auth status as model proof 315ms
✓ flows check CLI > refuses a typo model before probing or contacting relayflowd 1015ms
✓ flows check CLI > maps every input refusal path to its declared kind without raw exceptions 2032ms
✓ flows run/resume CLI over the journal protocol > parses run options, submits the kernel dialect, and exits 0 on success 616ms
✓ flows run/resume CLI over the journal protocol > exits 1 and emits the declared completionReason for a failed run 567ms
✓ flows run/resume CLI over the journal protocol > exits 3 and names the parked llm step 697ms
✓ flows run/resume CLI over the journal protocol > reports a needs_human agent step as parked for human recovery 1152ms
✓ flows run/resume CLI over the journal protocol > classifies a typed hello refusal as a protocol error, not an unreachable daemon 610ms
✓ flows run/resume CLI over the journal protocol > follows a dispatched worker step instead of reporting a protocol error 549ms
✓ flows run/resume CLI over the journal protocol > bounds a worker wait by its lease and reports what it is waiting for 750ms
✓ flows run/resume CLI over the journal protocol > resumes a parked run from snapshot step types without reading journal sequence one 612ms
✓ flows run/resume CLI over the journal protocol > maps only run_not_found resumes to exit 2 2254ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

FAIL tests/bundle.test.ts > immutable bundles > builds a standalone TS fixture twice with identical executable hashes
AssertionError: expected 'REFUSED [bundle_invalid] TypeScript b…' to be '' // Object.is equality

- Expected
+ Received

+ REFUSED [bundle_invalid] TypeScript build requires Bun: spawnSync bun ENOENT
+

❯ tests/bundle.test.ts:210:27
208| it('builds a standalone TS fixture twice with identical executable h…
209| const result = invoke(['--out', await temp(), 'packages/sdk/tests/…
210| expect(result.stderr).toBe(''); expect(result.status).toBe(0);
| ^
211| const bundle = result.stdout.trim();
212| const second = invoke(['--out', await temp(), 'packages/sdk/tests/…

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

Test Files 1 failed | 4 passed (5)
Tests 1 failed | 98 passed (99)
Start at 19:09:41
Duration 13.42s (transform 350ms, setup 0ms, collect 1.74s, tests 20.84s, environment 0ms, prepare 201ms)

exit=1

Bun was installed but absent from PATH.
$ PATH=/Users/khaliqgant/.bun/bin:$PATH npx vitest run tests/bundle.test.ts

RUN v2.1.9 /Users/khaliqgant/flows-spec-Adep-deploy/packages/sdk

✓ tests/bundle.test.ts (21 tests) 3717ms
✓ immutable bundles > builds and verifies the canonical YAML fixture through the compiled CLI 542ms
✓ immutable bundles > emits the ephemeral warning on CLI stderr and uses the default output directory 346ms
✓ immutable bundles > builds a standalone TS fixture twice with identical executable hashes 1234ms

Test Files 1 passed (1)
Tests 21 passed (21)
Start at 19:10:12
Duration 4.20s (transform 99ms, setup 0ms, collect 289ms, tests 3.72s, environment 0ms, prepare 38ms)

exit=0
79 changes: 79 additions & 0 deletions packages/sdk/src/bundle-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { constants } from 'node:fs';
import { access, cp, lstat, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
import { dirname, isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { verifyBundle } from './bundle.js';

export type BundleFailureKind = 'bucket_unconfigured' | 'bucket_unreachable'
| 'bundle_missing_locally' | 'bundle_signature_invalid' | 'bundle_unsupported' | 'deploy_partial';
export class BundleFailure extends Error {
constructor(readonly kind: BundleFailureKind, message: string) { super(message); }
}
export interface DigestReference { name: string; digest: string }
export function parseDigestReference(value: string): DigestReference | undefined {
// Match the safe single-component name grammar used by sealBundle.
const match = /^([A-Za-z0-9][A-Za-z0-9._-]*)@sha256:([0-9a-f]{64})$/.exec(value);
return match ? { name: match[1]!, digest: match[2]! } : undefined;
Comment thread
cursor[bot] marked this conversation as resolved.
}
export function bucketDirectory(uri: string, ref: DigestReference): string {
try {
const url = new URL(uri);
if (url.protocol !== 'file:' || url.search || url.hash) throw new Error('expected file:// URI');
const path = fileURLToPath(url);
if (!isAbsolute(path)) throw new Error('expected absolute bucket path');
return join(path, ref.name, 'sha256', ref.digest);
} catch {
throw new BundleFailure('bucket_unreachable', 'This slice requires an absolute file:// bucket URI.');
}
}
export async function exists(path: string): Promise<boolean> {
try { await lstat(path); return true; }
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
}
export async function verifyDigest(directory: string, digest: string): Promise<void> {
try { await verifyBundle(directory, digest); }
catch (error) { throw new BundleFailure('bundle_signature_invalid', String(error)); }
}
/** Publish only a complete, verified directory. Concurrent identical writers converge. */
export async function copyBundle(source: string, target: string, digest: string): Promise<boolean> {
if (await exists(target)) { await verifyDigest(target, digest); return false; }
await mkdir(dirname(target), { recursive: true });
const staging = await mkdtemp(join(dirname(target), '.bundle-'));
try {
await cp(source, staging, { recursive: true, dereference: false, verbatimSymlinks: true });
await verifyDigest(staging, digest);
try { await rename(staging, target); }
catch (error) {
if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
await verifyDigest(target, digest);
return false;
}
return true;
} finally { await rm(staging, { recursive: true, force: true }); }
}
export async function writableBucket(target: string): Promise<void> {
try {
await mkdir(dirname(target), { recursive: true });
await access(dirname(target), constants.W_OK);
const probe = await mkdtemp(join(dirname(target), '.probe-'));
await rm(probe, { recursive: true });
} catch (error) { throw new BundleFailure('bucket_unreachable', String(error)); }
}
export async function fetchBundle(ref: DigestReference, bucket: string): Promise<string> {
const cacheRoot = process.env['XDG_CACHE_HOME'];
const cache = join(cacheRoot && isAbsolute(cacheRoot) ? cacheRoot : join(homedir(), '.cache'),
'flows', 'bundles', ref.digest);
// Verify every hit; a corrupt cache never executes or silently falls back.
if (await exists(cache)) { await verifyDigest(cache, ref.digest); return cache; }
const source = bucketDirectory(bucket, ref);
try {
if (!await exists(source)) throw new BundleFailure('bucket_unreachable', `Bundle is absent at ${bucket}.`);
await verifyDigest(source, ref.digest);
await copyBundle(source, cache, ref.digest);
return cache;
} catch (error) {
if (error instanceof BundleFailure) throw error;
throw new BundleFailure('bucket_unreachable', String(error));
}
}
9 changes: 6 additions & 3 deletions packages/sdk/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,9 @@ async function regularFile(root: string, path: string): Promise<Buffer> {
}
}

/** Verify an untrusted directory without following symlinks or manifest traversal. */
export async function verifyBundle(directory: string): Promise<string> {
/** Verify an untrusted directory without following symlinks or manifest traversal.
* Bucket/cache readers supply the requested digest; local builds bind it to the directory name. */
export async function verifyBundle(directory: string, expectedDigest?: string): Promise<string> {
const root = resolve(directory);
if (!(await lstat(root)).isDirectory()) throw new Error('manifest.json: bundle must be a directory, not a symlink');
const raw = (await regularFile(root, 'manifest.json')).toString('utf8');
Expand Down Expand Up @@ -146,7 +147,9 @@ export async function verifyBundle(directory: string): Promise<string> {
const canonical = canonicalize(manifest);
if (raw !== canonical) throw new Error('manifest.json: noncanonical bytes');
const digest = sha256(canonical);
if (!basename(root).endsWith(`@sha256:${digest}`)) throw new Error('manifest.json: directory digest mismatch');
const matchesDigest = expectedDigest === undefined
? basename(root).endsWith(`@sha256:${digest}`) : digest === expectedDigest;
if (!matchesDigest) throw new Error('manifest.json: directory digest mismatch');
await rejectExtras(root, '', new Set([...paths, 'manifest.json', 'identity.json']));
try {
const identity = JSON.parse((await regularFile(root, 'identity.json')).toString('utf8'));
Expand Down
Loading
Loading