Add a single-session AI flow alongside program generation: describe a
workout in plain words and get a ready-to-log workout back — exercises
with suggested weights, target reps, and set counts grounded in the
user's recent history. The suggestion can be inline-edited or refined
by sending a follow-up instruction back to the model, then "Use this
workout" pre-fills the normal New Workout form (nothing persists until
the user saves through the regular path).
Why reuse, not fork: the existing program-generation spine (detached
background runner, SSE streaming, lenient-JSON preview, 5 providers,
history context, library name->id mapping) already does the hard parts.
A new AIGeneration.kind discriminant ("program" | "workout", default
"program" via boot-time guarded ALTER) selects the parser and keeps the
ephemeral workout rows out of the program-shaped AI history. Refine is a
fresh generation seeded with the prior suggestion (validated through the
same schema before it re-enters the prompt).
Hand-off is sessionStorage -> /main/workouts/new?from=ai -> AiWorkoutPrefill,
which expands each suggestion into N sets and maps effort by cardio-ness
(Gear for cardio, RPE for strength). EditWorkoutData.id is now optional so
the prefill CREATEs rather than PATCHing a nonexistent id. The AI suggests
each weight in that exercise's effective logging unit (the library JSON
carries a per-exercise unit) so the stored number and unit never diverge.
Built + sideloaded to immense-voyage.local as 1.2.0:6; on-box ALTER and
non-root launch confirmed via start-cli. tsc clean (app + packaging),
251 tests pass, next build + s9pk build succeed.
17 KiB
AGENTS.md — Proof of Work
Self-hosted multi-user workout logger (Next.js app) packaged as a StartOS 0.4 s9pk, published to a private Start9 registry.
Inbox check: At session start, if
~/Projects/standards/INBOX.mdexists, scan it for items tagged(proof-of-work)and surface them before proposing next steps; triage with/triage.
Stack (versions that matter)
- Next.js 15 (App Router, server components + server actions, SSE streaming) — dynamic request APIs are async (see Conventions)
- React 19, TypeScript 5, TailwindCSS 3
- Prisma 5 ORM over SQLite (WAL mode; tuned at boot)
- bcrypt (native — NOT bcryptjs), zod 3 for validation
- Vitest 4 for tests
- @start9labs/start-sdk for the 0.4 packaging layer
- Node >= 20 to build
Layout (two projects in one repo)
proof-of-work/ ← the Next.js app (THIS is where you run npm)
app/ ← App Router routes; app/api/** = route handlers
app/main/ ← authed UI; navigation.tsx = sidebar
components/ ← React components (workouts/, ai/, settings/)
lib/ai/ ← AI subsystem (see below)
lib/ai/providers/ ← claude.ts openai.ts gemini.ts ollama.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 5 registered providers)
prisma/schema.prisma ← schema (mirror; real DB migrates via entrypoint ALTERs)
prisma/*.seed.json ← curated exercise library + AI templates (reconciled each boot)
tests/ ← Vitest specs (ai-*.test.ts, routes-*.test.ts, ...)
start9/0.4/ ← StartOS packaging wrapper
docker_entrypoint.sh ← boot: first-boot seed, additive ALTERs, library reconcile
Makefile / s9pk.mk ← s9pk build (ARCHES := x86)
startos/versions/ ← one file per ExVer version + index.ts (the version graph)
~/.proof-of-work/ ← publish.sh + unpublish.sh (NOT in repo; self-hosted registry)
workout-planner/ is scratch (only logs/) — ignore. start9/0.4/*.s9pk are build artifacts.
Commands
Run app commands from proof-of-work/ (running tsc/vitest/next from repo root fails — wrong cwd):
cd proof-of-work
npm run dev # local dev server
npm run build # next build (run this to catch route/type errors before shipping)
npm run lint # next lint
npm test # vitest run (full suite)
npx vitest run tests/ai-pricing.test.ts # single file
npx vitest run -t "findPrice" # single test by name
npx tsc --noEmit # typecheck only
npx prisma generate # REQUIRED after editing schema.prisma (else TS can't see new fields)
npm run db:seed # seed InstanceSettings singleton (NO users/library — see below)
npm run create-admin -- you@example.com pw "Name" # create first admin locally (--force resets existing)
Build/sideload the s9pk (from start9/0.4/): make x86 then make install. Targets come from s9pk.mk (the wrapper Makefile just sets ARCHES := x86):
make x86— build the x86 s9pk.make install— sideload the newest local.s9pkto the StartOS box athost:in~/.startos/config.yaml(viastart-cli package install).make publish— upload every.s9pkto the S3 bucket (s9pk-s3base:) and index it onregistry:from~/.startos/config.yaml(vias3cmd+start-cli s9pk publish). Distinct from~/.proof-of-work/publish.shbelow.make clean— remove build artifacts.
Both install and publish read host/registry config from ~/.startos/config.yaml, which is not in the repo — verify against the live setup, not from a checkout.
Verify on-box state read-only via start-cli (the same host config) instead of punting to the StartOS web UI — used this way to confirm the 1.2.0:4/:5 ALTERs and a persisted set:
start-cli package installed-version proof-of-work— what version the box actually runs.start-cli package logs proof-of-work --limit N | grep -iE "adding missing column|as nextjs|error"— confirms boot ALTERs ran (each logs once) and the non-root launch.start-cli package attach proof-of-work -- sqlite3 /data/app.db "<SELECT …>"— read-only query of the live app DB (e.g. confirm a new column exists / a value persisted). SELECT only; never mutate prod data this way.
Canonical publish path for this project: ~/.proof-of-work/publish.sh (builds, uploads to FileBrowser, registers) — separate from the generic make publish. Unpublish: ~/.proof-of-work/unpublish.sh.
npm run db:seed (= tsx prisma/seed.ts) seeds only the InstanceSettings singleton — deliberately NO users and NO curated library (the library attaches at admin-creation and via the boot-time ensure). It is live, not dead — invoked at Docker image-build time (start9/0.4/Dockerfile) and the local-dev first-run path. npm run create-admin (= tsx scripts/create-admin.ts) is the local-dev equivalent of the StartOS "Set admin credentials" action: creates the first admin + seeds their library; --force to reset/promote an existing account. Runtime first-boot/upgrade seeding is handled separately by docker_entrypoint.sh.
Conventions
- Versioning is ExVer:
1.1.0:4(note the colon). Every release = a newstart9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.tsfile, imported intoversions/index.tsand promoted tocurrent(previouscurrentmoves intoother[]). - Bump the version BEFORE building the s9pk — Start9 0.4 won't recognize a rebuild as an update otherwise.
- Schema changes are additive ALTERs in
docker_entrypoint.sh, guarded byPRAGMA table_infochecks. Keepschema.prismain sync as the mirror, but the entrypoint is what migrates live/data. Never write a destructive migration. - Adding a first-class numeric set metric (precedent:
watts, 1.2.0:4): mirrorcaloriesend-to-end —schema.prismacolumn +prisma generate; guarded additiveALTERindocker_entrypoint.sh; zod field + insert in all 5 set-write paths (workoutsPOST,workouts/[id]PATCH,workouts/[id]/sets,workouts/import/save,me/import);SetRow.tsx(show*/state/emitUpdate/buildSummary/firstField/input) +WorkoutForm.tsx(set interfaces,buildPayload,handleUpdateSet,initial*prop, has-data checks); read summary inapp/main/workouts/[id]/page.tsx+ edit mapping inapp/main/workouts/new/page.tsx; CSV export/parse +page-csvpayload; field-option label lists (lib/exerciseOptions.ts,app/main/exercises/[id]/page.tsx,ExercisePicker.tsx). TheinputFieldstoken == the column name; the human label lives in those option lists (tokenwatts→ "Avg. watts").me/exportrides the 1:1 Prisma dump automatically. Add a round-trip test intests/routes-crud.test.ts. - Logged-set effort is Gear or RPE, by cardio-ness (1.2.0:5): the effort select is always shown (not an
inputFieldstoken). Cardio exercises log breathing Gear (SetLog.gear, 1–5); everything else logs RPE (SetLog.rpe, 6–10). The switch isisCardioExercise(exercise)(lib/exerciseOptions.ts):type === "cardio"ORmuscleGroupscontains "cardio".SetRowtakes anisCardioprop (fromWorkoutForm) and renders one; both are always emitted (the hidden one stays empty). Distinct from program/AI target-RPE (ProgramExercise.rpe), which is unrelated and unaffected. - Commit subject =
vX.Y.Z:N — short summary, imperative, body explains the why. - Git remote is self-hosted (a private Start9 registry + a FileBrowser artifact host), NOT GitHub. The actual registry/file-host URLs are constants in
~/.proof-of-work/{publish,unpublish}.sh; FileBrowser creds live in~/.keysat/filebrowser.env(outside the repo, gitignored). Default branch ismaster. - Authorization tiers: whole-instance routes (
settings/{export,import}-db) are admin-only (!user.isAdmin → 403); per-user data routes scope byuser.id. Custom-URL AI providers (Ollama, OpenAI-compatible — anything withrequiresBaseUrl) are admin-only (SSRF surface); fixed-URL cloud providers (claude/openai/gemini) stay per-user. Gate the server route AND hide the control in the UI. - Cross-user id ownership: any route writing
SetLogs orProgramExercises from a client-suppliedexerciseIdmust validate it viafindUnownedExerciseIds(userId, ids)(lib/exerciseOwnership.ts) →400 { error, details: bad }. Unknown vs. foreign ids are deliberately indistinguishable (no existence oracle). Applied to all workout-write routes (create/PATCH/add-sets/import-save) + both program routes. (Server-derived ids, e.g. program-daystart, are already owned — no check needed.) - Login must not leak account existence: both login paths (
app/auth/login/actions.ts,app/api/auth/route.ts) callverifyPasswordOrDummy(lib/auth.ts) so an unknown email spends the same bcrypt as a real one. Never reintroduce an earlyif (!user) returnbefore the compare — that's the timing oracle. - Malformed JSON body must return 400, not 500. Routes whose catch maps
instanceof z.ZodError → 400parse viareadJsonBody(request)(lib/http.ts— throws aZodErroron bad JSON, so the existing branch handles it with no catch change).safeParse-style routes (me/import,admin/signups) wraprequest.json()in an explicittry/catch → 400. (AI/admin routes using.catch(() => ({}))are a third, pre-existing pattern — unify if you touch them.) - Next 15 dynamic APIs are async —
awaitthem. Route-handler contextparams, page/layoutparams+searchParams, andcookies()/headers()are all Promises. Established idiom (keeps handler bodies untouched):[id]routes takecontext: { params: Promise<{…}> }thenconst params = await context.params; server pages takepropsthenconst params = await props.params/const searchParams = await props.searchParams. Route tests passparams: Promise.resolve({…}). All routes are dynamic, so the Next 15 "uncached by default" change is a no-op here. - The container runs the Node server as non-root.
docker_entrypoint.shruns as root only to prep/data(seed, ALTERs, library reconcile), thenchown -R nextjs:nodejs "$DATA_DIR"andexec su-exec nextjs:nodejs node /app/server.js(su-exec added in the Dockerfile runner stage). Any new entrypoint step that needs root must run before that final line. - Auth forms retry their server action once on a transport failure:
LoginForm/SignupFormwrap the action inretryOnTransportError(lib/retryAction.ts). iOS Safari drops the first POST on a stale keep-alive socket (NSURLErrorNetworkConnectionLost, "The network connection was lost") → the client catch showed "An unexpected error occurred". Retry only on a thrown error; a returned{ error }is a real result and passes through. - Tests live in
proof-of-work/tests/; mock server-action deps withvi.hoisted()+vi.mock. Route tests run against a real temp SQLite DB (tests/helpers/db.ts) withgetCurrentUsermocked. - Before editing the AI subsystem (
proof-of-work/lib/ai/**or the generate/generations routes), readdocs/guides/ai-subsystem.md— provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.
Always
- Run
npx prisma generateafter anyschema.prismaedit, thennpx tsc --noEmit. - Run
npm testANDnpm run buildbefore shipping a version. - Add the boot-time
ALTER TABLE(with an existence guard) for any new column, indocker_entrypoint.sh. - Treat API keys / secrets as plaintext in
/dataBY DESIGN (threat model: the operator owns/data). Reference env-var names (DATABASE_URL, etc.); never hardcode values. - Keep migrations idempotent and additive; data already on a user's server must survive upgrades.
- Verify the published file actually changed (size / 404 / Last-Modified) after publish.sh.
Never
- Never add
Co-Authored-By/ "Generated with" trailers to commits — the user authors commits solo. (This was done wrong in earlier commits; do not repeat.) - Never reintroduce nonce-based CSP — it broke first paint. Use the static
'unsafe-inline'CSP innext.config.js. - Never run app commands from the repo root — always
cd proof-of-workfirst. - Never export non-HTTP-method symbols from a
route.ts— Next.js rejects the build (helpers go inlib/, e.g.lib/ai/activateConfig.ts). - Never commit
app.db,*.bak, or any user data — they're gitignored; double-checkgit statusbeforegit add. - Never click Uninstall on a StartOS package during a data cutover — it destroys the volume; use Stop.
- Never assume GitHub — don't add a GitHub remote or push there.
Current state
Latest version is 1.2.0:6 — AI "generate today's workout": a new AI flow alongside program generation. Describe one session in plain words → a streamed, ready-to-log workout (exercises + suggested weights/reps/set-counts grounded in 90-day history) → inline-edit + a Refine box that round-trips changes back to the LLM → Use this workout pre-fills the normal New Workout form (nothing persists until you save). Reuses the whole generation spine (detached runner / SSE / lenient-JSON / 5 providers / historyContext) via a new AIGeneration.kind discriminant ("program" | "workout", default "program"); the runner picks the parser by kind and stores JSON in the reused parsedProgram column. Workout rows are ephemeral (the saved Workout is the durable record) so they're filtered out of the program-shaped AI History (kind:'program'). Refine = a fresh generation seeded with the prior suggestion JSON (validated via aiWorkoutSchema → REVISION mode in workoutPrompt.ts). Hand-off is sessionStorage → /main/workouts/new?from=ai → AiWorkoutPrefill (workoutDraft.ts::buildPrefillExercises: expands to N sets, cardio→Gear / strength→RPE via isCardioExercise, drops unmapped ids). EditWorkoutData.id is now optional so the prefill creates (not PATCHes). AI suggests each weight in that exercise's effective unit (library JSON carries per-exercise unit = defaultWeightUnit || "lbs", matching what WorkoutForm.buildPayload stores). New AIGeneration.kind column via boot-time guarded ALTER. New files: lib/ai/workoutSchema.ts, workoutPrompt.ts, workoutDraft.ts, api/ai/generate-workout/route.ts, components/ai/GenerateWorkoutClient.tsx, components/workouts/AiWorkoutPrefill.tsx, app/main/ai/generate-workout/page.tsx. Built + sideloaded (immense-voyage.local, 2026-06-19, master) as proof-of-work_x86_64.s9pk (80M). Verified: tsc clean (app + packaging), lint clean (pre-existing warnings only), 251 tests pass (incl. parseAIWorkout, buildPrefillExercises gear/RPE mapping, generate-workout route auth/validation), next build succeeds. Registry empty, publishing parked (sideload-only via make install). See docs/guides/ai-subsystem.md → "Two generation kinds".
Confirmed on-box (2026-06-19, via start-cli): box runs 1.2.0:6; entrypoint logged adding AIGeneration.kind (default 'program') once, then launched as nextjs with no errors (clears the long-standing non-root check); read-only SELECT confirms the AIGeneration.kind column exists and the existing generation row backfilled to program. Recent prior ships (1.2.0 line): 1.2.0:5 Gear replaces RPE for cardio; 1.2.0:4 watts as first-class set field; 1.2.0:3 P3 hardening (login timing oracle + exerciseId ownership).
No on-box checks pending. Known bug (tracked in ROADMAP.md → Known bugs): the 1.2.0:2 Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (-1005 → client delayed-retry; 502/503 → Node keep-alive tuning).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (5 providers, multi-config, background generation, single-workout generation + refine, history detail, cost/duration, Ollama auto-detect, infinite-scroll exercise history).
Next steps (priority order):
- Finish the P3 hardening batch (
ROADMAP.md→ Security & hardening — timing oracle + exerciseId ownership now DONE): CSPunsafe-eval,/api/healthinfo disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (try{json}catch{→{}}) inprograms/[id]/days/[dayId]/start. - Tiered AI prompt formatting (
ROADMAP.md→ AI quality). - (Later) Next 15→16 when ready —
next lintdeprecated in 15.5 (removed in 16) + Next 16 breaking changes; its own tested bump.
Open/parked: rate-limit per-IP correctness depends on the StartOS proxy forwarding real client IPs (unverified on the box). publish.sh Step-3 registry no-op (parked w/ publishing). Community-registry 4 blockers (ROADMAP.md → Packaging).
Git remote: origin → self-hosted Gitea at ssh://git@immense-voyage.local:59916/grant/proof-of-work.git; master tracks it. (~/.proof-of-work/{publish,unpublish}.sh registry/FileBrowser hosts are separate from this code remote.)