Files
proof-of-work/start9/0.4/docker_entrypoint.sh
T
Keysat 5e291203a5 v1.1.0:4 — multi-config AI, background generation, ollama auto-detect, system prompt overhaul
User-feedback-driven release after testing v1.1.0:3. Nine themes:

1. Multi-config persistence
   - New AIConfigProfile table (per-user). Save N configs, toggle one
     active. Switching providers no longer wipes the previous setup.
   - UserPreferences gains activeAIConfigId; legacy single-config
     columns are mirrored from the active profile so existing reads
     keep working without conditional logic.
   - Idempotent boot migration lifts any existing single-config row
     into a default profile.

2. Ollama auto-detect
   - The "Add config" form probes /api/tags on the StartOS internal
     addresses (ollama.startos / ollama.embassy on :11434). If
     reachable: URL pre-fills, model field becomes a dropdown of
     installed models. Fixes the copy-paste UX.

3. Curated model dropdowns for major providers
   - Claude: Opus 4.7, Sonnet 4.6 (1M ctx), Haiku 4.5
   - OpenAI: GPT-5.5, 5.4, 5.4-mini, 5.4-nano
   - Gemini: 3.1-pro-preview, 2.5-pro, 2.5-flash, etc.
   - "Other (type your own)" stays for niche models.
   - Fixes "I tried gemini-3.0-pro and got 404."

4. Background generation
   - lib/ai/generationRunner.ts: detached runner with in-memory
     pub/sub bus. POST /api/ai/generate kicks it off and returns
     immediately. SSE stream attaches by id. The runner survives
     request cancellation; navigating away no longer kills it.
   - New AIGeneration columns: progressText (in-flight stream),
     durationMs (final wall-clock).
   - Generate UI shows a banner explaining background-safety.
   - History detail page polls progress + renders partial JSON
     live for cross-process resume (page refresh, new tab).

5. System prompt overhaul
   - lib/ai/systemPromptBase.ts: structural contract prepended to
     every template. Forces JSON-only output, library-exerciseId
     usage (kills "exerciseId doesn't belong to this user" errors),
     and per-resistance-exercise suggestedWeight (with-history vs
     without-history variants).
   - aiExerciseSchema + ProgramExercise gain suggestedWeight +
     suggestedWeightUnit. Starting a workout from a ProgramDay
     pre-populates SetLog.weight from the suggestion.

