chore(daemon): collapse the HTTP router onto one deep request adapter + route table#323
Merged
Conversation
… + route table
The daemon's `handle()` was a 2545-line shell routing via a hand-written
if/else cascade of 83 branches, each re-implementing the same mechanics:
read body → validate → call subsystem → shape an ad-hoc { error } envelope →
pick a status. Apply the deletion test and the behavior doesn't vanish — the
tell of a shallow shell carrying no logic of its own.
This introduces ONE deep request adapter (`router.ts: handleRequest`) plus a
route table of declarative data rows `(method, match) → { auth?, schema?,
handler }`. The adapter owns the cross-cutting mechanics — auth gate, body
parse, schema validation, the error envelope — so `handle()` shrinks to a
3-line dispatch and each of the 83 branches becomes one row. Modeled on the
repo's own blessed exemplar (`inbox/route-registry.ts`): the schema field is
the producer-agnostic `{ safeParse }` structural contract, so the router never
imports zod's full surface.
Structural deepening, NOT a contract change — verified faithful by 3 independent
adversarial passes against the original cascade:
- 83 original branches → 83 table rows, 1:1, none dropped/added.
- The 21 inline-`authorizeSameHost` routes carry `auth: true`; the other 62
rely on the 127.0.0.1 bind exactly as before. No gated→public regression.
- Declaration order preserves every specific-before-broad case (memory/search
before /<id>; habits checkin/archive before bare /<id>; etc.).
- The 5 streaming/binary/seam routes (events SSE, uploads POST/GET, evolve scan,
commands dispatch) are `raw` rows that own req/res — no body parse, no
serialize. The ADR-036 evolve-scan `daemonEffects` object (config-gated
triage/builder spawn IO + the un-forgeable evolveEscalation carve-out) moves
byte-for-byte. The helper tail + createAgent are byte-for-byte identical.
Three intentional, documented improvements (each pinned by a test):
- POST /api/memory's inline title/content check becomes a zod schema run by the
adapter (the one demonstration of the schema path) — 400 message text changes
and non-array `tags` now 400s instead of being silently mis-stored.
- A malformed JSON body becomes one clean 400 instead of an uncaught readJson
throw (which previously became an unhandled rejection / hung connection).
- An uncaught handler throw becomes one centralized 500 instead of a hang.
Tests: `router.test.ts` unit-tests the adapter's 4 cross-cutting behaviors +
the matcher once each (14 tests, fake req/res, real zod schema). Added
round-trip endpoint tests for the habits/memory/uploads families, which had no
daemon HTTP coverage and would otherwise have migrated without a net. Full
`src/api` suite: 83 tests green; daemon typecheck + prettier clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGvF96oXJeppyc7PvhMPen
…ing, auth, raw) Closes the one gap the Spec-axis review flagged: the handoff's Test Strategy asked for a route-table contract test, which was missing. `router.test.ts` exercised the adapter against a fake 3-row table, so the REAL 83-row table had no exhaustiveness/ordering/auth guard. Exports `ROUTES` and adds `router-table.test.ts` asserting, against the real table: - unique (method, match) keys — no accidental shadow/duplicate; - the table covers EXACTLY the golden (method, match) set the original cascade served — a hand-listed set, independent of the table, so any drop/add fails loudly (83 routes); - every specific route precedes the broad regex that would swallow it (memory/search, agents/sessions, habits checkin/archive, elicitation, schedules); - exactly the 21 cascade-gated routes carry auth:true (security-regression guard); - exactly the 5 streaming/binary/seam routes are raw and carry no JSON handler/schema. Going forward a new route must update the golden set here — that friction is the point: every routing change stays intentional and reviewed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGvF96oXJeppyc7PvhMPen
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The daemon's
handle()was a 2545-line shell routing via a hand-written if/else cascade of 83 branches, each re-implementing the same mechanics: read body → validate → call subsystem → shape an ad-hoc{ error }envelope → pick a status. Apply the deletion test and the behavior doesn't vanish — the tell of a shallow shell carrying no logic of its own.This introduces one deep request adapter (
router.ts: handleRequest) plus a route table of declarative data rows(method, match) → { auth?, schema?, handler }. The adapter owns the cross-cutting mechanics — auth gate → body parse → schema validation → handler → error envelope — sohandle()shrinks to a 3-line dispatch and each of the 83 branches becomes one row. Modeled on the repo's own blessed exemplar (inbox/route-registry.ts): the schema field is the producer-agnostic{ safeParse }structural contract, so the router never imports zod's full surface.Faithfulness — structural, not a contract change
Verified by 3 independent adversarial passes diffing the new table against the original cascade (
git show HEAD):authorizeSameHostroutes carryauth: true; the other 62 rely on the127.0.0.1bind as before. No gated→public regression./api/memory/searchbefore/<id>; habits checkin/archive before bare/<id>; schedules action-paths before bare; etc.).rawrows that ownreq/res— no body parse, no serialize. The ADR-036 evolve-scandaemonEffectsobject (config-gated triage/builder spawn IO + the un-forgeableevolveEscalationcarve-out) moves byte-for-byte, as do the helper tail +createAgent.Three intentional improvements (each test-pinned)
POST /api/memory's inline title/content check becomes a zod schema run by the adapter (the one demonstration of the schema path) — 400 message text changes and non-arraytagsnow 400s instead of being silently mis-stored.400instead of an uncaughtreadJsonthrow (previously an unhandled rejection / hung connection).500instead of a hang.Tests
router.test.tsunit-tests the adapter's 4 cross-cutting behaviors + the matcher once each (14 tests, fakereq/res, real zod schema so validation genuinely runs).pnpm --filter @friday/daemon exec vitest run src/api→ 14 files / 83 tests green; daemontsc --noEmitclean; prettier clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01PGvF96oXJeppyc7PvhMPen