Skip to content

feat(hooks): daemon /hooks/eval fast-path + guard env-forwarding (B, stage 3a)#612

Merged
Necmttn merged 1 commit into
mainfrom
feat/hook-eval-endpoint
Jun 25, 2026
Merged

feat(hooks): daemon /hooks/eval fast-path + guard env-forwarding (B, stage 3a)#612
Necmttn merged 1 commit into
mainfrom
feat/hook-eval-endpoint

Conversation

@Necmttn

@Necmttn Necmttn commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Context

Stage 3a of the hybrid dispatcher (B) — on #605/#606/#607. The server half of the daemon fast-path: a warm endpoint that evaluates the guard set inside the already-running ax serve, skipping the cold bun spawn + ~0.9 MB bundle parse the spawned path pays per fire. This is where the latency win comes from.

INERT until the shim ships — nothing POSTs to the endpoint yet, so existing installs are unaffected. Safe to land.

What

  • POST /hooks/eval (dashboard/router/routes/hooks.ts) — reads the raw harness event, runs dispatchEvent warm, returns the merged ProcessOutcome ({exitCode, stdout?, stderr?}). DB-free (only GitEnvLive), so it works on source + compiled binary and answers when SurrealDB is down. Fail-open: unreadable body / any error → allow. Capability hooks_eval advertised on /api/version for shim detection.

  • Env-forwarding (the daemon-correctness fix) — a daemon-evaluated guard sees the daemon's process.env, not the agent's, so bypass flags would silently break. The payload now carries an _ax_env allowlist → decodeevent.env; guards read readEnv(event, NAME) = event.env?.[NAME] ?? process.env[NAME] for ALLOW_MAIN_WRITE / ALLOW_BRANCH_CHECKOUT / ALLOW_DIRTY_MAIN_MUTATION + AX_SPEND_MODE. Additiveprocess.env still wins when nothing is forwarded, so the spawned path is byte-for-byte unchanged.

Tested (no live serve/DB)

  • forwarded-env.testdecode _ax_envevent.env (drops non-strings); readEnv precedence; a forwarded ALLOW_MAIN_WRITE=1 (in the payload only, NOT process.env) unblocks an Edit-on-main that otherwise blocks — the daemon-correctness proof.
  • hooks.test (route via handleDashboardRequest, no server boot) — no-match → allow, garbage → allow (fail-open), empty → allow, /api/version advertises hooks_eval.
  • Full suites: 815 pass, 0 fail. typecheck (hooks-sdk + axctl) → exit 0.

Roadmap

  1. feat(hooks): dispatcher core — multiplex all guards in one process (B, stage 1) #605 dispatch core ✅ · feat(hooks): scaffold + embed the single dispatcher (B, stage 2) #606 scaffold+embed ✅ · feat(hooks): flip install --all to the dispatcher + migrate legacy (B, stage 2b) #607 --all→dispatcher+migrate ✅
  2. This PR (3a) — daemon endpoint + env-forwarding (server half, inert).
  3. Stage 3b — the effect-free bun shim (POST daemon-first, lazy-import the bundle on fallback) + an opt-in install that swaps bun dispatch.js for the shim, with live ax serve e2e. That's where the latency win lands for users.

🤖 Generated with Claude Code

…stage 3a)

The server half of the hybrid dispatcher: a warm endpoint that evaluates the
guard set in the already-running daemon, skipping the cold bun spawn + bundle
parse the spawned path pays per fire.

- POST /hooks/eval (apps/.../dashboard/router/routes/hooks.ts): reads the raw
  harness event, runs dispatchEvent warm, returns the merged ProcessOutcome.
  DB-free (only GitEnvLive), fail-open (unreadable body / any error -> allow).
  Capability `hooks_eval` advertised on /api/version for shim detection.

