Skip to content

Commit b8eb12c

Browse files
DylanMerigaudclaude
andcommitted
Merge feat/pull-erp: persist runs as an audit trail, bounded by a nightly reset
Bounded persistence replacing the stateless design: every run writes an append-only agent_runs audit row, a "Recent runs" panel lists + replays them, and a daily Vercel Cron resets Postgres so the demo stays pristine. Postgres only — never touches the ERP/HRIS sandboxes. HITL resume stays replay-based. Verified live; full gate green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents c84352c + d937dea commit b8eb12c

18 files changed

Lines changed: 834 additions & 221 deletions

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,12 @@ QBO_REALM_ID=
6565
# as-is and skip the refresh exchange — handy for a one-off capture from the
6666
# Playground without touching the refresh token.
6767
# QBO_ACCESS_TOKEN=
68+
69+
# ─── Nightly reset cron (optional) ─────────────────────────────────────────
70+
# Every run is persisted as an append-only audit row (agent_runs); a daily Vercel
71+
# Cron (vercel.json) hits /api/reset to truncate + reseed Postgres so the demo
72+
# returns to a pristine queue each morning. The route is guarded by this shared
73+
# secret — Vercel injects it as `Authorization: Bearer $CRON_SECRET`; anything else
74+
# gets 401. Unset → the route refuses all callers (nothing resets). Postgres only;
75+
# the reset never touches the QuickBooks/BambooHR sandboxes.
76+
CRON_SECRET=

