Files
proof-of-work/AGENTS.md
T
Keysat 2b0abad68e
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run
v1.2.0:6 — AI "generate today's workout" from a brain-dump
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.
2026-06-19 10:59:12 -05:00

17 KiB
Raw Blame History

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.md exists, 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 .s9pk to the StartOS box at host: in ~/.startos/config.yaml (via start-cli package install).
  • make publish — upload every .s9pk to the S3 bucket (s9pk-s3base:) and index it on registry: from ~/.startos/config.yaml (via s3cmd + start-cli s9pk publish). Distinct from ~/.proof-of-work/publish.sh below.
  • 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 new start9/0.4/startos/versions/vMAJOR.MINOR.PATCH.N.ts file, imported into versions/index.ts and promoted to current (previous current moves into other[]).
  • 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 by PRAGMA table_info checks. Keep schema.prisma in 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): mirror calories end-to-end — schema.prisma column + prisma generate; guarded additive ALTER in docker_entrypoint.sh; zod field + insert in all 5 set-write paths (workouts POST, 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 in app/main/workouts/[id]/page.tsx + edit mapping in app/main/workouts/new/page.tsx; CSV export/parse + page-csv payload; field-option label lists (lib/exerciseOptions.ts, app/main/exercises/[id]/page.tsx, ExercisePicker.tsx). The inputFields token == the column name; the human label lives in those option lists (token watts → "Avg. watts"). me/export rides the 1:1 Prisma dump automatically. Add a round-trip test in tests/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 inputFields token). Cardio exercises log breathing Gear (SetLog.gear, 15); everything else logs RPE (SetLog.rpe, 610). The switch is isCardioExercise(exercise) (lib/exerciseOptions.ts): type === "cardio" OR muscleGroups contains "cardio". SetRow takes an isCardio prop (from WorkoutForm) 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 is master.
  • Authorization tiers: whole-instance routes (settings/{export,import}-db) are admin-only (!user.isAdmin → 403); per-user data routes scope by user.id. Custom-URL AI providers (Ollama, OpenAI-compatible — anything with requiresBaseUrl) 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 or ProgramExercises from a client-supplied exerciseId must validate it via findUnownedExerciseIds(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-day start, 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) call verifyPasswordOrDummy (lib/auth.ts) so an unknown email spends the same bcrypt as a real one. Never reintroduce an early if (!user) return before the compare — that's the timing oracle.
  • Malformed JSON body must return 400, not 500. Routes whose catch maps instanceof z.ZodError → 400 parse via readJsonBody(request) (lib/http.ts — throws a ZodError on bad JSON, so the existing branch handles it with no catch change). safeParse-style routes (me/import, admin/signups) wrap request.json() in an explicit try/catch → 400. (AI/admin routes using .catch(() => ({})) are a third, pre-existing pattern — unify if you touch them.)
  • Next 15 dynamic APIs are async — await them. Route-handler context params, page/layout params + searchParams, and cookies()/headers() are all Promises. Established idiom (keeps handler bodies untouched): [id] routes take context: { params: Promise<{…}> } then const params = await context.params; server pages take props then const params = await props.params / const searchParams = await props.searchParams. Route tests pass params: 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.sh runs as root only to prep /data (seed, ALTERs, library reconcile), then chown -R nextjs:nodejs "$DATA_DIR" and exec 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/SignupForm wrap the action in retryOnTransportError (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 with vi.hoisted() + vi.mock. Route tests run against a real temp SQLite DB (tests/helpers/db.ts) with getCurrentUser mocked.
  • Before editing the AI subsystem (proof-of-work/lib/ai/** or the generate/generations routes), read docs/guides/ai-subsystem.md — provider abstraction, SSE/lenient-JSON, pricing/model menus, and the background-runner architecture live there.

Always

  • Run npx prisma generate after any schema.prisma edit, then npx tsc --noEmit.
  • Run npm test AND npm run build before shipping a version.
  • Add the boot-time ALTER TABLE (with an existence guard) for any new column, in docker_entrypoint.sh.
  • Treat API keys / secrets as plaintext in /data BY 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 in next.config.js.
  • Never run app commands from the repo root — always cd proof-of-work first.
  • Never export non-HTTP-method symbols from a route.ts — Next.js rejects the build (helpers go in lib/, e.g. lib/ai/activateConfig.ts).
  • Never commit app.db, *.bak, or any user data — they're gitignored; double-check git status before git 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:6AI "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=aiAiWorkoutPrefill (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):

  1. Finish the P3 hardening batch (ROADMAP.md → Security & hardening — timing oracle + exerciseId ownership now DONE): CSP unsafe-eval, /api/health info disclosure, rate-limit map leak, configurable/shorter sessions (currently 30-day), text max-length. Also unify the 3rd JSON-parse pattern (try{json}catch{→{}}) in programs/[id]/days/[dayId]/start.
  2. Tiered AI prompt formatting (ROADMAP.md → AI quality).
  3. (Later) Next 15→16 when ready — next lint deprecated 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.)