diff --git a/.gitignore b/.gitignore index 554689b42..d249b39fd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ dist/ .relayflow/ .relayflowd/ .relayflowd-*/ + +# Legacy drive-local snapshots; the preparing launcher no longer executes them. +.drive-gate/ diff --git a/ops/BACKLOG.md b/ops/BACKLOG.md index 1a285abb1..e70f11b6b 100644 --- a/ops/BACKLOG.md +++ b/ops/BACKLOG.md @@ -238,6 +238,9 @@ complete. Recorded here so they are tracked rather than lost — found by tick message. - **F8b** — `validateKernelRetry` names an authoring rule as if the kernel imposed it — a doc claim the kernel does not make. + Scope: `packages/sdk/src/compile.ts`. Use `validateAuthoringRetryDefaults` + for the authoring validator and its call site; remove the old identifier. + Verify: ["node", "--input-type=module", "-e", "import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source = readFileSync('packages/sdk/src/compile.ts', 'utf8'); assert(!source.includes('validateKernelRetry')); assert(source.includes('function validateAuthoringRetryDefaults(')); assert(source.includes('validateAuthoringRetryDefaults(step['));"] - **F9** — `probeTrigger` catches every error with no classification, so a broken probe environment is indistinguishable from a bad trigger. - **F10** — small load-bearing boundary details a reader will trip over. diff --git a/ops/DRIVE-LOCAL.md b/ops/DRIVE-LOCAL.md new file mode 100644 index 000000000..48ff2967c --- /dev/null +++ b/ops/DRIVE-LOCAL.md @@ -0,0 +1,87 @@ +# Local drive packages + +Launch from a trusted checkout with the SDK dependencies installed: + +```sh +node scripts/run-drive-local.mjs +``` + +Run on the branch that should receive the diff, from a clean checkout. The +flow leaves delivery to the operator. It does not commit or merge. The wrapper +builds the SDK for the local launcher, pins the original HEAD and branch, and +submits commands through the existing local launcher. The daemon journals those +pins before implementation starts. Running the YAML template directly refuses +the snapshot step because its pinned inputs are missing. + +`gate-snapshot` runs first. It extracts the package helper, verifier and SDK +picker source with `git --no-replace-objects show :`, then compiles +that picker in a temporary directory outside the checkout. Its own extraction +script also comes from that Git ref. Neither working-tree helper files nor +ignored SDK dist supply gate inputs. The installed TypeScript compiler is a +trusted toolchain dependency; no compiler package is copied into the temp dir. + +Selection records the same ref in the work package as `head`. Subsequent checks +compare that field to the submitted pin, so changing package metadata cannot +repin it after a commit. The ref supplies the gate-input integrity claim; there +is no SHA256SUMS file beside the scripts. The launcher removes the temporary +directory when its run returns. + +Selection uses the SDK backlog picker. A locally executable entry must name +repository paths committed at HEAD in backticks and declare at least one acceptance +command on an indented `Verify:` line. Each command is a JSON argv array, +executed at the repository root with a two-minute bound. For example: + +```text +- **Fix the value** Update `src/value.txt` to contain exactly "fixed". + Verify: ["node", "-e", "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"] +``` + +Multiple `Verify:` lines mean all commands must pass. Commands come from the +backlog before implementation; the agent cannot substitute its own acceptance +checks in package.json. Entries with no executable checks are skipped with +`missing_executable_checks`, alongside the existing unbounded/stale scope +reasons. The old F8b entry now carries a source assertion for its declared +rename. That assertion also allows an already-completed package to pass +without manufacturing another edit. + +The submitted scope step executes the helpers extracted from Git and refuses changes +to its checkout sources and backlog. It checks the working tree and +index against the selected HEAD, including untracked non-ignored files and +both sides of renames. Scope uses exact paths or directory descendants, never +string-prefix siblings. Backlog, verifier and gate changes fail even when a +package names a containing directory. Symlink changes are refused. Ignored +build/runtime artifacts are excluded from this Git diff boundary; it is not an +OS filesystem sandbox. Moving HEAD or branch fails against the submitted pins, +even if the agent updates the ignored package metadata to match. +Pre-existing unresolved symlinks under directory scopes are refused: creating +a file through a dangling symlink can write outside the checkout. + +Verification reconstructs the selected work from the unchanged backlog and +compares its scope and commands to package.json. Each package check must pass +before the SDK regression suite runs. An unchanged implementation whose DoD +is unmet fails. HEAD/branch changes fail, and out-of-scope changes produced by +an acceptance command fail too. A failed scope or verification step prevents +the dependent report step from running. + +The scope command runs again after the SDK build and suite, before reporting. +Reports include tracked changes against HEAD and non-ignored untracked paths. + +The execution contract has one owner for each kind of data: + +| Input or policy | Owner and validation | +| --- | --- | +| Gate inputs | Package helper, verifier, extraction script and picker source come from the pinned Git commit, with replacement objects disabled. The picker is built during `gate-snapshot` before selection or implementation. | +| HEAD, branch, backlog hash | Preparing launcher pins the original commit/branch and the hash of its committed backlog in submitted commands. The package records the commit as `head`; no adjacent ref or checksum file is authority. | +| Package metadata | Private atomic JSON artifact for the agent; scope, verify and report reconstruct its fields from the picker built from the pinned ref and the pinned backlog. It cannot redefine the original HEAD. | +| Allowed paths and protected paths | `local-work-verification.mjs` extracted from the pinned ref; index and working tree checked separately against the original HEAD, including non-ignored untracked files. | +| Acceptance argv | Parsed from the pinned backlog; every command must succeed, with scope rechecked after execution. | +| Acceptance scripts and dependencies | **Open blocker:** arbitrary argv can load mutable source from implementation scope. | + +**A same-user agent can still write to the temporary execution directory.** +This meets the narrower bar that gate inputs come from a pinned Git ref rather +than files implementation edits. It does not make extracted runtime files +immutable, isolate processes, or prevent arbitrary Git-storage tampering. + +The remaining acceptance-input decision is documented in +`runtime-evidence/drive-threads-0909-decisions.md`. This flow is not ready for +unattended use until those inputs have an explicit enforced ownership contract. diff --git a/ops/RUNTIME-STATUS.md b/ops/RUNTIME-STATUS.md index f78af0474..4b3256933 100644 --- a/ops/RUNTIME-STATUS.md +++ b/ops/RUNTIME-STATUS.md @@ -147,6 +147,10 @@ Its runtime behavior was not tested or inferred from a successful v2 check. ## Work package execution and delivery Scaffold commit: `89f2f1d31f262420c2c5f613efa3f50c70153bfa`. +The command and receipts below describe that historical scaffold. The current +local drive uses `node scripts/run-drive-local.mjs` to capture gate inputs before +submission; see [DRIVE-LOCAL.md](DRIVE-LOCAL.md) for its remaining acceptance-input +blocker. From a clean work branch at that commit, after the setup above: ```sh diff --git a/ops/drive-local-flow.test.mjs b/ops/drive-local-flow.test.mjs new file mode 100644 index 000000000..c39ed7fe5 --- /dev/null +++ b/ops/drive-local-flow.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { fixture, packagePath } from './local-work-test-fixture.mjs'; + +const quote = text => "'" + text.replaceAll("'", "'\\''") + "'"; +for (const scenario of ['outside edit', 'verifier edit', 'unchanged package', 'HEAD repin', 'forged snapshot']) { + test(`drive-local journals failure and blocks reporting for ${scenario}`, t => { + const f = fixture(t); + const wrapper = resolve('testdata/preflight/wrapper-session.mjs'); + const cli = join(f.root, '.relayflow/agent.mjs'); + f.put('.relayflow/agent.mjs', `#!/usr/bin/env node +import { receiveWrapperRequest } from ${JSON.stringify(wrapper)}; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +const request = await receiveWrapperRequest(); +if (request) { + ${scenario === 'outside edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');` : ''} + ${scenario === 'verifier edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'ops/local-work-package.mjs'))}, 'process.exit(0)');` : ''} + ${scenario === 'HEAD repin' ? ` + const git = (...args) => execFileSync('git', args, {cwd: ${JSON.stringify(f.root)}, encoding: 'utf8'}).trim(); + writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed'); + git('add', 'outside.txt'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside'); + const path = ${JSON.stringify(join(f.root, packagePath))}; + const pkg = JSON.parse(readFileSync(path, 'utf8')); + pkg.head = git('rev-parse', 'HEAD'); + writeFileSync(path, JSON.stringify(pkg));` : ''} + ${scenario === 'forged snapshot' ? ` + const directory = ${JSON.stringify(join(f.root, '.drive-gate'))}; + mkdirSync(directory, {recursive: true}); + writeFileSync(directory + '/local-work-package.mjs', 'process.exit(0);'); + const sums = execFileSync('shasum', ['-a', '256', '.drive-gate/local-work-package.mjs'], {cwd: ${JSON.stringify(f.root)}}); + writeFileSync(directory + '/SHA256SUMS', sums);` : ''} + console.log('DONE'); +} +`); + // A scripted worker controls the edit while using the real worker protocol, + // stream pin, submitted flow commands, daemon and journal. + chmodSync(cli, 0o755); + const spec = structuredClone(f.preparedFlow); + for (const step of spec.steps) { + if (step.type === 'agent') step.cli = cli; + else step.command = `cd ${quote(f.root)}\n${step.command}`; + } + const path = join(f.root, '.relayflow/flow.json'); + writeFileSync(path, JSON.stringify(spec)); + const result = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], { + encoding: 'utf8', timeout: 20000, + }); + assert.equal(result.error, undefined, result.stderr); + assert.equal(result.status, 1, result.stderr + result.stdout); + const dataDir = result.stdout.match(/^LOCAL_DATA_DIR=(.+)$/m)?.[1]; + assert(dataDir, result.stderr); + t.after(() => rmSync(dataDir, { recursive: true, force: true })); + const journal = readFileSync(join(dataDir, 'journal.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); + const failedStep = ['unchanged package', 'forged snapshot'].includes(scenario) ? 'verify' : 'scope'; + const completion = journal.find(e => e.entry_type === 'step.completed' && e.step_id === failedStep); + assert.equal(completion?.payload.verification.verdict, 'fail', JSON.stringify(journal)); + assert.equal(completion.payload.completionReason, 'retries_exhausted'); + assert.equal(completion.payload.verification.detail, 'exit code was 1'); + assert(!journal.some(e => e.entry_type === 'step.attempt.started' && e.step_id === 'report')); + assert(!existsSync(join(dataDir, 'relayflowd.sock'))); + }); +} diff --git a/ops/local-work-gate.mjs b/ops/local-work-gate.mjs new file mode 100644 index 000000000..8400df52f --- /dev/null +++ b/ops/local-work-gate.mjs @@ -0,0 +1,49 @@ +// Pin the Git ref before submission; gate-snapshot extracts only its Git objects. +// The temporary execution directory is not a same-user filesystem sandbox. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'"; +export function prepareLocalDrive(flow, { root = process.cwd(), + compiler = fileURLToPath(new URL('../packages/sdk/node_modules/typescript/bin/tsc', import.meta.url)) } = {}) { + assert.equal(flow.name, 'drive-local', 'NOT_LOCAL_DRIVE'); + const git = (...args) => execFileSync('git', ['--no-replace-objects', ...args], { cwd: root, encoding: 'utf8' }); + const head = git('rev-parse', 'HEAD').trim(); + const baseline = { + head, + branch: git('branch', '--show-current').trim(), + backlogSha256: createHash('sha256').update(git('show', `${head}:ops/BACKLOG.md`)).digest('hex'), + }; + assert(baseline.branch && baseline.branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch'); + const snapshot = git('show', `${head}:ops/local-work-snapshot.mjs`); + const directory = mkdtempSync(join(tmpdir(), 'drive-gate-')); + const dispose = () => rmSync(directory, { recursive: true, force: true }); + try { + const picker = pathToFileURL(join(directory, 'dist/backlog-picker.js')).href; + const prefix = `GIT_NO_REPLACE_OBJECTS=1 DRIVE_GATE_BASELINE=${shellQuote(JSON.stringify(baseline))} ` + + `DRIVE_GATE_PICKER=${shellQuote(picker)} ${shellQuote(process.execPath)} ` + + shellQuote(join(directory, 'local-work-package.mjs')); + const commands = Object.fromEntries(['select', 'scope', 'verify', 'report'] + .map(operation => [operation, `${prefix} ${operation}`])); + const prepared = structuredClone(flow); + const snapshotStep = prepared.steps.find(step => step.id === 'gate-snapshot'); + assert.equal(snapshotStep?.command, 'node ops/local-work-snapshot.mjs', 'DRIVE_SNAPSHOT_CHANGED'); + snapshotStep.command = `${shellQuote(process.execPath)} --input-type=module --eval ${shellQuote(snapshot)} ` + + `local-drive-snapshot ${shellQuote(head)} ${shellQuote(directory)} ${shellQuote(resolve(compiler))}`; + const operations = { select: 'select', 'initial-scope': 'scope', scope: 'scope', + verify: 'verify', 'final-scope': 'scope', report: 'report' }; + for (const [id, operation] of Object.entries(operations)) { + const step = prepared.steps.find(candidate => candidate.id === id); + assert(step?.type === 'deterministic', `DRIVE_STEP_CHANGED: ${id}`); + const call = `node ops/local-work-package.mjs ${operation}`; + assert.equal(step.command.split(call).length, 2, `DRIVE_COMMAND_CHANGED: ${id}`); + step.command = step.command.replace(call, commands[operation]); + } + return { flow: prepared, commands, baseline, directory, dispose }; + } catch (error) { dispose(); throw error; } +} diff --git a/ops/local-work-gate.test.mjs b/ops/local-work-gate.test.mjs new file mode 100644 index 000000000..f2a06805f --- /dev/null +++ b/ops/local-work-gate.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { entry, fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs'; + +test('committing outside scope and repinning metadata cannot replace the submitted HEAD', t => { + const f = fixture(t); + pass(f.run('select')); + f.put('outside.txt', 'committed outside scope'); + f.git('add', 'outside.txt'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside scope'); + const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')); + pkg.head = f.git('rev-parse', 'HEAD').toString().trim(); + f.put(packagePath, JSON.stringify(pkg)); + for (const operation of ['scope', 'verify', 'report']) fail(f.run(operation), /HEAD_MOVED/); +}); + +test('forged legacy checkout snapshot and compiled picker cannot replace the Git-extracted judge', t => { + const f = fixture(t); + pass(f.run('select')); + for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs', + '.drive-gate/backlog-picker.js', 'packages/sdk/dist/backlog-picker.js']) { + f.put(path, 'process.exit(0);\n'); + } + pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'], + { cwd: f.root, encoding: 'utf8' })); + pass(f.scope()); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); + pass(f.run('report')); +}); + +test('raw template refuses selection before implementation without captured gate inputs', t => { + const f = fixture(t); + const env = { ...process.env }; + delete env.DRIVE_GATE_PICKER; + delete env.DRIVE_GATE_BASELINE; + fail(spawnSync(process.execPath, ['ops/local-work-package.mjs', 'select'], + { cwd: f.root, encoding: 'utf8', env }), /LOCAL_DRIVE_NOT_PREPARED/); +}); + +for (const title of ['Same title', 'Regex [a].* (b) + $']) { + test(`selection advances past rejected duplicate title: ${title}`, t => { + const f = fixture(t, entry(title, 'missing/value.txt') + entry(title)); + const result = f.run('select'); + pass(result); + assert(result.stdout.includes(`SKIPPED [stale_scope: missing/value.txt] ${title}`)); + const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')); + assert.deepEqual(pkg.filesInScope, ['src/value.txt']); + }); +} diff --git a/ops/local-work-git-input.test.mjs b/ops/local-work-git-input.test.mjs new file mode 100644 index 000000000..ad14c834c --- /dev/null +++ b/ops/local-work-git-input.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs'; + +test('preparation reads the committed gate instead of a working-tree replacement', t => { + const f = fixture(t); + const source = f.git('show', 'HEAD:ops/local-work-package.mjs').toString(); + f.put('ops/local-work-package.mjs', source.replace('const packagePath', 'process.exit(0);\nconst packagePath')); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + assert.equal(readFileSync(join(f.gateDirectory, 'local-work-package.mjs'), 'utf8'), source); + assert.equal(JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')).head, + f.git('rev-parse', 'HEAD').toString().trim()); + fail(f.run('verify'), /OUT_OF_SCOPE/); + f.put('ops/local-work-package.mjs', f.git('show', 'HEAD:ops/local-work-package.mjs')); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); + +test('gate-snapshot builds committed picker source outside the checkout', t => { + const f = fixture(t); + const original = f.git('show', 'HEAD:packages/sdk/src/backlog-picker.ts'); + f.put('packages/sdk/src/backlog-picker.ts', 'this is not valid TypeScript'); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + assert(!f.gateDirectory.startsWith(f.root)); + assert.deepEqual(readFileSync(join(f.gateDirectory, 'backlog-picker.ts')), original); + assert(!existsSync(join(f.gateDirectory, 'SHA256SUMS'))); + fail(f.scope(), /OUT_OF_SCOPE/); + f.put('packages/sdk/src/backlog-picker.ts', original); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); + +test('a committed picker compile failure prevents package selection', t => { + const f = fixture(t); + f.put('packages/sdk/src/backlog-picker.ts', 'this is not valid TypeScript'); + f.git('add', 'packages/sdk/src/backlog-picker.ts'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'broken picker source'); + fail(f.run('select'), /error TS/); + assert(!existsSync(join(f.root, packagePath))); +}); + +test('an ignored compiled picker cannot become the input to selection', t => { + const f = fixture(t); + f.put('packages/sdk/dist/backlog-picker.js', 'process.exit(0);\n'); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); + +test('Git replacement objects cannot substitute gate source at the pinned ref', t => { + const f = fixture(t); + const original = f.git('rev-parse', 'HEAD:ops/local-work-package.mjs').toString().trim(); + f.put('.relayflow/replacement.mjs', 'process.exit(0);\n'); + const replacement = f.git('hash-object', '-w', '.relayflow/replacement.mjs').toString().trim(); + f.git('replace', original, replacement); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 00affa790..ecc3eeef1 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -1,71 +1,245 @@ -// The smallest useful local drive package: a mechanical change from BACKLOG. +// Select one work package from ops/BACKLOG.md for a local drive tick. +// +// This used to hardcode a single item. The constants at the top named one file, +// one old identifier and one new one, and `select` asserted that BACKLOG still +// contained that exact entry. It proved a relayflow could drive a real change on +// this checkout, which was the point at the time, but it could only ever drive +// that one change — every later tick needed a human to rewrite the script first. +// +// Selection now comes from the SDK's backlog picker (gate 3, PR #20), which is +// the same rule the cloud drive uses: the first top-level bullet whose title is +// bold, validated for a title, files in scope, and a definition of done. Using +// it here rather than a second implementation means the local loop and the cloud +// loop cannot drift into disagreeing about what "the next work package" is. +// +// Implementation is no longer this script's job. A mechanical rewrite is the +// only kind of change a deterministic step can make, and most backlog entries +// are not mechanical. The flow now hands the package to an agent step, which is +// what makes the loop general. import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, +} from 'node:fs'; import { dirname } from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { checkScope, runChecks, verificationCommands } from './local-work-verification.mjs'; -const target = 'packages/sdk/src/compile.ts'; const packagePath = '.relayflow/drive-local/package.json'; -const oldName = 'validateKernelRetry'; -const newName = 'validateAuthoringRetryDefaults'; -const hash = text => createHash('sha256').update(text).digest('hex'); -const read = path => readFileSync(path, 'utf8'); -const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); - -// A killed writer leaves the destination wholly old or wholly new. Flush the -// replacement before rename and the containing directory before reporting it. +const backlogPath = 'ops/BACKLOG.md'; +// Both values are embedded by the preparing launcher into the submitted command. +// There is no fallback to an ignored artifact that implementation can replace. +assert(process.env.DRIVE_GATE_PICKER && process.env.DRIVE_GATE_BASELINE, + 'LOCAL_DRIVE_NOT_PREPARED: run node scripts/run-drive-local.mjs'); +const sdkEntry = new URL(process.env.DRIVE_GATE_PICKER); +const baseline = JSON.parse(process.env.DRIVE_GATE_BASELINE); + +const read = (p) => readFileSync(p, 'utf8'); +const hash = (t) => createHash('sha256').update(t).digest('hex'); +const git = (...a) => execFileSync('git', a, { encoding: 'utf8' }).trim(); + +function assertBaseline() { + assert.equal(git('rev-parse', 'HEAD'), baseline.head, 'HEAD_MOVED'); + assert.equal(git('branch', '--show-current'), baseline.branch, 'BRANCH_MOVED'); + assert.equal(hash(read(backlogPath)), baseline.backlogSha256, 'BACKLOG_CHANGED'); +} + +// Only package metadata is written, always private (0600), never executable +// source. Rename prevents partial JSON; file and directory fsync are required +// before success. A directory fsync failure propagates even after rename. function writeAtomically(path, contents) { const temporary = `${path}.${randomUUID()}.tmp`; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 }); + const handle = openSync(temporary, 'r'); + try { fsyncSync(handle); } finally { closeSync(handle); } + renameSync(temporary, path); + const directory = openSync(dirname(path), 'r'); + try { fsyncSync(directory); } finally { closeSync(directory); } +} + +async function loadPicker() { try { - const mode = path === target ? statSync(path).mode & 0o777 : 0o600; - writeFileSync(temporary, contents, { flag: 'wx', mode, flush: true }); - renameSync(temporary, path); - const directory = openSync(dirname(path), 'r'); - try { fsyncSync(directory); } finally { closeSync(directory); } - } finally { rmSync(temporary, { force: true }); } + return await import(sdkEntry.href); + } catch (cause) { + // This is the picker built from Git by gate-snapshot, not SDK dist in the + // implementation checkout. Missing artifacts must never trigger a fallback. + throw new Error( + `GATE_PICKER_UNAVAILABLE: ${sdkEntry.pathname}; rerun through scripts/run-drive-local.mjs`, + { cause }, + ); + } } -switch (process.argv[2]) { - case 'select': { - const branch = git('branch', '--show-current'); - assert(branch && branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch'); - assert.equal(git('status', '--porcelain', '--', target), '', 'LOCAL_DRIVE_REFUSED: target has uncommitted edits'); - const entry = read('ops/BACKLOG.md').match(/^- \*\*F8b\*\*[^\n]*(?:\n [^\n]*)*/m)?.[0]; - assert(entry?.includes(oldName), 'BACKLOG_F8B_MISSING: expected the recorded work item'); - const source = read(target); - assert.equal(source.split(oldName).length - 1, 2, 'PACKAGE_ALREADY_APPLIED_OR_CHANGED: expected declaration and call'); - const work = { id: 'F8b', entry, branch, target, before: hash(source), after: hash(source.replaceAll(oldName, newName)) }; - mkdirSync('.relayflow/drive-local', { recursive: true }); - writeAtomically(packagePath, JSON.stringify(work, null, 2) + '\n'); - assert.equal(JSON.parse(read(packagePath)).before, work.before); - console.log(JSON.stringify(work)); - break; +async function choose(markdown, { pathExists, log = true }) { + const { selectBacklogEntry, packageFromEntry, validateWorkPackage, renderWorkPackage } = + await loadPicker(); + const skipped = []; + let entry = null; + let validation = null; + + // Take the first entry this loop can actually bound. The picker returns one + // entry -- the first top-level bullet with a bold title -- so "next" is found + // by removing the one just rejected and asking it again, rather than writing + // a second parser that could disagree with it about what an entry is. + let commands; + while (markdown.length > 0) { + const candidateEntry = selectBacklogEntry(markdown); + if (!candidateEntry) break; + + const candidate = packageFromEntry(candidateEntry); + const result = validateWorkPackage(candidate); + const scope = result.accepted ? result.work.files_in_scope : []; + + // The picker emits ['.'] when an entry references code but names no path. + // That is deliberate on its side -- its comment calls it "honest breadth" -- + // and it is a fair description of the entry. It is not usable as scope for + // an agent: "." is the whole repository, and an agent told its scope is + // everything has been told nothing. Skip rather than widen what an + // unattended tick may touch. + const unbounded = scope.length === 1 && scope[0] === '.'; + + // A path that no longer exists is not scope either. Backlog entries outlive + // the tree they were written against -- this repo moved `sdk/` to + // `packages/sdk/`, so entries naming `sdk/src/protocol.ts` still read as + // precise while pointing at nothing. An agent handed four missing files + // will either invent work or widen scope to find something, and both are + // failures the flow's instruction explicitly forbids. Skipping here means a + // rotted entry can never silently become an agent's instruction, and the + // skip line names the missing paths so the entry can be repaired. + const missing = unbounded ? [] : scope.filter((path) => !pathExists(path)); + const checks = verificationCommands(candidateEntry.body); + + if (result.accepted && !unbounded && missing.length === 0 && checks.length > 0) { + entry = candidateEntry; + validation = result; + commands = checks; + break; + } + skipped.push({ + title: candidateEntry.title, + reason: !result.accepted + ? result.reason + : unbounded + ? 'unbounded_scope' + : missing.length > 0 + ? `stale_scope: ${missing.join(', ')}` + : 'missing_executable_checks', + }); + // Locate the bullet the SDK matched, never a mention of its title in an + // earlier entry's body. Consuming the full line guarantees forward progress. + const lines = markdown.split('\n'); + const at = lines.findIndex(line => line.startsWith(`- **${candidateEntry.title}**`)); + assert(at >= 0, 'BACKLOG_CURSOR_LOST'); + markdown = lines.slice(at + 1).join('\n'); } - case 'apply': { - const work = JSON.parse(read(packagePath)); - assert.equal(git('branch', '--show-current'), work.branch, 'work branch changed'); - const before = read(target); - // A retry after an interrupted write can observe the exact intended end state. - if (hash(before) === work.after) { console.log('PACKAGE_ALREADY_APPLIED: F8b'); break; } - assert.equal(hash(before), work.before, 'TARGET_CHANGED: refusing to overwrite intervening work'); - const after = before.replaceAll(oldName, newName); - assert.notEqual(after, before); - writeAtomically(target, after); - assert.equal(hash(read(target)), work.after, 'MUTATION_NOT_PERSISTED'); - console.log(`PACKAGE_APPLIED: F8b ${work.before} -> ${work.after}`); - console.log(git('diff', '--', target)); - break; + + if (log) for (const s of skipped) console.log(`SKIPPED [${s.reason}] ${s.title.slice(0, 90)}`); + assert( + entry && validation?.accepted, + `NO_BOUNDED_WORK: ${skipped.length} entr(y|ies) considered, none named files ` + + `this loop can scope and verify. Add explicit paths and Verify: JSON argv to a BACKLOG entry.`, + ); + + return { + title: validation.work.title, + filesInScope: validation.work.files_in_scope, + definitionOfDone: validation.work.definition_of_done, + verificationCommands: commands, + brief: renderWorkPackage(entry), + }; +} + +async function select() { + assertBaseline(); + // Restored guard. The generalization recorded the branch but stopped asserting + // it, so the loop would happily select work while sitting on `main` and let + // the agent edit the protected branch. `--show-current` prints nothing on a + // detached HEAD, which is equally not a work branch. + const branch = git('branch', '--show-current'); + assert( + branch && branch !== 'main', + branch + ? `LOCAL_DRIVE_REFUSED: on '${branch}'; use a work branch, not main` + : 'LOCAL_DRIVE_REFUSED: detached HEAD is not a work branch; check out one', + ); + const head = git('rev-parse', 'HEAD'); + const markdown = read(backlogPath); + // Selection and the gate must share one baseline. `existsSync` accepts a path + // that exists only in the working tree, which selection then persists and + // checkScope immediately rejects -- its `git cat-file` lookup consults the + // selected commit, so an untracked path aborts the run on a package selection + // had already blessed. Committed-at-HEAD is the honest rule for both: scope is + // a claim about reviewable content, and an untracked path is not yet that. + const work = await choose(markdown, { + pathExists: path => spawnSync('git', ['cat-file', '-e', `${head}:${path.replace(/\/$/, '')}`], + { stdio: 'ignore' }).status === 0, + }); + const pkg = { + selectedAt: new Date().toISOString(), + branch, + head, + backlogSha256: hash(markdown), + ...work, + }; + writeAtomically(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + console.log(`SELECTED ${pkg.title}`); + console.log(` files in scope: ${pkg.filesInScope.join(', ')}`); + console.log(` definition of done: ${pkg.definitionOfDone.length} item(s)`); +} + +async function verifiedPackage() { + assertBaseline(); + const pkg = JSON.parse(read(packagePath)); + assert.equal(pkg.head, baseline.head, 'PACKAGE_CHANGED: head'); + assert.equal(pkg.branch, baseline.branch, 'PACKAGE_CHANGED: branch'); + const markdown = read(backlogPath); + assert.equal(hash(markdown), pkg.backlogSha256, 'BACKLOG_CHANGED'); + // Reconstruct from the unchanged backlog, so editing ignored package.json + // cannot widen scope or replace acceptance commands with `true`. + const selected = await choose(markdown, { + log: false, + // Consult the selected commit so deleting an in-scope file neither shifts + // selection nor resurrects an earlier entry with stale paths. + pathExists: path => spawnSync('git', ['cat-file', '-e', `${pkg.head}:${path.replace(/\/$/, '')}`], + { stdio: 'ignore' }).status === 0, + }); + for (const key of Object.keys(selected)) { + assert.deepEqual(pkg[key], selected[key], `PACKAGE_CHANGED: ${key}`); } - case 'report': { - const work = JSON.parse(read(packagePath)); - assert.equal(hash(read(target)), work.after, 'TARGET_CHANGED: expected the applied package'); - const diff = git('diff', '--', target); - assert(diff.includes(`+function ${newName}(`), 'PACKAGE_DIFF_MISSING'); - console.log('PACKAGE_EXECUTED: F8b; delivery requires a branch commit and human-reviewed PR.'); - console.log(diff); - break; + checkScope(pkg); + return pkg; +} + +async function report() { + const pkg = await verifiedPackage(); + // The package pins the HEAD it was selected against. Reporting a diff from a + // different commit would describe work this tick did not do. + const head = git('rev-parse', 'HEAD'); + assert.equal(head, pkg.head, `HEAD_MOVED: selected at ${pkg.head}, now ${head}`); + const stat = [ + git('diff', 'HEAD', '--stat'), + git('ls-files', '--others', '--exclude-standard'), + ].filter(Boolean).join('\n'); + console.log(`REPORT ${pkg.title}`); + console.log(stat || ' (no working-tree changes)'); + for (const item of pkg.definitionOfDone) console.log(` DoD: ${item}`); +} + +try { + const command = process.argv[2]; + if (command === 'select') await select(); + else if (command === 'report') await report(); + else if (command === 'scope') await verifiedPackage(); + else if (command === 'verify') { + const pkg = await verifiedPackage(); + runChecks(pkg); + await verifiedPackage(); + } else { + console.error('usage: local-work-package.mjs '); + process.exitCode = 2; } - default: throw new Error('Usage: node ops/local-work-package.mjs '); +} catch (error) { + console.error(error.message); + process.exitCode = 1; } diff --git a/ops/local-work-package.test.mjs b/ops/local-work-package.test.mjs index 3aa83f6d2..498fbcd8b 100644 --- a/ops/local-work-package.test.mjs +++ b/ops/local-work-package.test.mjs @@ -1,53 +1,133 @@ import assert from 'node:assert/strict'; -import { spawnSync, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; import test from 'node:test'; +import { entry, fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs'; -const script = resolve('ops/local-work-package.mjs'); - -test('interrupted package write preserves the original and retry applies once', t => { - const root = mkdtempSync(join(tmpdir(), 'local-package-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); - git('init', '-q', '-b', 'work'); - mkdirSync(join(root, 'packages/sdk/src'), { recursive: true }); - mkdirSync(join(root, 'ops')); - const target = join(root, 'packages/sdk/src/compile.ts'); - const original = 'function validateKernelRetry() {}\nvalidateKernelRetry();\n'; - writeFileSync(target, original); - writeFileSync(join(root, 'ops/BACKLOG.md'), '- **F8b** — rename `validateKernelRetry`\n'); - git('add', '.'); - git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', - '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); - const run = (operation, extra = []) => spawnSync(process.execPath, [...extra, script, operation], - { cwd: root, encoding: 'utf8', timeout: 5000 }); - const selected = run('select'); - assert.equal(selected.status, 0, selected.stderr); - // Intercept the real fs write in a separate process, write a partial prefix, - // then SIGKILL before rename. Never change the package implementation. - const hook = join(root, 'interrupt.cjs'); +const check = ['node', '-e', "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"]; + +test('skip cursor passes a later title mentioned in an earlier body', t => { + const f = fixture(t, '- **First** Update `symbol`; Later is mentioned here.\n' + + '- **Later** Update `anotherSymbol`.\n' + entry()); + const result = f.run('select'); + pass(result); + assert.equal((result.stdout.match(/SKIPPED/g) ?? []).length, 2); + assert.match(result.stdout, /SELECTED Fix value/); + pass(f.scope()); +}); + +test('selection skips missing checks and stale paths with reasons', t => { + const f = fixture(t, entry('No checks', 'src/value.txt', []) + entry('Stale', 'src/missing.txt') + entry()); + const result = f.run('select'); + pass(result); + assert.match(result.stdout, /SKIPPED \[missing_executable_checks\] No checks/); + assert.match(result.stdout, /SKIPPED \[stale_scope: src\/missing.txt\] Stale/); + assert.match(result.stdout, /SELECTED Fix value/); + pass(f.scope()); +}); + +test('a non-SDK package fails unchanged and passes only after its check holds', t => { + const f = fixture(t); + pass(f.run('select')); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.scope()); + pass(f.run('verify')); +}); + +test('multiple acceptance checks all execute and failures propagate', t => { + const f = fixture(t, entry('Two checks', 'src/value.txt', [check, ['node', '-e', 'process.exit(7)']])); + pass(f.run('select')); + f.put('src/value.txt', 'fixed'); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED.*7/); +}); + +for (const kind of ['unstaged', 'staged', 'untracked', 'staged reversal', 'rename', 'deleted']) { + test(`scope refuses an outside ${kind} path`, t => { + const f = fixture(t); + pass(f.run('select')); + if (kind === 'rename') f.git('mv', 'outside.txt', 'src/renamed.txt'); + else if (kind === 'deleted') rmSync(join(f.root, 'outside.txt')); + else { + f.put(kind === 'untracked' ? 'new.txt' : 'outside.txt', 'changed'); + if (kind === 'staged' || kind === 'staged reversal') f.git('add', 'outside.txt'); + if (kind === 'staged reversal') f.put('outside.txt', 'original'); + } + fail(f.scope(), /OUT_OF_SCOPE/); + }); +} + +for (const path of ['ops/local-work-package.mjs', 'ops/local-work-verification.mjs', 'ops/BACKLOG.md']) { + test(`the submitted scope command refuses a changed ${path} before loading it`, t => { + const f = fixture(t); + pass(f.run('select')); + f.put(path, 'process.exit(0);\n'); + fail(f.scope()); + }); +} + +test('editing ignored package metadata cannot widen scope or replace checks', t => { + const f = fixture(t); + pass(f.run('select')); + const original = readFileSync(join(f.root, packagePath), 'utf8'); + for (const change of [{ filesInScope: ['.'] }, { verificationCommands: [['true']] }]) { + f.put(packagePath, JSON.stringify({ ...JSON.parse(original), ...change })); + fail(f.scope(), /PACKAGE_CHANGED/); + } +}); + +test('scope allows directory children but rejects a sibling with the same prefix', t => { + const f = fixture(t, entry('Directory', 'src/')); + pass(f.run('select')); + f.put('src/new.txt', 'new'); + pass(f.scope()); + f.put('src-other/new.txt', 'outside'); + fail(f.scope(), /OUT_OF_SCOPE/); +}); + +test('scope allows in-scope deletions and rejects symlink escapes', t => { + const f = fixture(t); + pass(f.run('select')); + rmSync(join(f.root, 'src/value.txt')); + pass(f.scope()); + symlinkSync('../outside.txt', join(f.root, 'src/value.txt')); + fail(f.scope(), /SYMLINK_SCOPE/); +}); + +test('replacing a scoped file with a directory does not authorize its children', t => { + const f = fixture(t); + pass(f.run('select')); + rmSync(join(f.root, 'src/value.txt')); + f.put('src/value.txt/child.txt', 'outside the declared file'); + fail(f.scope(), /OUT_OF_SCOPE/); +}); + +test('a check that writes outside scope fails verification', t => { + const f = fixture(t, entry('Bad check', 'src/value.txt', + [['node', '-e', "require('node:fs').writeFileSync('outside.txt','changed')"]])); + pass(f.run('select')); + fail(f.run('verify'), /OUT_OF_SCOPE/); +}); + +test('interrupted package write preserves the original and retry replaces it atomically', t => { + const f = fixture(t); + pass(f.run('select')); + const original = readFileSync(join(f.root, packagePath), 'utf8'); + const hook = join(f.root, '.relayflow/interrupt.cjs'); writeFileSync(hook, `const fs = require('node:fs'); const write = fs.writeFileSync; fs.writeFileSync = (path, data, options) => { - if (String(path).includes('compile.ts') && String(path).endsWith('.tmp')) { + if (String(path).includes('package.json') && String(path).endsWith('.tmp')) { write(path, data.slice(0, 9), options); process.kill(process.pid, 'SIGKILL'); } return write(path, data, options); }; -require('node:module').syncBuiltinESMExports(); -`); - const interrupted = run('apply', ['--require', hook]); +require('node:module').syncBuiltinESMExports();\n`); + const interrupted = f.run('select', ['--require', hook]); assert.equal(interrupted.signal, 'SIGKILL', interrupted.stderr); - assert.equal(readFileSync(target, 'utf8'), original); - const applied = run('apply'); - assert.equal(applied.status, 0, applied.stderr); - const expected = original.replaceAll('validateKernelRetry', 'validateAuthoringRetryDefaults'); - assert.equal(readFileSync(target, 'utf8'), expected); - const retry = run('apply'); - assert.equal(retry.status, 0, retry.stderr); - assert.match(retry.stdout, /PACKAGE_ALREADY_APPLIED/); - assert.equal(readFileSync(target, 'utf8'), expected); + assert.equal(readFileSync(join(f.root, packagePath), 'utf8'), original); + pass(f.run('select')); + assert.equal(JSON.parse(readFileSync(join(f.root, packagePath))).title, 'Fix value'); + assert.equal(readFileSync(join(f.root, 'src/value.txt'), 'utf8'), 'broken'); }); diff --git a/ops/local-work-review.test.mjs b/ops/local-work-review.test.mjs new file mode 100644 index 000000000..ce2993ae3 --- /dev/null +++ b/ops/local-work-review.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { entry, fixture, flow, pass, fail } from './local-work-test-fixture.mjs'; + +for (const kind of ['unstaged', 'staged', 'untracked']) { + test(`report includes an allowed ${kind} change`, t => { + const f = fixture(t, entry('Directory', 'src/')); + pass(f.run('select')); + const path = kind === 'untracked' ? 'src/new.txt' : 'src/value.txt'; + f.put(path, 'fixed'); + if (kind === 'staged') f.git('add', path); + pass(f.run('scope')); + const report = f.run('report'); + pass(report); + assert.match(report.stdout, new RegExp(path)); + assert.doesNotMatch(report.stdout, /no working-tree changes/); + }); +} + +for (const kind of ['dangling escape', 'existing internal']) { + test(`directory scope checks a pre-existing ${kind} symlink`, t => { + const f = fixture(t, entry('Directory', 'src/')); + symlinkSync(kind === 'dangling escape' ? '../../../missing-target' : 'value.txt', + join(f.root, 'src/link')); + f.git('add', 'src/link'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'existing link'); + pass(f.run('select')); + const result = f.run('scope'); + if (kind === 'dangling escape') fail(result, /SYMLINK_UNRESOLVED/); + else pass(result); + }); +} + +test('selection skips untracked scope and accepts the next committed scope', t => { + const f = fixture(t, entry('Untracked', 'new/value.txt') + entry()); + f.put('new/value.txt', 'untracked'); + const result = f.run('select'); + pass(result); + assert.match(result.stdout, /SKIPPED \[stale_scope: new\/value.txt\] Untracked/); + assert.match(result.stdout, /SELECTED Fix value/); +}); + +for (const path of ['outside.txt', 'src/value.txt']) { + test(`reporting after SDK suite effects enforces scope for ${path}`, t => { + const f = fixture(t); + pass(f.run('select')); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); + // Model a Git-visible effect produced by the SDK suite after package checks. + f.put(path, 'suite effect'); + let previous = 'verify'; + let result; + while (previous !== 'report') { + const next = flow.steps.find(step => step.dependsOn?.includes(previous)); + assert(next, `no path from ${previous} to report`); + result = f.step(next.id); + if (result.status !== 0) break; + previous = next.id; + } + if (path === 'outside.txt') fail(result, /OUT_OF_SCOPE/); + else { pass(result); assert.equal(previous, 'report'); } + }); +} diff --git a/ops/local-work-snapshot.mjs b/ops/local-work-snapshot.mjs new file mode 100644 index 000000000..ed528128f --- /dev/null +++ b/ops/local-work-snapshot.mjs @@ -0,0 +1,35 @@ +// Executed as gate-snapshot before selection and implementation. Source inputs +// come from a commit, never the checkout. The ref is the integrity claim; there +// is no checksum manifest. A same-user process can still alter these temp files. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +try { + const [head, directory, compiler, ...extra] = process.argv.slice(2); + assert(head && directory && compiler && !extra.length, 'LOCAL_DRIVE_NOT_PREPARED: run node scripts/run-drive-local.mjs'); + assert(/^[a-f0-9]{40}$/.test(head), 'INVALID_GATE_REF'); + const git = (...args) => execFileSync('git', ['--no-replace-objects', ...args]); + assert.equal(git('rev-parse', 'HEAD').toString().trim(), head, 'HEAD_MOVED'); + assert.equal(readdirSync(directory).length, 0, 'GATE_DIRECTORY_NOT_EMPTY'); + for (const [source, destination] of [ + ['ops/local-work-package.mjs', 'local-work-package.mjs'], + ['ops/local-work-verification.mjs', 'local-work-verification.mjs'], + ['packages/sdk/src/backlog-picker.ts', 'backlog-picker.ts'], + ]) { + writeFileSync(join(directory, destination), git('show', `${head}:${source}`), { flag: 'wx', mode: 0o600 }); + } + writeFileSync(join(directory, 'package.json'), '{"type":"module"}\n', { flag: 'wx', mode: 0o600 }); + writeFileSync(join(directory, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'ES2022', module: 'NodeNext', outDir: 'dist', strict: true, types: [], skipLibCheck: true }, + files: ['backlog-picker.ts'], + }), { flag: 'wx', mode: 0o600 }); + // The installed TypeScript compiler is part of the trusted local toolchain. + // Its inputs are the extracted source and this explicit build configuration. + execFileSync(process.execPath, [compiler, '--project', join(directory, 'tsconfig.json')], { stdio: 'inherit' }); + console.log(`GATE_FROM_GIT: ${head}`); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/ops/local-work-test-fixture.mjs b/ops/local-work-test-fixture.mjs new file mode 100644 index 000000000..80689a97f --- /dev/null +++ b/ops/local-work-test-fixture.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { prepareLocalDrive, shellQuote } from './local-work-gate.mjs'; + +// Resolve the SDK's declared YAML dependency through its public package entry, +// allowing Node to find either a local install or a hoisted dependency. +const { load } = createRequire(new URL('../packages/sdk/package.json', import.meta.url))('js-yaml'); + +export const flow = load(readFileSync('workflows/drive-local.yaml', 'utf8')); +export const packagePath = '.relayflow/drive-local/package.json'; +const check = ['node', '-e', "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"]; +export const entry = (title = 'Fix value', path = 'src/value.txt', commands = [check]) => + `- **${title}** Update \`${path}\` to the required value.\n` + + commands.map(argv => ` Verify: ${JSON.stringify(argv)}\n`).join(''); + +export function fixture(t, backlog = entry()) { + mkdirSync('.relayflow', { recursive: true }); + const root = mkdtempSync(resolve('.relayflow/package-tests-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const put = (path, content) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), content); + }; + const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + git('init', '-q', '-b', 'work'); + put('.gitignore', '.relayflow/\n.drive-gate/\npackages/sdk/dist/\nnode_modules/\n'); + put('src/value.txt', 'broken'); + put('outside.txt', 'original'); + put('ops/BACKLOG.md', backlog); + for (const path of ['ops/local-work-package.mjs', 'ops/local-work-verification.mjs', + 'ops/local-work-snapshot.mjs', 'packages/sdk/src/backlog-picker.ts', 'workflows/drive-local.yaml']) { + put(path, readFileSync(path)); + } + put('packages/sdk/dist/backlog-picker.js', readFileSync('packages/sdk/dist/backlog-picker.js')); + git('add', '.'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); + let captured = prepareLocalDrive(flow, { root }); + t.after(() => captured.dispose()); + const step = id => spawnSync('sh', ['-c', captured.flow.steps.find(s => s.id === id).command], + { cwd: root, encoding: 'utf8', timeout: 10000 }); + const run = (operation, extra = []) => { + if (operation === 'select') { + captured.dispose(); + captured = prepareLocalDrive(flow, { root }); + const snapshot = step('gate-snapshot'); + if (snapshot.status !== 0) return snapshot; + } + const command = captured.commands[operation].replace(`${shellQuote(process.execPath)} `, + `${shellQuote(process.execPath)} ${extra.map(shellQuote).join(' ')} `); + const result = spawnSync('sh', ['-c', command], { cwd: root, encoding: 'utf8', timeout: 5000 }); + return result; + }; + const scope = () => step('scope'); + return { root, put, git, run, scope, step, get preparedFlow() { return captured.flow; }, + get gateDirectory() { return captured.directory; } }; +} +export const pass = result => assert.equal(result.status, 0, result.stderr + result.stdout); +export const fail = (result, pattern) => { + assert.notEqual(result.status, 0, result.stdout); + if (pattern) assert.match(result.stderr + result.stdout, pattern); +}; diff --git a/ops/local-work-verification.mjs b/ops/local-work-verification.mjs new file mode 100644 index 000000000..e22544ffd --- /dev/null +++ b/ops/local-work-verification.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { lstatSync, readdirSync, realpathSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; + +// An explicit argv contract avoids treating prose or arbitrary backticked +// snippets as shell commands. Checks are authored before implementation. +export function verificationCommands(body) { + const lines = body.split('\n').filter(line => /^\s*Verify:/.test(line)); + return lines.map(line => { + let argv; + try { argv = JSON.parse(line.replace(/^\s*Verify:\s*/, '')); } + catch { throw new Error('INVALID_EXECUTABLE_CHECK: Verify must contain a JSON argv array'); } + assert(Array.isArray(argv) && argv.length > 0 && + argv.every(arg => typeof arg === 'string' && !arg.includes('\0')) && argv[0].trim(), + 'INVALID_EXECUTABLE_CHECK: expected nonempty command argv'); + return argv; + }); +} + +const protectedPaths = [ + 'ops/BACKLOG.md', 'ops/local-work-package.mjs', 'ops/local-work-verification.mjs', + 'ops/local-work-gate.mjs', 'ops/local-work-snapshot.mjs', 'scripts/run-drive-local.mjs', + 'workflows/drive-local.yaml', 'workflows/gates', 'packages/sdk/src/backlog-picker.ts', +]; +const within = (path, scope) => path === scope || path.startsWith(`${scope}/`); +const gitPaths = (...args) => execFileSync('git', args, { encoding: 'utf8' }).split('\0').filter(Boolean); + +export function checkScope(pkg) { + const root = realpathSync('.'); + const scopes = pkg.filesInScope.map(path => { + const normalized = path.replace(/\/$/, ''); + assert(normalized && !normalized.startsWith('/') && + normalized.split('/').every(part => part && part !== '.' && part !== '..'), + `INVALID_SCOPE: ${path}`); + const kind = execFileSync('git', ['cat-file', '-t', `${pkg.head}:${normalized}`], + { encoding: 'utf8' }).trim(); + return { path: normalized, directory: kind === 'tree' }; + }); + // A declared DIRECTORY scope is an authorization to write anywhere beneath it, + // so its descendants have to be sound before any check runs -- not just the + // paths that happen to be touched. A pre-existing symlink inside such a scope + // is never "touched", so the per-path walk below never sees it, and a Verify + // command can write straight through it to somewhere outside the checkout. + // Symlinks that stay inside the root are left alone: workspace layouts use + // them legitimately, and the threat is escape, not indirection. + for (const scope of scopes.filter(s => s.directory)) { + const stack = [scope.path]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue; + throw error; + } + for (const entry of entries) { + if (entry.name === '.git') continue; + const child = `${dir}/${entry.name}`; + if (entry.isSymbolicLink()) { + let target; + try { + target = realpathSync(child); + } catch (error) { + // O_CREAT follows dangling links too. Without a resolvable target + // we cannot prove containment, so refuse before running checks. + if (error.code === 'ENOENT') throw new Error(`SYMLINK_UNRESOLVED: ${child}`, { cause: error }); + throw error; + } + assert(target === root || target.startsWith(`${root}${sep}`), + `SYMLINK_ESCAPES_SCOPE: ${child} -> ${target}`); + } else if (entry.isDirectory()) { + stack.push(child); + } + } + } + } + // Check index and working tree separately: a staged edit followed by an + // unstaged reversal must not disappear. --no-renames exposes both endpoints. + const touched = new Set([ + ...gitPaths('diff', '--name-only', '--no-renames', '-z', pkg.head, '--'), + ...gitPaths('diff', '--cached', '--name-only', '--no-renames', '-z', pkg.head, '--'), + ...gitPaths('ls-files', '--others', '--exclude-standard', '-z'), + ]); + for (const path of touched) { + assert(!protectedPaths.some(scope => within(path, scope)) && + scopes.some(scope => path === scope.path || (scope.directory && within(path, scope.path))), + `OUT_OF_SCOPE: ${path}`); + // Reject symlinks, including an ancestor replaced by a symlink, so a + // lexical prefix cannot authorize writing outside the checkout. + let current = root; + for (const part of path.split('/')) { + current = resolve(current, part); + try { + assert(!lstatSync(current).isSymbolicLink(), `SYMLINK_SCOPE: ${path}`); + assert(realpathSync(current).startsWith(`${root}${sep}`), `OUT_OF_SCOPE: ${path}`); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') break; // deletion + throw error; + } + } + } + console.log(`SCOPE_OK: ${touched.size} changed path(s)`); +} + +export function runChecks(pkg) { + assert(pkg.verificationCommands?.length > 0, 'MISSING_EXECUTABLE_CHECKS'); + for (const argv of pkg.verificationCommands) { + console.log(`CHECK ${JSON.stringify(argv)}`); + const result = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit', timeout: 120000 }); + assert(!result.error && result.status === 0, + `PACKAGE_CHECK_FAILED: ${JSON.stringify(argv)} (${result.error?.message ?? result.signal ?? result.status})`); + } + console.log(`PACKAGE_VERIFIED: ${pkg.verificationCommands.length} check(s)`); +} diff --git a/ops/runtime-evidence/drive-hardening-0908.txt b/ops/runtime-evidence/drive-hardening-0908.txt new file mode 100644 index 000000000..688ceb594 --- /dev/null +++ b/ops/runtime-evidence/drive-hardening-0908.txt @@ -0,0 +1,480 @@ +Drive-local hardening — captured commands and output + +The baseline local-package test failed during select with NO_BOUNDED_WORK (ops/local-work-package.mjs:123), before any apply command ran. The replacement retains interrupted-write coverage for package selection. + +The default npm test invocation encountered a broken mise Cargo shim. The subsequent command selects the installed Rust toolchain without changing repository build scripts. + +F3 mutation procedure replaced only the cursor block with the original title-search implementation, ran the named test, restored the file byte-for-byte in a finally block, and reran the same test. + + +=== baseline.txt === +$ npm --prefix packages/sdk run build + +> @relayflows/sdk@2.0.6 build +> tsc && node scripts/make-cli-executable.mjs + +EXIT_CODE=0 + +$ node ops/local-work-package.mjs select +SKIPPED [unbounded_scope] `timeoutMs` is enforced LATE, not never — a step ran 2.3x past its limit. +SKIPPED [stale_scope: sdk/tests/live-kernel.test.ts, sdk/src/protocol.ts, sdk/src/journal-client.ts, sdk/src/cli/run.ts] GATES 2 AND 3 ARE BLOCKED ON A MISSING COMPONENT: there is no agent worker. +SKIPPED [unbounded_scope] Half the drive runs complete but build nothing. +SKIPPED [missing_scope] DONE (PR #45, merged): refuse an entry with unterminated backticks. +SKIPPED [missing_scope] DONE (PR #42, merged): sharpen what the picker considers actionable. +SKIPPED [unbounded_scope] Close the deterministic-command preflight gap (Codex P1). +SKIPPED [unbounded_scope] Release pipeline (relay pattern, NOT crates.io): +SKIPPED [missing_scope] Persist review transcripts: +SKIPPED [missing_scope] Re-register cloud schedules from current drive.yaml +SKIPPED [missing_definition_of_done] Customer harness is a named design partner +SKIPPED [missing_scope] PR titles from the pr step +SKIPPED [unbounded_scope] The PR-shepherd flow (Garden component, gate 3): +SELECTED Regression suite (`regressions/`, dormant): + files in scope: regressions/MANIFEST.json + definition of done: 1 item(s) +EXIT_CODE=0 + +$ node --test ops/local-work-package.test.mjs +✖ interrupted package write preserves the original and retry applies once (83.60475ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 0 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 123.169166 + +✖ failing tests: + +test at ops/local-work-package.test.mjs:10:1 +✖ interrupted package write preserves the original and retry applies once (83.60475ms) + AssertionError [ERR_ASSERTION]: node:internal/modules/run_main:107 + triggerUncaughtException( + ^ + + AssertionError [ERR_ASSERTION]: NO_BOUNDED_WORK: 1 entr(y|ies) considered, none named files this loop can scope. Add explicit paths to a BACKLOG entry. + at select (file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.mjs:123:3) + at async file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.mjs:158:27 { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: null, + expected: true, + operator: '==', + diff: 'simple' + } + + Node.js v26.7.0 + + + 1 !== 0 + + at TestContext. (file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.test.mjs:27:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 1, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } +EXIT_CODE=1 + + +=== cursor-red-green.txt === +F3: restore the title-search cursor only. +$ node --test --test-name-pattern='skip cursor' ops/local-work-package.test.mjs +✖ skip cursor passes a later title mentioned in an earlier body (110.139667ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 0 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 154.29075 + +✖ failing tests: + +test at ops/local-work-package.test.mjs:9:1 +✖ skip cursor passes a later title mentioned in an earlier body (110.139667ms) + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + 3 !== 2 + + at TestContext. (file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.test.mjs:14:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 3, + expected: 2, + operator: 'strictEqual', + diff: 'simple' + } +EXIT_CODE=1 + +F3: restore the fix byte-for-byte. +$ node --test --test-name-pattern='skip cursor' ops/local-work-package.test.mjs +✔ skip cursor passes a later title mentioned in an earlier body (193.51275ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 1 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 235.98425 +EXIT_CODE=0 + +=== local-tests.txt === +$ node --test ops/local-work-package.test.mjs ops/drive-local-flow.test.mjs scripts/run-local-workflow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (771.314958ms) +✔ drive-local journals failure and blocks reporting for verifier edit (602.589416ms) +✔ drive-local journals failure and blocks reporting for unchanged package (676.513334ms) +✔ skip cursor passes a later title mentioned in an earlier body (216.564416ms) +✔ selection skips missing checks and stale paths with reasons (218.617167ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (488.200666ms) +✔ multiple acceptance checks all execute and failures propagate (231.413083ms) +✔ scope refuses an outside unstaged path (205.714125ms) +✔ scope refuses an outside staged path (216.896625ms) +✔ scope refuses an outside untracked path (205.137375ms) +✔ scope refuses an outside staged reversal path (204.578667ms) +✔ scope refuses an outside rename path (199.010584ms) +✔ scope refuses an outside deleted path (193.009708ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (107.126583ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (104.506792ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (108.702042ms) +✔ editing ignored package metadata cannot widen scope or replace checks (234.544833ms) +✔ scope allows directory children but rejects a sibling with the same prefix (293.47625ms) +✔ scope allows in-scope deletions and rejects symlink escapes (290.1025ms) +✔ replacing a scoped file with a directory does not authorize its children (192.775208ms) +✔ a check that writes outside scope fails verification (255.667708ms) +✔ interrupted package write preserves the original and retry replaces it atomically (194.482458ms) +✔ local launcher journals deterministic effects and reads more than one journal page (694.106625ms) +✔ a failed command fails the run and prevents dependent effects (134.531791ms) +✔ the SDK worker completes an agent step through the local journal protocol (614.73825ms) +✔ missing daemon is refused before a data directory or run is created (63.763834ms) +ℹ tests 26 +ℹ suites 0 +ℹ pass 26 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 4206.092958 +EXIT_CODE=0 + +=== sdk.txt === +$ npm --prefix packages/sdk test + +> @relayflows/sdk@2.0.6 test +> sh scripts/test.sh + + +> @relayflows/sdk@2.0.6 test:prep +> ( cd ../../kernel && sh ../ops/cargo.sh build ) && ( [ ! -d ../../testdata/preflight ] || find ../../testdata/preflight -name '*-cli' -type f -exec chmod +x {} + ) + +mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use ` to reinstall the tool. +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information +EXIT_CODE=1 + +=== sdk-toolchain.txt === +$ env PATH="/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH" CARGO_TARGET_DIR="$PWD/kernel/target" npm --prefix packages/sdk test + +> @relayflows/sdk@2.0.6 test +> sh scripts/test.sh + + +> @relayflows/sdk@2.0.6 test:prep +> ( cd ../../kernel && sh ../ops/cargo.sh build ) && ( [ ! -d ../../testdata/preflight ] || find ../../testdata/preflight -name '*-cli' -type f -exec chmod +x {} + ) + + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling libc v0.2.189 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling cfg-if v1.0.4 + Compiling autocfg v1.5.1 + Compiling serde_core v1.0.229 + Compiling getrandom v0.3.4 + Compiling zerocopy v0.8.56 + Compiling smallvec v1.15.2 + Compiling serde v1.0.229 + Compiling litemap v0.8.3 + Compiling writeable v0.6.4 + Compiling num-traits v0.2.19 + Compiling memchr v2.8.3 + Compiling generic-array v0.14.7 + Compiling icu_normalizer_data v2.3.0 + Compiling utf8_iter v1.0.4 + Compiling icu_properties_data v2.3.0 + Compiling zmij v1.0.23 + Compiling typenum v1.20.1 + Compiling parking_lot_core v0.9.12 + Compiling ref-cast v1.0.27 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling aho-corasick v1.1.5 + Compiling num-integer v0.1.47 + Compiling synstructure v0.13.2 + Compiling num-bigint v0.4.8 + Compiling ahash v0.8.12 + Compiling serde_json v1.0.151 + Compiling shlex v2.0.1 + Compiling find-msvc-tools v0.1.11 + Compiling regex-syntax v0.8.11 + Compiling scopeguard v1.2.0 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling lock_api v0.4.14 + Compiling num-rational v0.4.2 + Compiling cc v1.4.4 + Compiling num-iter v0.1.46 + Compiling zerofrom v0.1.8 + Compiling num-complex v0.4.6 + Compiling rand_core v0.9.5 + Compiling ppv-lite86 v0.2.21 + Compiling once_cell v1.21.4 + Compiling itoa v1.0.18 + Compiling pkg-config v0.3.34 + Compiling bit-vec v0.8.0 + Compiling borrow-or-share v0.2.4 + Compiling zerovec-derive v0.11.6 + Compiling displaydoc v0.2.7 + Compiling serde_derive v1.0.229 + Compiling ref-cast-impl v1.0.27 + Compiling regex-automata v0.4.18 + Compiling vcpkg v0.2.15 + Compiling bit-set v0.8.0 + Compiling num v0.4.3 + Compiling rand_chacha v0.9.0 + Compiling parking_lot v0.12.5 + Compiling block-buffer v0.10.4 + Compiling libsqlite3-sys v0.35.0 + Compiling crypto-common v0.1.7 + Compiling utf8parse v0.2.2 + Compiling thiserror v2.0.20 + Compiling foldhash v0.1.5 + Compiling uuid v1.26.0 + Compiling yoke v0.8.3 + Compiling lazy_static v1.5.0 + Compiling vsimd v0.8.0 + Compiling percent-encoding v2.3.2 + Compiling outref v0.5.2 + Compiling fraction v0.15.4 + Compiling hashbrown v0.15.5 + Compiling anstyle-parse v1.0.0 + Compiling uuid-simd v0.8.0 + Compiling digest v0.10.7 + Compiling rand v0.9.5 + Compiling thiserror-impl v2.0.20 + Compiling cpufeatures v0.2.17 + Compiling bytecount v0.6.9 + Compiling base64 v0.22.1 + Compiling num-cmp v0.1.0 + Compiling anstyle v1.0.14 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling colorchoice v1.0.5 + Compiling anstyle-query v1.1.5 + Compiling is_terminal_polyfill v1.70.2 + Compiling hashlink v0.10.0 + Compiling sha2 v0.10.9 + Compiling anstream v1.0.0 + Compiling heck v0.5.0 + Compiling fallible-streaming-iterator v0.1.9 + Compiling strsim v0.11.1 + Compiling clap_lex v1.1.0 + Compiling anyhow v1.0.104 + Compiling fallible-iterator v0.3.0 + Compiling fancy-regex v0.16.2 + Compiling regex v1.13.1 + Compiling bitflags v2.13.1 + Compiling clap_builder v4.6.6 + Compiling clap_derive v4.6.4 + Compiling tinystr v0.8.4 + Compiling icu_locale_core v2.3.0 + Compiling potential_utf v0.1.6 + Compiling icu_collections v2.3.0 + Compiling wait-timeout v0.2.1 + Compiling fluent-uri v0.3.2 + Compiling email_address v0.2.9 + Compiling ulid v1.2.1 + Compiling icu_provider v2.3.1 + Compiling icu_normalizer v2.3.0 + Compiling icu_properties v2.3.0 + Compiling referencing v0.33.0 + Compiling clap v4.6.6 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling rusqlite v0.37.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.67s + +> @relayflows/sdk@2.0.6 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.6 build +> tsc && node scripts/make-cli-executable.mjs + + +> @relayflows/sdk@2.0.6 typecheck:tests +> tsc -p tsconfig.tests.json + + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/packages/sdk + +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/target/debug/relayflowd +LIVE_KERNEL flows=/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/packages/sdk/dist/cli.js + + ✓ tests/tick-source.test.ts (33 tests) 13ms + ✓ tests/daemon-lifecycle.test.ts (42 tests) 19ms + ✓ tests/journal-client.test.ts (14 tests) 70ms + ✓ tests/validate.test.ts (68 tests) 16ms + ✓ tests/preflight.test.ts (25 tests) 29ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 98ms + ✓ tests/gate-contract.test.ts (20 tests) 156ms + ✓ tests/verb-field-lint.test.ts (78 tests) 272ms + ✓ tests/tick-runner.test.ts (22 tests) 524ms + ✓ tests/backlog-picker.test.ts (14 tests) 139ms + ✓ tests/authored-flow-lifecycle-executor.test.ts (27 tests) 508ms + ✓ tests/authored-flow.test.ts (23 tests) 843ms + ✓ authored flow journal executor > refuses manually chained work even when it settles before the body returns 305ms + ✓ authored flow journal executor > retains root operation failures even when a derived rejection handler consumes them 306ms + ✓ tests/work-package-consumer.test.ts (13 tests) 170ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 466ms + ✓ tests/authored-flow-operation.test.ts (23 tests) 340ms + ✓ tests/spec-parity.test.ts (31 tests) 256ms + ✓ tests/typed-output.test.ts (14 tests) 160ms + ✓ tests/model-selection.test.ts (10 tests) 11ms + ✓ tests/relayflowd-path.test.ts (10 tests) 9ms + ✓ tests/deterministic-llm.test.ts (5 tests) 73ms + ✓ tests/hn-poller.test.ts (6 tests) 3ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 6ms + ✓ tests/dependency-validation.test.ts (6 tests) 414ms + ✓ tests/direct-input.test.ts (4 tests) 1245ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 637ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 428ms + ✓ tests/work-package-validator.test.ts (7 tests) 4ms + ✓ tests/hello-deterministic.test.ts (5 tests) 12ms + ✓ tests/bin.test.ts (7 tests) 645ms + ✓ tests/parse-json-output.test.ts (7 tests) 1ms + ↓ tests/real-cli-adapters.test.ts (3 tests | 3 skipped) + ✓ tests/cli-adapter.test.ts (3 tests) 4ms + ✓ tests/placement.test.ts (54 tests) 7ms + ✓ tests/memory.test.ts (18 tests) 4ms + ✓ tests/json-schema-bound.test.ts (71 tests) 1702ms + ✓ JSON Schema termination bound > walks a deep schema with an explicit stack rather than recursion 1352ms + ✓ tests/classify-outcome.test.ts (2 tests) 2228ms + ✓ classifyOutcome > gives up and reports when a running run never becomes classifiable 2066ms + ✓ tests/cli.test.ts (63 tests) 4804ms + ✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 1161ms + ✓ flows check CLI > uses the raw Claude adapter model flag instead of accepting auth status as model proof 620ms + ✓ flows check CLI > uses Codex login status and reports a rejected model as unavailable, not unauthenticated 624ms + ✓ flows check CLI > refuses a nonconforming custom wrapper without calling it an authentication failure 316ms + ✓ flows check CLI > accepts an exact allowlisted named-agent model and probes that model 515ms + ✓ flows check CLI > checks the same named-agent contract from declarative JSON 506ms + ✓ tests/daemon-lifecycle-live.test.ts (9 tests) 5488ms + ✓ flows run against a data dir with no daemon (§6 test 7) > cold start spawns exactly one daemon, the run succeeds, and the daemon outlives the CLI 1099ms + ✓ flows run against a data dir with no daemon (§6 test 7) > polls, bounded, for a daemon that holds the lock before it binds 1526ms + ✓ flows run against a data dir with no daemon (§6 test 7) > attaches to a serving daemon that has not published a connection file 539ms + ✓ flows run against a data dir with no daemon (§6 test 7) > a second run attaches to the daemon the first one started, spawning nothing 301ms + ✓ flows run against a data dir with no daemon (§6 test 7) > detects a stale connection file left by a hard kill and starts a fresh daemon 522ms + ✓ concurrent invocations against one empty data dir (§6 test 15) > ends with exactly one daemon owning the socket, and both runs succeed 867ms + ✓ tests/worker-cli.test.ts (13 tests) 25064ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 1106ms + ✓ custom wrapper execution identity > bounds wrapper execution after acknowledgement 881ms + ✓ custom wrapper execution identity > bounds captured wrapper output 489ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 318ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 2063ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 1906ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3258ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11257ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 852ms + ✓ custom wrapper execution bounds are reader-owned > still bounds an un-terminated handshake buffer and names the bound 1526ms + ✓ delivers the journaled memory pack to the real wrapper and excludes its charge from completion usage 830ms +stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER ready: claude -p --model claude-haiku-4-5-20251001 round-trip OK + +stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER analysis: {"reasoning":"This story directly describes an AI agent performing autonomous software development tasks (opening and reviewing pull requests), which is a core application of AI agent automation and closely aligned with autonomous systems in development workflows.","relevance_score":9,"story_title":"Show HN: an agent that opens and reviews its own pull requests [wake-nonce-7f3a91c4]"} + +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=58369 run=01M206M7KTVASPSFP8S8529PP2 while step=two state=Running + + ✓ tests/live-kernel.test.ts (28 tests) 52485ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 1644ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 32411ms + ✓ built flows CLI against live relayflowd > runs an agent CLI end to end through the SDK worker 317ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 5557ms + ✓ built flows CLI against live relayflowd > AgentWorker passes a declared model to an identified wrapper as RELAYFLOW_MODEL 673ms + ✓ built flows CLI against live relayflowd > AgentWorker refuses a nonconforming journal-submitted wrapper before exposing RELAYFLOW_MODEL 473ms + ✓ built flows CLI against live relayflowd > AgentWorker executes the raw codex adapter with its real model flag 501ms + ✓ built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI 8661ms + + Test Files 37 passed | 1 skipped (38) + Tests 803 passed | 3 skipped (806) + Start at 11:46:20 + Duration 52.90s (transform 710ms, setup 0ms, collect 3.40s, tests 98.29s, environment 6ms, prepare 1.37s) + +EXIT_CODE=0 + +=== Real backlog at implementation commit 6242049 === +$ node ops/local-work-package.mjs select +SKIPPED [unbounded_scope] `timeoutMs` is enforced LATE, not never — a step ran 2.3x past its limit. +SKIPPED [stale_scope: sdk/tests/live-kernel.test.ts, sdk/src/protocol.ts, sdk/src/journal-client.ts, sdk/src/cli/run.ts] GATES 2 AND 3 ARE BLOCKED ON A MISSING COMPONENT: there is no agent worker. +SKIPPED [unbounded_scope] Half the drive runs complete but build nothing. +SKIPPED [missing_scope] DONE (PR #45, merged): refuse an entry with unterminated backticks. +SKIPPED [missing_scope] DONE (PR #42, merged): sharpen what the picker considers actionable. +SKIPPED [unbounded_scope] Close the deterministic-command preflight gap (Codex P1). +SKIPPED [unbounded_scope] Release pipeline (relay pattern, NOT crates.io): +SKIPPED [missing_scope] Persist review transcripts: +SKIPPED [missing_scope] Re-register cloud schedules from current drive.yaml +SKIPPED [missing_definition_of_done] Customer harness is a named design partner +SKIPPED [missing_scope] PR titles from the pr step +SKIPPED [unbounded_scope] The PR-shepherd flow (Garden component, gate 3): +SKIPPED [missing_executable_checks] Regression suite (`regressions/`, dormant): +SKIPPED [unbounded_scope] `f.browser` helper (gate-6 family, plugin-shaped). +SKIPPED [unbounded_scope] Computer use — deferred, behind heavier rails. +SKIPPED [missing_definition_of_done] Upstream issues (2026-08-27): +SKIPPED [unbounded_scope] Cloud sandbox runs die in `sync`: no git remote. +SKIPPED [missing_executable_checks] Documented `steps: []` check/kernel asymmetry (P3, WP-4 review V3). +SKIPPED [unbounded_scope] Scope the `flows check` CLI probe environment (gate 8; WP-7 F6). +SKIPPED [unbounded_scope] F1 +SKIPPED [missing_scope] F2 +SKIPPED [unbounded_scope] F3 +SKIPPED [unbounded_scope] F5 +SKIPPED [unbounded_scope] F6 +SELECTED F8b + files in scope: packages/sdk/src/compile.ts + definition of done: 1 item(s) +EXIT_CODE=0 + +$ node ops/local-work-package.mjs scope +SCOPE_OK: 0 changed path(s) +EXIT_CODE=0 + +$ node ops/local-work-package.mjs verify +SCOPE_OK: 0 changed path(s) +CHECK ["node","--input-type=module","-e","import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source = readFileSync('packages/sdk/src/compile.ts', 'utf8'); assert(!source.includes('validateKernelRetry')); assert(source.includes('function validateAuthoringRetryDefaults(')); assert(source.includes('validateAuthoringRetryDefaults(step['));"] +PACKAGE_VERIFIED: 1 check(s) +SCOPE_OK: 0 changed path(s) +EXIT_CODE=0 + +$ node ops/local-work-package.mjs report +REPORT F8b + (no working-tree changes) + DoD: F8b +EXIT_CODE=0 + diff --git a/ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs b/ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs new file mode 100644 index 000000000..57885f8f1 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs @@ -0,0 +1,22 @@ +// Open design blocker: argv is pinned, but arbitrary script dependencies are not. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { entry, fixture, pass, fail } from '../local-work-test-fixture.mjs'; + +const cleanup = []; +try { + const f = fixture({ after: fn => cleanup.push(fn) }, entry('Directory', 'src/', [['node', 'src/check.mjs']])); + f.put('src/check.mjs', "import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; assert.equal(readFileSync('src/value.txt','utf8'),'fixed');\n"); + f.git('add', 'src/check.mjs'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'acceptance'); + pass(f.run('select')); + const before = f.run('verify'); + fail(before, /PACKAGE_CHECK_FAILED/); + console.log('ORIGINAL_CHECK_WITH_BROKEN_IMPLEMENTATION_EXIT=' + before.status); + f.put('src/check.mjs', 'process.exit(0);\n'); + const forged = f.run('verify'); + pass(forged); + console.log('EDITED_ACCEPTANCE_SCRIPT_EXIT=' + forged.status); + process.stdout.write(forged.stdout + forged.stderr); + console.log('IMPLEMENTATION=' + readFileSync(join(f.root, 'src/value.txt'), 'utf8')); +} finally { for (const fn of cleanup) fn(); } diff --git a/ops/runtime-evidence/drive-threads-0909-acceptance-input.txt b/ops/runtime-evidence/drive-threads-0909-acceptance-input.txt new file mode 100644 index 000000000..b23686593 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-acceptance-input.txt @@ -0,0 +1,10 @@ +$ node ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs +ORIGINAL_CHECK_WITH_BROKEN_IMPLEMENTATION_EXIT=1 +EDITED_ACCEPTANCE_SCRIPT_EXIT=0 +SCOPE_OK: 1 changed path(s) +CHECK ["node","src/check.mjs"] +PACKAGE_VERIFIED: 1 check(s) +SCOPE_OK: 1 changed path(s) +IMPLEMENTATION=broken + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-after.txt b/ops/runtime-evidence/drive-threads-0909-after.txt new file mode 100644 index 000000000..e337c3a7c --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-after.txt @@ -0,0 +1,38 @@ +$ node --test ops/local-work-package.test.mjs ops/local-work-review.test.mjs +✔ skip cursor passes a later title mentioned in an earlier body (592.25075ms) +✔ selection skips missing checks and stale paths with reasons (605.41ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (909.826375ms) +✔ multiple acceptance checks all execute and failures propagate (603.32375ms) +✔ scope refuses an outside unstaged path (596.18475ms) +✔ scope refuses an outside staged path (588.12325ms) +✔ scope refuses an outside untracked path (566.958417ms) +✔ scope refuses an outside staged reversal path (573.086334ms) +✔ scope refuses an outside rename path (539.01ms) +✔ scope refuses an outside deleted path (529.127208ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (440.83575ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (416.019333ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (426.724833ms) +✔ editing ignored package metadata cannot widen scope or replace checks (588.573417ms) +✔ scope allows directory children but rejects a sibling with the same prefix (671.063833ms) +✔ scope allows in-scope deletions and rejects symlink escapes (654.396083ms) +✔ replacing a scoped file with a directory does not authorize its children (525.916125ms) +✔ a check that writes outside scope fails verification (580.079417ms) +✔ interrupted package write preserves the original and retry replaces it atomically (856.901417ms) +✔ report includes an allowed unstaged change (604.990041ms) +✔ report includes an allowed staged change (613.164375ms) +✔ report includes an allowed untracked change (600.954375ms) +✔ directory scope checks a pre-existing dangling escape symlink (525.141417ms) +✔ directory scope checks a pre-existing existing internal symlink (578.75775ms) +✔ selection skips untracked scope and accepts the next committed scope (449.340959ms) +✔ reporting after SDK suite effects enforces scope for outside.txt (771.198042ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (862.321625ms) +ℹ tests 27 +ℹ suites 0 +ℹ pass 27 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 11308.214375 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-baseline.txt b/ops/runtime-evidence/drive-threads-0909-baseline.txt new file mode 100644 index 000000000..6185ab0ca --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-baseline.txt @@ -0,0 +1,321 @@ +$ node --test ops/local-work-package.test.mjs +✖ skip cursor passes a later title mentioned in an earlier body (189.542625ms) +✖ selection skips missing checks and stale paths with reasons (173.940125ms) +✖ a non-SDK package fails unchanged and passes only after its check holds (282.30425ms) +✔ multiple acceptance checks all execute and failures propagate (273.12475ms) +✖ scope refuses an outside unstaged path (161.20125ms) +✖ scope refuses an outside staged path (163.438917ms) +✖ scope refuses an outside untracked path (153.918833ms) +✖ scope refuses an outside staged reversal path (165.684917ms) +✖ scope refuses an outside rename path (163.418917ms) +✖ scope refuses an outside deleted path (156.003625ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (139.637792ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (137.1565ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (133.5985ms) +✖ editing ignored package metadata cannot widen scope or replace checks (158.204ms) +✖ scope allows directory children but rejects a sibling with the same prefix (153.890833ms) +✖ scope allows in-scope deletions and rejects symlink escapes (151.434708ms) +✖ replacing a scoped file with a directory does not authorize its children (153.157625ms) +✔ a check that writes outside scope fails verification (316.586583ms) +✔ interrupted package write preserves the original and retry replaces it atomically (256.604083ms) +ℹ tests 19 +ℹ suites 0 +ℹ pass 6 +ℹ fail 13 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3530.785583 + +✖ failing tests: + +test at ops/local-work-package.test.mjs:9:1 +✖ skip cursor passes a later title mentioned in an earlier body (189.542625ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:16:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:19:1 +✖ selection skips missing checks and stale paths with reasons (173.940125ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:26:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:29:1 +✖ a non-SDK package fails unchanged and passes only after its check holds (282.30425ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:34:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside unstaged path (161.20125ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside staged path (163.438917ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside untracked path (153.918833ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside staged reversal path (165.684917ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside rename path (163.418917ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside deleted path (156.003625ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:69:1 +✖ editing ignored package metadata cannot widen scope or replace checks (158.204ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /PACKAGE_CHANGED/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:75:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /PACKAGE_CHANGED/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:79:1 +✖ scope allows directory children but rejects a sibling with the same prefix (153.890833ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:83:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:88:1 +✖ scope allows in-scope deletions and rejects symlink escapes (151.434708ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:92:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:97:1 +✖ replacing a scoped file with a directory does not authorize its children (153.157625ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:102:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-build.txt b/ops/runtime-evidence/drive-threads-0909-build.txt new file mode 100644 index 000000000..fb0a423a9 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-build.txt @@ -0,0 +1,261 @@ +$ cd packages/sdk && node node_modules/typescript/bin/tsc && node scripts/make-cli-executable.mjs +EXIT_CODE=0 + +$ cd kernel && sh ../ops/cargo.sh build +mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use ` to reinstall the tool. +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information +EXIT_CODE=1 + +$ cd kernel && PATH=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH cargo build + Updating crates.io index + Downloading crates ... + Downloaded wait-timeout v0.2.1 + Downloaded anstyle-query v1.1.5 + Downloaded bit-set v0.8.0 + Downloaded zerofrom v0.1.8 + Downloaded block-buffer v0.10.4 + Downloaded borrow-or-share v0.2.4 + Downloaded anstyle-parse v1.0.0 + Downloaded cfg-if v1.0.4 + Downloaded anstream v1.0.0 + Downloaded anstyle v1.0.14 + Downloaded autocfg v1.5.1 + Downloaded typenum v1.20.1 + Downloaded yoke-derive v0.8.2 + Downloaded utf8parse v0.2.2 + Downloaded uuid-simd v0.8.0 + Downloaded version_check v0.9.5 + Downloaded bit-vec v0.8.0 + Downloaded bytecount v0.6.9 + Downloaded anyhow v1.0.104 + Downloaded ahash v0.8.12 + Downloaded bitflags v2.13.1 + Downloaded cpufeatures v0.2.17 + Downloaded zerofrom-derive v0.1.7 + Downloaded crypto-common v0.1.7 + Downloaded colorchoice v1.0.5 + Downloaded email_address v0.2.9 + Downloaded displaydoc v0.2.7 + Downloaded utf8_iter v1.0.4 + Downloaded clap_lex v1.1.0 + Downloaded fallible-streaming-iterator v0.1.9 + Downloaded tinystr v0.8.4 + Downloaded zerovec-derive v0.11.6 + Downloaded zmij v1.0.23 + Downloaded ulid v1.2.1 + Downloaded vsimd v0.8.0 + Downloaded writeable v0.6.4 + Downloaded idna_adapter v1.2.2 + Downloaded itoa v1.0.18 + Downloaded yoke v0.8.3 + Downloaded is_terminal_polyfill v1.70.2 + Downloaded num-iter v0.1.46 + Downloaded digest v0.10.7 + Downloaded generic-array v0.14.7 + Downloaded stable_deref_trait v1.2.1 + Downloaded unicode-ident v1.0.24 + Downloaded ref-cast v1.0.27 + Downloaded uuid v1.26.0 + Downloaded zerotrie v0.2.5 + Downloaded zerovec v0.11.8 + Downloaded vcpkg v0.2.15 + Downloaded zerocopy v0.8.56 + Downloaded heck v0.5.0 + Downloaded lazy_static v1.5.0 + Downloaded num v0.4.3 + Downloaded num-cmp v0.1.0 + Downloaded num-rational v0.4.2 + Downloaded outref v0.5.2 + Downloaded percent-encoding v2.3.2 + Downloaded pkg-config v0.3.34 + Downloaded potential_utf v0.1.6 + Downloaded rand_chacha v0.9.0 + Downloaded ref-cast-impl v1.0.27 + Downloaded scopeguard v1.2.0 + Downloaded strsim v0.11.1 + Downloaded thiserror v2.0.20 + Downloaded base64 v0.22.1 + Downloaded cc v1.4.4 + Downloaded clap_derive v4.6.4 + Downloaded fallible-iterator v0.3.0 + Downloaded find-msvc-tools v0.1.11 + Downloaded foldhash v0.1.5 + Downloaded hashlink v0.10.0 + Downloaded lock_api v0.4.14 + Downloaded num-complex v0.4.6 + Downloaded num-integer v0.1.47 + Downloaded once_cell v1.21.4 + Downloaded parking_lot_core v0.9.12 + Downloaded ppv-lite86 v0.2.21 + Downloaded quote v1.0.47 + Downloaded rand_core v0.9.5 + Downloaded sha2 v0.10.9 + Downloaded shlex v2.0.1 + Downloaded synstructure v0.13.2 + Downloaded fluent-uri v0.3.2 + Downloaded parking_lot v0.12.5 + Downloaded clap v4.6.6 + Downloaded litemap v0.8.3 + Downloaded thiserror-impl v2.0.20 + Downloaded getrandom v0.3.4 + Downloaded icu_provider v2.3.1 + Downloaded referencing v0.33.0 + Downloaded smallvec v1.15.2 + Downloaded num-traits v0.2.19 + Downloaded icu_normalizer_data v2.3.0 + Downloaded serde_derive v1.0.229 + Downloaded serde_core v1.0.229 + Downloaded proc-macro2 v1.0.107 + Downloaded icu_locale_core v2.3.0 + Downloaded serde v1.0.229 + Downloaded fraction v0.15.4 + Downloaded icu_collections v2.3.0 + Downloaded memchr v2.8.3 + Downloaded num-bigint v0.4.8 + Downloaded fancy-regex v0.16.2 + Downloaded icu_properties v2.3.0 + Downloaded rand v0.9.5 + Downloaded aho-corasick v1.1.5 + Downloaded jsonschema v0.33.0 + Downloaded clap_builder v4.6.6 + Downloaded serde_json v1.0.151 + Downloaded idna v1.1.0 + Downloaded hashbrown v0.15.5 + Downloaded icu_properties_data v2.3.0 + Downloaded regex v1.13.1 + Downloaded rusqlite v0.37.0 + Downloaded syn v2.0.119 + Downloaded syn v3.0.4 + Downloaded regex-syntax v0.8.11 + Downloaded icu_normalizer v2.3.0 + Downloaded regex-automata v0.4.18 + Downloaded libc v0.2.189 + Downloaded libsqlite3-sys v0.35.0 + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling libc v0.2.189 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling cfg-if v1.0.4 + Compiling autocfg v1.5.1 + Compiling serde_core v1.0.229 + Compiling zerocopy v0.8.56 + Compiling getrandom v0.3.4 + Compiling serde v1.0.229 + Compiling smallvec v1.15.2 + Compiling memchr v2.8.3 + Compiling writeable v0.6.4 + Compiling num-traits v0.2.19 + Compiling litemap v0.8.3 + Compiling generic-array v0.14.7 + Compiling icu_properties_data v2.3.0 + Compiling utf8_iter v1.0.4 + Compiling icu_normalizer_data v2.3.0 + Compiling zmij v1.0.23 + Compiling ref-cast v1.0.27 + Compiling parking_lot_core v0.9.12 + Compiling typenum v1.20.1 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling num-integer v0.1.47 + Compiling aho-corasick v1.1.5 + Compiling num-bigint v0.4.8 + Compiling ahash v0.8.12 + Compiling serde_json v1.0.151 + Compiling shlex v2.0.1 + Compiling regex-syntax v0.8.11 + Compiling find-msvc-tools v0.1.11 + Compiling synstructure v0.13.2 + Compiling scopeguard v1.2.0 + Compiling lock_api v0.4.14 + Compiling num-rational v0.4.2 + Compiling cc v1.4.4 + Compiling num-iter v0.1.46 + Compiling num-complex v0.4.6 + Compiling ppv-lite86 v0.2.21 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling regex-automata v0.4.18 + Compiling rand_core v0.9.5 + Compiling itoa v1.0.18 + Compiling borrow-or-share v0.2.4 + Compiling bit-vec v0.8.0 + Compiling once_cell v1.21.4 + Compiling vcpkg v0.2.15 + Compiling pkg-config v0.3.34 + Compiling bit-set v0.8.0 + Compiling rand_chacha v0.9.0 + Compiling zerofrom v0.1.8 + Compiling num v0.4.3 + Compiling parking_lot v0.12.5 + Compiling crypto-common v0.1.7 + Compiling block-buffer v0.10.4 + Compiling utf8parse v0.2.2 + Compiling vsimd v0.8.0 + Compiling thiserror v2.0.20 + Compiling libsqlite3-sys v0.35.0 + Compiling yoke v0.8.3 + Compiling percent-encoding v2.3.2 + Compiling uuid v1.26.0 + Compiling outref v0.5.2 + Compiling zerovec-derive v0.11.6 + Compiling displaydoc v0.2.7 + Compiling serde_derive v1.0.229 + Compiling ref-cast-impl v1.0.27 + Compiling lazy_static v1.5.0 + Compiling foldhash v0.1.5 + Compiling fraction v0.15.4 + Compiling uuid-simd v0.8.0 + Compiling hashbrown v0.15.5 + Compiling zerotrie v0.2.5 + Compiling thiserror-impl v2.0.20 + Compiling anstyle-parse v1.0.0 + Compiling digest v0.10.7 + Compiling zerovec v0.11.8 + Compiling rand v0.9.5 + Compiling cpufeatures v0.2.17 + Compiling anstyle-query v1.1.5 + Compiling is_terminal_polyfill v1.70.2 + Compiling bytecount v0.6.9 + Compiling colorchoice v1.0.5 + Compiling base64 v0.22.1 + Compiling num-cmp v0.1.0 + Compiling anstyle v1.0.14 + Compiling anstream v1.0.0 + Compiling sha2 v0.10.9 + Compiling hashlink v0.10.0 + Compiling anyhow v1.0.104 + Compiling bitflags v2.13.1 + Compiling fallible-streaming-iterator v0.1.9 + Compiling fallible-iterator v0.3.0 + Compiling heck v0.5.0 + Compiling strsim v0.11.1 + Compiling clap_lex v1.1.0 + Compiling clap_derive v4.6.4 + Compiling clap_builder v4.6.6 + Compiling wait-timeout v0.2.1 + Compiling regex v1.13.1 + Compiling fancy-regex v0.16.2 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.6 + Compiling icu_locale_core v2.3.0 + Compiling icu_collections v2.3.0 + Compiling fluent-uri v0.3.2 + Compiling email_address v0.2.9 + Compiling ulid v1.2.1 + Compiling icu_provider v2.3.1 + Compiling icu_properties v2.3.0 + Compiling icu_normalizer v2.3.0 + Compiling referencing v0.33.0 + Compiling clap v4.6.6 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling rusqlite v0.37.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/flows-threads-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/flows-threads-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/flows-threads-wt/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 15.90s + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-decisions.md b/ops/runtime-evidence/drive-threads-0909-decisions.md new file mode 100644 index 000000000..8cfb2462b --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-decisions.md @@ -0,0 +1,61 @@ +# PR #244: pinned Git gate inputs and remaining acceptance-input decision + +The user clarified the H1 design: copying working-tree bytes does not create a +trust boundary. The earlier command embedding at `4d88ac2` is superseded because +it also took its helper and compiled-picker inputs from the working tree. + +The launcher now pins HEAD before submission. `gate-snapshot` extracts the +package helper, verifier and picker TypeScript source from that commit with +`git --no-replace-objects show`, and builds the picker in a temporary directory +outside the checkout before selection and implementation. The snapshot script +itself is loaded from the same ref. The package records it as `head`; checks +compare it to the submitted pin so repinning metadata cannot hide a moved HEAD. +No SHA256SUMS or adjacent ref file is used as authority. + +The installed TypeScript compiler and local launcher remain trusted tools. A +same-user agent can still write to the extracted temp files. The claim is about +the origin of gate inputs, not same-user filesystem isolation or runtime-file +immutability. The kernel and journal protocol are unchanged. + +The contract is summarized in `../DRIVE-LOCAL.md`. The fixture uses the same +preparer and actual snapshot step, commits source into its disposable Git repo, +and invokes the installed compiler without symlinking host node_modules. + +`drive-threads-0909-git-input-before.txt` captures two failures before this fix: +a substituted working-tree helper or ignored compiled picker returned success +without selecting any package. The current regression tests also assert that +extracted bytes match Git, dirty TypeScript is ignored as input, committed +invalid TypeScript fails the snapshot build, and fixed implementation passes. +The combined captured run is `drive-threads-0909-git-input-after.txt`. + +**Remaining decision: how are acceptance-check inputs declared?** Immutable +argv alone does not freeze a script or its imports. A command such as +`node src/check.mjs` can execute an agent-edited judge. Inferring its transitive +inputs from arbitrary argv is not reliable. Choose a declared immutable +acceptance-input contract or an independently preserved acceptance workspace +that tests the changed source. Do not silently narrow the existing arbitrary +argv authoring contract. The flow remains blocked for unattended use; no swarm +pass is claimed. + +`drive-threads-0909-acceptance-input-probe.mjs` reproduces this remaining bypass: +an unchanged broken value fails its original check, but replacing an allowed +check script with a no-op makes verification exit zero. The adjacent capture +records the literal command and output. A zero probe exit means the known +bypass was reproduced, not that this boundary is safe. + +## Captured evidence lifecycle + +- At commit `34349b2`, `drive-threads-0909-trust-probe.mjs` produced the adjacent + `drive-threads-0909-trust-probe.txt`: repinning HEAD and rewriting a snapshot + plus manifest were both accepted. That file is historical failure evidence. + To reproduce those exact bytes, use that commit's probe and fixture together. +- The updated probe now asserts HEAD repinning is refused and a forged legacy + snapshot cannot bless broken implementation. It then fixes the value and + verifies the good case. `drive-threads-0909-owned-trust-after.txt` captures it. +- `drive-threads-0909-owned-gate-after.txt` captures the combined package, review, + gate and real-daemon journal tests. The daemon cases assert failed completion + reasons and that reporting never starts after a bypass attempt. +- Earlier `baseline`, `review-before`, `post-suite-before` and `after` captures + retain their historical meaning. The old fixture compiled a snapshot, which + is superseded by the preparing launcher. These are before/after reproductions; + none is labeled mutation verification. diff --git a/ops/runtime-evidence/drive-threads-0909-flow-after.txt b/ops/runtime-evidence/drive-threads-0909-flow-after.txt new file mode 100644 index 000000000..7f53770b9 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-flow-after.txt @@ -0,0 +1,14 @@ +$ node --test ops/drive-local-flow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (1786.44325ms) +✔ drive-local journals failure and blocks reporting for verifier edit (946.490334ms) +✔ drive-local journals failure and blocks reporting for unchanged package (1199.776667ms) +ℹ tests 3 +ℹ suites 0 +ℹ pass 3 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3977.453917 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-git-input-after.txt b/ops/runtime-evidence/drive-threads-0909-git-input-after.txt new file mode 100644 index 000000000..ebab4b647 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-git-input-after.txt @@ -0,0 +1,53 @@ +$ node --test ops/local-work-package.test.mjs ops/local-work-review.test.mjs ops/local-work-gate.test.mjs ops/local-work-git-input.test.mjs ops/drive-local-flow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (1569.386209ms) +✔ drive-local journals failure and blocks reporting for verifier edit (1338.876042ms) +✔ drive-local journals failure and blocks reporting for unchanged package (1419.740917ms) +✔ drive-local journals failure and blocks reporting for HEAD repin (1237.099416ms) +✔ drive-local journals failure and blocks reporting for forged snapshot (1282.077542ms) +✔ committing outside scope and repinning metadata cannot replace the submitted HEAD (933.017166ms) +✔ forged legacy checkout snapshot and compiled picker cannot replace the Git-extracted judge (1304.608875ms) +✔ raw template refuses selection before implementation without captured gate inputs (174.595834ms) +✔ selection advances past rejected duplicate title: Same title (617.767041ms) +✔ selection advances past rejected duplicate title: Regex [a].* (b) + $ (600.579708ms) +✔ preparation reads the committed gate instead of a working-tree replacement (1081.631542ms) +✔ gate-snapshot builds committed picker source outside the checkout (999.972542ms) +✔ a committed picker compile failure prevents package selection (513.214333ms) +✔ an ignored compiled picker cannot become the input to selection (1016.511833ms) +✔ Git replacement objects cannot substitute gate source at the pinned ref (970.567792ms) +✔ skip cursor passes a later title mentioned in an earlier body (817.737334ms) +✔ selection skips missing checks and stale paths with reasons (804.1355ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (1051.719084ms) +✔ multiple acceptance checks all execute and failures propagate (829.5705ms) +✔ scope refuses an outside unstaged path (669.861834ms) +✔ scope refuses an outside staged path (631.429708ms) +✔ scope refuses an outside untracked path (594.261792ms) +✔ scope refuses an outside staged reversal path (568.371417ms) +✔ scope refuses an outside rename path (569.997916ms) +✔ scope refuses an outside deleted path (519.848917ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (501.358125ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (495.480167ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (462.9035ms) +✔ editing ignored package metadata cannot widen scope or replace checks (524.0125ms) +✔ scope allows directory children but rejects a sibling with the same prefix (584.357666ms) +✔ scope allows in-scope deletions and rejects symlink escapes (606.571792ms) +✔ replacing a scoped file with a directory does not authorize its children (505.246208ms) +✔ a check that writes outside scope fails verification (586.051958ms) +✔ interrupted package write preserves the original and retry replaces it atomically (1099.643667ms) +✔ report includes an allowed unstaged change (976.478625ms) +✔ report includes an allowed staged change (911.101833ms) +✔ report includes an allowed untracked change (891.555583ms) +✔ directory scope checks a pre-existing dangling escape symlink (770.519ms) +✔ directory scope checks a pre-existing existing internal symlink (706.921209ms) +✔ selection skips untracked scope and accepts the next committed scope (510.050208ms) +✔ reporting after SDK suite effects enforces scope for outside.txt (781.408584ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (895.763ms) +ℹ tests 42 +ℹ suites 0 +ℹ pass 42 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 12484.159542 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-git-input-before.txt b/ops/runtime-evidence/drive-threads-0909-git-input-before.txt new file mode 100644 index 000000000..276f418bb --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-git-input-before.txt @@ -0,0 +1,55 @@ +$ node --test ops/local-work-git-input.test.mjs +✖ preparation reads the committed gate instead of a working-tree replacement (109.660583ms) +✖ an ignored compiled picker cannot become the input to selection (134.15875ms) +ℹ tests 2 +ℹ suites 0 +ℹ pass 0 +ℹ fail 2 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 288.575875 + +✖ failing tests: + +test at ops/local-work-git-input.test.mjs:7:1 +✖ preparation reads the committed gate instead of a working-tree replacement (109.660583ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /SELECTED Fix value/. Input: + + '' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-git-input.test.mjs:13:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: '', + expected: /SELECTED Fix value/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-git-input.test.mjs:22:1 +✖ an ignored compiled picker cannot become the input to selection (134.15875ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /SELECTED Fix value/. Input: + + '' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-git-input.test.mjs:27:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: '', + expected: /SELECTED Fix value/, + operator: 'match', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-owned-build.txt b/ops/runtime-evidence/drive-threads-0909-owned-build.txt new file mode 100644 index 000000000..4dc625bd6 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-owned-build.txt @@ -0,0 +1,6 @@ +$ node --check scripts/run-drive-local.mjs +EXIT_CODE=0 +$ node --check ops/local-work-gate.mjs +EXIT_CODE=0 +$ cd packages/sdk && node node_modules/typescript/bin/tsc +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt b/ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt new file mode 100644 index 000000000..268e3c747 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt @@ -0,0 +1,48 @@ +$ node --test ops/local-work-package.test.mjs ops/local-work-review.test.mjs ops/local-work-gate.test.mjs ops/drive-local-flow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (1089.814667ms) +✔ drive-local journals failure and blocks reporting for verifier edit (901.611166ms) +✔ drive-local journals failure and blocks reporting for unchanged package (1054.339291ms) +✔ drive-local journals failure and blocks reporting for HEAD repin (841.583833ms) +✔ drive-local journals failure and blocks reporting for forged snapshot (986.059959ms) +✔ committing outside scope and repinning metadata cannot replace the submitted HEAD (365.7425ms) +✔ forged snapshot, checksum and compiled picker cannot replace the submitted judge (811.5855ms) +✔ raw template refuses selection before implementation without captured gate inputs (94.041792ms) +✔ selection advances past rejected duplicate title: Same title (185.584792ms) +✔ selection advances past rejected duplicate title: Regex [a].* (b) + $ (204.468ms) +✔ skip cursor passes a later title mentioned in an earlier body (301.222167ms) +✔ selection skips missing checks and stale paths with reasons (322.633041ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (624.09425ms) +✔ multiple acceptance checks all execute and failures propagate (350.9095ms) +✔ scope refuses an outside unstaged path (279.713667ms) +✔ scope refuses an outside staged path (266.095375ms) +✔ scope refuses an outside untracked path (238.358541ms) +✔ scope refuses an outside staged reversal path (256.264875ms) +✔ scope refuses an outside rename path (263.418417ms) +✔ scope refuses an outside deleted path (294.105541ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (236.873958ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (233.794208ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (196.681375ms) +✔ editing ignored package metadata cannot widen scope or replace checks (274.704167ms) +✔ scope allows directory children but rejects a sibling with the same prefix (331.91425ms) +✔ scope allows in-scope deletions and rejects symlink escapes (327.241416ms) +✔ replacing a scoped file with a directory does not authorize its children (238.158333ms) +✔ a check that writes outside scope fails verification (308.1535ms) +✔ interrupted package write preserves the original and retry replaces it atomically (323.611125ms) +✔ report includes an allowed unstaged change (422.349958ms) +✔ report includes an allowed staged change (452.419666ms) +✔ report includes an allowed untracked change (408.382209ms) +✔ directory scope checks a pre-existing dangling escape symlink (293.552958ms) +✔ directory scope checks a pre-existing existing internal symlink (294.07025ms) +✔ selection skips untracked scope and accepts the next committed scope (168.807167ms) +✔ reporting after SDK suite effects enforces scope for outside.txt (417.239459ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (579.685042ms) +ℹ tests 37 +ℹ suites 0 +ℹ pass 37 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 5726.671 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt b/ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt new file mode 100644 index 000000000..2ee9ac921 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt @@ -0,0 +1,41 @@ +$ node ops/runtime-evidence/drive-threads-0909-trust-probe.mjs +COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=1 +HEAD_MOVED ++ actual - expected + ++ '706ca9e0bcf4182e1c5e40b0646de396f8f55b86' +- '281522931b9fe9fe8fbbde78ca27ce7da0018ff7' + +REWRITTEN_SNAPSHOT_AND_CHECKSUM_VERIFY_EXIT=1 +SCOPE_OK: 0 changed path(s) +CHECK ["node","-e","require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"] +node:internal/assert/utils:146 + throw error; + ^ + +AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + +'broken' !== 'fixed' + + at [eval]:1:31 + at runScriptInThisContext (node:internal/vm:219:10) + at node:internal/process/execution:483:12 + at [eval]-wrapper:6:24 + at runScriptInContext (node:internal/process/execution:481:60) + at evalFunction (node:internal/process/execution:315:30) + at evalTypeScript (node:internal/process/execution:327:3) + at node:internal/main/eval_string:71:3 { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'broken', + expected: 'fixed', + operator: 'strictEqual', + diff: 'simple' +} + +Node.js v26.7.0 +PACKAGE_CHECK_FAILED: ["node","-e","require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"] (1) +IMPLEMENTATION=broken +REPAIRED_IMPLEMENTATION_VERIFY_EXIT=0 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-post-suite-before.txt b/ops/runtime-evidence/drive-threads-0909-post-suite-before.txt new file mode 100644 index 000000000..6a116b78a --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-post-suite-before.txt @@ -0,0 +1,37 @@ +$ node --test --test-name-pattern='reporting after SDK' ops/local-work-review.test.mjs +✖ reporting after SDK suite effects enforces scope for outside.txt (1530.28375ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (710.962875ms) +ℹ tests 2 +ℹ suites 0 +ℹ pass 1 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2313.327542 + +✖ failing tests: + +test at ops/local-work-review.test.mjs:47:3 +✖ reporting after SDK suite effects enforces scope for outside.txt (1530.28375ms) + AssertionError [ERR_ASSERTION]: REPORT Fix value + outside.txt | 2 +- + src/value.txt | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + DoD: Fix value + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:60:10) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:63:33) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 0, + expected: 0, + operator: 'notStrictEqual', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-review-before.txt b/ops/runtime-evidence/drive-threads-0909-review-before.txt new file mode 100644 index 000000000..73f9bc603 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-review-before.txt @@ -0,0 +1,81 @@ +$ node --test ops/local-work-review.test.mjs +✔ report includes an allowed unstaged change (273.4115ms) +✖ report includes an allowed staged change (286.932584ms) +✖ report includes an allowed untracked change (268.523542ms) +✖ directory scope checks a pre-existing dangling escape symlink (251.117166ms) +✔ directory scope checks a pre-existing existing internal symlink (242.251375ms) +✔ selection skips untracked scope and accepts the next committed scope (128.28975ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 3 +ℹ fail 3 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1496.888125 + +✖ failing tests: + +test at ops/local-work-review.test.mjs:8:3 +✖ report includes an allowed staged change (286.932584ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /src\/value.txt/. Input: + + 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:17:12) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n', + expected: /src\/value.txt/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-review.test.mjs:8:3 +✖ report includes an allowed untracked change (268.523542ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /src\/new.txt/. Input: + + 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:17:12) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n', + expected: /src\/new.txt/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-review.test.mjs:23:3 +✖ directory scope checks a pre-existing dangling escape symlink (251.117166ms) + AssertionError [ERR_ASSERTION]: SCOPE_OK: 0 changed path(s) + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:43:10) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:32:37) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 0, + expected: 0, + operator: 'notStrictEqual', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs b/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs new file mode 100644 index 000000000..e5a69d72a --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fixture, packagePath, pass, fail } from '../local-work-test-fixture.mjs'; +const cleanup = []; +const t = { after: fn => cleanup.push(fn) }; +try { + const f = fixture(t); + pass(f.run('select')); + f.put('outside.txt', 'committed outside scope'); + f.git('add', 'outside.txt'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside scope'); + const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')); + pkg.head = f.git('rev-parse', 'HEAD').toString().trim(); + f.put(packagePath, JSON.stringify(pkg)); + const repinned = f.scope(); + console.log('COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=' + repinned.status); + process.stdout.write(repinned.stdout + repinned.stderr); + fail(repinned, /HEAD_MOVED/); + + const g = fixture(t); + pass(g.run('select')); + for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs', + '.drive-gate/backlog-picker.js']) g.put(path, 'process.exit(0);\n'); + pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'], {cwd:g.root, encoding:'utf8'})); + const forged = g.run('verify'); + console.log('REWRITTEN_SNAPSHOT_AND_CHECKSUM_VERIFY_EXIT=' + forged.status); + process.stdout.write(forged.stdout + forged.stderr); + fail(forged, /PACKAGE_CHECK_FAILED/); + console.log('IMPLEMENTATION=' + readFileSync(join(g.root, 'src/value.txt'), 'utf8')); + g.put('src/value.txt', 'fixed'); + const repaired = g.run('verify'); + pass(repaired); + console.log('REPAIRED_IMPLEMENTATION_VERIFY_EXIT=' + repaired.status); +} finally { for (const fn of cleanup) fn(); } diff --git a/ops/runtime-evidence/drive-threads-0909-trust-probe.txt b/ops/runtime-evidence/drive-threads-0909-trust-probe.txt new file mode 100644 index 000000000..067a6cc69 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-trust-probe.txt @@ -0,0 +1,13 @@ +$ node ops/runtime-evidence/drive-threads-0909-trust-probe.mjs +COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=0 +.drive-gate/backlog-picker.js: OK +.drive-gate/local-work-package.mjs: OK +.drive-gate/local-work-verification.mjs: OK +SCOPE_OK: 0 changed path(s) +REWRITTEN_SNAPSHOT_AND_CHECKSUM_SCOPE_EXIT=0 +.drive-gate/local-work-package.mjs: OK +.drive-gate/local-work-verification.mjs: OK +.drive-gate/backlog-picker.js: OK +IMPLEMENTATION=broken + +EXIT_CODE=0 diff --git a/scripts/run-drive-local.mjs b/scripts/run-drive-local.mjs new file mode 100644 index 000000000..043af6e47 --- /dev/null +++ b/scripts/run-drive-local.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Prepare the local drive's trusted commands before submitting its journaled run. +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { prepareLocalDrive } from '../ops/local-work-gate.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +process.chdir(root); +let prepared; +try { + if (process.argv.length !== 2) throw new Error('Usage: node scripts/run-drive-local.mjs'); + const built = spawnSync(process.execPath, ['node_modules/typescript/bin/tsc'], { + cwd: join(root, 'packages/sdk'), stdio: 'inherit', + }); + if (built.error || built.status !== 0) throw new Error('LOCAL_DRIVE_SDK_BUILD_FAILED'); + const { load } = createRequire(join(root, 'packages/sdk/package.json'))('js-yaml'); + const authored = load(readFileSync('workflows/drive-local.yaml', 'utf8')); + prepared = prepareLocalDrive(authored, { root }); + mkdirSync('.relayflow', { recursive: true }); + const directory = mkdtempSync(resolve('.relayflow/drive-submission-')); + const path = join(directory, 'flow.json'); + writeFileSync(path, JSON.stringify(prepared.flow), { mode: 0o600 }); + // The submitted commands carry the original ref and gate directory. Rewriting + // the input JSON cannot change commands already journaled by the daemon. + const run = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], { stdio: 'inherit' }); + if (run.error) throw run.error; + process.exitCode = run.status ?? 1; +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} finally { + prepared?.dispose(); +} diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 9760c43c2..8e3a80922 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -1,28 +1,121 @@ -# A bounded, deterministic drive tick: execute backlog F8b on this checkout. -# Run: node scripts/run-local-workflow.mjs workflows/drive-local.yaml -# Selection, mutation, existing tests, and the resulting diff are journaled. -# Delivery is a branch/PR by the operator; this flow never merges or switches branches. +# A local drive tick: pick the next backlog item, do it, verify it, report. +# +# This replaces a flow that could only ever execute BACKLOG F8b. That version +# proved a relayflow can drive a real change on this checkout with no Cloud +# admission, no Daytona and no Relaycast workspace — but its selector hardcoded +# one file and one identifier, so every subsequent tick needed a human to edit +# the script before the loop could run again. A loop that needs editing between +# iterations is not a loop. +# +# Two changes make it general. Selection comes from the SDK's backlog picker +# (gate 3), the same rule the cloud drive uses, so the two cannot drift about +# what "next" means. And implementation is an agent step rather than a +# deterministic rewrite, because a deterministic step can only make mechanical +# changes and most backlog entries are not mechanical. +# +# Run: node scripts/run-drive-local.mjs +# The preparing launcher pins the Git ref in submitted commands. gate-snapshot +# extracts gate inputs from that ref and builds the picker outside the checkout. +# Running this template directly fails before any implementation step. +# Delivery stays with the operator: this flow never commits, never switches +# branches and never merges. It leaves a working tree and a report. version: '0.1.0' -name: drive-local-f8b -description: Correct the retry validator name to describe the authoring restriction (BACKLOG F8b). +name: drive-local +description: >- + Select the next work package from ops/BACKLOG.md, implement it, verify it + against its own definition of done, and report. Selection, implementation, + scope enforcement, verification and reporting are journaled steps. steps: - - id: select-package + # The preparing launcher builds the SDK before capture and submission. + - id: gate-snapshot type: deterministic + timeoutMs: 300000 + command: node ops/local-work-snapshot.mjs + + # Selection uses the picker built from Git and records that ref as pkg.head. + - id: select + type: deterministic + dependsOn: [gate-snapshot] + timeoutMs: 120000 command: node ops/local-work-package.mjs select - - id: implement-package + + - id: initial-scope type: deterministic - dependsOn: [select-package] - command: node ops/local-work-package.mjs apply - - id: verify-package + dependsOn: [select] + timeoutMs: 120000 + command: &scope-check node ops/local-work-package.mjs scope + + - id: implement + type: agent + cli: claude + dependsOn: [initial-scope] + # Required, not decorative: the launcher refuses an agent step with no pins + # (`LOCAL_AGENT_PINS_REQUIRED: declare a stream; the kernel refuses workers + # with no pins`) and the run is never created. Verified by running it. + surfaces: + streams: + - stream: drive-local + # No timeoutMs: 0.1.0 bounds deterministic steps only, so an agent step + # cannot declare one. The run-level bound is the operator's. + maxIterations: 1 + instruction: | + Read .relayflow/drive-local/package.json. It holds one work package + selected from ops/BACKLOG.md: a title, the files in scope, and a + definition of done. + + Implement exactly that package on this checkout. Stay inside the files it + names; if the work genuinely requires a file outside that list, say so in + your output rather than widening scope silently. + + Do not commit, do not switch branches, do not merge. The operator delivers + the diff. + + End your final message with the single uppercase word DONE on its own + line. The verification below gates on that exact marker, so a correct + implementation that omits it is recorded as a failure. + + If the package cannot be implemented as written — it is already done, its + premise is false, or it is underspecified — say that plainly and change + nothing. A tick that reports "already done" is a good tick; a tick that + invents an edit to look busy is not. + verification: + type: output_contains + value: "DONE" + + - id: scope type: deterministic - dependsOn: [implement-package] + dependsOn: [implement] timeoutMs: 120000 + # The launcher substitutes the command for the Git-extracted helper. + # protectedPaths has one owner in the verifier extracted from the same ref. + command: *scope-check + + - id: verify + type: deterministic + dependsOn: [scope] + timeoutMs: 900000 + # The selected package's checks must pass before the regression suite. command: | set -eu - npm_config_cache="$PWD/.relayflow/npm-cache" npm --userconfig /tmp/empty-npmrc run build --prefix packages/sdk + node ops/local-work-package.mjs verify cd packages/sdk - node node_modules/vitest/vitest.mjs run tests/spec-parity.test.ts tests/cli.test.ts - - id: report-package + # Rebuild before the suite. Tests and probes import packages/sdk/dist/*, + # so running vitest against a stale dist exercises the PRE-agent artifact + # and can pass code the agent just changed. Invoked directly rather than + # via `npm run build` because npm hangs on this host's ~/.npmrc. + node node_modules/typescript/bin/tsc + node scripts/make-cli-executable.mjs + node node_modules/vitest/vitest.mjs run + + - id: final-scope type: deterministic - dependsOn: [verify-package] + dependsOn: [verify] + timeoutMs: 120000 + # Builds and tests can write too; recheck before loading the report helper. + command: *scope-check + + - id: report + type: deterministic + dependsOn: [final-scope] + timeoutMs: 120000 command: node ops/local-work-package.mjs report