Rebuild Piggy's interface, and give the demo book a business to describe
CI / verify (push) Successful in 4m57s
CI / publish (push) Has been skipped

Piggy answered in raw markdown, threw away every tool result it streamed,
and fought the reader's scroll on every token. The three surfaces that
made it worth having — what it read, how it reasoned, what it cost — were
all on the wire and none of them reached the screen.

The transcript is now composed of five parts under components/piggy:
answers render through streamdown, the container sticks to the bottom
without pinning the reader there, tool steps say what they read and link
to the record, and each turn carries its model and token count. Three
lifecycle bugs went with them: Stop left a permanent spinner, a truncated
stream was indistinguishable from thinking, and a failed send destroyed
the message it failed to send.

Underneath, the inference path grew timeouts, jittered retries on 429 and
5xx, tolerance of the malformed frames a 30B model emits, and an
agent_runs row per turn so chat spend is observable. The system prompt now
states that a field ending in Cents is cents — without it nemotron renders
costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on
the most scrutinised number in the room.

The demo book was arithmetically incoherent: every deal's value
contradicted its own allocation revenue by up to 3.6x, nothing had ever
closed, no customer had any paper, and the marketplace was empty. Deal
value is now derived from the allocation, the book clears 5.3% across five
blocks with one deliberately underwater, and the renewal, compliance and
agent-provenance machinery finally has rows to act on. A --clear that
deleted every obligation, SLA term and capacity request in the database
regardless of origin is scoped to the demo's own ids.