AGENT_BRIEF.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,15 @@ The "complete loop" is built and on `main`. Key facts a fresh session must know:
5353
bounded loop (`lib/workflow-edit-agent.ts`) over the Claude SDK — NOT a Mastra
5454
Agent (see its header comment for why). Mastra owns the P2P pipeline
5555
(`src/mastra/workflows/p2p.ts`) and the exception-investigator agent.
56-
- **Stateless by design.** The run never writes to the DB. Keep it that way unless
57-
told otherwise.
56+
- **Bounded persistence, not stateless.** The run writes ONE thing: an append-only
57+
`agent_runs` audit row at the end (`db/runs.ts`), read back by the Recent runs
58+
panel + replay. It NEVER writes the document tables or the ERP/HRIS, so a run
59+
can't change a future run's verdict (the matcher always reads the pristine seed).
60+
A nightly Vercel Cron (`/api/reset`, `vercel.json`) truncates+reseeds Postgres so
61+
the demo stays pristine; the reset touches Postgres only, never the sandboxes.
62+
HITL resume stays REPLAY-based (recompute the deterministic prefix from the
63+
decisions), NOT Mastra snapshot/suspend — keep it that way (the snapshot path has
64+
a known Postgres-bloat footgun). Don't reintroduce writes to the document tables.
5865
- **The recorded HRIS fixture is SEED-BUILT**, not a live capture
5966
(`scripts/build-recorded-fixture.ts``pnpm fixture:build`). recorded == live ==
6067
the 13-person "LedgerLoop Demo" org. Don't reintroduce a real-capture claim.

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,13 @@ The manager gate fires on any exception **or** a clean bill over a floor ($1,000
103103

104104
**Streaming, typed end to end.** The `run` procedure is an **oRPC event iterator** (a typed async generator of `TraceEvent | StreamDone`); the client consumes it with `for await`, no manual reader or cast. A small adapter ([`lib/trace.ts`](lib/trace.ts)) maps Mastra's raw `run.stream()` chunks to the stable `TraceEvent` vocabulary so the UI depends on ours, not Mastra's internals, and a junk chunk is dropped rather than crashing the stream.
105105

106-
### Stateless by design
106+
### Bounded persistence + a nightly reset
107107

108-
The seeded data is read-only. "Run pipeline" executes server-side, streams the trace, and **forgets** — so the 50th visitor sees the same pristine state as the 1st. (The `agent_runs` table is modelled as the canonical persisted shape of a run but intentionally left empty.)
108+
Every run is persisted as an append-only **audit row** ([`agent_runs`](db/schema.ts)) — its verdict, outcome, and the full trace — and the dashboard's **Recent runs** panel lists them, each one replayable (click → the stored trace re-renders with no model call, zero tokens). That's the audit trail an AP buyer asks for first.
109+
110+
The persistence is **bounded**: a daily [Vercel Cron](vercel.json) hits [`/api/reset`](app/api/reset/route.ts) (guarded by `CRON_SECRET`), which truncates + reseeds Postgres — so the demo returns to a pristine queue each morning, the 1st visitor's view restored for the next. The reset touches **Postgres only**; it never calls the QuickBooks/BambooHR sandboxes (frozen fixtures the pipeline reads, never writes), so it can't fail on a rotated token or desync an external system.
111+
112+
Crucially, a saved run **can't change a future run's verdict**: the app writes only `agent_runs`, never the document tables or the ERP/HRIS, so the matcher always reads the pristine seed. Persisting the audit trail and keeping every run deterministic are not in tension. The human-in-the-loop pause/resume stays **replay-based** (the run recomputes the deterministic prefix from the decisions) — persistence is for the audit log, not the resume, which keeps it off the costly Mastra-snapshot path.
109113

110114
### Project layout
111115

@@ -177,6 +181,7 @@ pnpm dev # http://localhost:3000
177181
| `BAMBOO_HR_API_KEY` + `BAMBOO_HR_SUBDOMAIN` | optional | Live BambooHR. **Without them onboarding replays the committed real fixture** — the demo and CI work with no key. |
178182
| `QBO_CLIENT_ID` + `QBO_CLIENT_SECRET` + `QBO_REFRESH_TOKEN` + `QBO_REALM_ID` | optional | Live QuickBooks (ERP pull). **Without all four the pipeline replays the committed real fixture** — demo and CI work with no key. |
179183
| `UPSTASH_*` / `KV_REST_API_*` | optional | Per-IP rate limiting; fails open without it |
184+
| `CRON_SECRET` | optional | Guards the nightly `/api/reset` cron (Vercel injects it as a bearer token). Unset → the reset route refuses all callers. |
180185

181186
> **Set a spend cap on the Anthropic key** — the deployed demo is public and the buttons call the model.
182187
@@ -186,11 +191,11 @@ pnpm dev # http://localhost:3000
186191

187192
## What's next
188193

189-
A stateless demo; the decision logic is pure, typed, and unit-tested, and the read-side integrations (HRIS, ERP pull) are already real-or-replayed. Production is additive, not a rewrite:
194+
The decision logic is pure, typed, and unit-tested; the read-side integrations (HRIS, ERP pull) are already real-or-replayed; and runs are persisted as an audit trail bounded by a nightly reset. Production is additive, not a rewrite:
190195

191196
- the ERP **pull** is real (QuickBooks); swap the **post**-side stub (`fakeErp`) and the Slack/Jira integration stubs for real adapters of the same interfaces,
192197
- live BambooHR + QuickBooks (both adapters + captured fixtures already exist; keys unlock the live path) and a second HRIS / ERP behind the same `HrisAdapter` / `PoSourceAdapter`,
193-
- add persistence and an audit trail (the `agent_runs` table is shaped for it) so a workflow and a paused run survive a refresh,
198+
- the audit trail persists today (append-only `agent_runs`, replayable, reset nightly); drop the reset and add per-tenant scoping for a multi-client deployment, and persist the paused-run snapshot if you want resume to survive a server restart (today it's recomputed from the decisions),
194199
- wire real approver identity to the per-step gates,
195200
- accept real uploaded PDFs at intake.
196201

app/api/reset/route.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getDb } from "@/db/client";
2+
import { resetAndReseed } from "@/db/reset";
3+
import { env } from "@/lib/env";
4+
import { log } from "@/lib/logger";
5+
6+
/**
7+
* GET /api/reset — truncate + reseed Postgres back to the pristine demo dataset.
8+
*
9+
* Invoked once a day by a Vercel Cron (see vercel.json). This is what makes the
10+
* persistence safe: every run writes an append-only `agent_runs` audit row, and
11+
* this clears them nightly so the public demo returns to a clean queue — the
12+
* "pristine for the next visitor" property, kept while still having an audit trail.
13+
*
14+
* Scope: Postgres ONLY. It never touches the QuickBooks / BambooHR sandboxes
15+
* (those are frozen fixtures the pipeline reads, never writes), so the reset can't
16+
* fail on a rotated QBO token and can't desync an external system.
17+
*
18+
* Guarded by CRON_SECRET: Vercel Cron sends `Authorization: Bearer $CRON_SECRET`.
19+
* Anything without the matching secret gets 401 — so this isn't a public truncate
20+
* button. If CRON_SECRET is unset, the route refuses all callers.
21+
*
22+
* Node runtime — it does real Postgres writes via the postgres-js driver.
23+
*/
24+
export const runtime = "nodejs";
25+
26+
export const GET = async (request: Request): Promise<Response> => {
27+
const secret = env.CRON_SECRET;
28+
const auth = request.headers.get("authorization");
29+
if (!secret || auth !== `Bearer ${secret}`) {
30+
return new Response("Unauthorized", { status: 401 });
31+
}
32+
33+
try {
34+
const counts = await resetAndReseed(getDb());
35+
log.info("nightly reset complete", counts);
36+
return Response.json({ ok: true, ...counts });
37+
} catch (err) {
38+
log.error("nightly reset failed", {
39+
error: err instanceof Error ? err.message : String(err),
40+
});
41+
return new Response("Reset failed.", { status: 500 });
42+
}
43+
};

0 commit comments

Comments
 (0)