6. Test connection improvements
   - Latency in seconds (was ms — confusing for slow Ollama).
   - Stale "✓ Connected" clears on form change.
   - Per-config Test (no need to activate first).
   - Generous maxOutputTokens for thinking models.
   - Gemini surfaces finishReason on empty response (e.g. "blocked
     by safety filter") instead of generic "empty response."
   - Test endpoint accepts a draft body so you can verify before
     saving + before activating.

7. History detail view
   - Click row → full program tree + exact prompts sent. Apply from
     here without re-generating. Pending rows poll for progress.

8. Sidebar sub-navigation
   - AI: Generate / History / Templates
   - Settings: General / Password / Sessions / AI integration /
     Export / Instance (admin) / Danger zone, with anchor scroll.

9. API key UX
   - "Key saved" indicator on saved configs (was confusing to see
     an empty input after a successful save).

Schema migrations (additive, idempotent in entrypoint):
  - AIConfigProfile table created
  - UserPreferences.activeAIConfigId
  - AIGeneration.progressText + durationMs
  - ProgramExercise.suggestedWeight + suggestedWeightUnit

Tests: 16 new (systemPromptBase, modelMenu, generationRunner). 177
total pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:09:01 -05:00

342 lines
16 KiB
Bash
Executable File

#!/bin/sh
# Proof of Work (proof-of-work) — StartOS 0.4 container entrypoint.
#
# Responsibilities (in order):
# 1. Ensure the persistent /data directory exists.
# 2. First-boot fallback ONLY: if /data is empty (no app.db, no
# .seeded marker) AND a fallback DB exists at
# /app/prisma/data/app.db, copy it into /data. This handles
# brand-new sideloads on a host that has never had proof-of-work
# installed before. Existing installs ALWAYS skip this branch
# because /data/app.db is already present.
# (v1.0.0:1 had a second branch that copied a baked cutover seed
# from /app/seed/data/app.db. v1.0.0:3 stripped both the COPY of
# that seed in the Dockerfile and the branch that copied it here,
# now that the cutover from `workout-log` is verified done.)
# (v1.0.0:4 made another change to this fallback DB: it now
# contains ONLY the InstanceSettings singleton, NOT a default
# admin user or seeded exercises. The operator is required to
# run the StartOS Action 'Set admin credentials' to bootstrap
# the first admin — eliminates the default-credentials footgun.)
# 3. Run idempotent compat ALTERs for columns added after older snapshots.
# No-ops on hosts whose schema is already current.
# 4. Ensure the curated exercise library is present for every user. New
# maintainer-shipped exercises appear on every boot (INSERT OR IGNORE
# keyed on (userId, name); never overwrites a user's own exercises).
# 5. Exec the Next.js standalone server as PID 1 (under dumb-init).
#
# Every branch logs to stderr so it is visible in StartOS -> Logs.
set -eu
DATA_DIR="${WORKOUT_DATA_DIR:-/data}"
DB_PATH="${WORKOUT_DB_PATH:-$DATA_DIR/app.db}"
FALLBACK_SEED_DB_PATH="${WORKOUT_FALLBACK_SEED_DB_PATH:-/app/prisma/data/app.db}"
LIBRARY_JSON_PATH="${WORKOUT_LIBRARY_JSON_PATH:-/app/prisma/exercises.seed.json}"
log() {
# write to stderr so StartOS log viewer surfaces it immediately
printf '[entrypoint] %s\n' "$*" 1>&2
}
mkdir -p "$DATA_DIR"
# -----------------------------------------------------------------------------
# Step 1 — first-boot fallback. NEVER overwrites an existing app.db.
# Skipped on every restart of an installed host because /data/app.db
# already exists.
# -----------------------------------------------------------------------------
if [ ! -f "$DB_PATH" ] && [ ! -f "$DATA_DIR/.seeded" ]; then
if [ -f "$FALLBACK_SEED_DB_PATH" ]; then
log "no $DB_PATH and no .seeded marker; copying empty-schema fallback from $FALLBACK_SEED_DB_PATH"
cp "$FALLBACK_SEED_DB_PATH" "$DB_PATH"
date -u +"seeded from empty-schema fallback at %Y-%m-%dT%H:%M:%SZ" > "$DATA_DIR/.seeded"
else
log "no $DB_PATH and no fallback DB; creating empty $DB_PATH"
touch "$DB_PATH"
fi
else
log "$DB_PATH already present; live data is the source of truth"
if [ -f "$DATA_DIR/.seeded" ]; then
log "found .seeded: $(cat "$DATA_DIR/.seeded")"
fi
fi
# -----------------------------------------------------------------------------
# Step 2 — idempotent compat ALTERs (safety net for older snapshots).
# No-ops on hosts whose schema is already current.
# -----------------------------------------------------------------------------
if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|customMetrics|"; then
log "adding missing column SetLog.customMetrics"
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
log "adding missing column Workout.deletedAt"
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
fi
# Multi-user support shipped in v1.0.0:1: User.isAdmin column +
# InstanceSettings singleton. New install: seed.ts creates both. Upgrade
# from a snapshot pulled off the legacy `workout-log` package: this block
# adds them in place, then promotes the oldest user to admin so the
# in-app admin Settings panel + change-credentials action keep working.
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|isAdmin|"; then
log "adding missing column User.isAdmin (default 0)"
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN isAdmin INTEGER NOT NULL DEFAULT 0;"
log "promoting oldest user to admin (one-shot, only if no admin exists)"
sqlite3 "$DB_PATH" \
"UPDATE User SET isAdmin = 1 \
WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1) \
AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|lastLoginAt|"; then
log "adding missing column User.lastLoginAt (nullable)"
sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;"
fi
# v1.1.0:1 added Workout.programDayId so workouts can be tagged with the
# planned ProgramDay they were logged against (for adherence tracking).
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|programDayId|"; then
log "adding missing column Workout.programDayId (nullable)"
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN programDayId TEXT REFERENCES ProgramDay(id) ON DELETE SET NULL;"
sqlite3 "$DB_PATH" "CREATE INDEX IF NOT EXISTS Workout_programDayId_idx ON Workout(programDayId);"
fi
# v1.1.0:2 added the model-agnostic AI configuration fields to
# UserPreferences. Replaces the dead enableClaudeAI / claudeApiKey
# single-provider scheme (those columns stay as no-op fields for
# back-compat).
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('UserPreferences');" 2>/dev/null | grep -q "|aiProvider|"; then
log "adding AI configuration columns to UserPreferences"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiProvider TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiModel TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiBaseUrl TEXT;"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN aiApiKey TEXT;"
fi
# v1.1.0:2 also added AIPromptTemplate + AIGeneration tables.
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIPromptTemplate';" \
2>/dev/null | grep -q AIPromptTemplate; then
log "creating AIPromptTemplate table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIPromptTemplate (
id TEXT PRIMARY KEY,
userId TEXT,
name TEXT NOT NULL,
description TEXT,
systemPrompt TEXT NOT NULL,
userPromptTemplate TEXT NOT NULL,
isBuiltIn INTEGER NOT NULL DEFAULT 0,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIPromptTemplate_userId_idx ON AIPromptTemplate(userId);
CREATE INDEX AIPromptTemplate_isBuiltIn_idx ON AIPromptTemplate(isBuiltIn);
"
fi
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIGeneration';" \
2>/dev/null | grep -q AIGeneration; then
log "creating AIGeneration table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIGeneration (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
templateId TEXT,
templateName TEXT,
userInput TEXT NOT NULL,
systemPrompt TEXT NOT NULL,
userPrompt TEXT NOT NULL,
rawResponse TEXT,
parsedProgram TEXT,
provider TEXT NOT NULL,
model TEXT NOT NULL,
tokensIn INTEGER,
tokensOut INTEGER,
status TEXT NOT NULL DEFAULT 'pending',
errorMessage TEXT,
appliedProgramId TEXT,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIGeneration_userId_createdAt_idx ON AIGeneration(userId, createdAt);
CREATE INDEX AIGeneration_status_idx ON AIGeneration(status);
CREATE INDEX AIGeneration_appliedProgramId_idx ON AIGeneration(appliedProgramId);
"
fi
# v1.1.0:4 added AIConfigProfile table (multi-config support) +
# UserPreferences.activeAIConfigId pointer + AIGeneration progress/
# duration columns + ProgramExercise suggested-weight columns.
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='AIConfigProfile';" \
2>/dev/null | grep -q AIConfigProfile; then
log "creating AIConfigProfile table"
sqlite3 "$DB_PATH" "
CREATE TABLE AIConfigProfile (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
name TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
baseUrl TEXT,
apiKey TEXT,
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE
);
CREATE INDEX AIConfigProfile_userId_idx ON AIConfigProfile(userId);
"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('UserPreferences');" 2>/dev/null | grep -q "|activeAIConfigId|"; then
log "adding UserPreferences.activeAIConfigId"
sqlite3 "$DB_PATH" "ALTER TABLE UserPreferences ADD COLUMN activeAIConfigId TEXT;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('AIGeneration');" 2>/dev/null | grep -q "|progressText|"; then
log "adding AIGeneration.progressText"
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN progressText TEXT;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('AIGeneration');" 2>/dev/null | grep -q "|durationMs|"; then
log "adding AIGeneration.durationMs"
sqlite3 "$DB_PATH" "ALTER TABLE AIGeneration ADD COLUMN durationMs INTEGER;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('ProgramExercise');" 2>/dev/null | grep -q "|suggestedWeight|"; then
log "adding ProgramExercise.suggestedWeight + suggestedWeightUnit"
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeight REAL;"
sqlite3 "$DB_PATH" "ALTER TABLE ProgramExercise ADD COLUMN suggestedWeightUnit TEXT;"
fi
# v1.1.0:4 one-shot migration: lift each user's legacy single-config
# (UserPreferences.aiProvider/aiModel/...) into a new AIConfigProfile
# row marked active. Idempotent — only runs for users who have a
# configured legacy config but no profiles yet.
log "migrating any legacy single-config to AIConfigProfile (idempotent)"
sqlite3 "$DB_PATH" "
INSERT INTO AIConfigProfile (id, userId, name, provider, model, baseUrl, apiKey)
SELECT
'c' || lower(hex(randomblob(12))),
up.userId,
'Default (' || up.aiProvider || ')',
up.aiProvider,
up.aiModel,
up.aiBaseUrl,
up.aiApiKey
FROM UserPreferences up
WHERE up.aiProvider IS NOT NULL
AND up.aiModel IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM AIConfigProfile p WHERE p.userId = up.userId);
" 2>/dev/null || log "WARN: legacy-config migration skipped"
# Set activeAIConfigId for users who now have exactly one profile.
sqlite3 "$DB_PATH" "
UPDATE UserPreferences
SET activeAIConfigId = (
SELECT id FROM AIConfigProfile WHERE userId = UserPreferences.userId LIMIT 1
)
WHERE activeAIConfigId IS NULL
AND (SELECT COUNT(*) FROM AIConfigProfile WHERE userId = UserPreferences.userId) = 1;
" 2>/dev/null || true
if ! sqlite3 "$DB_PATH" \
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
2>/dev/null | grep -q InstanceSettings; then
log "creating InstanceSettings table + singleton row (signupsOpen=0)"
sqlite3 "$DB_PATH" \
"CREATE TABLE InstanceSettings ( \
id INTEGER PRIMARY KEY DEFAULT 1, \
signupsOpen INTEGER NOT NULL DEFAULT 0, \
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP \
);"
sqlite3 "$DB_PATH" \
"INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);"
fi
# SQLite tuning. Enabling WAL means readers don't block on a concurrent
# writer (and vice versa) — crucial for the "background StartOS Backup
# while users are using the app" case, which under the default rollback
# journal can produce a torn snapshot. journal_mode persists in the DB
# header once set, so this is effectively a one-shot. synchronous=NORMAL
# is the safe-with-WAL balance: no fsync after every commit but still
# crash-consistent at every checkpoint, ~10x faster than FULL.
current_mode=$(sqlite3 "$DB_PATH" "PRAGMA journal_mode;" 2>/dev/null || echo "")
if [ "$current_mode" != "wal" ]; then
log "switching SQLite journal_mode from '${current_mode:-unknown}' to WAL"
sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" >/dev/null
fi
sqlite3 "$DB_PATH" "PRAGMA synchronous=NORMAL;" >/dev/null
# Composite indexes for hot query paths. CREATE INDEX IF NOT EXISTS is
# idempotent — no-op if Prisma already created them via db push on a
# fresh install. These are also declared in schema.prisma so any future
# `prisma db push` keeps them in sync.
log "ensuring composite indexes are present"
sqlite3 "$DB_PATH" "
CREATE INDEX IF NOT EXISTS Session_userId_expiresAt_idx
ON Session(userId, expiresAt);
CREATE INDEX IF NOT EXISTS Workout_userId_deletedAt_date_idx
ON Workout(userId, deletedAt, date);
CREATE INDEX IF NOT EXISTS SetLog_workoutId_setNumber_idx
ON SetLog(workoutId, setNumber);
" >/dev/null
fi
# -----------------------------------------------------------------------------
# Step 3 — reconcile curated exercise library for every user
# (multi-user-aware). As of v1.0.0:7 this is INSERT-or-UPDATE rather than
# INSERT-or-IGNORE: existing rows where isCustom = 0 get refreshed from
# /app/prisma/exercises.seed.json so maintainer-side fixes (e.g. correct
# inputFields for cardio) propagate to existing installs. Rows where
# isCustom = 1 are skipped — user customizations win.
#
# PATCH /api/exercises/[id] flips isCustom to 1 on any user edit, so the
# moment you change a library exercise via the in-app UI it stops getting
# overwritten on subsequent boots.
#
# Additive on names: exercises removed from the curated JSON are NOT
# deleted from existing installs (users may have logged sets against them).
# -----------------------------------------------------------------------------
if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then
log "ensuring curated exercise library is present for every user"
node /app/prisma/ensureExerciseLibrary.cjs \
--db "$DB_PATH" \
--json "$LIBRARY_JSON_PATH" \
|| log "WARNING: ensureExerciseLibrary failed; continuing boot"
else
log "skipping library ensure (json or db not found)"
fi
# v1.1.0:2 — reconcile built-in AI prompt templates from the curated
# JSON. Same INSERT-or-UPDATE pattern as the exercise library, scoped
# to userId IS NULL so user-created templates are never touched.
TEMPLATES_JSON_PATH="${WORKOUT_TEMPLATES_JSON_PATH:-/app/prisma/aiTemplates.seed.json}"
TEMPLATES_SCRIPT="/app/prisma/ensurePromptTemplates.cjs"
if [ -f "$TEMPLATES_JSON_PATH" ] && [ -f "$TEMPLATES_SCRIPT" ] && [ -f "$DB_PATH" ]; then
log "ensuring built-in AI prompt templates are present"
node "$TEMPLATES_SCRIPT" \
--db "$DB_PATH" \
--json "$TEMPLATES_JSON_PATH" \
|| log "WARNING: ensurePromptTemplates failed; continuing boot"
fi
# -----------------------------------------------------------------------------
# Step 4 — launch the app.
# -----------------------------------------------------------------------------
export DATABASE_URL="file:$DB_PATH"
export NODE_ENV="${NODE_ENV:-production}"
export HOSTNAME="${HOSTNAME:-0.0.0.0}"
export PORT="${PORT:-3000}"
log "launching Next.js on :${PORT} with DATABASE_URL=file:${DB_PATH}"
exec node /app/server.js