## Problem
Agent CI still depended on the TypeScript runner as the main execution path. That made it harder to ship a small native binary and harder to prove the Rust path matched the TypeScript path. Review also found places where Rust and TypeScript could drift before the Rust path becomes the default.
## Solution
This PR adds the opt-in Rust native `agent-ci` binary and platform npm packages while keeping the TypeScript fallback. It splits the Rust code into `agent-ci-core`, `agent-ci-runtime`, and a thin CLI/orchestration crate. Pure planning, reusable workflow expansion, event contracts, and run-result contracts now live in core; runtime owns Docker, DTU, runner, macOS VM, workspace, execution-plan adapters, and the generic wave worker pool.
The fixture checks now use real TypeScript helpers instead of a shadow planner. Shared fixtures cover plans, events, run results, Docker socket resolution, and default job-limit CPU/memory vectors. The Rust runner honors `--jobs`, probes Docker memory for the default job limit, supports macOS VM concurrency limits, avoids nested runner container name collisions, has `--all` smoke coverage, and stages `bin/agent-ci` in every platform package.
## Validation
- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- `cargo clippy --workspace --all-targets -- -D warnings`
- `cargo test --workspace --all-targets`
- `pnpm check`
- `pnpm fixtures:check`
- `pnpm golden:cli`
- `pnpm rust:smoke:parity`
- `pnpm agent-ci-dev run --all -q -p --json --github-token`
## Notes
Rust execution is still opt-in. The TypeScript implementation and fallback remain in place until the later default-switch and removal gates pass.
* chore: require Node 24 and drop tsx
Node 24 ships native TypeScript stripping as a stable feature. We no
longer need tsx to execute .ts files in development scripts.
Changes:
- `engines.node` bumped from `>=22` to `>=24` in each package.
- `tsx` removed from every workspace's devDependencies.
- Each `tsx foo.ts` invocation in package scripts becomes `node foo.ts`.
Includes `scripts/agent-ci-dev.sh` (the dev wrapper) and the demo
package.
- tsconfigs gain `allowImportingTsExtensions`, `rewriteRelativeImportExtensions`,
and `verbatimModuleSyntax` so source uses `.ts` extensions while tsc
still emits `.js` paths in dist.
- All relative imports rewritten from `from "./foo.js"` to `from "./foo.ts"`
across 72 source files. Dynamic imports updated too.
- Type-only imports (`Job`, `Polka`, MessageResponse, …) converted to
`import type` to satisfy `verbatimModuleSyntax`.
- The json-loader hook registration in workflow-parser.ts picks `.ts`
when the source file exists (dev) and `.js` otherwise (compiled dist).
- Root `package.json` adds `"type": "module"` (no existing .js files
affected) and `yaml` as a devDep (already used by scripts/diff-parse.ts;
previously hoisted through cli's deps).
- `tests.yml` CI bumps from Node 22 to Node 24. Smoke fixtures that set
`node-version: 22` are left alone — they exercise `actions/setup-node`
with various versions deliberately.
Verified:
- pnpm check: clean
- Node 24 dev run: `node packages/cli/src/cli.ts --help` works
- Full smoke suite (`pnpm agent-ci-dev run --all`): 45/45 jobs pass
* fix(shutdown.test): use `node --input-type=module` instead of `npx tsx`
The shutdown integration test spawns a fresh process to sidestep
vitest's mock system. It was using `npx tsx`, but `tsx` is no longer
a dependency. Node 24 reads TypeScript files natively, so we ask Node
directly via `--input-type=module`.
* fix(cli): unblock --pause-on-failure in non-TTY pipes (#315)
Spawn the run as a detached worker when stdout isn't a TTY (and we're
not under an LLM harness), then exit 77 the moment the worker emits a
`run.paused` NDJSON event — freeing the caller's pipe while the worker
keeps the container + DTU alive for `agent-ci retry`. Retry tails the
same log so a re-failure surfaces as another exit-77.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): NDJSON event stream behind --json (#289)
Extends the existing pause/finish sentinel emitter into a full lifecycle
event stream. New `--json` flag (and `AGENT_CI_JSON=1` env var) emits
`run.start`, `job.start`/`finish`, `step.start`/`finish`, `run.paused`,
`run.finish`, `diagnostic` events on stdout — one JSON object per line —
each carrying an `event` discriminator and a `schemaVersion: 1` on
`run.start`.
`--json` is decoupled from `--quiet` so existing `-q` callers keep silent
stdout; the diff renderer is auto-suppressed under `--json` so ANSI
sequences don't collide with the stream. Detached-worker mode (#315)
keeps emitting only sentinels — the launcher filters non-sentinel events
anyway, so non-JSON callers don't pay the cost.
Renames `run.completed` -> `run.finish` to match the issue's naming.
Closes#289.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): suppress printSummary under --json
The ━━━ FAILURES ━━━ / ━━━ SUMMARY ━━━ blocks write to stdout
unconditionally, which interleaves with the NDJSON event stream and
breaks line-based parsers. Skip them when isJsonMode() — consumers get
the same information from job.finish + run.finish events.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): AGENT_CI_DETACHED=1 forces detached launcher on TTY
Overload AGENT_CI_DETACHED so it can serve both roles:
- `=<absolute path>` → set by the launcher on its worker child;
`isDetachedWorker()` returns true.
- `=1` (or any non-absolute value) → caller-side opt-in: force the
launcher path even when stdout is a TTY. Useful for manual
verification of the #315 pause-then-exit-77 flow without having to
construct a non-TTY pipe.
The two are distinguished by whether the value starts with `/`. New
`isForceDetachedRequested()` helper drives a `forceDetached` arg into
`shouldLaunchDetached`, which still respects `alreadyWorker`,
`agentMode`, and the `pauseOnFailure` requirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(ci): smoke for --json NDJSON event stream (#289)
scripts/smoke-289.sh creates a tmp repo with a 2-step passing workflow,
runs `agent-ci run --json`, and asserts:
- run.start carries schemaVersion=1
- step.start + step.finish for each declared step
- job.finish status=passed
- run.finish status=passed
- every JSON-shaped line on stdout has a recognized event field
.github/workflows/smoke-json-events.yml runs the script in CI on every
PR (gated by the same safe-to-run / collaborator predicate as the other
smokes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): stop smoke-resource-mismatch from hanging on real GitHub runners
The fixture in .github/workflows/smoke-resource-mismatch.yml declares
`runs-on: ubuntu-latest-999-cores` so agent-ci's resource-fidelity
classifier deterministically tags the job as `degraded` on any host.
With its previous `pull_request_target` trigger, GitHub Actions also
picked it up on every PR and queued a job that no real runner can
satisfy — five queued runs piled up on PR #323 alone, each timing out
after hours.
Switch the workflow to `workflow_dispatch:`-only so GitHub never
auto-dispatches it, and teach `isWorkflowRelevant` to treat
`workflow_dispatch:`-only workflows as relevant for `agent-ci run --all`
so the local smoke keeps running. The opt-in is gated to the sole-event
case to avoid overriding `pull_request` / `push` path/branch filters on
workflows that legitimately combine triggers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#297. CI/scripts-only — no packages/ change, so no changeset required
per .claude/rules/changeset-required.md.
- scripts/diff-parse.ts: walks raw YAML at workflow + job scopes (in addition
to the existing step scope) and grades every recognized key against a
preservation predicate or a documented expected-drop. Reuses focused parsers
(parseJobIf, parseJobOutputDefs, parseWorkflowContainer, parseWorkflowServices,
parseMatrixDef, parseFailFast, parseJobDependencies, getWorkflowTemplate).
defaults.run.shell preservation detects the heredoc-wrap marker emitted by
wrapScriptForShell.
- third-party-workflows/: vendored scan-only corpus — turborepo-lint.yml,
playwright-infra.yml, docker-validate.yml — each with attribution header.
Picked up automatically by collectWorkflowFiles.
- package.json: parse:diff added to the parallel pnpm check pipeline so
regressions fail standard CI.
Result: 37 workflows scanned (.github/workflows + third-party-workflows),
0 drifts. pnpm check exits 0; pnpm agent-ci-dev run --all reports 39/39.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For each .github/workflows/*.yml, read the raw YAML and compare against
what parseWorkflowSteps emits, flagging any step-level key our parser
silently drops without a documented expected-drop policy.
First run over all 26 workflows: 0 drifts. Job-level and workflow-level
scans are tracked as follow-ups in #297.
Refs #297.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(compat): make compatibility.json the source of truth
Flip the compatibility matrix so packages/cli/compatibility.json is
authoritative. scripts/gen-compatibility-md.mjs renders the .md from it,
pnpm check runs a drift check, and the website imports the JSON directly
and renders inline markdown in keys/notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add compat smoke coverage for secrets, working-dir defaults, runner image
Fill three audit gaps identified against packages/cli/compatibility.json:
- smoke-secrets-env-file: secrets resolved from .env.agent-ci and from
the shell environment fallback
- smoke-defaults-workdir: workflow- and job-level
defaults.run.working-directory plus a step-level override
- smoke-runner-image: .github/agent-ci.Dockerfile is picked up and the
resulting image is used at job start
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(workflow): propagate defaults.run.working-directory to steps
Parse defaults.run.working-directory at workflow and job level and merge
it into each run-step's inputs with step > job > workflow precedence.
Step-level working-directory already worked; the defaults blocks were
silently ignored.
Closes#290.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(compat): add proof field to compatibility.json rows
Every `supported`/`partial` row now lists the smoke workflows that
exercise it end-to-end. `pnpm compat:gen`/`compat:check` fails if a
listed path does not resolve, catching renames.
Rows with an empty `proof: []` are the audit gaps tracked in #292.
Refs #292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dtu): attach step env per-step so it does not leak to siblings
Step-level env was being aggregated into the job-wide EnvironmentVariables
map (last-wins), so a later step's `env: FOO=x` would override an earlier
step's effective FOO at runtime. Emit step.Env as the mapped step's own
`environment` instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(compat): fill audit gaps #292 — env, expressions, local action
Three smokes covering eight of the ten gaps listed in #292:
- smoke-env-scoping: workflow + job + step `env:` precedence
- smoke-expressions: hashFiles, format, join, runner.arch, type
literals, startsWith/endsWith
- smoke-local-action: `steps[*].uses: ./.github/actions/...`
(verified via marker file rather than steps.<id>.outputs.*)
Proof fields updated for every row these smokes cover. hashFiles
downgraded to ⚠️ partial — agent-ci emits SHA-1, GitHub Actions emits
SHA-256 (tracked by #294).
Two gaps remain open as filed issues:
- #293 defaults.run.shell / steps[*].shell not applied
- #295 ${{ steps.<id>.outputs.<name> }} leaks literal text in run
scripts
Refs #292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
During pnpm run version, scripts/release-closes.mjs captures
Closes|Fixes|Resolves #N references from pending changesets into
.release-closes.json, pairs each with the PR that added the
changeset, and rewrites the keywords to Refs #N so the 'Version
Packages' PR does not close them on merge. After the release
workflow publishes, a new step closes each captured issue with
the comment 'Closes Issue #N via PR #M.'
* feat: support composite action step outputs and push event context
Enable workflows that use composite actions with cross-step output
references (e.g. Khan/actions@get-changed-files + filter-files) by:
- Preserving steps.* expressions for runner-time evaluation instead
of expanding them to empty string at parse time
- Using correct BasicExpression token type (type 3, not 6) matching
the runner's wire format
- Adding ContextName to steps so the runner can resolve
steps.<id>.outputs.<name> expressions
- Adding push event before/after SHAs and GITHUB_API_URL so
changed-files actions can call the compare commits API
- Implementing compare commits and list-PRs-for-commit mock endpoints
in the DTU server using git diff on the original repo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: resolve symlinks in agent-ci-dev.sh so it works when invoked via symlink
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: support local reusable workflows (uses: ./.github/workflows/...)
Inline called workflow jobs into the caller's dependency graph so that
`jobs.<id>.uses` with local refs works end-to-end. Includes dependency
rewiring, smoke tests, runner seed resilience fix, boot-failure log
surfacing, and dev script improvements.
Closes#149
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update website compatibility matrix for reusable workflows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add section descriptions to website compatibility matrix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add descriptions to compatibility matrix legend items
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add 'not planned' status icon to compatibility matrix
Distinguish architectural limitations (concurrency groups, GitHub
secrets) from features that could be implemented in the future.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: support remote reusable workflows (uses: owner/repo/path@ref)
Pre-fetch remote workflow YAML from GitHub Contents API before expansion,
cache SHA refs forever and always re-fetch mutable tag/branch refs. Also
changes missing workflow files from warn+skip to hard failure.
Closes#165
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: split monolithic smoke-tests.yml into discrete workflow files
Each smoke test area now has its own workflow file for isolated failure
visibility: matrix, artifacts/services, cross-job outputs, job conditions,
reusable workflows (local + remote), and binary smoke.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix formatting in .docs/ files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: clarify agent-ci-dev.sh is for local development only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Rename package from 'machinen' to 'agent-ci' across all source, tests, scripts, configs, and docs
- Rename CLI binary from 'machinen' to 'agent-ci'
- Rename .machinen → .agent-ci, .env.machinen → .env.agent-ci, MACHINEN_* → AGENT_CI_*
- Rename --runner flag to --name/-n (--runner still accepted for compat)
- agent-ci-dev script now builds DTU before running CLI
- Extract YAML compatibility tables to cli/compatibility.md
- Expand CLI usage docs with per-command flag tables
- Document DOCKER_HOST support for remote Docker