Commit graph

539 commits

Author SHA1 Message Date
Bryan Helmkamp
ea176b6ca1
feat: bundle workflow package files for remote runs 2026-07-29 11:02:03 -04:00
fabro-releases[bot]
d8434e7672 Bump version to 0.309.0-nightly.0 2026-07-29 10:11:28 +00:00
fabro-releases[bot]
5396565cd4 Bump version to 0.308.0-nightly.1 2026-07-28 22:42:08 +00:00
Bryan Helmkamp
b19dda6aa3
Merge pull request #661 from fabro-sh/fix/small-default-skips-providers-without-small-model
Fix run title generation picking a flagship model, and sharpen the prompt
2026-07-28 17:38:27 -04:00
Bryan Helmkamp
df0bd58819
Merge remote-tracking branch 'origin/main' into fix/small-default-skips-providers-without-small-model
# Conflicts:
#	lib/apps/fabro-server/src/test_support.rs
2026-07-28 17:06:20 -04:00
Bryan Helmkamp
1aa7a153b0
Merge pull request #652 from fabro-sh/fix/publish-failures
Make publish failures terminal
2026-07-28 17:03:18 -04:00
Bryan Helmkamp
73f48eeddb
refactor: simplify publish pipeline and collapse duplicated stage errors
Follow-up cleanup on the publish-failures change.

Error model:
- Collapse `Error::{Engine, Publish, Handler}` into one `Error::Stage` with an
  `ErrorStage` discriminator. The three shared a field shape and had to be
  edited together in four match groups; nine near-identical constructors
  become two private helpers.
- Add `Error::failure_reason()`, replacing the same error -> FailureReason
  mapping written out in four places.
- Publish errors are now terminal. Publish runs once, after execution, so no
  caller could ever act on the retryable classification.

Publish phase:
- Fix: a branch that was pushed is now still reported when pull request
  creation fails afterwards. `PublishOutcome` records what happened and
  carries the error separately, instead of hiding both behind a `Result`.
- Drop `PublishOutcome::NoChanges`, which no consumer distinguished from
  `Published { pr_url: None }`.
- Move publish onto `Concluded` as methods and replace three near-identical
  precondition guards with one `publish_target()`.

Pull requests:
- `maybe_open_pull_request` -> `open_pull_request` returning the record
  directly. Both callers already reject empty diffs, so the `Ok(None)` path
  was unreachable.
- Drop `CreatedPullRequest.head_sha`, which echoed back its own input.

GitHub client:
- Delete `branch_exists`, which had no callers and duplicated
  `branch_head_sha`. Give `branch_head_sha` the `_with_client` split every
  sibling has and port the tests to `MockHttpClient`.
- Collapse the copy-pasted credential match in `resolve_clone_credentials`.

Events:
- `PullRequestCreated.head_sha` is `Option<String>` instead of using an empty
  string to mean absent.
- Centralize the run-branch refspec in `lifecycle::git::push_run_branch`, so
  `git.push` reports a branch name from both emitters as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:09:10 -04:00
Bryan Helmkamp
010c8d50c1
Add structured review targets to human gates 2026-07-28 13:24:12 -04:00
fabro-releases[bot]
8cc711463b Bump version to 0.308.0-nightly.0 2026-07-28 10:11:13 +00:00
Bryan Helmkamp
24b0576ffe
Record fabro-template as a fabro-server dependency in Cargo.lock
Missed in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:23:26 -04:00
fabro-releases[bot]
2bcf94fed8 Bump version to 0.305.0-nightly.3
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
2026-07-25 19:35:43 +00:00
fabro-releases[bot]
e510dac98d Bump version to 0.305.0-nightly.2 2026-07-25 16:18:41 +00:00
Bryan Helmkamp
5980627bc8
refactor(glob): unify workspace path matching 2026-07-25 10:30:19 -04:00
fabro-releases[bot]
db0b6c9b7c Bump version to 0.305.0-nightly.1 2026-07-25 11:18:11 +00:00
fabro-releases[bot]
c2b95a7a75 Bump version to 0.305.0-nightly.0 2026-07-25 03:21:30 +00:00
Bryan Helmkamp
1ca9fe977d
fix(sandbox): simplify Bash contract implementation 2026-07-24 21:55:36 -04:00
Bryan Helmkamp
3606ba6a0f
feat(sandbox): standardize command execution on non-login Bash
Fabro advertised Bash while its three backends implemented three
different contracts: Daytona evaluated commands through `sh`, and
Docker's streaming, stdio, and setup paths used a login shell. Bash-only
syntax silently misbehaved depending on provider and code path, and
login profiles could change PATH and command behavior per image.

