diff --git a/.github/workflows/engine-freeze.yml b/.github/workflows/engine-freeze.yml new file mode 100644 index 000000000..be8b84f07 --- /dev/null +++ b/.github/workflows/engine-freeze.yml @@ -0,0 +1,53 @@ +name: Engine freeze + +# The engine half of fabro-workflow takes bug fixes only; new engine behaviour +# goes to Petri. A pull request that adds lines under the frozen paths fails +# here unless it carries the `bugfix` label. The frozen paths live in +# `scripts/check-engine-freeze.sh`, which runs locally the same way. +# Labeling re-runs the check so a label added after a failure clears it. + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + paths: + - "lib/components/fabro-workflow/src/**" + - "scripts/check-engine-freeze.sh" + - "scripts/check-engine-freeze-test.sh" + - ".github/workflows/engine-freeze.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + freeze: + name: Engine half takes bug fixes only + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # The check diffs against the merge base with the base branch. + fetch-depth: 0 + - name: Self-test the check + run: scripts/check-engine-freeze-test.sh + - name: Check the frozen paths + env: + BASE_REF: ${{ github.base_ref }} + HAS_BUGFIX_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'bugfix') }} + run: | + git fetch --no-tags origin "$BASE_REF" + if scripts/check-engine-freeze.sh "origin/$BASE_REF"; then + exit 0 + fi + if [ "$HAS_BUGFIX_LABEL" = "true" ]; then + echo "The 'bugfix' label waives the engine freeze for this pull request." + exit 0 + fi + echo "::error::This pull request adds lines to the frozen engine half of fabro-workflow (see the log for the paths). New engine behaviour goes to Petri (lib/components/fabro-petri). A bug fix needs the 'bugfix' label." + exit 1 diff --git a/AGENTS.md b/AGENTS.md index 3e417f116..e77e01f92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,10 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as - **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery - **fabro-util** — Shared utilities (redaction, terminal formatting) +### Engine freeze + +The engine half of `fabro-workflow` takes bug fixes only: `handler/`, `lifecycle/`, `pipeline/execute` (the file and the directory), `graph/routing.rs`, `node_handler.rs`, `retry.rs`, `condition.rs`, `context.rs` and `model_fallback.rs` under `lib/components/fabro-workflow/src/`. New engine behaviour goes to Petri through `fabro-petri`. The `Engine freeze` CI check (`.github/workflows/engine-freeze.yml`) fails a pull request that adds lines under those paths unless it carries the `bugfix` label. The path list lives in `scripts/check-engine-freeze.sh`, which runs locally as `scripts/check-engine-freeze.sh origin/main` and reports the added lines; `scripts/check-engine-freeze-test.sh` is its self-test. + ### TypeScript (`apps/` and `lib/packages/`) - **apps/fabro-web** — React 19 + React Router + Tailwind CSS frontend, bundled by a custom Bun script (`apps/fabro-web/scripts/build.ts`), not Vite - **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec diff --git a/lib/components/fabro-petri/README.md b/lib/components/fabro-petri/README.md index d613eccab..6f2b740cd 100644 --- a/lib/components/fabro-petri/README.md +++ b/lib/components/fabro-petri/README.md @@ -10,6 +10,18 @@ member that lists them as dependencies. Every other Fabro crate reaches the engine through what this crate exports. A Petri pin move is therefore a change to this crate and the lockfile, nothing else. +## Engine freeze + +The engine half of `fabro-workflow` (`handler/`, `lifecycle/`, +`pipeline/execute`, `graph/routing.rs`, `node_handler.rs`, `retry.rs`, +`condition.rs`, `context.rs` and `model_fallback.rs` under its `src/`) takes +bug fixes only. New engine behaviour goes to Petri and reaches Fabro through +this crate. The `Engine freeze` CI check +(`.github/workflows/engine-freeze.yml`) fails a pull request that adds lines +under those paths unless it carries the `bugfix` label. The path list is in +`scripts/check-engine-freeze.sh`; run it locally as +`scripts/check-engine-freeze.sh origin/main` to see what a branch adds there. + ## What it holds Every adapter the integration plan describes lands here. diff --git a/scripts/check-engine-freeze-test.sh b/scripts/check-engine-freeze-test.sh new file mode 100755 index 000000000..8a8de3f53 --- /dev/null +++ b/scripts/check-engine-freeze-test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Self-test for scripts/check-engine-freeze.sh against a synthetic repository: +# an added line under a frozen path exits 1, and deletions or additions +# elsewhere exit 0. +set -euo pipefail + +CHECK="$(cd "$(dirname "$0")" && pwd)/check-engine-freeze.sh" +SRC="lib/components/fabro-workflow/src" + +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com + +repo="$(mktemp -d)" +trap 'rm -rf "$repo"' EXIT +cd "$repo" +git init -q -b main +mkdir -p "$SRC/handler" "$SRC/pipeline/execute" "$SRC/transforms" +printf 'a\nb\nc\n' > "$SRC/handler/agent.rs" +printf 'a\nb\n' > "$SRC/pipeline/execute/tests.rs" +printf 'a\n' > "$SRC/retry.rs" +printf 'a\n' > "$SRC/transforms/preamble.rs" +printf 'a\n' > "$SRC/pipeline/finalize.rs" +git add -A +git commit -q -m base + +failures=0 +expect() { + local name="$1" want="$2" + git checkout -q -b "$name" main + "case_$name" + git add -A + git commit -q --allow-empty -m "$name" + local got=0 + "$CHECK" main > /dev/null || got=$? + if [ "$got" -eq "$want" ]; then + echo "ok $name (exit $got)" + else + echo "FAIL $name: expected exit $want, got $got" + failures=$((failures + 1)) + fi + git checkout -q main +} + +case_adds_to_frozen_file() { echo d >> "$SRC/handler/agent.rs"; } +case_adds_to_frozen_dir() { echo c >> "$SRC/pipeline/execute/tests.rs"; } +case_rewrites_frozen_line() { printf 'a\nB\nc\n' > "$SRC/handler/agent.rs"; } +case_deletes_from_frozen_file() { printf 'a\n' > "$SRC/handler/agent.rs"; } +case_adds_outside_freeze() { + echo b >> "$SRC/transforms/preamble.rs" + echo b >> "$SRC/pipeline/finalize.rs" +} +case_no_change() { :; } + +expect adds_to_frozen_file 1 +expect adds_to_frozen_dir 1 +expect rewrites_frozen_line 1 +expect deletes_from_frozen_file 0 +expect adds_outside_freeze 0 +expect no_change 0 + +if [ "$failures" -ne 0 ]; then + echo "$failures case(s) failed" + exit 1 +fi +echo "all cases passed" diff --git a/scripts/check-engine-freeze.sh b/scripts/check-engine-freeze.sh new file mode 100755 index 000000000..03fcc73a5 --- /dev/null +++ b/scripts/check-engine-freeze.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Report added lines under the frozen engine half of fabro-workflow. +# +# The engine half of `lib/components/fabro-workflow` takes bug fixes only; +# new engine behaviour goes to Petri (`lib/components/fabro-petri`). This +# script lists every frozen file the current branch adds lines to, compared +# with the base ref, and exits 1 when there is at least one. It knows nothing +# about pull request labels: the CI job (`.github/workflows/engine-freeze.yml`) +# waives a failure when the pull request carries the `bugfix` label. +# +# Usage: scripts/check-engine-freeze.sh [] (default: origin/main) +set -euo pipefail + +BASE_REF="${1:-origin/main}" +LABEL="bugfix" + +# The frozen paths, relative to the fabro-workflow crate's `src/`. A directory +# freezes everything under it. `preamble` in the integration plan is +# `handler/llm/preamble.rs`, which `handler/` covers; `transforms/preamble.rs` +# is a graph transform and is not frozen. +FROZEN=( + handler + lifecycle + pipeline/execute.rs + pipeline/execute + graph/routing.rs + node_handler.rs + retry.rs + condition.rs + context.rs + model_fallback.rs +) + +if [ "${FREEZE_LIST_ONLY:-}" = "1" ]; then + printf '%s\n' "${FROZEN[@]}" + exit 0 +fi + +ROOT="$(git rev-parse --show-toplevel)" +CRATE_SRC="lib/components/fabro-workflow/src" + +paths=() +for entry in "${FROZEN[@]}"; do + paths+=("$CRATE_SRC/$entry") +done + +# Three dots: the changes since the merge base, which is what a pull request +# adds to its base branch. +numstat="$(git -C "$ROOT" diff --numstat "$BASE_REF...HEAD" -- "${paths[@]}")" + +offenders=() +while IFS=$'\t' read -r added _deleted path; do + [ -n "${path:-}" ] || continue + case "$added" in + ''|*[!0-9]*) continue ;; # binary files report '-' + esac + if [ "$added" -gt 0 ]; then + offenders+=("$added $path") + fi +done <<< "$numstat" + +if [ "${#offenders[@]}" -eq 0 ]; then + echo "engine freeze: no lines added under the frozen paths of fabro-workflow since $BASE_REF" + exit 0 +fi + +echo "engine freeze: this branch adds lines to the frozen engine half of fabro-workflow (since $BASE_REF):" +for line in "${offenders[@]}"; do + echo " +${line%% *} ${line#* }" +done +echo +echo "The engine half of lib/components/fabro-workflow takes bug fixes only." +echo "New engine behaviour goes to Petri (lib/components/fabro-petri)." +echo "Frozen paths under $CRATE_SRC/:" +for entry in "${FROZEN[@]}"; do + echo " $entry" +done +echo +echo "A bug fix passes CI when the pull request carries the '$LABEL' label." +exit 1