Around that: accounts have a detail page, ⌘K searches the book, Settings
can mint the API keys it always claimed to, and deploy.sh actually ships
the agent instead of silently skipping its compose profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 00:33:41 -07:00
parent 76e3caa1cb
commit 99d165b5e5
81 changed files with 21780 additions and 2250 deletions
+122 -20
View File
@@ -1,8 +1,8 @@
# Deploying PIG
PIG is an API/web container, a private Piggy worker/chat container and Postgres,
behind any reverse proxy that terminates TLS. Nothing here is specific to a
particular host.
PIG is an API/web container, Postgres, and — when you ask for it — a private
Piggy worker/chat container, behind any reverse proxy that terminates TLS.
Nothing here is specific to a particular host.
## 1. DNS
@@ -28,11 +28,11 @@ The values that must be set for a production start:
| `PIG_PUBLIC_URL` | The single origin the app is served from |
| `SUPABASE_URL` / `SUPABASE_ANON_KEY` | Authentication. The app refuses to start in production without a Supabase URL, because it would otherwise serve the whole CRM unauthenticated |
| `PIG_ADMIN_EMAILS` | Who may administer. **Every address here must already have an account** — an unregistered address listed as an admin is a standing offer of admin rights to whoever claims it first |
| `PIGGY_INFERENCE_API_KEY` | Model credential held only by the Piggy process |
| `PIGGY_INTERNAL_TOKEN` | A generated 32+ character bearer token shared only by API and Piggy |
| `PIG_SETTINGS_ENCRYPTION_KEY` | Base64-encoded 32 bytes (`openssl rand -base64 32`). Only needed for the Notion and Google OAuth secrets typed into the admin UI, which the API refuses to store without it |
Optional: `PRIME_API_KEY` (scope it to `Availability → Read` only),
`PIGGY_ENABLED`, and the Slack and Buzz credentials.
Optional: `PRIME_API_KEY` (scope it to `Availability → Read` only), the Slack
and Buzz credentials, and the whole Piggy block — the agent is off unless you
[turn it on](#turning-piggy-on).
Piggy listens on `piggy:8931` inside the Compose network. The port is exposed to
other containers but never published to the host, and Caddy must not route to
@@ -57,11 +57,10 @@ have a container restarting every few seconds and no way in. `run --rm
dependencies twice. This is what commit d4d7095 changed and it is what
`scripts/deploy.sh` does.
When Piggy is enabled, start its private profile as well:
```bash
docker compose -p pig --profile piggy up -d --build
```
That starts the CRM alone: the agent is behind a Compose profile and stays
down. To run it, see [Turning Piggy on](#turning-piggy-on) — set the switch in
`.env` rather than starting the container by hand, because a hand-started Piggy
is one no later deploy knows to upgrade.
Use `-p pig`. A compose project that shares a name with a neighbouring stack
will adopt its volumes, which is a memorable way to lose a database.
@@ -111,6 +110,100 @@ satisfies every check that only asks whether something responded.
`scripts/deploy.sh` now asserts the body is non-empty and contains the
application's mount point for this reason.
## Turning Piggy on
Piggy is the in-app agent: a queue worker and a private chat server, one image
running a second command. It is **off by default** and nothing about it is
configurable from the admin UI — every value below is read once, when the
container boots.
Three keys in `.env`, and all three are needed:
```bash
PIGGY_ENABLED=true
PIGGY_INFERENCE_API_KEY=<an inference key from app.primeintellect.ai>
PIGGY_INTERNAL_TOKEN=<openssl rand -hex 32>
```
The fourth thing the API needs, `PIGGY_INTERNAL_URL`, is already set to
`http://piggy:8931` by `docker-compose.yml`. Set it in `.env` only for a Piggy
running outside Compose.
Then deploy as usual:
```bash
bash scripts/deploy.sh
```
**Do not start the container by hand.** The service carries
`profiles: ['piggy']`, and compose skips a profile-gated service *silently*:
without the profile, `pull`, `build` and `up` behave as though it were not in
the file, with no warning and a zero exit.
`deploy.sh` reads `PIGGY_ENABLED` from `.env` and adds `--profile piggy`
to the pull, the build, the `up` **and the rollback**, so the agent moves with
the app. A Piggy started once with `--profile piggy up -d` and then forgotten is
not covered by any of that: it keeps running the image of the day it was
started, against a schema several migrations newer, which is the failure the
comment at the top of `docker-compose.yml` is about. `deploy.sh` therefore
compares the piggy container's image ID with the release's and rolls back if
they differ.
`scripts/autodeploy.sh` compares both containers' digests for the same reason.
Without that, an old Piggy is invisible to the poller: the app matches the
newest tag, the poller says "up to date" every five minutes, and the agent runs
last month's code indefinitely.
### A missing API key crash-loops the worker
`PIGGY_INFERENCE_API_KEY` is required by `apps/piggy/src/config.ts`. Without it
the process exits at boot with `Invalid Piggy configuration:
PIGGY_INFERENCE_API_KEY is required.`, and `restart: unless-stopped` starts it
again — so the symptom is a container restarting every few seconds, not an error
anyone sees in the CRM. `PIGGY_INTERNAL_TOKEN` shorter than 32 characters fails
the same way. `deploy.sh` catches both: it waits for the container to report
healthy and exits 3 if it does not, deliberately **without** rolling back,
because the previous image reads the same `.env` and would fail identically.
### Health
The piggy container has its own healthcheck, against the chat server's
unauthenticated `GET /internal/health`:
```bash
docker compose -p pig --profile piggy ps
# NAME STATUS
# pig-piggy-1 Up 2 minutes (healthy)
docker compose -p pig --profile piggy exec piggy \
node -e "fetch('http://127.0.0.1:8931/internal/health').then(r=>r.text()).then(console.log)"
# {"ok":true,"service":"piggy-chat","model":"nvidia/nemotron-3-nano-30b-a3b"}
```
It needs its own because the image's `HEALTHCHECK` asks for
`127.0.0.1:8920/api/health` — the API's port, which this container does not
serve. Inherited unchanged, Piggy reported `unhealthy` for ever while working
perfectly.
There is no published port and there must not be one. The listener is reachable
only from inside the Compose network, the API authenticates the user before
forwarding anything, and the bearer token goes in a header — never a query
string, where a proxy or an access log would keep it.
### Tuning
Everything else has a working default and exists to be lowered:
`PIGGY_MAX_TOKENS` (per queued task), `PIGGY_CHAT_MAX_TOKENS` (per interactive
answer), `PIGGY_MAX_TURNS`, `PIGGY_POLL_INTERVAL_MS`, `PIGGY_LEASE_SECONDS`,
`PIGGY_REASONING_EFFORT` and the two `PIGGY_PRICE_*_CENTS_PER_MTOK` values that
make the cost recorded against each run exact. `.env.example` lists them
commented out, and that is not decoration: an empty `PIGGY_MAX_TOKENS=` line is
passed to the container as the empty string, which coerces to 0 and refuses to
start. Leave a key commented to get its default; do not leave it blank.
Changing any of them means restarting the container — `bash scripts/deploy.sh`,
or `docker compose -p pig --profile piggy up -d piggy` if the release is
otherwise unchanged.
## Learn videos
PIG hosts its own Learn videos. There is no video service to configure, no
@@ -219,8 +312,9 @@ bash scripts/deploy.sh
```
It fetches `origin/main`, dumps the database, builds, migrates from a one-off
container, starts the app, and refuses to call the deploy done until the health
endpoint, the unauthenticated-401 gate and the public origin all agree.
container, starts the app and Piggy, when `PIGGY_ENABLED` is on — and refuses
to call the deploy done until the health endpoint, the unauthenticated-401 gate,
the piggy container's image and health, and the public origin all agree.
### By tag — the normal path
@@ -256,16 +350,19 @@ without it and compose interpolates the `pig:local` fallback from
if it had not died, the migrate, the `up` and the rollback would all have run
the stale local image while the log named the release tag. Every compose
invocation in `deploy.sh` therefore goes through the `dc()` wrapper, which uses
`sudo env PIG_IMAGE=… docker compose …`; `sudo -E` and bare `sudo VAR=val` are
both refused by that same policy. Anything new that shells out to compose must
use the wrapper.
`sudo env PIG_IMAGE=… COMPOSE_PROFILES=… docker compose …`; `sudo -E` and bare
`sudo VAR=val` are both refused by that same policy. `COMPOSE_PROFILES` travels
the same way and for a sharper reason: dropped, compose does not error, it just
leaves Piggy out of whatever you asked for. Anything new that shells out to
compose must use the wrapper.
### Rollback
`scripts/deploy.sh` records the image the app container was running before it
replaces it. If the health check, the unauthenticated-401 gate, or the
public-origin marker check fails, it re-tags that image, restarts the app on it,
reports whether the restored version is healthy, and exits non-zero. Previously
replaces it. If the health check, the unauthenticated-401 gate, the piggy image
assertion or the public-origin marker check fails, it re-tags that image,
restarts the app — and Piggy with it, since they are one image running two
commands — reports whether the restored version is healthy, and exits non-zero. Previously
those exits left the broken release live, which was fine when a human was
watching the terminal and an outage when the poller ran at 04:00.
@@ -291,6 +388,11 @@ Two things it deliberately does **not** do:
- **It does not roll back when the public origin answers with an EMPTY body.**
Something terminated TLS and replied, so the fault is the proxy — see `bind`
below — and the previous image would fail the same check. Exit 3.
- **It does not roll back when Piggy is enabled but does not come up.** Same
reasoning: the previous image reads the same `.env`, so restoring it churns a
healthy CRM without fixing the agent. Exit 3, and the log names
`PIGGY_INFERENCE_API_KEY` because that is nearly always the cause. This is the
one exit-3 case where the site itself is fine.
It *does* roll back when the origin answers with a **non-empty** body that lacks
the marker. A proxy fault cannot serve a wrong-but-populated page for this