Make `bash -c` the enforced interpreter for every command string the
Unix sandbox API accepts, on every production backend and through both
buffered and streaming execution. This selects the interpreter only —
no `errexit`, no `pipefail`, no login mode — so `false | true` still
succeeds and a workflow that wants other semantics writes them into its
own command.

Local resolves `bash` through the worker's PATH (NixOS has no
/bin/bash) and reuses that one executable across all three command
paths. Docker and Daytona require /bin/bash with no `sh` fallback.

Fresh initialization and resume/start now verify Bash through a shared
marker-validating probe before reporting the sandbox usable, so a
missing or non-Bash interpreter fails at the lifecycle boundary with
provider-specific remediation instead of on the first command. The
probe also rejects Bash in POSIX mode, which an image whose `bash` is
really `sh` would otherwise pass.

Sandbox MCP scripts and the detached launch wrapper move under the same
contract; host-side stdio MCP scripts, hooks, and interactive terminals
are separate executors and keep their existing `sh` behavior.

The `shell` tool's name and JSON schema are unchanged across providers;
only its prose now identifies `command` as Bash source.

BREAKING CHANGE: sandbox commands no longer load login-shell profiles,
so environment set in /etc/profile.d/*.sh, ~/.bash_profile, or
nvm/rbenv/sdkman initializers is gone. Move those exports into the
Dockerfile's ENV or the Daytona snapshot image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:26:05 -04:00
Bryan Helmkamp
fd6f14f107
refactor(agent): simplify prompt template rendering 2026-07-24 19:10:48 -04:00
Bryan Helmkamp
1d685cbea4
refactor(agent): render profile prompts with minijinja via fabro-template
The previous commit introduced render_prompt and splice_optional_section, a
second templating mechanism in a workspace that already standardizes on
MiniJinja behind fabro-template. Drop both and render the profile prompts the
same way fabro-workflow and fabro-manifest render theirs.

Expressing the conditionals as {% if %} lets every profile collapse to a single
template, since the optional blocks no longer need to be separate files spliced
in from Rust:

  before: 6 files + 2 splice helpers, prompt prose split across .md and .rs
  after:  3 files, one per profile, all prose in the template

Rust now passes only facts -- provider name, which file-edit tool is active,
and whether web search and subagents are available. Values land under `vars`,
so templates read {{ vars.env_block }}. Booleans are passed as "true"/"false"
and compared explicitly via the bool_var helper, because the shared
TemplateContext types vars as strings and a bare {% if %} on the string
"false" would be truthy.

Also converts fabro-server's Ask Fabro prompt, which is assembled at runtime.
Its tool guidance now arrives as a template variable instead of being
interpolated into the template text. That guidance carries tool names and
descriptions that can originate from MCP servers, and MiniJinja does not
re-render substituted values, so a tool description containing {{ ... }} stays
inert rather than being evaluated.

Output is unchanged. Verified by diffing all ten prompt variants against the
same unmodified origin/main worktree used for the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 18:23:47 -04:00
fabro-releases[bot]
ffee6ed29e Bump version to 0.304.0-nightly.1 2026-07-24 14:47:39 +00:00
fabro-releases[bot]
df3c834581 Bump version to 0.304.0-nightly.0 2026-07-24 01:48:13 +00:00
Fabro
b3e085dc45 Merge remote-tracking branch 'origin/main' into fabro/run/01KY7Y01REECZ24XXTMBZ3PPV9 2026-07-23 20:29:21 +00:00
fabro-releases[bot]
8c37f5fae5 Bump version to 0.303.0-nightly.4 2026-07-23 20:10:14 +00:00
Fabro
56119990be fabro(01KY7Y01REECZ24XXTMBZ3PPV9): simplify_fable (succeeded)
Fabro-Run: 01KY7Y01REECZ24XXTMBZ3PPV9
Fabro-Completed: 6
Fabro-Checkpoint: 497f92f4d9

⚒️ Generated with [Fabro](https://fabro.sh)
2026-07-23 19:10:39 +00:00
fabro-releases[bot]
5d7ecd7348 Bump version to 0.303.0-nightly.3 2026-07-23 18:35:29 +00:00
Bryan Helmkamp
0432c019ee
chore: address review feedback on error mapping and test-support gating
- Return 400 (not 500) for WorkflowError::ModelReference from run
  creation, matching ModelSelection: an ambiguous model/provider token
  is user input, not a server fault.
- Gate fabro-workflow's test_support module behind
  cfg(any(test, feature = "test-support")) so the feature actually
  controls exposure, per the repo's test-support boundary guidance.
  Add the self dev-dependency so tests/it keeps compiling, and gate
  the pipeline helpers that only test_support consumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:38:55 -04:00
fabro-releases[bot]
e9a571da2e Bump version to 0.303.0-nightly.2 2026-07-23 10:06:14 +00:00
fabro-releases[bot]
d9282e66f9 Bump version to 0.303.0-nightly.1 2026-07-23 01:49:45 +00:00
fabro-releases[bot]
3c4ed41d57 Bump version to 0.303.0-nightly.0 2026-07-23 00:17:24 +00:00
Bryan Helmkamp
2843b33d92 Simplify runs read model: single-source mappings, leaner queries
Consolidate duplicated logic from the SQLite runs read model review:

- Derive the status sort CASE and board-column filter from a new
  RunStatusKind::board_rank(), replacing three hand-maintained copies
  of the status/column mapping; add a test upserting every status
  variant so the migration CHECK can't silently drift
- Share RunSize bucket thresholds between from_total_usd_micros and
  the generated size-sort CASE via RunSize::BUCKET_MAX_USD_MICROS
- Resolve run selectors from a lean identity query instead of
  decoding every stored summary per request
- Delete the RunsSortKey/RunsSortDirection adapter enums; the store
  sort enums now carry the wire serde names
- Consolidate the workflow display-name fallback chain into
  WorkflowRef::display_name() (store, CLI, run lookup)
- Share pagination clamping and the paginated list envelope across
  handlers
- Reconcile now skips rows whose source seq is unchanged and
  batch-deletes stale rows; drop the two indexes no query can use
- Hold the summary store OnceLock cell in RunDatabaseInner instead of
  a snapshot so late attachment reaches already-open writers
- Misc: expect() on COUNT(*) sign, %err logging, shared wall-time
  helper, shared SQLite test fixture, dead billing fallback removed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:55:22 -04:00
Bryan Helmkamp
22844300af Add SQLite runs read model 2026-07-22 13:55:22 -04:00
Bryan Helmkamp
c12264c16d Migrate automations to SQLite 2026-07-22 13:32:35 -04:00
Bryan Helmkamp
431399826d Simplify SQLite stores after review
- Delete the dead test-only Vault-based env-secrets migration and point
  the startup migration tests at the production migrate_to_store path
  over a real SQLite-backed SecretStore
- Extract shared legacy-import helpers (timestamped backup rename,
  is_toml_file) into fabro_db::legacy and parse_rfc3339_utc into
  fabro-db, replacing four per-crate copies
- Take one secrets snapshot in migrate_to_store instead of per-name
  queries
- Share one bind order between the MCP store INSERT and UPDATE
  statements
- Return SecretEntry directly from entry_from_row
- Unify the environment/MCP store blocking loaders into a generic
  load_store_blocking helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:11:10 -04:00
Bryan Helmkamp
ec3933d5de Move MCP servers to SQLite storage 2026-07-22 13:10:01 -04:00
Bryan Helmkamp
f2cbc016ee Clean up SQLite secrets migration and fix CLI env credential regression
Review pass over the secrets-to-SQLite migration:

- Add SecretStore::open() consolidating the connect/migrate/import-legacy
  sequence repeated at five call sites; fabro-agent and fabro-cli drop
  their fabro-db dependency
- Restore process-env LLM credential lookup in the standalone CLI/agent
  sources via SqlVaultCredentialSource::new (regression: vault_only
  dropped the env fallback that VaultCredentialSource::new provided)
- Fix five install tests that still asserted against the legacy
  secrets.json, which the importer renames to .bak
- Make AppStateConfig.preloaded_vault required, deleting the fallback
  that re-read the already-renamed legacy file; drop the now-unused
  vault_path field and demote load_startup_vault to test-only
- Skip the snapshot clones and CAS retry in resolve() when the vault
  holds no OAuth secrets (per-request hot path)
- Remove dead persist_with_secret_store, the VaultSecretWrite alias,
  the secret_type_string one-liner (now SecretType::as_str), the
  impossible RowCountOverflow error, and duplicated row parsing
- Run check_crypto concurrently with the other diagnostics checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:45:38 -04:00
Bryan Helmkamp
d325a2c1e6 Migrate secrets storage to SQLite 2026-07-22 12:44:20 -04:00
Scott Werner
70370fdaba
Merge pull request #585 from fabro-sh/db-premigration-snapshot
Snapshot fabro.sqlite3 before applying new migrations
2026-07-22 12:38:30 -04:00
Bryan Helmkamp
ec6292827e
Bump version to 0.302.0-nightly.1 2026-07-22 12:29:40 -04:00
Scott Werner
f91c71b8e0 Snapshot fabro.sqlite3 before applying new migrations
A binary downgrade after new SQLite migrations have been applied fails
sqlx's startup validation ("migration was previously applied but is
missing in the resolved migrations") and previously left the operator
with no rollback artifact: the shared database had no backup, so
recovering meant hand-editing _sqlx_migrations and dropping tables.

Database::migrate now writes a consistent single-file snapshot to
<db>.pre-migration.bak (via VACUUM INTO, mode 0600) before applying any
migration the database has not seen. Rollback is: stop the server,
replace the database file with the snapshot, delete -wal/-shm siblings,
start the previous binary. Fresh databases and no-op migrates skip the
snapshot, so the file always preserves the state from immediately before
the most recent schema change. A snapshot failure fails the migration:
no rollback artifact, no schema change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 08:58:33 -04:00
fabro-releases[bot]
d967b33e92 Bump version to 0.302.0-nightly.0 2026-07-22 10:06:43 +00:00
fabro-releases[bot]
ed78df733d Bump version to 0.293.0-nightly.0
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
2026-07-13 10:25:55 +00:00
fabro-releases[bot]
52d8c01c2a Bump version to 0.292.0-nightly.0 2026-07-12 09:51:04 +00:00
fabro-releases[bot]
dfcdda2ab7 Bump version to 0.291.0-nightly.0 2026-07-11 09:48:48 +00:00
fabro-releases[bot]
aadaa3f370 Bump version to 0.290.0-nightly.0 2026-07-10 10:25:04 +00:00
fabro-releases[bot]
3a1295ba72 Bump version to 0.289.0-nightly.0 2026-07-09 10:27:10 +00:00
fabro-releases[bot]
c17b2dbee2 Bump version to 0.288.0-nightly.0 2026-07-08 10:05:49 +00:00
fabro-releases[bot]
4d82e44b00 Bump version to 0.287.0-nightly.0
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
2026-07-07 18:23:12 +00:00
fabro-releases[bot]
8c7d5dc7d0 Bump version to 0.283.0-nightly.0
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
2026-07-03 10:17:50 +00:00
fabro-sh-fabro[bot]
9008058eab
Resolve secret tokens at the run boundary (worker-side, fail-closed) (#545)
## Summary

`{{ secrets.NAME }}` tokens in workflow config (MCP transport, prepare
steps, run environment) now resolve from the server vault at the run
boundary — the same late-binding point where `{{ env.* }}` tokens
resolve. Secret values are never persisted and never left literal in
resolved commands or env; a missing or non-Token secret aborts startup
with a clear error.

## What changed

**`fabro-types` — `run.rs`**

- `resolve_env_string` (shared choke-point for MCP and prepare) gains a
`secrets_lookup` parameter and routes through
`ResolveCtx::new().with_env(...).with_secrets(...)` / `resolve_with`.
- `McpServerSettings::resolve_transport_env` and
`RunPrepareSettings::resolve_step_env` thread the new parameter through.
- `RunEnvironmentSettings::resolve_env` becomes fallible
(`Result<HashMap<_,_>, ResolveError>`). Per-value error handling
preserves the historical env fallback for `Namespace::Env`-only errors
while failing closed for `Namespace::Secrets` errors. The intentional
`as_source()` fallback is gated behind its
`#[expect(clippy::disallowed_methods)]` with an explicit reason.

**`fabro-workflow` — `start.rs`**

- A single vault read guard is acquired once at the top of
`RunSession::new`, replacing the previous per-site reads (Daytona key,
etc.).
- `vault_token_lookup` wraps `fabro_auth::vault_get_token` — returning
`Some(value)` only for `Token`-type secrets; `Oauth` and `File` secrets
become `None` (fail-closed).
- The shared `secret_lookup` closure is threaded into
`runtime_mcp_server`, `runtime_setup_commands`, and `resolve_env`.
`resolve_docker_config` gains the same parameter and now returns
`Result`.

**`fabro-sandbox` — `from_environment.rs`**

- `docker_config_from_environment` (server-preflight path, no vault
available) retains `resolve_or_source` behavior unchanged.
- New `docker_config_from_environment_with_secrets` is the vault-backed
variant used by `start.rs`.

**`fabro-cli` — `exec.rs`**

- `fabro exec` has no vault; passes `|_| None` for secrets, preserving
existing behavior with updated call signature.

### Plan summary

- **B.1** — Secret lookup threaded through `resolve_env_string` /
`resolve_transport_env` / `resolve_step_env` / `resolve_env` in
`fabro-types`.
- **B.2** — Vault-backed `secret_lookup` closure built once in
`RunSession::new` and passed to all boundary resolvers in `start.rs`.
- **B.3** — Persistence invariant test: a created run's persisted
`RunCreated` event still carries `{{ secrets.DEPLOY_TOKEN }}` in source
form, not the resolved value.
- **B.4** — Verification (fmt, clippy, nextest, release build) with
hermetic temp-vault tests.

### Key design decisions

- **Fail closed everywhere secrets are referenced** — no source fallback
for secret tokens, even in `resolve_env` which otherwise keeps the env
fallback. This is enforced by checking
`value.references(Namespace::Secrets)` before the fallback branch.
- **Token-only** — `vault_get_token` enforces this; `Oauth` and `File`
secrets silently become `None` and then hard-error via the resolver, not
a panic.
- **Single vault read guard per `RunSession::new`** — acquired once,
shared across MCP / prepare / env resolvers, then dropped before the
struct is returned. Mirrors how the Daytona key was already read.
- **`fabro exec` stays unchanged behaviorally** — the added `|_| None`
secrets argument makes the new signature explicit about having no vault.


### Fabro Details

<details>
<summary>Ran 9 stages in 82m 55s for $24.43</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 21s | – | 0 |
| preflight_lint | 2m 34s | – | 0 |
| implement | 46m 58s | $15.83 | 0 |
| simplify_opus | 12m 43s | $4.79 | 0 |
| simplify_gpt | 6m 43s | $3.18 | 0 |
| verify | 6m 41s | – | 0 |
| fixup | 4m 33s | $0.63 | 0 |
| **Total** | **82m 55s** | **$24.43** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-8; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> exit  [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
2026-07-02 17:00:20 -04:00
Scott Werner
1806e91d7e
Fix web app load performance: caching, compression, and eager chunk loading (#550)
## Problem

Loading the web UI from a remote server took **~11 seconds to first
render on every refresh**. A HAR capture against a remote deployment
showed the page downloading **13.5 MB of JavaScript across 356 files,
uncompressed, on every single page load** — even though the assets are
content-hashed and served with `Cache-Control: immutable`.

Four compounding causes:

1. **`Pragma: no-cache` defeated the browser cache.** The
security-headers middleware stamped `Pragma: no-cache` onto every
response, including hashed assets that set a year-long immutable
`Cache-Control`. Browsers treat a response `Pragma: no-cache` as
`Cache-Control: no-cache` and check it *before* `max-age` (Chromium
zeroes freshness on it), and since assets carried no validators,
"revalidate" degraded into a full re-download. Empirically visible in
the HAR: Google-Fonts woff2s served from cache (`transfer = 0`) during
the same page load where all 356 of our assets re-downloaded in full.
2. **No response compression.** The server had no compression layer;
13.5 MB of JS compresses to ~2.5 MB with brotli.
3. **The HTML force-loaded every chunk.** `writeIndexHtml` emitted a
`<script type="module">` tag for all 356 outputs. Only 2.9 MB is
statically reachable from the entry; the other ~10.7 MB is
dynamic-import-only code (syntax grammars, Graphviz WASM, xterm, diff
file tree) that was being downloaded eagerly at high priority.
4. **The immutable heuristic over-matched.** Any dash in a filename
counted as a content hash, so stable-named files
(`pierre-diffs-worker/worker-portable.js`, `apple-touch-icon.png`) would
be pinned in browser caches for a year across deploys once fix 1 made
immutable caching effective.

## Changes

- **`security_headers`**: apply the `no-store`/`Pragma: no-cache`
defaults only when the handler didn't set its own `Cache-Control`. API
responses keep the conservative defaults.
- **Compression**: `tower-http` `CompressionLayer` (brotli + gzip) on
both the main router and the install-mode router (install mode serves
the same SPA bundle through a separate router). Default predicate keeps
SSE (`text/event-stream`), gRPC, images, and tiny bodies
identity-encoded. Quality pinned to `Precise(4)` — tower-http's default
defers to the codec default, and brotli's default is quality 11 (seconds
of CPU per multi-megabyte asset).
- **Entry-only HTML**: `writeIndexHtml` emits script tags only for `kind
=== "entry-point"` outputs. The module graph pulls static imports (depth
1, so no waterfall); dynamic `import()` chunks load on demand.
- **Cache-control classifier + validators**: only files matching the
bundler's actual output shape (`assets/<stem>-<hash8>.js|css`, lowercase
base-36) get `immutable`. Everything else is `no-cache` **with a strong
ETag** and `If-None-Match` → `304` support, so index.html / app.css /
the pierre worker revalidate in one cheap conditional request instead of
a full re-download.

## Impact (measured on the built bundle)

| | Before | After |
|---|---|---|
| Cold load, ~1 MB/s link | 13.5 MB raw ≈ **11–14 s** | ~0.8 MB
compressed eager payload ≈ **~1 s** |
| Refresh | full re-download, same 11–14 s | served from cache + one 304
≈ **instant** |
| Eager JS on first render | 13.56 MB / 356 files | 2.88 MB raw (0.79 MB
gzip) / 6 files |

## Verification

- 959 fabro-server tests pass (incl. new coverage); fmt + clippy clean;
`bun run typecheck` passes (the 5 pre-existing bun test failures
reproduce identically on `main` — missing `@pierre/diffs/dist/worker`
fixture + flaky InstallApp timing tests).
- New integration tests pin compression through **both** serving shapes
that matter: regular routes and the SPA fallback service, each via tower
`oneshot` **and** over a real TCP connection through hyper (raw-socket
assertions, so no client auto-decompression can mask a regression).
- Live-verified against a debug server: hashed assets get `immutable` +
brotli and no `Pragma`; mutable assets get `no-cache` + ETag and answer
conditionals with `304`; API responses keep `no-store`.
- Headless Chrome boots the rebuilt SPA from the entry-only HTML and
fully renders the UI.

## Notes for reviewers

- The ETag is skipped for immutable assets deliberately — they never
revalidate, so hashing multi-MB bodies per request would be pure
overhead.
- Install mode previously had **no** compression and shares the same
bundle; it gets the same layer via a shared `compression_layer()`
helper.
- `bun test` has a pre-existing suite (`production build copies Pierre
worker assets`) that fails without `@pierre/diffs/dist/worker` present
locally; unrelated to this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:58:47 -04:00