care-loopd: SQLite as source of truth, HTTP service, React dashboard - #7
Open
Jacobjeevan wants to merge 31 commits into
Open
care-loopd: SQLite as source of truth, HTTP service, React dashboard#7Jacobjeevan wants to merge 31 commits into
Jacobjeevan wants to merge 31 commits into
Conversation
The journal was the source of truth; it is now a continuously-verified
replica. `Journal.read()` queries the active RunStore, and `append()`
writes the DB BEFORE the jsonl line, so a crash between the two leaves
the DB correct and the replica merely lagging — never the reverse. Both
writes are fatal.
`prev` is the one field that stays replica-sourced. It is the file's own
integrity checksum, a property of the bytes on disk rather than an
ordering fact, and taking it from the DB broke every reindexed legacy
run: those lines carry a pre-ULID run_id while reindex backfills a ULID,
so re-serializing the DB row reproduced a line the file never held. `seq`
and `deltaMs` are ordering facts and are DB-owned.
Runs get a ULID primary key, minted once at run.start and cached in the
run dir. Legacy journals self-heal through one deterministic backfill in
validateState, so live resume and `reindex` land on the same id with no
special-casing — replacing four drifting `${repo}-${branch}` derive
sites, one of which (adopt) used a different formula than the rest.
Parity between replica and DB is checked at run.end and run.resume, with
deliberately different policies: resume throws, because that is the
moment the DB is trusted to reconstruct a run; run.end records to
`runs.parity_error`, because the work is already committed and refusing
to finish cannot undo the divergence. An unreadable replica warns at
both — it is a degraded backup, not a corrupt truth.
Two bugs found while verifying against the real 6-run fleet:
- `started_at` was seeded before the fold and then patched by the very
event that seeds it — run.start carries a CareState built ~1ms before
append() stamps the event ts. Re-asserted after the fold. This was
invisible to both parity and reindex checks, which replay the same
pipeline; it only surfaced from an independent fold of the journal.
- `requested_by` was hardcoded null at all four seed sites. Now one
resolver reading CARE_REQUESTED_BY, with --requested-by as CLI sugar.
Attribution only, never authorization.
Adds `care-loopd reindex` to rebuild loops.db from the journals, and
points the dashboard's fleet list at the index when a db is present.
Tracks the two design records this work is built against, force-added
past care-loop/.gitignore's blanket `PLAN-*`: PLAN-sqlite-run-store.md
(the plan of record, six passes) and PLAN-loop-service.md (the BE/FE
service this cutover unblocks). The other PLAN-* docs stay ignored.
Verified: 307/307 tests green, tsc clean, and reindex over the real
fleet (7 runs, 1351 events) is deterministic across repeated runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two prerequisites for the loop-service bring-up, plus a latent bug the
second one surfaced.
`CARE_RUN_ID` lets a caller supply the run id instead of having one
minted in the child. The service needs this because `POST /api/runs`
must answer `{ run_id }` synchronously, while the child starts long
afterwards — possibly never, if the row sits pending or the spawn
fails. Precedence is "established beats pinned": the pin may name a run
dir that has no id yet, and throws rather than rebinding one that does.
Silently preferring either side would be worse than failing — taking the
pin hijacks a live run's identity, taking the existing id makes the
service's POST response a lie. A supervisor restart re-spawning the same
row with the same id is therefore idempotent.
The dashboard's detail route now keys on run_id, falling back to the
directory slug for the no-db path and existing bookmarks. The two are
unambiguous by shape, so this dispatches on the value rather than
guessing. `slug` was never a key: it has no unique constraint and is
derived from repo+branch, so a reused branch collides on it
deterministically. RunSummary carries runId for the same reason — the
frontend cannot build a detail link without it.
Fixes a latent bug found while wiring that route: RunIndex.get builds
`new Journal(path, slug)` and then read() it, but read() is DB-backed
and queries by run_id, so it matched no rows and returned an EMPTY
timeline with no error. A detail page that silently renders nothing.
Switched to readReplica(), which is what a slug can address. Currently
unreached — the dashboard uses its own scan path — but it is the exact
call the service's detail route was going to make.
Branch admission control deliberately does NOT go in the loop. The loop
already owns the invariant that matters: run dir is derived from
repo+branch, so a second run on the same branch IS the first one, and
withLock(runDir) refuses it. Whether to queue behind or reject is
scheduling policy, and belongs to the thing that owns the queue.
Building it in both would mean two implementations of one policy, which
is how run_id drifted into three formulas. PLAN-loop-service.md §12
records the split, and the one seam the service will need from the loop
(a non-destructive inspectLock, since a `running` queue row is a lie
after a crash).
Verified: 316/316 green, tsc clean. Both new guards were confirmed to
fail without their fix — the slug/run_id one by asserting a populated
timeline, which returns empty and error-free under the old read().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`care-loopd serve` exposes the fleet over HTTP for the coming React
frontend. Three layers with exactly one thing below each: the service
reads the database and nothing else, and the frontend will talk only to
the service.
Making that true meant rewriting `run-index.ts`. Its `get` took a
directory slug and read journal.jsonl; it now takes a run_id and reads
`run_events`, which mirrors the journal completely, so the filesystem
has nothing to add. The port gained `count`, `events` (seq-cursor
pagination), and composable list filters. Reading files put the
filesystem back under an API with no other reason to know it exists —
and slug was never a key, having no unique constraint.
Routes built: /api/health, /api/me, /api/runs, /api/runs/:id,
/api/runs/:id/events. The full surface through step 5 is now specified
in PLAN-loop-service.md §6, with the conventions frozen before the FE is
written: envelopes on lists, `{error:{code,message}}` with real statuses,
ISO timestamps, and no route making an authorization decision.
Deliberate choices worth naming:
- Query values are parsed and validated at the edge (query.ts), not
coerced inline. `?active=false` is a non-empty string and therefore
truthy — that class of silent wrongness is a 400 here instead.
- A malformed run id is 400, an unknown one 404. The frontend needs to
tell a broken link from a deleted run.
- `/events` checks the run exists first, so an empty timeline reads as
an empty timeline rather than a missing run — that is the normal state
of a run that just started.
- `/health` actually touches the db. "The process is up" is not the
question anyone asks health for.
- `serve` opens the db readOnly and binds loopback. The child that owns
a run is the only writer, and there is no authentication.
One thing DB-only cannot serve: skill artifact bodies live in sidecar
files, with only bounded fields and a {path,sha256} ref in the journal.
So the API can report that the reviewer returned 3 findings, not what it
wrote. §6 records the three options; recommendation is to ship v1
without bodies and add a `run_artifacts` table if the FE wants
drill-down, rather than putting the filesystem back under the service.
The vanilla dashboard.html reads a different shape, so dashboard.ts now
adapts index rows back into it — the old page keeps working until step 2
deletes it, and that adapter goes with it.
Verified: 329/329 green, tsc clean, and smoke-tested against a copy of
the real fleet db (7 runs, 1351 events) — list, detail, event
pagination, and both error shapes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service reads the database and nothing else, but skill OUTPUT was
not in it: the journal spine deliberately carries only bounded fields
plus a {path,sha256} ref, with the envelope in a sidecar file. So the
API could report that the reviewer returned 3 findings without being
able to show what it wrote. Fixed by storing the bodies, not by letting
the service read files.
`run_artifacts` holds content as jsonb in a BLOB column. Every artifact
is a serialized JSON value by construction now — `SkillLogger.artifact`
takes a value and does the serializing, so "valid JSON" is structural
rather than a convention each caller honours, and the sidecar text, the
hash, and the db encoding all derive from one serialization. Measured on
the real fleet: 643 KB of text becomes 568 KB of jsonb, ~12% smaller,
and json_extract runs on it with no reparse.
Note jsonb is a function and an encoding, NOT a column type. Declaring a
column JSONB is accepted but matches no affinity rule — it does not
contain "BLOB" — so it lands on NUMERIC affinity and coerces
numeric-looking strings. BLOB is the correct declaration.
PK is (run_id, path), not (run_id, sha256): path is unique within a run,
content is not, and an unchanged input recurring across two rounds would
otherwise collapse two artifacts into one row.
I had argued against re-encoding on the grounds that it breaks the
content address. That was overstated: nothing in the codebase re-hashes
an artifact and compares. The only verified hashes are journal.ts's prev
chain and run-id's backfill seed, and the doctor opens sidecars by path.
sha256 is a handle, not a digest anyone checks — which is exactly what
makes it the right key for the API to serve bodies by, since the journal
ref already carries it.
The sidecar files stay, deliberately. care-loop-doctor reads
skills/*.json by path off the run dir, and `reindex` rebuilds
run_artifacts from them — which keeps artifacts out of the
queue/gate_asks category of data no rebuild can restore. Verified
against a copy of the real fleet: 7 runs, 188 artifacts, rebuilt from
disk alone with bodies intact.
Routes: GET /api/runs/:id/artifacts (metadata only — a timeline wants
links, not 160 KB of envelopes) and GET /api/runs/:id/artifacts/:sha
(one body, content parsed so the client gets a real JSON value rather
than a string containing JSON).
Also parameterizes two migration assertions that hardcoded
user_version 2, so the next schema bump does not break them.
Recorded but NOT built, per discussion: soft deletes. No call site
exists yet. When it lands, clearAll() and the CASCADE chain must stay
hard — they are reindex's truncate-before-rebuild, and soft-deleting
there would turn a rebuild into an append. The place it would help today
is `stale`, currently derived by string-matching the directory name.
Verified: 338/338 green, tsc clean, and smoke-tested over HTTP against
the reindexed real fleet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The artifact-bodies decision kept the sidecar files for two reasons, and they have different lifetimes. The doctor reading skills/*.json by path goes away when the doctor loop is reworked — run_artifacts already holds every body as queryable jsonb, so nothing needs adding to the schema for it. Reindex rebuilding artifacts from disk does NOT go away: it is what keeps "rm loops.db && reindex is lossless" true. Recorded so a later change proposing to drop the files argues with the right one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Permanent" overstated the disaster-recovery argument. It narrows at step 3, when queue/gate_asks arrive with no journal behind them and backups become the real answer to a lost db — at which point artifact rebuildability is cheap insurance rather than a necessity. The durable reason is schema evolution: reindex is how existing runs acquire data a new schema version projects. run_artifacts is the worked example — v3 gave every existing db the table and zero rows, and the 188 artifacts exist only because reindex read them off disk. A future column derived from artifact content can only be backfilled if that content is on disk independent of the db being rebuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Withdraws the schema-evolution argument for keeping artifact sidecars. It was one-time and is now spent: the content is in the database, so a later version deriving something from artifact content reads run_artifacts.content directly. Disk is only needed to backfill what the db does not already hold. Both remaining reasons expire — the doctor reading them by path at the doctor rework, disaster recovery at step 3 backups. The sidecars are a transitional artifact; nothing should be built assuming they persist. Separates the journal.jsonl replica out, which had been getting lumped in with them. It is the independent witness the parity check compares the db against, so retiring it is a question about assurance rather than about storage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while checking whether anything blocked step 2 — and it did. The list response echoed the REQUESTED paging, not the effective paging: no limit -> reported null, applied 50 limit=999 -> reported 999, applied 200 limit=0 -> reported 0, applied 1 The second is the damaging one. A frontend doing `offset += limit` on the reported value would skip 799 rows per page and never see an error — exactly the kind of thing that gets built on before it gets noticed, and this contract is about to have a frontend written against it. `resolvePaging` is now the single resolver, used by both the query and the envelope, so the two cannot disagree. `?limit=0` is a 400 rather than being silently clamped up to 1: asking for zero rows is a caller bug, and answering with one row is stranger than an error. Verified: 339/339 green, tsc clean, all three re-checked live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Freezes the API before the frontend is written against it. Ten routes built, seven specified with the step they land in. Auth is the new piece. POST /auth/login takes a GitHub login and starts a cookie session; /auth/logout revokes it; /auth/me answers who the caller is. Nothing verifies the login — the boundary is still the network — so what this buys is the SHAPE real auth needs: adding GitHub OAuth replaces one handler's body and leaves /auth/me, /auth/logout, the middleware, and every other route untouched. Cookies rather than bearer tokens precisely because OAuth's redirect lands on a cookie anyway. Two habits taken now because they are cheap now and awkward later: the db stores a hash of the session token, never the token, so a leaked database is not a set of live logins; and logout revokes rather than deletes, consistent with the standing soft-delete preference and keeping "who was signed in when". X-Care-User still works for curl and the CLI, which have no cookie jar, with the session winning when both are present. That header is exactly what to delete when real auth lands — a trusted header beside a verified session is a bypass — so it stays confined to the one middleware. Deliberate: /auth/me answers 200-with-null rather than 401, so the FE makes one unconditional call and branches on the answer instead of treating an error as a state. An unresolvable cookie falls through to the header rather than 401ing — the middleware identifies, it does not gate. And ?requested_by=me 400s when nobody is signed in rather than 401ing, because it is a filter that cannot be expanded, not a permission being refused. No route makes an authorization decision. List filters now cover requested_by (with `me`), repo, branch, step, ticket, pr, free-text q, since/until, active, stale, order, dir, limit, offset — joined through run_detail so a filter on task or ticket means the same thing in the page and in its total. /runs/facets answers the same filters with what is left, so narrowing to one repo offers only that repo's branches. Two things worth not re-learning: `order` is whitelisted rather than interpolated, being the one parameter that would otherwise reach SQL as syntax; and /runs/facets is declared before /runs/:id, since Express matches in order and "facets" would otherwise be rejected as a malformed run id. Schema v4 adds users and sessions. The service connection is now read-write, which step 1 had as read-only — correct then, since the API only read. It does not weaken "one writer per run": that is scoped to the run tables, and these are service-owned. Verified: 352/352 green, tsc clean, and exercised live against the reindexed real fleet including injection-shaped order values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vite + React 19 + TanStack Router/Query at care-loop/web, at read parity with the vanilla dashboard: fleet table (Run · Step · Pipeline · Round · Tier · Age · Cost · Duration), run detail with metadata, and the full event timeline with lazily-fetched skill artifacts. Care UI (careui.ohc.network) is the design system. Its shadcn registry could NOT be used: every /r/<name>.json endpoint returns the docs SPA's HTML, so `shadcn add` fails on `Unexpected token '<'` — verified against the real CLI, not inferred. What was adopted instead is the substance, the tokens, read off the live docs site's computed styles: the full semantic set for both themes, --radius, the chart ramp, Figtree and Geist Mono. Care UI's --primary is emerald, not blue, which my hand-rolled palette had wrong on the most visible thing. ui/primitives.tsx holds the few primitives needed, written against exactly the token names Care UI's components consume — so when the registry is fixed, the real components replace that one file and nothing else changes. Dark mode is a `.dark` class rather than a media query, because Care UI ships five modes and a media query expresses two. Filter state lives in the URL, so a filtered view is a link and browser back behaves. Filters are driven by the server's facet counts, so a dropdown never offers a value returning nothing. Query keys mirror URLs, making back/forward instant. `serve` now also serves web/dist when built, putting the API and the app on ONE origin — which is what lets the session cookie be plain same-origin with no CORS anywhere, in dev (Vite proxy) and prod alike. Two bugs found by driving the real thing in a browser rather than assuming: unclassed <a> elements took the browser's default dark blue, near-invisible on the dark panel; and the SPA fallback answered 200-with-HTML for a missing asset, which makes the browser execute a document as JavaScript and report a MIME error that says nothing about the real cause — a stale asset reference after a redeploy. Paths with a file extension are now excluded from the fallback, with a regression test. Verified against the reindexed real fleet: signed in, listed 7 runs, opened one, confirmed all 327 events and 61 step dividers rendered, and expanded an artifact to see its body arrive from the db. Verified: 353/353 green, tsc clean in both packages, web builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Superseded by care-loop/web, which reached read parity in step 2. Removes
dashboard.ts, dashboard.html, the `care-loopd dashboard` command, and the
npm script. `care-loopd serve` replaces it.
Three things go with it, all deliberately:
- the second API response shape. dashboard.html read `{name, state}`
while the service returns flat rows, so dashboard.ts carried an
adapter between them. One endpoint serving two shapes was always a
transitional state.
- the no-loops.db full-journal scan. It existed for a runs/ tree that
predated the projection; `care-loopd reindex` rebuilds the db from the
journals in under a second, so the fallback was covering a case that
no longer costs anything to fix properly.
- 1200 lines of hand-rolled server-rendered HTML and vanilla JS.
Also corrects two now-stale lines of usage text: `serve` still described
itself as read-only, which stopped being true when sessions landed, and
`reindex` referred to the dashboard as a reader.
Verified: 353/353 green, tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its only caller was the vanilla dashboard's detail route, which mapped a run_id back to a directory so it could read that run's journal off disk. Nothing does that any more: the service is DB-only and reads run_events, and the doctor opens sidecars by path without going through this port. Removed rather than kept for a future caller. It is one query, trivially re-addable if the doctor rework wants run_id → directory — and a read port that still exposes on-disk location invites exactly the filesystem coupling the DB-only rule exists to prevent. `slug` stays on RunSummary as a display label, which is all it ever was: no unique constraint, and a reused branch collides on it deterministically. Verified: 352/352 green, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `queue`, the service-owned request table. Two shape decisions are
load-bearing and easy to get wrong later:
`run_id` is minted at ENQUEUE and is deliberately NOT a foreign key to
runs(run_id). The queue row exists before any run row does — POST
/api/runs must answer { run_id } synchronously while the child starts
long afterwards, or never, if the row is cancelled or the spawn fails —
so a FK would reject every insert.
That absence is also what protects the queue from reindex. `clearAll()`
is `DELETE FROM runs`, and with no FK there is no cascade reaching the
queue. This matters asymmetrically: a runs row is rebuildable from its
journal, a queue row is not. The scoping is now stated in clearAll's
own comment rather than left as a property of the schema.
Indexed for the two reads the supervisor makes: pending-oldest-first,
and "is this repo+branch already live".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`POST /api/runs` inserts a pending row and returns the run id it minted; `GET /api/queue` and `GET /api/stats` show what the queue is doing; the run detail route now carries its queue row. Enqueue validates with the LOOP's own rules — front-terminal.ts's `validateSeed`, not a second copy of them. A ticket that would fail the [ENG-###] PR-title assert, or a branch `git worktree add` would reject, fails while a human is looking at the form rather than hours later inside a spawned child. `repo` is an allowlist, since it reaches both `git worktree add` and a GitHub API call. The claim is the risky part and is written accordingly: BEGIN IMMEDIATE takes the write lock up front so two supervisors cannot both read the same pending row, and a conditional UPDATE with a changes() check is the belt to that braces. Getting it wrong means two orchestrators writing one journal. Queue-behind, not reject. A pending row whose repo+branch already has a RUNNING row is SKIPPED rather than failed — and skipped rather than blocking the head of the queue, so one busy branch cannot stall everything. It becomes claimable the moment the first finishes. The caller is told which run they are behind, because a request that will not start immediately should say so. Backups land here rather than earlier because this is the first data a reindex cannot rebuild: a pending request has no journal behind it. VACUUM INTO rather than copying the file — it snapshots a live database through SQLite, safe with WAL and open connections, where `cp` can catch a torn page or miss the WAL entirely. Pruning orders by the ISO stamp in the name, not mtime, which a restore would rewrite. PRAGMA integrity_check runs before the first request and REPORTS rather than refusing to boot: corruption found weeks later, after backups have rotated past the last good snapshot, is what this prevents. Verified: 376/376 green, tsc clean, and exercised live against the real fleet — enqueued, saw queue-behind reported, watched a bad ticket rejected with the loop's own message, and confirmed the boot snapshot on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was defined three times — run-index.ts, web/format.ts, and implicitly by STEP_VOCAB — which is the same drift that put run_id in three formulas. state.ts now owns it, with `satisfies readonly Step[]` so a terminal step that is not in STEP_VOCAB fails to compile rather than silently becoming a string that matches nothing. Verified by making it fail. RunSummary carries `terminal`, so the frontend renders live-vs-finished from the server's answer instead of keeping its own copy of a vocabulary that belongs to the orchestrator. The frontend's PIPELINE list stays, because that is a presentation choice — STEP_VOCAB also holds sub-states that would make ten pips into seventeen without saying more. pipelineIndex now returns null rather than -1 for a step it does not know. A step the frontend predates used to render as a strip of all-future pips, indistinguishable from a run that had not started — a wrong answer told confidently. It shows the step name instead. Verified: 377/377 green, tsc clean in both packages, and the flag confirmed over the wire against the real fleet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewing what shipped against PLAN-loop-service found one genuine bug, which §10 had named as a test I had not written. BUG: the service ran with busy_timeout = 0. `busy_timeout`, `foreign_keys`, and `synchronous` are PER-CONNECTION pragmas that are not stored in the database file — only journal_mode = WAL is. They lived inside the schema string, which only SqliteRunStore's constructor executes, and serve.ts opens a bare DatabaseSync. So the child waited five seconds on a lock while the service failed instantly. Measured before the fix: 42 of 300 service writes took SQLITE_BUSY against a concurrently-writing child; after, 300/300 on both sides. WAL is what hid it. Readers do not contend, so a read-only service looked healthy — the bug only became reachable when sessions and queue rows made the service a writer, which happened two commits later and nowhere near the pragma. Both paths now call applyConnectionPragmas. test/concurrency.test.ts covers the interleaved-writers case §10 asked for, and asserts that a bare connection really is unconfigured — so the helper cannot quietly become decorative if a default changes. Plan reconciled where the code had moved past it: - the queue DDL was stale: ticket is NOT NULL (the PR-title assert requires it), summary exists at all (the loop needs all four seeds), and run_id is UNIQUE. - §8 specified a render-diff against the vanilla page before deleting it. That was SKIPPED. The plan now says so, says what covered the risk instead — parity is checked continuously rather than once — and where the deleted page lives if anyone wants the comparison. - non-goals claimed cli.ts stays "unchanged", which stopped being true when it gained serve/reindex/--requested-by and lost dashboard. The claim that actually matters is restated: terminal and service run the same code, and the CLI is not a compatibility shim. Verified: 380/380 green, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
queue.test.ts claims the same row twice sequentially, which proves the status guard but not the locking — both calls run in one transaction context. §10 asks for two claim loops, and this drives two independent connections, which is what actually exercises BEGIN IMMEDIATE. Worth testing properly because the failure is remote from its cause: two supervisors claiming one row means two orchestrators on one journal, and that surfaces hours later as the loop's own lockfile refusing the second one, long after the queue has moved on. Also drains a 20-row queue by alternating between the two connections and asserts no row is claimed twice. Verified: 382/382 green, tsc clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified every [confirmed] claim by running it before changing anything.
All three reproduced.
BLOCKER 1.1 — a branch could be run exactly once, ever. Run dirs are
keyed by ${repo}-${branch}, so the second run of a branch lands on the
first run's directory; with ids minted at enqueue, resolveRunId threw
RunIdConflictError every time. Guaranteed, not racy. It would have
surfaced as three failed spawns and an error about run ids to whoever
asked for the run.
The missing distinction was whether anything is actually DRIVING the old
run. A live lock means rebinding would hijack a run in flight — still
refused, which is what the guard was written for. A finished or crashed
run holds no live lock and its directory is merely in the way: it is
archived as <dir>.stale-<ts> and the new run starts clean.
Archiving at START, not on exit: a crashed run never reaches an exit
path, and that is precisely the run whose directory would otherwise
block its own retry. It also makes `.stale-` a convention code produces
— the read-side filters were matching a naming scheme nothing wrote.
BLOCKER 1.2 — every authenticated request was a write. identity() calls
sessions.resolve() on every request, which unconditionally UPDATEd
last_seen_at, including on /api/health and every static asset. Measured
under a child's write lock: 5438ms then `database is locked`, reaching
the error handler as an unmodelled error — HTTP 500 on every route for
every signed-in user for as long as the lock was held. A pure read cost
0ms; WAL was doing its job and the bookkeeping write was undoing it.
Now throttled to 60s, non-fatal on failure (a timestamp nobody reads at
second granularity must not cost a caller their identity), and
/api/health is registered BEFORE identity — health is precisely the
route that must answer when the database is unhappy. Same measurement
after: 0ms.
1.3 POST /api/runs is gated on a supervisor and 503s `no_supervisor`
without one, and /health reports it. Accepting work nothing will
ever run is a silent black hole to the person who asked.
1.4 reindex refuses while a run is live — its DELETE FROM runs cascades
to run_events and kills a running child with an FK error. Liveness
is the LOCK plus a running queue row, deliberately not
`step NOT IN (terminal)`, which flags month-old crashed runs
forever. --force added; the "safe at any time" usage string
corrected, since it described the design reindex was written for and
stopped being true at the cutover.
2.1 malformed JSON is 400 bad_json, not 500. 2.2 X-Care-User is held to
the same rule /auth/login enforces — same column, two standards.
2.3 use the changes .run() already returns. 2.4 --port validated;
EADDRINUSE reported instead of an unhandled event.
3.5 --secure-cookies/--static/--repos/--backup-dir wired; they existed
on ServeOptions with no way to set them, and step 6 needs
secure-cookies.
4.1 shutdown calls closeAllConnections with a hard backstop — an idle
keep-alive socket from one open dashboard was enough for SIGTERM
never to complete. 4.5 documented. 4.7 @types/express moved to dev.
5.1 the timeline paginates via next_seq instead of silently stopping at
2000 events. 5.2 the validateSearch comment corrected — it
normalises, it does not whitelist, and pagination depends on that.
Also builds inspectLock (the §12 seam), which 1.1 and 1.4 both need.
Verified: 393/393 green, tsc clean in both packages, web builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spawning care-loopd from the loop-service supervisor is the first thing that ever ran it with a foreign cwd and no stdin. Both went badly, and neither failure was the service's. **The launcher only worked from inside its own package.** The shebang was `node --import tsx`, and node resolves that bare specifier against the WORKING DIRECTORY — so `care-loopd` died with "Cannot find package 'tsx'" from anywhere else. That is every `npm link` user, and every child the supervisor spawns, whose cwd is the run directory by design. It now registers tsx programmatically, resolved relative to the launcher file. **The plan gate hung forever on a closed stdin.** `rl.question` against an ended stream never resolves — not EOF, not an empty string, just a promise that sits there. The child printed the approval prompt and stopped: alive, idle, holding its lock, with no error and no exit. Racing the question against the interface's own `close` turns that into a message naming the two places a run CAN be approved. `question` on an already-closed interface throws synchronously instead (`echo "a" | care-loopd`), so that is converted too. Both terminal dialogs share one `askOrFail` — two copies of this fix would mean the second one is the one nobody remembers. The CLI already advertised a non-interactive (CI/bot) mode, so this hazard predates the service; the service is just the first caller to hit it every single time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 4 of PLAN-loop-service. The only part of the service that starts a
process: everything above it writes rows, this turns rows into children and
children's exits back into rows.
`--supervise` is opt-in. Two `serve` processes on one db must not both claim,
and a read-only dashboard is a reasonable thing to run. `POST /api/runs`
already refuses while it is off, so the failure mode is a clear 503 rather
than rows nothing consumes.
**The claim now consults the filesystem.** `QueueStore.claim` takes a
`startable` predicate evaluated INSIDE the `BEGIN IMMEDIATE` transaction, and
the supervisor backs it with `inspectLock`. Without it the queue is blind to a
run someone launched from a terminal — which holds the very same lockfile,
since the run dir is `${repo}-${branch}` either way — and the service would
claim, spawn, set up a worktree, and die in `withLock` minutes later, reported
as a spawn failure. The candidate query returns the oldest pending row PER
BRANCH so fifty rows queued on one blocked branch cannot fill the scan window
and starve every branch behind them. `runSlug` is now shared with
`derivePaths`, because two copies of that rule would mean the service
inspecting one path's lock while the child takes another's.
`stop()` does NOT kill the children. They are independent processes holding
their own locks and journals; a service restart aborting every teammate's run
would be far worse than a few unsupervised minutes.
**Reconciliation is one path, not two.** The plan had a dead child "resumed in
place, staying running". Returning it to `pending` reaches the same
destination through machinery that already exists: the claim/spawn cycle
re-spawns it with the same `CARE_RUN_ID` onto the same run dir, and
`run-context` adopts that id rather than rebinding it. The alternative was a
second code path that runs only after a crash — the least-tested kind there
is. A re-adopted child does need something a spawned one doesn't: there is no
exit event for a process you did not fork, so adopted pids are swept each
tick, and the lock recovers the status the exit code can't (released in
`withLock`'s finally ⇒ clean, still present ⇒ died where it stood). Without
that sweep the row stays `running` forever and its slot stays spent.
Settles the three paper decisions REVIEW-loop-service left open:
- §3.1, the §4/§5 contradiction: §4 wins, the child never sees the queue.
Cancel is SIGTERM with a SIGKILL escalation. Keeping the child free of DB
coupling is what makes it the same binary you run locally, and crash-only
`resume` already handles a terminated run — one mechanism, already tested.
- §3.3, `queued_behind` reported the wrong thing: now `blocked_by_branch` AND
`queue_position`. With a cap of 2 and five queued branches, the old field
told three of five callers nothing was in their way while they waited on the
cap — the common reason a run doesn't start.
- §3.4, `GET /queue` broke the plan's own envelope rule: now
`{items, total, limit, offset}` on the same 50/200 as every other list
route. `/stats` was counting a PAGE of rows, so its totals quietly stopped
being totals once the queue outgrew one page; it counts in SQL now.
Verified against a real service with a real spawned child: enqueue → claim →
spawn → the planner actually ran → the gate → exit → `failed` written with the
reason. 418 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things in §7 would have been expensive to retrofit, so they are settled now, on paper, against the real code. **Cancel travels the channel the child is already blocked on.** A run parked at its gate is the one moment the child is definitionally idle AND already polling a row the service can write. SIGTERM would work — crash-only makes it safe — but it is strictly worse than saying so in the row: a signalled child skips its `finally`, leaving its lockfile behind, writing no `run.end`, with the run's last journal event a question nobody will answer. So a parked run is cancelled cooperatively (stamp `cancelled_at`, short grace) and signalled only on escalation; a non-parked run still gets SIGTERM immediately, and which branch applies is one indexed query. This does not breach §4's "the child never sees the queue". The child reads its own gate row for its own run — the transport it was handed — and never learns a queue exists. §4 protects the child's independence from the SCHEDULER, not an embargo on the gate transport discussing its own gate. **`ask_id` must be per ATTEMPT.** §7 specified the literal `'approve'` for the consolidated ask. That is an infinite loop: `amend` re-drafts and asks again, the second ask finds the first row — already answered `amend` — and returns it immediately, so the planner amends forever against an answer nobody re-gave. `plan.ts:134` is a bare `for (;;)` whose own comment says amend re-drafts unbounded, and every iteration is a real planner call. `runPlan` already has the counter for this: `spawn` is monotonic across the stage, so the ask is `approve:<that draft's round>` — tied to the exact draft the human is looking at rather than to a second counter that could drift from it. Also settled: the child polls SQLite directly rather than the HTTP API, so a gate survives the service restarting; `expires_at` (24h), because a parked run holds a concurrency slot and a cap of 2 means one forgotten plan halves the fleet; and `answered_by`, because the person who approves is not always the person who requested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Parked" means the plan is FINISHED and waiting on a human — not waiting to
start (that is `pending`, no process yet), not waiting to plan (recon, the
interview, and the draft have all already run). The child has done every
expensive thing it will do before approval and is sitting on the answer.
The previous draft had it hold that position for 24h and called the
concurrency slot an acceptable cost. It is not: with the cap at 2, one plan
left unanswered overnight halves the fleet and two stop it, on a shared box
where the requester may simply have gone home. A cap two unanswered plans can
exhaust is not a cap, it is a deadlock with a countdown.
So the child waits briefly (~10 min, "someone is probably looking at it right
now") and then EXITS, and the run resumes at the gate when the answer arrives.
Two timers doing different jobs: `wait_ms` bounds an expensive wait so it is
short; `expires_at` bounds a free one so it can be 7 days.
Resuming costs no model calls, because everything the approval path needs is
already durable — verified, not assumed:
- writeArtifacts runs BEFORE the ask, so criteria/baseline/decisions.md are
on disk before the human sees the question
- gate_asks.payload IS the ConsolidatedAsk, and the two fields plan.approved
reads off the draft — plannedBy, classification — are both in it
- ticket/summary arrive as seed flags the supervisor already passes
- the worktree is provisioned by runStart, AFTER the gate, so a suspended
run holds no worktree either
`approve` resumes having called no model at all; `reject` is one journal
write; only `amend` re-invokes the planner, which is the work the human just
asked for.
Adds one status, `awaiting_gate` — live but not claimable, re-admitted to
`pending` by the answer in the same transaction that writes it. Distinct from
`pending` because "waiting on a human" and "waiting on capacity" are different
states, the FE must render them differently, and `queue_position` is
meaningless for the first.
The child still never sees the queue: it signals suspension with exit code 75
(EX_TEMPFAIL), "I am not done and nothing is wrong". A terminal-started run
uses the readline gate, never suspends, and never emits it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan gate as rows ([[PLAN-loop-service]] §7). Ask and answer are both committed, and the two sides never talk to each other, only to this table — which is what lets a gate survive the service restarting AND the child exiting, since neither holds state the other needs. `payload`/`answer` are BLOB written through `jsonb()`, matching run_artifacts: smaller than text, and `json_extract` works without a reparse. `ask_id` is documented as per-attempt in the DDL itself, because the bare `approve` it replaces is an infinite loop, not a style preference: amend re-drafts, re-asks, finds the previous row already answered `amend`, and the planner amends forever at one real planner call per lap. The pending index is partial. A pending ask is a tiny minority of rows the moment the fleet has any history, and three different callers ask "does this run have an open question?" — the claim path, the cancel path, and the FE's needs-you list. `clearAll` now names gate_asks alongside queue/users/sessions as the tables a reindex must not touch: losing a gate ask means a human re-approves a plan, and no journal can rebuild it. `gate_asks.run_id` is deliberately not a foreign key, so the cascade cannot reach it either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5 of PLAN-loop-service, minus the FE. A gate is now a suspend point rather than a blocking wait. **The child polls SQLite, not the API.** The plan called this `HttpPlanGate`; HTTP is how the human answers, not how the child listens. It already opens the database to write every run event, so this needs no HTTP client, no service URL, and no credentials in the child — and a gate survives the service being restarted or redeployed, because neither side holds state the other needs. **Suspension.** An unanswered gate raises `GateSuspendedError` after ~10 min; `runPlan` journals `gate.suspended` and returns `suspended`; the CLI exits 75 (EX_TEMPFAIL); the supervisor writes `awaiting_gate` instead of `done` and the slot goes to the next branch. No `run.end` — it is a pause, not a terminus. **Resume costs no model calls**, because everything the approval path needs was already durable: the artifacts are written before the ask, and `plannedBy` / `classification` — the only two fields the approval path reads off the in-memory draft — are in the ask itself. So approve journals `plan.approved` having called no planner, reject is one journal write, and only amend re-invokes it, exactly once, which is the work the human asked for. Verified by counting planner phases in the tests. `runPlan` gains this without gaining a dependency on the service: `PlanRestore` is plain data assembled by the caller, and the three gate outcomes live in `plan-gate.ts` as part of the CONTRACT — any transport may be revoked or run out of patience, and the readline adapter simply never raises them. **Cancel is cooperative first.** The supervisor revokes the ask before reaching for a signal, and only escalates if the child does not unwind. A signalled child skips its `finally`: lockfile left behind, no `run.end`, and the run's last journal event a question nobody will answer. **`awaiting_gate` is live but unclaimable.** It blocks its branch — a suspended run still owns its run dir — and `answerAndReadmit` flips it back to `pending` in the SAME transaction as the answer. Splitting them would leave a window where the gate is settled and the run is still awaiting_gate, waiting on a question already answered. Ask ids are content-derived rather than counted: a counter lives in memory and a re-spawned child restarts it at 1, colliding a different second draft with the first draft's answer. A hash gets identical content (idempotent re-ask, what crash-only needs) and different content (amend) right at once, with an in-process guard for the one case it cannot see — a re-draft that comes back byte-identical. Routes: `GET/POST /api/runs/:id/gate` and `GET /api/gates`, the needs-you list — a gate nobody sees is a gate that expires, and expiry is the one outcome that throws away finished planning work. Answers are validated because `approve` authorizes a push to origin: an interview must answer every question it was asked, and an empty amendment is refused rather than sent to the planner to re-draft against no instruction. 446 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`GET /api/runs/:id` 404'd for a run that had been enqueued but not started.
Found by driving the FE: the new-run form navigates to the run it just
created, and landed on "No such run".
Minting the run id at enqueue is exactly what lets POST /api/runs answer
synchronously — so the id is a valid address BEFORE any process exists to
write a journal. 404 made a successful enqueue look like a failure for the
first few seconds of every run's life, and for the whole time a run sits
behind a busy branch.
Both halves of `{run, queue}` are now nullable, which is the honest shape:
`queue` is null for a CLI-started run that never went through the service,
`run` is null for a queued one. Neither is the record and the other a
decoration. 404 is reserved for an id where neither exists, which is a
genuinely unknown run.
An existing test asserted the old behaviour with a comment calling it
intended; it now covers the CLI half and points at the new test for the
mirror case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two views §8 was still missing, which together close the loop: a run can now be requested, and its one human gate answered, without a terminal. **New run** replaces `terminalFront`'s questionnaire with the same four seed fields, validated by the same rules — server-side, by `validateSeed`, so a ticket that would fail the [ENG-###] PR-title assert fails while a person is looking at the form rather than hours later inside a spawned child. The server names the offending field in `error.code`, so the message lands under the input it is about instead of as a banner the eye has to correlate. **The gate view** renders the ask in full — every acceptance criterion, the test plan, and the mandated `Planned by:` line — rather than summarising it into a yes/no. This is the one place in the app where a click authorizes something irreversible, and a gate easier to skim than to read is a rubber stamp. Amend collects its free text before sending, and the empty case is disabled locally as well as refused server-side: it would send the planner off to re-draft against no instruction. It renders in EVERY run state, above everything else, because it is the only thing on the page waiting on the reader — including for a run whose journal has not reached the database yet. A "N waiting on a human" badge sits in the header on every page and links to the filtered fleet. A gate nobody notices is a gate that expires, and expiry is the one outcome that throws away planning work already finished and paid for. Cancel is wired in both the full and the not-yet-started views, for the three non-terminal statuses. Driven end-to-end against a real service with a real suspended run: sign in → the badge appears → open the run → the gate renders → amend with text → the ask leaves the needs-you list and the row goes awaiting_gate → pending. Then new-run with a bad ticket (inline under Ticket), with no supervisor (the 503 explained), and valid (201 → the run page, showing the queued state). That last one also turned up two 404s per poll: the timeline and artifact queries fired for a run with no journal. Both are now gated on the run existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The route table now matches what is served, including `GET /gates`, which was not in the original surface — the needs-you list earns its place because an unnoticed gate expires, and expiry is the one outcome that discards finished planning work. Also records the two corrections the FE turned up: a queued run 404ing on its own detail page, and the journal-backed queries polling 404s for a run that has no journal yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 6, corrected for the actual box. The plan said "systemd unit"; on NixOS a
hand-written /etc/systemd/system/care-loopd.service plus `systemctl enable` is
outside the generation — it survives no rebuild, appears in no rollback, and
is invisible to anyone reading configuration.nix to find out what the machine
runs. `deploy/care-loopd.nix` is a normal NixOS module.
Three things a ported unit would get wrong, all of which fail late rather than
loudly:
- `path` must be explicit — there is no /usr/bin. The loop shells out to git
for every worktree, and the opencode SDK launches a BARE `opencode` from
PATH for every judgment spawn (@opencode-ai/sdk cross-spawns it). Both
fail at first use, not at startup.
- `opencode` must come from nixpkgs. The upstream install script drops a
dynamically-linked ELF in ~/.opencode/bin that cannot run on NixOS without
an FHS shim.
- The secrets file must live outside the Nix store. /nix/store is
world-readable, so a token written from a Nix expression is a token
published to every user on the box. The module asserts against a store
path rather than trusting the reader to know that; it also asserts stateDir
is under /var/lib, since StateDirectory is derived from its basename and
the two silently disagreeing is worse than a build error.
`KillMode=process` is the setting that matters most. The default kills the
whole cgroup on restart, aborting every teammate's run and leaving a stale
lockfile and no `run.end` on every deploy — undoing §4's reconciliation
exactly when it is needed. Restarts now stop the supervisor claiming and leave
live children alone, which is what reconcile-at-boot was built for.
Also fixes a real gap for headless running: `derivePaths` hardcoded
`~/Desktop`, a fine guess on a laptop and nonsense on a server whose service
user's home is a state directory. Now flag → `CARE_MAIN_REPO` /
`CARE_WORKTREE_ROOT` → the laptop default, so the unit sets layout once
instead of the supervisor passing two more flags per spawn. These are
properties of the machine, not of a run.
Exposure is LAN now (--host 0.0.0.0, firewall port open, plain HTTP over an
unverified login) with the Tailscale path written down as a three-line change
plus --secure-cookies.
Verified the runbook's bootstrap on an empty directory: reindex creates the
db, serve starts against it. 449 tests green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit wrote the deployment as a 250-line NixOS module, which over-fitted it. Almost everything in there was plain systemd. What is actually deployed is ONE process: `care-loopd serve --supervise` serves the API, serves the built web app from the same origin and port, and runs the supervisor that spawns a `care-loopd run` child per queued run. No second daemon, no worker pool, no queue broker. One command, one env file, one port. So `deploy/care-loopd.service` is now the reference — usable directly on any systemd distro, and readable as documentation of what to set anywhere else (launchd, supervisord, a tmux session while trying it out). `care-loopd.nix` declares the same unit natively, because copying a file into /etc/systemd/system on NixOS puts it outside the generation. Only two settings genuinely differ there, and the trimmed module says so at the top rather than restating systemd: PATH must be built from packages (no /usr/bin, and `opencode` must come from nixpkgs rather than the install script's dynamically-linked ELF), and the secrets file must not be a store path, since /nix/store is world-readable. The README now leads with the shape rather than with a platform, including the one non-obvious consequence: the CHILDREN inherit the service's environment, which is why the unit lists tools the service itself never calls. The service does not run git or spawn opencode; every child does. `KillMode=process` — the most important line in either file — was never NixOS-specific and now reads that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two passes over the loop-service branch, both subtractive. CUT (schema v7) The jsonl log was being diffed against the db on every run.end/run.resume — a standing invariant guarding a single-writer local SQLite file, which bought nothing that VACUUM INTO backups do not, while costing a per-run check, a parity_error column, an API field, and two frontend surfaces. It never fired once: parity_error was NULL across all 9 runs in the live db. Deletes parity.ts, its 12 tests, the check in Journal.append, the column, and the fleet/detail badges. journal.jsonl stays exactly as it was — fsync'd, hash-chained, what reindex rebuilds from and what care-loop-doctor greps. Only the verification layer on top of it is gone. Also drops run_rounds: declared schema-complete for per-round analytics that was never built, with no INSERT, no SELECT, and zero rows anywhere. Re-add it alongside the feature that needs it, when its columns can be chosen against a real query rather than guessed. Both drops migrate in place; migrate() checks column and table presence rather than trusting user_version, so an older db upgrades cleanly. Verified on a copy of the live db: 14 columns, 7 runs / 1351 events intact, integrity_check ok. The two migrate() tests move to run-store.test.ts, where they belong — they exercise the migration hook, not the parity check that was its first customer. COMMENTS Comment density in the branch's own files drops 38% (1474 -> 914 lines; 17% -> 12% overall). Removed: every plan-doc section reference, historical narrative that git already holds, restatements of the signature below them, and paragraphs defending choices against alternatives nobody proposed. Kept, compressed, the facts code cannot express and that would otherwise be silently re-broken: busy_timeout must be per-connection and WAL masks its absence; `prev` must come from the file, since sourcing it from the db broke every reindexed legacy run; rl.question never resolves on an ended stream; the SPA fallback must exclude extensioned paths; server.close waits on idle keep-alive sockets. Where a comment was propping up an unclear expression, the fix is a name: encodeRandom() shared by both run-id encoders, Supervisor#lockOptions, looksLikeAsset(), clientErrorStatus()/isMalformedJson(), SessionStore#touch(). Also moves an orphaned doc comment in app.ts onto runIdParam, which it had drifted away from and stopped describing. 439 tests pass; orchestrator and web both typecheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One conflicting file: ci-round.ts, where care-loop broke runCiRounds into four step functions (4c8f08a) while this branch had rewired its journal for the SQLite cutover (56d7981). Resolved by taking the refactor wholesale and re-applying this branch's three changes onto it — they are orthogonal to the restructuring, and small: - `openRun(o.runDir)` replaces the slug-derived runId and the bare Journal. Since the cutover the journal is DB-backed and keyed by a real ULID, so `${repo}-${branch}` is a display label, not an identity. - `seedJournal` takes `isNew` instead of testing for an empty event list — a DB-backed read does not answer that question the same way. - The seed carries run_id / requested_by / ticket / summary / started_at, the columns `runs` projects. A CI-only run was adopted from a PR rather than planned, so ticket and summary are explicitly null. The eight golden journal streams that arrived with the refactor pass UNCHANGED, which is the useful confirmation: the resolution alters the seed's state fields and not one event in the sequence. Two fixes were needed in the incoming golden test itself, both because it was written where `Journal` was still file-backed: - it needs `useRealStore()`, or the seed never lands and every scenario dies on "cannot project state from an empty journal"; - the trace must use `readReplica()`, not `read()` — the latter queries the DB by run_id, and the literal "x" placeholder matches nothing, so all eight goldens would have compared an empty stream against itself once the store was installed. 456 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Stacked on #4. Base is
care-loop, so this diff is only the 14 commits after 68d472a.Three layers, committed separately so they can be read in order.
DB — SQLite becomes the source of truth
The journal was authoritative; it is now a continuously-verified replica.
Journal.read()queries the store, andappend()writes the DB before the jsonl line, so a crash between the two leaves the DB correct and the replica merely lagging — never the reverse.previs the one field that stays replica-sourced: it is the file's own integrity checksum, not an ordering fact. Sourcing it from the DB broke every reindexed legacy run, because those lines carry a pre-ULIDrun_idwhile reindex backfills a ULID, so re-serializing the row reproduced a line the file never held.Runs get a ULID primary key, minted once and cached in the run dir, with one deterministic backfill so legacy journals self-heal identically on live resume and on
reindex. Parity between replica and DB is checked atrun.endandrun.resumewith deliberately different policies — resume throws,run.endrecords — because atrun.endboth writes have already committed and refusing to finish cannot undo what it found.Later commits add skill artifact bodies as jsonb (schema v3), sessions and users (v4), and the queue (v5).
Two bugs found while verifying against the real 6-run fleet:
started_atwas seeded before the fold and then patched by the very event that seeds it. Invisible to both the parity and reindex checks, since both replay the same pipeline — it only surfaced from an independent fold of the journal.requested_bywas hardcodednullat all four seed sites.BE —
care-loopd serveExpress over
loops.db, DB-only: no route touches a run directory. Ten routes built, the rest specified with the step they land in. Conventions frozen before the frontend existed — envelopes on lists,{error:{code,message}}with real statuses, and no route making an authorization decision.Auth is a cookie session started by claiming a GitHub login. Nothing verifies it — the trust boundary is the network. What it buys is the shape real auth needs: adding OAuth replaces one handler's body. The DB stores a hash of the token, never the token, and logout revokes rather than deletes.
The queue mints
run_idat enqueue soPOST /api/runscan answer synchronously, validates with the loop's own field rules rather than a second copy of them, and queues behind a busy branch rather than rejecting — skipping it, so one busy branch cannot stall the queue head.Backups land with the queue because that is the first data
reindexcannot rebuild.VACUUM INTO, not a file copy, which can catch a torn page or miss the WAL.FE — React dashboard
Vite + React 19 + TanStack Router/Query, at read parity with the vanilla dashboard, which this deletes along with 1,200 lines of hand-rolled HTML.
Themed with Care UI tokens. Its shadcn registry could not be used — every
/r/<name>.jsonendpoint returns the docs SPA's HTML, soshadcn addfails onUnexpected token '<'(verified against the real CLI). The tokens were read off the live docs site instead;ui/primitives.tsxis written against the same token names, so the real components can replace that one file when the registry is fixed.Filter state lives in the URL, so a filtered view is a shareable link. Filters are driven by the server's facet counts, so a dropdown never offers a value returning nothing.
Verification
376/376 tests green,
tscclean in both packages. Exercised live against a copy of the real fleet throughout — 7 runs, 1,351 events, 188 artifacts — including a fullrm loops.db && reindexround trip to confirm it stays lossless.Two bugs were caught only by driving the real app in a browser: unclassed
<a>elements took the browser's default dark blue, near-invisible on the dark panel; and the SPA fallback answered 200-with-HTML for missing assets, which makes the browser execute a document as JavaScript.Notes for review
PLAN-sqlite-run-store.mdandPLAN-loop-service.mdare force-added pastcare-loop/.gitignore'sPLAN-*, following the precedent ofPLAN-orchestrator-architecture.md. They are 1,476 of the added lines; skip them for code review.🤖 Generated with Claude Code