Files
proof-of-work/AGENTS.md
T
Keysat 794070a1d8
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:8 — tolerate decimal integers in AI output
Local models (Qwen via SparkControl, surfaced on the first SparkControl smoke test) sometimes emit a decimal where the AI-output schema expects an integer — e.g. a half-step "rpe": 7.5 or "reps": 8.0. Zod's .int() rejected these and failed the ENTIRE parse, so one stray decimal killed an otherwise good generation.

Fix: a shared looseInt helper rounds a number to the nearest int before the .int() check, applied to every integer field in both the program and single-workout schemas (rpe, reps, sets, gear, order, durationSeconds, rest/week/day numbers). RPE/reps/sets are stored as integers downstream, so rounding is the correct landing. Transform-before-validate, so inferred types are unchanged.

Parse-only; no schema/data change. 261 tests pass; built + sideloaded to immense-voyage.local (1.2.0:8, clean non-root launch). SparkControl now confirmed working end-to-end.
2026-06-19 15:30:06 -05:00

19 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.

Design: before building or changing any user-facing UI, read design/DESIGN.md and design/tokens.tokens.json and conform to them.

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 sparkcontrol.ts + index.ts (getProvider; openai.ts exports both openai + openai-compatible = 6 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)
design/                   ← UI design contract: DESIGN.md + tokens.tokens.json (read before UI work); brand/ inspiration/
~/.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:8decimal-tolerant AI parsing, a fix-forward on the 1.2.0:7 SparkControl ship. SparkControl is confirmed working end-to-end on-box (smoke-tested with RedHatAI/Qwen3.6-35B-A3B-NVFP4: connected, streamed 9.7k in / 5.3k out, $0/FREE, model auto-detected). The smoke test surfaced one bug: local models emit decimals where the AI schema expected integers (a half-step "rpe": 7.5), and zod .int() failed the entire parse. Fix: a shared looseInt (programSchema.ts, used by workoutSchema.ts) rounds a number to the nearest int before the .int() check, applied to every integer field in both the program and single-workout schemas (rpe, reps, sets, gear, order, durationSeconds, rest/week/day numbers). Parse-only, types unchanged.

1.2.0:7 (the SparkControl feature itself): adds a 6th provider, SparkControl (local) — the operator's own self-hosted local-inference gateway. OpenAI-compatible wire format (reuses generateOpenAIStyle), keyless on the LAN (requireApiKey:false → no Authorization header), reached over the internal same-box StartOS address http://spark-control.startos:9999/v1 (plain HTTP — no TLS/cert-skip; the public LAN interface is HTTPS w/ a self-signed cert we deliberately avoid). The Settings form auto-detects the loaded vLLM model via SparkControl's /api/endpoints (app/api/ai/sparkcontrol/model, admin-only + SSRF-guarded); $0 in the cost UI. Also fixed a base-URL footgun: a custom URL could ride along to a fixed-URL provider (claude/openai/gemini), get stored, and be silently ignored — both config write paths now null baseUrl for non-custom-URL providers and the form clears it on provider change. No schema/data change (AIConfigProfile.provider is free-text). Details: docs/guides/ai-subsystem.md → Provider abstraction (incl. the "adding a provider" fan-out checklist). Built + sideloaded (immense-voyage.local, 2026-06-19, master) as proof-of-work_x86_64.s9pk (80M). tsc clean (app + packaging), lint clean (pre-existing only), 261 tests pass, next build + s9pk build succeed. Registry empty, publishing parked (sideload-only via make install).

Design contract established this session (2026-06-19, committed 7fda9ce, no UI code changed): the design/ folder now holds the durable contract — DESIGN.md (9-section brief) + tokens.tokens.json (DTCG) + brand/palette.css + inspiration/ provenance. From a Case-B document-as-is extract of the as-built dark UI: monochrome gym-brutalist (#0A0A0A canvas, zinc-only neutral, white primary button, Bebas-uppercase/tracked headings, flat border-based depth), plus two owner calls — red elevated to the single brand accent #DC2626 and a two-tier radius (4px controls / 8px containers). AGENTS.md carries the read-before-UI Design line; ROADMAP.mdDesign holds the design-checker cleanup backlog (gray→zinc, green→emerald, yellow→amber, rounded-mdrounded, overlay-only shadows, and a shared <Button>). Read design/DESIGN.md before any UI work.

Confirmed on-box (2026-06-19, via start-cli): box runs 1.2.0:8; launched as nextjs with no errors, "Ready in 221ms", and (correctly) no migration ran (neither :7 nor :8 adds a column). The operator added a SparkControl config and generated through it successfully (modulo the decimal bug now fixed in :8). Recent prior ships (1.2.0 line): 1.2.0:6 AI "today's workout"; 1.2.0:5 Gear replaces RPE for cardio; 1.2.0:4 watts as first-class set field.

On-box follow-up: the operator is now on the SparkControl config; the old misconfigured gemini+baseUrl profile (the empty-response trigger) is no longer active — delete it at leisure (cosmetic). 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 (6 providers incl. SparkControl, multi-config, background generation, single-workout generation + refine, history detail, cost/duration, Ollama + SparkControl 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. Design cleanup batch (ROADMAP.md → Design): conform the code to the just-shipped contract — mechanical palette unifications (gray→zinc ×21, green→emerald ×10, yellow→amber ×13), radius/shadow fixes, then the durable one: extract a shared <Button> (the empty components/common/ is why the white-button drift spread across 44 inline copies). All cosmetic, none ship-blocking.
  3. Tiered AI prompt formatting (ROADMAP.md → AI quality).
  4. (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.)