- Env-forwarding (the daemon-correctness fix): a daemon-evaluated guard sees the
  DAEMON's process.env, not the agent's, so bypass flags would silently break.
  The payload now carries an `_ax_env` allowlist -> decode -> `event.env`;
  guards read `readEnv(event, NAME) = event.env?.[NAME] ?? process.env[NAME]`
  for ALLOW_MAIN_WRITE / ALLOW_BRANCH_CHECKOUT / ALLOW_DIRTY_MAIN_MUTATION +
  AX_SPEND_MODE. Additive - `process.env` still wins when nothing is forwarded,
  so the spawned path is byte-for-byte unchanged.

Tested without a live serve/DB:
- forwarded-env.test: decode `_ax_env` -> event.env (drops non-strings); readEnv
  precedence; a forwarded ALLOW_MAIN_WRITE=1 (in payload only, NOT process.env)
  unblocks an Edit-on-main that otherwise blocks - the daemon correctness proof.
- hooks.test (route via handleDashboardRequest): no-match -> allow, garbage ->
  allow (fail-open), empty -> allow, /api/version advertises hooks_eval.
- Full suites: 815 pass, 0 fail. typecheck (hooks-sdk + axctl) exit 0.

INERT until the shim ships - nothing POSTs to the endpoint yet, so existing
installs are unaffected. Stage 3b adds the effect-free bun shim (daemon-first,
lazy-import the bundle on fallback) + an opt-in install that swaps
`bun dispatch.js` for the shim, with live `ax serve` e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying ax with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6e9c4a2
Status: ✅  Deploy successful!
Preview URL: https://9a964d07.ax-62d.pages.dev
Branch Preview URL: https://feat-hook-eval-endpoint.ax-62d.pages.dev

View logs

@Necmttn Necmttn merged commit 28934ca into main Jun 25, 2026
3 checks passed
@Necmttn Necmttn deleted the feat/hook-eval-endpoint branch June 25, 2026 04:21
@letItCurl

Copy link
Copy Markdown

dude all of this would be so much better if you had a cloud version and the local client just pings local diff...
But thats another product and devs needs to agree for that. hard selll

Necmttn added a commit that referenced this pull request Jun 25, 2026
#613)

The client half of the hybrid - and the realized latency win. `ax hooks install
--all --daemon` swaps the spawned dispatcher for an EFFECT-FREE shim that POSTs
to the warm /hooks/eval (#612) and only pays the effect parse on fallback.

- shim-core.ts (@ax/hooks-sdk/shim-core, effect-free): `withForwardedEnv`
  (inject the `_ax_env` bypass allowlist), `runShim` (POST daemon-first with a
  2s timeout; on down/timeout/non-2xx LAZY-import the sibling dispatch bundle
  and run guards locally with the already-read stdin). Standalone bundle = 1.8 KB
  vs the dispatcher's ~0.9 MB - the fast path is a tiny spawn + a loopback fetch,
  no effect parse.
- dispatch.ts: extract `runDispatchFromStdin` (the shim's fallback entry).
- Scaffold + embed the shim (`SHIM_NAME`/`shimScaffoldContent`, sibling-ext aware
  so .ts falls back to dispatch.ts and the embedded .js to dispatch.js).
- install: `--all --daemon` installs the shim; `dispatcherFamilyCommands` +
  `keepCommand` make a dispatch<->shim switch remove the other entry (no
  double-fire). Default `--all` (direct dispatcher) unchanged -> zero risk.

LIVE-verified against a source-booted `ax serve`:
- daemon up: Edit-on-main -> block (warm); + forwarded ALLOW_MAIN_WRITE=1 (in
  the payload, NOT process.env) -> allow; Read -> allow.
- daemon killed: Edit-on-main -> still blocks via the local-bundle fallback.
Plus unit tests (withForwardedEnv / isDaemonOutcome / hookEvalUrl, family-switch
removal, shim scaffold). 414 hooks tests pass; typecheck (sdk + axctl) exit 0.

Completes B. Default install is the dispatcher (size + correctness); `--daemon`
is the opt-in latency path, transparent (falls back to the same bundle offline).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants