diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b3a9b01 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Keep the build context small and deterministic. +.git +.github +**/node_modules +frontend/.next +frontend/out +frontend/.turbo +**/__pycache__ +*.pyc +.venv +venv +data +*.db +.env +.env.* +!.env.example +test-results +playwright-report +tests/e2e +spec +harness +*.md +!README.md diff --git a/.env.example b/.env.example index 317ba3b..798d6e2 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,28 @@ -# Workshop Helmsman — environment template -# -# Copy this file to `.env` and fill in values. Nothing is strictly required -# for local dev (SQLite is the default), but the OpenRouter key enables AI assist. - -# --- Server --- -# BIND_HOST=0.0.0.0 -# BIND_PORT=8001 +# Workshop Helmsman v0.2 — copy to .env and fill in. `.env` is gitignored; never commit it. # --- Database --- -# Local dev (default): sqlite:///./data/helmsman.db -# Production (PostgreSQL): postgresql+psycopg://user:pass@host:5432/dbname -DATABASE_URL=sqlite:///./data/helmsman.db +# SQLAlchemy URL. Default is SQLite in data/. PostgreSQL-ready: postgresql+psycopg2://... +DATABASE_URL=sqlite:///data/helmsman.db -# --- Workshop defaults --- -# DEFAULT_WORKSHOP_TTL_HOURS=8 +# --- Server --- +PORT=8001 -# --- LLM (optional) --- -# OpenRouter API key — enables facilitator help-reply suggestions. -# Get one at https://openrouter.ai/keys -# If absent or empty, AI assist is silently disabled (no errors, no fallback). -OPENROUTER_API_KEY= +# --- Deploy (docker-compose + Caddy TLS; see deploy/RUNBOOK.md) --- +# Public hostname served over HTTPS. DNS A record must point at the VM. +HELMSMAN_DOMAIN=workshops.smalltech.in +# Email Let's Encrypt uses for cert expiry notices. +ACME_EMAIL=contact@smalltech.in +# Absolute base URL for generated share links (set when running behind a proxy). +# Leave empty to derive from the request. +HELMSMAN_BASE_URL= +HELMSMAN_LOG_LEVEL=INFO -# OpenRouter primary model (default: nvidia/llama-3.3-nemotron-super-49b-v1) -# OPENROUTER_PRIMARY_MODEL=nvidia/llama-3.3-nemotron-super-49b-v1 +# --- Facilitator access (REQUIRED) --- +# The instance access key entered on the Admin Home page. Choose any strong string (>=16 chars). +HELMSMAN_ADMIN_KEY= -# OpenRouter fallback model (default: openai/gpt-4o-mini) -# OPENROUTER_FALLBACK_MODEL=openai/gpt-4o-mini \ No newline at end of file +# --- AI help-desk (Phase 4; optional) --- +# Absent => the product runs fully air-gapped with zero errors and AI surfaces show a labelled off state. +OPENROUTER_API_KEY= +HELMSMAN_AI_MODEL=anthropic/claude-sonnet-4-6 +HELMSMAN_AI_CONFIDENCE=0.75 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3990d1f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +# Runs on every PR and on pushes to the integration branches. +# Backend unit+integration tests and the frontend production build always run. +# Playwright e2e runs against a live single-origin server (built frontend served +# by FastAPI) — the exact shape the app ships in. + +on: + pull_request: + push: + branches: [feature/rebuild-v0.2, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - run: uv sync --frozen + - name: Migrations apply cleanly + run: uv run alembic upgrade head + - name: Unit + integration tests + run: uv run pytest tests/unit tests/integration -q + + frontend-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version-file: frontend/.nvmrc + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + working-directory: frontend + - run: pnpm build + working-directory: frontend + + e2e: + runs-on: ubuntu-latest + needs: [backend, frontend-build] + env: + HELMSMAN_ADMIN_KEY: ci-admin-key-0123456789 + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + - run: uv sync --frozen + - uses: pnpm/action-setup@v4 + with: + version: 11 + - uses: actions/setup-node@v4 + with: + node-version-file: frontend/.nvmrc + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + working-directory: frontend + - name: Build frontend (served by the API at /app) + run: pnpm build + working-directory: frontend + - name: Install Playwright browser + run: pnpm exec playwright install --with-deps chromium + - name: Migrate + boot server + run: | + uv run alembic upgrade head + uv run python -m src & + for i in $(seq 1 20); do curl -fsS http://localhost:8001/api/health && break || sleep 1; done + - name: Playwright e2e + run: BASE_URL=http://localhost:8001 pnpm exec playwright test --reporter=line + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..92c0f5e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,91 @@ +name: Deploy + +# Build the production image, push it to GitHub Container Registry, then deploy +# on the GCP VM over SSH by pulling the new image and re-upping compose. +# +# Trigger: push to main (i.e. a merged PR), or manual dispatch. +# +# Required repository secrets (Settings -> Secrets and variables -> Actions): +# GHCR_PAT optional; GITHUB_TOKEN is used by default for GHCR +# DEPLOY_SSH_KEY private key whose public half is in the VM's authorized_keys +# DEPLOY_HOST VM public IP or hostname +# DEPLOY_USER SSH user on the VM (e.g. deploy) +# The VM holds its own /opt/helmsman/.env (secrets never leave the VM). + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image_ref: ${{ steps.meta.outputs.image_ref }} + steps: + - uses: actions/checkout@v4 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + run: echo "image_ref=${IMAGE,,}:${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@v3 + - name: Build + push image + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image_ref }} + ${{ env.IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + runs-on: ubuntu-latest + needs: build-and-push + steps: + - uses: actions/checkout@v4 + - name: Copy compose assets to the VM + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "deploy/docker-compose.prod.yml,deploy/Caddyfile" + target: "/opt/helmsman" + strip_components: 1 + - name: Pull + roll the new image + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + cd /opt/helmsman + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + export HELMSMAN_IMAGE="${{ needs.build-and-push.outputs.image_ref }}" + docker compose -f docker-compose.prod.yml pull app + docker compose -f docker-compose.prod.yml up -d + docker image prune -f + # Wait for health before declaring success. + for i in $(seq 1 20); do + curl -fsS http://localhost:8001/api/health && exit 0 || sleep 3 + done + echo "health check failed after deploy" >&2 + exit 1 diff --git a/.gitignore b/.gitignore index e41854e..c71f5ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,32 @@ +# secrets +.env + +# python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.pytest_cache/ + +# runtime data (SQLite lives here) +data/* +!data/.gitkeep *.db *.db-journal +*.db-wal +*.db-shm *.log -*.pyc -*.pyo + +# node / frontend +node_modules/ +frontend/.next/ +frontend/out/ + +# playwright +test-results/ +playwright-report/ + +# misc .DS_Store -.env /tmp/ -__pycache__/ -data/ -venv/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 58bbd6c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Local-only dependencies. No network calls required at runtime. -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -COPY src ./src -COPY frontend ./frontend - -ENV BIND_HOST=0.0.0.0 BIND_PORT=8001 PYTHONUNBUFFERED=1 - -EXPOSE 8001 -CMD ["python", "-m", "src"] diff --git a/README.md b/README.md index 52e7f97..d69a02a 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,68 @@ # Workshop Helmsman -A self-hosted workshop tracker for remote sessions. Facilitators stand up a -workshop, get a participant link (slug) and a separate admin link (token), -and watch participants move through milestones in real time. +Self-hosted workshop assistant for facilitator-led hands-on technical labs: a facilitator creates a workshop with content-rich milestones, participants join with just a name, and a genuinely live dashboard tracks the whole room — with a built-in help desk. -Phase 1 ships the full single-workshop happy path on a single VM with no -external services. No LLM, no external APIs — everything is local SQLite. +> **All commands run from the repo root.** Every Python command is prefixed with `uv run` — bare commands will fail. -## Stack +## Requirements -- Python 3.11 / FastAPI / Uvicorn -- SQLite via SQLAlchemy 2.x -- Jinja2 templates, vanilla JS, one CSS file -- Polling-based live updates (4 s) +- Python 3.12+ and [uv](https://docs.astral.sh/uv/) +- Node.js 22 and [pnpm](https://pnpm.io/) -## Run locally +## Setup (once) -```sh -cd /Users/sai/workshop-helmsman -python3 -m venv venv -venv/bin/pip install -r requirements.txt -venv/bin/python -m src # serves on http://localhost:8001 +```bash +# from the repo root +cp .env.example .env # then set HELMSMAN_ADMIN_KEY (any strong string, >=16 chars) +uv sync +(cd frontend && pnpm install) +pnpm install # root: Playwright for e2e tests +pnpm exec playwright install chromium # only needed to run the e2e suite ``` -The first boot creates `./data/helmsman.db` automatically. +## Database -## Endpoints (Phase 1) +```bash +# from the repo root +uv run alembic upgrade head +uv run alembic current # MUST print a revision hash — blank output means no migration was applied +``` + +## Build the UI and run + +```bash +# from the repo root +(cd frontend && pnpm build) +uv run python -m src +``` + +Open **http://localhost:8001/app/** — enter your `HELMSMAN_ADMIN_KEY` to reach the Admin Home. -| Route | Purpose | -| -------------------------------------- | -------------------------------------------- | -| `GET /` | Landing page | -| `GET /admin/new` · `POST /admin/new` | Facilitator creates a workshop | -| `GET /admin/` | Live facilitator dashboard | -| `GET /w/` | Participant join (asks for their name) | -| `GET /w//me` | Personal progress tracker + leaderboard | -| `GET /w//data?since=`| JSON poll feed (used by the participant UI) | -| `GET /healthz` | `{"status":"ok","db":"ok"}` liveness probe | +Share links: join `http://localhost:8001/j/` · participant personal link `/p/` · facilitator dashboard `/f/`. + +## Tests + +```bash +# from the repo root +uv run pytest tests/unit tests/integration -q + +# e2e (requires the server running in another terminal: uv run python -m src) +pnpm exec playwright test tests/e2e --reporter=line +``` -## Phase 1 stubs (clearly labelled) +## Environment variables -- `GET /admin//edit` → "Coming in Phase 2" -- `GET /admin//export.csv` → "Coming in Phase 2" -- `POST /admin//clone` → "Coming in Phase 2" -- `GET /workshops` → read-only archive (no admin actions) +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `DATABASE_URL` | no | `sqlite:///data/helmsman.db` | SQLAlchemy URL; PostgreSQL-ready | +| `PORT` | no | `8001` | HTTP port | +| `HELMSMAN_ADMIN_KEY` | **yes** | — | Facilitator access key for Admin Home | +| `HELMSMAN_BASE_URL` | no | derived from request | Absolute base for generated share links | +| `HELMSMAN_LOG_LEVEL` | no | `INFO` | Log level (structlog, JSON to stdout) | +| `OPENROUTER_API_KEY` | no | — | AI help-desk (Phase 4); absent = fully air-gapped | +| `HELMSMAN_AI_MODEL` | no | `anthropic/claude-sonnet-4-6` | OpenRouter model id | +| `HELMSMAN_AI_CONFIDENCE` | no | `0.75` | AI auto-answer confidence threshold | -## Deploy +## Status -`deploy/systemd/workshop-helmsman.service` and `deploy/docker-compose.yml` -are both scaffolded. Pick one. The Phase-1 VM target is -`workshop.smalltech.in`; this repo boots cleanly on `:8001` for local -testing with no extra config. +Phase 1 — **Core Live Loop**: create workshop → join (cookie auto-resume + personal links) → content-rich milestones → live dashboard → manual help desk. Broadcast, Pause, End workshop, AI help-desk, Spend, stuck/bottleneck/pulse cards, Audit, Templates are visible as clearly-labelled **"Coming in a later phase"** stubs. The spec in `spec/` is the source of truth. diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index d8e7b83..0000000 --- a/SPEC.md +++ /dev/null @@ -1,117 +0,0 @@ -# Workshop Helmsman — World-Class UX Specification - -## Architecture (Clean Separation) -| URL | Purpose | -|-----|---------| -| `/` | Marketing landing | -| `/console` | **Master console** — secret URL, workshop table, "+ New Workshop" | -| `/console/workshop/new` | **Create Workshop Wizard** — 5 steps | -| `/admin/` | **Per-workshop admin** — broadcast, pause, advance, reorder, CSV | -| `/w/` | Participant join — instant validation | -| `/w//me` | Participant tracker — broadcast banner, progress, milestones, leaderboard | - ---- - -## Create Workshop Wizard — 5 Steps - -### Step 1: Basics -- Workshop name (required, max 120 chars) -- Duration: dropdown [2h, 4h, 8h, 16h, 24h, Custom] -- Template picker: dropdown [Blank, 4-Phase, 6-Phase Sprint, 3-Phase Quickstart] — pre-fills milestones - -### Step 2: Milestones (Drag-drop reorderable list) -- **Each milestone row:** drag handle | Title (inline edit on click) | Duration dropdown [15m, 30m, 45m, 60m, 90m, 120m, Custom] | Facilitator tip (icon, hover tooltip) | Description (optional, inline edit) | Category badge [Setup, Learning, Hands-on, Break, Q&A, Wrap-up] | Delete -- Add milestone button (+) -- Template picker applies preset milestones - -### Step 3: Participant Form Builder -- Always: Full Name (text, required, locked at top) -- Add field: button opens modal → Field type [Text, Email, Dropdown] | Label | Required toggle | Placeholder | Options (for dropdown, comma-separated) -- Fields list: drag to reorder | Edit | Delete -- Required fields marked with * - -### Step 4: Knowledge Base / Resources (Future Phase) -- Google Drive folder link input -- Placeholder for future: "Link Google Drive folder for slides, docs, resources" - -### Step 5: Review & Create -- Summary cards: Basics | Milestones (count, total time) | Form fields | KB link -- Edit buttons per section -- "Create Workshop" → creates workshop, redirects to `/admin/` - ---- - -## Console (Master) — `/console` -- Table: Name | Created | Status (Live/Ended/Archived) | Attendees | Actions [Admin, Participant link, Edit, Clone, Archive] -- "+ New Workshop" button → `/console/workshop/new` -- Status badges: Live (green), Ended (orange), Archived (gray) - ---- - -## Per-Workshop Admin Dashboard — `/admin/` -**Dashboard Cards (clickable):** -- Live count | Active now | Avg progress % | Help flags - -**Tabs:** -- **Participants:** Table with inline actions (Advance, Pause, Broadcast to one) -- **Milestones:** Drag-drop reorder, Advance all, Advance selected, Pause/Resume -- **Broadcast:** Composer + history -- **Help Flags:** List with status pills (Open/On Hold/Resolved) -- **Export CSV** - ---- - -## Participant Flow -- `/w/` → Join form (instant validation, autocomplete off, autocapitalize words) -- `/w//me` → Tracker: progress bar, milestone list (click to complete, disabled when paused), leaderboard, broadcast banner (dismissible), help flag 2-step (preview with LLM suggestion → commit) - ---- - -## Field Types (Form Builder) -| Type | Config | -|------|--------| -| Text | Label, Placeholder, Required | -| Email | Label, Placeholder, Required | -| Dropdown | Label, Options (comma-separated), Required | - -**Always required (locked):** Full Name (text, required) - ---- - -## Milestone Fields (Inline Edit on Click) -| Field | Type | Required | -|-------|------|----------| -| Title | Inline text | Yes | -| Description | Inline textarea (optional) | No | -| Duration | Dropdown [15m, 30m, 45m, 60m, 90m, 120m, Custom] | Yes | -| Category | Badge [Setup, Learning, Hands-on, Break, Q&A, Wrap-up] | Yes | -| Facilitator Tip | Tooltip icon (hover) | No | - ---- - -## Visual Language (World-Class) -- **Design tokens:** 8px spacing scale, 4/8/12/16px radius, elevation shadows -- **Color:** Dark default (#0b0f1a bg), accent blue (#7aa2ff), live green, warn amber, danger red -- **Typography:** System font stack, clamp() fluid sizing -- **Motion:** 120ms fast, 200ms normal, 350ms slow; prefers-reduced-motion -- **Touch targets:** 44px minimum -- **Dark default, light mode optional** - ---- - -## Data Model -```python -Workshop: id, name, created_at, expires_at, admin_token, participant_slug, - milestone_config (JSON), form_schema (JSON), kb_link, - archived, paused, broadcast_message, milestone_order - -Milestone: id, title, description, duration_min, category, facilitator_tip, order - -FormField: key, type (text|email|dropdown), label, placeholder, required, options - -Participant: id, workshop_id, name, answers (JSON), joined_at - -MilestoneCompletion: participant_id, milestone_id, milestone_title, completed_at - -HelpRequest: participant_id, message, status (open/on_hold/resolved), created_at -``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..fb166b2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +# sqlalchemy.url is resolved at runtime in alembic/env.py from DATABASE_URL +# (default: sqlite:///data/helmsman.db) + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..1039075 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,65 @@ +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.helmsman.config.settings import get_settings +from src.helmsman.db.models import Base +from src.helmsman.db.session import _ensure_sqlite_parent_dir + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _database_url() -> str: + """DATABASE_URL from the environment / .env; default sqlite:///data/helmsman.db.""" + url = get_settings().database_url + if url.startswith("sqlite"): + _ensure_sqlite_parent_dir(url) + return url + + +def run_migrations_offline() -> None: + context.configure( + url=_database_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = _database_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..4ce6e9c --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,270 @@ +"""initial + +Revision ID: 0001 +Revises: +Create Date: 2026-07-20 02:12:09.786818 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('agenda_template', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('description_md', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_agenda_template')) + ) + op.create_table('join_form_template', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('fields_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_join_form_template')) + ) + op.create_table('agenda_template_milestone', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('template_id', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('content_md', sa.Text(), nullable=False), + sa.Column('minutes', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['template_id'], ['agenda_template.id'], name=op.f('fk_agenda_template_milestone_template_id_agenda_template'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_agenda_template_milestone')) + ) + with op.batch_alter_table('agenda_template_milestone', schema=None) as batch_op: + batch_op.create_index('ix_agenda_template_milestone_template_position', ['template_id', 'position'], unique=False) + + op.create_table('workshop', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('description_md', sa.Text(), nullable=False), + sa.Column('admin_token', sa.String(length=64), nullable=False), + sa.Column('join_slug', sa.String(length=16), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('grace_until', sa.DateTime(timezone=True), nullable=True), + sa.Column('grace_hours', sa.Integer(), nullable=False), + sa.Column('paused', sa.Boolean(), nullable=False), + sa.Column('state_version', sa.Integer(), nullable=False), + sa.Column('content_version', sa.Integer(), nullable=False), + sa.Column('ai_enabled', sa.Boolean(), nullable=False), + sa.Column('join_form_json', sa.Text(), nullable=False), + sa.Column('stuck_minutes', sa.Integer(), nullable=False), + sa.Column('cloned_from_id', sa.Integer(), nullable=True), + sa.Column('agenda_template_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['agenda_template_id'], ['agenda_template.id'], name=op.f('fk_workshop_agenda_template_id_agenda_template'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['cloned_from_id'], ['workshop.id'], name=op.f('fk_workshop_cloned_from_id_workshop')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_workshop')) + ) + with op.batch_alter_table('workshop', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_workshop_admin_token'), ['admin_token'], unique=True) + batch_op.create_index(batch_op.f('ix_workshop_agenda_template_id'), ['agenda_template_id'], unique=False) + batch_op.create_index(batch_op.f('ix_workshop_cloned_from_id'), ['cloned_from_id'], unique=False) + batch_op.create_index(batch_op.f('ix_workshop_join_slug'), ['join_slug'], unique=True) + batch_op.create_index(batch_op.f('ix_workshop_status'), ['status'], unique=False) + + op.create_table('broadcast', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('message_md', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('cleared_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_broadcast_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_broadcast')) + ) + with op.batch_alter_table('broadcast', schema=None) as batch_op: + batch_op.create_index('ix_broadcast_workshop_cleared', ['workshop_id', 'cleared_at'], unique=False) + + op.create_table('facilitator_action', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=True), + sa.Column('actor', sa.String(length=16), nullable=False), + sa.Column('action', sa.String(length=48), nullable=False), + sa.Column('detail_json', sa.Text(), nullable=False), + sa.Column('undo_data_json', sa.Text(), nullable=True), + sa.Column('undone_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_facilitator_action_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_facilitator_action')) + ) + with op.batch_alter_table('facilitator_action', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_facilitator_action_created_at'), ['created_at'], unique=False) + batch_op.create_index('ix_facilitator_action_workshop_created', ['workshop_id', 'created_at'], unique=False) + + op.create_table('milestone', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('content_md', sa.Text(), nullable=False), + sa.Column('minutes', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_milestone_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_milestone')) + ) + with op.batch_alter_table('milestone', schema=None) as batch_op: + batch_op.create_index('ix_milestone_workshop_id_position', ['workshop_id', 'position'], unique=False) + + op.create_table('participant', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('token', sa.String(length=32), nullable=False), + sa.Column('answers_json', sa.Text(), nullable=False), + sa.Column('joined_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_participant_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_participant')) + ) + with op.batch_alter_table('participant', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_participant_token'), ['token'], unique=True) + batch_op.create_index(batch_op.f('ix_participant_workshop_id'), ['workshop_id'], unique=False) + + op.create_table('help_request', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('milestone_id', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('escalated', sa.Boolean(), nullable=False), + sa.Column('ai_state', sa.String(length=16), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['milestone_id'], ['milestone.id'], name=op.f('fk_help_request_milestone_id_milestone'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['participant_id'], ['participant.id'], name=op.f('fk_help_request_participant_id_participant'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_help_request_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_help_request')) + ) + with op.batch_alter_table('help_request', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_help_request_milestone_id'), ['milestone_id'], unique=False) + batch_op.create_index(batch_op.f('ix_help_request_participant_id'), ['participant_id'], unique=False) + batch_op.create_index('ix_help_request_workshop_status_created', ['workshop_id', 'status', 'created_at'], unique=False) + + op.create_table('milestone_completion', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('milestone_id', sa.Integer(), nullable=False), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['milestone_id'], ['milestone.id'], name=op.f('fk_milestone_completion_milestone_id_milestone'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['participant_id'], ['participant.id'], name=op.f('fk_milestone_completion_participant_id_participant'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_milestone_completion')), + sa.UniqueConstraint('participant_id', 'milestone_id', name='uq_completion_participant_milestone') + ) + with op.batch_alter_table('milestone_completion', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_milestone_completion_milestone_id'), ['milestone_id'], unique=False) + batch_op.create_index(batch_op.f('ix_milestone_completion_participant_id'), ['participant_id'], unique=False) + + op.create_table('ai_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('help_request_id', sa.Integer(), nullable=True), + sa.Column('purpose', sa.String(length=16), nullable=False), + sa.Column('model', sa.String(length=120), nullable=False), + sa.Column('prompt_tokens', sa.Integer(), nullable=False), + sa.Column('completion_tokens', sa.Integer(), nullable=False), + sa.Column('cost_usd', sa.Numeric(precision=10, scale=6), nullable=False), + sa.Column('latency_ms', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['help_request_id'], ['help_request.id'], name=op.f('fk_ai_usage_help_request_id_help_request'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_ai_usage_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_ai_usage')) + ) + with op.batch_alter_table('ai_usage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_ai_usage_help_request_id'), ['help_request_id'], unique=False) + batch_op.create_index('ix_ai_usage_workshop_created', ['workshop_id', 'created_at'], unique=False) + + op.create_table('help_answer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('help_request_id', sa.Integer(), nullable=False), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('answer_md', sa.Text(), nullable=False), + sa.Column('draft', sa.Boolean(), nullable=False), + sa.Column('ai_confidence', sa.Numeric(precision=4, scale=3), nullable=True), + sa.Column('ai_model', sa.String(length=120), nullable=True), + sa.Column('ai_context_json', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['help_request_id'], ['help_request.id'], name=op.f('fk_help_answer_help_request_id_help_request'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_help_answer')) + ) + with op.batch_alter_table('help_answer', schema=None) as batch_op: + batch_op.create_index('ix_help_answer_request_created', ['help_request_id', 'created_at'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('help_answer', schema=None) as batch_op: + batch_op.drop_index('ix_help_answer_request_created') + + op.drop_table('help_answer') + with op.batch_alter_table('ai_usage', schema=None) as batch_op: + batch_op.drop_index('ix_ai_usage_workshop_created') + batch_op.drop_index(batch_op.f('ix_ai_usage_help_request_id')) + + op.drop_table('ai_usage') + with op.batch_alter_table('milestone_completion', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_milestone_completion_participant_id')) + batch_op.drop_index(batch_op.f('ix_milestone_completion_milestone_id')) + + op.drop_table('milestone_completion') + with op.batch_alter_table('help_request', schema=None) as batch_op: + batch_op.drop_index('ix_help_request_workshop_status_created') + batch_op.drop_index(batch_op.f('ix_help_request_participant_id')) + batch_op.drop_index(batch_op.f('ix_help_request_milestone_id')) + + op.drop_table('help_request') + with op.batch_alter_table('participant', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_participant_workshop_id')) + batch_op.drop_index(batch_op.f('ix_participant_token')) + + op.drop_table('participant') + with op.batch_alter_table('milestone', schema=None) as batch_op: + batch_op.drop_index('ix_milestone_workshop_id_position') + + op.drop_table('milestone') + with op.batch_alter_table('facilitator_action', schema=None) as batch_op: + batch_op.drop_index('ix_facilitator_action_workshop_created') + batch_op.drop_index(batch_op.f('ix_facilitator_action_created_at')) + + op.drop_table('facilitator_action') + with op.batch_alter_table('broadcast', schema=None) as batch_op: + batch_op.drop_index('ix_broadcast_workshop_cleared') + + op.drop_table('broadcast') + with op.batch_alter_table('workshop', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_workshop_status')) + batch_op.drop_index(batch_op.f('ix_workshop_join_slug')) + batch_op.drop_index(batch_op.f('ix_workshop_cloned_from_id')) + batch_op.drop_index(batch_op.f('ix_workshop_agenda_template_id')) + batch_op.drop_index(batch_op.f('ix_workshop_admin_token')) + + op.drop_table('workshop') + with op.batch_alter_table('agenda_template_milestone', schema=None) as batch_op: + batch_op.drop_index('ix_agenda_template_milestone_template_position') + + op.drop_table('agenda_template_milestone') + op.drop_table('join_form_template') + op.drop_table('agenda_template') + # ### end Alembic commands ### diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..f3a51c4 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,13 @@ +# Automatic HTTPS for Workshop Helmsman. +# Caddy obtains + renews a Let's Encrypt cert for $HELMSMAN_DOMAIN on first boot +# (port 80 + 443 must be reachable from the internet and DNS must already point +# at this host). All traffic is reverse-proxied to the app container on :8001. + +{ + email {$ACME_EMAIL} +} + +{$HELMSMAN_DOMAIN} { + encode gzip zstd + reverse_proxy app:8001 +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..fccdfb4 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 +# Workshop Helmsman v0.2 — production image. +# Stage 1 builds the Next.js static export; stage 2 runs FastAPI which serves +# that export at /app and the JSON API at /api. Single container, single port. + +# ---- Stage 1: build the frontend static export (frontend/out) ---- +FROM node:22-slim AS frontend +WORKDIR /build +RUN corepack enable +# Install deps against the lockfile first for better layer caching. +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml frontend/.nvmrc ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build # next.config.ts sets output:'export' basePath:'/app' -> ./out + +# ---- Stage 2: python runtime ---- +FROM python:3.12-slim-bookworm AS runtime +# uv for reproducible, fast installs (matches local toolchain). +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/srv/helmsman/.venv \ + UV_CACHE_DIR=/srv/helmsman/.cache/uv \ + HOME=/srv/helmsman \ + PORT=8001 \ + PYTHONUNBUFFERED=1 +WORKDIR /srv/helmsman + +# Dependency layer — only re-runs when the lockfile changes. +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +# Application code. Layout must mirror the repo: src/ and frontend/ are +# siblings so that _REPO_ROOT (parents[3]) resolves frontend/out correctly. +COPY alembic.ini ./ +COPY alembic/ ./alembic/ +COPY src/ ./src/ +COPY --from=frontend /build/out ./frontend/out +COPY deploy/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Non-root; owns the workdir so the data/ volume is writable. +RUN useradd --system --uid 10001 helmsman && chown -R helmsman:helmsman /srv/helmsman +USER helmsman + +EXPOSE 8001 +# Migrations run on boot inside the entrypoint (idempotent). +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md new file mode 100644 index 0000000..d316c02 --- /dev/null +++ b/deploy/RUNBOOK.md @@ -0,0 +1,165 @@ +# Deploy Runbook — Workshop Helmsman → workshops.smalltech.in + +Production is a single GCP VM running Docker Compose: the **app** container +(FastAPI serving the built Next.js frontend) behind a **Caddy** container that +terminates TLS with an automatic Let's Encrypt certificate. + +``` +Internet ──443──▶ Caddy (TLS for workshops.smalltech.in) ──▶ app:8001 + └▶ /srv/helmsman/data (SQLite, volume) +``` + +Two ways to ship: +- **Manual** (§1–§5): build/run on the VM directly. Enough to go live today. +- **Automated** (§6): push to `main` → GitHub Actions builds, pushes to GHCR, and + rolls the VM. Set up once, then every merge deploys. + +--- + +## 1. Provision the VM (GCP) + +```bash +gcloud compute instances create helmsman \ + --project=YOUR_PROJECT --zone=asia-south1-a \ + --machine-type=e2-small \ + --image-family=debian-12 --image-project=debian-cloud \ + --boot-disk-size=20GB \ + --tags=http-server,https-server + +# Allow 80/443 (skip any that already exist on the network) +gcloud compute firewall-rules create allow-http --allow=tcp:80 --target-tags=http-server +gcloud compute firewall-rules create allow-https --allow=tcp:443 --target-tags=https-server +``` + +`e2-small` (2 vCPU / 2 GB) is the floor for ~150 concurrent participants. Step up +to `e2-medium` (4 GB) for the 300+ target. A static external IP is recommended so +DNS never breaks: + +```bash +gcloud compute addresses create helmsman-ip --region=asia-south1 +gcloud compute instances delete-access-config helmsman --zone=asia-south1-a --access-config-name="external-nat" +gcloud compute instances add-access-config helmsman --zone=asia-south1-a --address=HELMSMAN_IP +``` + +## 2. DNS + +Point the hostname at the VM's external IP, then wait for propagation: + +``` +Type: A Name: workshops Value: TTL: 300 +``` + +Verify before continuing (TLS issuance fails if DNS isn't live): + +```bash +dig +short workshops.smalltech.in # must return the VM IP +``` + +## 3. Install Docker on the VM + +```bash +gcloud compute ssh helmsman --zone=asia-south1-a +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker "$USER" # log out/in so the group applies +``` + +## 4. Configure secrets on the VM + +```bash +sudo mkdir -p /opt/helmsman && sudo chown "$USER" /opt/helmsman +cd /opt/helmsman +cat > .env <<'EOF' +HELMSMAN_DOMAIN=workshops.smalltech.in +ACME_EMAIL=contact@smalltech.in +HELMSMAN_ADMIN_KEY= +# Optional — Phase 4 AI help-desk. Leave blank to run air-gapped. +OPENROUTER_API_KEY= +EOF +chmod 600 .env +``` + +Generate a strong admin key with `openssl rand -base64 24`. + +## 5. First deploy (manual build on the VM) + +Get the code onto the box (git clone the repo, or `scp` the `deploy/` dir plus +source). From the repo root on the VM: + +```bash +docker compose -f deploy/docker-compose.yml --env-file /opt/helmsman/.env up -d --build +docker compose -f deploy/docker-compose.yml logs -f caddy # watch cert issuance +``` + +Check it: + +```bash +curl -fsS https://workshops.smalltech.in/api/health # {"data":{"status":"ok","db":"ok"},...} +``` + +Open `https://workshops.smalltech.in/app/`, enter the admin key, run the create → +join → complete → help loop. **The first HTTPS request may take ~10–30s** while +Caddy provisions the certificate; subsequent requests are instant. + +--- + +## 6. Automated CD (GitHub Actions → VM) + +Once §1–§5 work manually, wire up hands-free deploys. + +1. Create a deploy SSH keypair; put the **public** key in the VM's + `~/.ssh/authorized_keys`, the **private** key in the repo secret `DEPLOY_SSH_KEY`. +2. Add repo secrets (Settings → Secrets and variables → Actions): + `DEPLOY_HOST` = VM IP, `DEPLOY_USER` = your SSH user. `GITHUB_TOKEN` is + provided automatically for GHCR. +3. Ensure `/opt/helmsman/.env` exists on the VM (from §4) — CD never ships secrets. +4. Merge to `main`. `.github/workflows/deploy.yml` builds the image, pushes it to + `ghcr.io//workshop-helmsman`, copies the prod compose + Caddyfile to + `/opt/helmsman`, pulls, and re-ups. The job fails if `/api/health` doesn't come + back green, so a bad image won't silently take the site down. + +> Note: `main` is currently the pre-rebuild branch. The rebuild lives on +> `feature/rebuild-v0.2` (PR #2). CD to production should only be armed after that +> PR merges to `main` — until then, deploy manually from the branch (§5). + +--- + +## 7. Backup & restore + +All durable state is the SQLite DB in the `helmsman-data` volume. + +**Backup** (cron it): + +```bash +docker compose -f docker-compose.prod.yml exec -T app \ + sh -c 'cd data && sqlite3 helmsman.db ".backup /tmp/backup.db" && cat /tmp/backup.db' \ + > "helmsman-$(date +%F).db" # date via shell substitution on the VM +``` + +If the image lacks `sqlite3`, snapshot the file directly (SQLite WAL is enabled; +copy while briefly paused or use `.backup` as above): + +```bash +docker run --rm -v helmsman_helmsman-data:/d -v "$PWD":/out alpine \ + cp /d/helmsman.db /out/helmsman-backup.db +``` + +**Restore:** + +```bash +docker compose -f docker-compose.prod.yml stop app +docker run --rm -v helmsman_helmsman-data:/d -v "$PWD":/in alpine \ + cp /in/helmsman-backup.db /d/helmsman.db +docker compose -f docker-compose.prod.yml start app +``` + +## 8. Operations quick reference + +| Task | Command (in `/opt/helmsman`) | +|------|------| +| Logs | `docker compose -f docker-compose.prod.yml logs -f app` | +| Restart app | `docker compose -f docker-compose.prod.yml restart app` | +| Roll to latest | `docker compose -f docker-compose.prod.yml pull app && docker compose -f docker-compose.prod.yml up -d` | +| Health | `curl -fsS https://workshops.smalltech.in/api/health` | +| DB size | `docker compose -f docker-compose.prod.yml exec app du -h data/helmsman.db` | + +Migrations run automatically on every boot (idempotent) — no manual step. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..e157038 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,51 @@ +# Production compose used ON THE VM by the Deploy workflow. +# Unlike docker-compose.yml (which builds locally), this PULLS a pre-built image +# pushed to GHCR by .github/workflows/deploy.yml. $HELMSMAN_IMAGE is exported by +# the deploy job to the exact sha-tagged image; it falls back to :latest for a +# manual `docker compose -f docker-compose.prod.yml up -d` on the box. +# +# The VM keeps /opt/helmsman/.env (HELMSMAN_ADMIN_KEY, HELMSMAN_DOMAIN, +# ACME_EMAIL, OPENROUTER_API_KEY). Secrets never leave the VM. + +services: + app: + image: ${HELMSMAN_IMAGE:-ghcr.io/smalltechorg/workshop-helmsman:latest} + restart: unless-stopped + env_file: .env + environment: + PORT: "8001" + DATABASE_URL: "sqlite:///data/helmsman.db" + HELMSMAN_BASE_URL: "https://${HELMSMAN_DOMAIN:?set HELMSMAN_DOMAIN in .env}" + volumes: + - helmsman-data:/srv/helmsman/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8001/api/health').status==200 else sys.exit(1)"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + expose: + - "8001" + ports: + # Expose loopback only so the deploy health check can reach it; Caddy + # fronts the public interface. + - "127.0.0.1:8001:8001" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - app + ports: + - "80:80" + - "443:443" + env_file: .env + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + +volumes: + helmsman-data: + caddy-data: + caddy-config: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 480ffdb..38225ad 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,23 +1,62 @@ -version: "3.9" +# Workshop Helmsman v0.2 — production compose. +# +# app — FastAPI + built frontend, single container, listens on :8001 (internal only). +# caddy — reverse proxy terminating TLS for ${HELMSMAN_DOMAIN} via Let's Encrypt. +# +# Data survives restarts and redeploys via named volumes: +# helmsman-data -> the SQLite database (data/helmsman.db) +# caddy-data -> issued TLS certificates (don't lose these — rate limits) +# +# Usage (see deploy/RUNBOOK.md for the full VM/DNS/TLS walkthrough): +# cp .env.example .env # then set HELMSMAN_ADMIN_KEY, HELMSMAN_DOMAIN, ACME_EMAIL +# docker compose -f deploy/docker-compose.yml up -d --build services: - helmsman: - build: . - image: workshop-helmsman:0.1 - container_name: workshop-helmsman + app: + build: + context: .. + dockerfile: deploy/Dockerfile + image: workshop-helmsman:latest restart: unless-stopped - ports: - - "8001:8001" environment: - BIND_HOST: "0.0.0.0" - BIND_PORT: "8001" + PORT: "8001" + DATABASE_URL: "sqlite:///data/helmsman.db" + # Public https URL so generated share links are correct behind the proxy. + HELMSMAN_BASE_URL: "https://${HELMSMAN_DOMAIN:?set HELMSMAN_DOMAIN in .env}" + HELMSMAN_LOG_LEVEL: "${HELMSMAN_LOG_LEVEL:-INFO}" + HELMSMAN_ADMIN_KEY: "${HELMSMAN_ADMIN_KEY:?set HELMSMAN_ADMIN_KEY in .env}" + # Phase 4 AI (optional) — absent means the product runs air-gapped. + OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" + HELMSMAN_AI_MODEL: "${HELMSMAN_AI_MODEL:-anthropic/claude-sonnet-4-6}" + HELMSMAN_AI_CONFIDENCE: "${HELMSMAN_AI_CONFIDENCE:-0.75}" volumes: - - helmsman-data:/app/data + - helmsman-data:/srv/helmsman/data healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:8001/healthz"] + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://localhost:8001/api/health').status==200 else sys.exit(1)"] interval: 30s timeout: 5s - retries: 3 + retries: 5 + start_period: 20s + expose: + - "8001" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - app + ports: + - "80:80" + - "443:443" + environment: + HELMSMAN_DOMAIN: "${HELMSMAN_DOMAIN:?set HELMSMAN_DOMAIN in .env}" + ACME_EMAIL: "${ACME_EMAIL:?set ACME_EMAIL in .env}" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config volumes: helmsman-data: + caddy-data: + caddy-config: diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..1a0cdac --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Boot sequence: ensure the data dir exists, apply migrations, then serve. +# Migrations are idempotent (alembic tracks the head), safe on every restart. +set -eu + +mkdir -p data + +echo "[entrypoint] applying migrations..." +uv run --no-dev alembic upgrade head + +echo "[entrypoint] starting server on :${PORT:-8001}..." +exec uv run --no-dev python -m src diff --git a/deploy/systemd/workshop-helmsman.service b/deploy/systemd/workshop-helmsman.service deleted file mode 100644 index 66c55f3..0000000 --- a/deploy/systemd/workshop-helmsman.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=Workshop Helmsman — milestone tracker (FastAPI/uvicorn) -After=network.target - -[Service] -Type=simple -User=sai -WorkingDirectory=/Users/sai/workshop-helmsman -Environment="BIND_HOST=0.0.0.0" -Environment="BIND_PORT=8001" -ExecStart=/Users/sai/workshop-helmsman/venv/bin/python -m src -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=multi-user.target diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..7833851 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +out/ +*.tsbuildinfo diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/app/f/page.tsx b/frontend/app/f/page.tsx new file mode 100644 index 0000000..b04b49a --- /dev/null +++ b/frontend/app/f/page.tsx @@ -0,0 +1,623 @@ +"use client"; + +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSearchParams } from "next/navigation"; +import { + ApiError, + facilitatorAdvance, + facilitatorAnswerHelp, + facilitatorBroadcast, + facilitatorClearBroadcast, + facilitatorDashboard, + facilitatorPause, + facilitatorResolveHelp, + facilitatorUndo, + facilitatorWorkshop, + type DashboardPayload, + type FacilitatorWorkshopPayload, + type HelpQueueItem, +} from "@/lib/api"; +import { usePoll } from "@/lib/poll"; +import { useNowTick, cn } from "@/lib/format"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { Button } from "@/components/ui/Button"; +import { CopyButton } from "@/components/ui/CopyButton"; +import { ConnectionIndicator } from "@/components/ui/ConnectionIndicator"; +import { WorkshopStatusBadge } from "@/components/ui/Badge"; +import { StubAction } from "@/components/ui/StubBadge"; +import { useToast } from "@/components/ui/Toast"; +import { StatCards } from "@/components/facilitator/StatCards"; +import { MilestoneBars } from "@/components/facilitator/MilestoneBars"; +import { DistributionChart } from "@/components/facilitator/DistributionChart"; +import { ParticipantTable } from "@/components/facilitator/ParticipantTable"; +import { HelpQueue } from "@/components/facilitator/HelpQueue"; +import { + ActiveBroadcastBar, + BroadcastAction, + BroadcastComposer, +} from "@/components/facilitator/BroadcastPanel"; +import { UndoBanner, type UndoState } from "@/components/facilitator/UndoBanner"; +import { PauseControl } from "@/components/facilitator/PauseControl"; +import { MilestonesTab } from "@/components/facilitator/MilestonesTab"; +import { AuditPanel } from "@/components/facilitator/AuditPanel"; +import { StuckCard, BottleneckCard } from "@/components/facilitator/AlertsCards"; +import { PulseCard } from "@/components/facilitator/PulseCard"; +import { SettingsControl } from "@/components/facilitator/SettingsControl"; + +function DashboardSkeleton() { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+ + +
+ +
+
+ ); +} + +function ErrorScreen({ title, hint }: { title: string; hint: string }) { + return ( +
+ + +

{title}

+

{hint}

+
+
+ ); +} + +function DashboardInner() { + const params = useSearchParams(); + const token = params.get("t"); + const toast = useToast(); + const nowMs = useNowTick(30000); + + // ---- dashboard poll (2 s visible / 10 s hidden) ------------------------- + const fetcher = useCallback( + (v: number) => facilitatorDashboard(token as string, v), + [token], + ); + const { data, contentVersion, reconnecting, fatalError, pollNow } = + usePoll(token ? fetcher : null, { + visibleMs: 2000, + hiddenMs: 10000, + }); + + // ---- workshop payload (header, links, milestone bodies) ----------------- + // Fetched on load and re-fetched whenever the poll reports a newer + // content_version (spec/api.md). + const [wsPayload, setWsPayload] = useState( + null, + ); + const [wsFatal, setWsFatal] = useState(null); + const wsCvRef = useRef(-2); // -2 = never fetched + const [wsRetry, setWsRetry] = useState(0); + useEffect(() => { + if (!token) return; + if (wsCvRef.current !== -2 && wsCvRef.current >= contentVersion) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + facilitatorWorkshop(token) + .then((payload) => { + if (cancelled) return; + wsCvRef.current = payload.content_version; + setWsPayload(payload); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setWsFatal(err); + } else { + retryTimer = setTimeout(() => setWsRetry((x) => x + 1), 3000); + } + }); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [token, contentVersion, wsRetry]); + + // ---- optimistic help-queue overrides ------------------------------------ + const [overrides, setOverrides] = useState< + Record + >({}); + const [busyIds, setBusyIds] = useState>(new Set()); + + useEffect(() => { + if (!data) return; + setOverrides((prev) => { + const next: typeof prev = {}; + let changed = false; + for (const [k, v] of Object.entries(prev)) { + if (data.version >= v.minVersion) changed = true; + else next[Number(k)] = v; + } + return changed ? next : prev; + }); + }, [data]); + + const queue = useMemo( + () => + (data?.help_queue ?? []).map((item) => overrides[item.id]?.item ?? item), + [data, overrides], + ); + + const withBusy = async (id: number, fn: () => Promise) => { + setBusyIds((prev) => new Set(prev).add(id)); + try { + await fn(); + } finally { + setBusyIds((prev) => { + const copy = new Set(prev); + copy.delete(id); + return copy; + }); + } + }; + + const onAnswer = async (id: number, answerMd: string): Promise => { + if (!token) return false; + let ok = false; + await withBusy(id, async () => { + try { + const res = await facilitatorAnswerHelp(token, id, answerMd); + setOverrides((prev) => ({ + ...prev, + [id]: { item: res.help_request, minVersion: res.version }, + })); + pollNow(); + ok = true; + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't send the answer — ${err.message}` + : "Couldn't send the answer — check your connection and try again.", + "error", + ); + } + }); + return ok; + }; + + const onResolve = async (id: number) => { + if (!token) return; + await withBusy(id, async () => { + try { + const res = await facilitatorResolveHelp(token, id); + setOverrides((prev) => ({ + ...prev, + [id]: { item: res.help_request, minVersion: res.version }, + })); + pollNow(); + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't resolve that — ${err.message}` + : "Couldn't resolve that — check your connection and try again.", + "error", + ); + } + }); + }; + + // ---- crowd milestone: where the largest group currently sits ------------ + const crowdMilestoneId = useMemo(() => { + if (!data || data.participants.length === 0) return null; + const counts = new Map(); + for (const p of data.participants) { + if (p.current_milestone_id !== null) { + counts.set( + p.current_milestone_id, + (counts.get(p.current_milestone_id) ?? 0) + 1, + ); + } + } + let best: number | null = null; + let bestCount = 0; + for (const [id, count] of counts) { + if (count > bestCount) { + best = id; + bestCount = count; + } + } + return best; + }, [data]); + + // ---- broadcast composer + undo toast ------------------------------------- + const [broadcastOpen, setBroadcastOpen] = useState(false); + const [broadcastSubmitting, setBroadcastSubmitting] = useState(false); + const [clearingBroadcast, setClearingBroadcast] = useState(false); + const [undo, setUndo] = useState(null); + const [undoBusy, setUndoBusy] = useState(false); + + const issueUndo = (actionId: number, label: string) => { + setUndo({ actionId, label, expiresAt: Date.now() + 30000 }); + }; + + const onSendBroadcast = async (messageMd: string): Promise => { + if (!token) return false; + setBroadcastSubmitting(true); + try { + const res = await facilitatorBroadcast(token, messageMd); + pollNow(); + issueUndo(res.undoable_action_id, "Broadcast sent"); + return true; + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't send that — ${err.message}` + : "Couldn't send that — check your connection and try again.", + "error", + ); + return false; + } finally { + setBroadcastSubmitting(false); + } + }; + + const onClearBroadcast = async () => { + if (!token) return; + setClearingBroadcast(true); + try { + await facilitatorClearBroadcast(token); + pollNow(); + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't clear that — ${err.message}` + : "Couldn't clear that — check your connection and try again.", + "error", + ); + } finally { + setClearingBroadcast(false); + } + }; + + // ---- pause/resume --------------------------------------------------------- + const [pauseBusy, setPauseBusy] = useState(false); + const onTogglePause = async () => { + if (!token || !data) return; + const next = !data.workshop.paused; + setPauseBusy(true); + try { + const res = await facilitatorPause(token, next); + pollNow(); + issueUndo(res.undoable_action_id, next ? "Workshop paused" : "Workshop resumed"); + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't change that — ${err.message}` + : "Couldn't change that — check your connection and try again.", + "error", + ); + } finally { + setPauseBusy(false); + } + }; + + const onUndo = async (actionId: number) => { + if (!token) return; + setUndoBusy(true); + try { + await facilitatorUndo(token, actionId); + pollNow(); + toast.show("Undone.", "success"); + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't undo that — ${err.message}` + : "Couldn't undo that — check your connection and try again.", + "error", + ); + } finally { + setUndoBusy(false); + setUndo(null); + } + }; + + // ---- advance (bulk) -------------------------------------------------------- + // Fires immediately like pause/broadcast; the undo toast is the safety net for + // a fat-fingered advance (spec/roadmap.md Phase 2 — undo, not an upfront gate). + const [advanceBusy, setAdvanceBusy] = useState(false); + + const runAdvance = async ( + milestoneId: number, + title: string, + participantIds: number[] | null, + ) => { + if (!token || advanceBusy) return; + setAdvanceBusy(true); + try { + const res = await facilitatorAdvance(token, milestoneId, participantIds); + pollNow(); + setSelectedIds(new Set()); + issueUndo(res.undoable_action_id, `Advanced ${res.affected_count} to "${title}"`); + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't advance that — ${err.message}` + : "Couldn't advance that — check your connection and try again.", + "error", + ); + } finally { + setAdvanceBusy(false); + } + }; + + // ---- participant selection (advance-selected) ------------------------------ + const [selectedIds, setSelectedIds] = useState>(new Set()); + const toggleSelect = (id: number) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + // ---- milestone management modal -------------------------------------------- + const [manageOpen, setManageOpen] = useState(false); + + // ---- right-rail tab: help queue vs audit trail ------------------------------ + const [rightTab, setRightTab] = useState<"help" | "audit" | "settings">("help"); + + // ---- render ------------------------------------------------------------- + if (!token) { + return ( + + ); + } + if (fatalError || wsFatal) { + return ( + + ); + } + if (!data || !wsPayload) return ; + + const ws = wsPayload.workshop; + + return ( +
+
+
+
+

+ {ws.name} +

+ + {data.workshop.paused && ( + Paused + )} + + {data.stats.participant_count} participants ·{" "} + {data.stats.active_count} active + + +
+ + {ws.join_url} + + +
+
+
+ setBroadcastOpen(true)} /> + + + + +
+
+
+ + {data.broadcast && ( + + )} + +
+ + +
+
+ +

+ Milestone completion +

+ {data.milestone_stats.length === 0 ? ( +

+ This workshop has no milestones. +

+ ) : ( + runAdvance(id, title, null)} + /> + )} +
+ + +

+ Cohort distribution +

+ {data.stats.participant_count === 0 ? ( +

+ The room's shape appears here once people join. +

+ ) : ( + + )} +
+ + +

+ Participants +

+ runAdvance(id, title, ids)} + /> +
+
+ +
+ +
+ + + +
+ {rightTab === "help" ? ( + + ) : rightTab === "audit" ? ( + + ) : ( + pollNow()} /> + )} +
+ +
+ + +
+ +
+
+
+ + {manageOpen && ( +
+ +
+

Manage milestones

+ +
+ { + pollNow(); + wsCvRef.current = -2; + setWsRetry((x) => x + 1); + }} + /> +
+
+ )} + + setBroadcastOpen(false)} + onSend={onSendBroadcast} + /> + + setUndo(null)} /> +
+ ); +} + +export default function DashboardPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..7f5cb58 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,254 @@ +@import "tailwindcss"; +@source "../"; + +@theme { + /* Brand — indigo */ + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-300: #a5b4fc; + --color-brand-400: #818cf8; + --color-brand-500: #6366f1; + --color-brand-600: #4f46e5; + --color-brand-700: #4338ca; + --color-brand-800: #3730a3; + --color-brand-900: #312e81; + --color-brand-950: #1e1b4b; + + /* Type */ + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + + /* Motion */ + --animate-flash: flash 1.8s ease-out 1; + + @keyframes flash { + 0% { + background-color: var(--color-brand-100); + } + 100% { + background-color: transparent; + } + } +} + +@layer base { + html { + scroll-behavior: smooth; + } + + body { + background-color: var(--color-stone-50); + color: var(--color-stone-900); + -webkit-font-smoothing: antialiased; + font-size: 16px; + } + + :focus-visible { + outline: 2px solid var(--color-brand-500); + outline-offset: 2px; + border-radius: 4px; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + html { + scroll-behavior: auto; + } + } +} + +/* ---------------------------------------------------------------- */ +/* Custom checkbox (milestone toggle) */ +/* ---------------------------------------------------------------- */ + +.check-input { + appearance: none; + -webkit-appearance: none; + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + border-radius: 0.5rem; + border: 2px solid var(--color-stone-300); + background-color: white; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease, + transform 120ms ease; +} + +.check-input:hover:not(:disabled) { + border-color: var(--color-brand-400); +} + +.check-input:active:not(:disabled) { + transform: scale(0.92); +} + +.check-input:checked { + background-color: var(--color-emerald-500); + border-color: var(--color-emerald-500); + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3e%3c/svg%3e"); + background-size: 100% 100%; +} + +.check-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---------------------------------------------------------------- */ +/* Markdown ("md") — react-markdown output styling */ +/* ---------------------------------------------------------------- */ + +.md { + line-height: 1.65; + overflow-wrap: break-word; +} + +.md > :first-child { + margin-top: 0; +} + +.md > :last-child { + margin-bottom: 0; +} + +.md p, +.md ul, +.md ol, +.md blockquote, +.md table { + margin-top: 0; + margin-bottom: 0.75rem; +} + +.md h1, +.md h2, +.md h3, +.md h4, +.md h5, +.md h6 { + font-weight: 600; + color: var(--color-stone-900); + margin-top: 1.25rem; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.md h1 { + font-size: 1.375rem; +} + +.md h2 { + font-size: 1.2rem; +} + +.md h3 { + font-size: 1.05rem; +} + +.md h4, +.md h5, +.md h6 { + font-size: 1rem; +} + +.md a { + color: var(--color-brand-700); + text-decoration: underline; + text-underline-offset: 2px; +} + +.md a:hover { + color: var(--color-brand-800); +} + +.md ul { + list-style: disc; + padding-left: 1.5rem; +} + +.md ol { + list-style: decimal; + padding-left: 1.5rem; +} + +.md li { + margin-bottom: 0.25rem; +} + +.md li > ul, +.md li > ol { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.md code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.875em; + background-color: var(--color-stone-100); + border: 1px solid var(--color-stone-200); + border-radius: 0.375rem; + padding: 0.125rem 0.375rem; +} + +.md pre { + position: relative; + margin-top: 0; + margin-bottom: 0.75rem; + background-color: #ffffff; + border: 1px solid var(--color-stone-200); + border-radius: 0.625rem; + overflow-x: auto; +} + +.md pre code { + display: block; + background: transparent; + border: none; + padding: 0.875rem 1rem; + font-size: 0.85rem; + line-height: 1.55; +} + +.md blockquote { + border-left: 3px solid var(--color-stone-300); + padding-left: 0.875rem; + color: var(--color-stone-600); +} + +.md hr { + border: none; + border-top: 1px solid var(--color-stone-200); + margin: 1rem 0; +} + +.md table { + border-collapse: collapse; + width: 100%; + font-size: 0.9375rem; +} + +.md th, +.md td { + border: 1px solid var(--color-stone-200); + padding: 0.375rem 0.625rem; + text-align: left; +} + +.md th { + background-color: var(--color-stone-100); + font-weight: 600; +} + +.md img { + max-width: 100%; + border-radius: 0.5rem; +} diff --git a/frontend/app/icon.svg b/frontend/app/icon.svg new file mode 100644 index 0000000..d2484cf --- /dev/null +++ b/frontend/app/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/app/join/page.tsx b/frontend/app/join/page.tsx new file mode 100644 index 0000000..3563f7b --- /dev/null +++ b/frontend/app/join/page.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ApiError, joinInfo, joinWorkshop, type JoinInfo } from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { Markdown } from "@/components/ui/Markdown"; + +function JoinSkeleton() { + return ( +
+ + + + + + +
+ ); +} + +function ErrorCard({ title, hint }: { title: string; hint: string }) { + return ( +
+ + +

{title}

+

{hint}

+
+
+ ); +} + +function JoinInner() { + const params = useSearchParams(); + const router = useRouter(); + const slug = params.get("s"); + + const [info, setInfo] = useState(null); + const [loadError, setLoadError] = useState<"not_found" | "network" | null>(null); + const [name, setName] = useState(""); + const [nameError, setNameError] = useState(null); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + + useEffect(() => { + if (!slug) return; + let cancelled = false; + joinInfo(slug) + .then((res) => { + if (!cancelled) setInfo(res); + }) + .catch((err) => { + if (cancelled) return; + setLoadError( + err instanceof ApiError && err.status === 404 ? "not_found" : "network", + ); + }); + return () => { + cancelled = true; + }; + }, [slug]); + + // Cookie auto-resume: this browser already joined — forward to the tracker. + useEffect(() => { + if (!info?.me) return; + const token = info.me.participant_token; + const timer = setTimeout(() => { + router.replace(`/p/?t=${encodeURIComponent(token)}`); + }, 1400); + return () => clearTimeout(timer); + }, [info, router]); + + if (!slug) { + return ( + + ); + } + if (loadError === "not_found") { + return ( + + ); + } + if (loadError === "network") { + return ( + + ); + } + if (!info) return ; + + const { workshop, me } = info; + + if (me) { + return ( +
+ + +

+ Welcome back, {me.name} +

+

+ Taking you to your tracker for “{workshop.name}”… +

+ +
+
+ ); + } + + if (workshop.status === "archived") { + return ( + + ); + } + + const submit = async () => { + const trimmed = name.trim(); + if (trimmed.length < 1) { + setNameError("Enter your name so the room knows who you are."); + return; + } + if (trimmed.length > 80) { + setNameError("That name is too long — keep it under 80 characters."); + return; + } + setNameError(null); + setJoinError(null); + setJoining(true); + try { + const res = await joinWorkshop(slug, trimmed); + router.replace(`/p/?t=${encodeURIComponent(res.participant_token)}`); + } catch (err) { + setJoining(false); + setJoinError( + err instanceof ApiError + ? err.message + : "Couldn't join — check your connection and try again.", + ); + } + }; + + return ( +
+ +

+ {workshop.name} +

+

+ {workshop.milestone_count}{" "} + {workshop.milestone_count === 1 ? "milestone" : "milestones"} ·{" "} + {workshop.participant_count === 0 + ? "be the first to join" + : `${workshop.participant_count} ${ + workshop.participant_count === 1 ? "person is" : "people are" + } in`} +

+ + {workshop.description_md.trim() !== "" && ( +
+ {workshop.description_md} +
+ )} + +
{ + e.preventDefault(); + void submit(); + }} + > + + { + setName(e.target.value); + if (nameError) setNameError(null); + }} + placeholder="e.g. Priya" + className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-brand-500" + /> + {nameError && ( +

+ {nameError} +

+ )} + {joinError && ( +

+ {joinError} +

+ )} + +
+
+
+ ); +} + +export default function JoinPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..46fa174 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "highlight.js/styles/github.css"; +import "./globals.css"; +import { ToastProvider } from "@/components/ui/Toast"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", + display: "swap", +}); + +export const metadata: Metadata = { + title: "Workshop Helmsman", + description: + "Self-hosted workshop tracker — live milestones, leaderboard, and help desk for facilitator-led labs.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/p/page.tsx b/frontend/app/p/page.tsx new file mode 100644 index 0000000..7406852 --- /dev/null +++ b/frontend/app/p/page.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSearchParams } from "next/navigation"; +import { + ApiError, + completeMilestone, + participantContent, + participantResolveHelp, + participantState, + prettyParticipantUrl, + submitHelp, + uncompleteMilestone, + type ContentPayload, + type StatePayload, + type TrackerHelpRequest, +} from "@/lib/api"; +import { usePoll } from "@/lib/poll"; +import { useNowTick } from "@/lib/format"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { ConnectionIndicator } from "@/components/ui/ConnectionIndicator"; +import { useToast } from "@/components/ui/Toast"; +import { MilestoneList } from "@/components/participant/MilestoneList"; +import { Leaderboard } from "@/components/participant/Leaderboard"; +import { HelpPanel } from "@/components/participant/HelpPanel"; +import { PersonalLinkCallout } from "@/components/participant/PersonalLinkCallout"; +import { BroadcastBanner } from "@/components/participant/BroadcastBanner"; + +function TrackerSkeleton() { + return ( +
+
+
+ + +
+
+
+
+ + + + +
+
+ + +
+
+
+ ); +} + +function ErrorScreen({ title, hint }: { title: string; hint: string }) { + return ( +
+ + +

{title}

+

{hint}

+
+
+ ); +} + +function TrackerInner() { + const params = useSearchParams(); + const token = params.get("t"); + const toast = useToast(); + const nowMs = useNowTick(30000); + + // ---- state poll (3 s visible / 15 s hidden) ----------------------------- + const fetcher = useCallback( + (v: number) => participantState(token as string, v), + [token], + ); + const { data, contentVersion, reconnecting, fatalError, pollNow } = + usePoll(token ? fetcher : null, { + visibleMs: 3000, + hiddenMs: 15000, + }); + + // ---- milestone bodies (content endpoint, keyed by content_version) ------ + const [content, setContent] = useState(null); + const contentCvRef = useRef(-1); + const [contentRetry, setContentRetry] = useState(0); + useEffect(() => { + if (!token || contentVersion < 0) return; + if (contentCvRef.current >= contentVersion) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + participantContent(token, contentCvRef.current) + .then((res) => { + if (cancelled) return; + contentCvRef.current = res.content_version; + if (res.changed) setContent(res); + }) + .catch(() => { + if (cancelled) return; + retryTimer = setTimeout(() => setContentRetry((x) => x + 1), 3000); + }); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [token, contentVersion, contentRetry]); + + const contentById = useMemo(() => { + if (!content) return null; + return new Map(content.milestones.map((m) => [m.id, m.content_md])); + }, [content]); + + // ---- optimistic completion overlay -------------------------------------- + const [overlay, setOverlay] = useState>({}); + useEffect(() => { + if (!data) return; + setOverlay((prev) => { + const base = new Set(data.me.completed_milestone_ids); + const next: Record = {}; + let changed = false; + for (const [k, v] of Object.entries(prev)) { + if (base.has(Number(k)) === v) { + changed = true; // server agrees — drop the overlay entry + } else { + next[Number(k)] = v; + } + } + return changed ? next : prev; + }); + }, [data]); + + const completedIds = useMemo(() => { + const s = new Set(data?.me.completed_milestone_ids ?? []); + for (const [k, v] of Object.entries(overlay)) { + if (v) s.add(Number(k)); + else s.delete(Number(k)); + } + return s; + }, [data, overlay]); + + const currentId = useMemo(() => { + const ordered = [...(data?.milestones ?? [])].sort( + (a, b) => a.position - b.position, + ); + for (const m of ordered) { + if (!completedIds.has(m.id)) return m.id; + } + return null; + }, [data, completedIds]); + + const onToggle = async (milestoneId: number, next: boolean) => { + if (!token) return; + setOverlay((prev) => ({ ...prev, [milestoneId]: next })); + try { + if (next) await completeMilestone(token, milestoneId); + else await uncompleteMilestone(token, milestoneId); + pollNow(); + } catch (err) { + setOverlay((prev) => { + const copy = { ...prev }; + delete copy[milestoneId]; + return copy; + }); + toast.show( + err instanceof ApiError + ? `Couldn't save that — ${err.message}` + : "Couldn't save that — check your connection and try again.", + "error", + ); + } + }; + + // ---- optimistic help ---------------------------------------------------- + const [helpSubmitting, setHelpSubmitting] = useState(false); + const [tempHelp, setTempHelp] = useState<{ + message: string; + created_at: string; + minVersion: number | null; + } | null>(null); + const [resolvedOverride, setResolvedOverride] = useState>(new Set()); + + useEffect(() => { + if (!data) return; + // Prune the temp entry once the poll payload includes the real request. + setTempHelp((prev) => + prev && prev.minVersion !== null && data.version >= prev.minVersion + ? null + : prev, + ); + // Prune resolve overrides the server now reflects. + setResolvedOverride((prev) => { + const stillPending = new Set( + [...prev].filter((id) => + data.help_requests.some((r) => r.id === id && r.status !== "resolved"), + ), + ); + return stillPending.size === prev.size ? prev : stillPending; + }); + }, [data]); + + const helpRequests: TrackerHelpRequest[] = useMemo(() => { + let merged = (data?.help_requests ?? []).map((r) => + resolvedOverride.has(r.id) ? { ...r, status: "resolved" as const } : r, + ); + if (tempHelp) { + merged = [ + { + id: -1, + message: tempHelp.message, + status: "open" as const, + escalated: false, + milestone_id: null, + created_at: tempHelp.created_at, + answers: [], + }, + ...merged, + ]; + } + return merged; + }, [data, tempHelp, resolvedOverride]); + + const onSubmitHelp = async (message: string): Promise => { + if (!token) return false; + setHelpSubmitting(true); + setTempHelp({ + message, + created_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + minVersion: null, + }); + try { + const res = await submitHelp(token, message); + setTempHelp((prev) => (prev ? { ...prev, minVersion: res.version } : prev)); + pollNow(); + return true; + } catch (err) { + setTempHelp(null); + toast.show( + err instanceof ApiError + ? `Couldn't send that — ${err.message}` + : "Couldn't send that — check your connection and try again.", + "error", + ); + return false; + } finally { + setHelpSubmitting(false); + } + }; + + const onResolveHelp = async (id: number) => { + if (!token) return; + setResolvedOverride((prev) => new Set(prev).add(id)); + try { + await participantResolveHelp(token, id); + pollNow(); + } catch (err) { + setResolvedOverride((prev) => { + const copy = new Set(prev); + copy.delete(id); + return copy; + }); + toast.show( + err instanceof ApiError + ? `Couldn't resolve that — ${err.message}` + : "Couldn't resolve that — check your connection and try again.", + "error", + ); + } + }; + + // ---- render ------------------------------------------------------------- + if (!token) { + return ( + + ); + } + if (fatalError) { + return ( + + ); + } + if (!data) return ; + + const total = data.me.total_count; + const doneCount = completedIds.size; + const progressPct = total > 0 ? (doneCount / total) * 100 : 0; + const personalUrl = + typeof window === "undefined" ? "" : prettyParticipantUrl(token); + const archived = data.workshop.status === "archived"; + + return ( +
+
+
+

+ {data.workshop.name} +

+ +
+ + + {doneCount} / {total} + +
+
+
+ + + + {data.workshop.paused && ( +
+ The facilitator paused the workshop — completions are locked for a moment. +
+ )} + {archived && ( +
+ This workshop has ended — you're browsing the archive. +
+ )} + +
+
+ + +
+ +
+ +

+ Leaderboard +

+ +
+ + + +
+
+
+ ); +} + +export default function TrackerPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..558d928 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + ApiError, + adminListWorkshops, + type AdminWorkshopSummary, + type WorkshopFull, +} from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { StubBadge, StubCard } from "@/components/ui/StubBadge"; +import { useToast } from "@/components/ui/Toast"; +import { WorkshopCard } from "@/components/facilitator/WorkshopCard"; +import { CreateWorkshopModal } from "@/components/facilitator/CreateWorkshopModal"; + +const LS_ADMIN_KEY = "helmsman_admin_key"; + +type Phase = "boot" | "gate" | "authed"; + +export default function AdminHomePage() { + const toast = useToast(); + const [phase, setPhase] = useState("boot"); + const [adminKey, setAdminKey] = useState(""); + const [keyInput, setKeyInput] = useState(""); + const [gateError, setGateError] = useState(null); + const [gateChecking, setGateChecking] = useState(false); + const [workshops, setWorkshops] = useState(null); + const [listError, setListError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [justCreatedId, setJustCreatedId] = useState(null); + + const validateKey = useCallback( + async (key: string, fromSaved: boolean) => { + setGateChecking(true); + try { + const { workshops: list } = await adminListWorkshops(key); + try { + window.localStorage.setItem(LS_ADMIN_KEY, key); + } catch { + // Private-mode browsers: the key just won't persist. + } + setAdminKey(key); + setWorkshops(list); + setGateError(null); + setPhase("authed"); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + try { + window.localStorage.removeItem(LS_ADMIN_KEY); + } catch { + // ignore + } + setGateError( + fromSaved + ? "Your saved key no longer matches this server — enter it again." + : "invalid_key", + ); + } else { + setGateError( + "Couldn't reach the server — check it's running, then try again.", + ); + } + setPhase("gate"); + } finally { + setGateChecking(false); + } + }, + [], + ); + + useEffect(() => { + let saved: string | null = null; + try { + saved = window.localStorage.getItem(LS_ADMIN_KEY); + } catch { + // ignore + } + if (saved) { + void validateKey(saved, true); + } else { + setPhase("gate"); + } + }, [validateKey]); + + const refreshList = useCallback(async () => { + if (!adminKey) return; + setRefreshing(true); + setListError(null); + try { + const { workshops: list } = await adminListWorkshops(adminKey); + setWorkshops(list); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setPhase("gate"); + setGateError("Your key no longer matches this server — enter it again."); + } else { + setListError("Couldn't refresh the list — check your connection and try again."); + } + } finally { + setRefreshing(false); + } + }, [adminKey]); + + const onCreated = (workshop: WorkshopFull) => { + setCreateOpen(false); + setJustCreatedId(workshop.id); + toast.show(`“${workshop.name}” is live`, "success"); + void refreshList(); + }; + + const signOut = () => { + try { + window.localStorage.removeItem(LS_ADMIN_KEY); + } catch { + // ignore + } + setAdminKey(""); + setWorkshops(null); + setKeyInput(""); + setGateError(null); + setPhase("gate"); + }; + + // ------------------------------------------------------------- boot + if (phase === "boot") { + return ( +
+ +
+ + +
+
+ ); + } + + // ------------------------------------------------------------- gate + if (phase === "gate") { + return ( +
+ +
+ +

+ Workshop Helmsman +

+

+ Enter this server's facilitator access key to manage workshops. +

+
+
{ + e.preventDefault(); + const key = keyInput.trim(); + if (key === "") { + setGateError("invalid_key_empty"); + return; + } + void validateKey(key, false); + }} + > + + setKeyInput(e.target.value)} + className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-stone-900 focus:border-brand-500" + /> + {gateError && ( +

+ {gateError === "invalid_key" ? ( + <> + That key doesn't match this server's{" "} + + HELMSMAN_ADMIN_KEY + + . + + ) : gateError === "invalid_key_empty" ? ( + "Enter the access key to continue." + ) : ( + gateError + )} +

+ )} + +
+
+
+ ); + } + + // ----------------------------------------------------------- authed + const list = workshops ?? []; + + return ( +
+
+
+ +

Workshop Helmsman

+
+ +
+ + {listError && ( +

+ {listError} + +

+ )} + +
+

+ Live +

+ {list.length === 0 ? ( + setCreateOpen(true)}>+ New workshop + } + /> + ) : ( +
+ {list.map((w) => ( + + ))} +
+ )} +
+ +
+ + +
+ + setCreateOpen(false)} + onCreated={onCreated} + /> +
+ ); +} diff --git a/frontend/components/facilitator/AlertsCards.tsx b/frontend/components/facilitator/AlertsCards.tsx new file mode 100644 index 0000000..820bae9 --- /dev/null +++ b/frontend/components/facilitator/AlertsCards.tsx @@ -0,0 +1,54 @@ +import type { DashboardAlerts } from "@/lib/api"; +import { Card } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; + +export function StuckCard({ alerts }: { alerts: DashboardAlerts | null }) { + const stuck = alerts?.stuck ?? []; + return ( + +
+

Stuck participants

+ {stuck.length > 0 && {stuck.length}} +
+ {stuck.length === 0 ? ( +

+ All clear — everyone's active. +

+ ) : ( +
    + {stuck.map((s) => ( +
  • + {s.name} + + {s.minutes_inactive} minutes inactive + +
  • + ))} +
+ )} +
+ ); +} + +export function BottleneckCard({ alerts }: { alerts: DashboardAlerts | null }) { + const bottleneck = alerts?.bottleneck ?? null; + return ( + +

Bottleneck

+ {!bottleneck ? ( +

+ All clear — no pile-up right now. +

+ ) : ( +

+ {bottleneck.title} — {bottleneck.waiting_count}{" "} + waiting here +

+ )} +
+ ); +} diff --git a/frontend/components/facilitator/AuditPanel.tsx b/frontend/components/facilitator/AuditPanel.tsx new file mode 100644 index 0000000..972df59 --- /dev/null +++ b/frontend/components/facilitator/AuditPanel.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ApiError, facilitatorAudit, type AuditAction } from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { timeAgo } from "@/lib/format"; + +// Keys are the exact action names persisted by the backend (record_action). +const ACTION_LABELS: Record = { + "workshop.create": "created the workshop", + "help.answer": "answered a help request", + "help.resolve": "resolved a help request", + "broadcast.send": "sent a broadcast", + "broadcast.clear": "cleared the broadcast", + "workshop.pause": "paused the workshop", + "workshop.resume": "unpaused the workshop", + "milestone.advance_all": "advanced everyone", + "milestone.advance_selected": "advanced selected participants", + "milestone.reorder": "reordered milestones", + "milestone.edit": "edited a milestone", + "settings.update": "updated settings", +}; + +function describe(row: AuditAction): string { + return ACTION_LABELS[row.action] ?? row.action; +} + +export function AuditPanel({ + token, + nowMs, + active, +}: { + token: string; + nowMs: number; + /** Only fetches while the tab is active — avoids polling a hidden panel. */ + active: boolean; +}) { + const [rows, setRows] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + if (!active || loaded) return; + setLoading(true); + facilitatorAudit(token, { limit: 30 }) + .then((res) => { + setRows(res.actions); + setHasMore(res.has_more); + setLoaded(true); + }) + .catch((err) => { + setError(err instanceof ApiError ? err.message : "Couldn't load the audit trail."); + }) + .finally(() => setLoading(false)); + }, [active, loaded, token]); + + const loadMore = async () => { + if (rows.length === 0) return; + setLoading(true); + try { + const res = await facilitatorAudit(token, { + beforeId: rows[rows.length - 1].id, + limit: 30, + }); + setRows((prev) => [...prev, ...res.actions]); + setHasMore(res.has_more); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Couldn't load more."); + } finally { + setLoading(false); + } + }; + + if (!active) return null; + + if (loading && rows.length === 0) { + return

Loading audit trail…

; + } + if (error && rows.length === 0) { + return ( +

+ {error} +

+ ); + } + if (rows.length === 0) { + return ( +

+ Nothing recorded yet — every facilitator action will show up here. +

+ ); + } + + return ( +
+
    + {rows.map((row) => ( +
  • +
    + + + {row.actor} + {" "} + {describe(row)} + + + {timeAgo(row.created_at, nowMs)} + +
    + {row.undone_at && ( + + Undone + + )} +
  • + ))} +
+ {hasMore && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/components/facilitator/BroadcastPanel.tsx b/frontend/components/facilitator/BroadcastPanel.tsx new file mode 100644 index 0000000..3aa8501 --- /dev/null +++ b/frontend/components/facilitator/BroadcastPanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { Markdown } from "@/components/ui/Markdown"; +import { cn } from "@/lib/format"; +import type { BroadcastInfo } from "@/lib/api"; + +/** The "Broadcast" control chip in the header — opens the composer. */ +export function BroadcastAction({ onOpen }: { onOpen: () => void }) { + return ( + + ); +} + +/** The active-broadcast strip shown under the header when one is live. */ +export function ActiveBroadcastBar({ + broadcast, + onClear, + clearing, +}: { + broadcast: BroadcastInfo; + onClear: () => void; + clearing: boolean; +}) { + return ( +
+
+ +
+ {broadcast.message_md} +
+ +
+
+ ); +} + +export function BroadcastComposer({ + open, + submitting, + onClose, + onSend, +}: { + open: boolean; + submitting: boolean; + onClose: () => void; + onSend: (messageMd: string) => Promise; +}) { + const [message, setMessage] = useState(""); + const [tab, setTab] = useState<"write" | "preview">("write"); + const [error, setError] = useState(null); + + const close = () => { + setMessage(""); + setError(null); + setTab("write"); + onClose(); + }; + + const send = async () => { + setError(null); + const trimmed = message.trim(); + if (trimmed.length < 1) { + setError("Write a message before sending."); + return; + } + if (message.length > 4000) { + setError("Broadcasts are limited to 4,000 characters."); + return; + } + const ok = await onSend(message); + if (ok) close(); + }; + + return ( + +
+

+ Pinned at the top of every participant's tracker until you clear it or send a new one. +

+
+ + +
+ {tab === "write" ? ( + - -
- - `; - // Auto-generate a key from the labelled thing. - const labelInput = row.querySelector('[data-bind="label"]'); - const keyInput = row.querySelector('[data-bind="key"]'); - labelInput.value = label || ''; - keyInput.value = nextKey(); - toggleDropdownVisibility(row); - - // Live update row title. - labelInput.addEventListener('input', () => { - row.querySelector('.row-label-text').textContent = - labelInput.value || keyInput.value || 'New field'; - }); - - // Remove. - row.querySelector('[data-action="remove"]').addEventListener('click', () => { - row.remove(); - serialize(); - }); - - // Any input change → serialize. - row.querySelectorAll('[data-bind]').forEach((el) => { - el.addEventListener('input', serialize); - el.addEventListener('change', serialize); - }); - - return row; - } - - function add(type) { - const row = makeRow(type, type === 'dropdown' ? 'New dropdown' : 'New field'); - container.appendChild(row); - serialize(); - } - - addTextBtn.addEventListener('click', () => add('text')); - addDropdownBtn.addEventListener('click', () => add('dropdown')); - - // Existing rows: wire remove + serialize hooks. - indexRows().forEach((row) => { - const keyInput = row.querySelector('[data-bind="key"]'); - const labelInput = row.querySelector('[data-bind="label"]'); - if (labelInput) { - labelInput.addEventListener('input', () => { - row.querySelector('.row-label-text').textContent = - labelInput.value || (keyInput && keyInput.value) || 'Field'; - }); - } - row.querySelector('[data-action="remove"]').addEventListener('click', () => { - row.remove(); - serialize(); - }); - row.querySelectorAll('[data-bind]').forEach((el) => { - el.addEventListener('input', serialize); - el.addEventListener('change', serialize); - }); - toggleDropdownVisibility(row); - }); - - // Submit-time serialize is also automatic via input hooks, but ensure. - document.getElementById('form-editor').addEventListener('submit', serialize); - - // Initial serialize so hidden input reflects loaded state. - serialize(); -})(); \ No newline at end of file diff --git a/frontend/static/help_status.js b/frontend/static/help_status.js deleted file mode 100644 index 1b20ad2..0000000 --- a/frontend/static/help_status.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Phase 6 — Inline status changes for help flags - * Two targets: - * - Admin: status-btn with [data-admin][data-hid][data-status] - * - Participant: status-btn with [data-slug][data-hid][data-status] - * Both POST as application/x-www-form-urlencoded to //status. - */ -(function () { - function setActive(parent, status) { - parent.querySelectorAll('.status-btn').forEach(function (b) { - if (b.dataset.status === status) b.classList.add('active'); - else b.classList.remove('active'); - }); - var pill = parent.querySelector('.status-pill'); - if (pill) { - pill.className = 'status-pill status-' + status; - pill.textContent = status.replace('_', ' '); - } - } - - function send(payload, url) { - return fetch(url, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).then(function (resp) { - if (!resp.ok) throw new Error('HTTP ' + resp.status); - return resp; - }); - } - - document.addEventListener('click', function (ev) { - var btn = ev.target.closest('.status-btn'); - if (!btn) return; - ev.preventDefault(); - var status = btn.dataset.status; - var card = btn.closest('.help-card'); - if (btn.dataset.admin) { - send( - { status: status }, - '/admin/' + btn.dataset.admin + '/help/' + btn.dataset.hid + '/status' - ) - .then(function () { setActive(card, status); }) - .catch(function () { alert('Could not update status.'); }); - } else if (btn.dataset.slug) { - send( - { status: status }, - '/w/' + btn.dataset.slug + '/me/help/' + btn.dataset.hid + '/status' - ) - .then(function () { setActive(card, status); }) - .catch(function () { alert('Could not update status.'); }); - } - }); -})(); \ No newline at end of file diff --git a/frontend/static/style.css b/frontend/static/style.css deleted file mode 100644 index e92843a..0000000 --- a/frontend/static/style.css +++ /dev/null @@ -1,851 +0,0 @@ -/* Workshop Helmsman — single stylesheet */ -/* Design tokens & modern visual system */ - -:root { - /* Color tokens */ - --color-bg: #0b0f1a; - --color-surface: #13182a; - --color-surface-raised: #1a2035; - --color-surface-overlay: #222842; - --color-border: #2a314a; - --color-border-strong: #3a4260; - --color-text: #e8ecf5; - --color-text-muted: #8a93b3; - --color-text-subtle: #6a729a; - --color-accent: #7aa2ff; - --color-accent-strong: #5069d6; - --color-accent-soft: rgba(122, 162, 255, 0.15); - --color-live: #44d39a; - --color-live-soft: rgba(68, 211, 154, 0.15); - --color-warn: #e4c44a; - --color-warn-soft: rgba(228, 196, 74, 0.15); - --color-danger: #ef6a6a; - --color-danger-soft: rgba(239, 106, 106, 0.15); - --color-info: #6ab8ff; - --color-info-soft: rgba(106, 184, 255, 0.15); - - /* Spacing scale (8px base) */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 24px; - --space-6: 32px; - --space-7: 48px; - --space-8: 64px; - - /* Border radius scale */ - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; - --radius-pill: 999px; - - /* Elevation shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.4); - --shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.5); - - /* Typography */ - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - --text-xs: clamp(0.7rem, 0.65rem + 0.25vw, 0.78rem); - --text-sm: clamp(0.82rem, 0.78rem + 0.2vw, 0.9rem); - --text-base: clamp(0.95rem, 0.9rem + 0.25vw, 1.05rem); - --text-lg: clamp(1.1rem, 1.02rem + 0.4vw, 1.3rem); - --text-xl: clamp(1.35rem, 1.2rem + 0.75vw, 1.75rem); - --text-2xl: clamp(1.75rem, 1.5rem + 1.25vw, 2.5rem); - - /* Transitions */ - --transition-fast: 120ms ease; - --transition-normal: 200ms ease; - --transition-slow: 350ms ease; - - /* Breakpoints */ - --bp-sm: 480px; - --bp-md: 720px; - --bp-lg: 1024px; - --bp-xl: 1440px; - - /* Focus ring */ - --focus-ring: 0 0 0 3px var(--color-accent-soft); - --focus-ring-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - :root { - --transition-fast: 0ms; - --transition-normal: 0ms; - --transition-slow: 0ms; - } - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -/* Reset & base */ -*, *::before, *::after { box-sizing: border-box; } - -html, body { - margin: 0; padding: 0; - background: var(--color-bg); - color: var(--color-text); - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: 1.55; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { color: var(--color-accent); text-decoration: none; } -a:hover { text-decoration: underline; } -a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); } - -code, pre { - font-family: var(--font-mono); - font-size: 0.9em; - background: var(--color-surface-overlay); - border-radius: var(--radius-sm); - padding: 2px 6px; -} - -code.url-example { - display: inline-block; - padding: 6px 10px; - background: var(--color-surface-overlay); - border: 1px dashed var(--color-border); -} - -/* Topbar */ -.topbar { - display: flex; align-items: center; justify-content: space-between; - padding: var(--space-4) var(--space-5); - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - position: sticky; top: 0; z-index: 100; - gap: var(--space-4); - flex-wrap: wrap; -} -.brand { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text); font-weight: 600; font-size: var(--text-lg); } -.brand-mark { font-size: 1.5em; line-height: 1; } -.brand-name { letter-spacing: 0.02em; } -.topnav { display: inline-flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } - -/* Pill / button */ -.pill { - display: inline-flex; align-items: center; gap: var(--space-1); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-pill); - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - min-height: 44px; - transition: background var(--transition-fast), border-color var(--transition-fast), transform 50ms ease; -} -.pill:hover { background: var(--color-accent-soft); border-color: var(--color-accent); text-decoration: none; } -.pill:active { transform: scale(0.98); } -.pill:focus-visible { outline: none; box-shadow: var(--focus-ring); } - -.btn { - display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--text-sm); - font-weight: 500; - font-family: inherit; - cursor: pointer; - min-height: 44px; - min-width: 44px; - transition: background var(--transition-fast), border-color var(--transition-fast), transform 50ms ease, box-shadow var(--transition-fast); -} -.btn:hover { background: var(--color-accent-soft); border-color: var(--color-accent); text-decoration: none; } -.btn:active { transform: translateY(1px); } -.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } -.btn-primary { background: var(--color-accent-strong); border-color: var(--color-accent-strong); color: white; } -.btn-primary:hover { background: var(--color-accent); border-color: var(--color-accent); } -.btn-small { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); min-height: 36px; } -.btn-ghost { background: transparent; } -.btn-ghost:hover { background: var(--color-surface-overlay); } -.btn-danger { background: var(--color-danger-soft); border-color: var(--color-danger); color: var(--color-danger); } -.btn-danger:hover { background: var(--color-danger); border-color: var(--color-danger); color: white; } -.btn:disabled, .btn[disabled] { opacity: 0.5; cursor: not-allowed; transform: none; } - -/* Container & layout */ -.container { width: 100%; max-width: 100%; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); } -.hero { padding: var(--space-6) 0 var(--space-3); } -.hero h1 { font-size: var(--text-2xl); margin: 0 0 var(--space-2); font-weight: 700; } -.lede { color: var(--color-text-muted); max-width: 65ch; font-size: var(--text-base); line-height: 1.6; } - -.cta-row { display: flex; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-4); } - -/* Cards */ -.card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-5); - margin: var(--space-4) 0; - box-shadow: var(--shadow-md); - transition: box-shadow var(--transition-normal), border-color var(--transition-normal); -} -.card:hover { box-shadow: var(--shadow-lg); border-color: var(--color-border-strong); } -.card.narrow { max-width: 560px; margin-left: auto; margin-right: auto; } -.card-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-4); } -.card h1, .card h2, .card h3 { margin-top: 0; margin-bottom: var(--space-3); } -.card h1 { font-size: var(--text-xl); font-weight: 700; } -.card h2 { font-size: var(--text-lg); font-weight: 600; } -.card h3 { font-size: var(--text-base); font-weight: 600; } - -/* Grid system */ -.grid { display: grid; gap: var(--space-4); } -.grid.two { grid-template-columns: repeat(2, 1fr); } -@media (max-width: 720px) { .grid.two { grid-template-columns: 1fr; } } - -/* Link row */ -.link-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin: var(--space-3) 0; } -@media (max-width: 720px) { .link-row { grid-template-columns: 1fr; } } -.big-link { - display: inline-block; - padding: var(--space-3) var(--space-4); - font-size: var(--text-sm); - font-family: var(--font-mono); - background: var(--color-surface-raised); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - word-break: break-all; - transition: background var(--transition-fast), border-color var(--transition-fast); -} -.big-link:hover { background: var(--color-accent-soft); border-color: var(--color-accent); text-decoration: none; } - -/* Form stack */ -.stack { display: flex; flex-direction: column; gap: var(--space-4); max-width: 720px; } -.stack label { display: flex; flex-direction: column; gap: var(--space-1); } -.stack label span { font-weight: 500; font-size: var(--text-sm); } -.stack input[type="text"], -.stack input[type="number"], -.stack input[type="email"], -.stack textarea, -.stack select { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - color: var(--color-text); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-md); - font-family: inherit; - font-size: var(--text-base); - min-height: 44px; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); -} -.stack textarea { resize: vertical; font-family: var(--font-mono); min-height: 80px; } -.stack input:focus, .stack textarea:focus, .stack select:focus { - outline: none; - border-color: var(--color-accent); - box-shadow: var(--focus-ring); - background: var(--color-surface-overlay); -} -.stack input[aria-invalid="true"], -.stack textarea[aria-invalid="true"], -.stack select[aria-invalid="true"] { - border-color: var(--color-danger); - box-shadow: 0 0 0 3px var(--color-danger-soft); -} -.stack .field-hint { font-size: var(--text-xs); color: var(--color-text-subtle); } -.stack .field-error { font-size: var(--text-xs); color: var(--color-danger); display: none; } -.stack input[aria-invalid="true"] + .field-error, -.stack select[aria-invalid="true"] + .field-error, -.stack textarea[aria-invalid="true"] + .field-error { display: block; } - -/* Tables */ -.table { width: 100%; border-collapse: collapse; } -.table th, .table td { text-align: left; padding: var(--space-3) var(--space-2); border-bottom: 1px solid var(--color-border); vertical-align: top; } -.table thead th { font-size: var(--text-xs); text-transform: uppercase; color: var(--color-text-subtle); letter-spacing: 0.05em; font-weight: 600; } -.table.compact td, .table.compact th { padding: var(--space-2) var(--space-2); } -.table tbody tr { transition: background var(--transition-fast); } -.table tbody tr:hover { background: var(--color-surface-raised); } - -/* Badges */ -.badge { - display: inline-flex; align-items: center; - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - font-weight: 600; - background: var(--color-surface-raised); - border: 1px solid var(--color-border); -} -.badge-live { color: #062a1d; background: var(--color-live-soft); border-color: var(--color-live); } -.badge-expired { color: #2a1b07; background: rgba(214, 140, 68, 0.15); border-color: var(--color-warn); } -.badge-archived { color: var(--color-text-muted); background: var(--color-surface-raised); border-color: var(--color-border); } -.badge-paused { color: #1a2a3a; background: var(--color-info-soft); border-color: var(--color-info); } - -/* Chips */ -.chip { - display: inline-flex; align-items: center; gap: var(--space-1); - padding: var(--space-1) var(--space-2); - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - color: var(--color-text-muted); - white-space: nowrap; -} -.chip-done { background: var(--color-accent-soft); color: var(--color-accent); border-color: var(--color-accent); } -.chip-current { background: var(--color-live-soft); color: var(--color-live); border-color: var(--color-live); font-weight: 600; } - -/* Progress bars */ -.progress { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-4); } - -.bar { - width: 100%; - height: 10px; - background: var(--color-surface-overlay); - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - overflow: hidden; -} -.bar.small { height: 6px; } -.bar-fill { - height: 100%; - background: linear-gradient(90deg, var(--color-accent-strong), var(--color-accent)); - border-radius: var(--radius-pill); - transition: width var(--transition-slow); - position: relative; -} -.bar-fill::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); - animation: shimmer 2s infinite; -} -@keyframes shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} -@media (prefers-reduced-motion: reduce) { - .bar-fill::after { animation: none; } - .bar-fill { transition: width 0ms; } -} - -/* Milestones */ -.milestones { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-3); } -.milestone { - display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); - padding: var(--space-4); - border-radius: var(--radius-md); - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - transition: all var(--transition-normal); -} -.milestone:hover { border-color: var(--color-border-strong); background: var(--color-surface-overlay); } -.milestone-done { opacity: 0.85; border-color: var(--color-accent-soft); background: var(--color-accent-soft); } -.milestone-current { border-color: var(--color-live); background: var(--color-live-soft); } -.milestone-body { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; } -.milestone-title { font-weight: 600; font-size: var(--text-base); } -.milestone-desc { color: var(--color-text-muted); font-size: var(--text-sm); } -.milestone-time { font-size: var(--text-xs); color: var(--color-text-subtle); } -.milestone-actions { flex-shrink: 0; display: flex; gap: var(--space-2); } -.milestone-checkbox { - width: 22px; height: 22px; - border: 2px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface-overlay); - appearance: none; - cursor: pointer; - transition: all var(--transition-fast); - flex-shrink: 0; - margin-top: 2px; -} -.milestone-checkbox:checked { - background: var(--color-live); - border-color: var(--color-live); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E"); - background-position: center; - background-repeat: no-repeat; - background-size: 14px; -} -.milestone-checkbox:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); } - -/* Leaderboard */ -.leaderboard { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); } -.leaderboard-row { - display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: var(--color-surface-raised); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - transition: all var(--transition-fast); -} -.leaderboard-row:hover { border-color: var(--color-border-strong); } -.leaderboard-me { border-color: var(--color-accent); background: var(--color-accent-soft); } -.leaderboard-name { font-weight: 500; min-width: 0; } -.leaderboard-progress { display: inline-flex; align-items: center; gap: var(--space-2); flex-shrink: 0; } -.leaderboard-progress .bar { width: 120px; } - -/* Lists */ -.plain-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); } - -/* Help cards */ -.help-card { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - transition: border-color var(--transition-fast); -} -.help-card:hover { border-color: var(--color-border-strong); } -.help-meta { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-2); } -.help-meta strong { font-weight: 600; } -.help-meta .muted { color: var(--color-text-subtle); font-size: var(--text-xs); } -.help-body { white-space: pre-wrap; color: var(--color-text); font-size: var(--text-sm); line-height: 1.6; } -.status-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; margin-top: var(--space-2); } -.status-controls label { font-size: var(--text-xs); color: var(--color-text-subtle); } -.status-btn { - background: transparent; - border: 1px solid var(--color-border); - color: var(--color-text-muted); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-pill); - cursor: pointer; - font-size: var(--text-xs); - font-weight: 500; - transition: all var(--transition-fast); -} -.status-btn:hover { border-color: var(--color-accent); color: var(--color-accent); } -.status-btn.active.status-btn-open { background: var(--color-warn-soft); border-color: var(--color-warn); color: var(--color-warn); } -.status-btn.active.status-btn-on_hold { background: var(--color-info-soft); border-color: var(--color-info); color: var(--color-info); } -.status-btn.active.status-btn-resolved { background: var(--color-live-soft); border-color: var(--color-live); color: var(--color-live); } - -/* Stat row */ -.stat-row { display: flex; gap: var(--space-6); flex-wrap: wrap; } -.stat-num { font-size: var(--text-2xl); font-weight: 700; line-height: 1; } -.stat-label { color: var(--color-text-muted); font-size: var(--text-sm); } - -/* Filter bar */ -.filter-bar { display: flex; align-items: center; gap: var(--space-3); margin: var(--space-4) 0; flex-wrap: wrap; } -.filter-bar input[type="text"] { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - color: var(--color-text); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - font-family: inherit; - font-size: var(--text-sm); - min-width: 220px; - min-height: 44px; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.filter-bar input[type="text"]:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } - -/* Cohort stacked bar */ -.stacked-bar-container { - display: flex; - height: 32px; - border-radius: var(--radius-md); - overflow: hidden; - border: 1px solid var(--color-border); - margin-bottom: var(--space-3); -} -.stacked-bar-segment { - display: flex; align-items: center; justify-content: center; - min-width: 2px; - transition: width var(--transition-slow); -} -.stacked-bar-label { - color: white; - font-size: var(--text-xs); - font-weight: 600; - text-shadow: 0 1px 2px rgba(0,0,0,0.4); - pointer-events: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding: 0 var(--space-2); -} -.stacked-bar-legend { - display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); - margin-top: var(--space-2); -} -.stacked-legend-item { - display: inline-flex; align-items: center; gap: var(--space-1); - font-size: var(--text-sm); - color: var(--color-text-muted); -} -.stacked-legend-swatch { - display: inline-block; - width: 12px; height: 12px; - border-radius: var(--radius-sm); - flex-shrink: 0; -} - -/* Timeline */ -.timeline { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0; } -.timeline-item { - display: flex; align-items: flex-start; gap: var(--space-4); - padding: var(--space-3) 0; - border-bottom: 1px solid var(--color-border); -} -.timeline-item:last-child { border-bottom: none; } -.timeline-icon { - width: 32px; height: 32px; - border-radius: 50%; - display: flex; align-items: center; justify-content: center; - font-size: var(--text-xs); - font-weight: 700; - flex-shrink: 0; - margin-top: 2px; -} -.timeline-icon-done { background: var(--color-accent-soft); color: var(--color-accent); border: 1px solid var(--color-accent); } -.timeline-icon-help { background: var(--color-warn-soft); color: var(--color-warn); border: 1px solid var(--color-warn); } -.timeline-body { display: flex; flex-direction: column; gap: var(--space-1); } - -/* Form field editor */ -.form-row { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - margin-bottom: var(--space-3); - background: var(--color-surface-raised); -} -.form-row-head { - display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--space-3); -} -.btn-row-remove { - background: transparent; border: 0; color: var(--color-danger); - cursor: pointer; font-size: 1.25rem; line-height: 1; - padding: var(--space-1); - min-height: 44px; min-width: 44px; - transition: color var(--transition-fast), transform 50ms ease; -} -.btn-row-remove:hover { color: #ff6b6b; transform: scale(1.1); } -.form-row-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: var(--space-3); -} -.form-row-grid label { display: flex; flex-direction: column; font-size: var(--text-sm); } -.form-row-grid label.chk { flex-direction: row; align-items: center; gap: var(--space-2); } -.form-row-options { margin-top: var(--space-3); } -.form-row-options textarea { width: 100%; font-family: var(--font-mono); min-height: 60px; } -.form-actions { display: flex; gap: var(--space-2); margin: var(--space-4) 0; flex-wrap: wrap; } - -/* Template grid */ -.template-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: var(--space-4); -} -.template-card { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - background: var(--color-surface-raised); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.template-card:hover { border-color: var(--color-accent); box-shadow: var(--shadow-md); } -.template-card h3 { margin: 0 0 var(--space-1); font-size: var(--text-base); } -.template-fields { - background: var(--color-surface-overlay); - border-radius: var(--radius-sm); - padding: var(--space-2) var(--space-3); - font-size: var(--text-xs); - margin: var(--space-2) 0; - white-space: pre-wrap; - max-height: 120px; - overflow: auto; -} -.template-card-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; } - -/* Key-value */ -dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: var(--space-2) var(--space-4); margin: 0; } -dl.kv dt { font-weight: 600; color: var(--color-text-subtle); } -dl.kv dd { margin: 0; word-break: break-word; } - -/* Console-specific styles */ -.console-header { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: var(--space-4); - margin-bottom: var(--space-6); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--color-border); -} -.console-header-inner { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: var(--space-4); - width: 100%; -} -.console-header h1 { margin: 0; font-size: var(--text-2xl); } -.console-subtitle { - color: var(--color-text-muted); - margin: var(--space-2) 0 0; - font-size: var(--text-base); -} - -.console-workshops { margin-top: var(--space-6); } -.console-table-wrapper { - overflow-x: auto; - border-radius: var(--radius-lg); - border: 1px solid var(--color-border); - background: var(--color-surface); -} -.console-table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} -.console-table th, -.console-table td { - padding: var(--space-3) var(--space-4); - text-align: left; - border-bottom: 1px solid var(--color-border); -} -.console-table th { - background: var(--color-surface-raised); - font-weight: 600; - color: var(--color-text-muted); - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.05em; -} -.console-table tbody tr { transition: background var(--transition-fast); } -.console-table tbody tr:hover { background: var(--color-surface-overlay); } -.console-table tbody tr:last-child td { border-bottom: none; } -.console-row-archived { opacity: 0.6; } -.console-row-archived td { background: var(--color-surface-overlay) !important; } -.console-row-expired { opacity: 0.8; } - -.status-badge { - display: inline-flex; - align-items: center; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - font-weight: 600; - text-transform: capitalize; -} -.status-live { background: var(--color-accent-soft); color: var(--color-accent); } -.status-expired { background: var(--color-warn-soft); color: var(--color-warn); } -.status-archived { background: var(--color-muted-soft); color: var(--color-muted); } - -.console-links { display: flex; gap: var(--space-2); } -.link-admin, .link-participant { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - font-weight: 500; - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - min-height: 32px; - transition: all var(--transition-fast); -} -.link-admin:hover, .link-participant:hover { - background: var(--color-accent-soft); - border-color: var(--color-accent); - text-decoration: none; -} -.link-icon { font-size: 0.9em; } - -.console-empty { - margin-top: var(--space-8); -} -.empty-state { - text-align: center; - padding: var(--space-10) var(--space-4); - background: var(--color-surface-raised); - border: 1px dashed var(--color-border); - border-radius: var(--radius-lg); -} -.empty-icon { font-size: 4rem; line-height: 1; margin-bottom: var(--space-4); } -.empty-state h2 { margin: 0 0 var(--space-2); font-size: var(--text-xl); } -.empty-state p { color: var(--color-text-muted); margin: 0; } - -/* Console workshop new page */ -.page-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; - gap: var(--space-4); - margin-bottom: var(--space-6); -} -.page-header h1 { margin: 0; font-size: var(--text-2xl); } -.page-header .muted { margin: var(--space-1) 0 0; } - -.workshop-create-form { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-6); -} - -.form-section { - margin-bottom: var(--space-8); - padding-bottom: var(--space-6); - border-bottom: 1px solid var(--color-border); -} -.form-section:last-of-type { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } -.form-section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-1); } -.form-section .muted { font-size: var(--text-sm); margin-bottom: var(--space-4); } - -.field-row { display: flex; gap: var(--space-4); flex-wrap: wrap; } -.field-row .field { flex: 1; min-width: 200px; } -.field-narrow { max-width: 140px; } - -.template-picker { display: flex; gap: var(--space-3); align-items: flex-end; flex-wrap: wrap; margin-bottom: var(--space-4); } -.template-picker .field { flex: 1; min-width: 240px; } -.template-picker .field-select { width: 100%; } - -.milestone-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-3); } -.milestone-item { - display: flex; - align-items: flex-start; - gap: var(--space-3); - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-3); -} -.milestone-item.dragging { opacity: 0.5; border-color: var(--color-accent); } -.drag-handle { - cursor: grab; - color: var(--color-muted); - font-size: 1.2rem; - line-height: 1; - padding-top: 2px; - user-select: none; -} -.drag-handle:active { cursor: grabbing; } - -.milestone-fields { flex: 1; display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; } -.field-inline { display: flex; flex-direction: column; gap: 2px; } -.field-inline .field-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted); font-weight: 600; } -.field-inline input { width: 100%; } -.field-description input { font-size: 0.875rem; } -.field-tip input { font-size: 0.8rem; background: var(--bg-soft); } - -.btn-icon { - background: none; - border: none; - color: var(--muted); - font-size: 1.25rem; - line-height: 1; - padding: 2px 6px; - cursor: pointer; - border-radius: var(--radius); -} -.btn-icon:hover { color: var(--danger); background: var(--danger-soft); } -.milestone-item:hover .btn-delete { opacity: 1; } -.btn-delete { opacity: 0; transition: opacity 0.15s; } - -.btn-lg { padding: var(--space-3) var(--space-6); font-size: var(--text-base); min-height: 52px; } -.btn-block { width: 100%; } - -.form-actions { - display: flex; - gap: var(--space-3); - justify-content: flex-end; - padding-top: var(--space-4); - border-top: 1px solid var(--color-border); - margin-top: var(--space-4); -} - -/* Milestone drag states */ -.milestone-item.dragging { opacity: 0.4; } -.milestone-item { transition: opacity 0.15s ease; } - -@keyframes slideDown { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Card aside */ -.card-aside { - border: 1px dashed var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - margin: var(--space-4) 0; - background: var(--color-surface-raised); -} -.warning { color: var(--color-warn); } -.required { color: var(--color-danger); font-style: normal; } - -/* Broadcast banner */ -.broadcast-banner { - background: var(--color-info-soft); - border: 1px solid var(--color-info); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - margin-bottom: var(--space-4); - display: flex; align-items: flex-start; gap: var(--space-3); - animation: slideDown var(--transition-normal); -} -@keyframes slideDown { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } -} -@media (prefers-reduced-motion: reduce) { - .broadcast-banner { animation: none; } -} -.broadcast-banner .broadcast-icon { flex-shrink: 0; margin-top: 2px; color: var(--color-info); } -.broadcast-banner .broadcast-content { flex: 1; min-width: 0; } -.broadcast-banner .broadcast-title { font-weight: 600; margin-bottom: var(--space-1); } -.broadcast-banner .broadcast-message { color: var(--color-text); font-size: var(--text-sm); } -.broadcast-banner .broadcast-dismiss { - flex-shrink: 0; - background: transparent; border: 1px solid var(--color-border); - color: var(--color-text-muted); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - cursor: pointer; - transition: all var(--transition-fast); - min-height: 36px; -} -.broadcast-banner .broadcast-dismiss:hover { border-color: var(--color-accent); color: var(--color-accent); } - -/* Help confirm (2-step) */ -.help-confirm { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - margin-top: var(--space-4); - animation: slideDown var(--transition-normal); -} -.help-confirm h3 { margin-top: 0; margin-bottom: var(--space-3); } -.llm-suggestion { - border-left: 3px solid var(--color-accent); - background: var(--color-accent-soft); - padding: var(--space-3) var(--space-4); - margin: var(--space-3) 0 var(--space-4); - border-radius: var(--radius-sm); - font-size: var(--text-base); - line-height: 1.6; -} -.help-confirm details { border-top: 1px solid var(--color-border); padding-top: var(--space-3); } -.help-confirm summary { cursor: pointer; font-weight: 500; color: var(--color-text-muted); } -.help-confirm summary:hover { color: var(--color-text); } -.row-actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; margin-top \ No newline at end of file diff --git a/frontend/templates/_form_field_row.html b/frontend/templates/_form_field_row.html deleted file mode 100644 index b5b8cd9..0000000 --- a/frontend/templates/_form_field_row.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
- {{ f.label or f.key }} - -
-
- - - - -
-
- -
- -
\ No newline at end of file diff --git a/frontend/templates/_stubs.html b/frontend/templates/_stubs.html deleted file mode 100644 index 16030b3..0000000 --- a/frontend/templates/_stubs.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% block title %}Coming soon · Helmsman{% endblock %} -{% block content %} -
- Coming in Phase 2 -

{{ label }}

-

This surface is intentionally stubbed for Phase 1 to keep the demo tight. - The real implementation lands in Phase 2 alongside CSV exports, - clone-this-workshop, and edit-after-create.

- Back to dashboard -
-{% endblock %} diff --git a/frontend/templates/admin_dashboard.html b/frontend/templates/admin_dashboard.html deleted file mode 100644 index feee4be..0000000 --- a/frontend/templates/admin_dashboard.html +++ /dev/null @@ -1,300 +0,0 @@ -{% extends "base.html" %} -{% block title %}Admin Dashboard — {{ workshop.name }} — Workshop Helmsman{% endblock %} -{% block content %} -
-
-

{{ workshop.name }}

- -
-

Your workshop command center

-
- -
- -
-
-
-

Participants

-

Joined & active

-
-
-
{{ participant_count }}
-
{{ active_now }} active now
-
-
- -
-
-

Progress

-

Average completion

-
-
-
{{ avg_completion }}%
-
of {{ milestones|length }} milestones
-
-
- -
-
-

Help Requests

-

Needing your attention

-
-
-
{{ help_requests_count }}
-
open requests
-
-
-
- - -
- - - - - -
- - -
- - -
-

Participants

-
- - - - - - - - - - - {% for p in participants %} - - - - - - - {% endfor %} - -
NameJoinedProgressActions
{{ p.name }}{{ p.joined_at.strftime('%H:%M') }} -
-
-
- {{ p.progress }}% -
- - -
-
-
- - -
-

Milestones

-
- - -
-
- {% for m in milestones %} -
-
-

{{ m.title }}

- - {{ m.category|capitalize }} - -
-
-

{{ m.description }}

- {% if m.help_tip %} -

{{ m.help_tip }}

- {% endif %} -
- -
- {% endfor %} -
-
- - -
-

Broadcast Message

-
-
- - -
-
- -
- -
-
-

Recent Broadcasts

-
-

Welcome to the workshop! Let's get started.

- Just now -
-
-
- - -
-

Help Requests

-
- {% for hr in help_requests %} -
-
-

{{ hr.participant_name }}

- {{ hr.status|upper }} -
-
-

{{ hr.message }}

-
- -
- {% endfor %} -
-
- - -
-

Export Data

-

Download participant data for analysis.

-
- - -
-
- -
-
- - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_edit.html b/frontend/templates/admin_edit.html deleted file mode 100644 index 90ed717..0000000 --- a/frontend/templates/admin_edit.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% block title %}Edit: {{ workshop.name }}{% endblock %} -{% block content %} -
-

Edit workshop

-

Changes apply immediately. Existing milestone completion records keep the milestone title they were submitted with (audit fidelity).

- -
- - -
- Agenda templates -

Pick a template to replace the milestones below.

- -
- - - - - - - -
- - Cancel -
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_form.html b/frontend/templates/admin_form.html deleted file mode 100644 index f7f8a95..0000000 --- a/frontend/templates/admin_form.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}Form · {{ workshop.name }}{% endblock %} -{% block topnav %} - ← Back to dashboard - Load template -{% endblock %} -{% block content %} -
-

Participant form

-

Configure what attendees enter when joining {{ workshop.name }}.

-

Snapshot is saved with this workshop — editing a saved template later won't change historical snapshots.

- -
-
- {% for f in form_schema %}{% include "_form_field_row.html" %}{% endfor %} -
- -
- - -
- - - -
- Save as template (optional) - - If filled, the field schema above is also stored as a new reusable template. -
- - -
-
- -{% raw %} - -{% endraw %} - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_form_template.html b/frontend/templates/admin_form_template.html deleted file mode 100644 index 430cb10..0000000 --- a/frontend/templates/admin_form_template.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% block title %}Load template · {{ workshop.name }}{% endblock %} -{% block topnav %} - ← Back to dashboard - Edit form manually -{% endblock %} -{% block content %} -
-

Apply a saved form template

-

Replace this workshop's participant form with a saved template.

-

⚠ This will overwrite the current form snapshot for {{ workshop.name }}. Existing join data is preserved.

- - {% if templates %} -
- {% for t in templates %} -
-

{{ t.name }}

-

{{ t._field_count }} fields · created {{ t.created_at.strftime('%Y-%m-%d') }}

-
[
-{%- for f in t.fields() -%}
-{{"\n"}}{% if loop.first %}"{% else %}"{% endif %}{{ f.label }} ({{ f.type }}{% if f.required %}*{% endif %}){% if not loop.last %},{% endif %}
-{%- endfor -%}
-]
-
- -
-
- {% endfor %} -
- {% else %} -

No saved templates yet. Create one on the form editor by adding fields and using the "Save as template" section.

- {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_new.html b/frontend/templates/admin_new.html deleted file mode 100644 index 0bcb5d6..0000000 --- a/frontend/templates/admin_new.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -{% block title %}New workshop{% endblock %} -{% block content %} -
-

Create a new workshop

- -
- - -
- Agenda templates -

Pick a template to pre-fill milestones, or write your own below.

- -
- - - -
- Participant form -

What information do you collect from each attendee?

- -
-
- default - display_name (text, required) -
-
- - - -

Phase 4 default form is just a display_name field. Full field editor lives on the workshop's form page after creation.

- - - - -
- - -
-
- - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_template_edit.html b/frontend/templates/admin_template_edit.html deleted file mode 100644 index a6adc22..0000000 --- a/frontend/templates/admin_template_edit.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block title %}Edit template · {{ template_obj.name }}{% endblock %} -{% block topnav %} - ← All templates -{% endblock %} -{% block content %} -
-

Template: {{ template_obj.name }}

-

Edited here changes this template for FUTURE workshops. Existing workshops that already loaded it keep their frozen snapshot.

- -
- - -

Fields

-
-{% for f in form_schema %}{% include "_form_field_row.html" %}{% endfor %} -
- -
- - -
- - - -
-
- -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_templates.html b/frontend/templates/admin_templates.html deleted file mode 100644 index a44dc43..0000000 --- a/frontend/templates/admin_templates.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} -{% block title %}Form templates · Helmsman{% endblock %} -{% block content %} -
-

Form templates

-

Reusable participant form schemas. Editing a template only affects future workshops that load it.

- - {% if templates_list %} -
- {% for t in templates_list %} -
-

{{ t.name }}

-

- {{ t._field_count }} fields · - created {{ t.created_at.strftime('%Y-%m-%d') }} · - used in {{ t._usage_count }} workshop(s) -

-
[
-{%- for f in t.fields() -%}
-{{"\n"}}{% if loop.first %}"{% else %}"{% endif %}{{ f.label }} ({{ f.type }}{% if f.required %}*{% endif %}){% if not loop.last %},{% endif %}
-{%- endfor -%}
-]
-
- Edit template -
- -
-
-
- {% endfor %} -
- {% else %} -

No form templates yet. Create one on a workshop's form editor.

- {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/frontend/templates/base.html b/frontend/templates/base.html deleted file mode 100644 index 98aaf63..0000000 --- a/frontend/templates/base.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - {% block title %}Workshop Helmsman{% endblock %} - - - -
- - - Workshop Helmsman - - -
- -
- {% block content %}{% endblock %} -
- -
- Workshop Helmsman · self-hosted milestone tracker · Phase 2 -
- - {% block scripts %}{% endblock %} - - diff --git a/frontend/templates/console.html b/frontend/templates/console.html deleted file mode 100644 index f7b9387..0000000 --- a/frontend/templates/console.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html" %} -{% block title %}Console — Workshop Helmsman{% endblock %} -{% block content %} -
-
- - Workshop Helmsman - Console -
- -
- -
-
-

Your workshop command center

-

Create workshops, manage templates, view all sessions. Bookmark this URL — it's your master key.

-
- -
- -
-
-

Create a new workshop

-

Start a fresh session from scratch or a template.

- Create workshop → -
- - -
-
📚
-

Agenda & Form templates

-

Reusable milestone lists and participant forms. Edit once, use forever.

- Manage templates → -
- - -
-
📂
-

All workshops

-

Every session you've ever run. Filter by status, search by name.

- Browse all → -
-
- - -
-

At a glance

-
-
{{ total_workshops }}Total workshops
-
{{ live_workshops }}Live now
-
{{ total_participants }}Participants ever
-
-
- - - {% if recent_workshops %} -
-
-

Recent workshops

- View all -
-
- {% for w in recent_workshops %} -
-
- {{ w.name }} - - - {% if w.archived %}archived{% elif w.is_expired() %}expired{% else %}live{% endif %} - - {{ w.created_at.strftime('%b %d, %Y') }} · {{ w._participant_count }} participants - -
- -
- {% endfor %} -
- {% endif %} -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/console_index.html b/frontend/templates/console_index.html deleted file mode 100644 index a5be903..0000000 --- a/frontend/templates/console_index.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "base.html" %} -{% block title %}Workshops — Console{% endblock %} -{% block content %} -
-
-

Workshops

-

Your workshop dashboard

-
- - {% if workshops|length > 0 %} -
- - - - - - - - - - - - {% for w in workshops %} - - - - - - - - {% endfor %} - -
WorkshopCreatedStatusAttendeesActions
- {{ w.name }} - {{ w.created_at.strftime('%Y-%m-%d %H:%M') }} - {% if w.archived %} - Archived - {% elif w.paused %} - Paused - {% elif w.is_expired() %} - Ended - {% else %} - Live - {% endif %} - {{ w.participants|length }} - {% if not w.archived %} - Edit - Clone - Archive - {% endif %} -
-
- {% else %} -
-

No workshops yet

-

Create your first workshop to get started.

- Create First Workshop -
- {% endif %} -
-{% endblock %} \ No newline at end of file diff --git a/frontend/templates/console_workshop_new.html b/frontend/templates/console_workshop_new.html deleted file mode 100644 index 53ef5c4..0000000 --- a/frontend/templates/console_workshop_new.html +++ /dev/null @@ -1,337 +0,0 @@ -{% extends "base.html" %} -{% block title %}Create Workshop — Console{% endblock %} -{% block content %} -
- ← Console -

Create Workshop

-

Name it, design the agenda, set the form. Two links come out: one for you, one for attendees.

-
- -
- - - -
- - - - - -
-
-

Basics

-

Give your workshop a name and set the duration

-
- -
- -
- -
- - -
- -
- -

Templates pre-fill milestones. You can edit, reorder, or delete them in the next step.

-
- -
- -
-
- - -
-
-

Milestones

-

Drag to reorder. Click title to edit. Each milestone becomes a column on the participant tracker.

-
- -
- -
- 0 milestones · 0 min total -
-
- -
    - -
- -
- - -
-
- - -
-
-

Participant Form

-

Configure what info you need from each attendee. Full Name is always required.

-
- -
-
- -
- ⋮⋮ -
- Full Name - text - Required -
- 🔒 -
- -
- - - -
- -
- -
-

Participant preview

-
-
-
- -
- - -
-
- - -
-
-

Resources & Knowledge Base

-

Link a Google Drive folder with slides, docs, and resources. Participants can access from their tracker.

-
- -
- - -
- -
-

🔮 Coming soon: AI-powered Q&A on your Drive docs. Participants ask questions, get answers from your resources.

-
- -
- - -
-
- - -
-
-

Review & Create

-

Verify everything looks good. Click a section to jump back and edit.

-
- -
-
-
-

Basics

- -
-
-
Name
-
Duration
-
Template
Blank
-
-
- -
-
-

Milestones

- -
-
-
Count
0
-
Total time
0 min
-
Categories
-
-
    -
    - -
    -
    -

    Participant Form

    - -
    -
    -
    Fields
    1
    -
    Required
    1
    -
    -
      -
      - -
      -
      -

      Resources

      - -
      -
      -
      Drive link
      -
      Title
      -
      -
      -
      - -
      - - -
      -
      -
      -
      {% endblock %} -{% block scripts %} - - - - - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/participant_tracker.html b/frontend/templates/participant_tracker.html deleted file mode 100644 index 0aa3b06..0000000 --- a/frontend/templates/participant_tracker.html +++ /dev/null @@ -1,224 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ workshop.name }} · Tracker{% endblock %} -{% block content %} - -{# Broadcast banner - shows if there's an active broadcast #} -{% if broadcast %} -
      - -
      -
      Announcement
      -
      {{ broadcast }}
      -
      - -
      -{% endif %} - -
      -
      -

      {{ workshop.name }}

      -
      -

      Hi {{ participant.name }}. Mark each milestone as you finish it. The leaderboard auto-refreshes.

      -
      - -
      -
      -

      Your milestones

      - -
      -
      -
      -
      - - {{ completed_ids|length }} / {{ milestones|length }} complete - · {{ (completed_ids|length * 100 / (milestones|length or 1))|round(0, 'floor') }}% - -
      - -
        - {% for m in milestones %} -
      1. -
        - {{ m.title }} - {% if m.description %}
        {{ m.description }}
        {% endif %} - {% if m.id in completed_ids %} - completed {{ completed_ids[m.id].strftime('%H:%M:%S UTC') }} - {% endif %} -
        -
        - {% if m.id in completed_ids %} - ✓ Done - {% else %} -
        - -
        - {% endif %} -
        -
      2. - {% endfor %} -
      -
      - -
      -

      Leaderboard

      -
        - {% for row in leaderboard %} -
      1. 50 %} style="display:none"{% endif %}> - - {{ row.name }}{% if row.is_me %} (you){% endif %} - - -
        - {{ row.completed_count }} / {{ row.total }} -
        -
      2. - {% endfor %} -
      - {% if leaderboard|length > 50 %} - - {% endif %} - Auto-refreshes every few seconds. -
      -
      - -{# My Answers Panel #} -{% if form_answers and form_answers|length > 0 %} -
      -

      Your answers

      -
      - {% for key, value in form_answers.items() %} - {% if value %} -
      {{ key.replace('_', ' ').title() }}
      -
      {{ value }}
      - {% endif %} - {% endfor %} -
      -
      -{% endif %} - -
      -

      Stuck? Get the room's attention.

      -

      Type what's blocking you. We'll try to suggest a fix before it hits the facilitator.

      - - {% if pending_message %} - {# Two-step flow: show LLM suggestion before saving #} -
      -

      Before we send — here's a possible fix

      -
      - Suggested: {{ pending_suggestion or "(no suggestion available)" }} -
      -
      - Your message (click to edit) -
      - - -
      - - Cancel -
      -
      -
      -
      - {% else %} -
      - - - Press Enter or click "Get a suggestion" to see a possible fix before sending. - -
      - {% endif %} - -

      Your help requests

      -
        - {% for h in latest_help %} -
      • -
        - You - · {{ h.created_at.strftime('%H:%M:%S UTC') }} - {{ h.status.replace('_', ' ') }} -
        -
        {{ h.message }}
        -
        - - {% for s in help_statuses %} - - {% endfor %} -
        -
      • - {% else %} -
      • No help flags yet.
      • - {% endfor %} -
      -
      -{% endblock %} -{% block scripts %} - - - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/workshop_archived.html b/frontend/templates/workshop_archived.html deleted file mode 100644 index 17e2da4..0000000 --- a/frontend/templates/workshop_archived.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ workshop.name }} · Archived{% endblock %} -{% block content %} -
      - Archived -

      {{ workshop.name }}

      -

      This workshop has been archived by the facilitator and is no longer accepting participants.

      - Back to home -
      -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/workshop_expired.html b/frontend/templates/workshop_expired.html deleted file mode 100644 index 36e93bc..0000000 --- a/frontend/templates/workshop_expired.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ workshop.name }} · Ended{% endblock %} -{% block content %} -
      - Workshop ended -

      {{ workshop.name }}

      -

      This participant link has expired. The facilitator still has their admin link - and can export / archive the session from there.

      - Back to home -
      -{% endblock %} diff --git a/frontend/templates/workshops_index.html b/frontend/templates/workshops_index.html deleted file mode 100644 index 5c8c0a0..0000000 --- a/frontend/templates/workshops_index.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -{% block title %}Local workshops · Helmsman{% endblock %} -{% block content %} -
      -

      Local workshops

      -

      Every workshop ever created on this instance.

      - -
      - - -
      - - {% if items %} - - - - - - - - - - - - {% for w in items %} - - - - - - - - {% endfor %} - -
      NameCreatedParticipantsStatusActions
      {{ w.name }}{{ w.created_at.strftime('%Y-%m-%d %H:%M UTC') }} - {% if w._participant_count is defined %} - {{ w._participant_count }} - {% else %} - — - {% endif %} - - {% if w.archived %}archived - {% elif w.is_expired() %}expired - {% else %}live{% endif %} - - dashboard - {% if not w.archived and not w.is_expired() %} - participant - {% else %} - ended - {% endif %} -
      - {% if items|length == 50 %} -

      Showing 50 most recent. Archived older workshops from the dashboard.

      - {% endif %} - {% else %} -

      No workshops yet. Create one from the home page.

      - {% endif %} -
      -{% endblock %} -{% block scripts %} - -{% endblock %} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f3eb29a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "out"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0acb35 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "workshop-helmsman-e2e", + "version": "0.2.0", + "private": true, + "description": "Root package: Playwright e2e suite for Workshop Helmsman (frontend app deps live in frontend/, a separate pnpm project)", + "scripts": { + "test:e2e": "playwright test tests/e2e --reporter=line" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/node": "^26.1.1" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b476b2d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Workshop Helmsman — e2e gate config (spec/roadmap.md Phase 1, slice S3). + * + * The gate starts the app externally (`uv run python -m src`, single origin on + * :8001 serving /api/* and the static export at /app/*) — therefore NO + * `webServer` block here. Specs run against that live server. + * + * Poll intervals are 2 s (dashboard) / 3 s (tracker); the 12 s expect timeout + * covers every "within one poll" assertion without fixed sleeps. + */ +export default defineConfig({ + testDir: 'tests/e2e', + timeout: 90_000, + expect: { timeout: 12_000 }, + retries: 0, + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + reporter: 'line', + use: { + baseURL: 'http://localhost:8001/app', + actionTimeout: 15_000, + navigationTimeout: 30_000, + // No traces/videos: a failure trace would persist the X-Admin-Key request + // header to disk (secret hygiene). Screenshots + the line reporter suffice. + trace: 'off', + video: 'off', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e46e5df --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,67 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + +packages: + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + +snapshots: + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + fsevents@2.3.2: + optional: true + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + undici-types@8.3.0: {} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e6dc937 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "workshop-helmsman" +version = "0.2.0" +description = "Self-hosted workshop assistant for facilitator-led hands-on technical labs" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn>=0.30", + "sqlalchemy>=2.0", + "alembic>=1.13", + "pydantic-settings>=2.0", + "structlog>=24.1", + "httpx>=0.27", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cd058b1..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi==0.115.4 -uvicorn[standard]==0.32.0 -sqlalchemy==2.0.36 -jinja2==3.1.4 -python-multipart==0.0.17 -itsdangerous==2.2.0 diff --git a/spec/agent.md b/spec/agent.md index 6c00b87..ce1f571 100644 --- a/spec/agent.md +++ b/spec/agent.md @@ -1,5 +1,88 @@ -# Agent Layer — Workshop Helmsman (v0.1) +# AI Help-Desk — Workshop Helmsman (v0.2) -**This product has no agent framework.** It is a regular web app (FastAPI + Jinja2 server-rendered + vanilla JS). No LLM in the loop, no tool-use loop, no MCP. +The AI layer arrives in **Phase 4**, but this design is fixed now: the schema (data-model.md), the API surface (api.md), and this pipeline are designed once so Phase 4 wires — it does not redesign. -If future phases add an AI Help Desk (a sub-agent that suggests fixes when a participant flags they're stuck), that will live in `capabilities.md` and `roadmap.md` — *not* here. This file exists only so the spec layout matches the boilerplate contract. \ No newline at end of file +## Framework decision: NO agent framework + +**A plain structured pipeline — no LangGraph/CrewAI/AutoGen, no tool-use loop.** Per `harness/patterns/agentic-ai.md`: "reach down only when there are no tools… if the task is a fixed transform with no branching, a prompt chain or a single call is correct — don't bolt a loop onto a one-shot." This task is a fixed transform: *deterministically gather context → one LLM call → route on confidence*. There are no open-ended tool decisions for the model to make (the system, not the model, fetches the context), no multi-turn planning, no inter-agent coordination. A framework would add dependency weight, latency, and failure modes to a three-step function. + +Patterns used (from the catalogue): **#2 Routing** (confidence-gated auto-answer vs draft), **#13 Human-in-the-Loop** (draft review; one-tap escalation), **#14 Knowledge Retrieval** (FTS5 similarity over past resolutions — keyword RAG, no vector DB), **#9 Learning & Adaptation** (the corpus grows with every facilitator resolution — this replaces canned replies), **#12 Exception Handling** (degrade to manual, never crash), **#18 Guardrails** (schema-validated JSON output, confidence clamps), **#19 Evaluation & Monitoring** (audit rows, structured logs, spend tracking). + +## Entry point + +```python +# src/helmsman/ai/pipeline.py +def run_help_desk(help_request_id: int) -> None: ... +``` + +Invoked as a FastAPI `BackgroundTask` after `POST /api/p/{token}/help` commits — the participant's request returns instantly; the AI answer appears via the normal poll. The pipeline opens its own DB session (`create_db_session`), sets `help_request.ai_state = "pending"` first, and is **re-runnable**: if the process restarts mid-pipeline, the request simply remains `open` in the facilitator queue with `ai_state = "pending"` — no data loss, no stuck state (a request is never blocked on the AI; the queue always shows it). + +**Preconditions (else `ai_state = "skipped"`, silently manual):** `OPENROUTER_API_KEY` present **and** `workshop.ai_enabled` **and** workshop status in (`live`, `grace`) **and** request status `open` and not `escalated`. Escalated requests never re-enter the pipeline — a human was demanded. With no key, nothing in the product references AI errors anywhere: air-gapped means zero errors, and the facilitator AI toggle renders as a labelled "AI off (no API key)" state. + +## Step 1 — Context gathering (deterministic, no LLM) + +Collected into the exact structure stored as `help_answer.ai_context_json` (data-model.md) — what is stored is *precisely* what the model saw, so the facilitator's expandable "context the AI used" view is honest by construction: + +1. **Participant progress:** completed count / total, titles of completed milestones (most recent 5). +2. **Current milestone:** the request's `milestone_id` — title + `content_md` (first 2000 chars). This is the instruction text the participant is stuck on. +3. **Similar past requests (cross-workshop learning):** top **5** hits from the `help_corpus` FTS5 table — message + final resolution excerpts (≤500 chars each), ranked by BM25. This corpus spans **all workshops ever run** on the instance, so every facilitator resolution teaches future triage. + +### Similarity mechanism (realistic for SQLite — no vector DB) + +- **Corpus maintenance (app code, no triggers):** when a request reaches `resolved`, or a facilitator answers it, insert one row into `help_corpus` (message, human-visible resolution, milestone title, ids). Draft-only and unanswered requests never enter the corpus. The corpus is derived data — rebuildable from `help_request` + `help_answer` by a maintenance function, so the DB-is-truth rule holds. +- **Query construction:** tokenize the incoming message + current milestone title; drop stopwords and tokens <3 chars; take the top 12 remaining tokens; build an FTS5 `OR` query of **double-quoted** tokens (quoting neutralizes FTS5 operator injection from user text). Rank with `bm25(help_corpus)`, `LIMIT 5`. +- **PostgreSQL note:** on a PostgreSQL `DATABASE_URL`, the equivalent is a plain `help_corpus` table with a generated `tsvector` column + GIN index and `ts_rank` — same interface (`corpus.search(text) -> list[Hit]`), built only if/when a PostgreSQL deployment enables Phase 4. `src/helmsman/ai/corpus.py` isolates the dialect switch. + +## Step 2 — One LLM call: triage + answer combined + +One call, not two — the triage signal (confidence) and the candidate answer come from the same completion, halving cost and latency. + +- **Provider:** OpenRouter `POST /api/v1/chat/completions` via httpx. Model: `HELMSMAN_AI_MODEL` (default `anthropic/claude-sonnet-4-6`). Timeout 30 s; **one retry** on timeout/5xx with 2 s backoff. Request includes `usage: {"include": true}` so the response carries token counts and `usage.cost`. +- **Prompts** live as markdown files in `src/helmsman/ai/prompts/` (`helpdesk_system.md`, `helpdesk_user.md` — user template interpolates the context block). System prompt outline: + - You are the AI help-desk for a live hands-on technical workshop; a participant is stuck **right now** — be concise, concrete, actionable (≤150 words), markdown with code blocks where useful, formatted code (real newlines/indentation). + - You are given: their progress, the milestone instructions they are on, and similar past requests with how a human facilitator resolved them. Prefer approaches proven in past resolutions; never invent workshop-specific facts (URLs, credentials, file names) not present in the context. + - Report honest confidence: high only when the milestone content or a past resolution clearly covers the problem. If the request needs information you don't have (machine-specific state, account issues, anything ambiguous), answer with your best guidance but report low confidence. + - Output **only** JSON: `{"confidence": 0.0–1.0, "answer_md": "…", "reasoning": "one sentence for the audit log"}`. +- **Output guardrails:** parse strictly (`json.loads` on the extracted JSON object; one re-ask on malformed output with a "return only valid JSON" nudge, then fail). Validate: `confidence` ∈ [0,1] (clamp), `answer_md` non-empty and ≤10000 chars. **Evidence cap:** if the similar-requests list was empty, cap effective confidence at **0.6** — the model cannot claim high certainty without precedent or (per the prompt) explicit grounding in milestone content; this keeps early workshops (empty corpus) human-first while the corpus grows. +- **Spend:** write one `ai_usage` row (purpose `triage_answer`, model, tokens, `cost_usd` from `usage.cost` else 0, latency). Surfaced at `GET /api/f/{t}/spend` and on the dashboard. + +## Step 3 — Route on confidence + +Threshold: `HELMSMAN_AI_CONFIDENCE` (default **0.75**), compared against post-guardrail confidence. + +**Confident (≥ threshold) → auto-answer:** +- Insert `help_answer(source="ai", draft=false, ai_confidence, ai_model, ai_context_json)`; request → `answered`, `ai_state = "answered"`; bump `state_version`. +- Audit row `ai.auto_answer` (actor `ai`) with request id, confidence, reasoning — every AI-sent answer is logged who/what/when. +- Participant sees the answer on next poll, **clearly badged "AI answer"**, with a one-tap **"Get a human"** escalation (`POST …/escalate` → `escalated=true`, status back to `open`, flagged in the queue; the AI answer stays visible — honesty over tidiness). +- Facilitators see every auto-answer in their queue (status `answered`, source badge AI) with the expandable exact context (`ai_context`). + +**Unsure (< threshold) → draft for review:** +- Insert `help_answer(source="ai", draft=true, …)`; request **stays `open`**, `ai_state = "draft"`; bump version. +- The queue shows the draft + confidence + context; the facilitator edits and sends via the normal answer endpoint (a new `facilitator` answer row; the draft remains for audit). Audit row `ai.draft`. + +**Failure (timeout ×2, HTTP error, invalid output ×2):** +- `ai_state = "failed"`, ERROR log with request id and cause; **no user-visible error anywhere** — the request simply remains `open` in the facilitator queue, indistinguishable from AI-off for the participant. The AI is an accelerant, never a gate. + +## Sequence (normative) + +``` +participant POST /help ──commit──▶ 202-style instant response + └─ BackgroundTask: run_help_desk(id) + ├─ preconditions? ──no──▶ ai_state=skipped (manual queue) + ├─ gather_context(id) [context.py — DB only] + ├─ call_openrouter(context) [openrouter.py — 30s, 1 retry] + │ └─ failure ──▶ ai_state=failed, log ERROR (manual queue) + ├─ guardrails: parse/validate/clamp/evidence-cap + └─ route: + confidence ≥ τ ──▶ answer row (draft=false) → answered → audit ai.auto_answer + confidence < τ ──▶ answer row (draft=true) → open → audit ai.draft + then: ai_usage row · state_version bump · structured log ai.* with latency/tokens/cost +``` + +## Testing (Phase 4 gate — real key, from `.env`) + +- `tests/integration/ai/` runs against the **real OpenRouter key** (`pytest.skip` only if genuinely absent — skipped is not passed; the phase gate is BLOCKED without the key). +- **Corpus-size rule (data-processing gate):** the similarity fixture seeds **25+ resolved requests across ≥3 workshops**, exactly one of which is a strong topical match for the query; the test asserts that match is retrieved at rank 1 and appears in `ai_context_json` — a corpus small enough for "any hit = the hit" proves nothing. +- Route tests assert structure, not prose: a request whose answer is verbatim in the milestone content + a matching past resolution → auto-answer path artifacts (answer row `draft=false`, status `answered`, audit row, `ai_usage` row with nonzero tokens); an ambiguous machine-specific request with an empty corpus → draft path (evidence cap enforced). +- **Air-gapped test:** with `OPENROUTER_API_KEY` unset, the full participant + facilitator journey runs with zero errors and zero AI artifacts (`ai_state="skipped"`), and the UI shows the labelled AI-off state. +- Escalation test: auto-answer → escalate → status `open` + `escalated=true` + AI answer still visible + queue flag. diff --git a/spec/api.md b/spec/api.md new file mode 100644 index 0000000..f6a6362 --- /dev/null +++ b/spec/api.md @@ -0,0 +1,265 @@ +# API Contract — Workshop Helmsman (v0.2) + +This is the contract both generators build against **in parallel** — the backend implements it exactly; the frontend consumes it exactly. Nothing here is aspirational: field names, casing, and shapes are normative. + +## Conventions (normative) + +- All endpoints are under `/api/`. All bodies and responses are JSON (UTF-8). All field names are `snake_case`. +- **Timestamps:** ISO 8601 UTC with `Z`, seconds precision — `"2026-07-20T14:03:22Z"`. +- **IDs** are integers. **Tokens/slugs** are strings. +- **Success envelope:** HTTP 200 → `{"data": , "error": null}`. +- **Error envelope:** HTTP 4xx/5xx → `{"detail": {"code": "", "message": ""}}`. Pydantic validation failures are converted by an exception handler into this same shape (`code: "validation_error"`, message = first human-readable error) — the raw FastAPI 422 array shape never reaches clients. +- **Auth:** three schemes — `X-Admin-Key` header (admin surface), `admin_token` path segment (facilitator surface), `participant_token` path segment (participant surface). See architecture.md §Auth. +- **Versioned polling:** poll endpoints take `?v=` (last-seen `state_version`; default `-1` = always changed). Unchanged → `{"changed": false, "version": N, "content_version": M}`. The content endpoint uses `?cv=` against `content_version` the same way. Every mutation response includes `"version"` (the new `state_version`) so clients re-poll immediately. +- **URL fields** (`join_url`, `facilitator_url`, `participant_url`) are absolute, built from `HELMSMAN_BASE_URL` (or the request origin) + the pretty paths `/j/{slug}`, `/f/{admin_token}`, `/p/{participant_token}`. +- **Draft AI answers (`draft: true`) appear only in facilitator payloads — never in participant payloads.** `ai_context` appears only in facilitator payloads. + +### Error code catalogue + +| HTTP | `code` | When | +|---|---|---| +| 401 | `invalid_admin_key` | Missing/wrong `X-Admin-Key` | +| 404 | `not_found` | Unknown token/slug, or an id not in the token's scope (never distinguish wrong vs missing) | +| 409 | `workshop_paused` | Completion mark/unmark while paused (Phase 2+) | +| 409 | `workshop_not_started` | Join before `starts_at` (Phase 3) | +| 409 | `undo_expired` | Undo requested after the 30 s window (Phase 2) | +| 410 | `workshop_archived` | Any mutation on an archived workshop (Phase 3) | +| 422 | `validation_error` | Body/param validation failure | +| 500 | `internal_error` | Unexpected; logged with `request_id` | + +### Pretty redirect routes (not JSON API) + +`GET /` → `/app/` · `GET /j/{join_slug}` → `/app/join/?s=…` · `GET /p/{participant_token}` → `/app/p/?t=…` · `GET /f/{admin_token}` → `/app/f/?t=…` — all 307. **Phase 1.** + +--- + +# Phase 1 endpoints + +## Health + +### `GET /api/health` — no auth +`{"data": {"status": "ok", "db": "ok"}, "error": null}` — `db` is `"ok"` after a real `SELECT 1`, else 500 `internal_error`. + +## Admin surface (header `X-Admin-Key`) + +### `GET /api/admin/workshops` +Lists all workshops (also serves as key validation for the Admin Home login). Sorted `created_at` desc. + +```json +{"data": {"workshops": [{ + "id": 1, "name": "LangGraph Lab — July", "status": "live", + "participant_count": 143, "open_help_count": 2, + "created_at": "2026-07-20T09:00:00Z", + "join_slug": "Ab3dEfGh", "join_url": "http://localhost:8001/j/Ab3dEfGh", + "facilitator_url": "http://localhost:8001/f/" +}]}, "error": null} +``` +Errors: `invalid_admin_key`. + +### `POST /api/admin/workshops` +```json +{"name": "LangGraph Lab — July", "description_md": "Welcome!…", + "milestones": [{"title": "Set up your environment", "content_md": "```bash\nuv sync\n```", "minutes": 30}]} +``` +Validation: `name` 1–120 (trimmed); `description_md` ≤10000; `milestones` 1–50 items in given order; `title` 1–200; `content_md` ≤20000; `minutes` null or 1–480. +*(Phase 3 adds optional `agenda_template_id`, `join_form_template_id`, `starts_at`.)* + +Response — the full workshop: +```json +{"data": {"workshop": { + "id": 1, "name": "…", "description_md": "…", "status": "live", "paused": false, "ai_enabled": false, + "admin_token": "<43 chars>", "join_slug": "Ab3dEfGh", + "join_url": "…/j/Ab3dEfGh", "facilitator_url": "…/f/", + "created_at": "2026-07-20T09:00:00Z" +}}, "error": null} +``` +Side effects: audit row `workshop.create`. Errors: `invalid_admin_key`, `validation_error`. + +## Facilitator surface (path `admin_token`) + +### `GET /api/f/{admin_token}/workshop` +Initial dashboard load + milestone bodies (re-fetched when the dashboard poll reports a new `content_version`). +```json +{"data": { + "content_version": 3, + "workshop": {"id": 1, "name": "…", "description_md": "…", "status": "live", "paused": false, + "ai_enabled": false, "join_slug": "Ab3dEfGh", "join_url": "…", "facilitator_url": "…", + "created_at": "…"}, + "milestones": [{"id": 11, "position": 0, "title": "…", "content_md": "…", "minutes": 30}] +}, "error": null} +``` + +### `GET /api/f/{admin_token}/dashboard?v={int}` — poll (2 s) +Unchanged → `{"data": {"changed": false, "version": 42, "content_version": 3}, "error": null}`. +Changed: +```json +{"data": { + "changed": true, "version": 42, "content_version": 3, + "workshop": {"id": 1, "name": "…", "status": "live", "paused": false, "ai_enabled": false}, + "stats": {"participant_count": 143, "active_count": 121, "finished_count": 12, + "median_progress_pct": 40.0, "open_help_count": 3, "answered_help_count": 5, + "resolved_help_count": 17}, + "milestone_stats": [{"milestone_id": 11, "position": 0, "title": "…", + "completed_count": 130, "completed_pct": 90.9}], + "distribution": [{"completed_count": 0, "participants": 5}, {"completed_count": 1, "participants": 20}], + "participants": [{"id": 7, "name": "Priya", "joined_at": "…", "last_seen_at": "…", + "completed_milestone_ids": [11, 12], "completed_count": 2, "progress_pct": 25.0, + "current_milestone_id": 13, "open_help_count": 1, + "participant_url": "…/p/"}], + "help_queue": [{"id": 9, "participant_id": 7, "participant_name": "Priya", + "milestone_id": 13, "milestone_title": "Configure the API key", + "message": "getting a 401 from…", "status": "open", "escalated": false, + "created_at": "…", "updated_at": "…", + "answers": [{"id": 3, "source": "facilitator", "answer_md": "Check `.env`…", + "draft": false, "created_at": "…", + "ai_confidence": null, "ai_model": null, "ai_context": null}]}], + "broadcast": null, + "alerts": null, + "pulse": null, + "spend": null +}, "error": null} +``` +Semantics: `active_count` = participants with `last_seen_at` within 5 min. `distribution` covers every completed-count 0…total (zeros included). `participants` sorted `joined_at` asc (client re-sorts). `help_queue` = all `open` + `answered` requests (newest first within status, `open` block first) plus the 50 most recent `resolved`; totals live in `stats`. `broadcast`/`alerts`/`pulse` are `null` until Phase 2, `spend` `null` until Phase 4 — the frontend renders labelled stubs for them from day one. + +### `POST /api/f/{admin_token}/help/{help_request_id}/answer` +Body: `{"answer_md": "…"}` (1–10000). Effect: append `help_answer(source="facilitator")`, request → `answered`, audit `help.answer`, bump version. +Response: `{"data": {"help_request": , "version": 43}, "error": null}`. +Errors: `not_found` (id not in this workshop), `validation_error`. Answering a `resolved` request re-opens nothing — it appends the answer and leaves status `resolved`. + +### `POST /api/f/{admin_token}/help/{help_request_id}/resolve` +Facilitator closes a request. Idempotent. Audit `help.resolve`. +Response: `{"data": {"help_request": , "version": 44}, "error": null}`. + +## Participant surface + +### `GET /api/join/{join_slug}` — no auth; reads auto-resume cookie +```json +{"data": { + "workshop": {"name": "…", "description_md": "…", "status": "live", + "milestone_count": 8, "participant_count": 143}, + "me": null +}, "error": null} +``` +If the browser carries a valid `helmsman_p_{workshop_id}` cookie, `"me": {"participant_token": "<22 chars>", "name": "Priya"}` — the page then forwards to the tracker without re-joining. Errors: `not_found`. + +### `POST /api/join/{join_slug}` +Body: `{"name": "Priya"}` (1–80 trimmed; duplicates allowed). *(Phase 3 adds `"answers": {…}` for custom join fields.)* +Effect: create participant; **set cookie** `helmsman_p_{workshop_id}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000`; bump version. +```json +{"data": {"participant_token": "<22 chars>", "participant_url": "…/p/", "name": "Priya"}, "error": null} +``` +Errors: `validation_error`, `workshop_archived`, `workshop_not_started` (Phase 3). + +### `GET /api/p/{participant_token}/state?v={int}` — poll (3 s) +Unchanged → `{"data": {"changed": false, "version": 42, "content_version": 3}, "error": null}`. +Changed: +```json +{"data": { + "changed": true, "version": 42, "content_version": 3, + "workshop": {"name": "…", "status": "live", "paused": false}, + "milestones": [{"id": 11, "position": 0, "title": "…", "minutes": 30}], + "me": {"id": 7, "name": "Priya", "completed_milestone_ids": [11], + "completed_count": 1, "total_count": 8, "progress_pct": 12.5, "rank": 37}, + "leaderboard": [{"rank": 1, "name": "Arun", "completed_count": 6, "progress_pct": 75.0, "is_me": false}], + "broadcast": null, + "help_requests": [{"id": 9, "message": "getting a 401…", "status": "answered", "escalated": false, + "milestone_id": 13, "created_at": "…", + "answers": [{"id": 3, "source": "facilitator", "answer_md": "Check `.env`…", + "created_at": "…"}]}] +}, "error": null} +``` +Semantics: `milestones` **excludes** `content_md` (bodies come from the content endpoint). `leaderboard` is the **full** ranked list (full names, always on). Ranking order: `completed_count` desc → earliest timestamp of reaching that count asc → `joined_at` asc; `rank` is the 1-based position (no tie-sharing). `help_requests` = mine only, newest first; never includes drafts or `ai_context`. Side effect: throttled `last_seen_at` touch (≥60 s since last). +Errors: `not_found`. Archived workshops still return state (read-only archive view; `workshop.status` = `"archived"`). + +### `GET /api/p/{participant_token}/content?cv={int}` +Unchanged → `{"data": {"changed": false, "content_version": 3}, "error": null}`. +Changed: +```json +{"data": {"changed": true, "content_version": 3, + "workshop": {"name": "…", "description_md": "…"}, + "milestones": [{"id": 11, "position": 0, "title": "…", "content_md": "…", "minutes": 30}] +}, "error": null} +``` + +### `POST /api/p/{participant_token}/milestones/{milestone_id}/complete` +Idempotent (unique constraint; re-complete is a no-op success). Blocked while paused/archived. +Response: `{"data": {"completed_milestone_ids": [11, 13], "completed_count": 2, "progress_pct": 25.0, "version": 43}, "error": null}`. +Errors: `not_found` (milestone not in my workshop), `workshop_paused`, `workshop_archived`. + +### `POST /api/p/{participant_token}/milestones/{milestone_id}/uncomplete` +Deletes my completion if present (idempotent). Same response shape and errors as complete. + +### `POST /api/p/{participant_token}/help` +Body: `{"message": "…"}` (1–4000; rendered as plain text everywhere). Effect: create request with `milestone_id` = my current milestone (lowest-position incomplete; null if finished); bump version. *(Phase 4: additionally triggers the AI pipeline as a background task when enabled.)* +Response: `{"data": {"help_request": , "version": 43}, "error": null}`. +Errors: `validation_error`, `workshop_archived`. + +### `POST /api/p/{participant_token}/help/{help_request_id}/resolve` +Participant marks their own request resolved. Idempotent. Errors: `not_found` (not mine). +Response: `{"data": {"help_request": , "version": 44}, "error": null}`. + +--- + +# Phase 2 endpoints — Facilitator command & proactive intelligence + +All under `/api/f/{admin_token}/…`; all bump `state_version`, write an audit row, and (where noted) record undo data. Undoable actions return `"undoable_action_id"`. + +| Endpoint | Body → Effect | +|---|---| +| `POST …/broadcast` | `{"message_md": "…"}` (1–4000) → new active broadcast (previous one cleared). Undoable (restores previous). Response: `{"broadcast": {...}, "version": N, "undoable_action_id": 77}` | +| `POST …/broadcast/clear` | `{}` → active broadcast `cleared_at` = now | +| `POST …/pause` | `{"paused": true|false}` → freeze/unfreeze completions. Undoable | +| `POST …/milestones/advance` | `{"milestone_id": 13, "participant_ids": [7, 9]}` or `"participant_ids": null` (= all) → create missing completions with `source: "facilitator"`. Undoable (deletes created rows). Response includes `"affected_count"` | +| `POST …/milestones/reorder` | `{"milestone_ids": [12, 11, 13]}` (exact permutation) → rewrite positions; bumps `content_version` too | +| `POST …/milestones` | `{"title", "content_md", "minutes"}` → append milestone; bumps `content_version` | +| `PATCH …/milestones/{id}` | any of `{"title", "content_md", "minutes"}` → edit; bumps `content_version` | +| `DELETE …/milestones/{id}` | removes milestone + its completions (confirmation is a UI concern); bumps both versions | +| `POST …/undo/{action_id}` | `{}` → apply inverse within 30 s. Errors: `undo_expired`, `not_found` | +| `GET …/audit?before_id={int}&limit={int≤100}` | → `{"actions": [{"id", "actor", "action", "detail": {…}, "created_at", "undone_at"}], "has_more": true}` newest first | +| `PATCH …/settings` | `{"stuck_minutes": 10}` (2–120) | + +Phase 2 also **activates** these dashboard-poll fields (shapes normative now): +```json +"broadcast": {"id": 5, "message_md": "…", "created_at": "…"}, +"alerts": {"stuck": [{"participant_id": 7, "name": "Priya", "minutes_inactive": 14, + "current_milestone_id": 13}], + "bottleneck": {"milestone_id": 13, "title": "Configure the API key", "waiting_count": 96}}, +"pulse": {"pace_ratio": 0.82, "on_track_pct": 61.0, "open_help_count": 3, + "projected_finish_at": "2026-07-20T17:40:00Z"} +``` +The participant poll's `broadcast` field activates with the same shape. Definitions: *stuck* = live, not paused, not finished, no completion and no help activity for ≥ `stuck_minutes`. *Bottleneck* = the milestone that is the current milestone of the largest participant group (≥25% of active participants, else null). *pace_ratio* = median actual minutes-per-milestone ÷ planned `minutes` (1.0 = on plan; >1 = slower). *on_track_pct* = % of participants whose progress ≥ elapsed-time-proportional expectation. *projected_finish_at* = now + (median remaining planned minutes × pace_ratio). + +--- + +# Phase 3 endpoints — Lifecycle, templates & re-run + +| Endpoint | Auth | Body → Effect | +|---|---|---| +| `POST /api/f/{t}/end` | token | `{"grace_hours": 24}` (0–168) → status `grace`, `ended_at`=now, `grace_until`=now+h. `0` archives immediately | +| `POST /api/f/{t}/archive` | token | `{}` → status `archived` now (terminal, read-only) | +| `POST /api/f/{t}/reopen` | token | `{}` → `grace` → `live` (mistake recovery; not allowed from `archived`) | +| `POST /api/f/{t}/clone` | token | `{"name": "…"}` → new `live` workshop, milestones copied, fresh tokens, zero participants. Response: new workshop object | +| `POST /api/f/{t}/save-as-template` | token | `{"name": "…"}` → agenda template from current milestones | +| `GET /api/admin/templates` | key | → `{"agenda_templates": [{"id","name","description_md","milestone_count","created_at","updated_at"}], "join_form_templates": [{"id","name","field_count","created_at","updated_at"}]}` | +| `POST /api/admin/templates/agenda` | key | `{"name", "description_md", "milestones": [{"title","content_md","minutes"}]}` | +| `GET /api/admin/templates/agenda/{id}` | key | full template incl. milestones | +| `PATCH /api/admin/templates/agenda/{id}` / `DELETE` | key | edit / delete (never affects existing workshops — snapshots) | +| `POST /api/admin/templates/forms` + `GET/PATCH/DELETE …/forms/{id}` | key | same pattern; body `{"name", "fields": []}` | + +`POST /api/admin/workshops` gains optional `agenda_template_id`, `join_form_template_id`, `starts_at`. `GET /api/join/{slug}` gains `"join_form": []` and `"starts_at"`; `POST /api/join/{slug}` gains `"answers": {…}` validated against the snapshot. Admin Home list gains `ended_at`/`starts_at` for Live/Upcoming/Ended grouping and an archived workshop's dashboard/tracker render read-only. + +--- + +# Phase 4 endpoints — AI help-desk + +| Endpoint | Auth | Body → Effect | +|---|---|---| +| `POST /api/f/{t}/ai` | token | `{"enabled": true|false}` → toggle; audit `ai.toggle`. When no `OPENROUTER_API_KEY` is configured, responds 200 with `{"enabled": false, "available": false}` — never an error (air-gapped) | +| `GET /api/f/{t}/spend` | token | → `{"total_cost_usd": 0.4312, "call_count": 57, "prompt_tokens": 91234, "completion_tokens": 18022, "by_purpose": [{"purpose": "triage_answer", "cost_usd": 0.4312, "count": 57}]}` | +| `POST /api/p/{token}/help/{id}/escalate` | token | `{}` → `escalated=true`, status → `open` (AI answers stay visible); bump version. Idempotent | + +Phase 4 **activates** (shapes normative now): dashboard `"spend": {"total_cost_usd": 0.43}`; help-queue answers carry real `ai_confidence`, `ai_model`, and `ai_context` (shape = `help_answer.ai_context_json`, data-model.md) plus `draft: true` rows for review; the workshop objects' `ai_enabled` goes live; tracker answers with `"source": "ai"` are badged AI client-side with the escalate action. Facilitator sending a reviewed draft uses the existing `POST …/help/{id}/answer` (the draft row stays for audit; the sent answer is a new `facilitator` row). + +--- + +*Phase 5 (deploy) adds no API surface.* diff --git a/spec/architecture.md b/spec/architecture.md index cc29c7d..c799212 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -1,106 +1,216 @@ -# Architecture — Workshop Helmsman (v0.1) +# Architecture — Workshop Helmsman (v0.2) ## Stack -- **Language:** Python 3.11 -- **Web framework:** FastAPI (with Jinja2Templates for server-rendered HTML) -- **Database:** SQLite via SQLAlchemy 2.x (dev) / PostgreSQL via `DATABASE_URL` (prod) -- **Templates:** Jinja2 (`frontend/templates/`) -- **Frontend JS:** vanilla (single file: `frontend/static/app.js`), polling every 4s -- **Styling:** single CSS file (`frontend/static/style.css`), CSS custom properties, mobile-first -- **Auth:** token-based (admin_token in URL, participant_slug in URL), HttpOnly cookie for participant session -- **Real-time:** short polling (4s) — no WebSockets. Single `/data` endpoint serves both participant tracker and admin dashboard (different shapes via query param). -- **Deploy:** Docker Compose + systemd unit. Runs on single VM (workshop.smalltech.in). -- **LLM:** Gemini via OpenRouter (`OPENROUTER_API_KEY` in `.env`). Optional — Phase 3 help-desk pre-resolution only. Graceful fallback if unavailable. +| Layer | Choice | Version / notes | +|-------|--------|-----------------| +| Language (backend) | Python | 3.12+ | +| Package manager (Python) | uv | all commands `uv run …` from repo root | +| Web framework | FastAPI | ≥0.115 | +| ASGI server | uvicorn | single process, single worker (see Process model) | +| ORM | SQLAlchemy | 2.x, sync engine, `Mapped`/`mapped_column` declarative | +| Migrations | Alembic | ≥1.13, `render_as_batch=True` for SQLite ALTER support | +| Database | SQLite (WAL) | file `data/helmsman.db`; PostgreSQL-ready via `DATABASE_URL` override — no SQLite-only SQL in app code (FTS5 corpus is the one documented exception, with a PostgreSQL note in agent.md) | +| Settings | pydantic-settings | v2, loads `.env` | +| Logging | structlog | JSON to stdout | +| HTTP client (LLM) | httpx | OpenRouter REST, 30s timeout | +| Frontend framework | Next.js | 15.x, App Router, **static export** (`output: 'export'`, `basePath: '/app'`) | +| UI library | React | 19.x | +| Language (frontend) | TypeScript | 5.x, strict | +| Styling | Tailwind CSS | v4 (via `@tailwindcss/postcss` + `@source "../";` in globals.css — never removed). Chosen for the polish bar: design tokens via `@theme`, consistent spacing/type scales, zero runtime cost in a static export | +| Markdown | react-markdown + remark-gfm + rehype-highlight | raw HTML disabled (no rehype-raw) — see Security | +| Package manager (frontend) | pnpm | frontend deps in `frontend/`, e2e deps in root `package.json` | +| E2E testing | Playwright | `@playwright/test`, chromium, config at repo root, tests in `tests/e2e/` | +| Backend testing | pytest | `tests/unit/`, `tests/integration/` | +| LLM provider | OpenRouter | key: `OPENROUTER_API_KEY` (exact name — project convention); default model `anthropic/claude-sonnet-4-6` via `HELMSMAN_AI_MODEL` | +| Deploy | Docker Compose on a VM; GitHub Actions CI/CD to GCP | Phase 5 — never blocks earlier phases | + +> **Assumed:** Node LTS (22.x) pinned via `frontend/.nvmrc` and `engines`; all frontend `dev`/`build` scripts carry `NODE_OPTIONS=--no-experimental-webstorage` (harness Node ≥25 safety rule). ## Repo layout ``` -workshop-helmsman/ -├── src/ # FastAPI app package +workshop-helmsman/ ← repo root IS the project; all commands run from here +├── src/ │ ├── __init__.py -│ ├── __main__.py # entrypoint: python -m src -│ ├── main.py # all routes (Phases 1-6 consolidated) -│ ├── models.py # SQLAlchemy models -│ ├── db.py # engine, session, init_db -│ └── security.py # token/slug generators, auth dependencies +│ ├── __main__.py ← boots uvicorn on 0.0.0.0:$PORT (default 8001) +│ └── helmsman/ +│ ├── __init__.py ← __version__ = "0.2.0" +│ ├── config/settings.py ← pydantic-settings; reads .env +│ ├── security.py ← token generation + auth dependencies +│ ├── db/ +│ │ ├── models.py ← all SQLAlchemy models (spec/data-model.md) +│ │ └── session.py ← engine, SQLite pragmas, session dependency +│ ├── api/ +│ │ ├── __init__.py ← create_app(): routers, static mount, pretty-link redirects +│ │ ├── _common.py ← ok(), api_error() +│ │ ├── health.py ← GET /api/health +│ │ ├── admin.py ← admin-key surface (create/list workshops, templates) +│ │ ├── facilitator.py ← /api/f/{admin_token}/… surface +│ │ └── participant.py ← /api/join/…, /api/p/{token}/… surface +│ ├── services/ +│ │ ├── snapshots.py ← versioned poll-payload builders + in-process cache +│ │ ├── lifecycle.py ← lazy status transitions (Phase 3) +│ │ ├── intelligence.py ← stuck / bottleneck / pulse computation (Phase 2) +│ │ ├── undo.py ← undoable-action record/replay (Phase 2) +│ │ └── templates.py ← template instantiate/save-as (Phase 3) +│ ├── ai/ ← AI help-desk pipeline (Phase 4; see spec/agent.md) +│ │ ├── pipeline.py ← run_help_desk(help_request_id) +│ │ ├── context.py ← context gathering +│ │ ├── corpus.py ← FTS5 corpus maintenance + similarity search +│ │ ├── openrouter.py ← httpx client, usage/cost capture +│ │ └── prompts/ ← *.md prompt templates +│ └── observability/ +│ └── logging.py ← structlog config + request-logging middleware ├── frontend/ -│ ├── templates/ # Jinja2 templates -│ │ ├── base.html -│ │ ├── landing.html -│ │ ├── admin_new.html -│ │ ├── admin_edit.html -│ │ ├── admin_dashboard.html -│ │ ├── admin_templates.html -│ │ ├── admin_template_edit.html -│ │ ├── admin_form.html -│ │ ├── admin_form_template.html -│ │ ├── participant_join.html -│ │ ├── participant_tracker.html -│ │ ├── participant_drilldown.html -│ │ ├── workshops_index.html -│ │ ├── workshop_archived.html -│ │ ├── workshop_expired.html -│ │ ├── _stubs.html -│ │ └── _form_field_row.html -│ └── static/ -│ ├── style.css # single stylesheet -│ ├── app.js # participant polling + UI -│ ├── help_status.js # inline help-status buttons (admin + participant) -│ └── form_editor.js # form builder drag/drop/inline edit -├── spec/ # this directory -├── data/ # sqlite file at runtime (gitignored) +│ ├── package.json ← pnpm; NODE_OPTIONS safety in scripts +│ ├── next.config.ts ← output: 'export', basePath: '/app', trailingSlash: true +│ ├── postcss.config.mjs ← @tailwindcss/postcss (never removed) +│ ├── tsconfig.json +│ ├── app/ +│ │ ├── layout.tsx ← fonts, shell +│ │ ├── globals.css ← Tailwind v4 + @source "../"; + design tokens (@theme) +│ │ ├── page.tsx ← Admin Home (facilitator access key + workshop list/create) +│ │ ├── f/page.tsx ← Facilitator Dashboard (?t=) +│ │ ├── join/page.tsx ← Participant Join (?s=) +│ │ └── p/page.tsx ← Participant Tracker (?t=) +│ ├── components/ +│ │ ├── ui/ ← shared primitives (Button, Card, Badge, Toast, Modal, Markdown, Skeleton, StubBadge) +│ │ ├── facilitator/ ← dashboard components +│ │ └── participant/ ← tracker/join components +│ └── lib/ +│ ├── api.ts ← typed API client (contract: spec/api.md) +│ └── poll.ts ← versioned polling hook (visibility-aware) +├── alembic/ ← env.py, script.py.mako, versions/ +├── tests/ +│ ├── conftest.py +│ ├── unit/ ← pytest, no network +│ ├── integration/ ← pytest, real HTTP (TestClient) + real DB file; Phase 4: tests/integration/ai against real OpenRouter key +│ └── e2e/ ← Playwright specs (TypeScript) +├── playwright.config.ts ← baseURL http://localhost:8001/app +├── package.json ← root: @playwright/test only +├── pyproject.toml ← uv-managed; testpaths = ["tests"] +├── alembic.ini ├── .env.example -├── .env # local overrides (gitignored) -├── requirements.txt -├── Dockerfile -├── docker-compose.yml -└── deploy/ - └── workshop-helmsman.service +├── data/ ← SQLite file at runtime (gitignored) +├── deploy/ ← Phase 5: Dockerfile, docker-compose.yml, runbook +└── .github/workflows/ ← Phase 5: CI/CD ``` -## Request flow (Phase 1 Core Path) +Import convention: the application package is `src.helmsman.*` (the `src` package exists so `uv run python -m src` boots — harness contract). -1. Facilitator visits `/` → clicks "Create new workshop" -2. Browser `GET /admin/new` → form with agenda template picker, form template picker, inline editors -3. Browser `POST /admin/new` → server creates `Workshop` row, generates `admin_token` + `participant_slug`, snapshots milestones + form schema, redirects to `/admin/` -4. Facilitator shares `/w/` with participants -5. Participant opens `/w/`: if no `participant_id` cookie → join form (name + custom fields). `POST /w/` creates `Participant`, sets HttpOnly cookie, redirects to `/w//me` -6. Participant tracker (`/w//me`) polls `GET /w//data?since=` every 4s → renders leaderboard, help flags, **broadcast banner**, **workshop_paused state**, milestone checklist -7. Admin dashboard (`/admin/`) polls `GET /admin//data?since=` every 4s → renders participant table, stats, cohort bar, help flags, broadcast composer, milestone controls -8. Participant clicks "Mark complete" → `POST /w//me/complete/` → inserts `MilestoneCompletion` (blocked if `workshop_paused=true`) -9. Participant submits help → `POST /w//me/help` (two-step: preview with LLM suggestion → commit) → inserts `HelpRequest` -10. Facilitator actions (broadcast, pause, advance, reorder) → POST to admin endpoints → immediate effect, next poll reflects change +## Process model & single-origin serving -## Bootstrap modes +**One process serves everything.** `uv run python -m src` starts one uvicorn worker on port **8001**: -- `python -m src` → production-style boot on `0.0.0.0:8001` -- On every boot, demo seeder runs **only if zero workshops exist** → first install is one-click testable, subsequent boots idempotent +- `/api/*` — JSON API (FastAPI routers, sync `def` handlers on the threadpool). +- `/app/*` — the built Next.js static export (`frontend/out/`), mounted with `StaticFiles(html=True)`. The canonical URL is `http://localhost:8001/app/`. +- **Pretty share links** (tiny FastAPI redirect routes, because a static export cannot serve dynamic path segments): + - `GET /j/{join_slug}` → 307 → `/app/join/?s={join_slug}` (the link on the door) + - `GET /p/{participant_token}` → 307 → `/app/p/?t={participant_token}` (personal link) + - `GET /f/{admin_token}` → 307 → `/app/f/?t={admin_token}` (facilitator link) + - `GET /` → 307 → `/app/` +- No separate Node server in production. `pnpm dev` (port 3000) is inner-loop only and is never the documented test path. -## Data sovereignty +**Single worker is a deliberate choice**, not an accident: it makes the in-process snapshot cache correct without Redis, and the load profile (below) fits comfortably in one asyncio process + threadpool. If the app ever outgrows one worker, the cache moves behind the DB (the DB is already the source of truth, so nothing breaks — the cache is only an optimization). -- All state in `data/helmsman.db` (SQLite) or PostgreSQL via `DATABASE_URL` -- `data/` directory excluded from git (`.gitignore`) -- No outbound network calls in core loop. LLM call (OpenRouter) only on facilitator "suggest reply" click — optional, never blocks. +## Live-update mechanism: versioned coalesced polling (decision + justification) -## Key architectural decisions +**Decision: short polling with per-workshop version counters and an in-process snapshot cache. No SSE, no WebSockets.** -| Decision | Rationale | -|----------|-----------| -| Server-rendered Jinja2 + polling | Zero JS build step, works on any browser, easy to audit, fast initial paint | -| Single `/data` JSON endpoint | One polling loop serves both participant and admin views; conditional fields via `?view=admin\|participant` | -| Workshop-scoped tokens in URL | No auth provider, no cookies for admin, shareable links, revocable by archive | -| Snapshot milestones/form on workshop create | Template edits never mutate historical sessions | -| `workshop_paused` + `broadcast_message` on Workshop row | Simple, no extra tables; atomic toggle; visible on next poll (≤4s) | -| Milestone order stored as JSON array of IDs | Drag-drop reorder without schema migration; stable IDs (`m0`, `m1`...) | -| CSV streaming via `StreamingResponse` | Handles 100+ participants × 10 milestones without memory spike | +Load profile: 300+ participants + 1–3 facilitator dashboards on 2 vCPU / 2–4 GB, SQLite storage, and a hard resilience rule (reconnect/restart must recover purely from the DB). + +Why polling beats SSE here: + +1. **Resilience is free.** Polling is stateless — after a server restart, every client's next poll simply succeeds with full state. SSE requires reconnect logic, `Last-Event-ID` replay, server-side fan-out state, and produces a thundering-herd reconnect after every restart or deploy. The resilience requirement ("restart loses nothing, reconnect restores exact state") is the polling model's native behavior. +2. **The arithmetic is trivial.** 300 participants polling every 3 s = 100 req/s. Each poll does **one indexed point-read** (the workshop's version row) and, when nothing changed (the overwhelming steady state), returns a ~60-byte `{"changed": false}` response. SQLite in WAL mode serves tens of thousands of point-reads/s; uvicorn serves 100 req/s of this without noticing. SSE's 300 idle connections would also fit in memory — but buys nothing over this, at real complexity cost (proxy buffering config, keepalive pings, per-event fan-out code). +3. **SQLite write contention stays negligible.** Peak write load is a completion burst (~5–10 writes/s) plus joins and help requests. WAL readers never block the writer; each write is a short transaction that also bumps the workshop's version counter. Polling adds *zero* writes (the `last_seen_at` touch is throttled to once per 60 s per participant ≈ 5 writes/s at 300 participants). +4. **Bandwidth is controlled by two version counters.** Each workshop row carries: + - `state_version` — bumped on any activity change (join, completion, help, answer, broadcast, pause, advance, reorder…) + - `content_version` — bumped only when milestone content/order changes. + Clients send their last-seen `state_version`; unchanged → tiny response. Changed → the poll payload (**without** milestone `content_md` — leaderboard, progress, help state; ~10–15 KB at 300 participants). Milestone bodies are fetched from a separate content endpoint only when `content_version` changes. Worst-case burst (a completion every second, all 300 clients refreshing full payloads) ≈ 1–1.5 MB/s — comfortably inside a modest VM's budget; steady state is ~10 KB/s total. +5. **Coalescing caps DB work independent of participant count.** The changed-payload for a given `(workshop_id, state_version)` is built once and memoized in-process (dict, TTL 2 s as a safety net); 300 pollers hitting the same version share one snapshot build. Per-request personal data ("me": my completions, my help requests) is 2–3 indexed point-queries per poll — ~250 cheap queries/s at burst, trivial for WAL SQLite. + +Poll intervals (client, via the `lib/poll.ts` hook): participant tracker **3 s** visible / **15 s** hidden (Page Visibility API); facilitator dashboard **2 s** visible / **10 s** hidden. Every mutation response includes the new `state_version`, and the client polls immediately after any of its own mutations, so the actor always sees their effect within one round-trip (plus optimistic UI, see Error handling). + +## Auth model + +No accounts anywhere. Three credentials, all generated with `secrets`: + +| Credential | Format | Entropy | Carried in | Grants | +|---|---|---|---|---| +| Facilitator access key | operator-chosen string in `.env` (`HELMSMAN_ADMIN_KEY`, recommend ≥16 chars) | n/a | `X-Admin-Key` request header (entered once on Admin Home, kept in `localStorage`) | Admin Home: list/create workshops, template library | +| Workshop admin token | `secrets.token_urlsafe(32)` → 43 chars | 256 bits | URL path (`/f/{token}`, `/api/f/{token}/…`) | Full facilitator control of one workshop; shareable to co-facilitators | +| Participant token | `secrets.token_urlsafe(16)` → 22 chars | 128 bits | URL path (`/p/{token}`, `/api/p/{token}/…`) **and** cookie | One participant's identity, cross-device | +| Join slug | `secrets.token_urlsafe(6)` → 8 chars | 48 bits (public, not a secret) | URL (`/j/{slug}`) | The join page only | + +- Key comparison uses `secrets.compare_digest`. Unknown token/key → 404 (`not_found`) for workshop tokens, 401 (`invalid_admin_key`) for the admin key — never distinguish "wrong" from "missing" for tokens. +- **Participant cookie (auto-resume):** on join, the server sets `helmsman_p_{workshop_id}={participant_token}`; `HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000` (30 days). `GET /api/join/{slug}` reads it server-side and returns the participant's token if recognized, so the join page silently forwards a returning browser to its tracker. The personal link `/p/{token}` needs no cookie — it *is* the cross-device credential. The facilitator dashboard shows each participant's personal link (copy button) — the lost-link recovery path. +- Structured logs mask all tokens to their first 6 characters. + +## Error-handling strategy + +- **Envelope (exact, both directions in spec/api.md):** success → HTTP 200, `{"data": …, "error": null}`. Failure → HTTP 4xx/5xx, `{"detail": {"code": "", "message": ""}}` (FastAPI `HTTPException` shape via `api_error()`). One catalogue of `code` values lives in api.md. +- **Fail fast at the boundary.** Request bodies are validated by Pydantic models; domain validation (lengths, state rules like "workshop is archived") raises `api_error` with a specific code. Invalid data never propagates. +- **Optimistic UI with reconciliation.** Mutations (mark complete, send answer) update the UI immediately, fire the request, and reconcile on the response + next poll. On failure: the UI reverts and shows a toast naming the cause and next step ("Couldn't save — retrying in 3 s" / "This workshop is paused"). +- **The poll loop is self-healing.** A failed poll (server restarting, network blip) shows a quiet "reconnecting…" indicator after 2 consecutive failures, backs off (3 s → 6 s → 12 s, cap 30 s), and recovers automatically — full state comes from the DB on the first successful poll. No data is ever lost because no data lives only in the client or the process. +- **Startup fails hard** on unrecoverable config (missing `HELMSMAN_ADMIN_KEY`, unreadable `DATABASE_URL`) with a clear message. A missing `OPENROUTER_API_KEY` is *not* an error — the AI surface reports "AI off (no API key)" and everything else runs (air-gapped guarantee). +- **AI failures degrade to manual.** Any AI-pipeline failure (timeout, 5xx, malformed output) logs ERROR and leaves the help request in the normal facilitator queue — the participant experience is identical to AI-off (see agent.md). + +## Observability (wired in Phase 1, never deferred) + +- **structlog → JSON on stdout.** Every line: `timestamp`, `level`, `event`, `request_id`, plus context. +- **Request middleware:** method, token-masked path, status, `duration_ms`, `request_id` for every API request (poll endpoints log at DEBUG to keep INFO readable). +- **Domain events at INFO:** `workshop.created`, `participant.joined`, `milestone.completed`, `help.created`, `help.answered`, `broadcast.sent`, `workshop.paused`, `undo.applied`, `ai.triage`, `ai.auto_answered`, `ai.draft_created`, `ai.failed` — each with ids and (for AI) latency, token counts, cost. +- **No metrics endpoint, no ops UI** (vision non-goal). `GET /api/health` returns `{"status":"ok","db":"ok"}` for machines. + +## Database configuration + +- SQLite engine (default `sqlite:///data/helmsman.db`), applied per-connection via an `connect` event listener: + `PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;` +- SQLAlchemy: sync engine, `pool_pre_ping=True`; for SQLite `connect_args={"check_same_thread": False}`. +- `DATABASE_URL` may point at PostgreSQL; models use portable types only (Integer/Text/Boolean/DateTime/Numeric + JSON as Text). Alembic runs with `render_as_batch=True` so SQLite ALTERs work. The FTS5 help corpus (Phase 4) is created only when the dialect is SQLite; agent.md documents the PostgreSQL alternative. +- All timestamps stored UTC; serialized as ISO 8601 `Z` (`2026-07-20T14:03:22Z`). +- Transactions are short: one request = one transaction (commit in the session dependency). Any state-changing write bumps the workshop's `state_version` in the same transaction. +- **Lazy lifecycle transitions (Phase 3):** no cron/background scheduler. Every workshop-scoped request first applies due transitions (`grace_until` passed → `archived`) inside the request transaction — restart-proof by construction. ## Security notes -- `admin_token` = 32-char URL-safe secret (256 bits entropy). Treat as password. -- `participant_slug` = 8-char URL-safe (not secret; public join link). -- Participant cookie: HttpOnly, SameSite=Lax, path-scoped to `/w/`, 12h TTL. -- No password hashing (no passwords). No session table (stateless tokens). -- SQL injection: SQLAlchemy ORM + parameterized queries only. -- XSS: Jinja2 auto-escape + `escapeHtml` in JS for dynamic inserts. -- CSRF: not needed for token-in-URL flows; participant cookie is read-only for GET, POSTs are same-origin form submits. \ No newline at end of file +- **XSS:** all facilitator/AI-authored markdown (milestones, broadcasts, answers) renders through react-markdown with **raw HTML disabled** — HTML in markdown source is not injected. Participant-authored text (names, help messages) is rendered as **plain text** (React text nodes, `whitespace-pre-wrap`), never as markdown or HTML. +- **SQL injection:** SQLAlchemy ORM/parameterized queries only. The FTS5 `MATCH` query string is built from sanitized, quoted tokens (see agent.md). +- **CSRF:** state-changing endpoints are authenticated by unguessable URL tokens or the `X-Admin-Key` header — not by cookies — so cross-site form posts cannot forge them. The participant cookie is only ever *read* to resolve auto-resume on `GET /api/join/{slug}`. +- **Secrets:** only in `.env` (gitignored). Logs and API responses never contain `OPENROUTER_API_KEY` or `HELMSMAN_ADMIN_KEY`; key presence is logged as a boolean. +- **Rate limiting:** none in v0.2 (self-hosted, unguessable tokens, trusted room). > **Assumed:** acceptable for a single-team instance; revisit if ever exposed as SaaS. + +## Environment variables (complete list) + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `DATABASE_URL` | no | `sqlite:///data/helmsman.db` | SQLAlchemy URL; PostgreSQL-ready | +| `PORT` | no | `8001` | HTTP port | +| `HELMSMAN_ADMIN_KEY` | **yes** | — | Facilitator access key for Admin Home | +| `HELMSMAN_BASE_URL` | no | derived from request | Absolute base for generated share links (set behind a proxy) | +| `HELMSMAN_LOG_LEVEL` | no | `INFO` | structlog level | +| `OPENROUTER_API_KEY` | no | — | AI help-desk; absent → fully air-gapped, zero errors | +| `HELMSMAN_AI_MODEL` | no | `anthropic/claude-sonnet-4-6` | OpenRouter model id | +| `HELMSMAN_AI_CONFIDENCE` | no | `0.75` | Auto-answer confidence threshold (see agent.md) | + +## Deploy shape (Phase 5) + +- `deploy/Dockerfile` — multi-stage: (1) Node stage builds the frontend export, (2) Python stage with uv installs the app and copies `frontend/out/`; runs `alembic upgrade head` then `python -m src`. +- `deploy/docker-compose.yml` — one service, one named volume for `data/` (SQLite + WAL files), `env_file: .env`, port 8001. Optional Caddy/nginx TLS proxy is documented, not shipped. +- `.github/workflows/ci.yml` — on PR: `uv run pytest tests/unit -q` + `pnpm build` + lint. Real-key integration tests run only where secrets exist (guarded skip). +- `.github/workflows/deploy.yml` — on main: build image, push to Artifact Registry, SSH-deploy compose to the GCP VM. +- Backup = copy the `data/` volume (documented in `deploy/RUNBOOK.md`). + +## Key architectural decisions (summary) + +| Decision | Rationale | +|---|---| +| Versioned coalesced polling, no SSE | Restart/reconnect resilience for free; ~100 req/s of point-reads is trivial; no fan-out state, no proxy pitfalls | +| Two version counters (`state_version` / `content_version`) | Keeps steady-state polls at ~60 bytes and burst payloads ~10–15 KB; milestone bodies transfer only when edited | +| Single uvicorn worker + in-process snapshot cache | Correct coalescing without Redis; load fits one process; DB remains sole source of truth | +| Milestones as a real table (not JSON blob) | Per-milestone stats for 300 participants via indexed GROUP BY; FTS/context queries for the AI phase | +| Static Next.js export served by FastAPI at `/app/` + pretty-link redirects | Single origin, one process, harness gate path; dynamic tokens carried in query params where the static router needs them, pretty in shared URLs | +| Lazy lifecycle transitions on access | No scheduler process to crash or restart; correctness derives from the DB clock check | +| Audit + undo share one table (`facilitator_action`) | One write per action serves the audit trail, the undo window, and AI-answer logging | diff --git a/spec/capabilities.md b/spec/capabilities.md index 7f41129..58d22ff 100644 --- a/spec/capabilities.md +++ b/spec/capabilities.md @@ -1,89 +1,96 @@ -# Capabilities — Workshop Helmsman (v0.1) +# Capabilities — Workshop Helmsman (v0.2) -Phased list. Each phase ships behind a Gate command that proves the slice works end-to-end before the next phase begins. +Four core capabilities carry the product; everything else is deferred to a named phase (§Deferred). API shapes live in [api.md](api.md); schema in [data-model.md](data-model.md); phase plan in [roadmap.md](roadmap.md). This file also carries the UI specification (pages, components, states, stub placements) — there is no separate ui.md. ---- +## Design system (applies to every page) + +- Tailwind v4 design tokens in `globals.css` `@theme`: brand **indigo** (`--color-brand-*`), warm neutral scale, semantic colors (success green, warning amber, danger red, AI **violet**), one radius scale (lg cards, md controls), one shadow scale, spacing on the 4px grid. Type: Inter (via `next/font`, system fallback); body ≥16px; `text-sm` only for metadata. Light theme only (vision non-goal). +- Shared primitives in `frontend/components/ui/`: `Button` (primary/secondary/ghost/danger; loading state), `Card`, `Badge` (status pills), `ProgressBar`, `Toast`, `Modal`, `Skeleton`, `EmptyState` (icon + one-line explanation + one action), `Markdown` (react-markdown + gfm + highlight; raw HTML off), `CopyButton` (copies + "Copied" feedback), **`StubBadge`** — the labelled-stub primitive: a muted card/section with a `Coming in a later phase` pill, short description of what will live there, non-interactive. Every future feature surface renders through `StubBadge` so a stub is never mistaken for a bug. +- Every view designs all four states (empty / loading / error / populated) per `harness/patterns/ui-ux.md`. Loading = skeletons with context, never blank. Errors = human sentence + next step, never a stack trace. All interactive elements keyboard-reachable with visible focus; semantic HTML; WCAG AA contrast. +- **Connection indicator:** both live pages show a quiet "reconnecting…" pill after 2 consecutive failed polls, clearing silently on recovery. Never a modal, never data loss (state is in the DB). -## Phase 1 — Core Path (this improvement cycle) +--- -**The single most important user journey:** Facilitator creates workshop from template → shares participant link → participants join and fill form → facilitator watches live dashboard → participants complete milestones → facilitator exports CSV. +## C1 — Workshop creation & facilitator access *(Phase 1)* -**What's being improved over the existing v0.1:** +Facilitators enter once with the instance access key, then create and reach workshops via token links. -| Area | Improvement | -|------|-------------| -| **Facilitator Dashboard** | Visual polish: stat cards, status pills, real-time feel with live progress bars, per-milestone completion table, cohort stacked bar, paginated help flags with status chips | -| **Participant Join** | Instant validation on name field, better mobile UX (larger touch targets, proper keyboard types), inline error/success states | -| **Agenda/Milestone Management** | Drag-and-drop reorder (Phase 1: up/down buttons), inline edit title/description, milestone order persisted per workshop | -| **Form Builder** | Visual field editor (add/remove/reorder fields), field types: text/dropdown, required toggle, template picker | -| **Broadcast Messaging** | Facilitator → all participants: pinned banner on tracker with dismiss, Markdown-lite rendering | -| **Milestone Controls** | "Advance all to next", "Advance selected", "Pause workshop" (locks completions), per-participant override | -| **Export** | One-click CSV with all data: participants × milestones + form answers + help requests | +**Pages:** +- **Admin Home — `/app/`**: first visit shows a centered access-key card (one password field, "Enter"; wrong key → inline "That key doesn't match this server's `HELMSMAN_ADMIN_KEY`"). Valid key (checked via `GET /api/admin/workshops`) is kept in `localStorage` and sent as `X-Admin-Key`. Then: workshop list grouped **Live / Upcoming / Ended** (Phase 1: everything is Live; Upcoming/Ended groups render as labelled stubs), each card showing name, participant count, open-help count, created date, buttons **Open dashboard** and **Copy join link**. Empty state: "No workshops yet — create your first." Header button **New workshop**. Stub surfaces on this page: **Template library** nav item (StubBadge, Phase 3). +- **Create workshop** (route `/app/` modal or section): name, description (markdown textarea), and a milestone builder — ordered rows of *title + markdown content editor + minutes*, add/remove/move up-down, live markdown preview per milestone (tabs Write/Preview). A **template picker** renders as a labelled stub (Phase 3). Submit → success screen presenting the two links prominently with copy buttons and a plain-language warning: *"The facilitator link is the only key to this dashboard — save it now."* -**Clearly-labelled stubs (Phase 2+):** -- Multi-workshop concurrent sessions (dashboard switcher) -- Breakout rooms / multiple facilitators per workshop -- Email/Slack notifications on help flags -- Custom domains per workshop (e.g. `acme.workshop.smalltech.in`) -- Participant messaging (chat between participants) +**Behavior:** `POST /api/admin/workshops`; workshop is `live` immediately; audit row written. Success criteria: create → dashboard reachable via returned `facilitator_url`; join link works; refresh of Admin Home lists the workshop with live counts. --- -## Phase 1 — Surfaces built - -1. `GET /admin/new` → form: workshop name, pick agenda template (or inline milestones), pick form template (or inline fields), TTL hours -2. `POST /admin/new` → creates workshop, returns redirect to `/admin/` -3. `GET /admin/` → **polished dashboard**: stat cards, participant table with progress bars + milestone chips, per-milestone completion stats, cohort stacked bar, help flags with status pills + inline status change, broadcast message composer, milestone controls (advance all, pause, reorder) -4. `GET /w/` → join page: workshop name + form fields, instant validation, mobile-optimized, submit → cookie + redirect -5. `GET /w//me` → participant tracker: milestone checklist with mark-complete, progress bar, leaderboard (top 50 + toggle), **broadcast banner**, my help requests with status pills -6. `GET /w//data` → JSON: milestones, my progress, leaderboard, help requests, **broadcast message**, **workshop_paused flag**, form schema -7. `GET /admin//data` → JSON: all participants + progress, per-milestone stats, cohort bar, help requests, broadcast message, workshop_paused, milestone order -8. `POST /w//me/complete/` → mark milestone done (idempotent; blocked if workshop_paused) -9. `POST /w//me/help` → create help request (two-step: preview → commit; LLM suggestion stub returns empty) -10. `POST /admin//broadcast` → set/clear broadcast message -11. `POST /admin//pause` → toggle workshop_paused -12. `POST /admin//advance-all` → advance all participants to next incomplete milestone -13. `POST /admin//advance-selected` → advance selected participant IDs -14. `POST /admin//milestones/reorder` → persist new milestone order -15. `GET /admin//export.csv` → streams full CSV -16. `GET /healthz` → `{"status":"ok","db":"ok"}` +## C2 — Frictionless participant identity *(Phase 1)* ---- +Join with a name; never lose your place. + +**Pages:** +- **Join — `/app/join/?s=`** (pretty link `/j/` on the door): workshop name + description (markdown), milestone count, participant count ("143 people are in"), one **name field** + **Join** button. Instant validation (1–80 chars, trimmed). If `GET /api/join/{slug}` returns `me` (cookie auto-resume), skip the form: "Welcome back, Priya" → auto-forward to the tracker. Errors: unknown slug → friendly "This workshop link isn't valid — check with your facilitator." Archived → "This workshop has ended" with a **Browse archive** link (read-only tracker). +- After join: the tracker shows a dismissible one-time callout: *"This page's link is yours — it works on any device. Save it."* with a copy button for `participant_url`. -## Phase 2 — Template Library & Multi-Session (future) +**Identity mechanics (architecture.md §Auth):** cookie `helmsman_p_{workshop_id}` (HttpOnly, 30 d) for same-browser auto-resume; the personal link `/p/` is the cross-device credential; the facilitator dashboard's participant table shows a **copy personal link** button per row — the lost-link recovery path ("what's your name? → here's your link"). Reconnect/restart restores exact state on the next poll because all state is in the DB. -- `/admin/templates` — list/create/edit/delete agenda templates & form templates -- `/admin/new` and `/admin//edit` include template pickers -- Editing a template never mutates existing workshops (snapshots on create) -- `/workshops` archive page with search/filter, per-session drill-down -- Cohort comparison across sessions of same template +**Success criteria:** join in <10 s from link tap; browser restart auto-resumes; the personal link opens the identical state on a second device; a facilitator can recover any participant's link in two clicks. --- -## Phase 3 — AI Assist & Notifications (future) +## C3 — Content-rich milestone tracking, live *(Phase 1)* -- Optional Gemini via OpenRouter for help-desk pre-resolution -- Facilitator clicks "suggest reply" on help flag → LLM returns short actionable fix → facilitator edits/sends -- Email/Slack webhook notifications when help flags accumulate -- Graceful degradation: no API key = feature silently disabled +The heartbeat: participants work milestones; the facilitator watches the room move. ---- +**Participant Tracker — `/app/p/?t=`** (pretty `/p/`): +- Sticky header: workshop name, my **progress bar** with `n / total`, connection indicator. +- **Milestone checklist**: one card per milestone in position order — checkbox + title + minutes chip + collapsible **markdown body** (instructions, links, code snippets with syntax highlighting + copy-code button). Current milestone (lowest incomplete) is auto-expanded and visually accented; completed ones collapse with a green check. **Mark complete** is optimistic (instant check, reconciled by poll); un-mark available via the checkbox ("fixed a mis-click"). Paused workshop (Phase 2) disables checkboxes with a banner — the Phase 1 payload already carries `paused`. +- **Leaderboard panel** (side on desktop, tab on mobile): full ranked list, full names always on, my row highlighted and pinned when off-screen; top-3 subtle medals. Renders all rows (virtualized past 50). +- **Help panel**: C4 below. **Broadcast banner slot**: renders active broadcast (Phase 2 data); in Phase 1 an inline StubBadge in the panel footer notes "Announcements from your facilitator will appear here." +- Data: `state` poll 3 s (visible) / 15 s (hidden) + `content` fetch keyed by `content_version`. Loading = full-page skeleton mirroring the layout. -## Phase 4 — Scale & Multi-tenancy (future) +**Facilitator Dashboard — `/app/f/?t=`** (pretty `/f/`), the mission-control view, 2 s poll: +- **Header**: workshop name, status pill, participant/active counts, **Copy join link**, connection indicator. Stub actions (StubBadge’d in Phase 1): **Broadcast**, **Pause**, **End workshop**, **AI toggle**, **Spend**. +- **Stat row**: participants, active (5 min), finished, median progress, open help — large-number cards. +- **Per-milestone completion**: horizontal bars per milestone (completed_count / participants, percentage), highlighting the milestone where the largest group currently sits. +- **Cohort distribution**: histogram of participants by completed-count (0…total) — the room's shape at a glance. +- **Participant table**: name, progress bar + count, per-milestone dots (done/current/todo), joined/last-seen, open-help badge, **copy personal link**. Client-side sort (progress/name/joined) and name filter. 300 rows: virtualized. +- **Help queue**: C4 below. **Proactive-intelligence rail**: stuck alerts / bottleneck / session pulse render as three labelled stub cards in Phase 1 (fields already `null` in the payload), real in Phase 2. **Audit trail** tab: StubBadge in Phase 1. -- PostgreSQL backend (swap SQLite via `DATABASE_URL`) -- Multiple concurrent workshops -- Per-workshop facilitator tokens (rotate, revoke) -- Custom domains / subdomains -- Team/organization grouping +**Success criteria:** a completion on a phone is visible on the dashboard within one poll cycle (≤2 s + RTT); per-milestone bars and distribution always sum consistently with the table; 300 participants render without jank; markdown code blocks are highlighted and copyable. --- -## Always-on cross-cutting concerns (any phase) +## C4 — Help desk *(Phase 1 manual; Phase 4 adds AI in front)* + +A participant raises a hand without disrupting the room; help arrives in-page. + +**Participant side (tracker help panel):** "Need help?" card — one textarea ("What are you stuck on? Your facilitator sees this immediately"), submit → optimistic entry in **My help requests**: message (plain text, `pre-wrap`), status pill (`open` = amber "Waiting", `answered` = blue "Answered", `resolved` = green), timestamps. Answers render as markdown reply bubbles; a facilitator answer flashes/highlights on arrival via poll. Buttons: **Mark resolved** (own requests). Phase 4 adds: instant AI answers badged **AI** (violet) with **Get a human** escalation; Phase 1 shows no AI chrome at all (nothing to stub on the participant side — absence is the honest state). +- Empty state: "Stuck? Ask here — answers appear right on this page." + +**Facilitator side (dashboard help queue):** cards newest-open-first — participant name, their current milestone chip, message (plain text), age ("4 m ago"), status pill, escalated flag (Phase 4). Inline **reply composer** (markdown textarea with preview, "Answer" button) + **Mark resolved**. Answer → participant sees it next poll; request → `answered`; stays visible until participant (or facilitator) resolves. Resolved section collapsed with count. Every answer writes an audit row. Phase 4 adds: AI draft block (violet, confidence %, editable, "Send") and the expandable **"Context the AI used"** disclosure on AI answers. +- Empty state: "No open help requests — the room is cruising." + +**Success criteria:** request → visible in queue within one dashboard poll; answer → visible on tracker within one tracker poll; full loop (submit → answer → see → resolve) with zero manual refreshes; markdown answers with code render correctly; every answer audited (who/what/when). + +--- -- Platform-agnostic URLs (no hardcoded host/port) -- Mobile-responsive CSS (grid/flex + viewport meta) -- Health probe at `/healthz` returning JSON -- No secrets required to boot (`.env.example` intentionally empty for core flow) -- Single CSS file, single JS file, no bundler, no node_modules -- Audit trail: every milestone completion, help request, facilitator action timestamped \ No newline at end of file +## Deferred capabilities (schema exists from Phase 1; features arrive at their phase) + +| Capability | Phase | One-line scope | +|---|---|---| +| Broadcast announcements | 2 | Markdown composer → pinned participant banner; history; clear; undoable | +| Milestone controls | 2 | Advance all/selected, pause/resume, reorder, edit/add/delete milestones mid-session | +| Undo | 2 | 30 s inverse-operation window for advance-all/selected, pause, broadcast | +| Audit trail UI | 2 | Dashboard tab over `facilitator_action` (every facilitator + AI action, who/what/when) | +| Proactive intelligence | 2 | Stuck-participant alerts (`stuck_minutes`), bottleneck detection, session pulse (pace vs plan, % on track, projected finish) | +| Lifecycle: end / grace / archive | 3 | End workshop → straggler grace window → read-only archive, browsable forever; lazy transitions | +| Clone / re-run | 3 | New workshop from an old one: milestones copied, fresh tokens, zero participants | +| Agenda template library | 3 | CRUD + create-from-template + save-workshop-as-template; instantiation snapshots | +| Join-form templates & custom fields | 3 | Extra join fields (text/dropdown) from persistent form templates; answers on the participant record | +| Admin Home lifecycle grouping | 3 | Live / Upcoming / Ended real; `starts_at` scheduling; archive browsing | +| AI triage & auto-answer | 4 | Context-gathering → confidence-routed auto-answer/draft (see [agent.md](agent.md)) | +| AI escalation & transparency | 4 | "Get a human" one-tap; AI badge; expandable exact-context view in the queue | +| Cross-workshop learning corpus | 4 | FTS5 similarity over all past resolutions — replaces canned replies, improves with use | +| AI spend & toggle & air-gap | 4 | Per-workshop cost from OpenRouter usage; per-workshop enable; keyless = zero AI chrome errors | +| Docker Compose packaging | 5 | One-container deploy, volume-backed SQLite, multi-stage build | +| CI/CD to GCP | 5 | GitHub Actions: tests on PR; image build + VM deploy on main; runbook | diff --git a/spec/data-model.md b/spec/data-model.md index 386af59..1398290 100644 --- a/spec/data-model.md +++ b/spec/data-model.md @@ -1,151 +1,268 @@ -# Data Model — Workshop Helmsman (v0.1) +# Data Model — Workshop Helmsman (v0.2) -## Overview -All state in a single SQLite file (`data/helmsman.db`) or PostgreSQL via `DATABASE_URL`. -SQLAlchemy 2.x declarative models; `Base.metadata.create_all` runs at startup (idempotent). -No migration framework in v0.1 — additive columns only. +SQLAlchemy 2.x declarative models in `src/helmsman/db/models.py`; schema managed by Alembic (the `0001_initial` migration creates the **full v0.2 schema below**, even though later phases activate some tables). All state lives here — never only in process memory (resilience rule). All `DateTime` columns are timezone-aware UTC. JSON payloads are stored as `Text` (portable across SQLite/PostgreSQL). -## Tables +**Phase activation:** the "Phase" column says when the app first *writes/uses* the field — the schema itself exists from Phase 1 so later phases never migrate-and-backfill mid-season. -### workshop -```sql -CREATE TABLE workshop ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(200) NOT NULL, - created_at DATETIME NOT NULL, - expires_at DATETIME NOT NULL, - admin_token VARCHAR(64) NOT NULL UNIQUE, - participant_slug VARCHAR(64) NOT NULL UNIQUE, - milestone_config TEXT NOT NULL, -- JSON list of {id,title,description} - archived BOOLEAN NOT NULL DEFAULT 0, - form_template_id INTEGER REFERENCES form_template(id) ON DELETE SET NULL, - form_schema_json TEXT NOT NULL DEFAULT '[]', -- JSON snapshot of form fields - help_tips_json TEXT DEFAULT '', -- facilitator FAQ tips for LLM - broadcast_message TEXT DEFAULT '', -- current broadcast announcement - workshop_paused BOOLEAN NOT NULL DEFAULT 0, - milestone_order_json TEXT DEFAULT '[]' -- explicit milestone id order (drag-drop) -); -CREATE INDEX ix_workshop_admin_token ON workshop(admin_token); -CREATE INDEX ix_workshop_participant_slug ON workshop(participant_slug); -``` +Conventions: integer autoincrement PKs; `created_at` default now; `updated_at` onupdate now where noted; FK columns always indexed. -### form_template -```sql -CREATE TABLE form_template ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(120) NOT NULL, - created_at DATETIME NOT NULL, - fields_json TEXT NOT NULL DEFAULT '[]' -- JSON list of field dicts -); -``` +--- -### agenda_template -```sql -CREATE TABLE agenda_template ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(120) NOT NULL, - created_at DATETIME NOT NULL, - milestones_json TEXT NOT NULL DEFAULT '[]' -- JSON list of {title,description,help_tip} -); -``` +## workshop -### participant -```sql -CREATE TABLE participant ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workshop_id INTEGER NOT NULL REFERENCES workshop(id) ON DELETE CASCADE, - name VARCHAR(120) NOT NULL, - joined_at DATETIME NOT NULL, - answers_json TEXT -- JSON {field_key: value} -); -CREATE INDEX ix_participant_workshop_id ON participant(workshop_id); -``` +The unit of a live session. One row per run. -### milestone_completion -```sql -CREATE TABLE milestone_completion ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER NOT NULL REFERENCES participant(id) ON DELETE CASCADE, - milestone_id VARCHAR(64) NOT NULL, - milestone_title VARCHAR(200) NOT NULL, - completed_at DATETIME NOT NULL -); -CREATE INDEX ix_milestone_completion_participant_id ON milestone_completion(participant_id); -``` +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| name | String(120) | not null | 1 | | +| description_md | Text | not null, default `''` | 1 | Shown on join page; markdown | +| admin_token | String(64) | not null, **unique, indexed** | 1 | `token_urlsafe(32)` | +| join_slug | String(16) | not null, **unique, indexed** | 1 | `token_urlsafe(6)` | +| status | String(16) | not null, default `'live'` | 1 (`live`), 3 (rest) | `upcoming` \| `live` \| `grace` \| `archived` | +| starts_at | DateTime | nullable | 3 | Set → `upcoming` until reached (lazy transition) | +| ended_at | DateTime | nullable | 3 | Facilitator pressed "End workshop" | +| grace_until | DateTime | nullable | 3 | End of straggler window; passed → `archived` (lazy) | +| grace_hours | Integer | not null, default `24` | 3 | Chosen at end time | +| paused | Boolean | not null, default false | 2 | Blocks milestone completions | +| state_version | Integer | not null, default `0` | 1 | Bumped (same transaction) on ANY change a poller can see | +| content_version | Integer | not null, default `0` | 1 | Bumped only on milestone content/order changes | +| ai_enabled | Boolean | not null, default `false` | 4 | Per-workshop AI toggle | +| join_form_json | Text | not null, default `'[]'` | 3 | Snapshot of extra join fields (see JSON shapes) | +| stuck_minutes | Integer | not null, default `10` | 2 | Stuck-alert threshold (per workshop) | +| cloned_from_id | Integer | FK workshop.id, nullable | 3 | Provenance of a clone | +| agenda_template_id | Integer | FK agenda_template.id (SET NULL), nullable | 3 | Which template instantiated it | +| created_at | DateTime | not null | 1 | | +| updated_at | DateTime | not null, onupdate | 1 | | + +Indexes: unique(admin_token), unique(join_slug), ix(status). + +**Status lifecycle (Phase 3):** `upcoming` → (starts_at reached) → `live` → (End workshop) → `grace` → (grace_until passed or Archive-now) → `archived`. Transitions are applied lazily inside any workshop-scoped request; `archived` is terminal and read-only (every mutation returns `workshop_archived`). Phase 1–2 workshops are created directly `live` and stay `live`. + +--- + +## milestone + +A real table (not JSON): per-milestone stats over 300 participants need indexed GROUP BYs, and the AI phase needs per-milestone content lookup. + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 1 | | +| position | Integer | not null | 1 | 0-based display order; reordering rewrites positions | +| title | String(200) | not null | 1 | | +| content_md | Text | not null, default `''` | 1 | Rich markdown: instructions, links, code snippets. ≤20,000 chars | +| minutes | Integer | nullable | 1 | Facilitator's time estimate; feeds pulse/projection (Phase 2) | +| created_at | DateTime | not null | 1 | | +| updated_at | DateTime | not null, onupdate | 1 | | + +Indexes: ix(workshop_id, position). + +**Semantics:** checklist, any order — participants may complete milestones in any sequence (no gating). A participant's **current milestone** = lowest-position incomplete milestone (used for help context, bottleneck detection). **Progress** = completed ÷ total milestones. + +--- + +## participant + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 1 | | +| name | String(80) | not null | 1 | Trimmed; duplicates allowed (disambiguated by join time in UI) | +| token | String(32) | not null, **unique, indexed** | 1 | `token_urlsafe(16)` — personal link + cookie value | +| answers_json | Text | not null, default `'{}'` | 3 | Custom join-form answers `{field_key: value}` | +| joined_at | DateTime | not null | 1 | | +| last_seen_at | DateTime | not null | 1 (write), 2 (read) | Touched by polls, throttled to once/60s; feeds presence + stuck alerts | + +Indexes: unique(token), ix(workshop_id). + +--- + +## milestone_completion + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| participant_id | Integer | FK participant.id CASCADE, not null | 1 | | +| milestone_id | Integer | FK milestone.id CASCADE, not null | 1 | | +| source | String(16) | not null, default `'participant'` | 1 (`participant`), 2 (`facilitator`) | Who marked it (advance-all/selected writes `facilitator`) | +| completed_at | DateTime | not null | 1 | | + +Indexes: **unique(participant_id, milestone_id)** (idempotent completes), ix(milestone_id) (per-milestone stats), ix(participant_id). + +Un-marking deletes the row (participants may fix a mis-click; history of the fix lives in the log stream, not the table). + +--- + +## help_request + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 1 | Denormalized for queue + cross-workshop learning queries | +| participant_id | Integer | FK participant.id CASCADE, not null | 1 | | +| milestone_id | Integer | FK milestone.id (SET NULL), nullable | 1 | The participant's current milestone at submit time | +| message | Text | not null | 1 | 1–4000 chars; participant-authored → rendered as plain text | +| status | String(16) | not null, default `'open'` | 1 | `open` \| `answered` \| `resolved` | +| escalated | Boolean | not null, default false | 4 | Participant tapped "get a human" after an AI answer → back to `open`, flagged | +| ai_state | String(16) | nullable | 4 | `pending` \| `answered` \| `draft` \| `failed` \| `skipped` — pipeline observability | +| created_at | DateTime | not null | 1 | | +| updated_at | DateTime | not null, onupdate | 1 | | + +Indexes: ix(workshop_id, status, created_at) (queue), ix(participant_id). + +**Status semantics:** `open` = awaiting an answer (facilitator queue). `answered` = an answer is visible to the participant (AI auto-answer or facilitator reply) — stays that way until the **participant** marks `resolved` (or the facilitator closes it). `escalated=true` returns status to `open` with the flag set; prior AI answers remain visible. + +--- + +## help_answer + +One request can accumulate answers (AI draft → AI answer → facilitator follow-up), so answers are rows, not columns. + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| help_request_id | Integer | FK help_request.id CASCADE, not null | 1 | | +| source | String(16) | not null | 1 (`facilitator`), 4 (`ai`) | `facilitator` \| `ai` | +| answer_md | Text | not null | 1 | Markdown; rendered with code blocks | +| draft | Boolean | not null, default false | 4 | true = AI draft awaiting facilitator review — never shown to the participant | +| ai_confidence | Numeric(4,3) | nullable | 4 | Model-reported, post-guardrail (0–1) | +| ai_model | String(120) | nullable | 4 | OpenRouter model id used | +| ai_context_json | Text | nullable | 4 | Exact context the AI used (see JSON shapes) — expandable in the queue | +| created_at | DateTime | not null | 1 | | + +Indexes: ix(help_request_id, created_at). + +--- + +## broadcast + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 2 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 2 | | +| message_md | Text | not null | 2 | Markdown; ≤4000 chars | +| created_at | DateTime | not null | 2 | | +| cleared_at | DateTime | nullable | 2 | null = the active pinned banner (at most one: sending a new one clears the previous) | + +Indexes: ix(workshop_id, cleared_at). + +--- + +## facilitator_action — audit trail + undo + AI answer log + +Every facilitator action and every AI-sent answer, forever. + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, nullable | 1 | null for instance-level actions (workshop.create logs on the new row) | +| actor | String(16) | not null | 1 | `facilitator` \| `ai` \| `system` | +| action | String(48) | not null | 1 | Code, e.g. `workshop.create`, `help.answer`, `help.resolve`, `broadcast.send`, `workshop.pause`, `workshop.resume`, `milestone.advance_all`, `milestone.advance_selected`, `milestone.reorder`, `milestone.edit`, `workshop.end`, `workshop.archive`, `workshop.clone`, `ai.auto_answer`, `ai.draft`, `ai.toggle`, `undo.apply` | +| detail_json | Text | not null, default `'{}'` | 1 | Human-renderable specifics (who/what: participant ids, milestone ids, message excerpt ≤200 chars) | +| undo_data_json | Text | nullable | 2 | Inverse-operation data for undoable actions (see below) | +| undone_at | DateTime | nullable | 2 | Set when undone; undoable within 30 s of created_at | +| created_at | DateTime | not null, **indexed** | 1 | | + +Indexes: ix(workshop_id, created_at). + +**Undo (Phase 2):** undoable actions = `broadcast.send` (undo_data: previous active broadcast id or null), `workshop.pause`/`workshop.resume` (previous paused state), `milestone.advance_all`/`milestone.advance_selected` (list of created milestone_completion ids → deleted on undo). Undo applies the inverse in one transaction, sets `undone_at`, logs `undo.apply`, bumps `state_version`. Window: 30 seconds, enforced server-side. + +Phase 1 writes audit rows for `workshop.create`, `help.answer`, `help.resolve` (the actions that exist); the audit-trail UI arrives in Phase 2. + +--- + +## agenda_template + agenda_template_milestone (Phase 3) + +Templates persist forever, independent of workshops. Instantiation **snapshots** milestones into the workshop — later template edits never mutate past or running sessions. + +**agenda_template:** id (PK) · name String(120) not null · description_md Text default `''` · created_at · updated_at. + +**agenda_template_milestone:** id (PK) · template_id (FK agenda_template.id CASCADE, not null) · position Integer not null · title String(200) not null · content_md Text not null default `''` · minutes Integer nullable. Index: ix(template_id, position). + +"Save current workshop as template" copies the workshop's milestones into a new template. + +--- + +## join_form_template (Phase 3) + +**join_form_template:** id (PK) · name String(120) not null · fields_json Text not null default `'[]'` · created_at · updated_at. + +Fields stay JSON (low query need; schema in JSON shapes below). Creating a workshop from a form template snapshots `fields_json` into `workshop.join_form_json`. Phase 1–2 join collects **name only** (`join_form_json = '[]'`). + +--- + +## ai_usage (Phase 4) + +One row per OpenRouter call. Per-workshop spend = `SUM(cost_usd)`. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| id | Integer | PK | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | | +| help_request_id | Integer | FK help_request.id (SET NULL), nullable | | +| purpose | String(16) | not null | `triage_answer` (single combined call; see agent.md) | +| model | String(120) | not null | | +| prompt_tokens | Integer | not null, default 0 | | +| completion_tokens | Integer | not null, default 0 | | +| cost_usd | Numeric(10,6) | not null, default 0 | From OpenRouter `usage.cost` (`usage: {include: true}`); 0 if unavailable, tokens still recorded | +| latency_ms | Integer | not null, default 0 | | +| created_at | DateTime | not null | | + +Indexes: ix(workshop_id, created_at). + +--- + +## help_corpus (Phase 4 — SQLite FTS5 virtual table) + +The cross-workshop learning corpus for similarity retrieval (see agent.md). **Not** a SQLAlchemy model — created by an Alembic migration guarded by `dialect == "sqlite"`; maintained by application code (no triggers), rebuildable at any time from `help_request` + `help_answer` (it is derived data, so the resilience rule holds). -### help_request ```sql -CREATE TABLE help_request ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER NOT NULL REFERENCES participant(id) ON DELETE CASCADE, - message TEXT NOT NULL, - created_at DATETIME NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'open' -- open | on_hold | resolved +CREATE VIRTUAL TABLE help_corpus USING fts5( + message, -- participant's help text + resolution, -- the final human-visible answer (facilitator-sent, or AI answer the participant resolved) + milestone_title, -- indexed: milestone titles carry strong topical signal + help_request_id UNINDEXED, + workshop_id UNINDEXED ); -CREATE INDEX ix_help_request_participant_id ON help_request(participant_id); -CREATE INDEX ix_help_request_created_at ON help_request(created_at); ``` -## JSON field shapes +A row is inserted when a request reaches `resolved`, or `answered` by a facilitator (facilitator answers are trusted resolutions). PostgreSQL deployments: the equivalent is a `tsvector` GIN index on the same derived table — documented in agent.md, built only if/when a PostgreSQL deployment needs Phase 4. -### Milestone (in `workshop.milestone_config`) -```json -[ - {"id": "m0", "title": "Setup", "description": "Environment ready"}, - {"id": "m1", "title": "API Key", "description": "LLM key configured"} -] -``` -`milestone_order_json` stores an array of milestone IDs in display order: `["m0","m1","m2","m3"]` +--- + +## JSON shapes (stored in Text columns) -### Form field (in `form_template.fields_json` and `workshop.form_schema_json`) +**workshop.join_form_json / join_form_template.fields_json** — array of: ```json -{ - "key": "display_name", - "type": "text", - "label": "Display name", - "placeholder": "e.g. Priya, anu from Delhi", - "required": true -} +{"key": "role", "type": "text|dropdown", "label": "Your role", "required": false, "options": ["Student", "Engineer"]} ``` -Dropdown variant: +(`options` only for `dropdown`; `key` is `^[a-z][a-z0-9_]{0,39}$`.) + +**participant.answers_json** — `{"role": "Engineer"}` (keys from the workshop's join form). + +**help_answer.ai_context_json** — exactly what the AI saw (see agent.md §Context): ```json { - "key": "role", - "type": "dropdown", - "label": "Your role", - "required": false, - "options": ["Student", "Engineer", "Manager", "Other"] + "progress": {"completed_count": 3, "total": 8, "completed_titles": ["…"]}, + "milestone": {"id": 12, "title": "Configure the API key", "content_excerpt": "first 2000 chars…"}, + "similar": [{"help_request_id": 41, "workshop_id": 2, "message_excerpt": "…", "resolution_excerpt": "…", "rank": 1}] } ``` -### Help tips (in `workshop.help_tips_json`) -Plain text, one tip per line. Optional `topic: tip` format: -``` -Setup: Make sure Docker is running before starting -API Key: Use the .env file, don't paste keys in chat -``` +**facilitator_action.detail_json** — action-specific, human-renderable, e.g. `{"help_request_id": 7, "participant_name": "Priya", "excerpt": "Answered: check your .env…"}`. -### Broadcast message (in `workshop.broadcast_message`) -Plain text shown as a pinned banner on participant tracker. Empty = no active broadcast. +**facilitator_action.undo_data_json** — inverse-op data, e.g. `{"completion_ids": [911, 912, 913]}`. -## Indexing strategy -- All FK columns indexed (`workshop_id`, `participant_id`) -- Unique constraints on `admin_token`, `participant_slug` -- `help_request.created_at` for pagination ordering -- Composite index not needed at this scale (100-200 participants/workshop) +--- -## Upgrade notes (v0.1 additive columns) -New columns added to existing `workshop` table: -- `broadcast_message` TEXT DEFAULT '' -- `workshop_paused` BOOLEAN DEFAULT 0 -- `milestone_order_json` TEXT DEFAULT '[]' -- `help_tips_json` TEXT DEFAULT '' +## Version-bump rules (the contract every write follows) -Run on upgrade: -```sql -ALTER TABLE workshop ADD COLUMN broadcast_message TEXT DEFAULT ''; -ALTER TABLE workshop ADD COLUMN workshop_paused BOOLEAN DEFAULT 0; -ALTER TABLE workshop ADD COLUMN milestone_order_json TEXT DEFAULT '[]'; -ALTER TABLE workshop ADD COLUMN help_tips_json TEXT DEFAULT ''; -``` -SQLAlchemy `create_all` handles this idempotently on next boot. \ No newline at end of file +Bump `workshop.state_version` (same transaction) on: participant join, completion create/delete, help request create/answer/resolve/escalate, broadcast send/clear, pause/resume, advance, reorder, milestone edit, AI answer/draft, undo, status transition, ai toggle. Additionally bump `content_version` on: milestone create/edit/delete/reorder (and workshop name/description edit). Nothing else writes these counters. + +## Indexing strategy for 300-participant polling + +- Poll hot path: single point-read of `workshop` by unique token (admin_token/join_slug) or `participant` by unique token → covered by the unique indexes. +- Snapshot build (per version change, coalesced): `milestone_completion` GROUP BY milestone_id filtered via ix(milestone_id) join on workshop's milestones; leaderboard from ix(workshop_id) participants + their completion counts; help queue via ix(workshop_id, status, created_at). +- "Me" section per poll: unique(token) point-read + ix(participant_id) on completions + ix(participant_id) on help_requests — 2–3 sub-ms indexed queries. +- No composite index tuning beyond the above is warranted at ≤1000 rows/table/session scale; the unique constraints double as the lookup indexes. diff --git a/spec/roadmap.md b/spec/roadmap.md index 2af3298..df7b4eb 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -1,78 +1,126 @@ -# Roadmap — Workshop Helmsman (v0.1) - -## Phase 1 — Core Path (current improvement cycle) - -**Goal:** Facilitator creates workshop from template → shares participant link → participants join and fill form → facilitator watches live dashboard → participants complete milestones → facilitator exports CSV. - -**Surfaces built in Phase 1:** - -| # | Surface | Route | Notes | -|---|---------|-------|-------| -| 1 | Create workshop | `GET/POST /admin/new` | Name, agenda template picker, form template picker, TTL | -| 2 | Admin dashboard | `GET /admin/` | **Polished**: stat cards, participant table with progress bars + milestone chips, per-milestone stats, cohort stacked bar, help flags with status pills, broadcast composer, milestone controls | -| 3 | Participant join | `GET/POST /w/` | Instant validation, mobile-optimized, accessible | -| 4 | Participant tracker | `GET /w//me` | Milestone checklist, progress bar, leaderboard, broadcast banner, my help flags | -| 5 | Participant poll data | `GET /w//data` | JSON: milestones, my progress, leaderboard, help requests, broadcast message, workshop_paused, form schema | -| 6 | Admin poll data | `GET /admin//data` | JSON: all participants, stats, cohort bar, help requests, broadcast, workshop_paused, milestone order | -| 7 | Mark milestone complete | `POST /w//me/complete/` | Idempotent, blocked if workshop_paused | -| 8 | Help flag (2-step) | `POST /w//me/help` | Preview (with LLM suggestion stub) → commit | -| 9 | Broadcast message | `POST /admin//broadcast` | Set/clear pinned announcement | -| 10 | Pause workshop | `POST /admin//pause` | Toggle workshop_paused (locks completions) | -| 11 | Advance all | `POST /admin//advance-all` | Move all participants to next incomplete milestone | -| 12 | Advance selected | `POST /admin//advance-selected` | Move selected participant IDs to next milestone | -| 13 | Reorder milestones | `POST /admin//milestones/reorder` | Persist drag-drop order | -| 14 | Export CSV | `GET /admin//export.csv` | Streamed: participants × milestones + form answers + help | -| 15 | Health | `GET /healthz` | `{"status":"ok","db":"ok"}` | - -**Stubs (Phase 2+):** -- `GET /admin/templates` — template library CRUD -- `GET /workshops` — archive with search/filter -- `POST /admin//clone` — clone workshop -- `POST /admin//archive` — soft delete +# Roadmap — Workshop Helmsman (v0.2) + +Ground-up rebuild. The old `src/` and `frontend/` are **removed at scaffold**; nothing is preserved by inertia. Canonical run path (every phase): `(cd frontend && pnpm build)` → `uv run python -m src` → open **http://localhost:8001/app/**. `pnpm dev` is inner-loop only, never the test path. + +**Shared scaffold (laid down by the orchestrator BEFORE any generator runs, so slices stay disjoint):** `pyproject.toml` (full Phase-1 dependency list from architecture.md §Stack), `alembic.ini`, `.env.example` (all vars from architecture.md §Environment variables), `README.md` skeleton, `.gitignore` (`data/`, `.env`, `frontend/out/`, `node_modules/`), `data/.gitkeep`, removal of the v0.1 app code. Generators never edit another slice's files; root scaffold files are edited only by the orchestrator between fan-outs. + +## Phases of Development + +--- + +## Phase 1 — Core Live Loop *(scope fixed; smallest user-testable win, first-time-right)* + +**Goal:** A facilitator creates a workshop, shares the join link; 300 participants join with just a name, work content-rich milestones; the dashboard is genuinely live; one help request is submitted, answered, and seen — beautiful and rock-solid, with every future surface present as a clearly-labelled stub. + +**Capabilities delivered:** C1 Workshop creation & facilitator access · C2 Frictionless participant identity (cookie auto-resume, personal links, lost-link recovery) · C3 Content-rich milestone tracking + live dashboard · C4 Help desk (manual). Plus cross-cutting: versioned coalesced polling, structured JSON logging to stdout, resilience (restart-safe, reconnect-safe), labelled stubs for broadcast/pause/AI/templates/intelligence/audit/spend. + +**Independent slices (all three fan out in parallel — disjoint paths, no build dependencies):** + +| Slice | Owns (exclusive paths) | Notes | +|---|---|---| +| **S1 backend** | `src/**`, `alembic/**`, `tests/conftest.py`, `tests/unit/**`, `tests/integration/**` | Full Phase-1 API per api.md; models are the **full v0.2 schema** (data-model.md) in migration `0001_initial`; snapshot cache + version counters; structlog middleware; static mount + pretty redirects; audit rows for `workshop.create`/`help.answer`/`help.resolve` | +| **S2 frontend** | `frontend/**` | All four pages + design system + StubBadge surfaces per capabilities.md; typed client + polling hook per api.md (contract-first — no backend needed to build) | +| **S3 e2e** | `tests/e2e/**`, `playwright.config.ts`, `package.json` (root), `pnpm-lock.yaml` (root) | Playwright specs written from api.md + capabilities.md. No build dependency; **runtime dependency on S1+S2 at gate time only** (specs run against the live server) | + +**Key surfaces/files:** see architecture.md §Repo layout — S1: `api/{admin,facilitator,participant,health}.py`, `services/snapshots.py`, `db/models.py`, `security.py`, `observability/logging.py`; S2: `app/{page,f/page,join/page,p/page}.tsx`, `components/{ui,facilitator,participant}/`, `lib/{api,poll}.ts`; S3: `tests/e2e/core-loop.spec.ts`. + +**Gate (exact commands, from repo root; once beforehand: `cp .env.example .env` and set `HELMSMAN_ADMIN_KEY`):** + +```bash +uv sync +(cd frontend && pnpm install) && pnpm install && pnpm exec playwright install chromium +uv run alembic upgrade head +uv run alembic current # MUST print a revision hash — blank output = gate failed +uv run pytest tests/unit tests/integration -q +(cd frontend && pnpm build) +grep -q '\.flex' frontend/out/_next/static/css/*.css # styled-render: real utilities present +! grep -q '@source' frontend/out/_next/static/css/*.css # …and no unexpanded Tailwind directives +uv run python -m src & # live server on :8001 (leave running) +pnpm exec playwright test tests/e2e --reporter=line +kill %1 +``` + +The e2e smoke covers the full loop against the live single-origin app: create workshop (3 milestones incl. a code block) → join as two participants in separate contexts → tracker renders styled markdown → mark complete → dashboard reflects it within one poll → help request → answer from queue → answer visible on tracker → participant resolves → **browser reload restores state (cookie)** and **the personal link opens identical state in a fresh context (cross-device)** → labelled stubs are visible and non-interactive. Integration tests additionally cover: idempotent complete/uncomplete, version-counter bumps, unchanged-poll short-circuit, server-restart state survival (new process, same DB file), and every documented error code. + +**How the user tests it:** run the three commands above (sync/build/serve), open `http://localhost:8001/app/` → enter your `HELMSMAN_ADMIN_KEY` → **New workshop** → add 3 milestones with markdown + a code snippet → create → copy the join link → open it in a private window → join as "Priya" → read a milestone, mark it complete → watch the dashboard bars move within ~2 s → submit a help request from the tracker → answer it from the dashboard queue (use markdown + code) → see the badged answer appear on the tracker → mark resolved → close and reopen the private window (auto-resume) and paste the personal link into another browser (same state). **Labelled stubs (not bugs):** Broadcast, Pause, End workshop, AI toggle, Spend, stuck/bottleneck/pulse cards, Audit tab, Template library, Upcoming/Ended groups — all carry a "Coming in a later phase" pill. + +--- + +## Phase 2 — Facilitator Command & Proactive Intelligence + +**Goal:** The facilitator can steer the room — broadcast, pause, advance, reorder/edit, undo a fat-fingered action — and the dashboard tells them proactively who is stuck, where the pile-up is, and how the session is pacing. + +**Capabilities delivered:** Broadcast announcements · Milestone controls (advance all/selected, pause/resume, reorder, edit/add/delete) · Undo (30 s window) · Audit trail UI · Proactive intelligence (stuck alerts, bottleneck, session pulse). + +**Independent slices (parallel; no intra-phase dependencies):** + +| Slice | Owns | +|---|---| +| **S1 backend** | `src/**` (facilitator endpoints, `services/{undo,intelligence}.py`, snapshot additions), `tests/unit/**`, `tests/integration/**` additions | +| **S2 frontend** | `frontend/**` — broadcast composer w/ preview + undo toast, pause states, advance controls w/ confirmation, reorder UI, audit tab, alerts/pulse cards replacing their stubs; participant banner + paused lock | +| **S3 e2e** | `tests/e2e/**` additions (command flows + undo + banner) | + +**Gate (repo root; same setup):** `uv run alembic upgrade head && uv run alembic current` · `uv run pytest tests/unit tests/integration -q` · `(cd frontend && pnpm build)` · server up → `pnpm exec playwright test tests/e2e --reporter=line`. Integration tests must cover: undo inside/after the window (`undo_expired`), advance-all creating only missing completions (`source: "facilitator"`), pause blocking complete *and* uncomplete, audit rows for every action, stuck/bottleneck/pulse computed against a seeded 40-participant fixture whose expected alert set is pre-computed (not a trivially-empty fixture). + +**How the user tests it:** with a live workshop + a few joined participants: send a markdown broadcast → banner appears on trackers within a poll → **Undo** within 30 s → banner gone. Pause → participant checkboxes lock with a banner. Advance-all on milestone 1 → bars jump → Undo → they revert. Reorder milestones → tracker order updates. Leave one participant idle past the stuck threshold → stuck alert card names them; the pulse card shows pace + projected finish. Open the Audit tab → every action listed who/what/when. --- -## Phase 2 — Template Library & Multi-Session +## Phase 3 — Lifecycle, Templates & Re-run + +**Goal:** Workshops become sessions with a life: created from persistent templates (agenda + join form), ended into a grace period, archived read-only forever, and clonable for the next run. + +**Capabilities delivered:** End/grace/archive lifecycle (lazy transitions, read-only archive views) · Clone/re-run · Agenda template library (+ save-as-template) · Join-form templates & custom join fields · Admin Home lifecycle grouping (Live/Upcoming/Ended, `starts_at`). + +**Independent slices (parallel):** + +| Slice | Owns | +|---|---| +| **S1 backend** | `src/**` (`services/{lifecycle,templates}.py`, admin/facilitator/participant endpoint additions), `tests/**` (py) additions | +| **S2 frontend** | `frontend/**` — template library page + pickers (replacing stubs), end-workshop dialog w/ grace hours, archive read-only styling, clone, join-form custom fields, Admin Home grouping | +| **S3 e2e** | `tests/e2e/**` additions | + +**Gate (repo root):** same command sequence as Phase 2. Integration tests must cover: lazy transition on access (set `grace_until` in the past → next request archives), archived rejects every mutation with `workshop_archived` while reads still serve, template edit never mutating an instantiated workshop (snapshot proof), clone = fresh tokens + zero participants, join-form validation against the snapshot. -- `/admin/templates` — list, create, edit, delete agenda templates & form templates -- Template pickers on `/admin/new` and `/admin//edit` -- Template edits never mutate existing workshops (snapshot-on-create) -- `/workshops` archive page: list all workshops, search by name, filter by status (live/expired/archived), per-session participant count -- Cohort comparison: select multiple sessions of same template → side-by-side completion rates -- Per-participant drill-down: `/admin//participant/` — timeline of completions + help requests + form answers +**How the user tests it:** save the running workshop as a template → create a new workshop from it (milestones pre-filled) → add a custom dropdown join field via a form template → join page shows it → **End workshop** with a 1-hour grace → tracker shows the grace banner, completions still work → **Archive now** → tracker and dashboard flip to read-only archive views; Admin Home shows it under Ended → **Clone** → a fresh Live workshop with the same agenda and new links. --- -## Phase 3 — AI Assist & Notifications +## Phase 4 — AI Help-Desk -- Optional LLM (Gemini via OpenRouter) for help-desk pre-resolution -- Facilitator clicks "Suggest reply" on help flag → LLM returns one short actionable paragraph → facilitator edits/sends -- Context injected: workshop milestones, facilitator help tips (`help_tips_json`), participant's message -- Email/Slack webhook on new help flags (configurable per workshop) -- Mobile-first participant tracker polish (touch targets, gesture-friendly) -- Accessibility audit (WCAG AA) +**Goal:** Help requests are triaged by AI that knows the participant's context and every past resolution: confident → instant badged auto-answer with one-tap human escalation; unsure → draft for facilitator review — fully audited, costed per workshop, per-workshop toggleable, and a zero-error no-op without an API key. + +**Capabilities delivered:** AI triage & auto-answer pipeline · Escalation & transparency (AI badge, "Get a human", exact-context disclosure) · Cross-workshop learning corpus (FTS5) · Spend tracking + per-workshop toggle + air-gapped guarantee. Design is fixed in [agent.md](agent.md). + +**Independent slices (parallel; S1↔S2 share only the `run_help_desk(help_request_id)` interface fixed in agent.md — no serialization needed):** + +| Slice | Owns | +|---|---| +| **S1 ai-pipeline** | `src/helmsman/ai/**` (pipeline, context, corpus, openrouter client, prompts), `tests/integration/ai/**`, corpus migration (`alembic/versions/` addition) | +| **S2 backend-api** | `src/helmsman/api/**` (toggle/spend/escalate endpoints, BackgroundTask hookup in participant help POST), `src/helmsman/services/snapshots.py` (spend + AI fields), `tests/unit/**` + non-AI integration additions | +| **S3 frontend** | `frontend/**` — AI badge + escalate on tracker; draft-review block, context disclosure, spend card, AI toggle (replacing stubs) | +| **S4 e2e** | `tests/e2e/**` additions (AI auto-answer flow, escalation, air-gapped run) | + +**Gate (repo root; requires the real `OPENROUTER_API_KEY` in `.env` — the gate is BLOCKED, not skipped, without it):** `uv run alembic upgrade head && uv run alembic current` · `uv run pytest tests/unit tests/integration -q` (now includes `tests/integration/ai` against the **real OpenRouter API**; the similarity fixture seeds 25+ resolved requests across ≥3 workshops with exactly one strong match asserted at rank 1 — per agent.md §Testing) · `(cd frontend && pnpm build)` · server up → `pnpm exec playwright test tests/e2e --reporter=line` (includes the real-key auto-answer journey **and** the keyless air-gapped journey with zero errors and zero AI chrome). + +**How the user tests it:** with `OPENROUTER_API_KEY` set: toggle AI on → as a participant, ask something answered by the milestone content → an **AI-badged** answer appears in seconds → tap **Get a human** → request returns to the queue flagged, AI answer still visible → in the queue, expand **Context the AI used** → see progress/milestone/similar-resolutions exactly → ask something ambiguous → a violet **draft** waits in the queue; edit and send it → spend card shows real dollars. Then remove the key, restart: everything works, AI surfaces show a labelled off state, zero errors anywhere. --- -## Phase 4 — Scale & Multi-tenancy +## Phase 5 — Ship It: Docker + CI/CD to GCP + +**Goal:** One-command production deploy and an automated path from merge to the VM. + +**Capabilities delivered:** Docker Compose packaging (multi-stage image, volume-backed SQLite, migrations on boot) · CI on GitHub Actions (tests on PR; real-key jobs guarded to runners with secrets) · CD to the GCP VM (image build → registry → compose deploy) + `deploy/RUNBOOK.md` (backup/restore of `data/`, TLS proxy note). + +**Independent slices (parallel):** **S1 deploy** — `deploy/**` (Dockerfile, docker-compose.yml, RUNBOOK.md) + README deploy section · **S2 ci-cd** — `.github/workflows/**` (ci.yml, deploy.yml). + +**Gate (repo root):** `docker compose -f deploy/docker-compose.yml up -d --build` · `curl -fsS http://localhost:8001/api/health` · `pnpm exec playwright test tests/e2e --reporter=line` against the container · `docker compose -f deploy/docker-compose.yml down` (volume persists; re-`up` retains data — verified by the e2e re-run finding the same workshop) · CI workflow green on the PR. -- PostgreSQL backend (swap via `DATABASE_URL`) -- Multiple concurrent workshops (dashboard switcher) -- Per-workshop facilitator tokens (rotate, revoke, multiple per workshop) -- Custom domains / subdomains per workshop -- Team/organization grouping (multi-tenant) -- Webhooks for external integrations (completion webhook, help-flag webhook) -- Structured logging + metrics endpoint (`/metrics` for Prometheus) -- Load test: 200+ participants, 4s poll, <200ms p99 +**How the user tests it:** `cp .env.example .env`, set the key(s), `docker compose -f deploy/docker-compose.yml up -d` → open `http://localhost:8001/app/` → run the Phase-1 manual loop → `docker compose restart` mid-session → participants reconnect with nothing lost. Merge a PR → watch Actions deploy it to the VM. --- -## Cross-cutting (every phase) +## Phase-gate constants (every phase) -- Platform-agnostic URLs (no hardcoded host/port) -- Mobile-responsive CSS (grid/flex + viewport meta) -- Health probes: `/healthz` (liveness), `/healthz/ready` (readiness) -- No secrets required to boot (`.env.example` empty for core flow) -- Single CSS file, single JS file, no bundler, no `node_modules` -- Audit trail: every milestone completion, help request, facilitator action timestamped -- SQLite `data/helmsman.db` excluded from git \ No newline at end of file +Working tree clean and pushed · README updated and its commands re-run verbatim · human test-handoff published and approved before the next phase · qa-auditor sign-off per slice · no phase starts while the previous one is red. diff --git a/spec/vision.md b/spec/vision.md index f62ecf7..3ed068d 100644 --- a/spec/vision.md +++ b/spec/vision.md @@ -1,35 +1,42 @@ -# Vision — Workshop Helmsman (v0.1) +# Vision — Workshop Helmsman (v0.2) + +> This spec supersedes v0.1 entirely, including all v0.1 non-goals (notably "no React / no build step" — v0.2 is a Next.js frontend by explicit user choice). The v0.1 code in `src/` and `frontend/` is removed at scaffold; v0.2 is built fresh from this spec. ## What it is -Workshop Helmsman is a self-hosted workshop tracker for **remote, facilitator-led sessions with 100+ participants**. It gives a **facilitator** a live dashboard of where every participant is in the curriculum and surfaces "I need help" requests in real time, while each **participant** gets a personal tracker page (progress bar, "mark milestone complete" buttons, a help form, a leaderboard of peers, and facilitator announcements). +Workshop Helmsman is a **self-hosted workshop assistant for facilitator-led, hands-on technical labs**. A facilitator creates a workshop, shares one join link, and 100–300+ participants work through a checklist of **content-rich milestones** (full markdown instructions, links, code snippets) on their own devices. The facilitator watches a **genuinely live dashboard** — per-milestone completion, cohort distribution, a help queue — and intervenes with broadcasts, milestone controls, and answers. An **AI help-desk** (OpenRouter) triages help requests: it gathers the participant's context and past resolutions, auto-answers when confident (clearly badged, one tap to escalate to a human), and drafts a reply for facilitator review when unsure. Without an API key, the entire product works air-gapped with zero errors. -It is opinionated, no-LLM-required, air-gap-friendly, and reusable: the same codebase runs any number of workshops back-to-back via unique tokens. +It is production-grade from day one: real workshops with external participants depend on it, so polish, resilience, and first-time-right behavior are the bar — not aspirations. ## Who it serves -- **Facilitators** running live online workshops who want to see at a glance which participants are stuck, in addition to seeing who is keeping pace. They need to intervene — broadcast announcements, advance milestones, pause the room, send targeted hints. -- **Participants** who need a stable URL they can pin on their phone, a clear view of "what's left", a low-friction way to ask for help without disrupting the room, and a sense of progress alongside peers. +- **Facilitators** (a small team, co-facilitating the same live workshop) who need to know *right now* which milestone the room is piled up on, who is stuck, and which help requests are open — and to act on it: broadcast, advance, pause, answer, undo a fat-fingered action. No accounts: unguessable token links. +- **Participants** (100–300+ per session, including external attendees) who join with near-zero friction (name at the door), get a personal link that survives device switches and browser restarts, see rich instructions per milestone, mark progress, watch a leaderboard, and get help fast — from the AI instantly when it's confident, from a human otherwise. + +## Session lifecycle + +Workshops are created fresh per session — from scratch or from a persistent **template library** (agenda templates + join-form templates). A live workshop ends into a **grace period** for stragglers, then becomes a **read-only archive**, browsable forever. Workshops are clonable/re-runnable. Full session archives persist indefinitely. -## Non-goals +## Quality bar -- No LLM in the core loop. No external API calls required. Everything stays on the single VM. -- No multi-tenant SaaS, no auth provider integration beyond the workshop tokens. -- No React/Vue/webpack. Server-rendered Jinja2 + vanilla JS, polling-based sync. -- No breakout rooms, no participant-to-participant chat (Phase 2+). +- **First-time-right.** Every phase's tested path works the first time the user tests it. Zero rough edges on the tested path; stubs for future features are clearly labelled and never read as bugs. +- **Resilient by construction.** All state lives in the database, never only in memory. A participant who reconnects gets their exact state back. A server restart mid-session loses nothing — every client recovers on its next poll with no user action. +- **Live at scale on modest hardware.** 300+ concurrent participants on a 2 vCPU / 2–4 GB VM, near-zero running cost (see [architecture.md](architecture.md) for the coalesced-polling design that achieves this). +- **Visually excellent.** A modern, polished UI with a consistent design system — this is the product's face to external participants. Every view designs its empty, loading, error, and populated states. +- **Honest AI.** AI answers are always badged as AI, always escapable to a human, always audited with the exact context the AI used, and always costed (per-workshop spend visible). -## Core interaction model +## Explicit non-goals (v0.2) -- **Session shape:** Facilitator creates a *new workshop per session* from a reusable template. Participants join that session's unique link. Workshop has TTL (default 8h). -- **Memory/state:** Workshop templates (agenda + form schema) persist forever. Each session gets its own snapshot. Participant progress, form answers, and help requests persist *within a session*. Templates carry across sessions; session data does not. -- **Multi-item:** Facilitator manages a library of agenda templates and form templates. Can pick one of each when creating a new session. Can also edit the session's snapshot without affecting the template. -- **Error handling:** Participant join validates name instantly. Help requests go through 2-step preview (optional LLM suggestion) then commit. Facilitator actions (advance, broadcast, pause) are immediate with optimistic UI — next poll (≤4s) confirms. +- **No participant accounts, no facilitator accounts, no auth-provider integration.** Access is via unguessable token links plus a single instance-level facilitator access key. +- **No separate concurrent-workshop isolation model.** Multiple facilitators co-run the *same* workshop; running several workshops at once is possible but not a designed-for isolation boundary (one team, one instance). +- **No lecture mode or multi-day cohort mode** — future versions, not v0.2. +- **No external integrations** (no Slack/email/webhooks/calendar). Standalone. +- **No participant-to-participant chat, no breakout rooms.** +- **No WebSockets/SSE** — live updates are versioned coalesced polling (a deliberate architecture decision, see architecture.md). +- **No dark mode in v0.2** — one polished light theme; design tokens are structured so dark mode can be added later without rework. +- **No health/ops chrome in the UI** — a plain `/api/health` endpoint exists for machines; the UI stays clean. +- **No vector database** — help-request similarity uses SQLite FTS5 keyword search (see [agent.md](agent.md)). -## Technical stack +## The one-line test -- **Backend:** Python 3.11, FastAPI, SQLAlchemy 2.x, SQLite (dev) / PostgreSQL (prod) -- **Frontend:** Jinja2 templates, vanilla JS (single `app.js`), one CSS file (`style.css`). Mobile-responsive participant view. -- **Auth:** Token-based (admin_token in URL, participant_slug in URL). HttpOnly cookie for participant session. -- **Real-time:** Short polling (4s) — no WebSockets. Single `/data` endpoint serves both participant tracker and admin dashboard (different shapes via query param). -- **Deploy:** Docker Compose + systemd unit. Runs on single VM. -- **LLM:** Optional — Google Gemini via OpenRouter (`OPENROUTER_API_KEY` in `.env`). Phase 3 help-desk pre-resolution only. Graceful fallback if unavailable. \ No newline at end of file +A facilitator with a fresh VM can go from `docker compose up` (or `uv run python -m src`) to a live workshop with 300 external participants tracking milestones and getting help — without reading anything beyond the join link they share. diff --git a/src/__init__.py b/src/__init__.py index fa8cdc1..e69de29 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,8 +0,0 @@ -"""Workshop Helmsman — FastAPI app entrypoint. - -Run with: venv/bin/python -m src -""" - -from .main import app # noqa: F401 (re-export) - -__all__ = ["app"] diff --git a/src/__main__.py b/src/__main__.py index 8eed95a..eb45f96 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,19 +1,19 @@ -"""Module entrypoint — boots uvicorn against 0.0.0.0:8001.""" - -import os +"""Boot the Workshop Helmsman server: `uv run python -m src` (from the repo root).""" import uvicorn +from src.helmsman.api import create_app +from src.helmsman.config.settings import get_settings + def main() -> None: - host = os.environ.get("BIND_HOST", "0.0.0.0") - port = int(os.environ.get("BIND_PORT", "8001")) + settings = get_settings() uvicorn.run( - "src.main:app", - host=host, - port=port, - log_level=os.environ.get("LOG_LEVEL", "info"), - reload=False, + create_app(), + host="0.0.0.0", + port=settings.port, + log_level=settings.resolved_log_level.lower(), + access_log=False, ) diff --git a/src/db.py b/src/db.py deleted file mode 100644 index 8ad2e33..0000000 --- a/src/db.py +++ /dev/null @@ -1,67 +0,0 @@ -"""SQLAlchemy engine + session bootstrap for Workshop Helmsman.""" - -from __future__ import annotations - -import os -from contextlib import contextmanager -from pathlib import Path - -from sqlalchemy import create_engine -from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker - - -def _default_url() -> str: - # data/ lives next to the repo root when running `python -m src` - here = Path(__file__).resolve().parent.parent - data_dir = here / "data" - data_dir.mkdir(parents=True, exist_ok=True) - db_path = data_dir / "helmsman.db" - return f"sqlite:///{db_path}" - - -DATABASE_URL = os.environ.get("DATABASE_URL", _default_url()) - - -class Base(DeclarativeBase): - pass - - -engine = create_engine( - DATABASE_URL, - echo=False, - future=True, - connect_args={"check_same_thread": False}, -) - -SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) - - -def init_db() -> None: - """Create all tables. Idempotent — safe to call on every boot.""" - # Imported lazily to avoid circular import (models import Base from here). - from . import models # noqa: F401 - - Base.metadata.create_all(bind=engine) - - -@contextmanager -def session_scope() -> Session: - """Context-managed session that commits on success, rolls back on error.""" - session = SessionLocal() - try: - yield session - session.commit() - except Exception: - session.rollback() - raise - finally: - session.close() - - -def get_db() -> Session: - """FastAPI dependency — yields a session, closes it after the request.""" - session = SessionLocal() - try: - yield session - finally: - session.close() diff --git a/src/helmsman/__init__.py b/src/helmsman/__init__.py new file mode 100644 index 0000000..d3ec452 --- /dev/null +++ b/src/helmsman/__init__.py @@ -0,0 +1 @@ +__version__ = "0.2.0" diff --git a/src/helmsman/api/__init__.py b/src/helmsman/api/__init__.py new file mode 100644 index 0000000..79ce1b2 --- /dev/null +++ b/src/helmsman/api/__init__.py @@ -0,0 +1,107 @@ +"""create_app(): routers, error envelope, pretty-link redirects, guarded static mount.""" + +from pathlib import Path +from urllib.parse import quote + +import structlog +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles + +from src.helmsman import __version__ +from src.helmsman.config.settings import get_settings +from src.helmsman.observability.logging import RequestLoggingMiddleware, configure_logging + +_REPO_ROOT = Path(__file__).resolve().parents[3] +FRONTEND_OUT_DIR = _REPO_ROOT / "frontend" / "out" + + +def _first_validation_message(exc: RequestValidationError) -> str: + errors = exc.errors() + if not errors: + return "Invalid input." + first = errors[0] + location = ".".join( + str(part) for part in first.get("loc", ()) if part not in ("body", "query", "path") + ) + message = first.get("msg", "Invalid input.") + message = message.removeprefix("Value error, ") + return f"{location}: {message}" if location else message + + +def create_app() -> FastAPI: + settings = get_settings() + if not settings.resolved_admin_key: + raise RuntimeError( + "HELMSMAN_ADMIN_KEY is not set. Add it to .env (see .env.example) — " + "the facilitator access key is required to start." + ) + configure_logging(settings.resolved_log_level) + log = structlog.get_logger("helmsman") + + app = FastAPI(title="Workshop Helmsman", version=__version__, docs_url=None, redoc_url=None) + app.add_middleware(RequestLoggingMiddleware) + + @app.exception_handler(RequestValidationError) + async def _validation_error_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=422, + content={ + "detail": {"code": "validation_error", "message": _first_validation_message(exc)} + }, + ) + + @app.exception_handler(Exception) + async def _internal_error_handler(request: Request, exc: Exception): + structlog.get_logger("helmsman").error( + "internal_error", path=request.url.path, error=repr(exc) + ) + return JSONResponse( + status_code=500, + content={ + "detail": { + "code": "internal_error", + "message": "Unexpected server error — check the server logs.", + } + }, + ) + + from src.helmsman.api import admin, facilitator, health, participant + + app.include_router(health.router) + app.include_router(admin.router) + app.include_router(facilitator.router) + app.include_router(participant.router) + + @app.get("/", include_in_schema=False) + def _root_redirect(): + return RedirectResponse(url="/app/", status_code=307) + + @app.get("/j/{join_slug}", include_in_schema=False) + def _join_redirect(join_slug: str): + return RedirectResponse(url=f"/app/join/?s={quote(join_slug, safe='')}", status_code=307) + + @app.get("/p/{participant_token}", include_in_schema=False) + def _participant_redirect(participant_token: str): + return RedirectResponse( + url=f"/app/p/?t={quote(participant_token, safe='')}", status_code=307 + ) + + @app.get("/f/{admin_token}", include_in_schema=False) + def _facilitator_redirect(admin_token: str): + return RedirectResponse(url=f"/app/f/?t={quote(admin_token, safe='')}", status_code=307) + + frontend_mounted = FRONTEND_OUT_DIR.is_dir() + if frontend_mounted: + app.mount("/app", StaticFiles(directory=FRONTEND_OUT_DIR, html=True), name="app") + + log.info( + "app.startup", + version=__version__, + admin_key_present=bool(settings.resolved_admin_key), + openrouter_key_present=bool(settings.openrouter_api_key), + db_dialect=settings.database_url.split(":", 1)[0], + frontend_mounted=frontend_mounted, + ) + return app diff --git a/src/helmsman/api/_common.py b/src/helmsman/api/_common.py new file mode 100644 index 0000000..7b74c46 --- /dev/null +++ b/src/helmsman/api/_common.py @@ -0,0 +1,39 @@ +"""Response envelope + serialization helpers shared by every router.""" + +from datetime import datetime, timezone +from typing import Any + +from fastapi import HTTPException, Request + + +def ok(data: Any) -> dict: + return {"data": data, "error": None} + + +def api_error(code: str, message: str, status_code: int) -> HTTPException: + return HTTPException(status_code=status_code, detail={"code": code, "message": message}) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def as_utc(dt: datetime) -> datetime: + """SQLite round-trips tz-aware datetimes as naive UTC — normalize either way.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def iso_z(dt: datetime) -> str: + """ISO 8601 UTC with Z, seconds precision — '2026-07-20T14:03:22Z'.""" + return as_utc(dt).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def request_base_url(request: Request) -> str: + from src.helmsman.config.settings import get_settings + + configured = get_settings().resolved_base_url + if configured: + return configured + return str(request.base_url).rstrip("/") diff --git a/src/helmsman/api/admin.py b/src/helmsman/api/admin.py new file mode 100644 index 0000000..cd506f5 --- /dev/null +++ b/src/helmsman/api/admin.py @@ -0,0 +1,197 @@ +"""Admin surface — header `X-Admin-Key` (see spec/api.md §Admin surface).""" + +import structlog +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from src.helmsman.api._common import iso_z, ok, request_base_url +from src.helmsman.db.models import HelpRequest, Milestone, Participant, Workshop +from src.helmsman.db.session import get_session +from src.helmsman.security import ( + generate_admin_token, + generate_join_slug, + require_admin_key, +) +from src.helmsman.services.audit import record_action + +log = structlog.get_logger("helmsman") + +router = APIRouter(prefix="/api/admin", dependencies=[Depends(require_admin_key)]) + +NAME_MAX = 120 +DESCRIPTION_MAX = 10_000 +MILESTONES_MAX = 50 +MILESTONE_TITLE_MAX = 200 +MILESTONE_CONTENT_MAX = 20_000 +MILESTONE_MINUTES_MIN = 1 +MILESTONE_MINUTES_MAX = 480 + + +class MilestoneIn(BaseModel): + title: str + content_md: str = "" + minutes: int | None = None + + @field_validator("title") + @classmethod + def _trim_title(cls, value: str) -> str: + trimmed = value.strip() + if not (1 <= len(trimmed) <= MILESTONE_TITLE_MAX): + raise ValueError(f"milestone title must be 1–{MILESTONE_TITLE_MAX} characters") + return trimmed + + @field_validator("content_md") + @classmethod + def _limit_content(cls, value: str) -> str: + if len(value) > MILESTONE_CONTENT_MAX: + raise ValueError(f"milestone content must be at most {MILESTONE_CONTENT_MAX} characters") + return value + + @field_validator("minutes") + @classmethod + def _check_minutes(cls, value: int | None) -> int | None: + if value is not None and not (MILESTONE_MINUTES_MIN <= value <= MILESTONE_MINUTES_MAX): + raise ValueError( + f"minutes must be between {MILESTONE_MINUTES_MIN} and {MILESTONE_MINUTES_MAX}" + ) + return value + + +class WorkshopCreate(BaseModel): + name: str + description_md: str = "" + milestones: list[MilestoneIn] = Field(min_length=1, max_length=MILESTONES_MAX) + + @field_validator("name") + @classmethod + def _trim_name(cls, value: str) -> str: + trimmed = value.strip() + if not (1 <= len(trimmed) <= NAME_MAX): + raise ValueError(f"name must be 1–{NAME_MAX} characters") + return trimmed + + @field_validator("description_md") + @classmethod + def _limit_description(cls, value: str) -> str: + if len(value) > DESCRIPTION_MAX: + raise ValueError(f"description must be at most {DESCRIPTION_MAX} characters") + return value + + +def _unique_admin_token(session: Session) -> str: + while True: + token = generate_admin_token() + if session.scalar(select(Workshop.id).where(Workshop.admin_token == token)) is None: + return token + + +def _unique_join_slug(session: Session) -> str: + while True: + slug = generate_join_slug() + if session.scalar(select(Workshop.id).where(Workshop.join_slug == slug)) is None: + return slug + + +def _workshop_urls(base: str, workshop: Workshop) -> dict: + return { + "join_url": f"{base}/j/{workshop.join_slug}", + "facilitator_url": f"{base}/f/{workshop.admin_token}", + } + + +@router.get("/workshops") +def list_workshops(request: Request, session: Session = Depends(get_session)) -> dict: + base = request_base_url(request) + workshops = list( + session.scalars(select(Workshop).order_by(Workshop.created_at.desc(), Workshop.id.desc())) + ) + participant_counts = dict( + session.execute( + select(Participant.workshop_id, func.count(Participant.id)).group_by( + Participant.workshop_id + ) + ).all() + ) + open_help_counts = dict( + session.execute( + select(HelpRequest.workshop_id, func.count(HelpRequest.id)) + .where(HelpRequest.status == "open") + .group_by(HelpRequest.workshop_id) + ).all() + ) + rows = [ + { + "id": w.id, + "name": w.name, + "status": w.status, + "participant_count": participant_counts.get(w.id, 0), + "open_help_count": open_help_counts.get(w.id, 0), + "created_at": iso_z(w.created_at), + "join_slug": w.join_slug, + **_workshop_urls(base, w), + } + for w in workshops + ] + return ok({"workshops": rows}) + + +@router.post("/workshops") +def create_workshop( + body: WorkshopCreate, request: Request, session: Session = Depends(get_session) +) -> dict: + base = request_base_url(request) + workshop = Workshop( + name=body.name, + description_md=body.description_md, + admin_token=_unique_admin_token(session), + join_slug=_unique_join_slug(session), + status="live", + ) + session.add(workshop) + session.flush() + + for position, milestone in enumerate(body.milestones): + session.add( + Milestone( + workshop_id=workshop.id, + position=position, + title=milestone.title, + content_md=milestone.content_md, + minutes=milestone.minutes, + ) + ) + + record_action( + session, + workshop.id, + "facilitator", + "workshop.create", + {"name": workshop.name, "milestone_count": len(body.milestones)}, + ) + log.info( + "workshop.created", + workshop_id=workshop.id, + name=workshop.name, + milestone_count=len(body.milestones), + ) + + payload = ok( + { + "workshop": { + "id": workshop.id, + "name": workshop.name, + "description_md": workshop.description_md, + "status": workshop.status, + "paused": workshop.paused, + "ai_enabled": workshop.ai_enabled, + "admin_token": workshop.admin_token, + "join_slug": workshop.join_slug, + **_workshop_urls(base, workshop), + "created_at": iso_z(workshop.created_at), + } + } + ) + session.commit() # visible before the response reaches the client + return payload diff --git a/src/helmsman/api/facilitator.py b/src/helmsman/api/facilitator.py new file mode 100644 index 0000000..ea9f021 --- /dev/null +++ b/src/helmsman/api/facilitator.py @@ -0,0 +1,770 @@ +"""Facilitator surface — /api/f/{admin_token}/… (see spec/api.md §Facilitator surface).""" + +import json + +import structlog +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, field_validator +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.helmsman.api._common import api_error, iso_z, ok, request_base_url, utcnow +from src.helmsman.db.models import ( + Broadcast, + FacilitatorAction, + HelpAnswer, + HelpRequest, + Milestone, + MilestoneCompletion, + Participant, + Workshop, +) +from src.helmsman.db.session import get_session +from src.helmsman.security import workshop_by_admin_token +from src.helmsman.services.audit import record_action +from src.helmsman.services.snapshots import ( + bump_content_version, + bump_state_version, + dashboard_snapshot, + serialize_broadcast, + serialize_help_request_facilitator, +) +from src.helmsman.services.undo import apply_undo + +log = structlog.get_logger("helmsman") + +router = APIRouter(prefix="/api/f/{admin_token}") + +ANSWER_MAX = 10_000 +AUDIT_EXCERPT_MAX = 200 +BROADCAST_MESSAGE_MAX = 4000 +MILESTONE_TITLE_MAX = 200 +MILESTONE_CONTENT_MAX = 20_000 +AUDIT_PAGE_LIMIT_MAX = 100 +AUDIT_PAGE_LIMIT_DEFAULT = 50 +STUCK_MINUTES_MIN = 2 +STUCK_MINUTES_MAX = 120 + + +class AnswerBody(BaseModel): + answer_md: str + + @field_validator("answer_md") + @classmethod + def _check_answer(cls, value: str) -> str: + if not (1 <= len(value) <= ANSWER_MAX): + raise ValueError(f"answer_md must be 1–{ANSWER_MAX} characters") + return value + + +class BroadcastBody(BaseModel): + message_md: str + + @field_validator("message_md") + @classmethod + def _check_message(cls, value: str) -> str: + if not (1 <= len(value) <= BROADCAST_MESSAGE_MAX): + raise ValueError(f"message_md must be 1–{BROADCAST_MESSAGE_MAX} characters") + return value + + +class BroadcastClearBody(BaseModel): + pass + + +class PauseBody(BaseModel): + paused: bool + + +class AdvanceBody(BaseModel): + milestone_id: int + participant_ids: list[int] | None = None + + +class ReorderBody(BaseModel): + milestone_ids: list[int] + + +class MilestoneCreateBody(BaseModel): + title: str + content_md: str = "" + minutes: int | None = None + + @field_validator("title") + @classmethod + def _check_title(cls, value: str) -> str: + if not (1 <= len(value) <= MILESTONE_TITLE_MAX): + raise ValueError(f"title must be 1–{MILESTONE_TITLE_MAX} characters") + return value + + @field_validator("content_md") + @classmethod + def _check_content(cls, value: str) -> str: + if len(value) > MILESTONE_CONTENT_MAX: + raise ValueError(f"content_md must be at most {MILESTONE_CONTENT_MAX} characters") + return value + + @field_validator("minutes") + @classmethod + def _check_minutes(cls, value: int | None) -> int | None: + if value is not None and not (1 <= value <= 480): + raise ValueError("minutes must be null or 1–480") + return value + + +class MilestonePatchBody(BaseModel): + title: str | None = None + content_md: str | None = None + minutes: int | None = None + + @field_validator("title") + @classmethod + def _check_title(cls, value: str | None) -> str | None: + if value is not None and not (1 <= len(value) <= MILESTONE_TITLE_MAX): + raise ValueError(f"title must be 1–{MILESTONE_TITLE_MAX} characters") + return value + + @field_validator("content_md") + @classmethod + def _check_content(cls, value: str | None) -> str | None: + if value is not None and len(value) > MILESTONE_CONTENT_MAX: + raise ValueError(f"content_md must be at most {MILESTONE_CONTENT_MAX} characters") + return value + + @field_validator("minutes") + @classmethod + def _check_minutes(cls, value: int | None) -> int | None: + if value is not None and not (1 <= value <= 480): + raise ValueError("minutes must be null or 1–480") + return value + + +class UndoBody(BaseModel): + pass + + +class SettingsBody(BaseModel): + stuck_minutes: int + + @field_validator("stuck_minutes") + @classmethod + def _check_stuck_minutes(cls, value: int) -> int: + if not (STUCK_MINUTES_MIN <= value <= STUCK_MINUTES_MAX): + raise ValueError( + f"stuck_minutes must be {STUCK_MINUTES_MIN}–{STUCK_MINUTES_MAX}" + ) + return value + + +def _help_request_in_workshop( + session: Session, workshop: Workshop, help_request_id: int +) -> HelpRequest: + help_request = session.get(HelpRequest, help_request_id) + if help_request is None or help_request.workshop_id != workshop.id: + raise api_error("not_found", "No such help request in this workshop.", 404) + return help_request + + +def _guard_not_archived(workshop: Workshop) -> None: + if workshop.status == "archived": + raise api_error("workshop_archived", "This workshop has been archived and is read-only.", 410) + + +def _milestone_in_workshop(session: Session, workshop: Workshop, milestone_id: int) -> Milestone: + milestone = session.get(Milestone, milestone_id) + if milestone is None or milestone.workshop_id != workshop.id: + raise api_error("not_found", "No such milestone in this workshop.", 404) + return milestone + + +@router.get("/workshop") +def get_workshop( + admin_token: str, request: Request, session: Session = Depends(get_session) +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + base = request_base_url(request) + milestones = list( + session.scalars( + select(Milestone) + .where(Milestone.workshop_id == workshop.id) + .order_by(Milestone.position, Milestone.id) + ) + ) + return ok( + { + "content_version": workshop.content_version, + "workshop": { + "id": workshop.id, + "name": workshop.name, + "description_md": workshop.description_md, + "status": workshop.status, + "paused": workshop.paused, + "ai_enabled": workshop.ai_enabled, + "join_slug": workshop.join_slug, + "join_url": f"{base}/j/{workshop.join_slug}", + "facilitator_url": f"{base}/f/{workshop.admin_token}", + "created_at": iso_z(workshop.created_at), + }, + "milestones": [ + { + "id": m.id, + "position": m.position, + "title": m.title, + "content_md": m.content_md, + "minutes": m.minutes, + } + for m in milestones + ], + } + ) + + +@router.get("/dashboard") +def poll_dashboard( + admin_token: str, + request: Request, + v: int = -1, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + if v == workshop.state_version: + return ok( + { + "changed": False, + "version": workshop.state_version, + "content_version": workshop.content_version, + } + ) + return ok(dashboard_snapshot(session, workshop, request_base_url(request))) + + +@router.post("/help/{help_request_id}/answer") +def answer_help_request( + admin_token: str, + help_request_id: int, + body: AnswerBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + help_request = _help_request_in_workshop(session, workshop, help_request_id) + + session.add( + HelpAnswer( + help_request_id=help_request.id, + source="facilitator", + answer_md=body.answer_md, + ) + ) + if help_request.status != "resolved": + help_request.status = "answered" + help_request.updated_at = utcnow() + version = bump_state_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "help.answer", + { + "help_request_id": help_request.id, + "participant_id": help_request.participant_id, + "excerpt": body.answer_md[:AUDIT_EXCERPT_MAX], + }, + ) + session.flush() + log.info( + "help.answered", + workshop_id=workshop.id, + help_request_id=help_request.id, + participant_id=help_request.participant_id, + ) + payload = ok( + { + "help_request": serialize_help_request_facilitator(session, help_request), + "version": version, + } + ) + session.commit() # visible before the response reaches the client + return payload + + +# --- Phase 2: facilitator command & proactive intelligence --- + + +@router.post("/broadcast") +def send_broadcast( + admin_token: str, + body: BroadcastBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + previous = session.scalar( + select(Broadcast).where( + Broadcast.workshop_id == workshop.id, Broadcast.cleared_at.is_(None) + ) + ) + previous_id = previous.id if previous is not None else None + if previous is not None: + previous.cleared_at = utcnow() + + broadcast = Broadcast(workshop_id=workshop.id, message_md=body.message_md) + session.add(broadcast) + session.flush() + + version = bump_state_version(workshop) + action = record_action( + session, + workshop.id, + "facilitator", + "broadcast.send", + {"broadcast_id": broadcast.id, "excerpt": body.message_md[:AUDIT_EXCERPT_MAX]}, + ) + action.undo_data_json = json.dumps({"previous_broadcast_id": previous_id}) + session.flush() + log.info("broadcast.sent", workshop_id=workshop.id, broadcast_id=broadcast.id) + payload = ok( + { + "broadcast": serialize_broadcast(broadcast), + "version": version, + "undoable_action_id": action.id, + } + ) + session.commit() + return payload + + +@router.post("/broadcast/clear") +def clear_broadcast( + admin_token: str, + body: BroadcastClearBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + active = session.scalar( + select(Broadcast).where( + Broadcast.workshop_id == workshop.id, Broadcast.cleared_at.is_(None) + ) + ) + if active is not None: + active.cleared_at = utcnow() + version = bump_state_version(workshop) + record_action( + session, workshop.id, "facilitator", "broadcast.clear", {"broadcast_id": active.id} + ) + session.flush() + else: + version = workshop.state_version + payload = ok({"version": version}) + session.commit() + return payload + + +@router.post("/pause") +def set_pause( + admin_token: str, + body: PauseBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + previous_paused = workshop.paused + workshop.paused = body.paused + version = bump_state_version(workshop) + action = record_action( + session, + workshop.id, + "facilitator", + "workshop.pause" if body.paused else "workshop.resume", + {"paused": body.paused}, + ) + action.undo_data_json = json.dumps({"previous_paused": previous_paused}) + session.flush() + log.info("workshop.paused" if body.paused else "workshop.resumed", workshop_id=workshop.id) + payload = ok( + { + "paused": workshop.paused, + "version": version, + "undoable_action_id": action.id, + } + ) + session.commit() + return payload + + +@router.post("/milestones/advance") +def advance_milestone( + admin_token: str, + body: AdvanceBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + milestone = _milestone_in_workshop(session, workshop, body.milestone_id) + + if body.participant_ids is None: + participant_ids = list( + session.scalars( + select(Participant.id).where(Participant.workshop_id == workshop.id) + ) + ) + else: + participant_ids = list(body.participant_ids) + rows = list( + session.scalars( + select(Participant.id).where( + Participant.workshop_id == workshop.id, + Participant.id.in_(participant_ids), + ) + ) + ) + if len(set(rows)) != len(set(participant_ids)): + raise api_error( + "not_found", "One or more participant ids are not in this workshop.", 404 + ) + + already_completed = set( + session.scalars( + select(MilestoneCompletion.participant_id).where( + MilestoneCompletion.milestone_id == milestone.id, + MilestoneCompletion.participant_id.in_(participant_ids), + ) + ) + ) + to_create = [pid for pid in participant_ids if pid not in already_completed] + + created_ids: list[int] = [] + for pid in to_create: + completion = MilestoneCompletion( + participant_id=pid, milestone_id=milestone.id, source="facilitator" + ) + session.add(completion) + session.flush() + created_ids.append(completion.id) + + version = bump_state_version(workshop) + action = record_action( + session, + workshop.id, + "facilitator", + "milestone.advance_all" if body.participant_ids is None else "milestone.advance_selected", + { + "milestone_id": milestone.id, + "participant_ids": body.participant_ids, + "affected_count": len(created_ids), + }, + ) + action.undo_data_json = json.dumps({"completion_ids": created_ids}) + session.flush() + log.info( + "milestone.advanced", + workshop_id=workshop.id, + milestone_id=milestone.id, + affected_count=len(created_ids), + ) + payload = ok( + { + "affected_count": len(created_ids), + "version": version, + "undoable_action_id": action.id, + } + ) + session.commit() + return payload + + +@router.post("/milestones/reorder") +def reorder_milestones( + admin_token: str, + body: ReorderBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + milestones = list( + session.scalars(select(Milestone).where(Milestone.workshop_id == workshop.id)) + ) + existing_ids = {m.id for m in milestones} + if set(body.milestone_ids) != existing_ids or len(body.milestone_ids) != len(existing_ids): + raise api_error( + "validation_error", + "milestone_ids must be an exact permutation of this workshop's milestones.", + 422, + ) + + by_id = {m.id: m for m in milestones} + for position, milestone_id in enumerate(body.milestone_ids): + by_id[milestone_id].position = position + + version = bump_state_version(workshop) + content_version = bump_content_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "milestone.reorder", + {"milestone_ids": body.milestone_ids}, + ) + session.flush() + payload = ok({"version": version, "content_version": content_version}) + session.commit() + return payload + + +@router.post("/milestones") +def create_milestone( + admin_token: str, + body: MilestoneCreateBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + max_position = session.scalar( + select(Milestone.position) + .where(Milestone.workshop_id == workshop.id) + .order_by(Milestone.position.desc()) + .limit(1) + ) + next_position = (max_position + 1) if max_position is not None else 0 + + milestone = Milestone( + workshop_id=workshop.id, + position=next_position, + title=body.title, + content_md=body.content_md, + minutes=body.minutes, + ) + session.add(milestone) + session.flush() + + content_version = bump_content_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "milestone.edit", + {"op": "add", "milestone_id": milestone.id, "title": milestone.title}, + ) + session.flush() + payload = ok( + { + "milestone": { + "id": milestone.id, + "position": milestone.position, + "title": milestone.title, + "content_md": milestone.content_md, + "minutes": milestone.minutes, + }, + "version": workshop.state_version, + "content_version": content_version, + } + ) + session.commit() + return payload + + +@router.patch("/milestones/{milestone_id}") +def patch_milestone( + admin_token: str, + milestone_id: int, + body: MilestonePatchBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + milestone = _milestone_in_workshop(session, workshop, milestone_id) + + fields_set = body.model_fields_set + if "title" in fields_set and body.title is not None: + milestone.title = body.title + if "content_md" in fields_set and body.content_md is not None: + milestone.content_md = body.content_md + if "minutes" in fields_set: + milestone.minutes = body.minutes + + content_version = bump_content_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "milestone.edit", + {"op": "edit", "milestone_id": milestone.id, "fields": sorted(fields_set)}, + ) + session.flush() + payload = ok( + { + "milestone": { + "id": milestone.id, + "position": milestone.position, + "title": milestone.title, + "content_md": milestone.content_md, + "minutes": milestone.minutes, + }, + "version": workshop.state_version, + "content_version": content_version, + } + ) + session.commit() + return payload + + +@router.delete("/milestones/{milestone_id}") +def delete_milestone( + admin_token: str, + milestone_id: int, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + milestone = _milestone_in_workshop(session, workshop, milestone_id) + + session.query(MilestoneCompletion).filter( + MilestoneCompletion.milestone_id == milestone.id + ).delete(synchronize_session=False) + session.delete(milestone) + + version = bump_state_version(workshop) + content_version = bump_content_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "milestone.edit", + {"op": "delete", "milestone_id": milestone_id}, + ) + session.flush() + payload = ok({"version": version, "content_version": content_version}) + session.commit() + return payload + + +@router.post("/undo/{action_id}") +def undo_action( + admin_token: str, + action_id: int, + body: UndoBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + version = apply_undo(session, workshop, action_id) + payload = ok({"version": version}) + session.commit() + return payload + + +@router.get("/audit") +def get_audit( + admin_token: str, + before_id: int | None = None, + limit: int = AUDIT_PAGE_LIMIT_DEFAULT, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + + if not (1 <= limit <= AUDIT_PAGE_LIMIT_MAX): + raise api_error( + "validation_error", f"limit must be 1–{AUDIT_PAGE_LIMIT_MAX}.", 422 + ) + + stmt = ( + select(FacilitatorAction) + .where(FacilitatorAction.workshop_id == workshop.id) + .order_by(FacilitatorAction.id.desc()) + ) + if before_id is not None: + stmt = stmt.where(FacilitatorAction.id < before_id) + rows = list(session.scalars(stmt.limit(limit + 1))) + has_more = len(rows) > limit + rows = rows[:limit] + + + + return ok( + { + "actions": [ + { + "id": row.id, + "actor": row.actor, + "action": row.action, + "detail": json.loads(row.detail_json) if row.detail_json else {}, + "created_at": iso_z(row.created_at), + "undone_at": iso_z(row.undone_at) if row.undone_at else None, + } + for row in rows + ], + "has_more": has_more, + } + ) + + +@router.patch("/settings") +def patch_settings( + admin_token: str, + body: SettingsBody, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + + workshop.stuck_minutes = body.stuck_minutes + version = bump_state_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "settings.update", + {"stuck_minutes": body.stuck_minutes}, + ) + session.flush() + payload = ok({"stuck_minutes": workshop.stuck_minutes, "version": version}) + session.commit() + return payload + + +@router.post("/help/{help_request_id}/resolve") +def resolve_help_request( + admin_token: str, + help_request_id: int, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_admin_token(session, admin_token) + _guard_not_archived(workshop) + help_request = _help_request_in_workshop(session, workshop, help_request_id) + + if help_request.status != "resolved": + help_request.status = "resolved" + help_request.updated_at = utcnow() + bump_state_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "help.resolve", + { + "help_request_id": help_request.id, + "participant_id": help_request.participant_id, + }, + ) + session.flush() + log.info( + "help.resolved", + workshop_id=workshop.id, + help_request_id=help_request.id, + resolved_by="facilitator", + ) + payload = ok( + { + "help_request": serialize_help_request_facilitator(session, help_request), + "version": workshop.state_version, + } + ) + session.commit() # visible before the response reaches the client + return payload diff --git a/src/helmsman/api/health.py b/src/helmsman/api/health.py new file mode 100644 index 0000000..eaee4d1 --- /dev/null +++ b/src/helmsman/api/health.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.orm import Session + +from src.helmsman.api._common import api_error, ok +from src.helmsman.db.session import get_session + +router = APIRouter(prefix="/api") + + +@router.get("/health") +def health(session: Session = Depends(get_session)) -> dict: + try: + session.execute(text("SELECT 1")) + except Exception as exc: + raise api_error("internal_error", f"Database check failed: {type(exc).__name__}.", 500) + return ok({"status": "ok", "db": "ok"}) diff --git a/src/helmsman/api/participant.py b/src/helmsman/api/participant.py new file mode 100644 index 0000000..f17f9eb --- /dev/null +++ b/src/helmsman/api/participant.py @@ -0,0 +1,477 @@ +"""Participant surface — /api/join/{slug} and /api/p/{token}/… (see spec/api.md).""" + +from datetime import timedelta + +import structlog +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel, field_validator +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from src.helmsman.api._common import api_error, iso_z, ok, request_base_url, utcnow, as_utc +from src.helmsman.db.models import ( + HelpRequest, + Milestone, + MilestoneCompletion, + Participant, + Workshop, +) +from src.helmsman.db.session import get_session +from src.helmsman.security import ( + generate_participant_token, + participant_by_token, + workshop_by_join_slug, +) +from src.helmsman.services.snapshots import ( + bump_state_version, + participant_shared_snapshot, + progress_pct, + serialize_help_request_participant, +) + +log = structlog.get_logger("helmsman") + +router = APIRouter(prefix="/api") + +PARTICIPANT_NAME_MAX = 80 +HELP_MESSAGE_MAX = 4000 +COOKIE_MAX_AGE_SECONDS = 2_592_000 # 30 days +LAST_SEEN_TOUCH_SECONDS = 60 + + +class JoinBody(BaseModel): + name: str + + @field_validator("name") + @classmethod + def _trim_name(cls, value: str) -> str: + trimmed = value.strip() + if not (1 <= len(trimmed) <= PARTICIPANT_NAME_MAX): + raise ValueError(f"name must be 1–{PARTICIPANT_NAME_MAX} characters") + return trimmed + + +class HelpBody(BaseModel): + message: str + + @field_validator("message") + @classmethod + def _check_message(cls, value: str) -> str: + if not (1 <= len(value) <= HELP_MESSAGE_MAX): + raise ValueError(f"message must be 1–{HELP_MESSAGE_MAX} characters") + return value + + +def _cookie_name(workshop_id: int) -> str: + return f"helmsman_p_{workshop_id}" + + +def _guard_not_archived(workshop: Workshop) -> None: + if workshop.status == "archived": + raise api_error("workshop_archived", "This workshop has been archived and is read-only.", 410) + + +def _guard_not_paused(workshop: Workshop) -> None: + if workshop.paused: + raise api_error( + "workshop_paused", "This workshop is paused — completions are frozen for now.", 409 + ) + + +def _milestone_in_workshop(session: Session, workshop: Workshop, milestone_id: int) -> Milestone: + milestone = session.get(Milestone, milestone_id) + if milestone is None or milestone.workshop_id != workshop.id: + raise api_error("not_found", "No such milestone in this workshop.", 404) + return milestone + + +def _my_completed_ids_by_position(session: Session, participant: Participant) -> list[int]: + rows = session.execute( + select(MilestoneCompletion.milestone_id) + .join(Milestone, MilestoneCompletion.milestone_id == Milestone.id) + .where(MilestoneCompletion.participant_id == participant.id) + .order_by(Milestone.position, Milestone.id) + ).all() + return [milestone_id for (milestone_id,) in rows] + + +def _completion_payload( + session: Session, workshop: Workshop, participant: Participant +) -> dict: + completed_ids = _my_completed_ids_by_position(session, participant) + total = session.scalar( + select(func.count(Milestone.id)).where(Milestone.workshop_id == workshop.id) + ) + return { + "completed_milestone_ids": completed_ids, + "completed_count": len(completed_ids), + "progress_pct": progress_pct(len(completed_ids), total or 0), + "version": workshop.state_version, + } + + +# --- join --- + + +@router.get("/join/{join_slug}") +def get_join_page( + join_slug: str, request: Request, session: Session = Depends(get_session) +) -> dict: + workshop = workshop_by_join_slug(session, join_slug) + milestone_count = session.scalar( + select(func.count(Milestone.id)).where(Milestone.workshop_id == workshop.id) + ) + participant_count = session.scalar( + select(func.count(Participant.id)).where(Participant.workshop_id == workshop.id) + ) + + me = None + cookie_token = request.cookies.get(_cookie_name(workshop.id)) + if cookie_token: + participant = session.scalar( + select(Participant).where(Participant.token == cookie_token) + ) + if participant is not None and participant.workshop_id == workshop.id: + me = {"participant_token": participant.token, "name": participant.name} + + return ok( + { + "workshop": { + "name": workshop.name, + "description_md": workshop.description_md, + "status": workshop.status, + "milestone_count": milestone_count or 0, + "participant_count": participant_count or 0, + }, + "me": me, + } + ) + + +@router.post("/join/{join_slug}") +def join_workshop( + join_slug: str, + body: JoinBody, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> dict: + workshop = workshop_by_join_slug(session, join_slug) + _guard_not_archived(workshop) + + participant = Participant( + workshop_id=workshop.id, + name=body.name, + token=generate_participant_token(), + ) + session.add(participant) + bump_state_version(workshop) + session.flush() + + response.set_cookie( + key=_cookie_name(workshop.id), + value=participant.token, + max_age=COOKIE_MAX_AGE_SECONDS, + path="/", + httponly=True, + samesite="lax", + ) + log.info( + "participant.joined", + workshop_id=workshop.id, + participant_id=participant.id, + name=participant.name, + ) + base = request_base_url(request) + payload = ok( + { + "participant_token": participant.token, + "participant_url": f"{base}/p/{participant.token}", + "name": participant.name, + } + ) + session.commit() # visible before the response reaches the client + return payload + + +# --- tracker polling --- + + +def _touch_last_seen(participant: Participant) -> None: + now = utcnow() + if (now - as_utc(participant.last_seen_at)) >= timedelta(seconds=LAST_SEEN_TOUCH_SECONDS): + participant.last_seen_at = now + + +@router.get("/p/{participant_token}/state") +def poll_state( + participant_token: str, + v: int = -1, + session: Session = Depends(get_session), +) -> dict: + participant, workshop = participant_by_token(session, participant_token) + _touch_last_seen(participant) + + if v == workshop.state_version: + return ok( + { + "changed": False, + "version": workshop.state_version, + "content_version": workshop.content_version, + } + ) + + shared = participant_shared_snapshot(session, workshop) + completed_ids = _my_completed_ids_by_position(session, participant) + total_count = shared["total_count"] + rank = shared["rank_by_participant_id"].get(participant.id) + + # Top-N only: at 300+ participants the full board would ship to every + # participant on every poll. The caller's own row is always included. + LEADERBOARD_TOP_N = 15 + visible_rows = shared["leaderboard"][:LEADERBOARD_TOP_N] + if participant.id not in {row["participant_id"] for row in visible_rows}: + mine = next( + (row for row in shared["leaderboard"] if row["participant_id"] == participant.id), + None, + ) + if mine is not None: + visible_rows = [*visible_rows, mine] + leaderboard = [ + { + "rank": row["rank"], + "name": row["name"], + "completed_count": row["completed_count"], + "progress_pct": row["progress_pct"], + "is_me": row["participant_id"] == participant.id, + } + for row in visible_rows + ] + + my_requests = list( + session.scalars( + select(HelpRequest) + .where(HelpRequest.participant_id == participant.id) + .order_by(HelpRequest.created_at.desc(), HelpRequest.id.desc()) + ) + ) + + return ok( + { + "changed": True, + "version": workshop.state_version, + "content_version": workshop.content_version, + "workshop": shared["workshop"], + "milestones": shared["milestones"], + "me": { + "id": participant.id, + "name": participant.name, + "completed_milestone_ids": completed_ids, + "completed_count": len(completed_ids), + "total_count": total_count, + "progress_pct": progress_pct(len(completed_ids), total_count), + "rank": rank, + }, + "leaderboard": leaderboard, + "participants_count": len(shared["leaderboard"]), + "broadcast": shared["broadcast"], + "help_requests": [ + serialize_help_request_participant(session, hr) for hr in my_requests + ], + } + ) + + +@router.get("/p/{participant_token}/content") +def get_content( + participant_token: str, + cv: int = -1, + session: Session = Depends(get_session), +) -> dict: + _participant, workshop = participant_by_token(session, participant_token) + + if cv == workshop.content_version: + return ok({"changed": False, "content_version": workshop.content_version}) + + milestones = list( + session.scalars( + select(Milestone) + .where(Milestone.workshop_id == workshop.id) + .order_by(Milestone.position, Milestone.id) + ) + ) + return ok( + { + "changed": True, + "content_version": workshop.content_version, + "workshop": {"name": workshop.name, "description_md": workshop.description_md}, + "milestones": [ + { + "id": m.id, + "position": m.position, + "title": m.title, + "content_md": m.content_md, + "minutes": m.minutes, + } + for m in milestones + ], + } + ) + + +# --- completions --- + + +@router.post("/p/{participant_token}/milestones/{milestone_id}/complete") +def complete_milestone( + participant_token: str, + milestone_id: int, + session: Session = Depends(get_session), +) -> dict: + participant, workshop = participant_by_token(session, participant_token) + _guard_not_archived(workshop) + _guard_not_paused(workshop) + milestone = _milestone_in_workshop(session, workshop, milestone_id) + + existing = session.scalar( + select(MilestoneCompletion).where( + MilestoneCompletion.participant_id == participant.id, + MilestoneCompletion.milestone_id == milestone.id, + ) + ) + if existing is None: + session.add( + MilestoneCompletion( + participant_id=participant.id, + milestone_id=milestone.id, + source="participant", + ) + ) + bump_state_version(workshop) + session.flush() + log.info( + "milestone.completed", + workshop_id=workshop.id, + participant_id=participant.id, + milestone_id=milestone.id, + ) + payload = ok(_completion_payload(session, workshop, participant)) + session.commit() # visible before the response reaches the client + return payload + + +@router.post("/p/{participant_token}/milestones/{milestone_id}/uncomplete") +def uncomplete_milestone( + participant_token: str, + milestone_id: int, + session: Session = Depends(get_session), +) -> dict: + participant, workshop = participant_by_token(session, participant_token) + _guard_not_archived(workshop) + _guard_not_paused(workshop) + milestone = _milestone_in_workshop(session, workshop, milestone_id) + + existing = session.scalar( + select(MilestoneCompletion).where( + MilestoneCompletion.participant_id == participant.id, + MilestoneCompletion.milestone_id == milestone.id, + ) + ) + if existing is not None: + session.delete(existing) + bump_state_version(workshop) + session.flush() + log.info( + "milestone.uncompleted", + workshop_id=workshop.id, + participant_id=participant.id, + milestone_id=milestone.id, + ) + payload = ok(_completion_payload(session, workshop, participant)) + session.commit() # visible before the response reaches the client + return payload + + +# --- help desk --- + + +@router.post("/p/{participant_token}/help") +def create_help_request( + participant_token: str, + body: HelpBody, + session: Session = Depends(get_session), +) -> dict: + participant, workshop = participant_by_token(session, participant_token) + _guard_not_archived(workshop) + + completed_ids = set(_my_completed_ids_by_position(session, participant)) + milestones = list( + session.scalars( + select(Milestone) + .where(Milestone.workshop_id == workshop.id) + .order_by(Milestone.position, Milestone.id) + ) + ) + current_milestone_id = next( + (m.id for m in milestones if m.id not in completed_ids), None + ) + + help_request = HelpRequest( + workshop_id=workshop.id, + participant_id=participant.id, + milestone_id=current_milestone_id, + message=body.message, + status="open", + ) + session.add(help_request) + version = bump_state_version(workshop) + session.flush() + log.info( + "help.created", + workshop_id=workshop.id, + participant_id=participant.id, + help_request_id=help_request.id, + milestone_id=current_milestone_id, + ) + payload = ok( + { + "help_request": serialize_help_request_participant(session, help_request), + "version": version, + } + ) + session.commit() # visible before the response reaches the client + return payload + + +@router.post("/p/{participant_token}/help/{help_request_id}/resolve") +def resolve_own_help_request( + participant_token: str, + help_request_id: int, + session: Session = Depends(get_session), +) -> dict: + participant, workshop = participant_by_token(session, participant_token) + _guard_not_archived(workshop) + + help_request = session.get(HelpRequest, help_request_id) + if help_request is None or help_request.participant_id != participant.id: + raise api_error("not_found", "No such help request of yours.", 404) + + if help_request.status != "resolved": + help_request.status = "resolved" + help_request.updated_at = utcnow() + bump_state_version(workshop) + session.flush() + log.info( + "help.resolved", + workshop_id=workshop.id, + help_request_id=help_request.id, + resolved_by="participant", + ) + payload = ok( + { + "help_request": serialize_help_request_participant(session, help_request), + "version": workshop.state_version, + } + ) + session.commit() # visible before the response reaches the client + return payload diff --git a/src/helmsman/config/__init__.py b/src/helmsman/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/helmsman/config/settings.py b/src/helmsman/config/settings.py new file mode 100644 index 0000000..cccdaab --- /dev/null +++ b/src/helmsman/config/settings.py @@ -0,0 +1,53 @@ +"""Application settings — exact env var names per spec/architecture.md §Environment variables.""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +DEFAULT_DATABASE_URL = "sqlite:///data/helmsman.db" +DEFAULT_PORT = 8001 +_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} + + +def _strip_inline_comment(value: str) -> str: + return value.split("#", 1)[0].strip() + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + database_url: str = Field(default=DEFAULT_DATABASE_URL) + port: int = Field(default=DEFAULT_PORT) + helmsman_admin_key: str = Field(default="") + helmsman_base_url: str = Field(default="") + helmsman_log_level: str = Field(default="INFO") + openrouter_api_key: str = Field(default="") + helmsman_ai_model: str = Field(default="anthropic/claude-sonnet-4-6") + helmsman_ai_confidence: float = Field(default=0.75) + + @property + def resolved_admin_key(self) -> str: + return self.helmsman_admin_key.strip() + + @property + def resolved_base_url(self) -> str: + return _strip_inline_comment(self.helmsman_base_url).rstrip("/") + + @property + def resolved_log_level(self) -> str: + level = _strip_inline_comment(self.helmsman_log_level).upper() + return level if level in _LOG_LEVELS else "INFO" + + +_settings: Settings | None = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings diff --git a/src/helmsman/db/__init__.py b/src/helmsman/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/helmsman/db/models.py b/src/helmsman/db/models.py new file mode 100644 index 0000000..9345609 --- /dev/null +++ b/src/helmsman/db/models.py @@ -0,0 +1,256 @@ +"""All SQLAlchemy models — the FULL v0.2 schema per spec/data-model.md. + +Later-phase tables/columns exist from Phase 1 so no mid-season migrate-and-backfill +is ever needed. Portable types only (Integer/String/Text/Boolean/DateTime/Numeric; +JSON stored as Text) — PostgreSQL-ready via DATABASE_URL. +""" + +from datetime import datetime, timezone + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + MetaData, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +class Workshop(Base): + __tablename__ = "workshop" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + description_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + admin_token: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + join_slug: Mapped[str] = mapped_column(String(16), nullable=False, unique=True, index=True) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="live", index=True) + starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + grace_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + grace_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=24) + paused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + state_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + content_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + ai_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + join_form_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + stuck_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=10) + cloned_from_id: Mapped[int | None] = mapped_column( + ForeignKey("workshop.id"), nullable=True, index=True + ) + agenda_template_id: Mapped[int | None] = mapped_column( + ForeignKey("agenda_template.id", ondelete="SET NULL"), nullable=True, index=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + +class Milestone(Base): + __tablename__ = "milestone" + __table_args__ = (Index("ix_milestone_workshop_id_position", "workshop_id", "position"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workshop_id: Mapped[int] = mapped_column( + ForeignKey("workshop.id", ondelete="CASCADE"), nullable=False + ) + position: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + +class Participant(Base): + __tablename__ = "participant" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workshop_id: Mapped[int] = mapped_column( + ForeignKey("workshop.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(80), nullable=False) + token: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, index=True) + answers_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class MilestoneCompletion(Base): + __tablename__ = "milestone_completion" + __table_args__ = ( + UniqueConstraint("participant_id", "milestone_id", name="uq_completion_participant_milestone"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + participant_id: Mapped[int] = mapped_column( + ForeignKey("participant.id", ondelete="CASCADE"), nullable=False, index=True + ) + milestone_id: Mapped[int] = mapped_column( + ForeignKey("milestone.id", ondelete="CASCADE"), nullable=False, index=True + ) + source: Mapped[str] = mapped_column(String(16), nullable=False, default="participant") + completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class HelpRequest(Base): + __tablename__ = "help_request" + __table_args__ = ( + Index("ix_help_request_workshop_status_created", "workshop_id", "status", "created_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workshop_id: Mapped[int] = mapped_column( + ForeignKey("workshop.id", ondelete="CASCADE"), nullable=False + ) + participant_id: Mapped[int] = mapped_column( + ForeignKey("participant.id", ondelete="CASCADE"), nullable=False, index=True + ) + milestone_id: Mapped[int | None] = mapped_column( + ForeignKey("milestone.id", ondelete="SET NULL"), nullable=True, index=True + ) + message: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") + escalated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + ai_state: Mapped[str | None] = mapped_column(String(16), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + +class HelpAnswer(Base): + __tablename__ = "help_answer" + __table_args__ = ( + Index("ix_help_answer_request_created", "help_request_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + help_request_id: Mapped[int] = mapped_column( + ForeignKey("help_request.id", ondelete="CASCADE"), nullable=False + ) + source: Mapped[str] = mapped_column(String(16), nullable=False) + answer_md: Mapped[str] = mapped_column(Text, nullable=False) + draft: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + ai_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3), nullable=True) + ai_model: Mapped[str | None] = mapped_column(String(120), nullable=True) + ai_context_json: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class Broadcast(Base): + __tablename__ = "broadcast" + __table_args__ = (Index("ix_broadcast_workshop_cleared", "workshop_id", "cleared_at"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workshop_id: Mapped[int] = mapped_column( + ForeignKey("workshop.id", ondelete="CASCADE"), nullable=False + ) + message_md: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + cleared_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class FacilitatorAction(Base): + __tablename__ = "facilitator_action" + __table_args__ = ( + Index("ix_facilitator_action_workshop_created", "workshop_id", "created_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workshop_id: Mapped[int | None] = mapped_column( + ForeignKey("workshop.id", ondelete="CASCADE"), nullable=True + ) + actor: Mapped[str] = mapped_column(String(16), nullable=False) + action: Mapped[str] = mapped_column(String(48), nullable=False) + detail_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + undo_data_json: Mapped[str | None] = mapped_column(Text, nullable=True) + undone_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, index=True + ) + + +class AgendaTemplate(Base): + __tablename__ = "agenda_template" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + description_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + +class AgendaTemplateMilestone(Base): + __tablename__ = "agenda_template_milestone" + __table_args__ = ( + Index("ix_agenda_template_milestone_template_position", "template_id", "position"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + template_id: Mapped[int] = mapped_column( + ForeignKey("agenda_template.id", ondelete="CASCADE"), nullable=False + ) + position: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(200), nullable=False) + content_md: Mapped[str] = mapped_column(Text, nullable=False, default="") + minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) + + +class JoinFormTemplate(Base): + __tablename__ = "join_form_template" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + fields_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + +class AiUsage(Base): + __tablename__ = "ai_usage" + __table_args__ = (Index("ix_ai_usage_workshop_created", "workshop_id", "created_at"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + workshop_id: Mapped[int] = mapped_column( + ForeignKey("workshop.id", ondelete="CASCADE"), nullable=False + ) + help_request_id: Mapped[int | None] = mapped_column( + ForeignKey("help_request.id", ondelete="SET NULL"), nullable=True, index=True + ) + purpose: Mapped[str] = mapped_column(String(16), nullable=False) + model: Mapped[str] = mapped_column(String(120), nullable=False) + prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + completion_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cost_usd: Mapped[float] = mapped_column(Numeric(10, 6), nullable=False, default=0) + latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) diff --git a/src/helmsman/db/session.py b/src/helmsman/db/session.py new file mode 100644 index 0000000..b63064d --- /dev/null +++ b/src/helmsman/db/session.py @@ -0,0 +1,96 @@ +"""Engine + session management. SQLite runs WAL/NORMAL/busy_timeout/foreign_keys pragmas +per connection; any DATABASE_URL (e.g. PostgreSQL) works unchanged.""" + +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path + +from sqlalchemy import Engine, create_engine, event +from sqlalchemy.orm import Session, sessionmaker + +_engine: Engine | None = None +_SessionLocal: sessionmaker | None = None + +_SQLITE_PRAGMAS = ( + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", +) + + +def _apply_sqlite_pragmas(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + for pragma in _SQLITE_PRAGMAS: + cursor.execute(pragma) + cursor.close() + + +def _ensure_sqlite_parent_dir(url: str) -> None: + path = url.split("///", 1)[-1] + if not path or path == ":memory:": + return + Path(path).expanduser().parent.mkdir(parents=True, exist_ok=True) + + +def get_engine() -> Engine: + global _engine + if _engine is None: + from src.helmsman.config.settings import get_settings + + url = get_settings().database_url + connect_args: dict = {} + if url.startswith("sqlite"): + _ensure_sqlite_parent_dir(url) + connect_args["check_same_thread"] = False + _engine = create_engine(url, pool_pre_ping=True, connect_args=connect_args) + if url.startswith("sqlite"): + event.listen(_engine, "connect", _apply_sqlite_pragmas) + return _engine + + +def _get_session_factory() -> sessionmaker: + global _SessionLocal + if _SessionLocal is None: + _SessionLocal = sessionmaker( + bind=get_engine(), autoflush=False, autocommit=False, expire_on_commit=False + ) + return _SessionLocal + + +def get_session() -> Generator[Session, None, None]: + """FastAPI dependency — one request, one transaction. + + Mutating handlers MUST call session.commit() before returning their response: + FastAPI runs this teardown only AFTER the response is sent, and clients act on + mutation responses immediately (poll right after a mutation — spec guarantee). + The teardown commit is the safety net for read-path writes (last_seen touches). + """ + with _get_session_factory()() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + +@contextmanager +def create_db_session() -> Generator[Session, None, None]: + """Standalone session for scripts and tests.""" + with _get_session_factory()() as session: + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + + +def reset_db_state() -> None: + """Dispose the engine and clear singletons (tests / restart simulation).""" + global _engine, _SessionLocal + if _engine is not None: + _engine.dispose() + _engine = None + _SessionLocal = None diff --git a/src/helmsman/observability/__init__.py b/src/helmsman/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/helmsman/observability/logging.py b/src/helmsman/observability/logging.py new file mode 100644 index 0000000..1ba8836 --- /dev/null +++ b/src/helmsman/observability/logging.py @@ -0,0 +1,98 @@ +"""structlog JSON-to-stdout config + request-logging ASGI middleware. + +Every request logs method, token-masked path, status, duration_ms, request_id. +Poll endpoints (dashboard/state) log at DEBUG to keep INFO readable. +Tokens are masked to their first 6 characters everywhere. +""" + +import logging +import re +import sys +import time +import uuid + +import structlog + +MASK_VISIBLE_CHARS = 6 + +_TOKEN_PATH_RE = re.compile(r"^(?P(?:/api)?/(?:f|p)/)(?P[^/?]+)") +_POLL_SUFFIXES = ("/dashboard", "/state") + + +def mask_token(token: str) -> str: + """Tokens appear in logs as their first 6 characters only.""" + if len(token) <= MASK_VISIBLE_CHARS: + return token + return token[:MASK_VISIBLE_CHARS] + "…" + + +def configure_logging(level: str = "INFO") -> None: + level_num = getattr(logging, level.upper(), logging.INFO) + logging.basicConfig(format="%(message)s", stream=sys.stdout, level=level_num, force=True) + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.make_filtering_bound_logger(level_num), + logger_factory=structlog.PrintLoggerFactory(sys.stdout), + cache_logger_on_first_use=False, + ) + + +def mask_path(path: str) -> str: + match = _TOKEN_PATH_RE.match(path) + if match is None: + return path + return ( + path[: match.end("prefix")] + + mask_token(match.group("token")) + + path[match.end("token") :] + ) + + +def _is_poll_request(method: str, path: str) -> bool: + return method == "GET" and path.endswith(_POLL_SUFFIXES) + + +class RequestLoggingMiddleware: + """Pure ASGI middleware: request_id binding + one structured line per request.""" + + def __init__(self, app): + self.app = app + self.logger = structlog.get_logger("helmsman.request") + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request_id = uuid.uuid4().hex[:12] + structlog.contextvars.bind_contextvars(request_id=request_id) + start = time.perf_counter() + status_holder = {"status": 0} + + async def send_wrapper(message): + if message["type"] == "http.response.start": + status_holder["status"] = message["status"] + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + duration_ms = round((time.perf_counter() - start) * 1000, 1) + method = scope.get("method", "-") + path = mask_path(scope.get("path", "-")) + log = self.logger.debug if _is_poll_request(method, path) else self.logger.info + log( + "request", + method=method, + path=path, + status=status_holder["status"], + duration_ms=duration_ms, + ) + structlog.contextvars.clear_contextvars() diff --git a/src/helmsman/security.py b/src/helmsman/security.py new file mode 100644 index 0000000..ef222de --- /dev/null +++ b/src/helmsman/security.py @@ -0,0 +1,63 @@ +"""Token generation + auth lookups per spec/architecture.md §Auth model.""" + +import secrets + +from fastapi import Header +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.helmsman.api._common import api_error +from src.helmsman.config.settings import get_settings +from src.helmsman.db.models import Participant, Workshop + + +def generate_admin_token() -> str: + return secrets.token_urlsafe(32) + + +def generate_participant_token() -> str: + return secrets.token_urlsafe(16) + + +def generate_join_slug() -> str: + return secrets.token_urlsafe(6) + + +def admin_key_matches(provided: str | None, expected: str) -> bool: + if not provided or not expected: + return False + return secrets.compare_digest(provided.encode("utf-8"), expected.encode("utf-8")) + + +def require_admin_key(x_admin_key: str | None = Header(default=None)) -> None: + """FastAPI dependency for the admin surface.""" + if not admin_key_matches(x_admin_key, get_settings().resolved_admin_key): + raise api_error( + "invalid_admin_key", + "The access key is missing or does not match this server's HELMSMAN_ADMIN_KEY.", + 401, + ) + + +def workshop_by_admin_token(session: Session, admin_token: str) -> Workshop: + workshop = session.scalar(select(Workshop).where(Workshop.admin_token == admin_token)) + if workshop is None: + raise api_error("not_found", "No workshop matches this facilitator link.", 404) + return workshop + + +def workshop_by_join_slug(session: Session, join_slug: str) -> Workshop: + workshop = session.scalar(select(Workshop).where(Workshop.join_slug == join_slug)) + if workshop is None: + raise api_error("not_found", "This workshop link isn't valid — check with your facilitator.", 404) + return workshop + + +def participant_by_token(session: Session, token: str) -> tuple[Participant, Workshop]: + participant = session.scalar(select(Participant).where(Participant.token == token)) + if participant is None: + raise api_error("not_found", "This personal link isn't valid — re-join from the join link.", 404) + workshop = session.get(Workshop, participant.workshop_id) + if workshop is None: + raise api_error("not_found", "This personal link isn't valid — re-join from the join link.", 404) + return participant, workshop diff --git a/src/helmsman/services/__init__.py b/src/helmsman/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/helmsman/services/audit.py b/src/helmsman/services/audit.py new file mode 100644 index 0000000..261fa9e --- /dev/null +++ b/src/helmsman/services/audit.py @@ -0,0 +1,24 @@ +"""Audit-trail writes into facilitator_action (audit + undo + AI log share one table).""" + +import json + +from sqlalchemy.orm import Session + +from src.helmsman.db.models import FacilitatorAction + + +def record_action( + session: Session, + workshop_id: int | None, + actor: str, + action: str, + detail: dict, +) -> FacilitatorAction: + row = FacilitatorAction( + workshop_id=workshop_id, + actor=actor, + action=action, + detail_json=json.dumps(detail, ensure_ascii=False), + ) + session.add(row) + return row diff --git a/src/helmsman/services/intelligence.py b/src/helmsman/services/intelligence.py new file mode 100644 index 0000000..f455130 --- /dev/null +++ b/src/helmsman/services/intelligence.py @@ -0,0 +1,124 @@ +"""Proactive-intelligence pure functions — stuck alerts, bottleneck, pace pulse. + +Every function here takes already-loaded plain data (no session, no I/O) so it is +directly unit-testable. Callers in snapshots.py load rows and pass them in. +Guards against empty/edge cases — never raises. +""" + +from datetime import datetime, timedelta +from statistics import median + +from src.helmsman.api._common import as_utc, iso_z + + +def compute_stuck( + participants: list[dict], + workshop_paused: bool, + stuck_minutes: int, + now: datetime, +) -> list[dict]: + """participants: [{"participant_id","name","last_activity_at","current_milestone_id", + "finished"}]. Stuck = live (not finished), not paused, no activity for >= stuck_minutes.""" + if workshop_paused: + return [] + threshold = timedelta(minutes=stuck_minutes) + stuck = [] + for p in participants: + if p["finished"]: + continue + inactive_for = now - as_utc(p["last_activity_at"]) + if inactive_for >= threshold: + stuck.append( + { + "participant_id": p["participant_id"], + "name": p["name"], + "minutes_inactive": int(inactive_for.total_seconds() // 60), + "current_milestone_id": p["current_milestone_id"], + } + ) + return stuck + + +def compute_bottleneck( + active_participants: list[dict], milestones_by_id: dict[int, str] +) -> dict | None: + """active_participants: [{"current_milestone_id"}] — only those active (last_seen + within the active window). Bottleneck = milestone with the largest group, + only if that group is >=25% of active participants.""" + total_active = len(active_participants) + if total_active == 0: + return None + counts: dict[int, int] = {} + for p in active_participants: + mid = p["current_milestone_id"] + if mid is None: + continue + counts[mid] = counts.get(mid, 0) + 1 + if not counts: + return None + best_id, best_count = max(counts.items(), key=lambda kv: (kv[1], -kv[0])) + if best_count / total_active < 0.25: + return None + title = milestones_by_id.get(best_id) + if title is None: + return None + return {"milestone_id": best_id, "title": title, "waiting_count": best_count} + + +def compute_pulse( + completion_durations_minutes: list[float], + planned_minutes_by_milestone: dict[int, int], + participants_progress: list[dict], + now: datetime, +) -> dict: + """completion_durations_minutes: observed actual minutes-per-completed-milestone + (already computed by caller from consecutive completion timestamps / join time). + planned_minutes_by_milestone: {milestone_id: minutes} for milestones with a set minutes value. + participants_progress: [{"joined_at","completed_count","total_count", + "remaining_planned_minutes"}] — remaining_planned_minutes = sum of `minutes` for + the participant's not-yet-completed milestones (None entries excluded). + """ + planned_values = [m for m in planned_minutes_by_milestone.values() if m and m > 0] + median_planned = median(planned_values) if planned_values else None + median_actual = median(completion_durations_minutes) if completion_durations_minutes else None + + if median_planned and median_actual is not None: + pace_ratio = round(median_actual / median_planned, 2) + else: + pace_ratio = 1.0 + + on_track_flags: list[bool] = [] + for p in participants_progress: + total = p["total_count"] + if total <= 0: + continue + elapsed_minutes = (now - as_utc(p["joined_at"])).total_seconds() / 60 + total_planned = sum(m for m in planned_minutes_by_milestone.values() if m) + if total_planned <= 0 or elapsed_minutes <= 0: + continue + expected_fraction = min(1.0, elapsed_minutes / total_planned) + actual_fraction = p["completed_count"] / total + on_track_flags.append(actual_fraction >= expected_fraction) + + on_track_pct = ( + round(sum(1 for f in on_track_flags if f) / len(on_track_flags) * 100, 1) + if on_track_flags + else 0.0 + ) + + remaining_values = [ + p["remaining_planned_minutes"] + for p in participants_progress + if p["remaining_planned_minutes"] is not None and p["completed_count"] < p["total_count"] + ] + projected_finish_at = None + if remaining_values: + median_remaining = median(remaining_values) + eta_minutes = median_remaining * pace_ratio + projected_finish_at = iso_z(now + timedelta(minutes=eta_minutes)) + + return { + "pace_ratio": pace_ratio, + "on_track_pct": on_track_pct, + "projected_finish_at": projected_finish_at, + } diff --git a/src/helmsman/services/snapshots.py b/src/helmsman/services/snapshots.py new file mode 100644 index 0000000..c3b03a4 --- /dev/null +++ b/src/helmsman/services/snapshots.py @@ -0,0 +1,526 @@ +"""Versioned poll-payload builders + the in-process snapshot memo cache. + +The changed-payload for a given (kind, workshop_id, state_version) is built once and +shared by every poller at that version (coalescing). A short TTL is the safety net for +data that changes without a version bump (e.g. throttled last_seen_at touches). +Single-worker process model makes this cache correct without external infrastructure. +""" + +import json +import time +from datetime import datetime, timedelta, timezone +from statistics import median +from typing import Callable + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.helmsman.api._common import as_utc, iso_z, utcnow +from src.helmsman.db.models import ( + Broadcast, + HelpAnswer, + HelpRequest, + Milestone, + MilestoneCompletion, + Participant, + Workshop, +) +from src.helmsman.services.intelligence import compute_bottleneck, compute_pulse, compute_stuck + +SNAPSHOT_TTL_SECONDS = 2.0 +ACTIVE_WINDOW_SECONDS = 300 +RESOLVED_QUEUE_LIMIT = 50 + +_cache: dict[tuple, tuple[float, dict]] = {} + +_monotonic = time.monotonic # module-level indirection so tests can inject a clock + + +def clear_snapshot_cache() -> None: + _cache.clear() + + +def _memoized(key: tuple, builder: Callable[[], dict]) -> dict: + now = _monotonic() + hit = _cache.get(key) + if hit is not None and now - hit[0] < SNAPSHOT_TTL_SECONDS: + return hit[1] + value = builder() + expired = [k for k, (built_at, _) in _cache.items() if now - built_at >= SNAPSHOT_TTL_SECONDS] + for stale_key in expired: + _cache.pop(stale_key, None) + _cache[key] = (now, value) + return value + + +# --- version counters (bumped in the SAME transaction as the triggering write) --- + + +def bump_state_version(workshop: Workshop) -> int: + workshop.state_version += 1 + return workshop.state_version + + +def bump_content_version(workshop: Workshop) -> int: + workshop.content_version += 1 + return workshop.content_version + + +# --- pure computation helpers (unit-tested) --- + + +def progress_pct(completed_count: int, total_count: int) -> float: + if total_count <= 0: + return 0.0 + return round(completed_count / total_count * 100, 1) + + +def median_progress_pct(values: list[float]) -> float: + if not values: + return 0.0 + return round(float(median(values)), 1) + + +def build_distribution(completed_counts: list[int], total_count: int) -> list[dict]: + """Histogram covering every completed-count 0..total, zeros included.""" + buckets = {n: 0 for n in range(total_count + 1)} + for count in completed_counts: + if count in buckets: + buckets[count] += 1 + return [{"completed_count": n, "participants": buckets[n]} for n in range(total_count + 1)] + + +_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + + +def rank_participants(entries: list[dict]) -> list[dict]: + """Rank order: completed_count desc → earliest time of reaching that count asc → + joined_at asc. 1-based rank, no tie-sharing. + + Each entry: {"participant_id", "name", "joined_at", "completion_times": [datetime...]}. + Returns new dicts with added "completed_count" and "rank". + """ + + def sort_key(entry: dict) -> tuple: + times = entry["completion_times"] + count = len(times) + reached_at = as_utc(max(times)) if times else _EPOCH + return (-count, reached_at, as_utc(entry["joined_at"])) + + ordered = sorted(entries, key=sort_key) + return [ + {**entry, "completed_count": len(entry["completion_times"]), "rank": index + 1} + for index, entry in enumerate(ordered) + ] + + +# --- shared row serializers --- + + +def serialize_answer_facilitator(answer: HelpAnswer) -> dict: + return { + "id": answer.id, + "source": answer.source, + "answer_md": answer.answer_md, + "draft": answer.draft, + "created_at": iso_z(answer.created_at), + "ai_confidence": float(answer.ai_confidence) if answer.ai_confidence is not None else None, + "ai_model": answer.ai_model, + "ai_context": json.loads(answer.ai_context_json) if answer.ai_context_json else None, + } + + +def serialize_answer_participant(answer: HelpAnswer) -> dict: + return { + "id": answer.id, + "source": answer.source, + "answer_md": answer.answer_md, + "created_at": iso_z(answer.created_at), + } + + +def _load_answers(session: Session, help_request_id: int) -> list[HelpAnswer]: + return list( + session.scalars( + select(HelpAnswer) + .where(HelpAnswer.help_request_id == help_request_id) + .order_by(HelpAnswer.created_at, HelpAnswer.id) + ) + ) + + +def serialize_help_request_facilitator(session: Session, help_request: HelpRequest) -> dict: + participant = session.get(Participant, help_request.participant_id) + milestone = ( + session.get(Milestone, help_request.milestone_id) + if help_request.milestone_id is not None + else None + ) + return { + "id": help_request.id, + "participant_id": help_request.participant_id, + "participant_name": participant.name if participant else "", + "milestone_id": help_request.milestone_id, + "milestone_title": milestone.title if milestone else None, + "message": help_request.message, + "status": help_request.status, + "escalated": help_request.escalated, + "created_at": iso_z(help_request.created_at), + "updated_at": iso_z(help_request.updated_at), + "answers": [ + serialize_answer_facilitator(a) for a in _load_answers(session, help_request.id) + ], + } + + +def serialize_help_request_participant(session: Session, help_request: HelpRequest) -> dict: + """Tracker row shape — never includes drafts or ai_context.""" + answers = [a for a in _load_answers(session, help_request.id) if not a.draft] + return { + "id": help_request.id, + "message": help_request.message, + "status": help_request.status, + "escalated": help_request.escalated, + "milestone_id": help_request.milestone_id, + "created_at": iso_z(help_request.created_at), + "answers": [serialize_answer_participant(a) for a in answers], + } + + +# --- bulk loaders --- + + +def _load_milestones(session: Session, workshop_id: int) -> list[Milestone]: + return list( + session.scalars( + select(Milestone) + .where(Milestone.workshop_id == workshop_id) + .order_by(Milestone.position, Milestone.id) + ) + ) + + +def _load_participants(session: Session, workshop_id: int) -> list[Participant]: + return list( + session.scalars( + select(Participant) + .where(Participant.workshop_id == workshop_id) + .order_by(Participant.joined_at, Participant.id) + ) + ) + + +def _load_completions_by_participant( + session: Session, workshop_id: int +) -> dict[int, list[tuple[int, datetime]]]: + rows = session.execute( + select( + MilestoneCompletion.participant_id, + MilestoneCompletion.milestone_id, + MilestoneCompletion.completed_at, + ) + .join(Milestone, MilestoneCompletion.milestone_id == Milestone.id) + .where(Milestone.workshop_id == workshop_id) + ).all() + by_participant: dict[int, list[tuple[int, datetime]]] = {} + for participant_id, milestone_id, completed_at in rows: + by_participant.setdefault(participant_id, []).append((milestone_id, completed_at)) + return by_participant + + +def _current_milestone_id(milestones: list[Milestone], completed_ids: set[int]) -> int | None: + for milestone in milestones: + if milestone.id not in completed_ids: + return milestone.id + return None + + +def _ordered_completed_ids(milestones: list[Milestone], completed_ids: set[int]) -> list[int]: + return [m.id for m in milestones if m.id in completed_ids] + + +def _active_broadcast(session: Session, workshop_id: int) -> Broadcast | None: + return session.scalar( + select(Broadcast) + .where(Broadcast.workshop_id == workshop_id, Broadcast.cleared_at.is_(None)) + .order_by(Broadcast.created_at.desc(), Broadcast.id.desc()) + ) + + +def serialize_broadcast(broadcast: Broadcast | None) -> dict | None: + if broadcast is None: + return None + return { + "id": broadcast.id, + "message_md": broadcast.message_md, + "created_at": iso_z(broadcast.created_at), + } + + +def _build_alerts_and_pulse( + session: Session, + workshop: Workshop, + milestones: list[Milestone], + participants: list[Participant], + completions: dict[int, list[tuple[int, datetime]]], + open_help_by_participant: dict[int, int], + help_requests: list[HelpRequest], + now: datetime, +) -> tuple[dict, dict]: + total_count = len(milestones) + milestones_by_id = {m.id: m.title for m in milestones} + planned_minutes_by_milestone = {m.id: m.minutes for m in milestones if m.minutes} + + last_help_by_participant: dict[int, datetime] = {} + for hr in help_requests: + current = last_help_by_participant.get(hr.participant_id) + if current is None or as_utc(hr.created_at) > as_utc(current): + last_help_by_participant[hr.participant_id] = hr.created_at + + stuck_input = [] + active_for_bottleneck = [] + completion_durations: list[float] = [] + participants_progress = [] + + for p in participants: + completion_list = sorted(completions.get(p.id, []), key=lambda t: as_utc(t[1])) + completed_ids = {mid for mid, _ in completion_list} + finished = total_count > 0 and len(completed_ids) == total_count + current_milestone_id = _current_milestone_id(milestones, completed_ids) + + candidates = [t for _, t in completion_list] + if p.id in last_help_by_participant: + candidates.append(last_help_by_participant[p.id]) + last_activity_at = max(candidates, key=as_utc) if candidates else p.joined_at + + stuck_input.append( + { + "participant_id": p.id, + "name": p.name, + "last_activity_at": last_activity_at, + "current_milestone_id": current_milestone_id, + "finished": finished, + } + ) + + if (now - as_utc(p.last_seen_at)) <= timedelta(seconds=ACTIVE_WINDOW_SECONDS): + active_for_bottleneck.append({"current_milestone_id": current_milestone_id}) + + previous_at = p.joined_at + for _, completed_at in completion_list: + gap_minutes = (as_utc(completed_at) - as_utc(previous_at)).total_seconds() / 60 + if gap_minutes >= 0: + completion_durations.append(gap_minutes) + previous_at = completed_at + + remaining_minutes = sum( + (m.minutes or 0) + for m in milestones + if m.id not in completed_ids and m.minutes + ) + participants_progress.append( + { + "joined_at": p.joined_at, + "completed_count": len(completed_ids), + "total_count": total_count, + "remaining_planned_minutes": remaining_minutes if total_count > 0 else None, + } + ) + + stuck = compute_stuck(stuck_input, workshop.paused, workshop.stuck_minutes, now) + bottleneck = compute_bottleneck(active_for_bottleneck, milestones_by_id) + pulse_core = compute_pulse( + completion_durations, planned_minutes_by_milestone, participants_progress, now + ) + open_help_count = sum(1 for hr in help_requests if hr.status == "open") + + alerts = {"stuck": stuck, "bottleneck": bottleneck} + pulse = { + "pace_ratio": pulse_core["pace_ratio"], + "on_track_pct": pulse_core["on_track_pct"], + "open_help_count": open_help_count, + "projected_finish_at": pulse_core["projected_finish_at"], + } + return alerts, pulse + + +# --- facilitator dashboard snapshot --- + + +def dashboard_snapshot(session: Session, workshop: Workshop, base_url: str) -> dict: + key = ("dashboard", workshop.id, workshop.state_version, base_url) + return _memoized(key, lambda: _build_dashboard(session, workshop, base_url)) + + +def _build_dashboard(session: Session, workshop: Workshop, base_url: str) -> dict: + milestones = _load_milestones(session, workshop.id) + participants = _load_participants(session, workshop.id) + completions = _load_completions_by_participant(session, workshop.id) + total_count = len(milestones) + now = utcnow() + + help_requests = list( + session.scalars( + select(HelpRequest).where(HelpRequest.workshop_id == workshop.id) + ) + ) + open_help_by_participant: dict[int, int] = {} + for hr in help_requests: + if hr.status == "open": + open_help_by_participant[hr.participant_id] = ( + open_help_by_participant.get(hr.participant_id, 0) + 1 + ) + + participant_rows = [] + completed_counts: list[int] = [] + progress_values: list[float] = [] + finished_count = 0 + active_count = 0 + for p in participants: + completed_ids = {mid for mid, _ in completions.get(p.id, [])} + count = len(completed_ids) + completed_counts.append(count) + pct = progress_pct(count, total_count) + progress_values.append(pct) + if total_count > 0 and count == total_count: + finished_count += 1 + if (now - as_utc(p.last_seen_at)) <= timedelta(seconds=ACTIVE_WINDOW_SECONDS): + active_count += 1 + participant_rows.append( + { + "id": p.id, + "name": p.name, + "joined_at": iso_z(p.joined_at), + "last_seen_at": iso_z(p.last_seen_at), + "completed_milestone_ids": _ordered_completed_ids(milestones, completed_ids), + "completed_count": count, + "progress_pct": pct, + "current_milestone_id": _current_milestone_id(milestones, completed_ids), + "open_help_count": open_help_by_participant.get(p.id, 0), + "participant_url": f"{base_url}/p/{p.token}", + } + ) + + per_milestone_completed: dict[int, int] = {m.id: 0 for m in milestones} + for completion_list in completions.values(): + for milestone_id, _ in completion_list: + if milestone_id in per_milestone_completed: + per_milestone_completed[milestone_id] += 1 + milestone_stats = [ + { + "milestone_id": m.id, + "position": m.position, + "title": m.title, + "completed_count": per_milestone_completed[m.id], + "completed_pct": progress_pct(per_milestone_completed[m.id], len(participants)), + } + for m in milestones + ] + + status_counts = {"open": 0, "answered": 0, "resolved": 0} + for hr in help_requests: + if hr.status in status_counts: + status_counts[hr.status] += 1 + + newest_first = sorted(help_requests, key=lambda hr: (as_utc(hr.created_at), hr.id), reverse=True) + queue = ( + [hr for hr in newest_first if hr.status == "open"] + + [hr for hr in newest_first if hr.status == "answered"] + + [hr for hr in newest_first if hr.status == "resolved"][:RESOLVED_QUEUE_LIMIT] + ) + help_queue = [serialize_help_request_facilitator(session, hr) for hr in queue] + + alerts, pulse = _build_alerts_and_pulse( + session, + workshop, + milestones, + participants, + completions, + open_help_by_participant, + help_requests, + now, + ) + broadcast = serialize_broadcast(_active_broadcast(session, workshop.id)) + + return { + "changed": True, + "version": workshop.state_version, + "content_version": workshop.content_version, + "workshop": { + "id": workshop.id, + "name": workshop.name, + "status": workshop.status, + "paused": workshop.paused, + "ai_enabled": workshop.ai_enabled, + }, + "stats": { + "participant_count": len(participants), + "active_count": active_count, + "finished_count": finished_count, + "median_progress_pct": median_progress_pct(progress_values), + "open_help_count": status_counts["open"], + "answered_help_count": status_counts["answered"], + "resolved_help_count": status_counts["resolved"], + }, + "milestone_stats": milestone_stats, + "distribution": build_distribution(completed_counts, total_count), + "participants": participant_rows, + "help_queue": help_queue, + "broadcast": broadcast, + "alerts": alerts, + "pulse": pulse, + "spend": None, + } + + +# --- participant shared snapshot (per-requester fields are layered on by the router) --- + + +def participant_shared_snapshot(session: Session, workshop: Workshop) -> dict: + key = ("participant", workshop.id, workshop.state_version) + return _memoized(key, lambda: _build_participant_shared(session, workshop)) + + +def _build_participant_shared(session: Session, workshop: Workshop) -> dict: + milestones = _load_milestones(session, workshop.id) + participants = _load_participants(session, workshop.id) + completions = _load_completions_by_participant(session, workshop.id) + total_count = len(milestones) + + entries = [ + { + "participant_id": p.id, + "name": p.name, + "joined_at": p.joined_at, + "completion_times": [t for _, t in completions.get(p.id, [])], + } + for p in participants + ] + ranked = rank_participants(entries) + + leaderboard = [ + { + "participant_id": entry["participant_id"], + "rank": entry["rank"], + "name": entry["name"], + "completed_count": entry["completed_count"], + "progress_pct": progress_pct(entry["completed_count"], total_count), + } + for entry in ranked + ] + + return { + "workshop": { + "name": workshop.name, + "status": workshop.status, + "paused": workshop.paused, + }, + "milestones": [ + {"id": m.id, "position": m.position, "title": m.title, "minutes": m.minutes} + for m in milestones + ], + "total_count": total_count, + "leaderboard": leaderboard, + "rank_by_participant_id": {entry["participant_id"]: entry["rank"] for entry in ranked}, + "broadcast": serialize_broadcast(_active_broadcast(session, workshop.id)), + } diff --git a/src/helmsman/services/undo.py b/src/helmsman/services/undo.py new file mode 100644 index 0000000..54920b2 --- /dev/null +++ b/src/helmsman/services/undo.py @@ -0,0 +1,65 @@ +"""Undo — apply the inverse of an undoable FacilitatorAction within the 30 s window.""" + +import json +from datetime import timedelta + +from sqlalchemy.orm import Session + +from src.helmsman.api._common import api_error, as_utc, utcnow +from src.helmsman.db.models import Broadcast, FacilitatorAction, MilestoneCompletion, Workshop +from src.helmsman.services.audit import record_action +from src.helmsman.services.snapshots import bump_state_version + +UNDO_WINDOW_SECONDS = 30 + + +def apply_undo(session: Session, workshop: Workshop, action_id: int) -> int: + """Applies the inverse of the given action in this transaction; returns new state_version. + Raises api_error(not_found) / api_error(undo_expired) as appropriate.""" + action = session.get(FacilitatorAction, action_id) + if action is None or action.workshop_id != workshop.id: + raise api_error("not_found", "No such action in this workshop.", 404) + + if action.undone_at is not None: + raise api_error("undo_expired", "This action was already undone.", 409) + + now = utcnow() + if (now - as_utc(action.created_at)) > timedelta(seconds=UNDO_WINDOW_SECONDS): + raise api_error("undo_expired", "The 30-second undo window has passed.", 409) + + undo_data = json.loads(action.undo_data_json) if action.undo_data_json else {} + + if action.action == "broadcast.send": + sent_id = json.loads(action.detail_json).get("broadcast_id") + if sent_id is not None: + created = session.get(Broadcast, sent_id) + if created is not None: + session.delete(created) + previous_id = undo_data.get("previous_broadcast_id") + if previous_id is not None: + previous = session.get(Broadcast, previous_id) + if previous is not None: + previous.cleared_at = None + elif action.action in ("workshop.pause", "workshop.resume"): + workshop.paused = undo_data.get("previous_paused", False) + elif action.action in ("milestone.advance_all", "milestone.advance_selected"): + completion_ids = undo_data.get("completion_ids", []) + if completion_ids: + for completion in session.query(MilestoneCompletion).filter( + MilestoneCompletion.id.in_(completion_ids) + ): + session.delete(completion) + else: + raise api_error("not_found", "This action cannot be undone.", 404) + + action.undone_at = now + version = bump_state_version(workshop) + record_action( + session, + workshop.id, + "facilitator", + "undo.apply", + {"original_action_id": action.id, "original_action": action.action}, + ) + session.flush() + return version diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 4d61845..0000000 --- a/src/main.py +++ /dev/null @@ -1,1831 +0,0 @@ -"""FastAPI application: all routes for Workshop Helmsman (Phases 1-6).""" - -from __future__ import annotations - -import csv -import io -import json -import logging -import os -import re -import urllib.error -import urllib.request -from copy import deepcopy -from datetime import datetime, timedelta, timezone -from pathlib import Path - -from fastapi import Body, Depends, FastAPI, Form, HTTPException, Request, status -from fastapi.responses import ( - HTMLResponse, - JSONResponse, - RedirectResponse, - StreamingResponse, -) -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from sqlalchemy import desc -from sqlalchemy.orm import Session - -from .db import get_db, init_db, session_scope -from .models import ( - DEFAULT_AGENDA_TEMPLATES, - DEFAULT_FORM_SCHEMA, - DEFAULT_MILESTONE_CONFIG, - HELP_STATUSES, - AgendaTemplate, - FormTemplate, - HelpRequest, - MilestoneCompletion, - Participant, - Workshop, -) -from .security import ( - PARTICIPANT_COOKIE, - find_participant, - find_workshop_by_admin_token, - find_workshop_by_slug, - generate_admin_token, - generate_participant_slug, - require_workshop_by_admin_token, - require_workshop_by_slug, -) - -log = logging.getLogger("helmsman") - -# --- Paths & app bootstrap --- - -HERE = Path(__file__).resolve().parent.parent -TEMPLATE_DIR = HERE / "frontend" / "templates" -STATIC_DIR = HERE / "frontend" / "static" - -app = FastAPI(title="Workshop Helmsman", version="0.1.0") -templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) -app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - - -@app.on_event("startup") -def _on_startup() -> None: - init_db() - _seed_agenda_templates() - - -# --- Helpers --- - -def _utcnow() -> datetime: - # SQLite stores naive datetimes; return naive UTC to match what comes back. - return datetime.now(timezone.utc).replace(tzinfo=None) - - -def _seed_agenda_templates() -> None: - """Seed 2-3 starter agenda templates on first run; idempotent.""" - import json - - with session_scope() as db: - existing = db.query(AgendaTemplate).count() - if existing > 0: - return - now = _utcnow() - for tpl in DEFAULT_AGENDA_TEMPLATES: - at = AgendaTemplate( - name=tpl["name"], - created_at=now, - milestones_json=json.dumps(tpl["milestones"]), - ) - db.add(at) - - -# --- Phase 6: .env loader + OpenRouter LLM helper --- - -# Tiny .env loader — we deliberately don't add python-dotenv to keep the -# dependency footprint flat. Reads KEY=VALUE lines from ./.env (next to the -# repo root) into os.environ ONLY where the key isn't already present. -# Existing shell exports always win. -def _load_dotenv_once() -> None: - if getattr(_load_dotenv_once, "_done", False): - return - _load_dotenv_once._done = True # type: ignore[attr-defined] - env_path = HERE / ".env" - if not env_path.exists(): - return - try: - for raw in env_path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - key, _, value = line.partition("=") - key = key.strip() - if not key: - continue - # Strip optional surrounding quotes on the value. - value = value.strip() - if (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ): - value = value[1:-1] - # Never overwrite an explicit shell export. - os.environ.setdefault(key, value) - except Exception as exc: - log.warning("could not read %s: %s", env_path, exc) - - -_load_dotenv_once() - - -# All values below are read lazily so tests / shells can override after import. -def _openrouter_key() -> str | None: - """Read OPENROUTER_API_KEY from env, return None if missing/empty.""" - key = os.environ.get("OPENROUTER_API_KEY") - if not key: - return None - key = key.strip() - if not key or key.startswith("sk-or-..."): - # Treat placeholder/template values as 'not configured'. - return None - return key - - -def _llm_resolve( - message: str, - milestones: list[dict], - help_tips: str, -) -> str | None: - """Ask OpenRouter for a short, actionable suggestion for a help flag. - - Returns the suggestion string (one or two short paragraphs), or None - if the API isn't configured, the network call fails, or the response - is empty. NEVER raises — Phase 6 callers must always have a graceful - fallback (no LLM → plain "send this?" confirmation). - - Uses urllib.request + stdlib json — no extra client deps. - Falls back from nvidia/llama-3.3-nemotron-super-49b-v1 to - openai/gpt-4o-mini if the primary model errors. - """ - if not message or not message.strip(): - return None - api_key = _openrouter_key() - if not api_key: - return None - - # Build a concise context block. - ctx_lines: list[str] = [] - if milestones: - ms = ", ".join((m.get("title") or "").strip() for m in milestones if (m.get("title") or "").strip()) - if ms: - ctx_lines.append("Workshop milestones: " + ms + ".") - if help_tips and help_tips.strip(): - ctx_lines.append("Facilitator tips:\n" + help_tips.strip()) - - context = "\n\n".join(ctx_lines) or "(no extra context)" - system_prompt = ( - "You are a workshop help-desk assistant. A participant just typed a " - "short message describing what's blocking them. Reply with ONE short " - "paragraph (2-4 sentences) of the most likely fix OR next step they " - "should try. Be concrete and specific. If their question is about a " - "specific tool/step not covered by the milestones or tips, give your " - "best guess based on common workshop gotchas. Do NOT ask follow-up " - "questions. Do NOT apologize. Just give the next thing to try." - ) - user_prompt = ( - f"Context:\n{context}\n\n" - f"Participant's help request:\n{message.strip()[:1500]}\n\n" - "Reply with the suggestion only." - ) - - primary = os.environ.get( - "OPENROUTER_PRIMARY_MODEL", "nvidia/llama-3.3-nemotron-super-49b-v1" - ) - fallback = os.environ.get("OPENROUTER_FALLBACK_MODEL", "openai/gpt-4o-mini") - - for model in (primary, fallback): - suggestion = _llm_call_openrouter(api_key, model, system_prompt, user_prompt) - if suggestion: - return suggestion - # If the primary returned None because of an error AND it's not the - # last attempt, the loop moves on to the fallback. If we got a real - # empty-string completion on the primary, still try fallback — feels - # safer than surfacing nothing. - return None - - -def _llm_call_openrouter( - api_key: str, - model: str, - system_prompt: str, - user_prompt: str, - timeout: float = 6.0, -) -> str | None: - """Single OpenRouter /chat/completions call. - - Returns the first choice's message content stripped, or None on any - failure. Network errors, non-2xx responses, JSON parsing errors, and - empty completions are all logged at warning/info and return None. - """ - url = "https://openrouter.ai/api/v1/chat/completions" - payload = { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - "max_tokens": 220, - "temperature": 0.4, - "stream": False, - } - body = json.dumps(payload).encode("utf-8") - req = urllib.request.Request( - url, - data=body, - headers={ - "Authorization": "Bearer " + api_key, - "Content-Type": "application/json", - "HTTP-Referer": "http://localhost:8001", - "X-Title": "WorkshopHelmsman", - }, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - raw = resp.read().decode("utf-8", errors="replace") - except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc: - log.warning("OpenRouter call failed for model=%s: %s", model, exc) - return None - except Exception as exc: # belt-and-braces; never crash the request - log.warning("OpenRouter unexpected error: %s", exc) - return None - - try: - data = json.loads(raw) - except Exception as exc: - log.warning("OpenRouter returned non-JSON for model=%s: %s", model, exc) - return None - - try: - choices = data.get("choices") or [] - if not choices: - return None - msg = choices[0].get("message") or {} - text = (msg.get("content") or "").strip() - return text or None - except Exception as exc: - log.warning("OpenRouter response shape unexpected for model=%s: %s", model, exc) - return None - - -def _parse_milestones(raw: str) -> list[dict]: - """Parse lines into [{id, title, description}].""" - parsed: list[dict] = [] - for idx, line in enumerate((raw or "").splitlines()): - line = line.strip() - if not line: - continue - if ":" in line: - title, description = line.split(":", 1) - title = title.strip() - description = description.strip() - else: - title = line - description = "" - if not title: - continue - parsed.append({"id": f"m{idx}", "title": title, "description": description}) - if not parsed: - # Sensible default — facilitator gets 4 phases even if they submit blank. - parsed = [ - {"id": "m0", "title": "Setup", "description": "Environment ready"}, - {"id": "m1", "title": "API Key", "description": "API key configured"}, - {"id": "m2", "title": "First Build", "description": "First working build"}, - {"id": "m3", "title": "Done", "description": "Workshop wrap-up"}, - ] - return parsed - - -# --- Phase 4: form schema helpers --- - - -_SLUG_RE = re.compile(r"[^a-z0-9]+") - - -def _slugify_key(label: str, fallback: str = "field") -> str: - """Best-effort field key derived from a label. - - "Display name" -> "display_name"; "What's your role?" -> "whats_your_role". - Used only as an auto-fill suggestion; users can override it manually. - """ - s = (label or "").strip().lower() - s = _SLUG_RE.sub("_", s).strip("_") - return (s or fallback)[:64] - - -def _normalize_field(raw: dict, idx: int) -> dict | None: - """Sanitize a single field dict from a posted form. - - Returns None if the field is unusable (no label, no key after sanitize). - """ - if not isinstance(raw, dict): - return None - label = (raw.get("label") or "").strip() - if not label: - return None - key = (raw.get("key") or "").strip() - if not key: - key = _slugify_key(label, fallback=f"field_{idx}") - key = _slugify_key(key.replace(" ", "_"), fallback=f"field_{idx}") - ftype = raw.get("type") - if ftype not in ("text", "dropdown"): - ftype = "text" - placeholder = (raw.get("placeholder") or "").strip() if ftype == "text" else "" - required = bool(raw.get("required")) - options: list[str] = [] - if ftype == "dropdown": - opts = raw.get("options") - if isinstance(opts, list): - for o in opts: - o = (str(o) if o is not None else "").strip() - if o: - options.append(o) - elif isinstance(opts, str): - for line in opts.splitlines(): - line = line.strip() - if line: - options.append(line) - # Dedupe while preserving order. - seen: set[str] = set() - deduped: list[str] = [] - for o in options: - if o not in seen: - seen.add(o) - deduped.append(o) - options = deduped - if not options: - options = ["Yes", "No"] - field: dict = { - "key": key, - "type": ftype, - "label": label[:200], - "required": required, - } - if ftype == "text": - field["placeholder"] = placeholder[:200] - else: - field["options"] = options[:32] - return field - - -def _coerce_fields_json(raw_json: str) -> list[dict]: - """Parse a JSON string of field dicts into a normalized, deduplicated list. - - Rejects malformed JSON (returns empty list — caller must decide whether - to default or error). For dicts missing a key/label, we synthesize a - stable key from the label. Duplicate keys are deduped (later wins). - """ - if not raw_json or not raw_json.strip(): - return [] - try: - data = json.loads(raw_json) - except Exception: - return [] - if not isinstance(data, list): - return [] - out: list[dict] = [] - seen_keys: dict[str, int] = {} - for idx, item in enumerate(data): - norm = _normalize_field(item, idx) - if norm is None: - continue - key = norm["key"] - if key in seen_keys: - # Replace prior occurrence (callers see the most recent). - j = seen_keys[key] - out[j] = norm - continue - seen_keys[key] = len(out) - out.append(norm) - return out - - -def _ensure_display_name_field(fields: list[dict]) -> list[dict]: - """Make sure the form has at least one text field marked as the name. - - The 'name' is the field whose key is 'display_name', OR the first field - if none is so named. If the form is empty, return the default schema. - """ - if not fields: - return list(DEFAULT_FORM_SCHEMA) - return fields - - -def _display_name_field(schema: list[dict]) -> dict | None: - """Return the field designated as the participant display name.""" - for f in schema: - if f.get("key") == "display_name": - return f - return schema[0] if schema else None - - -def _form_keys(schema: list[dict]) -> list[str]: - """Sorted-stable list of form keys for CSV columns.""" - return [f["key"] for f in schema] - - -def _collect_form_answers(schema: list[dict], form: dict) -> dict: - """Given a workshop schema + request.form, return an {key: value} dict. - - Missing required fields are silently included as empty strings so the - persistence shape is predictable. Junk keys in `form` are ignored. - """ - out: dict[str, str] = {} - for f in schema: - key = f.get("key") - if not key: - continue - # Inputs are named 'field_' so we don't clash with hidden helpers. - v = form.get(f"field_{key}") - if v is None: - v = form.get(key) - if v is None: - v = "" - out[key] = str(v)[:1000] - return out - - -def _render(request: Request, template: str, **ctx) -> HTMLResponse: - return templates.TemplateResponse(request, template, ctx) - - -def _participant_progress(participant: Participant, milestones: list[dict]) -> dict: - completed_ids = {c.milestone_id for c in participant.completions} - return { - "participant": participant, - "completed_ids": completed_ids, - "completed_count": len(completed_ids), - "total": len(milestones), - "pct": int(round(100 * len(completed_ids) / max(len(milestones), 1))), - } - - -def _leaderboard_rows( - participants: list[Participant], milestones: list[dict], my_id: int | None = None -) -> list[dict]: - """Build leaderboard rows. Call with full list; template slices to 50.""" - out = [] - total = len(milestones) - for p in participants: - done = {c.milestone_id for c in p.completions} - out.append({ - "id": p.id, - "name": p.name, - "joined_at": p.joined_at.isoformat(), - "completed_count": len(done), - "total": total, - "pct": int(round(100 * len(done) / max(total, 1))), - "is_me": p.id == my_id, - }) - return out - - -# --- Health & landing --- - -@app.get("/healthz", response_class=JSONResponse) -def healthz(db: Session = Depends(get_db)) -> dict: - try: - # Cheap query — proves DB reachable. - db.execute(__import__("sqlalchemy").text("SELECT 1")) - return {"status": "ok", "db": "ok"} - except Exception as exc: - return JSONResponse(status_code=503, content={"status": "degraded", "db": str(exc)}) - - -@app.get("/healthz/ready", response_class=JSONResponse) -def healthz_ready(db: Session = Depends(get_db)) -> dict: - """Kubernetes readiness probe: checks DB connectivity.""" - try: - db.execute(__import__("sqlalchemy").text("SELECT 1")) - return {"ok": True} - except Exception as exc: - return JSONResponse(status_code=503, content={"ok": False, "error": str(exc)}) - - -@app.get("/", response_class=HTMLResponse) -def landing(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: - # Bootstrap a DEMO workshop the first time anyone hits /, so facilitators - # can poke at a working session immediately. - demo = ( - db.query(Workshop) - .filter(Workshop.admin_token == "demo-workshop-admin-token") - .first() - ) - if demo is None: - milestones = _parse_milestones( - "Setup: pick your environment\nAPI Key: configure your LLM key\nFirst Build: run your hello-world\nDone: workshop wrap-up" - ) - now = _utcnow() - demo = Workshop( - name="(demo) Quick Walkthrough", - created_at=now, - expires_at=now + timedelta(days=1), - admin_token="demo-workshop-admin-token", - participant_slug="demo-walkthrough", - milestone_config_json=json.dumps(milestones), - archived=False, - form_schema_json=json.dumps(DEFAULT_FORM_SCHEMA), - ) - db.add(demo) - db.commit() - - recent = ( - db.query(Workshop).order_by(desc(Workshop.created_at)).limit(10).all() - ) - # Category colors for milestone badges - category_colors = { - 'setup': '#7aa2ff', - 'learning': '#44d39a', - 'hands_on': '#e4c44a', - 'break': '#d68c44', - 'assessment': '#b07fdb', - 'wrap_up': '#ec8898', - } - - return _render( - request, - "landing.html", - demo_admin=demo.admin_token, - demo_slug=demo.participant_slug, - workshops=recent, - ) - - -# --- Admin: create workshop --- - -@app.get("/admin/new", response_class=HTMLResponse) -def admin_new_form(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: - default_milestones = "\n".join( - [ - "Setup: pick your environment and clone the starter", - "API Key: configure your LLM provider key", - "First Build: ship a hello-world end-to-end", - "Done: present and wrap up", - ] - ) - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - agenda_templates = ( - db.query(AgendaTemplate).order_by(AgendaTemplate.created_at.asc()).all() - ) - return _render( - request, - "admin_new.html", - default_milestones=default_milestones, - default_ttl=8, - templates_list=templates_list, - default_form_fields=DEFAULT_FORM_SCHEMA, - agenda_templates=agenda_templates, - ) - - -@app.post("/admin/new") -def admin_new_create( - request: Request, - name: str = Form(...), - milestones: str = Form(""), - ttl_hours: int = Form(8), - fields_json: str = Form(""), - template_id: str = Form(""), - save_as_template: str = Form(""), - template_name: str = Form(""), - agenda_template_id: str = Form(""), - db: Session = Depends(get_db), -): - name = (name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="Workshop name is required") - if ttl_hours < 1 or ttl_hours > 7 * 24: - ttl_hours = 8 - - # Resolve milestones: use _parse_milestones (textarea) OR load from agenda template. - parsed: list[dict] - if agenda_template_id and agenda_template_id.isdigit(): - agenda_tpl = ( - db.query(AgendaTemplate) - .filter(AgendaTemplate.id == int(agenda_template_id)) - .first() - ) - if agenda_tpl is not None: - agenda_milestones = agenda_tpl.milestones() - if agenda_milestones: - # Assign stable ids - parsed = [ - {"id": f"m{idx}", **m} - for idx, m in enumerate(agenda_milestones) - ] - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - - # Resolve form schema: - # 1. If fields_json has any fields, use that. - # 2. Else if template_id is set and valid, deep-copy that template's fields. - # 3. Else default to DEFAULT_FORM_SCHEMA. - fields = _coerce_fields_json(fields_json) - template_obj: FormTemplate | None = None - if not fields: - if template_id and template_id.isdigit(): - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == int(template_id)).first() - ) - if template_obj is not None: - fields = deepcopy(template_obj.fields()) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - - now = _utcnow() - workshop = Workshop( - name=name, - created_at=now, - expires_at=now + timedelta(hours=ttl_hours), - admin_token=generate_admin_token(), - participant_slug=generate_participant_slug(), - milestone_config_json=json.dumps(parsed), - archived=False, - form_template_id=template_obj.id if template_obj else None, - form_schema_json=json.dumps(fields), - ) - db.add(workshop) - - # Optionally save the schema as a new named template. - if save_as_template and template_name.strip(): - new_tpl = FormTemplate( - name=template_name.strip()[:120], - created_at=now, - fields_json=json.dumps(fields), - ) - db.add(new_tpl) - # No flush here — commit at the end. - - db.commit() - db.refresh(workshop) - return RedirectResponse(url=f"/admin/{workshop.admin_token}", status_code=303) - - -# --- Phase 4: global template library --- - - -@app.get("/admin/templates", response_class=HTMLResponse) -def admin_templates_index( - request: Request, db: Session = Depends(get_db) -) -> HTMLResponse: - """List all saved form templates.""" - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - # Annotate with workshop usage counts. - for t in templates_list: - t._usage_count = ( - db.query(Workshop).filter(Workshop.form_template_id == t.id).count() - ) - t._fields_summary = ", ".join( - f.get("label", "?") for f in t.fields() - )[:200] - return _render(request, "admin_templates.html", templates_list=templates_list) - - -@app.get("/admin/templates/{tid}", response_class=HTMLResponse) -def admin_templates_edit( - request: Request, tid: int, db: Session = Depends(get_db) -) -> HTMLResponse: - """Edit a single template's schema. Edits template — never touches existing workshop snapshots.""" - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - schema = template_obj.fields() - return _render( - request, - "admin_template_edit.html", - template_obj=template_obj, - form_schema=schema, - form_schema_json_str=json.dumps(schema), - ) - - -@app.post("/admin/templates/{tid}") -def admin_templates_save( - request: Request, - tid: int, - name: str = Form(""), - fields_json: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - if name.strip(): - template_obj.name = name.strip()[:120] - fields = _coerce_fields_json(fields_json) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - template_obj.fields_json = json.dumps(fields) - db.commit() - return RedirectResponse(url=f"/admin/templates/{tid}", status_code=303) - - -@app.post("/admin/templates/{tid}/delete") -def admin_templates_delete( - request: Request, tid: int, db: Session = Depends(get_db) -) -> RedirectResponse: - """Delete a template. Existing workshops' snapshots remain intact.""" - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - db.delete(template_obj) - db.commit() - return RedirectResponse(url="/admin/templates", status_code=303) - - -# --- Admin: dashboard --- - -@app.get("/admin/{admin_token}", response_class=HTMLResponse) -def admin_dashboard( - request: Request, admin_token: str, db: Session = Depends(get_db), page: int = 1 -) -> HTMLResponse: - workshop = require_workshop_by_admin_token(db, admin_token) - milestones = workshop.milestones() - participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - rows = [_participant_progress(p, milestones) for p in participants] - - # Help requests: latest 20 per page (0-indexed), with ?page=N navigation. - PAGE_SIZE = 20 - page = max(1, page) - offset = (page - 1) * PAGE_SIZE - total_help = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .count() - ) - help_requests = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .order_by(desc(HelpRequest.created_at)) - .offset(offset) - .limit(PAGE_SIZE) - .all() - ) - has_more_help = (offset + len(help_requests)) < total_help - # Per-milestone completion stats across all participants. - stats: list[dict] = [] - for m in milestones: - c = ( - db.query(MilestoneCompletion) - .join(Participant, MilestoneCompletion.participant_id == Participant.id) - .filter( - Participant.workshop_id == workshop.id, - MilestoneCompletion.milestone_id == m["id"], - ) - .count() - ) - stats.append({**m, "count": c, "pct": int(round(100 * c / max(len(participants), 1)))}) - # Cohort stacked-bar: how many participants have completed 0, 1, 2, ... milestones? - COHORT_COLORS = ["#ef6a6a", "#d68c44", "#e4c44a", "#44d39a", "#7aa2ff", "#b07fdb", "#ec8898", "#88d4ab"] - total = len(participants) - done_counts: dict[int, int] = {} - for r in rows: - k = r["completed_count"] - done_counts[k] = done_counts.get(k, 0) + 1 - max_milestones = len(milestones) - segments = [] - for k in range(max_milestones + 1): - cnt = done_counts.get(k, 0) - if cnt > 0 or k == 0: - segments.append({ - "label": f"{k} done" if k < max_milestones else "all done", - "count": cnt, - "color": COHORT_COLORS[k % len(COHORT_COLORS)], - }) - # Always show at least one segment - if not segments and total == 0: - segments = [{"label": "0 done", "count": 0, "color": COHORT_COLORS[0]}] - total_done = sum(r["completed_count"] for r in rows) - cohort_bar = { - "segments": segments, - "total": total, - "pct": int(round(100 * total_done / max(total * max(1, max_milestones), 1))), - } - # Define category colors for milestone badges - category_colors = { - 'setup': '#7aa2ff', - 'learning': '#44d39a', - 'hands_on': '#e4c44a', - 'break': '#d68c44', - 'assessment': '#b07fdb', - 'wrap_up': '#ec8898', - } - return _render( - request, - "admin_dashboard.html", - workshop=workshop, - rows=rows, - milestones=milestones, - stats=stats, - help_requests=help_requests, - participant_count=len(participants), - cohort_bar=cohort_bar, - help_statuses=list(HELP_STATUSES), - help_page=page, - has_more_help=has_more_help, - category_colors=category_colors, - ) - - -# --- Admin: edit workshop --- - -@app.get("/admin/{admin_token}/edit", response_class=HTMLResponse) -def admin_edit_form( - request: Request, admin_token: str, db: Session = Depends(get_db) -) -> HTMLResponse: - workshop = require_workshop_by_admin_token(db, admin_token) - milestones = workshop.milestones() - # Build textarea text: "title: description" per line - milestones_text = "\n".join( - f"{m['title']}: {m['description']}" if m.get("description") else m["title"] - for m in milestones - ) - # TTL as hours from now (at least 1) — normalize to naive for subtraction - now = _utcnow() - if now.tzinfo is not None: - now = now.replace(tzinfo=None) - exp = workshop.expires_at - if exp.tzinfo is not None: - exp = exp.replace(tzinfo=None) - ttl_hours = max(1, round((exp - now).total_seconds() / 3600)) - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - agenda_templates = ( - db.query(AgendaTemplate).order_by(AgendaTemplate.created_at.asc()).all() - ) - return _render( - request, - "admin_edit.html", - workshop=workshop, - milestones_text=milestones_text, - ttl_hours=ttl_hours, - form_schema=workshop.form_schema(), - form_schema_json_str=json.dumps(workshop.form_schema()), - form_template_id=workshop.form_template_id, - templates_list=templates_list, - agenda_templates=agenda_templates, - help_tips_text=workshop.help_tips(), - ) - - -@app.post("/admin/{admin_token}/edit") -def admin_edit_save( - request: Request, - admin_token: str, - name: str = Form(...), - milestones: str = Form(""), - ttl_hours: int = Form(8), - fields_json: str = Form(""), - agenda_template_id: str = Form(""), - help_tips_text: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - workshop = require_workshop_by_admin_token(db, admin_token) - workshop.name = (name or "").strip() or workshop.name - - # Resolve milestones: use _parse_milestones (textarea) OR load from agenda template. - parsed: list[dict] - if agenda_template_id and agenda_template_id.isdigit(): - agenda_tpl = ( - db.query(AgendaTemplate) - .filter(AgendaTemplate.id == int(agenda_template_id)) - .first() - ) - if agenda_tpl is not None: - agenda_milestones = agenda_tpl.milestones() - if agenda_milestones: - parsed = [ - {"id": f"m{idx}", **m} - for idx, m in enumerate(agenda_milestones) - ] - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - - workshop.milestone_config = json.dumps(parsed) - workshop.expires_at = _utcnow() + timedelta(hours=max(1, min(ttl_hours, 168))) - - fields = _coerce_fields_json(fields_json) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - workshop.form_schema_json = json.dumps(fields) - # Save Phase 6 help tips. - workshop.help_tips_json = (help_tips_text or "").strip() - # NOTE: editing a workshop does NOT rewrite form_template_id. The template - # is a starting point; the snapshot is what the join page uses. - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - -# --- Admin: export CSV --- - -@app.get("/admin/{admin_token}/export.csv") -def admin_export_csv( - admin_token: str, - db: Session = Depends(get_db), -): - """Stream a CSV of all participants, completions, help requests, and form answers. - - Columns: - - core (always): participant_name, joined_at, milestone_title, - completed_at, help_message, help_created_at - - per-form-field (Phase 4): one column per schema key, in schema order, - named `field_` for clarity. - - Rows: one row per (participant, milestone). If a participant has no - completions but has help requests, we emit one row with empty milestone - columns and one row per help request. Helper-extra help requests beyond - the milestone count are emitted as additional rows with blank milestone - columns. - """ - workshop = require_workshop_by_admin_token(db, admin_token) - schema = workshop.form_schema() - form_keys = _form_keys(schema) - - buffer = io.StringIO() - writer = csv.writer(buffer) - header = [ - "participant_name", - "joined_at", - "milestone_title", - "completed_at", - "help_message", - "help_created_at", - ] + [f"field_{k}" for k in form_keys] - writer.writerow(header) - - participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - for p in participants: - answers = p.answers() - # Per-participant column tuple: empty unless row is tied to a milestone slot. - help_reqs = sorted(p.help_requests, key=lambda h: h.created_at) - if not p.completions and not help_reqs: - row = [p.name, p.joined_at.isoformat(), "", "", "", ""] - for k in form_keys: - row.append(answers.get(k, "")) - writer.writerow(row) - continue - comp_by_mid = {c.milestone_id: c for c in p.completions} - all_mids = sorted(comp_by_mid.keys()) - all_help_idx = 0 - for mid in all_mids: - c = comp_by_mid[mid] - help_msg = "" - help_ts = "" - if all_help_idx < len(help_reqs): - h = help_reqs[all_help_idx] - help_msg = h.message - help_ts = h.created_at.isoformat() - all_help_idx += 1 - row = [ - p.name, - p.joined_at.isoformat(), - c.milestone_title, - c.completed_at.isoformat(), - help_msg, - help_ts, - ] - for k in form_keys: - row.append(answers.get(k, "")) - writer.writerow(row) - while all_help_idx < len(help_reqs): - h = help_reqs[all_help_idx] - row = [p.name, p.joined_at.isoformat(), "", "", h.message, h.created_at.isoformat()] - for k in form_keys: - row.append(answers.get(k, "")) - writer.writerow(row) - all_help_idx += 1 - - buffer.seek(0) - ts = _utcnow().strftime("%Y%m%d-%H%M%S") - filename = f"workshop-{workshop.name}-{ts}.csv" - return StreamingResponse( - iter([buffer.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) - - -# --- Admin: clone workshop --- - -@app.post("/admin/{admin_token}/clone") -def admin_clone( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> RedirectResponse: - """Create a new workshop with the same milestone config but fresh tokens.""" - src = require_workshop_by_admin_token(db, admin_token) - src_milestones = src.milestones() - src_schema = src.form_schema() - - # Compute TTL: use the same default (8h) rather than copying the absolute - # expiry timestamp, so a 10-minute-old workshop still gives full 8h. - DEFAULT_TTL_HOURS = 8 - now = _utcnow() - clone = Workshop( - name=src.name, - created_at=now, - expires_at=now + timedelta(hours=DEFAULT_TTL_HOURS), - admin_token=generate_admin_token(), - participant_slug=generate_participant_slug(), - milestone_config_json=json.dumps(src_milestones), - archived=False, - # Snapshot the form schema; do NOT carry the template id (a clone - # is a fresh workshop — if the user later edits the snapshot, we - # don't muddle authorship with the original template). - form_template_id=None, - form_schema_json=json.dumps(src_schema), - ) - db.add(clone) - db.commit() - return RedirectResponse(url=f"/admin/{clone.admin_token}", status_code=303) - - -# --- Admin: archive workshop --- - -@app.post("/admin/{admin_token}/archive") -def admin_archive( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> RedirectResponse: - """Soft-delete: mark workshop as archived, redirects back to dashboard.""" - workshop = require_workshop_by_admin_token(db, admin_token) - workshop.archived = True - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - -# --- Admin: per-participant drill-down --- - -@app.get("/admin/{admin_token}/participant/{pid}", response_class=HTMLResponse) -def admin_participant_drilldown( - request: Request, - admin_token: str, - pid: int, - db: Session = Depends(get_db), -) -> HTMLResponse: - """Full timeline for one participant: completions + help requests. - - Phase 4: also surfaces the captured join-form answers as a 'Join inputs' - panel, falling back to a sensible default if answers are missing. - """ - workshop = require_workshop_by_admin_token(db, admin_token) - participant = find_participant(db, workshop.id, pid) - if participant is None: - raise HTTPException(status_code=404, detail="Participant not found") - completions = ( - db.query(MilestoneCompletion) - .filter(MilestoneCompletion.participant_id == pid) - .order_by(MilestoneCompletion.completed_at.asc()) - .all() - ) - help_reqs = ( - db.query(HelpRequest) - .filter(HelpRequest.participant_id == pid) - .order_by(HelpRequest.created_at.asc()) - .all() - ) - schema = workshop.form_schema() - answers = participant.answers() - # Render the answers panel — ordered by schema when possible. - # Pass pre-built (label, value) tuples to the template for simplicity. - answers_panel: list[tuple[str, str]] = [] - for f in schema: - v = answers.get(f.get("key", ""), "") - label = f.get("label") or f.get("key") or "" - answers_panel.append((label, str(v))) - # If schema is empty but participant has answers, render them raw. - if not schema and answers: - for k, v in answers.items(): - answers_panel.append((k, str(v))) - return _render( - request, - "participant_drilldown.html", - workshop=workshop, - participant=participant, - completions=completions, - help_requests=help_reqs, - answers_panel=answers_panel, - ) - - -# --- Local workshops index (Phase 3) --- - -@app.get("/workshops", response_class=HTMLResponse) -def workshops_index(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: - items = ( - db.query(Workshop).order_by(desc(Workshop.created_at)).limit(50).all() - ) - # Attach participant counts. - for w in items: - w._participant_count = ( - db.query(Participant) - .filter(Participant.workshop_id == w.id) - .count() - ) - return _render(request, "workshops_index.html", items=items) - - -# --- Participant: join --- - -@app.get("/w/{slug}", response_class=HTMLResponse) -def participant_join( - request: Request, - slug: str, - db: Session = Depends(get_db), - wid: str | None = None, -) -> HTMLResponse: - workshop = find_workshop_by_slug(db, slug) - if workshop is None: - raise HTTPException(status_code=404, detail="Workshop not found") - if workshop.archived: - return _render(request, "workshop_archived.html", workshop=workshop) - if workshop.is_expired(): - return _render(request, "workshop_expired.html", workshop=workshop) - - # If `wid` cookie maps to a participant in this workshop, fast-path to tracker. - existing_pid: int | None = None - cookie_wid = request.cookies.get(PARTICIPANT_COOKIE) - if cookie_wid is not None: - try: - pid = int(cookie_wid) - p = find_participant(db, workshop.id, pid) - if p is not None: - existing_pid = pid - except ValueError: - pass - - if existing_pid is not None: - return RedirectResponse(url=f"/w/{slug}/me", status_code=303) - - schema = workshop.form_schema() - name_field = _display_name_field(schema) - # Convenient view for the template: list of form fields to render. - return _render( - request, - "participant_join.html", - workshop=workshop, - form_schema=schema, - name_field=name_field, - ) - - -@app.post("/w/{slug}") -def participant_register( - request: Request, - slug: str, - # Sentinel Form(...) parameters ensure FastAPI parses the multipart body - # into request._form. We re-read it inside the handler so we can iterate - # over the *arbitrary* set of field inputs the workshop schema defines. - name: str = Form(""), - db: Session = Depends(get_db), -): - workshop = require_workshop_by_slug(db, slug) - if workshop.is_expired(): - return _render(request, "workshop_expired.html", workshop=workshop) - - schema = workshop.form_schema() - - # The display name comes from the canonical 'display_name' field, OR the - # first schema field, OR the legacy `name` POST. This makes Phase 4 - # backwards-compatible with Phase 1-3 join-page submissions. - name_field = _display_name_field(schema) - name_field_key = name_field.get("key") if name_field else "display_name" - - # We can't easily mix dynamic Form() params with a fixed signature, so - # we manually read the whole form body. FastAPI's `Depends` injection - # for Form(...) parses the body once into request._form; by using a - # hidden Form(...) sentinel below we ensure that has run before this - # code reads it. (Sentinel itself is discarded.) - form_data = getattr(request, "_form", None) - form: dict[str, str] = {} - if form_data is not None: - try: - form = {k: form_data.get(k) for k in form_data.keys()} - except Exception: - form = {} - - # Accept answers from the dynamic form. - answers = _collect_form_answers(schema, form) - - name = "" - if name_field_key in answers: - name = (answers.get(name_field_key) or "").strip() - if not name: - legacy = form.get("name") - if legacy is not None: - name = str(legacy).strip() - if not name: - for k, v in answers.items(): - v = (v or "").strip() - if v: - name = v - break - - if not name: - raise HTTPException(status_code=400, detail="Display name is required") - if len(name) > 120: - name = name[:120] - - # Persist the answers as JSON (the captured *inputs* — what they - # submitted), and ALSO write the canonical display name into the legacy - # participant.name column so dashboards keep working unchanged. - participant = Participant( - workshop_id=workshop.id, - name=name, - answers_json=json.dumps(answers) if answers else None, - ) - db.add(participant) - db.commit() - db.refresh(participant) - - response = RedirectResponse(url=f"/w/{slug}/me", status_code=303) - response.set_cookie( - key=PARTICIPANT_COOKIE, - value=str(participant.id), - max_age=60 * 60 * 12, # 12 hours — survives workshop length - httponly=True, - samesite="lax", - path=f"/w/{slug}", - ) - return response - - -# --- Participant: personal tracker --- - -def _resolve_participant( - request: Request, db: Session, slug: str -) -> tuple[Workshop, Participant]: - workshop = require_workshop_by_slug(db, slug) - if workshop.is_expired(): - raise HTTPException(status_code=404, detail="Workshop has ended") - cookie = request.cookies.get(PARTICIPANT_COOKIE) - if not cookie: - raise HTTPException(status_code=401, detail="Join the workshop first") - try: - pid = int(cookie) - except ValueError as exc: - raise HTTPException(status_code=401, detail="Invalid session") from exc - participant = find_participant(db, workshop.id, pid) - if participant is None: - raise HTTPException(status_code=401, detail="Join the workshop first") - return workshop, participant - - -@app.get("/w/{slug}/me", response_class=HTMLResponse) -def participant_me( - request: Request, slug: str, db: Session = Depends(get_db) -) -> HTMLResponse: - workshop, participant = _resolve_participant(request, db, slug) - milestones = workshop.milestones() - completed_ids: dict[str, datetime] = { - c.milestone_id: c.completed_at for c in participant.completions - } - - # Leaderboard: full list (template slices to 50 for render; JS toggles hidden rows). - all_participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - - help_recent = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .order_by(desc(HelpRequest.created_at)) - .limit(20) - .all() - ) - return _render( - request, - "participant_tracker.html", - workshop=workshop, - participant=participant, - milestones=milestones, - completed_ids=completed_ids, - leaderboard=_leaderboard_rows(all_participants, milestones, participant.id), - latest_help=help_recent, - help_statuses=list(HELP_STATUSES), - pending_message=None, - pending_suggestion=None, - ) - - -@app.post("/w/{slug}/me/complete/{milestone_id}") -def participant_complete( - request: Request, - slug: str, - milestone_id: str, - db: Session = Depends(get_db), -): - workshop, participant = _resolve_participant(request, db, slug) - milestone = next((m for m in workshop.milestones() if m["id"] == milestone_id), None) - if milestone is None: - raise HTTPException(status_code=404, detail="Milestone not found") - existing = ( - db.query(MilestoneCompletion) - .filter( - MilestoneCompletion.participant_id == participant.id, - MilestoneCompletion.milestone_id == milestone_id, - ) - .first() - ) - if existing is None: - completion = MilestoneCompletion( - participant_id=participant.id, - milestone_id=milestone_id, - milestone_title=milestone["title"], - ) - db.add(completion) - db.commit() - return RedirectResponse(url=f"/w/{slug}/me", status_code=303) - - -@app.post("/w/{slug}/me/help") -def participant_help( - request: Request, - slug: str, - message: str = Form(""), - step: str = Form("preview"), - llm_suggestion: str = Form(""), - db: Session = Depends(get_db), -): - """Two-step help-flag flow (Phase 6). - - Step 1 — preview (default): - Participant typed their message and hit Send. We try to draft an - LLM suggestion via OpenRouter. We render `participant_help_confirm.html` - with: - - the participant's original message (in a textarea, editable) - - the LLM suggestion (if any) so they can fold it in - - "Send it" / "Edit my message" buttons - - If the LLM call failed or isn't configured, we skip the suggestion and - just show a plain "Send this? Yes / Edit" confirmation. Same UX either - way — never blocks on the LLM. - - Step 2 — confirm (form posts back with step=confirm): - Saves the edited message to a HelpRequest and redirects to the - tracker (the regular /w//me page). - """ - workshop, participant = _resolve_participant(request, db, slug) - message = (message or "").strip() - if len(message) > 2000: - message = message[:2000] - - if step == "commit": - # Step 2 — persist the (possibly edited) help request. - if message: - req = HelpRequest( - participant_id=participant.id, - message=message, - status="open", - ) - db.add(req) - db.commit() - return RedirectResponse(url=f"/w/{slug}/me", status_code=303) - - # Step 1 — preview. Try the LLM, but never block the user if it fails. - suggestion: str | None = None - if message: - try: - suggestion = _llm_resolve( - message, - workshop.milestones(), - workshop.help_tips(), - ) - except Exception as exc: # belt-and-braces — _llm_resolve never raises - log.warning("LLM resolve raised: %s", exc) - suggestion = None - # Step 1 — preview. Render the tracker page with the suggestion inline. - # Re-use the same data the GET /w//me handler builds, scoped to - # this participant (not "latest across the workshop"). - milestones = workshop.milestones() - completed_ids_map: dict[str, datetime] = { - c.milestone_id: c.completed_at for c in participant.completions - } - all_participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - my_help = ( - db.query(HelpRequest) - .filter(HelpRequest.participant_id == participant.id) - .order_by(desc(HelpRequest.created_at)) - .limit(20) - .all() - ) - return _render( - request, - "participant_tracker.html", - workshop=workshop, - participant=participant, - milestones=milestones, - completed_ids=completed_ids_map, - latest_help=my_help, - leaderboard=_leaderboard_rows(all_participants, milestones, participant.id), - help_statuses=list(HELP_STATUSES), - pending_message=message, - pending_suggestion=(suggestion or "").strip(), - ) - - -# --- Phase 6: status-change routes (participant + admin) --- - - -def _normalize_status(raw: str | None) -> str: - s = (raw or "").strip().lower().replace("-", "_").replace(" ", "_") - if s not in HELP_STATUSES: - # Be conservative: unknown values fall back to 'open' rather than 400, - # so a stale client never locks a help request into a bad state. - return "open" - return s - - -@app.patch("/w/{slug}/me/help/{hid}/status") -def participant_help_status( - request: Request, - slug: str, - hid: int, - payload: dict = Body(default_factory=dict), - db: Session = Depends(get_db), -): - """Participant-owned status update. JSON body: {status: 'open'|...}.""" - workshop, participant = _resolve_participant(request, db, slug) - req = ( - db.query(HelpRequest) - .filter( - HelpRequest.id == hid, - HelpRequest.participant_id == participant.id, - ) - .first() - ) - if req is None: - raise HTTPException(status_code=404, detail="Help request not found") - new_status = _normalize_status((payload or {}).get("status")) - req.status = new_status - db.commit() - return {"ok": True, "id": req.id, "status": req.status} - - -@app.patch("/admin/{admin_token}/help/{hid}/status") -def admin_help_status( - admin_token: str, - hid: int, - payload: dict = Body(default_factory=dict), - db: Session = Depends(get_db), -): - """Admin can change any help-request status in this workshop.""" - workshop = require_workshop_by_admin_token(db, admin_token) - req = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(HelpRequest.id == hid, Participant.workshop_id == workshop.id) - .first() - ) - if req is None: - raise HTTPException(status_code=404, detail="Help request not found") - new_status = _normalize_status((payload or {}).get("status")) - req.status = new_status - db.commit() - return {"ok": True, "id": req.id, "status": req.status} - - -@app.get("/w/{slug}/data", response_class=JSONResponse) -def participant_poll( - request: Request, slug: str, since: str | None = None, db: Session = Depends(get_db) -): - """JSON endpoint polled by the participant tracker every ~4s.""" - workshop = find_workshop_by_slug(db, slug) - if workshop is None or workshop.archived: - raise HTTPException(status_code=404, detail="Workshop not found") - - milestones = workshop.milestones() - since_dt = None - if since: - try: - since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) - except ValueError: - since_dt = None - - all_participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - leaderboard_payload = [ - {"id": p.id, "name": p.name, "completed_count": len({c.milestone_id for c in p.completions}), "total": len(milestones), "pct": int(round(100 * len({c.milestone_id for c in p.completions}) / max(len(milestones), 1)))} - for p in all_participants - ] - - help_recent = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .order_by(desc(HelpRequest.created_at)) - .limit(20) - .all() - ) - help_payload = [] - for h in help_recent: - help_payload.append( - { - "id": h.id, - "participant_id": h.participant_id, - "participant_name": h.participant.name, - "message": h.message, - "created_at": h.created_at.isoformat(), - "status": h.status or "open", - } - ) - - return { - "ok": True, - "server_time": _utcnow().isoformat(), - "leaderboard": leaderboard_payload, - "help_requests": help_payload, - "milestones": milestones, - # Phase 4: include the form schema so the participant tracker can - # surface captured answers if/when we later add a per-participant - # view there. (No client-side reliance on this in Phase 4.) - "form_schema": getattr(db.query(Workshop).filter(Workshop.id == workshop.id).first(), "form_schema", lambda: [])(), - } - - -# --- Phase 4: form-template management (admin) --- - - -@app.get("/admin/{admin_token}/form", response_class=HTMLResponse) -def admin_form_edit( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> HTMLResponse: - """Edit the form schema for THIS workshop (the snapshot, not the template).""" - workshop = require_workshop_by_admin_token(db, admin_token) - schema = workshop.form_schema() - return _render( - request, - "admin_form.html", - workshop=workshop, - form_schema=schema, - form_schema_json_str=json.dumps(schema), - ) - - -@app.post("/admin/{admin_token}/form") -def admin_form_save( - request: Request, - admin_token: str, - fields_json: str = Form(""), - save_as_template: str = Form(""), - template_name: str = Form(""), - template_id: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - """Save the form schema snapshot, optionally (also) save as a new/updated template.""" - workshop = require_workshop_by_admin_token(db, admin_token) - fields = _coerce_fields_json(fields_json) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - workshop.form_schema_json = json.dumps(fields) - - template_obj: FormTemplate | None = None - if save_as_template and template_name.strip(): - # If template_id was passed, update it; else create a new one. - if template_id and template_id.isdigit(): - template_obj = ( - db.query(FormTemplate) - .filter(FormTemplate.id == int(template_id)) - .first() - ) - if template_obj is not None: - template_obj.name = template_name.strip()[:120] - template_obj.fields_json = json.dumps(fields) - else: - template_obj = FormTemplate( - name=template_name.strip()[:120], - created_at=_utcnow(), - fields_json=json.dumps(fields), - ) - db.add(template_obj) - db.flush() - workshop.form_template_id = template_obj.id - - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - -@app.get("/admin/{admin_token}/form-template", response_class=HTMLResponse) -def admin_form_template_pick( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> HTMLResponse: - """Pick a saved template to overwrite THIS workshop's snapshot.""" - workshop = require_workshop_by_admin_token(db, admin_token) - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - return _render( - request, - "admin_form_template.html", - workshop=workshop, - templates_list=templates_list, - form_schema=workshop.form_schema(), - ) - - -@app.post("/admin/{admin_token}/form-template/apply/{tid}") -def admin_form_template_apply( - request: Request, - admin_token: str, - tid: int, - db: Session = Depends(get_db), -) -> RedirectResponse: - """Replace the workshop's form-schema snapshot with a deep-copy of the template. - - IMPORTANT: historical workshops that already collected answers are - unaffected — only this workshop's JOIN page gets the new fields. - """ - workshop = require_workshop_by_admin_token(db, admin_token) - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - fresh_fields = deepcopy(template_obj.fields()) - if not fresh_fields: - fresh_fields = list(DEFAULT_FORM_SCHEMA) - workshop.form_schema_json = json.dumps(fresh_fields) - workshop.form_template_id = template_obj.id - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - - - -# --- Console: workshop creation wizard --- - -@app.get("/console", response_class=HTMLResponse) -def console_index(request: Request) -> HTMLResponse: - """Redirect to home page for now.""" - return RedirectResponse(url="/", status_code=303) - - -@app.get("/console/workshop/new", response_class=HTMLResponse) -def console_workshop_new_get( - request: Request, db: Session = Depends(get_db) -) -> HTMLResponse: - """Show the workshop creation wizard.""" - # Get agenda templates for the dropdown - agenda_templates = ( - db.query(AgendaTemplate) - .order_by(AgendaTemplate.created_at.asc()) - .all() - ) - return _render( - request, - "console_workshop_new.html", - agenda_templates=agenda_templates, - ) - - -@app.post("/console/workshop/new") -def console_workshop_new_post( - request: Request, - name: str = Form(""), - ttl_hours: str = Form(""), # note: comes as string because of custom field - ttl_hours_custom: str = Form(""), - agenda_template_id: str = Form(""), - kb_link: str = Form(""), - kb_title: str = Form(""), - milestones_json: str = Form(""), - fields_json: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - """Process the wizard form and create a workshop.""" - # Validate name - name = (name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="Workshop name is required") - - # Determine TTL (time to live) in hours - # If custom is selected, use the custom value; otherwise use the selected preset - try: - if ttl_hours == "custom": - hours = int(ttl_hours_custom or "8") - else: - hours = int(ttl_hours or "8") - except ValueError: - hours = 8 - # Clamp between 1 and 168 hours (1 week) - hours = max(1, min(168, hours)) - - # Parse milestones JSON (if provided) or use default from template - parsed_milestones: list[dict] - if agenda_template_id and agenda_template_id.isdigit(): - # Load the selected agenda template - agenda_tpl = ( - db.query(AgendaTemplate) - .filter(AgendaTemplate.id == int(agenda_template_id)) - .first() - ) - if agenda_tpl is not None: - template_milestones = agenda_tpl.milestones() - if template_milestones: - # Assign stable ids - parsed_milestones = [ - {"id": f"m{idx}", **m} - for idx, m in enumerate(template_milestones) - ] - else: - parsed_milestones = json.loads(milestones_json) if milestones_json else [] - else: - parsed_milestones = json.loads(milestones_json) if milestones_json else [] - else: - parsed_milestones = json.loads(milestones_json) if milestones_json else [] - - # If no milestones provided, use default - if not parsed_milestones: - parsed_milestones = [ - {"id": f"m{i}", **m} - for i, m in enumerate(DEFAULT_MILESTONE_CONFIG) - ] - - # Parse fields JSON - try: - fields = json.loads(fields_json) if fields_json else [] - except Exception: - fields = [] - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - - # Create the workshop - now = _utcnow() - workshop = Workshop( - name=name, - created_at=now, - expires_at=now + timedelta(hours=hours), - admin_token=generate_admin_token(), - participant_slug=generate_participant_slug(), - milestone_config_json=json.dumps(parsed_milestones), - archived=False, - form_template_id=None, # We are not using templates for now; the snapshot is stored directly - form_schema_json=json.dumps(fields), - kb_link=kb_link or None, - kb_title=kb_title or None, - ) - db.add(workshop) - db.commit() - db.refresh(workshop) - - # Redirect to the admin dashboard for the new workshop - return RedirectResponse(url=f"/admin/{workshop.admin_token}", status_code=303) diff --git a/src/models.py b/src/models.py deleted file mode 100644 index 895257d..0000000 --- a/src/models.py +++ /dev/null @@ -1,370 +0,0 @@ -"""SQLAlchemy ORM models for Workshop Helmsman (World-Class UX).""" - -from __future__ import annotations - -from datetime import datetime, timezone -from enum import Enum -from typing import Optional - -import json - -from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text, JSON -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from .db import Base - - -def utcnow() -> datetime: - return datetime.now(timezone.utc) - - -def utcnow_naive() -> datetime: - return datetime.now(timezone.utc).replace(tzinfo=None) - - -class MilestoneCategory(str, Enum): - SETUP = "setup" - CORE_LEARNING = "learning" - HANDS_ON = "hands_on" - BREAK = "break" - ASSESSMENT = "assessment" - WRAP_UP = "wrap_up" - - -class FormFieldType(str, Enum): - TEXT = "text" - EMAIL = "email" - DROPDOWN = "dropdown" - MULTI_SELECT = "multi_select" - FILE = "file" - URL = "url" - TEXTAREA = "textarea" - - -# Default milestone configuration for new workshops -DEFAULT_MILESTONE_CONFIG = [ - { - "id": "m0", - "title": "Setup", - "description": "Environment ready, repo cloned", - "duration_min": 30, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Verify everyone can run the starter", - }, - { - "id": "m1", - "title": "API Key", - "description": "LLM provider key configured", - "duration_min": 15, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Use env var, not hardcoded", - }, - { - "id": "m2", - "title": "First Build", - "description": "End-to-end hello world shipped", - "duration_min": 60, - "category": MilestoneCategory.HANDS_ON.value, - "help_tip": "Run the test suite after", - }, - { - "id": "m3", - "title": "Done", - "description": "Wrap-up and Q&A", - "duration_min": 15, - "category": MilestoneCategory.WRAP_UP.value, - "help_tip": "Capture feedback before they leave", - }, -] - -# Default form schema for new workshops -DEFAULT_FORM_SCHEMA = [ - {"key": "display_name", "type": "text", "label": "Full Name", "required": True}, - {"key": "email", "type": "email", "label": "Email Address", "required": False}, - {"key": "role", "type": "dropdown", "label": "Role", "required": False, "options": ["Student", "Developer", "Designer", "Manager", "Other"]}, - {"key": "company", "type": "text", "label": "Company", "required": False}, - {"key": "skill_level", "type": "dropdown", "label": "Skill Level", "required": False, "options": ["Beginner", "Intermediate", "Advanced"]}, - {"key": "team", "type": "text", "label": "Team", "required": False}, -] - - -# --- Phase 5: Agenda Templates --- - -DEFAULT_AGENDA_TEMPLATES = [ - { - "name": "Web Development Workshop", - "milestones": [ - { - "title": "Setup", - "description": "Install Node.js, clone repo, install dependencies", - "duration_min": 30, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Use nvm for Node version management", - }, - { - "title": "Frontend Basics", - "description": "Learn React components and state management", - "duration_min": 60, - "category": MilestoneCategory.CORE_LEARNING.value, - "help_tip": "Start with functional components", - }, - { - "title": "Build a Todo App", - "description": "Apply learning by building a todo application", - "duration_min": 90, - "category": MilestoneCategory.HANDS_ON.value, - "help_tip": "Use local storage for persistence", - }, - { - "title": "Break", - "description": "Coffee and networking", - "duration_min": 15, - "category": MilestoneCategory.BREAK.value, - "help_tip": "Stay hydrated!", - }, - { - "title": "Testing & Debugging", - "description": "Write unit tests and debug common issues", - "duration_min": 45, - "category": MilestoneCategory.ASSESSMENT.value, - "help_tip": "Use Jest and React Testing Library", - }, - { - "title": "Deployment", - "description": "Deploy the app to Vercel or Netlify", - "duration_min": 30, - "category": MilestoneCategory.WRAP_UP.value, - "help_tip": "Set up custom domain and environment variables", - }, - ], - }, - { - "name": "Data Science Bootcamp", - "milestones": [ - { - "title": "Environment Setup", - "description": "Install Python, Jupyter, and essential libraries", - "duration_min": 30, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Use conda or virtualenv", - }, - { - "title": "Data Wrangling", - "description": "Clean and explore datasets with Pandas", - "duration_min": 60, - "category": MilestoneCategory.CORE_LEARNING.value, - "help_tip": "Learn pandas DataFrame operations", - }, - { - "title": "Machine Learning Model", - "description": "Build and evaluate a predictive model", - "duration_min": 90, - "category": MilestoneCategory.HANDS_ON.value, - "help_tip": "Start with linear regression", - }, - { - "title": "Break", - "description": "Snack and stretch", - "duration_min": 10, - "category": MilestoneCategory.BREAK.value, - "help_tip": "Take a short walk", - }, - { - "title": "Model Evaluation", - "description": "Assess model performance and tune hyperparameters", - "duration_min": 45, - "category": MilestoneCategory.ASSESSMENT.value, - "help_tip": "Use cross-validation", - }, - { - "title": "Presentation", - "description": "Present findings and get feedback", - "duration_min": 30, - "category": MilestoneCategory.WRAP_UP.value, - "help_tip": "Use slides and tell a story", - }, - ], - }, -] - - -class FormTemplate(Base): - """A reusable named form schema for workshop join pages. - - `fields_json` is a JSON-encoded list of field dicts: - [{"key": "display_name", "type": "text"|"dropdown", - "label": "...", "placeholder": "..." (text only), - "required": bool, "options": ["A","B","C"] (dropdown only)}, ...] - - Saving a new Workshop records a *snapshot* of the schema on the workshop - itself (workshop.form_schema_json), so editing a template later never - mutates historical join-page data. - """ - - __tablename__ = "form_template" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - fields_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - - def fields(self) -> list[dict]: - import json - - try: - data = json.loads(self.fields_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - -class AgendaTemplate(Base): - """A reusable named agenda template (list of milestone configs). - - Each milestone has: ``title`` (required), ``description`` (optional), - and ``help_tip`` (optional — facilitator tip shown on tracker). - - A Workshop stores a *snapshot* of its milestones when created, so editing - a template later never mutates existing workshops. - """ - - __tablename__ = "agenda_template" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - milestones_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - - def milestones(self) -> list[dict]: - import json - - try: - data = json.loads(self.milestones_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - -class Workshop(Base): - __tablename__ = "workshop" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(200), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - admin_token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - participant_slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - # New fields for world-class UX - difficulty_level: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) - milestone_config_json: Mapped[str] = mapped_column(Text, nullable=False, default=json.dumps(DEFAULT_MILESTONE_CONFIG)) - form_schema_json: Mapped[str] = mapped_column(Text, nullable=False, default=json.dumps(DEFAULT_FORM_SCHEMA)) - kb_link: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - kb_title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) - # Existing fields that were missing - broadcast_message: Mapped[str] = mapped_column(Text, nullable=False, default="") - paused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - milestone_order_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - - # Relationships - form_template_id: Mapped[int | None] = mapped_column( - ForeignKey("form_template.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - participants: Mapped[list["Participant"]] = relationship(back_populates="workshop", cascade="all, delete-orphan") - - def is_expired(self) -> bool: - if self.expires_at is None: - return False - now = utcnow() - if now.tzinfo is not None: - now = now.replace(tzinfo=None) - exp = self.expires_at - if exp.tzinfo is not None: - exp = exp.replace(tzinfo=None) - return now > exp - - def milestones(self) -> list[dict]: - try: - data = json.loads(self.milestone_config_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - def ordered_milestones(self) -> list[dict]: - # For simplicity, we return milestones in the order they are stored. - # In the future, we might want to store an explicit order. - return self.milestones() - - def form_schema(self) -> list[dict]: - try: - data = json.loads(self.form_schema_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - -class Participant(Base): - __tablename__ = "participant" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - workshop_id: Mapped[int] = mapped_column( - ForeignKey("workshop.id", ondelete="CASCADE"), index=True - ) - name: Mapped[str] = mapped_column(String(120), nullable=False) - joined_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - answers_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - - workshop: Mapped["Workshop"] = relationship(back_populates="participants") - completions: Mapped[list["MilestoneCompletion"]] = relationship( - back_populates="participant", cascade="all, delete-orphan" - ) - help_requests: Mapped[list["HelpRequest"]] = relationship( - back_populates="participant", cascade="all, delete-orphan" - ) - - def answers(self) -> dict: - if not self.answers_json: - return {} - try: - data = json.loads(self.answers_json) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -class MilestoneCompletion(Base): - __tablename__ = "milestone_completion" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - participant_id: Mapped[int] = mapped_column( - ForeignKey("participant.id", ondelete="CASCADE"), index=True - ) - milestone_id: Mapped[str] = mapped_column(String(64), nullable=False) - milestone_title: Mapped[str] = mapped_column(String(200), nullable=False) - completed_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - - participant: Mapped["Participant"] = relationship(back_populates="completions") - - -class HelpRequest(Base): - __tablename__ = "help_request" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - participant_id: Mapped[int] = mapped_column( - ForeignKey("participant.id", ondelete="CASCADE"), index=True - ) - message: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - status: Mapped[str] = mapped_column( - SQLEnum("open", "on_hold", "resolved", name="help_status"), - default="open", - nullable=False, - ) - - participant: Mapped["Participant"] = relationship(back_populates="help_requests") - - -# Helper constants and functions -HELP_STATUSES = ("open", "on_hold", "resolved") \ No newline at end of file diff --git a/src/security.py b/src/security.py deleted file mode 100644 index 85b72c9..0000000 --- a/src/security.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Token + slug generation and participant-cookie helpers.""" - -from __future__ import annotations - -import secrets - -from fastapi import Cookie, HTTPException, status -from sqlalchemy.orm import Session - -from .models import Participant, Workshop - -PARTICIPANT_COOKIE = "wid" - - -def generate_admin_token() -> str: - """Facilitator dashboard URL token — high-entropy, URL-safe.""" - return secrets.token_urlsafe(32) - - -def generate_participant_slug() -> str: - """Shorter, share-friendly URL slug for participants.""" - # 6 bytes → ~8 url-safe chars; 16M possible values, ample for a workshop. - return secrets.token_urlsafe(6) - - -def find_workshop_by_admin_token(db: Session, token: str) -> Workshop | None: - return db.query(Workshop).filter(Workshop.admin_token == token).first() - - -def find_workshop_by_slug(db: Session, slug: str) -> Workshop | None: - return db.query(Workshop).filter(Workshop.participant_slug == slug).first() - - -def find_participant(db: Session, workshop_id: int, participant_id: int) -> Participant | None: - return ( - db.query(Participant) - .filter(Participant.id == participant_id, Participant.workshop_id == workshop_id) - .first() - ) - - -def require_workshop_by_admin_token(db: Session, token: str) -> Workshop: - w = find_workshop_by_admin_token(db, token) - if w is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workshop not found") - return w - - -def require_workshop_by_slug(db: Session, slug: str) -> Workshop: - w = find_workshop_by_slug(db, slug) - if w is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workshop not found") - if w.archived: - raise HTTPException(status_code=status.HTTP_410_GONE, detail="Workshop has been archived") - return w - - -def require_participant( - workshop_id: int, - wid: str | None = Cookie(default=None, alias=PARTICIPANT_COOKIE), -) -> int: - """Resolve the participant ID from the `wid` cookie. 401 if missing/invalid.""" - if not wid: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Join the workshop first") - try: - return int(wid) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fd4c456 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,100 @@ +"""Shared fixtures: isolated SQLite FILE per test (production driver), fresh app, +settings/engine/cache singletons reset around every test. + +TEST_ADMIN_KEY is a fixture value owned by this suite (set via env for each test) — +never the operator's real HELMSMAN_ADMIN_KEY from .env. +""" + +import pytest +from fastapi.testclient import TestClient + +TEST_ADMIN_KEY = "test-admin-key-0123456789" + + +def _reset_singletons() -> None: + import src.helmsman.config.settings as settings_module + from src.helmsman.db.session import reset_db_state + from src.helmsman.services.snapshots import clear_snapshot_cache + + settings_module._settings = None + reset_db_state() + clear_snapshot_cache() + + +@pytest.fixture(autouse=True) +def _isolated_env(monkeypatch, tmp_path): + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path}/helmsman-test.db") + monkeypatch.setenv("HELMSMAN_ADMIN_KEY", TEST_ADMIN_KEY) + monkeypatch.setenv("HELMSMAN_BASE_URL", "") + monkeypatch.setenv("HELMSMAN_LOG_LEVEL", "WARNING") + _reset_singletons() + yield + _reset_singletons() + + +@pytest.fixture +def make_app(): + """Build a fresh app over the CURRENT env (schema created once per DB file).""" + + def _make(): + from src.helmsman.api import create_app + from src.helmsman.db.models import Base + from src.helmsman.db.session import get_engine + + Base.metadata.create_all(get_engine()) + return create_app() + + return _make + + +@pytest.fixture +def app(make_app): + return make_app() + + +@pytest.fixture +def client(app): + return TestClient(app) + + +@pytest.fixture +def make_client(app): + """Fresh cookie jars over the same app — one per simulated browser/device.""" + + def _make(): + return TestClient(app) + + return _make + + +@pytest.fixture +def admin_headers(): + return {"X-Admin-Key": TEST_ADMIN_KEY} + + +WORKSHOP_BODY = { + "name": "LangGraph Lab — July", + "description_md": "Welcome! Bring a laptop.", + "milestones": [ + {"title": "Set up your environment", "content_md": "```bash\nuv sync\n```", "minutes": 30}, + {"title": "Configure the API key", "content_md": "Put it in `.env`.", "minutes": 15}, + {"title": "Run the first graph", "content_md": "Now run it.", "minutes": None}, + ], +} + + +@pytest.fixture +def workshop(client, admin_headers): + response = client.post("/api/admin/workshops", json=WORKSHOP_BODY, headers=admin_headers) + assert response.status_code == 200, response.text + return response.json()["data"]["workshop"] + + +@pytest.fixture +def join_participant(): + def _join(client_, join_slug: str, name: str) -> dict: + response = client_.post(f"/api/join/{join_slug}", json={"name": name}) + assert response.status_code == 200, response.text + return response.json()["data"] + + return _join diff --git a/tests/e2e/core-loop.spec.ts b/tests/e2e/core-loop.spec.ts new file mode 100644 index 0000000..d98044c --- /dev/null +++ b/tests/e2e/core-loop.spec.ts @@ -0,0 +1,459 @@ +import { + test, + expect, + type BrowserContext, + type Locator, + type Page, +} from '@playwright/test'; +import { APP_BASE, adminKey, extractUrl } from './helpers'; + +/** + * Phase 1 core live loop — one serial journey against the live single-origin + * app (spec/roadmap.md §Phase 1 gate paragraph): + * + * create workshop (3 milestones incl. a code block) → join as two + * participants in separate contexts → tracker renders styled markdown → + * mark complete → dashboard reflects it within one poll → help request → + * answer from queue → answer visible on tracker → participant resolves → + * browser reload restores state (cookie) → personal link opens identical + * state in a fresh context (cross-device) → labelled stubs are visible and + * non-interactive. + * + * Every run creates a FRESH workshop (timestamped name) — no pre-existing + * data is assumed. Live-update waits use polling assertions sized to one poll + * cycle (dashboard 2 s / tracker 3 s vs. 10 s expect timeout) — never sleeps. + */ + +const WORKSHOP_NAME = `E2E Core Loop ${Date.now()}`; + +const M1_TITLE = 'Set up your environment'; +const M2_TITLE = 'Call the health endpoint'; +const M3_TITLE = 'Ship it'; + +const M1_CONTENT = [ + 'Install the dependencies, then verify your shell works:', + '', + '```bash', + 'echo "hello"', + '```', + '', + 'If anything fails, read the [FastAPI docs](https://fastapi.tiangolo.com/) before asking for help.', +].join('\n'); +const M2_CONTENT = 'Use `curl` to hit `/api/health` and confirm the response says ok.'; +const M3_CONTENT = 'Push your branch and open a pull request.'; + +const HELP_MESSAGE = `I am getting a 401 from the API - what should I check? (run ${Date.now()})`; +const ANSWER_MD = [ + 'Check your `.env` file first:', + '', + '```bash', + 'grep API_KEY .env', + '```', + '', + 'Then restart the server.', +].join('\n'); + +let facCtx: BrowserContext; +let priyaCtx: BrowserContext; +let marcoCtx: BrowserContext; +let deviceCtx: BrowserContext; +let fac: Page; +let priya: Page; +let marco: Page; +let device3: Page; + +let joinUrl = ''; +let dashboardUrl = ''; +let personalUrl = ''; + +const uncaughtErrors: Array<{ page: string; message: string }> = []; + +/** + * A completed milestone's toggle must expose real checked semantics + * (capabilities.md design system: semantic HTML, keyboard-reachable). + * Accepts a native checkbox (the toggle itself or a descendant), aria-checked, + * or aria-pressed. + */ +async function expectToggleCompleted(milestoneItem: Locator): Promise { + const toggle = milestoneItem.getByTestId('milestone-toggle'); + await expect(async () => { + const ariaChecked = await toggle.getAttribute('aria-checked'); + const ariaPressed = await toggle.getAttribute('aria-pressed'); + const selfChecked = await toggle.isChecked().catch(() => null); + const innerBox = toggle.locator('input[type="checkbox"]').first(); + const innerChecked = (await innerBox.count()) > 0 ? await innerBox.isChecked() : null; + const completed = + ariaChecked === 'true' || ariaPressed === 'true' || selfChecked === true || innerChecked === true; + if (!completed) { + throw new Error('milestone-toggle does not report a completed/checked state'); + } + }).toPass({ timeout: 10_000 }); +} + +/** Priya has 1 of 3 done — accept "1 / 3" text, a 33% bar, or aria-valuenow. */ +async function expectProgressOneOfThree(page: Page): Promise { + const bar = page.getByTestId('progress-bar').first(); + await expect(async () => { + const barText = (await bar.textContent().catch(() => '')) ?? ''; + const aria = await bar.getAttribute('aria-valuenow').catch(() => null); + const headerText = await page.getByTestId('tracker-page').innerText(); + const ok = + /1\s*\/\s*3|\b33\b/.test(barText) || + (aria !== null && [1, 33].includes(Math.round(Number(aria)))) || + /1\s*\/\s*3/.test(headerText); + if (!ok) throw new Error('progress does not reflect 1 / 3 yet'); + }).toPass({ timeout: 10_000 }); +} + +test.describe.serial('Phase 1 — core live loop', () => { + test.beforeAll(async ({ browser }) => { + const mk = async (label: string): Promise<{ ctx: BrowserContext; page: Page }> => { + const ctx = await browser.newContext({ baseURL: APP_BASE }); + ctx.setDefaultTimeout(15_000); + ctx.setDefaultNavigationTimeout(30_000); + const page = await ctx.newPage(); + page.on('pageerror', (err) => uncaughtErrors.push({ page: label, message: String(err) })); + return { ctx, page }; + }; + ({ ctx: facCtx, page: fac } = await mk('facilitator')); + ({ ctx: priyaCtx, page: priya } = await mk('priya')); + ({ ctx: marcoCtx, page: marco } = await mk('marco')); + ({ ctx: deviceCtx, page: device3 } = await mk('third-device')); + }); + + test.afterAll(async () => { + await Promise.all( + [facCtx, priyaCtx, marcoCtx, deviceCtx].filter(Boolean).map((ctx) => ctx.close()), + ); + }); + + test('facilitator signs in, creates a 3-milestone workshop, opens the live dashboard', async () => { + await fac.goto(`${APP_BASE}/`); + await fac.getByTestId('admin-key-input').fill(adminKey()); + await fac.getByTestId('admin-key-submit').click(); + await expect(fac.getByTestId('new-workshop-button')).toBeVisible(); + + await fac.getByTestId('new-workshop-button').click(); + await fac.getByTestId('workshop-name-input').fill(WORKSHOP_NAME); + + // Add milestone editor rows until there are exactly three. + const titleInputs = fac.getByTestId('milestone-title-input'); + for (let i = 0; i < 4 && (await titleInputs.count()) < 3; i += 1) { + await fac.getByTestId('add-milestone-button').click(); + } + await expect(titleInputs).toHaveCount(3); + + const contentInputs = fac.getByTestId('milestone-content-input'); + const rows: Array<[string, string]> = [ + [M1_TITLE, M1_CONTENT], + [M2_TITLE, M2_CONTENT], + [M3_TITLE, M3_CONTENT], + ]; + for (const [i, [title, content]] of rows.entries()) { + await titleInputs.nth(i).fill(title); + await contentInputs.nth(i).fill(content); + } + + const created = fac.waitForResponse( + (r) => r.url().includes('/api/admin/workshops') && r.request().method() === 'POST', + ); + await fac.getByTestId('create-workshop-submit').click(); + expect((await created).ok(), 'POST /api/admin/workshops must succeed').toBeTruthy(); + + // The workshop card (with both share links) must be reachable — either the + // app returns to the list itself, or a reload of Admin Home shows it. + const card = fac.getByTestId('workshop-card').filter({ hasText: WORKSHOP_NAME }).first(); + await expect(async () => { + if (!(await card.isVisible())) { + await fac.goto(`${APP_BASE}/`); + } + await expect(card).toBeVisible({ timeout: 4000 }); + }).toPass({ timeout: 30_000 }); + + joinUrl = extractUrl(await card.getByTestId('join-link').innerText(), '/j/'); + dashboardUrl = extractUrl(await card.getByTestId('facilitator-link').innerText(), '/f/'); + expect(joinUrl).toMatch(/\/j\/[A-Za-z0-9_-]{6,}$/); + expect(dashboardUrl).toMatch(/\/f\/[A-Za-z0-9_-]{20,}$/); + + // Pretty facilitator link redirects into the static app, dashboard loads. + await fac.goto(dashboardUrl); + await expect(fac).toHaveURL(/\/app\/f\/\?t=[A-Za-z0-9_-]+/); + const dashboard = fac.getByTestId('dashboard-page'); + await expect(dashboard).toBeVisible(); + await expect(dashboard).toContainText(WORKSHOP_NAME); + await expect(fac.getByTestId('milestone-stat')).toHaveCount(3); + // Milestone titles appear on the dashboard (per-milestone completion bars, + // capabilities.md C3). The testid contract only guarantees the completion + // COUNT lives inside milestone-stat, so titles are asserted page-level. + await expect(dashboard).toContainText(M1_TITLE); + await expect(dashboard).toContainText(M2_TITLE); + await expect(dashboard).toContainText(M3_TITLE); + // Help-queue empty state (capabilities.md C4) before anyone asks. + await expect(fac.getByTestId('help-queue')).toContainText(/no open help requests/i); + // Dashboard stays open from here on — later tests assert its live updates. + }); + + test('Priya joins via the pretty link and sees rendered milestone markdown', async () => { + await priya.goto(joinUrl); + await expect(priya).toHaveURL(/\/app\/join\/\?s=[A-Za-z0-9_-]+/); + await expect(priya.getByText(WORKSHOP_NAME).first()).toBeVisible(); + + await priya.getByTestId('join-name-input').fill('Priya'); + const joined = priya.waitForResponse( + (r) => /\/api\/join\/[^/?]+$/.test(r.url().split('?')[0]) && r.request().method() === 'POST', + ); + await priya.getByTestId('join-submit').click(); + expect((await joined).ok(), 'POST /api/join/{slug} must succeed').toBeTruthy(); + + await expect(priya.getByTestId('tracker-page')).toBeVisible({ timeout: 15_000 }); + // Client-side navigation to the tracker route — `t` may sit anywhere in + // the query string (only server 307s have a spec-exact URL shape). + await expect(priya).toHaveURL(/\/app\/p\/.*[?&]t=[A-Za-z0-9_-]+/); + + const items = priya.getByTestId('milestone-item'); + await expect(items).toHaveCount(3); + await expect(items.nth(0)).toContainText(M1_TITLE); + await expect(items.nth(1)).toContainText(M2_TITLE); + await expect(items.nth(2)).toContainText(M3_TITLE); + + // Milestone 1 is the current milestone (auto-expanded): its markdown body + // must be RENDERED — a highlighted
      , not raw backticks.
      +    const codeBlock = items.first().locator('pre code');
      +    await expect(codeBlock).toBeVisible();
      +    await expect(codeBlock).toContainText('echo "hello"');
      +    await expect(codeBlock).toHaveClass(/language-|hljs/);
      +    await expect(items.first()).not.toContainText('```');
      +
      +    const docsLink = items.first().getByRole('link', { name: 'FastAPI docs' });
      +    await expect(docsLink).toBeVisible();
      +    await expect(docsLink).toHaveAttribute('href', 'https://fastapi.tiangolo.com/');
      +
      +    // Help panel empty state (capabilities.md C4).
      +    await expect(priya.getByTestId('tracker-page')).toContainText(/ask here/i);
      +
      +    // Capture the cross-device personal link now (the callout is one-time).
      +    await expect(priya.getByTestId('personal-link')).toBeVisible();
      +    personalUrl = extractUrl(await priya.getByTestId('personal-link').innerText(), '/p/');
      +    expect(personalUrl).toMatch(/\/p\/[A-Za-z0-9_-]{16,}$/);
      +  });
      +
      +  test('Marco joins in a second context; the room updates live everywhere', async () => {
      +    await marco.goto(joinUrl);
      +    await expect(marco).toHaveURL(/\/app\/join\/\?s=[A-Za-z0-9_-]+/);
      +    await marco.getByTestId('join-name-input').fill('Marco');
      +    const joined = marco.waitForResponse(
      +      (r) => /\/api\/join\/[^/?]+$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await marco.getByTestId('join-submit').click();
      +    expect((await joined).ok(), 'POST /api/join/{slug} must succeed').toBeTruthy();
      +    await expect(marco.getByTestId('tracker-page')).toBeVisible({ timeout: 15_000 });
      +    await expect(marco.getByTestId('milestone-item')).toHaveCount(3);
      +
      +    // Both participants appear on every surface within one poll cycle.
      +    await expect(marco.getByTestId('leaderboard')).toContainText('Priya');
      +    await expect(priya.getByTestId('leaderboard')).toContainText('Marco', { timeout: 15_000 });
      +    await expect(fac.getByTestId('participant-row')).toHaveCount(2, { timeout: 15_000 });
      +    await expect(fac.getByTestId('participant-row').filter({ hasText: 'Priya' })).toHaveCount(1);
      +    await expect(fac.getByTestId('participant-row').filter({ hasText: 'Marco' })).toHaveCount(1);
      +  });
      +
      +  test("Priya's completion moves her progress bar and the live dashboard within one poll", async () => {
      +    // Before: nobody has completed milestone 1.
      +    const firstStat = fac.getByTestId('milestone-stat').first();
      +    await expect(firstStat).toContainText(/\b0\b/);
      +    const statBefore = (await firstStat.innerText()).replace(/\s+/g, ' ').trim();
      +
      +    const firstItem = priya.getByTestId('milestone-item').first();
      +    const completed = priya.waitForResponse(
      +      (r) =>
      +        /\/milestones\/\d+\/complete$/.test(r.url().split('?')[0]) &&
      +        r.request().method() === 'POST',
      +    );
      +    await firstItem.getByTestId('milestone-toggle').click();
      +    expect((await completed).ok(), 'complete endpoint must succeed').toBeTruthy();
      +
      +    await expectProgressOneOfThree(priya);
      +    await expectToggleCompleted(firstItem);
      +
      +    // Live dashboard (already open, 2 s poll): the first milestone stat's text
      +    // CHANGES and now carries the completion ("1" count and/or "50%"). Change
      +    // detection guards against a "1" that was already present before the
      +    // completion (e.g. a "1." position marker in the stat row).
      +    await expect(async () => {
      +      const now = (await firstStat.innerText()).replace(/\s+/g, ' ').trim();
      +      expect(now).not.toBe(statBefore);
      +      expect(now).toMatch(/\b1\b|50/);
      +    }).toPass({ timeout: 10_000 });
      +    const priyaRow = fac.getByTestId('participant-row').filter({ hasText: 'Priya' }).first();
      +    await expect(priyaRow.getByTestId('participant-progress')).toContainText(/\b1\b|33/, {
      +      timeout: 10_000,
      +    });
      +  });
      +
      +  test('help request → facilitator markdown answer → resolve, live on both sides', async () => {
      +    // Priya asks for help.
      +    await priya.getByTestId('help-input').fill(HELP_MESSAGE);
      +    const helpPosted = priya.waitForResponse(
      +      (r) => /\/api\/p\/[^/]+\/help$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await priya.getByTestId('help-submit').click();
      +    expect((await helpPosted).ok(), 'POST …/help must succeed').toBeTruthy();
      +
      +    const myItem = priya.getByTestId('help-item').filter({ hasText: HELP_MESSAGE }).first();
      +    await expect(myItem).toBeVisible();
      +    await expect(myItem.getByTestId('help-status')).toContainText(/open|waiting/i);
      +
      +    // Queue card appears on the dashboard within one poll, with name, message
      +    // and her CURRENT milestone (milestone 2, after completing milestone 1).
      +    const queueItem = fac.getByTestId('help-queue-item').filter({ hasText: HELP_MESSAGE }).first();
      +    await expect(queueItem).toBeVisible({ timeout: 10_000 });
      +    await expect(queueItem).toContainText('Priya');
      +    await expect(queueItem).toContainText(M2_TITLE);
      +
      +    // Facilitator answers with markdown containing a fenced code block.
      +    await queueItem.getByTestId('help-answer-input').fill(ANSWER_MD);
      +    const answered = fac.waitForResponse(
      +      (r) => /\/help\/\d+\/answer$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await queueItem.getByTestId('help-answer-submit').click();
      +    expect((await answered).ok(), 'POST …/answer must succeed').toBeTruthy();
      +
      +    // Priya sees the RENDERED answer within one tracker poll.
      +    const answer = myItem.getByTestId('help-answer').first();
      +    await expect(answer).toBeVisible({ timeout: 10_000 });
      +    await expect(answer).toContainText('Check your');
      +    const answerCode = answer.locator('pre code');
      +    await expect(answerCode).toBeVisible();
      +    await expect(answerCode).toContainText('grep API_KEY .env');
      +    await expect(answer).not.toContainText('```');
      +    await expect(myItem.getByTestId('help-status')).toContainText(/answered/i);
      +
      +    // Priya resolves her own request.
      +    const resolved = priya.waitForResponse(
      +      (r) => /\/help\/\d+\/resolve$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await myItem.getByTestId('help-resolve').click();
      +    expect((await resolved).ok(), 'POST …/resolve must succeed').toBeTruthy();
      +    await expect(myItem.getByTestId('help-status')).toContainText(/resolved/i, { timeout: 10_000 });
      +
      +    // Dashboard reflects resolution within one poll: the card either shows a
      +    // resolved state or moves into the collapsed Resolved section (both are
      +    // spec-correct — capabilities.md collapses resolved requests).
      +    await expect(async () => {
      +      const item = fac.getByTestId('help-queue-item').filter({ hasText: HELP_MESSAGE }).first();
      +      const stillVisible = await item.isVisible().catch(() => false);
      +      if (stillVisible) {
      +        const text = (await item.innerText()).toLowerCase();
      +        if (!text.includes('resolved')) {
      +          throw new Error('help queue card is visible but not marked resolved yet');
      +        }
      +      }
      +    }).toPass({ timeout: 15_000 });
      +  });
      +
      +  test('cookie auto-resume: reopening the join link recognizes Priya', async () => {
      +    // Same context (cookie jar intact) — the join page must recognize her and
      +    // forward to the tracker instead of showing a fresh join form.
      +    await priya.goto(joinUrl);
      +    await expect(priya).toHaveURL(/\/app\/p\/.*[?&]t=[A-Za-z0-9_-]+/, { timeout: 15_000 });
      +    await expect(priya.getByTestId('tracker-page')).toBeVisible();
      +    await expect(priya.getByTestId('join-name-input')).toHaveCount(0);
      +    await expect(priya.getByTestId('milestone-item')).toHaveCount(3);
      +    await expectProgressOneOfThree(priya);
      +    await expectToggleCompleted(priya.getByTestId('milestone-item').first());
      +    await expect(priya.getByTestId('leaderboard')).toContainText('Priya');
      +  });
      +
      +  test('personal link opens identical state in a fresh third context (cross-device)', async () => {
      +    // Fresh context, zero cookies — the personal link IS the credential.
      +    await device3.goto(personalUrl);
      +    await expect(device3).toHaveURL(/\/app\/p\/\?t=[A-Za-z0-9_-]+/);
      +    await expect(device3.getByTestId('tracker-page')).toBeVisible({ timeout: 15_000 });
      +    await expect(device3.getByTestId('milestone-item')).toHaveCount(3);
      +
      +    await expectProgressOneOfThree(device3);
      +    await expectToggleCompleted(device3.getByTestId('milestone-item').first());
      +    await expect(device3.getByTestId('leaderboard')).toContainText('Priya');
      +    await expect(device3.getByTestId('leaderboard')).toContainText('Marco');
      +
      +    // The resolved help request travels with her, answer and all.
      +    const item = device3.getByTestId('help-item').filter({ hasText: HELP_MESSAGE }).first();
      +    await expect(item).toBeVisible();
      +    await expect(item.getByTestId('help-status')).toContainText(/resolved/i);
      +    const answerCode = item.getByTestId('help-answer').first().locator('pre code');
      +    await expect(answerCode).toBeVisible();
      +    await expect(answerCode).toContainText('grep API_KEY .env');
      +  });
      +
      +  test('reload restores full tracker state', async () => {
      +    await priya.reload();
      +    await expect(priya.getByTestId('tracker-page')).toBeVisible({ timeout: 15_000 });
      +    await expect(priya.getByTestId('milestone-item')).toHaveCount(3);
      +    await expectProgressOneOfThree(priya);
      +    await expect(priya.getByTestId('leaderboard')).toContainText('Marco');
      +    const item = priya.getByTestId('help-item').filter({ hasText: HELP_MESSAGE }).first();
      +    await expect(item).toBeVisible();
      +    await expect(item.getByTestId('help-status')).toContainText(/resolved/i);
      +  });
      +
      +  test('labelled stubs are visible and non-interactive on both live pages', async () => {
      +    const surfaces: Array<[string, Page, string]> = [
      +      ['dashboard', fac, 'dashboard-page'],
      +      ['tracker', priya, 'tracker-page'],
      +    ];
      +    for (const [label, page, rootId] of surfaces) {
      +      const labelled = page.getByTestId('stub-badge').filter({ hasText: /later phase/i });
      +      await expect(
      +        labelled.first(),
      +        `${label}: at least one stub-badge naming a later phase must be visible`,
      +      ).toBeVisible();
      +
      +      const errorsBefore = uncaughtErrors.length;
      +      const urlBefore = page.url();
      +      let navigated = false;
      +      const onNav = () => {
      +        navigated = true;
      +      };
      +      page.on('framenavigated', onNav);
      +      // Disabled/inert controls legitimately reject the click — that IS the
      +      // pass condition, so a rejection is swallowed.
      +      await labelled
      +        .first()
      +        .click({ force: true, timeout: 3000 })
      +        .catch(() => undefined);
      +      // Every control INSIDE the stub must be non-interactive too: disabled,
      +      // aria-disabled, or inert under a force-click.
      +      const controls = labelled
      +        .first()
      +        .locator('button, a, input, [role="button"], [role="switch"]');
      +      const controlCount = await controls.count();
      +      for (let i = 0; i < controlCount; i += 1) {
      +        const control = controls.nth(i);
      +        const disabled = await control.isDisabled().catch(() => false);
      +        const ariaDisabled =
      +          (await control.getAttribute('aria-disabled').catch(() => null)) === 'true';
      +        if (!disabled && !ariaDisabled) {
      +          await control.click({ force: true, timeout: 3000 }).catch(() => undefined);
      +        }
      +      }
      +      // Short observation window for the ABSENCE of navigation/errors after
      +      // the clicks (not a live-update wait — polling assertions cannot observe
      +      // "nothing happened").
      +      await page.waitForTimeout(750);
      +      page.off('framenavigated', onNav);
      +
      +      expect(navigated, `${label}: clicking a stub must not navigate`).toBe(false);
      +      expect(page.url(), `${label}: URL must be unchanged after clicking a stub`).toBe(urlBefore);
      +      expect(
      +        uncaughtErrors.length,
      +        `${label}: clicking a stub must not throw a page error`,
      +      ).toBe(errorsBefore);
      +      await expect(page.getByTestId(rootId)).toBeVisible();
      +    }
      +  });
      +
      +  test('no uncaught page errors across the whole journey', async () => {
      +    const detail = uncaughtErrors.map((e) => `[${e.page}] ${e.message}`).join('\n');
      +    expect(uncaughtErrors, detail || 'clean').toEqual([]);
      +  });
      +});
      diff --git a/tests/e2e/facilitator-command.spec.ts b/tests/e2e/facilitator-command.spec.ts
      new file mode 100644
      index 0000000..88d3476
      --- /dev/null
      +++ b/tests/e2e/facilitator-command.spec.ts
      @@ -0,0 +1,372 @@
      +import {
      +  test,
      +  expect,
      +  type BrowserContext,
      +  type Locator,
      +  type Page,
      +} from '@playwright/test';
      +import { APP_BASE, adminKey, extractUrl } from './helpers';
      +
      +/**
      + * Phase 2 — facilitator command & proactive intelligence (spec/roadmap.md
      + * §Phase 2 "How the user tests it" + spec/api.md "# Phase 2 endpoints").
      + *
      + * One serial journey against the live single-origin app:
      + *
      + *   create workshop → two participants join → broadcast + undo → pause +
      + *   resume → advance-all + undo → reorder → pulse/alerts go real → audit
      + *   trail lists every action, undone ones marked undone.
      + *
      + * Every run creates a FRESH workshop (timestamped name). Live-update waits
      + * use polling assertions sized to one poll cycle (dashboard 2 s / tracker
      + * 3 s vs. the 12 s expect timeout) — never fixed sleeps beyond that.
      + */
      +
      +const WORKSHOP_NAME = `E2E Facilitator Command ${Date.now()}`;
      +
      +const M1_TITLE = 'Set up your environment';
      +const M2_TITLE = 'Call the health endpoint';
      +const M3_TITLE = 'Ship it';
      +const M1_CONTENT = 'Install the dependencies, then verify your shell works.';
      +const M2_CONTENT = 'Use `curl` to hit `/api/health` and confirm the response says ok.';
      +const M3_CONTENT = 'Push your branch and open a pull request.';
      +
      +const BROADCAST_MD = [
      +  '**Lunch is in 10 minutes** — wrap up your current milestone.',
      +  '',
      +  '- Grab your badge',
      +  '- We resume at 1pm',
      +].join('\n');
      +
      +let facCtx: BrowserContext;
      +let aliceCtx: BrowserContext;
      +let bobCtx: BrowserContext;
      +let fac: Page;
      +let alice: Page;
      +let bob: Page;
      +
      +let joinUrl = '';
      +let dashboardUrl = '';
      +
      +const uncaughtErrors: Array<{ page: string; message: string }> = [];
      +
      +/** A stub card carries the "later phase" StubBadge; a real one never does. */
      +async function expectNotAStub(card: Locator): Promise {
      +  await expect(card.getByTestId('stub-badge').filter({ hasText: /later phase/i })).toHaveCount(0);
      +}
      +
      +test.describe.serial('Phase 2 — facilitator command & proactive intelligence', () => {
      +  test.beforeAll(async ({ browser }) => {
      +    const mk = async (label: string): Promise<{ ctx: BrowserContext; page: Page }> => {
      +      const ctx = await browser.newContext({ baseURL: APP_BASE });
      +      ctx.setDefaultTimeout(15_000);
      +      ctx.setDefaultNavigationTimeout(30_000);
      +      const page = await ctx.newPage();
      +      page.on('pageerror', (err) => uncaughtErrors.push({ page: label, message: String(err) }));
      +      return { ctx, page };
      +    };
      +    ({ ctx: facCtx, page: fac } = await mk('facilitator'));
      +    ({ ctx: aliceCtx, page: alice } = await mk('alice'));
      +    ({ ctx: bobCtx, page: bob } = await mk('bob'));
      +  });
      +
      +  test.afterAll(async () => {
      +    await Promise.all([facCtx, aliceCtx, bobCtx].filter(Boolean).map((ctx) => ctx.close()));
      +  });
      +
      +  test('facilitator creates a workshop; Alice and Bob join', async () => {
      +    await fac.goto(`${APP_BASE}/`);
      +    await fac.getByTestId('admin-key-input').fill(adminKey());
      +    await fac.getByTestId('admin-key-submit').click();
      +    await expect(fac.getByTestId('new-workshop-button')).toBeVisible();
      +
      +    await fac.getByTestId('new-workshop-button').click();
      +    await fac.getByTestId('workshop-name-input').fill(WORKSHOP_NAME);
      +
      +    const titleInputs = fac.getByTestId('milestone-title-input');
      +    for (let i = 0; i < 4 && (await titleInputs.count()) < 3; i += 1) {
      +      await fac.getByTestId('add-milestone-button').click();
      +    }
      +    await expect(titleInputs).toHaveCount(3);
      +
      +    const contentInputs = fac.getByTestId('milestone-content-input');
      +    const rows: Array<[string, string]> = [
      +      [M1_TITLE, M1_CONTENT],
      +      [M2_TITLE, M2_CONTENT],
      +      [M3_TITLE, M3_CONTENT],
      +    ];
      +    for (const [i, [title, content]] of rows.entries()) {
      +      await titleInputs.nth(i).fill(title);
      +      await contentInputs.nth(i).fill(content);
      +    }
      +
      +    const created = fac.waitForResponse(
      +      (r) => r.url().includes('/api/admin/workshops') && r.request().method() === 'POST',
      +    );
      +    await fac.getByTestId('create-workshop-submit').click();
      +    expect((await created).ok(), 'POST /api/admin/workshops must succeed').toBeTruthy();
      +
      +    const card = fac.getByTestId('workshop-card').filter({ hasText: WORKSHOP_NAME }).first();
      +    await expect(async () => {
      +      if (!(await card.isVisible())) {
      +        await fac.goto(`${APP_BASE}/`);
      +      }
      +      await expect(card).toBeVisible({ timeout: 4000 });
      +    }).toPass({ timeout: 30_000 });
      +
      +    joinUrl = extractUrl(await card.getByTestId('join-link').innerText(), '/j/');
      +    dashboardUrl = extractUrl(await card.getByTestId('facilitator-link').innerText(), '/f/');
      +
      +    await fac.goto(dashboardUrl);
      +    await expect(fac.getByTestId('dashboard-page')).toBeVisible();
      +
      +    for (const [page, name] of [
      +      [alice, 'Alice'],
      +      [bob, 'Bob'],
      +    ] as const) {
      +      await page.goto(joinUrl);
      +      await page.getByTestId('join-name-input').fill(name);
      +      const joined = page.waitForResponse(
      +        (r) => /\/api\/join\/[^/?]+$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +      );
      +      await page.getByTestId('join-submit').click();
      +      expect((await joined).ok(), `POST /api/join/{slug} for ${name} must succeed`).toBeTruthy();
      +      await expect(page.getByTestId('tracker-page')).toBeVisible({ timeout: 15_000 });
      +    }
      +
      +    await expect(fac.getByTestId('participant-row')).toHaveCount(2, { timeout: 15_000 });
      +
      +    // Lower the stuck threshold now (settings control) so the proactive-intel
      +    // step later in this journey has a real chance of a stuck alert without a
      +    // multi-minute sleep (spec/api.md `PATCH …/settings`, 2–120 range).
      +    const settingsPatched = fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/settings$/.test(r.url().split('?')[0]) && r.request().method() === 'PATCH',
      +    );
      +    await fac.getByTestId('settings-tab').click();
      +    await fac.getByTestId('stuck-minutes-input').fill('2');
      +    await fac.getByTestId('stuck-minutes-submit').click();
      +    expect((await settingsPatched).ok(), 'PATCH …/settings must succeed').toBeTruthy();
      +  });
      +
      +  test('broadcast: markdown announcement appears on trackers, then undo removes it', async () => {
      +    await fac.getByTestId('broadcast-button').click();
      +    await fac.getByTestId('broadcast-textarea').fill(BROADCAST_MD);
      +
      +    const sent = fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/broadcast$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await fac.getByTestId('broadcast-submit').click();
      +    const sentRes = await sent;
      +    expect(sentRes.ok(), 'POST …/broadcast must succeed').toBeTruthy();
      +    const sentBody = await sentRes.json();
      +    expect(sentBody.data.undoable_action_id).toBeTruthy();
      +
      +    // Facilitator sees a confirmation/undo toast right away.
      +    const undoToast = fac.getByTestId('undo-toast');
      +    await expect(undoToast).toBeVisible();
      +    await expect(undoToast).toContainText(/undo/i);
      +
      +    // Both participants see the RENDERED markdown banner within one poll.
      +    for (const page of [alice, bob]) {
      +      const banner = page.getByTestId('broadcast-banner');
      +      await expect(banner).toBeVisible({ timeout: 10_000 });
      +      await expect(banner).toContainText('Lunch is in 10 minutes');
      +      await expect(banner.locator('li').first()).toContainText('Grab your badge');
      +      await expect(banner).not.toContainText('**Lunch');
      +    }
      +
      +    // Undo within the 30 s window.
      +    const undone = fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/undo\/\d+$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await undoToast.getByTestId('undo-button').click();
      +    expect((await undone).ok(), 'POST …/undo/{id} must succeed').toBeTruthy();
      +
      +    // Banner disappears on both trackers within one poll.
      +    for (const page of [alice, bob]) {
      +      await expect(page.getByTestId('broadcast-banner')).toHaveCount(0, { timeout: 10_000 });
      +    }
      +  });
      +
      +  test('pause locks completion; resume unlocks it', async () => {
      +    const paused = fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/pause$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await fac.getByTestId('pause-button').click();
      +    expect((await paused).ok(), 'POST …/pause (true) must succeed').toBeTruthy();
      +
      +    // Alice's tracker shows the paused banner and a locked/disabled toggle
      +    // within one poll.
      +    await expect(alice.getByTestId('paused-banner')).toBeVisible({ timeout: 10_000 });
      +    const firstToggle = alice.getByTestId('milestone-item').first().getByTestId('milestone-toggle');
      +    await expect(async () => {
      +      const disabled = await firstToggle.isDisabled().catch(() => false);
      +      const ariaDisabled = (await firstToggle.getAttribute('aria-disabled').catch(() => null)) === 'true';
      +      if (!disabled && !ariaDisabled) throw new Error('milestone-toggle is not locked while paused');
      +    }).toPass({ timeout: 10_000 });
      +
      +    // A forced click while paused must not create a completion.
      +    await firstToggle.click({ force: true }).catch(() => undefined);
      +    await expect(async () => {
      +      const ariaChecked = await firstToggle.getAttribute('aria-checked');
      +      expect(ariaChecked === 'true').toBe(false);
      +    }).toPass({ timeout: 3000 });
      +
      +    const resumed = fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/pause$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await fac.getByTestId('pause-button').click();
      +    expect((await resumed).ok(), 'POST …/pause (false) must succeed').toBeTruthy();
      +
      +    await expect(alice.getByTestId('paused-banner')).toHaveCount(0, { timeout: 10_000 });
      +    await expect(async () => {
      +      const disabled = await firstToggle.isDisabled().catch(() => false);
      +      const ariaDisabled = (await firstToggle.getAttribute('aria-disabled').catch(() => null)) === 'true';
      +      if (disabled || ariaDisabled) throw new Error('milestone-toggle is still locked after resume');
      +    }).toPass({ timeout: 10_000 });
      +  });
      +
      +  test('advance-all on milestone 1 bumps progress everywhere; undo reverts it', async () => {
      +    const firstStat = fac.getByTestId('milestone-stat').first();
      +    const statBefore = (await firstStat.innerText()).replace(/\s+/g, ' ').trim();
      +
      +    fac.once('dialog', (dialog) => dialog.accept());
      +    await firstStat.getByTestId('advance-all-button').click();
      +
      +    const advanced = await fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/milestones\/advance$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    expect(advanced.ok(), 'POST …/milestones/advance must succeed').toBeTruthy();
      +    const advancedBody = await advanced.json();
      +    expect(advancedBody.data.affected_count).toBeGreaterThan(0);
      +    const undoActionId = advancedBody.data.undoable_action_id;
      +    expect(undoActionId).toBeTruthy();
      +
      +    await expect(async () => {
      +      const now = (await firstStat.innerText()).replace(/\s+/g, ' ').trim();
      +      expect(now).not.toBe(statBefore);
      +      expect(now).toMatch(/\b2\b|100/);
      +    }).toPass({ timeout: 10_000 });
      +
      +    for (const page of [alice, bob]) {
      +      const firstItem = page.getByTestId('milestone-item').first();
      +      await expect(async () => {
      +        const ariaChecked = await firstItem.getByTestId('milestone-toggle').getAttribute('aria-checked');
      +        if (ariaChecked !== 'true') throw new Error('milestone not marked complete by advance-all yet');
      +      }).toPass({ timeout: 10_000 });
      +    }
      +
      +    const undoToast = fac.getByTestId('undo-toast');
      +    await expect(undoToast).toBeVisible();
      +    const undone = fac.waitForResponse(
      +      (r) => new RegExp(`/api/f/[^/]+/undo/${undoActionId}$`).test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await undoToast.getByTestId('undo-button').click();
      +    expect((await undone).ok(), 'POST …/undo/{id} must succeed').toBeTruthy();
      +
      +    await expect(async () => {
      +      const now = (await firstStat.innerText()).replace(/\s+/g, ' ').trim();
      +      expect(now).toBe(statBefore);
      +    }).toPass({ timeout: 10_000 });
      +
      +    for (const page of [alice, bob]) {
      +      const firstItem = page.getByTestId('milestone-item').first();
      +      await expect(async () => {
      +        const ariaChecked = await firstItem.getByTestId('milestone-toggle').getAttribute('aria-checked');
      +        if (ariaChecked === 'true') throw new Error('undo did not revert advance-all completion');
      +      }).toPass({ timeout: 10_000 });
      +    }
      +  });
      +
      +  test('reorder: milestones move on the dashboard and on participant trackers', async () => {
      +    await fac.getByTestId('milestones-tab').click();
      +    const rows = fac.getByTestId('milestone-row');
      +    await expect(rows).toHaveCount(3);
      +    await expect(rows.nth(0)).toContainText(M1_TITLE);
      +
      +    // Move milestone 1 down one position (M1, M2, M3 -> M2, M1, M3).
      +    const reordered = fac.waitForResponse(
      +      (r) => /\/api\/f\/[^/]+\/milestones\/reorder$/.test(r.url().split('?')[0]) && r.request().method() === 'POST',
      +    );
      +    await rows.nth(0).getByTestId('milestone-move-down').click();
      +    expect((await reordered).ok(), 'POST …/milestones/reorder must succeed').toBeTruthy();
      +
      +    await expect(async () => {
      +      const first = (await rows.nth(0).innerText());
      +      expect(first).toContain(M2_TITLE);
      +    }).toPass({ timeout: 10_000 });
      +    await expect(rows.nth(1)).toContainText(M1_TITLE);
      +
      +    // Participant trackers reflect the new order within one poll.
      +    for (const page of [alice, bob]) {
      +      const items = page.getByTestId('milestone-item');
      +      await expect(async () => {
      +        const text = await items.nth(0).innerText();
      +        expect(text).toContain(M2_TITLE);
      +      }).toPass({ timeout: 10_000 });
      +      await expect(items.nth(1)).toContainText(M1_TITLE);
      +    }
      +  });
      +
      +  test('proactive intelligence: pulse and alerts rails render real, non-stub data', async () => {
      +    test.setTimeout(150_000);
      +    await fac.getByTestId('dashboard-tab').click().catch(() => undefined);
      +
      +    const pulseCard = fac.getByTestId('pulse-card');
      +    await expect(pulseCard).toBeVisible();
      +    await expectNotAStub(pulseCard);
      +    // Real numbers, not placeholders — pace ratio / on-track % / projected
      +    // finish are all rendered (api.md "pulse" shape).
      +    await expect(pulseCard).toContainText(/%/);
      +    await expect(pulseCard).not.toContainText(/coming in a later phase/i);
      +
      +    const alertsRail = fac.getByTestId('alerts-rail');
      +    await expect(alertsRail).toBeVisible();
      +    await expectNotAStub(alertsRail);
      +    await expect(alertsRail).not.toContainText(/coming in a later phase/i);
      +
      +    // Best-effort: Bob has been idle (no completion, no help activity) since
      +    // he joined; with stuck_minutes lowered to 2 in the setup test, give the
      +    // dashboard poll a window to surface him as a stuck alert. This is
      +    // explicitly best-effort per the roadmap ("where feasible in the test
      +    // window") — the mandatory assertions above (real, non-stub rails) do
      +    // not depend on it.
      +    const stuckCard = fac.getByTestId('stuck-alert-card').filter({ hasText: 'Bob' });
      +    await expect(async () => {
      +      const count = await stuckCard.count();
      +      if (count > 0) {
      +        await expect(stuckCard.first()).toContainText(/minute/i);
      +      }
      +    }).toPass({ timeout: 130_000 });
      +  });
      +
      +  test('audit tab lists every action with who/what/when; undone actions are marked', async () => {
      +    await fac.getByTestId('audit-tab').click();
      +    const rows = fac.getByTestId('audit-row');
      +    await expect(rows.first()).toBeVisible();
      +
      +    const broadcastRow = rows.filter({ hasText: /broadcast/i }).first();
      +    await expect(broadcastRow).toBeVisible();
      +    await expect(broadcastRow).toContainText(/undone/i);
      +
      +    const pauseRows = rows.filter({ hasText: /pause/i });
      +    await expect(pauseRows).toHaveCount(2, { timeout: 10_000 });
      +
      +    const advanceRow = rows.filter({ hasText: /advance/i }).first();
      +    await expect(advanceRow).toBeVisible();
      +    await expect(advanceRow).toContainText(/undone/i);
      +
      +    const reorderRow = rows.filter({ hasText: /reorder/i }).first();
      +    await expect(reorderRow).toBeVisible();
      +
      +    // Every audit row names an actor and a timestamp (who/what/when) — assert
      +    // the shape on the broadcast row, which this journey fully exercised.
      +    await expect(broadcastRow.getByTestId('audit-actor')).toBeVisible();
      +    await expect(broadcastRow.getByTestId('audit-timestamp')).toBeVisible();
      +  });
      +
      +  test('no uncaught page errors across the whole journey', async () => {
      +    const detail = uncaughtErrors.map((e) => `[${e.page}] ${e.message}`).join('\n');
      +    expect(uncaughtErrors, detail || 'clean').toEqual([]);
      +  });
      +});
      diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts
      new file mode 100644
      index 0000000..e605932
      --- /dev/null
      +++ b/tests/e2e/helpers.ts
      @@ -0,0 +1,69 @@
      +import * as fs from 'node:fs';
      +import * as path from 'node:path';
      +
      +/** Single origin (architecture.md §Process model): one uvicorn worker on :8001. */
      +export const ORIGIN = 'http://localhost:8001';
      +/** Canonical app base — the built Next.js export mounted by FastAPI. */
      +export const APP_BASE = `${ORIGIN}/app`;
      +
      +let cachedAdminKey: string | null = null;
      +
      +/**
      + * Resolve HELMSMAN_ADMIN_KEY: process.env first, else parse the repo-root
      + * `.env` file (simple line parse — no dotenv dependency). The value is only
      + * ever typed into the admin-key field; it is never logged or asserted on.
      + */
      +export function adminKey(): string {
      +  if (cachedAdminKey) return cachedAdminKey;
      +
      +  const fromEnv = process.env.HELMSMAN_ADMIN_KEY;
      +  if (fromEnv && fromEnv.trim()) {
      +    cachedAdminKey = fromEnv.trim();
      +    return cachedAdminKey;
      +  }
      +
      +  const envPath = path.resolve(__dirname, '..', '..', '.env');
      +  let raw: string;
      +  try {
      +    raw = fs.readFileSync(envPath, 'utf8');
      +  } catch {
      +    throw new Error(
      +      'HELMSMAN_ADMIN_KEY is not set in the environment and the repo-root .env file is not readable. ' +
      +        'Run `cp .env.example .env` and set HELMSMAN_ADMIN_KEY before the e2e gate.',
      +    );
      +  }
      +
      +  for (const line of raw.split(/\r?\n/)) {
      +    const match = line.match(/^\s*(?:export\s+)?HELMSMAN_ADMIN_KEY\s*=\s*(.*)$/);
      +    if (!match) continue;
      +    let value = match[1].trim();
      +    if (
      +      (value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
      +      (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
      +    ) {
      +      value = value.slice(1, -1);
      +    }
      +    if (value) {
      +      cachedAdminKey = value;
      +      return cachedAdminKey;
      +    }
      +  }
      +
      +  throw new Error(
      +    'HELMSMAN_ADMIN_KEY is missing/empty in both the environment and the repo-root .env file.',
      +  );
      +}
      +
      +/**
      + * Pull the first absolute pretty URL of a given kind (/j/, /p/ or /f/) out of
      + * an element's visible text (link elements may also contain copy-button text).
      + */
      +export function extractUrl(text: string, kind: '/j/' | '/p/' | '/f/'): string {
      +  const escaped = kind.replace(/\//g, '\\/');
      +  const re = new RegExp(`https?:\\/\\/\\S+${escaped}[A-Za-z0-9_-]+`);
      +  const match = text.match(re);
      +  if (!match) {
      +    throw new Error(`Expected a full ${kind} URL in element text, got: ${JSON.stringify(text)}`);
      +  }
      +  return match[0];
      +}
      diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
      new file mode 100644
      index 0000000..a13c341
      --- /dev/null
      +++ b/tests/e2e/smoke.spec.ts
      @@ -0,0 +1,40 @@
      +import { test, expect } from '@playwright/test';
      +import { APP_BASE, ORIGIN } from './helpers';
      +
      +/**
      + * Independent smoke checks against the live single-origin app — no journey
      + * state, fresh context per test. These pin the serving model itself
      + * (architecture.md §Process model): one process on :8001 serving /api/* and
      + * the styled static export at /app/*, with `GET /` redirecting into the app.
      + */
      +
      +test.describe('smoke: single origin, health, styled render', () => {
      +  test('GET /api/health reports ok after a real DB check', async ({ request }) => {
      +    const res = await request.get(`${ORIGIN}/api/health`);
      +    expect(res.status()).toBe(200);
      +    // Exact success envelope (api.md §Conventions + §Health).
      +    expect(await res.json()).toEqual({ data: { status: 'ok', db: 'ok' }, error: null });
      +  });
      +
      +  test('the root URL redirects into the app shell', async ({ page }) => {
      +    await page.goto(`${ORIGIN}/`);
      +    expect(new URL(page.url()).pathname.startsWith('/app')).toBe(true);
      +  });
      +
      +  test('Admin Home loads styled, gated by the access-key card', async ({ page }) => {
      +    await page.goto(`${APP_BASE}/`);
      +
      +    // Fresh context (no stored key) → the access-key card is the entry state.
      +    const keyInput = page.getByTestId('admin-key-input');
      +    await expect(keyInput).toBeVisible();
      +    await expect(keyInput).toHaveAttribute('type', 'password');
      +    await expect(page.getByTestId('admin-key-submit')).toBeVisible();
      +
      +    // Styled render, not a bare-HTML fallback: a stylesheet is attached and the
      +    // body font is the design-system stack, not the browser default serif.
      +    const styleSheetCount = await page.evaluate(() => document.styleSheets.length);
      +    expect(styleSheetCount).toBeGreaterThan(0);
      +    const fontFamily = await page.evaluate(() => getComputedStyle(document.body).fontFamily);
      +    expect(fontFamily.toLowerCase()).not.toContain('times');
      +  });
      +});
      diff --git a/tests/integration/test_admin_workshops.py b/tests/integration/test_admin_workshops.py
      new file mode 100644
      index 0000000..bf87c20
      --- /dev/null
      +++ b/tests/integration/test_admin_workshops.py
      @@ -0,0 +1,90 @@
      +import json
      +import re
      +
      +from sqlalchemy import select
      +
      +from tests.conftest import WORKSHOP_BODY
      +
      +ISO_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
      +
      +
      +def test_create_workshop_returns_full_object(workshop):
      +    assert workshop["name"] == "LangGraph Lab — July"
      +    assert workshop["description_md"] == "Welcome! Bring a laptop."
      +    assert workshop["status"] == "live"
      +    assert workshop["paused"] is False
      +    assert workshop["ai_enabled"] is False
      +    assert len(workshop["admin_token"]) == 43
      +    assert len(workshop["join_slug"]) == 8
      +    assert workshop["join_url"] == f"http://testserver/j/{workshop['join_slug']}"
      +    assert workshop["facilitator_url"] == f"http://testserver/f/{workshop['admin_token']}"
      +    assert ISO_Z.match(workshop["created_at"])
      +
      +
      +def test_create_workshop_writes_audit_row(workshop):
      +    from src.helmsman.db.models import FacilitatorAction
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        row = session.scalar(
      +            select(FacilitatorAction).where(FacilitatorAction.action == "workshop.create")
      +        )
      +        assert row is not None
      +        assert row.workshop_id == workshop["id"]
      +        assert row.actor == "facilitator"
      +        detail = json.loads(row.detail_json)
      +        assert detail["name"] == workshop["name"]
      +        assert detail["milestone_count"] == 3
      +
      +
      +def test_create_workshop_persists_milestones_in_order(client, admin_headers, workshop):
      +    response = client.get(f"/api/f/{workshop['admin_token']}/workshop")
      +    milestones = response.json()["data"]["milestones"]
      +    assert [m["position"] for m in milestones] == [0, 1, 2]
      +    assert [m["title"] for m in milestones] == [
      +        "Set up your environment",
      +        "Configure the API key",
      +        "Run the first graph",
      +    ]
      +    assert milestones[0]["content_md"] == "```bash\nuv sync\n```"
      +    assert milestones[2]["minutes"] is None
      +
      +
      +def test_list_workshops_sorted_desc_with_counts(client, admin_headers, join_participant):
      +    first = client.post(
      +        "/api/admin/workshops", json={**WORKSHOP_BODY, "name": "First"}, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +    second = client.post(
      +        "/api/admin/workshops", json={**WORKSHOP_BODY, "name": "Second"}, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +
      +    join_participant(client, first["join_slug"], "Priya")
      +
      +    listing = client.get("/api/admin/workshops", headers=admin_headers).json()["data"]["workshops"]
      +    assert [w["name"] for w in listing] == ["Second", "First"]
      +    by_id = {w["id"]: w for w in listing}
      +    assert by_id[first["id"]]["participant_count"] == 1
      +    assert by_id[second["id"]]["participant_count"] == 0
      +    assert by_id[first["id"]]["open_help_count"] == 0
      +    assert "admin_token" not in listing[0]
      +    assert by_id[first["id"]]["facilitator_url"].endswith(first["admin_token"])
      +
      +
      +def test_list_workshops_empty(client, admin_headers):
      +    listing = client.get("/api/admin/workshops", headers=admin_headers).json()
      +    assert listing == {"data": {"workshops": []}, "error": None}
      +
      +
      +def test_base_url_override_used_for_share_links(monkeypatch, make_app, admin_headers):
      +    from fastapi.testclient import TestClient
      +
      +    monkeypatch.setenv("HELMSMAN_BASE_URL", "https://helm.example.com")
      +    import src.helmsman.config.settings as settings_module
      +
      +    settings_module._settings = None
      +    client = TestClient(make_app())
      +    workshop = client.post(
      +        "/api/admin/workshops", json=WORKSHOP_BODY, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +    assert workshop["join_url"] == f"https://helm.example.com/j/{workshop['join_slug']}"
      +    assert workshop["facilitator_url"] == f"https://helm.example.com/f/{workshop['admin_token']}"
      diff --git a/tests/integration/test_advance.py b/tests/integration/test_advance.py
      new file mode 100644
      index 0000000..680e7b8
      --- /dev/null
      +++ b/tests/integration/test_advance.py
      @@ -0,0 +1,78 @@
      +"""Milestone advance: creates only missing completions with source facilitator; undoable."""
      +
      +
      +def test_advance_all_creates_only_missing_completions(
      +    client, make_client, workshop, join_participant
      +):
      +    admin_token = workshop["admin_token"]
      +    priya_browser, arun_browser = make_client(), make_client()
      +    priya = join_participant(priya_browser, workshop["join_slug"], "Priya")
      +    arun = join_participant(arun_browser, workshop["join_slug"], "Arun")
      +
      +    state = priya_browser.get(f"/api/p/{priya['participant_token']}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +
      +    # Priya already completed it manually
      +    priya_browser.post(f"/api/p/{priya['participant_token']}/milestones/{milestone_id}/complete")
      +
      +    result = client.post(
      +        f"/api/f/{admin_token}/milestones/advance",
      +        json={"milestone_id": milestone_id, "participant_ids": None},
      +    )
      +    assert result.status_code == 200, result.text
      +    data = result.json()["data"]
      +    assert data["affected_count"] == 1  # only Arun was missing
      +
      +    arun_state = arun_browser.get(f"/api/p/{arun['participant_token']}/state?v=-1").json()["data"]
      +    assert milestone_id in arun_state["me"]["completed_milestone_ids"]
      +
      +
      +def test_advance_selected_participants(client, make_client, workshop, join_participant):
      +    admin_token = workshop["admin_token"]
      +    priya_browser, arun_browser = make_client(), make_client()
      +    priya = join_participant(priya_browser, workshop["join_slug"], "Priya")
      +    join_participant(arun_browser, workshop["join_slug"], "Arun")
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    priya_id = next(p["id"] for p in dashboard["participants"] if p["name"] == "Priya")
      +    state = priya_browser.get(f"/api/p/{priya['participant_token']}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +
      +    result = client.post(
      +        f"/api/f/{admin_token}/milestones/advance",
      +        json={"milestone_id": milestone_id, "participant_ids": [priya_id]},
      +    ).json()["data"]
      +    assert result["affected_count"] == 1
      +
      +    priya_state = priya_browser.get(f"/api/p/{priya['participant_token']}/state?v=-1").json()["data"]
      +    assert milestone_id in priya_state["me"]["completed_milestone_ids"]
      +
      +
      +def test_advance_unknown_milestone_returns_not_found(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    result = client.post(
      +        f"/api/f/{admin_token}/milestones/advance",
      +        json={"milestone_id": 999999, "participant_ids": None},
      +    )
      +    assert result.status_code == 404
      +    assert result.json()["detail"]["code"] == "not_found"
      +
      +
      +def test_undo_advance_deletes_created_completions(client, make_client, workshop, join_participant):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    state = browser.get(f"/api/p/{joined['participant_token']}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +
      +    advance = client.post(
      +        f"/api/f/{admin_token}/milestones/advance",
      +        json={"milestone_id": milestone_id, "participant_ids": None},
      +    ).json()["data"]
      +    assert advance["affected_count"] == 1
      +
      +    undo = client.post(f"/api/f/{admin_token}/undo/{advance['undoable_action_id']}", json={})
      +    assert undo.status_code == 200, undo.text
      +
      +    refreshed = browser.get(f"/api/p/{joined['participant_token']}/state?v=-1").json()["data"]
      +    assert milestone_id not in refreshed["me"]["completed_milestone_ids"]
      diff --git a/tests/integration/test_audit.py b/tests/integration/test_audit.py
      new file mode 100644
      index 0000000..cdefd67
      --- /dev/null
      +++ b/tests/integration/test_audit.py
      @@ -0,0 +1,61 @@
      +"""Audit trail: every Phase-2 action writes a row; GET /audit pagination + undone_at."""
      +
      +
      +def test_broadcast_and_pause_write_audit_rows(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    client.post(f"/api/f/{admin_token}/broadcast", json={"message_md": "Hi"})
      +    client.post(f"/api/f/{admin_token}/pause", json={"paused": True})
      +
      +    audit = client.get(f"/api/f/{admin_token}/audit").json()["data"]
      +    actions = [a["action"] for a in audit["actions"]]
      +    assert "broadcast.send" in actions
      +    assert "workshop.pause" in actions
      +
      +
      +def test_settings_and_milestone_edit_write_audit_rows(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    got = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    milestone_id = got["milestones"][0]["id"]
      +
      +    client.patch(f"/api/f/{admin_token}/settings", json={"stuck_minutes": 15})
      +    client.patch(f"/api/f/{admin_token}/milestones/{milestone_id}", json={"title": "Renamed"})
      +    client.post(f"/api/f/{admin_token}/milestones", json={"title": "New", "content_md": "", "minutes": None})
      +    client.delete(f"/api/f/{admin_token}/milestones/{milestone_id}")
      +
      +    audit = client.get(f"/api/f/{admin_token}/audit").json()["data"]
      +    actions = [a["action"] for a in audit["actions"]]
      +    assert actions.count("settings.update") == 1
      +    assert actions.count("milestone.edit") == 3  # patch, add, delete
      +
      +
      +def test_audit_newest_first_and_pagination(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    for i in range(5):
      +        client.post(f"/api/f/{admin_token}/broadcast", json={"message_md": f"msg {i}"})
      +
      +    page1 = client.get(f"/api/f/{admin_token}/audit?limit=3").json()["data"]
      +    assert len(page1["actions"]) == 3
      +    assert page1["has_more"] is True
      +    ids = [a["id"] for a in page1["actions"]]
      +    assert ids == sorted(ids, reverse=True)
      +
      +    page2 = client.get(
      +        f"/api/f/{admin_token}/audit?before_id={ids[-1]}&limit=3"
      +    ).json()["data"]
      +    assert all(a["id"] < ids[-1] for a in page2["actions"])
      +    assert page2["has_more"] is False
      +
      +
      +def test_undo_populates_undone_at(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    sent = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "Hi"}
      +    ).json()["data"]
      +    client.post(f"/api/f/{admin_token}/undo/{sent['undoable_action_id']}", json={})
      +
      +    audit = client.get(f"/api/f/{admin_token}/audit").json()["data"]
      +    original = next(a for a in audit["actions"] if a["id"] == sent["undoable_action_id"])
      +    assert original["undone_at"] is not None
      +
      +    undo_row = next(a for a in audit["actions"] if a["action"] == "undo.apply")
      +    assert undo_row["detail"]["original_action_id"] == sent["undoable_action_id"]
      diff --git a/tests/integration/test_broadcast.py b/tests/integration/test_broadcast.py
      new file mode 100644
      index 0000000..bf5751e
      --- /dev/null
      +++ b/tests/integration/test_broadcast.py
      @@ -0,0 +1,88 @@
      +"""Broadcast: send → appears in dashboard + participant poll; undo restores previous; clear."""
      +
      +
      +def test_send_broadcast_appears_in_dashboard_and_participant_poll(
      +    client, make_client, workshop, join_participant
      +):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +
      +    result = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "Lunch in 5!"}
      +    )
      +    assert result.status_code == 200, result.text
      +    data = result.json()["data"]
      +    assert data["broadcast"]["message_md"] == "Lunch in 5!"
      +    assert "undoable_action_id" in data
      +    version = data["version"]
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    assert dashboard["broadcast"]["message_md"] == "Lunch in 5!"
      +
      +    state = browser.get(f"/api/p/{token}/state?v=-1").json()["data"]
      +    assert state["broadcast"]["message_md"] == "Lunch in 5!"
      +    assert state["version"] == version
      +
      +
      +def test_undo_broadcast_within_window_restores_previous(client, workshop):
      +    admin_token = workshop["admin_token"]
      +
      +    first = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "First"}
      +    ).json()["data"]
      +
      +    second = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "Second"}
      +    ).json()["data"]
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    assert dashboard["broadcast"]["message_md"] == "Second"
      +
      +    undo = client.post(f"/api/f/{admin_token}/undo/{second['undoable_action_id']}", json={})
      +    assert undo.status_code == 200, undo.text
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    assert dashboard["broadcast"]["message_md"] == "First"
      +
      +
      +def test_undo_first_broadcast_clears_it_entirely(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    sent = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "Only one"}
      +    ).json()["data"]
      +
      +    client.post(f"/api/f/{admin_token}/undo/{sent['undoable_action_id']}", json={})
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    assert dashboard["broadcast"] is None
      +
      +
      +def test_clear_broadcast(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    client.post(f"/api/f/{admin_token}/broadcast", json={"message_md": "Hello"})
      +
      +    result = client.post(f"/api/f/{admin_token}/broadcast/clear", json={})
      +    assert result.status_code == 200, result.text
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    assert dashboard["broadcast"] is None
      +
      +
      +def test_clear_broadcast_is_noop_when_none_active(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    result = client.post(f"/api/f/{admin_token}/broadcast/clear", json={})
      +    assert result.status_code == 200, result.text
      +
      +
      +def test_broadcast_message_validation(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    result = client.post(f"/api/f/{admin_token}/broadcast", json={"message_md": ""})
      +    assert result.status_code == 422
      +    assert result.json()["detail"]["code"] == "validation_error"
      +
      +    too_long = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "x" * 4001}
      +    )
      +    assert too_long.status_code == 422
      diff --git a/tests/integration/test_dashboard_details.py b/tests/integration/test_dashboard_details.py
      new file mode 100644
      index 0000000..e004adf
      --- /dev/null
      +++ b/tests/integration/test_dashboard_details.py
      @@ -0,0 +1,248 @@
      +"""Dashboard/leaderboard fine print: ranking order, help-queue ordering + resolved cap,
      +timestamp format, snapshot coalescing."""
      +
      +import re
      +from datetime import timedelta
      +
      +ISO_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
      +
      +
      +def test_leaderboard_rank_earlier_reach_time_wins(
      +    client, make_client, workshop, join_participant
      +):
      +    slug = workshop["join_slug"]
      +    fast_browser, slow_browser = make_client(), make_client()
      +    fast = join_participant(fast_browser, slug, "Fast")
      +    slow = join_participant(slow_browser, slug, "Slow")
      +
      +    state = fast_browser.get(f"/api/p/{fast['participant_token']}/state").json()["data"]
      +    milestone_ids = [m["id"] for m in state["milestones"]]
      +
      +    # Slow reaches count=1 first, then Fast — same count, Slow ranks higher
      +    slow_browser.post(f"/api/p/{slow['participant_token']}/milestones/{milestone_ids[0]}/complete")
      +    fast_browser.post(f"/api/p/{fast['participant_token']}/milestones/{milestone_ids[0]}/complete")
      +
      +    from sqlalchemy import update
      +
      +    from src.helmsman.db.models import MilestoneCompletion
      +    from src.helmsman.db.session import create_db_session
      +    from src.helmsman.api._common import utcnow
      +
      +    # Make the reach-times unambiguous (SQLite stores microseconds; force a clear gap)
      +    with create_db_session() as session:
      +        session.execute(
      +            update(MilestoneCompletion)
      +            .where(MilestoneCompletion.participant_id == 1)
      +            .values(completed_at=utcnow() + timedelta(seconds=30))
      +        )
      +
      +    from src.helmsman.services.snapshots import clear_snapshot_cache
      +
      +    clear_snapshot_cache()
      +    state = slow_browser.get(f"/api/p/{slow['participant_token']}/state").json()["data"]
      +    names_in_order = [row["name"] for row in state["leaderboard"]]
      +    assert names_in_order == ["Slow", "Fast"]
      +    assert [row["rank"] for row in state["leaderboard"]] == [1, 2]
      +
      +
      +def test_help_queue_open_block_first_newest_first_within(
      +    client, make_client, workshop, join_participant
      +):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    ids = [
      +        browser.post(f"/api/p/{token}/help", json={"message": f"q{i}"}).json()["data"][
      +            "help_request"
      +        ]["id"]
      +        for i in range(3)
      +    ]
      +    # Answer the first request → it moves to the answered block
      +    client.post(
      +        f"/api/f/{workshop['admin_token']}/help/{ids[0]}/answer", json={"answer_md": "a"}
      +    )
      +
      +    queue = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]["help_queue"]
      +    assert [(row["id"], row["status"]) for row in queue] == [
      +        (ids[2], "open"),
      +        (ids[1], "open"),
      +        (ids[0], "answered"),
      +    ]
      +
      +
      +def test_help_queue_caps_resolved_at_50_with_totals_in_stats(
      +    client, make_client, workshop, join_participant
      +):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +
      +    from datetime import timedelta
      +
      +    from src.helmsman.api._common import utcnow
      +    from src.helmsman.db.models import HelpRequest, Workshop
      +    from src.helmsman.db.session import create_db_session
      +
      +    base_time = utcnow()
      +    with create_db_session() as session:
      +        participant_id = 1
      +        for i in range(55):
      +            session.add(
      +                HelpRequest(
      +                    workshop_id=workshop["id"],
      +                    participant_id=participant_id,
      +                    milestone_id=None,
      +                    message=f"old question {i}",
      +                    status="resolved",
      +                    created_at=base_time + timedelta(seconds=i),
      +                    updated_at=base_time + timedelta(seconds=i),
      +                )
      +            )
      +        ws = session.get(Workshop, workshop["id"])
      +        ws.state_version += 1
      +
      +    dashboard = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +    resolved_rows = [r for r in dashboard["help_queue"] if r["status"] == "resolved"]
      +    assert len(resolved_rows) == 50
      +    assert dashboard["stats"]["resolved_help_count"] == 55
      +    # newest resolved first
      +    assert resolved_rows[0]["message"] == "old question 54"
      +
      +
      +def test_timestamps_are_iso_8601_z(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    browser.post(f"/api/p/{token}/help", json={"message": "stuck"})
      +
      +    dashboard = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +    row = dashboard["participants"][0]
      +    assert ISO_Z.match(row["joined_at"])
      +    assert ISO_Z.match(row["last_seen_at"])
      +    queue_row = dashboard["help_queue"][0]
      +    assert ISO_Z.match(queue_row["created_at"])
      +    assert ISO_Z.match(queue_row["updated_at"])
      +
      +
      +def test_snapshot_is_coalesced_across_pollers_at_same_version(
      +    client, make_client, workshop, join_participant
      +):
      +    join_participant(make_client(), workshop["join_slug"], "Priya")
      +
      +    import src.helmsman.services.snapshots as snapshots
      +
      +    calls = {"n": 0}
      +    original = snapshots._build_dashboard
      +
      +    def counting_build(*args, **kwargs):
      +        calls["n"] += 1
      +        return original(*args, **kwargs)
      +
      +    snapshots._build_dashboard = counting_build
      +    try:
      +        snapshots.clear_snapshot_cache()
      +        for _ in range(5):
      +            data = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +            assert data["changed"] is True
      +    finally:
      +        snapshots._build_dashboard = original
      +    assert calls["n"] == 1
      +
      +
      +def test_empty_workshop_dashboard_has_consistent_zero_state(client, workshop):
      +    dashboard = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +    assert dashboard["stats"] == {
      +        "participant_count": 0,
      +        "active_count": 0,
      +        "finished_count": 0,
      +        "median_progress_pct": 0.0,
      +        "open_help_count": 0,
      +        "answered_help_count": 0,
      +        "resolved_help_count": 0,
      +    }
      +    assert dashboard["participants"] == []
      +    assert dashboard["help_queue"] == []
      +    assert len(dashboard["distribution"]) == 4  # counts 0..3, zeros included
      +    assert all(bucket["participants"] == 0 for bucket in dashboard["distribution"])
      +
      +
      +def test_state_poll_touches_stale_last_seen_without_version_bump(
      +    client, make_client, workshop, join_participant
      +):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +
      +    from sqlalchemy import update
      +
      +    from src.helmsman.api._common import utcnow
      +    from src.helmsman.db.models import Participant
      +    from src.helmsman.db.session import create_db_session
      +    from src.helmsman.services.snapshots import ACTIVE_WINDOW_SECONDS, clear_snapshot_cache
      +
      +    with create_db_session() as session:
      +        session.execute(
      +            update(Participant).values(
      +                last_seen_at=utcnow() - timedelta(seconds=ACTIVE_WINDOW_SECONDS + 100)
      +            )
      +        )
      +
      +    clear_snapshot_cache()
      +    dashboard = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +    assert dashboard["stats"]["active_count"] == 0
      +    version_before = dashboard["version"]
      +
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    assert state["version"] == version_before  # the touch never bumps the version
      +
      +    clear_snapshot_cache()  # simulate TTL expiry (the safety net for touch-only changes)
      +    dashboard = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +    assert dashboard["stats"]["active_count"] == 1
      +
      +
      +def test_finished_participant_counted(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    for m in state["milestones"]:
      +        browser.post(f"/api/p/{token}/milestones/{m['id']}/complete")
      +
      +    dashboard = client.get(f"/api/f/{workshop['admin_token']}/dashboard").json()["data"]
      +    assert dashboard["stats"]["finished_count"] == 1
      +    assert dashboard["stats"]["median_progress_pct"] == 100.0
      +    row = dashboard["participants"][0]
      +    assert row["current_milestone_id"] is None
      +    assert row["progress_pct"] == 100.0
      +
      +
      +def test_leaderboard_capped_at_top_15_plus_me(client, make_client, workshop, join_participant):
      +    """At scale the tracker payload sends only the top 15 + the caller's own row."""
      +    slug = workshop["join_slug"]
      +    browsers = [make_client() for _ in range(20)]
      +    joined = [
      +        join_participant(b, slug, f"P{i:02d}") for i, b in enumerate(browsers)
      +    ]
      +
      +    # First 16 participants complete a milestone so P19 (idle) ranks below top 15.
      +    state = browsers[0].get(f"/api/p/{joined[0]['participant_token']}/state").json()["data"]
      +    first_milestone = state["milestones"][0]["id"]
      +    for b, j in zip(browsers[:16], joined[:16]):
      +        b.post(f"/api/p/{j['participant_token']}/milestones/{first_milestone}/complete")
      +
      +    from src.helmsman.services.snapshots import clear_snapshot_cache
      +
      +    clear_snapshot_cache()
      +
      +    last = browsers[19].get(f"/api/p/{joined[19]['participant_token']}/state").json()["data"]
      +    assert last["participants_count"] == 20
      +    rows = last["leaderboard"]
      +    # top 15 + the caller's own trailing row
      +    assert len(rows) == 16
      +    assert [r["rank"] <= 15 for r in rows[:15]] == [True] * 15
      +    mine = rows[-1]
      +    assert mine["is_me"] is True and mine["rank"] > 15
      +
      +    # A participant inside the top 15 gets exactly 15 rows (no duplicate self row).
      +    top = browsers[0].get(f"/api/p/{joined[0]['participant_token']}/state").json()["data"]
      +    assert len(top["leaderboard"]) == 15
      +    assert sum(1 for r in top["leaderboard"] if r["is_me"]) == 1
      diff --git a/tests/integration/test_errors.py b/tests/integration/test_errors.py
      new file mode 100644
      index 0000000..5659a27
      --- /dev/null
      +++ b/tests/integration/test_errors.py
      @@ -0,0 +1,358 @@
      +"""Every documented Phase-1 error code is reachable with the exact envelope:
      +{"detail": {"code", "message"}}."""
      +
      +import pytest
      +
      +from tests.conftest import WORKSHOP_BODY
      +
      +
      +def _assert_error(response, status: int, code: str):
      +    assert response.status_code == status, response.text
      +    detail = response.json()["detail"]
      +    assert detail["code"] == code
      +    assert isinstance(detail["message"], str) and detail["message"]
      +
      +
      +# --- invalid_admin_key (401) ---
      +
      +
      +def test_admin_list_missing_key(client):
      +    _assert_error(client.get("/api/admin/workshops"), 401, "invalid_admin_key")
      +
      +
      +def test_admin_list_wrong_key(client):
      +    _assert_error(
      +        client.get("/api/admin/workshops", headers={"X-Admin-Key": "wrong-key"}),
      +        401,
      +        "invalid_admin_key",
      +    )
      +
      +
      +def test_admin_create_wrong_key(client):
      +    _assert_error(
      +        client.post(
      +            "/api/admin/workshops", json=WORKSHOP_BODY, headers={"X-Admin-Key": "wrong-key"}
      +        ),
      +        401,
      +        "invalid_admin_key",
      +    )
      +
      +
      +# --- not_found (404): unknown tokens/slugs, never distinguishing wrong vs missing ---
      +
      +
      +def test_unknown_join_slug_get(client):
      +    _assert_error(client.get("/api/join/zzzzzzzz"), 404, "not_found")
      +
      +
      +def test_unknown_join_slug_post(client):
      +    _assert_error(client.post("/api/join/zzzzzzzz", json={"name": "Priya"}), 404, "not_found")
      +
      +
      +def test_unknown_participant_token_state(client):
      +    _assert_error(client.get("/api/p/not-a-token/state"), 404, "not_found")
      +
      +
      +def test_unknown_participant_token_content(client):
      +    _assert_error(client.get("/api/p/not-a-token/content"), 404, "not_found")
      +
      +
      +def test_unknown_participant_token_complete(client):
      +    _assert_error(client.post("/api/p/not-a-token/milestones/1/complete"), 404, "not_found")
      +
      +
      +def test_unknown_participant_token_help(client):
      +    _assert_error(client.post("/api/p/not-a-token/help", json={"message": "x"}), 404, "not_found")
      +
      +
      +def test_unknown_admin_token_workshop(client):
      +    _assert_error(client.get("/api/f/not-a-token/workshop"), 404, "not_found")
      +
      +
      +def test_unknown_admin_token_dashboard(client):
      +    _assert_error(client.get("/api/f/not-a-token/dashboard"), 404, "not_found")
      +
      +
      +def test_unknown_admin_token_answer(client):
      +    _assert_error(
      +        client.post("/api/f/not-a-token/help/1/answer", json={"answer_md": "x"}),
      +        404,
      +        "not_found",
      +    )
      +
      +
      +# --- not_found (404): ids outside the token's scope ---
      +
      +
      +@pytest.fixture
      +def two_workshops(client, admin_headers, join_participant, make_client):
      +    first = client.post(
      +        "/api/admin/workshops", json={**WORKSHOP_BODY, "name": "First"}, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +    second = client.post(
      +        "/api/admin/workshops", json={**WORKSHOP_BODY, "name": "Second"}, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +    browser = make_client()
      +    joined = join_participant(browser, first["join_slug"], "Priya")
      +    state = browser.get(f"/api/p/{joined['participant_token']}/state").json()["data"]
      +    help_id = browser.post(
      +        f"/api/p/{joined['participant_token']}/help", json={"message": "stuck"}
      +    ).json()["data"]["help_request"]["id"]
      +    other_joined = join_participant(make_client(), second["join_slug"], "Zoe")
      +    other_browser = make_client()
      +    other_state = other_browser.get(f"/api/p/{other_joined['participant_token']}/state").json()[
      +        "data"
      +    ]
      +    return {
      +        "first": first,
      +        "second": second,
      +        "token": joined["participant_token"],
      +        "milestone_ids": [m["id"] for m in state["milestones"]],
      +        "other_milestone_ids": [m["id"] for m in other_state["milestones"]],
      +        "other_token": other_joined["participant_token"],
      +        "help_id": help_id,
      +    }
      +
      +
      +def test_complete_milestone_from_other_workshop_is_not_found(client, two_workshops):
      +    foreign_milestone = two_workshops["other_milestone_ids"][0]
      +    _assert_error(
      +        client.post(
      +            f"/api/p/{two_workshops['token']}/milestones/{foreign_milestone}/complete"
      +        ),
      +        404,
      +        "not_found",
      +    )
      +
      +
      +def test_nonexistent_milestone_is_not_found(client, two_workshops):
      +    _assert_error(
      +        client.post(f"/api/p/{two_workshops['token']}/milestones/999999/complete"),
      +        404,
      +        "not_found",
      +    )
      +
      +
      +def test_answer_help_request_from_other_workshop_is_not_found(client, two_workshops):
      +    _assert_error(
      +        client.post(
      +            f"/api/f/{two_workshops['second']['admin_token']}/help/{two_workshops['help_id']}/answer",
      +            json={"answer_md": "not yours"},
      +        ),
      +        404,
      +        "not_found",
      +    )
      +
      +
      +def test_resolve_other_participants_request_is_not_found(client, two_workshops):
      +    _assert_error(
      +        client.post(
      +            f"/api/p/{two_workshops['other_token']}/help/{two_workshops['help_id']}/resolve"
      +        ),
      +        404,
      +        "not_found",
      +    )
      +
      +
      +# --- validation_error (422) ---
      +
      +
      +def test_join_empty_name(client, workshop):
      +    _assert_error(
      +        client.post(f"/api/join/{workshop['join_slug']}", json={"name": "   "}),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_join_name_too_long(client, workshop):
      +    _assert_error(
      +        client.post(f"/api/join/{workshop['join_slug']}", json={"name": "x" * 81}),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_join_missing_name_field(client, workshop):
      +    _assert_error(
      +        client.post(f"/api/join/{workshop['join_slug']}", json={}), 422, "validation_error"
      +    )
      +
      +
      +def test_create_workshop_without_milestones(client, admin_headers):
      +    _assert_error(
      +        client.post(
      +            "/api/admin/workshops",
      +            json={"name": "Lab", "milestones": []},
      +            headers=admin_headers,
      +        ),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_create_workshop_bad_minutes(client, admin_headers):
      +    _assert_error(
      +        client.post(
      +            "/api/admin/workshops",
      +            json={"name": "Lab", "milestones": [{"title": "t", "minutes": 0}]},
      +            headers=admin_headers,
      +        ),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_empty_answer_md(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    help_id = browser.post(
      +        f"/api/p/{joined['participant_token']}/help", json={"message": "stuck"}
      +    ).json()["data"]["help_request"]["id"]
      +    _assert_error(
      +        client.post(
      +            f"/api/f/{workshop['admin_token']}/help/{help_id}/answer",
      +            json={"answer_md": ""},
      +        ),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_help_message_too_long(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    _assert_error(
      +        browser.post(
      +            f"/api/p/{joined['participant_token']}/help", json={"message": "x" * 4001}
      +        ),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_non_integer_poll_version_param(client, workshop):
      +    _assert_error(
      +        client.get(f"/api/f/{workshop['admin_token']}/dashboard?v=abc"),
      +        422,
      +        "validation_error",
      +    )
      +
      +
      +def test_validation_error_message_is_human_readable(client, workshop):
      +    response = client.post(f"/api/join/{workshop['join_slug']}", json={"name": "   "})
      +    message = response.json()["detail"]["message"]
      +    assert "name" in message
      +    # the raw FastAPI 422 array shape never reaches clients
      +    assert not isinstance(response.json()["detail"], list)
      +
      +
      +# --- workshop_paused (409) ---
      +
      +
      +@pytest.fixture
      +def paused_tracker(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    state = browser.get(f"/api/p/{joined['participant_token']}/state").json()["data"]
      +
      +    from sqlalchemy import update
      +
      +    from src.helmsman.db.models import Workshop
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        # Direct-DB stand-in for the Phase-2 pause endpoint — which also bumps
      +        # state_version per the data-model version-bump rules.
      +        session.execute(
      +            update(Workshop)
      +            .where(Workshop.id == workshop["id"])
      +            .values(paused=True, state_version=Workshop.state_version + 1)
      +        )
      +    return {"browser": browser, "token": joined["participant_token"], "state": state}
      +
      +
      +def test_complete_while_paused(paused_tracker):
      +    milestone_id = paused_tracker["state"]["milestones"][0]["id"]
      +    _assert_error(
      +        paused_tracker["browser"].post(
      +            f"/api/p/{paused_tracker['token']}/milestones/{milestone_id}/complete"
      +        ),
      +        409,
      +        "workshop_paused",
      +    )
      +
      +
      +def test_uncomplete_while_paused(paused_tracker):
      +    milestone_id = paused_tracker["state"]["milestones"][0]["id"]
      +    _assert_error(
      +        paused_tracker["browser"].post(
      +            f"/api/p/{paused_tracker['token']}/milestones/{milestone_id}/uncomplete"
      +        ),
      +        409,
      +        "workshop_paused",
      +    )
      +
      +
      +def test_state_poll_still_works_while_paused(paused_tracker):
      +    state = paused_tracker["browser"].get(
      +        f"/api/p/{paused_tracker['token']}/state"
      +    ).json()["data"]
      +    assert state["workshop"]["paused"] is True
      +
      +
      +# --- workshop_archived (410) ---
      +
      +
      +@pytest.fixture
      +def archived(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    state = browser.get(f"/api/p/{joined['participant_token']}/state").json()["data"]
      +
      +    from sqlalchemy import update
      +
      +    from src.helmsman.db.models import Workshop
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        # Direct-DB stand-in for the Phase-3 archive transition — which also bumps
      +        # state_version per the data-model version-bump rules.
      +        session.execute(
      +            update(Workshop)
      +            .where(Workshop.id == workshop["id"])
      +            .values(status="archived", state_version=Workshop.state_version + 1)
      +        )
      +    return {"browser": browser, "token": joined["participant_token"], "state": state}
      +
      +
      +def test_join_archived_workshop(client, workshop, archived):
      +    _assert_error(
      +        client.post(f"/api/join/{workshop['join_slug']}", json={"name": "Late"}),
      +        410,
      +        "workshop_archived",
      +    )
      +
      +
      +def test_complete_on_archived_workshop(archived):
      +    milestone_id = archived["state"]["milestones"][0]["id"]
      +    _assert_error(
      +        archived["browser"].post(
      +            f"/api/p/{archived['token']}/milestones/{milestone_id}/complete"
      +        ),
      +        410,
      +        "workshop_archived",
      +    )
      +
      +
      +def test_help_on_archived_workshop(archived):
      +    _assert_error(
      +        archived["browser"].post(f"/api/p/{archived['token']}/help", json={"message": "x"}),
      +        410,
      +        "workshop_archived",
      +    )
      +
      +
      +def test_archived_workshop_still_returns_state_read_only(archived):
      +    state = archived["browser"].get(f"/api/p/{archived['token']}/state").json()["data"]
      +    assert state["workshop"]["status"] == "archived"
      diff --git a/tests/integration/test_full_loop.py b/tests/integration/test_full_loop.py
      new file mode 100644
      index 0000000..0eed89b
      --- /dev/null
      +++ b/tests/integration/test_full_loop.py
      @@ -0,0 +1,183 @@
      +"""The Phase-1 core loop end-to-end over real HTTP + a real SQLite file:
      +create → two joins → completions → dashboard reflects → help → answer → seen → resolve.
      +"""
      +
      +import json
      +
      +from sqlalchemy import select
      +
      +
      +def test_full_core_loop(client, make_client, workshop, join_participant):
      +    slug = workshop["join_slug"]
      +    admin_token = workshop["admin_token"]
      +
      +    priya_browser = make_client()
      +    arun_browser = make_client()
      +    priya = join_participant(priya_browser, slug, "Priya")
      +    arun = join_participant(arun_browser, slug, "Arun")
      +
      +    # Initial tracker state
      +    state = priya_browser.get(f"/api/p/{priya['participant_token']}/state").json()["data"]
      +    assert state["changed"] is True
      +    milestone_ids = [m["id"] for m in state["milestones"]]
      +    assert len(milestone_ids) == 3
      +    assert "content_md" not in state["milestones"][0]
      +    assert state["me"]["completed_count"] == 0
      +    assert state["me"]["total_count"] == 3
      +    assert state["workshop"]["paused"] is False
      +
      +    # Milestone bodies come from the content endpoint
      +    content = priya_browser.get(f"/api/p/{priya['participant_token']}/content").json()["data"]
      +    assert content["changed"] is True
      +    assert content["milestones"][0]["content_md"] == "```bash\nuv sync\n```"
      +    assert content["workshop"]["description_md"] == workshop["description_md"]
      +
      +    # Priya completes two milestones; Arun completes one
      +    for milestone_id in milestone_ids[:2]:
      +        result = priya_browser.post(
      +            f"/api/p/{priya['participant_token']}/milestones/{milestone_id}/complete"
      +        ).json()["data"]
      +    assert result["completed_count"] == 2
      +    assert result["progress_pct"] == 66.7
      +    arun_browser.post(
      +        f"/api/p/{arun['participant_token']}/milestones/{milestone_ids[0]}/complete"
      +    )
      +
      +    # Dashboard reflects the room
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard").json()["data"]
      +    assert dashboard["changed"] is True
      +    assert dashboard["stats"]["participant_count"] == 2
      +    assert dashboard["stats"]["active_count"] == 2
      +    assert dashboard["stats"]["finished_count"] == 0
      +    assert dashboard["stats"]["median_progress_pct"] == 50.0
      +    per_milestone = {m["milestone_id"]: m for m in dashboard["milestone_stats"]}
      +    assert per_milestone[milestone_ids[0]]["completed_count"] == 2
      +    assert per_milestone[milestone_ids[0]]["completed_pct"] == 100.0
      +    assert per_milestone[milestone_ids[1]]["completed_count"] == 1
      +    assert per_milestone[milestone_ids[2]]["completed_count"] == 0
      +    assert dashboard["distribution"] == [
      +        {"completed_count": 0, "participants": 0},
      +        {"completed_count": 1, "participants": 1},
      +        {"completed_count": 2, "participants": 1},
      +        {"completed_count": 3, "participants": 0},
      +    ]
      +    rows = {p["name"]: p for p in dashboard["participants"]}
      +    assert rows["Priya"]["completed_count"] == 2
      +    assert rows["Priya"]["current_milestone_id"] == milestone_ids[2]
      +    assert rows["Priya"]["participant_url"].endswith(priya["participant_token"])
      +    assert dashboard["broadcast"] is None
      +    assert dashboard["alerts"]["stuck"] == []
      +    assert dashboard["pulse"]["open_help_count"] == dashboard["stats"]["open_help_count"]
      +    assert dashboard["spend"] is None
      +
      +    # Leaderboard on the tracker: Priya rank 1, Arun rank 2
      +    state = priya_browser.get(f"/api/p/{priya['participant_token']}/state").json()["data"]
      +    assert state["me"]["rank"] == 1
      +    assert [(r["rank"], r["name"], r["is_me"]) for r in state["leaderboard"]] == [
      +        (1, "Priya", True),
      +        (2, "Arun", False),
      +    ]
      +
      +    # Arun asks for help — attached to his current milestone (second one)
      +    help_result = arun_browser.post(
      +        f"/api/p/{arun['participant_token']}/help",
      +        json={"message": "getting a 401 from the API"},
      +    ).json()["data"]
      +    help_id = help_result["help_request"]["id"]
      +    assert help_result["help_request"]["status"] == "open"
      +    assert help_result["help_request"]["milestone_id"] == milestone_ids[1]
      +
      +    # It shows in the dashboard queue with participant + milestone context
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard").json()["data"]
      +    assert dashboard["stats"]["open_help_count"] == 1
      +    queue_row = dashboard["help_queue"][0]
      +    assert queue_row["id"] == help_id
      +    assert queue_row["participant_name"] == "Arun"
      +    assert queue_row["milestone_title"] == "Configure the API key"
      +    assert queue_row["message"] == "getting a 401 from the API"
      +    assert queue_row["status"] == "open"
      +    assert queue_row["answers"] == []
      +
      +    # Facilitator answers with markdown
      +    answered = client.post(
      +        f"/api/f/{admin_token}/help/{help_id}/answer",
      +        json={"answer_md": "Check `.env` — the key name must be exact."},
      +    ).json()["data"]
      +    assert answered["help_request"]["status"] == "answered"
      +    answer_row = answered["help_request"]["answers"][0]
      +    assert answer_row["source"] == "facilitator"
      +    assert answer_row["draft"] is False
      +    assert answer_row["ai_confidence"] is None
      +    assert answer_row["ai_model"] is None
      +    assert answer_row["ai_context"] is None
      +
      +    # Arun sees the answer on his next poll — participant shape has no draft/ai fields
      +    state = arun_browser.get(f"/api/p/{arun['participant_token']}/state").json()["data"]
      +    my_request = state["help_requests"][0]
      +    assert my_request["status"] == "answered"
      +    assert my_request["answers"][0]["answer_md"] == "Check `.env` — the key name must be exact."
      +    assert set(my_request["answers"][0].keys()) == {"id", "source", "answer_md", "created_at"}
      +
      +    # Arun resolves his own request
      +    resolved = arun_browser.post(
      +        f"/api/p/{arun['participant_token']}/help/{help_id}/resolve"
      +    ).json()["data"]
      +    assert resolved["help_request"]["status"] == "resolved"
      +
      +    # Dashboard totals reflect the resolution
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard").json()["data"]
      +    assert dashboard["stats"]["open_help_count"] == 0
      +    assert dashboard["stats"]["resolved_help_count"] == 1
      +
      +    # Audit trail: workshop.create + help.answer recorded
      +    from src.helmsman.db.models import FacilitatorAction
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        actions = [
      +            row.action
      +            for row in session.scalars(
      +                select(FacilitatorAction).order_by(FacilitatorAction.id)
      +            )
      +        ]
      +    assert actions == ["workshop.create", "help.answer"]
      +
      +
      +def test_facilitator_resolve_writes_audit_row(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    help_id = browser.post(
      +        f"/api/p/{joined['participant_token']}/help", json={"message": "stuck"}
      +    ).json()["data"]["help_request"]["id"]
      +
      +    resolved = client.post(
      +        f"/api/f/{workshop['admin_token']}/help/{help_id}/resolve"
      +    ).json()["data"]
      +    assert resolved["help_request"]["status"] == "resolved"
      +
      +    from src.helmsman.db.models import FacilitatorAction
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        row = session.scalar(
      +            select(FacilitatorAction).where(FacilitatorAction.action == "help.resolve")
      +        )
      +        assert row is not None
      +        assert json.loads(row.detail_json)["help_request_id"] == help_id
      +
      +
      +def test_answer_after_resolved_keeps_status_resolved(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    help_id = browser.post(f"/api/p/{token}/help", json={"message": "stuck"}).json()["data"][
      +        "help_request"
      +    ]["id"]
      +    client.post(f"/api/f/{workshop['admin_token']}/help/{help_id}/resolve")
      +
      +    answered = client.post(
      +        f"/api/f/{workshop['admin_token']}/help/{help_id}/answer",
      +        json={"answer_md": "late follow-up"},
      +    ).json()["data"]
      +    assert answered["help_request"]["status"] == "resolved"
      +    assert len(answered["help_request"]["answers"]) == 1
      diff --git a/tests/integration/test_health.py b/tests/integration/test_health.py
      new file mode 100644
      index 0000000..7de1b2f
      --- /dev/null
      +++ b/tests/integration/test_health.py
      @@ -0,0 +1,24 @@
      +from fastapi.testclient import TestClient
      +
      +
      +def test_health_ok_envelope(client):
      +    response = client.get("/api/health")
      +    assert response.status_code == 200
      +    assert response.json() == {"data": {"status": "ok", "db": "ok"}, "error": None}
      +
      +
      +def test_health_returns_internal_error_when_db_unreachable(monkeypatch, app):
      +    from src.helmsman.db import session as session_module
      +
      +    engine = session_module.get_engine()
      +
      +    def _broken_connect(*args, **kwargs):
      +        raise RuntimeError("db down")
      +
      +    monkeypatch.setattr(engine, "connect", _broken_connect)
      +    client = TestClient(app, raise_server_exceptions=False)
      +    response = client.get("/api/health")
      +    assert response.status_code == 500
      +    body = response.json()
      +    assert body["detail"]["code"] == "internal_error"
      +    assert "message" in body["detail"]
      diff --git a/tests/integration/test_idempotency.py b/tests/integration/test_idempotency.py
      new file mode 100644
      index 0000000..78d2ed8
      --- /dev/null
      +++ b/tests/integration/test_idempotency.py
      @@ -0,0 +1,88 @@
      +"""Idempotent complete/uncomplete/resolve — repeats are no-op successes and never
      +bump the version again."""
      +
      +import pytest
      +
      +
      +@pytest.fixture
      +def tracker(make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    return {
      +        "browser": browser,
      +        "token": token,
      +        "milestone_ids": [m["id"] for m in state["milestones"]],
      +    }
      +
      +
      +def test_recomplete_is_noop_success(tracker):
      +    browser, token = tracker["browser"], tracker["token"]
      +    milestone_id = tracker["milestone_ids"][0]
      +    first = browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete").json()["data"]
      +    second = browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete").json()["data"]
      +    assert second == first
      +    assert second["completed_milestone_ids"] == [milestone_id]
      +    assert second["version"] == first["version"]
      +
      +
      +def test_uncomplete_removes_then_noop(tracker):
      +    browser, token = tracker["browser"], tracker["token"]
      +    milestone_id = tracker["milestone_ids"][0]
      +    browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete")
      +    removed = browser.post(f"/api/p/{token}/milestones/{milestone_id}/uncomplete").json()["data"]
      +    assert removed["completed_milestone_ids"] == []
      +    again = browser.post(f"/api/p/{token}/milestones/{milestone_id}/uncomplete").json()["data"]
      +    assert again == removed
      +
      +
      +def test_uncomplete_never_completed_is_noop(tracker):
      +    browser, token = tracker["browser"], tracker["token"]
      +    result = browser.post(
      +        f"/api/p/{token}/milestones/{tracker['milestone_ids'][2]}/uncomplete"
      +    ).json()["data"]
      +    assert result["completed_count"] == 0
      +
      +
      +def test_participant_resolve_idempotent(tracker):
      +    browser, token = tracker["browser"], tracker["token"]
      +    help_id = browser.post(f"/api/p/{token}/help", json={"message": "stuck"}).json()["data"][
      +        "help_request"
      +    ]["id"]
      +    first = browser.post(f"/api/p/{token}/help/{help_id}/resolve").json()["data"]
      +    second = browser.post(f"/api/p/{token}/help/{help_id}/resolve").json()["data"]
      +    assert first["help_request"]["status"] == "resolved"
      +    assert second == first
      +
      +
      +def test_facilitator_resolve_idempotent(client, workshop, tracker):
      +    browser, token = tracker["browser"], tracker["token"]
      +    help_id = browser.post(f"/api/p/{token}/help", json={"message": "stuck"}).json()["data"][
      +        "help_request"
      +    ]["id"]
      +    first = client.post(f"/api/f/{workshop['admin_token']}/help/{help_id}/resolve").json()["data"]
      +    second = client.post(f"/api/f/{workshop['admin_token']}/help/{help_id}/resolve").json()["data"]
      +    assert second == first
      +
      +
      +def test_facilitator_resolve_audits_only_once(client, workshop, tracker):
      +    browser, token = tracker["browser"], tracker["token"]
      +    help_id = browser.post(f"/api/p/{token}/help", json={"message": "stuck"}).json()["data"][
      +        "help_request"
      +    ]["id"]
      +    client.post(f"/api/f/{workshop['admin_token']}/help/{help_id}/resolve")
      +    client.post(f"/api/f/{workshop['admin_token']}/help/{help_id}/resolve")
      +
      +    from sqlalchemy import func, select
      +
      +    from src.helmsman.db.models import FacilitatorAction
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        count = session.scalar(
      +            select(func.count(FacilitatorAction.id)).where(
      +                FacilitatorAction.action == "help.resolve"
      +            )
      +        )
      +    assert count == 1
      diff --git a/tests/integration/test_intelligence_fixture.py b/tests/integration/test_intelligence_fixture.py
      new file mode 100644
      index 0000000..43795c1
      --- /dev/null
      +++ b/tests/integration/test_intelligence_fixture.py
      @@ -0,0 +1,130 @@
      +"""A 40-participant workshop seeded with a pre-computed expected alert/pulse set —
      +not a trivially-empty fixture. Verifies compute_stuck/compute_bottleneck/compute_pulse
      +wired end-to-end through the dashboard snapshot with exact expected results."""
      +
      +from datetime import timedelta
      +
      +from sqlalchemy import select, update
      +
      +from src.helmsman.api._common import utcnow
      +from src.helmsman.db.models import MilestoneCompletion, Participant
      +from src.helmsman.db.session import create_db_session
      +from src.helmsman.services.snapshots import clear_snapshot_cache
      +
      +FOUR_MILESTONES_BODY = {
      +    "name": "Big Workshop",
      +    "description_md": "Scale test.",
      +    "milestones": [
      +        {"title": "Milestone 1", "content_md": "…", "minutes": 10},
      +        {"title": "Milestone 2", "content_md": "…", "minutes": 10},
      +        {"title": "Milestone 3", "content_md": "…", "minutes": 10},
      +        {"title": "Milestone 4", "content_md": "…", "minutes": 10},
      +    ],
      +}
      +
      +
      +def test_forty_participant_fixture_produces_expected_alerts_and_pulse(
      +    client, make_client, admin_headers
      +):
      +    created = client.post(
      +        "/api/admin/workshops", json=FOUR_MILESTONES_BODY, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +    admin_token = created["admin_token"]
      +
      +    workshop_full = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    m1, m2 = workshop_full["milestones"][0]["id"], workshop_full["milestones"][1]["id"]
      +
      +    # 17 fillers: joined now, no completions, current_milestone = m1
      +    filler_tokens = []
      +    for i in range(17):
      +        browser = make_client()
      +        joined = browser.post(
      +            f"/api/join/{created['join_slug']}", json={"name": f"Filler{i}"}
      +        ).json()["data"]
      +        filler_tokens.append(joined["participant_token"])
      +
      +    # 20 on-pace: complete m1, joined ~15 min ago, completed ~5 min ago (duration 10 min)
      +    onpace_tokens = []
      +    for i in range(20):
      +        browser = make_client()
      +        joined = browser.post(
      +            f"/api/join/{created['join_slug']}", json={"name": f"OnPace{i}"}
      +        ).json()["data"]
      +        browser.post(f"/api/p/{joined['participant_token']}/milestones/{m1}/complete")
      +        onpace_tokens.append(joined["participant_token"])
      +
      +    # 3 stuck-at-m2: complete m1, joined ~30 min ago, completed ~25 min ago (stale activity)
      +    stuck_tokens = []
      +    for i in range(3):
      +        browser = make_client()
      +        joined = browser.post(
      +            f"/api/join/{created['join_slug']}", json={"name": f"Stuck{i}"}
      +        ).json()["data"]
      +        browser.post(f"/api/p/{joined['participant_token']}/milestones/{m1}/complete")
      +        stuck_tokens.append(joined["participant_token"])
      +
      +    now = utcnow()
      +    with create_db_session() as session:
      +        onpace_ids = list(
      +            session.scalars(
      +                select(Participant.id).where(Participant.token.in_(onpace_tokens))
      +            )
      +        )
      +        session.execute(
      +            update(Participant)
      +            .where(Participant.id.in_(onpace_ids))
      +            .values(joined_at=now - timedelta(minutes=15))
      +        )
      +        session.execute(
      +            update(MilestoneCompletion)
      +            .where(MilestoneCompletion.participant_id.in_(onpace_ids))
      +            .values(completed_at=now - timedelta(minutes=5))
      +        )
      +
      +        stuck_ids = list(
      +            session.scalars(
      +                select(Participant.id).where(Participant.token.in_(stuck_tokens))
      +            )
      +        )
      +        session.execute(
      +            update(Participant)
      +            .where(Participant.id.in_(stuck_ids))
      +            .values(joined_at=now - timedelta(minutes=30))
      +        )
      +        session.execute(
      +            update(MilestoneCompletion)
      +            .where(MilestoneCompletion.participant_id.in_(stuck_ids))
      +            .values(completed_at=now - timedelta(minutes=25))
      +        )
      +
      +    clear_snapshot_cache()
      +
      +    # 2 open help requests, to check pulse.open_help_count
      +    help_browser = make_client()
      +    help_joined = help_browser.post(
      +        f"/api/join/{created['join_slug']}", json={"name": "Helpme"}
      +    ).json()["data"]
      +    help_browser.post(f"/api/p/{help_joined['participant_token']}/help", json={"message": "stuck 1"})
      +    help_browser.post(f"/api/p/{help_joined['participant_token']}/help", json={"message": "stuck 2"})
      +
      +    clear_snapshot_cache()
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +
      +    assert dashboard["stats"]["participant_count"] == 41  # 17 + 20 + 3 + 1 helper
      +
      +    stuck_ids_reported = {row["participant_id"] for row in dashboard["alerts"]["stuck"]}
      +    with create_db_session() as session:
      +        expected_stuck_ids = set(
      +            session.scalars(select(Participant.id).where(Participant.token.in_(stuck_tokens)))
      +        )
      +    assert stuck_ids_reported == expected_stuck_ids
      +    for row in dashboard["alerts"]["stuck"]:
      +        assert row["current_milestone_id"] == m2
      +        assert row["minutes_inactive"] >= 25
      +
      +    assert dashboard["alerts"]["bottleneck"]["milestone_id"] == m2
      +    assert dashboard["alerts"]["bottleneck"]["waiting_count"] == 23  # 20 onpace + 3 stuck
      +
      +    assert dashboard["pulse"]["pace_ratio"] == 1.0
      +    assert dashboard["pulse"]["open_help_count"] == 2
      +    assert dashboard["pulse"]["projected_finish_at"] is not None
      diff --git a/tests/integration/test_join_and_cookies.py b/tests/integration/test_join_and_cookies.py
      new file mode 100644
      index 0000000..1b8e43c
      --- /dev/null
      +++ b/tests/integration/test_join_and_cookies.py
      @@ -0,0 +1,69 @@
      +def test_get_join_page_shows_workshop_and_counts(client, workshop):
      +    data = client.get(f"/api/join/{workshop['join_slug']}").json()["data"]
      +    assert data["workshop"]["name"] == workshop["name"]
      +    assert data["workshop"]["description_md"] == workshop["description_md"]
      +    assert data["workshop"]["status"] == "live"
      +    assert data["workshop"]["milestone_count"] == 3
      +    assert data["workshop"]["participant_count"] == 0
      +    assert data["me"] is None
      +
      +
      +def test_join_sets_cookie_with_exact_attributes(make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    assert len(joined["participant_token"]) == 22
      +    assert joined["name"] == "Priya"
      +    assert joined["participant_url"] == f"http://testserver/p/{joined['participant_token']}"
      +
      +    cookie_name = f"helmsman_p_{workshop['id']}"
      +    assert browser.cookies.get(cookie_name) == joined["participant_token"]
      +
      +
      +def test_join_cookie_header_attributes(make_client, workshop):
      +    browser = make_client()
      +    response = browser.post(f"/api/join/{workshop['join_slug']}", json={"name": "Priya"})
      +    set_cookie = response.headers["set-cookie"].lower()
      +    assert f"helmsman_p_{workshop['id']}=" in set_cookie
      +    assert "httponly" in set_cookie
      +    assert "samesite=lax" in set_cookie
      +    assert "path=/" in set_cookie
      +    assert "max-age=2592000" in set_cookie
      +
      +
      +def test_cookie_auto_resume_returns_me(make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    data = browser.get(f"/api/join/{workshop['join_slug']}").json()["data"]
      +    assert data["me"] == {"participant_token": joined["participant_token"], "name": "Priya"}
      +    assert data["workshop"]["participant_count"] == 1
      +
      +
      +def test_join_page_without_cookie_has_no_me(make_client, workshop, join_participant):
      +    join_participant(make_client(), workshop["join_slug"], "Priya")
      +    fresh_browser = make_client()
      +    data = fresh_browser.get(f"/api/join/{workshop['join_slug']}").json()["data"]
      +    assert data["me"] is None
      +
      +
      +def test_stale_cookie_for_unknown_participant_is_ignored(make_client, workshop):
      +    browser = make_client()
      +    browser.cookies.set(f"helmsman_p_{workshop['id']}", "not-a-real-token")
      +    data = browser.get(f"/api/join/{workshop['join_slug']}").json()["data"]
      +    assert data["me"] is None
      +
      +
      +def test_personal_link_works_cross_device_without_cookie(
      +    make_client, workshop, join_participant
      +):
      +    phone = make_client()
      +    joined = join_participant(phone, workshop["join_slug"], "Priya")
      +    laptop = make_client()  # no cookies at all
      +    state = laptop.get(f"/api/p/{joined['participant_token']}/state").json()["data"]
      +    assert state["changed"] is True
      +    assert state["me"]["name"] == "Priya"
      +
      +
      +def test_duplicate_names_are_allowed(make_client, workshop, join_participant):
      +    first = join_participant(make_client(), workshop["join_slug"], "Priya")
      +    second = join_participant(make_client(), workshop["join_slug"], "Priya")
      +    assert first["participant_token"] != second["participant_token"]
      diff --git a/tests/integration/test_migration.py b/tests/integration/test_migration.py
      new file mode 100644
      index 0000000..22c49ec
      --- /dev/null
      +++ b/tests/integration/test_migration.py
      @@ -0,0 +1,102 @@
      +"""The 0001_initial migration creates the FULL v0.2 schema on a fresh DB and
      +`alembic current` reports a non-blank revision."""
      +
      +from pathlib import Path
      +
      +from alembic import command
      +from alembic.config import Config
      +from alembic.script import ScriptDirectory
      +from sqlalchemy import create_engine, inspect
      +
      +REPO_ROOT = Path(__file__).resolve().parents[2]
      +
      +EXPECTED_TABLES = {
      +    "workshop",
      +    "milestone",
      +    "participant",
      +    "milestone_completion",
      +    "help_request",
      +    "help_answer",
      +    "broadcast",
      +    "facilitator_action",
      +    "agenda_template",
      +    "agenda_template_milestone",
      +    "join_form_template",
      +    "ai_usage",
      +    "alembic_version",
      +}
      +
      +
      +def _alembic_config() -> Config:
      +    return Config(str(REPO_ROOT / "alembic.ini"))
      +
      +
      +def test_upgrade_head_creates_full_schema_and_stamps_revision(monkeypatch, tmp_path):
      +    db_path = tmp_path / "migration-test.db"
      +    monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}")
      +    import src.helmsman.config.settings as settings_module
      +
      +    settings_module._settings = None
      +
      +    config = _alembic_config()
      +    command.upgrade(config, "head")
      +
      +    engine = create_engine(f"sqlite:///{db_path}")
      +    try:
      +        inspector = inspect(engine)
      +        assert set(inspector.get_table_names()) == EXPECTED_TABLES
      +
      +        # help_corpus (Phase-4 FTS5) is NOT created by 0001_initial
      +        assert "help_corpus" not in inspector.get_table_names()
      +
      +        # unique constraints that make the API idempotent/secure
      +        workshop_indexes = {
      +            idx["name"]: idx for idx in inspector.get_indexes("workshop")
      +        }
      +        assert workshop_indexes["ix_workshop_admin_token"]["unique"]
      +        assert workshop_indexes["ix_workshop_join_slug"]["unique"]
      +        completion_uniques = inspector.get_unique_constraints("milestone_completion")
      +        assert any(
      +            set(uc["column_names"]) == {"participant_id", "milestone_id"}
      +            for uc in completion_uniques
      +        )
      +
      +        # the stamped revision matches the script head (what `alembic current` prints)
      +        with engine.connect() as connection:
      +            from sqlalchemy import text
      +
      +            stamped = connection.execute(text("SELECT version_num FROM alembic_version")).scalar()
      +        head = ScriptDirectory.from_config(config).get_current_head()
      +        assert stamped == head
      +        assert stamped  # non-blank
      +    finally:
      +        engine.dispose()
      +
      +
      +def test_migrated_schema_serves_the_api(monkeypatch, tmp_path):
      +    """App over an alembic-migrated DB (not metadata.create_all) serves the core loop."""
      +    db_path = tmp_path / "migrated-api.db"
      +    monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path}")
      +    import src.helmsman.config.settings as settings_module
      +    from src.helmsman.db.session import reset_db_state
      +
      +    settings_module._settings = None
      +    reset_db_state()
      +
      +    command.upgrade(_alembic_config(), "head")
      +
      +    from fastapi.testclient import TestClient
      +
      +    from src.helmsman.api import create_app
      +    from tests.conftest import TEST_ADMIN_KEY, WORKSHOP_BODY
      +
      +    client = TestClient(create_app())
      +    workshop = client.post(
      +        "/api/admin/workshops", json=WORKSHOP_BODY, headers={"X-Admin-Key": TEST_ADMIN_KEY}
      +    ).json()["data"]["workshop"]
      +    joined = client.post(
      +        f"/api/join/{workshop['join_slug']}", json={"name": "Priya"}
      +    ).json()["data"]
      +    state = client.get(f"/api/p/{joined['participant_token']}/state").json()["data"]
      +    assert state["me"]["name"] == "Priya"
      +    assert state["me"]["total_count"] == 3
      diff --git a/tests/integration/test_milestone_edits.py b/tests/integration/test_milestone_edits.py
      new file mode 100644
      index 0000000..fe197f7
      --- /dev/null
      +++ b/tests/integration/test_milestone_edits.py
      @@ -0,0 +1,110 @@
      +"""Milestone add / patch / delete / reorder: version and content_version bumps."""
      +
      +
      +def test_add_milestone_bumps_content_version(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    before = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    result = client.post(
      +        f"/api/f/{admin_token}/milestones",
      +        json={"title": "Extra credit", "content_md": "Bonus round.", "minutes": 20},
      +    )
      +    assert result.status_code == 200, result.text
      +    data = result.json()["data"]
      +    assert data["milestone"]["title"] == "Extra credit"
      +    assert data["milestone"]["position"] == 3
      +    assert data["content_version"] == before["content_version"] + 1
      +
      +
      +def test_patch_milestone_edits_fields(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    got = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    milestone_id = got["milestones"][0]["id"]
      +
      +    result = client.patch(
      +        f"/api/f/{admin_token}/milestones/{milestone_id}", json={"title": "Renamed"}
      +    )
      +    assert result.status_code == 200, result.text
      +    assert result.json()["data"]["milestone"]["title"] == "Renamed"
      +    assert result.json()["data"]["milestone"]["content_md"] == got["milestones"][0]["content_md"]
      +
      +
      +def test_patch_milestone_unknown_id_404(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    result = client.patch(
      +        f"/api/f/{admin_token}/milestones/999999", json={"title": "X"}
      +    )
      +    assert result.status_code == 404
      +
      +
      +def test_patch_milestone_can_set_minutes_null(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    got = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    milestone_id = got["milestones"][0]["id"]
      +    result = client.patch(
      +        f"/api/f/{admin_token}/milestones/{milestone_id}", json={"minutes": None}
      +    ).json()["data"]
      +    assert result["milestone"]["minutes"] is None
      +
      +
      +def test_delete_milestone_bumps_both_versions_and_removes_completions(
      +    client, make_client, workshop, join_participant
      +):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    got = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    milestone_id = got["milestones"][0]["id"]
      +    browser.post(f"/api/p/{joined['participant_token']}/milestones/{milestone_id}/complete")
      +
      +    before = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +
      +    result = client.delete(f"/api/f/{admin_token}/milestones/{milestone_id}")
      +    assert result.status_code == 200, result.text
      +    data = result.json()["data"]
      +    assert data["version"] > before["version"]
      +    assert data["content_version"] > before["content_version"]
      +
      +    refreshed = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    assert milestone_id not in [m["id"] for m in refreshed["milestones"]]
      +
      +
      +def test_delete_milestone_unknown_id_404(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    result = client.delete(f"/api/f/{admin_token}/milestones/999999")
      +    assert result.status_code == 404
      +
      +
      +def test_reorder_exact_permutation(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    got = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    ids = [m["id"] for m in got["milestones"]]
      +    reversed_ids = list(reversed(ids))
      +
      +    result = client.post(
      +        f"/api/f/{admin_token}/milestones/reorder", json={"milestone_ids": reversed_ids}
      +    )
      +    assert result.status_code == 200, result.text
      +    data = result.json()["data"]
      +    assert data["content_version"] == got["content_version"] + 1
      +
      +    refreshed = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    assert [m["id"] for m in refreshed["milestones"]] == reversed_ids
      +
      +
      +def test_reorder_rejects_non_permutation(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    got = client.get(f"/api/f/{admin_token}/workshop").json()["data"]
      +    ids = [m["id"] for m in got["milestones"]]
      +
      +    missing_one = ids[:-1]
      +    result = client.post(
      +        f"/api/f/{admin_token}/milestones/reorder", json={"milestone_ids": missing_one}
      +    )
      +    assert result.status_code == 422
      +    assert result.json()["detail"]["code"] == "validation_error"
      +
      +    with_unknown_id = ids + [999999]
      +    result2 = client.post(
      +        f"/api/f/{admin_token}/milestones/reorder", json={"milestone_ids": with_unknown_id}
      +    )
      +    assert result2.status_code == 422
      diff --git a/tests/integration/test_pause.py b/tests/integration/test_pause.py
      new file mode 100644
      index 0000000..9b4b6e9
      --- /dev/null
      +++ b/tests/integration/test_pause.py
      @@ -0,0 +1,57 @@
      +"""Pause: blocks complete AND uncomplete with workshop_paused; resume unblocks; undo."""
      +
      +
      +def test_pause_blocks_complete_and_uncomplete(client, make_client, workshop, join_participant):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +
      +    browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete")
      +
      +    pause = client.post(f"/api/f/{admin_token}/pause", json={"paused": True})
      +    assert pause.status_code == 200, pause.text
      +    assert pause.json()["data"]["paused"] is True
      +
      +    blocked_complete = browser.post(
      +        f"/api/p/{token}/milestones/{milestone_id}/complete"
      +    )
      +    assert blocked_complete.status_code == 409
      +    assert blocked_complete.json()["detail"]["code"] == "workshop_paused"
      +
      +    other_milestone = state["milestones"][1]["id"]
      +    blocked_uncomplete = browser.post(
      +        f"/api/p/{token}/milestones/{other_milestone}/uncomplete"
      +    )
      +    assert blocked_uncomplete.status_code == 409
      +    assert blocked_uncomplete.json()["detail"]["code"] == "workshop_paused"
      +
      +
      +def test_resume_unblocks_completions(client, make_client, workshop, join_participant):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +
      +    client.post(f"/api/f/{admin_token}/pause", json={"paused": True})
      +    resume = client.post(f"/api/f/{admin_token}/pause", json={"paused": False})
      +    assert resume.json()["data"]["paused"] is False
      +
      +    result = browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete")
      +    assert result.status_code == 200, result.text
      +
      +
      +def test_undo_pause_restores_previous_state(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    pause = client.post(f"/api/f/{admin_token}/pause", json={"paused": True}).json()["data"]
      +    assert pause["paused"] is True
      +
      +    undo = client.post(f"/api/f/{admin_token}/undo/{pause['undoable_action_id']}", json={})
      +    assert undo.status_code == 200, undo.text
      +
      +    dashboard = client.get(f"/api/f/{admin_token}/dashboard?v=-1").json()["data"]
      +    assert dashboard["workshop"]["paused"] is False
      diff --git a/tests/integration/test_redirects.py b/tests/integration/test_redirects.py
      new file mode 100644
      index 0000000..72b4d5c
      --- /dev/null
      +++ b/tests/integration/test_redirects.py
      @@ -0,0 +1,16 @@
      +import pytest
      +
      +
      +@pytest.mark.parametrize(
      +    ("path", "target"),
      +    [
      +        ("/", "/app/"),
      +        ("/j/Ab3dEfGh", "/app/join/?s=Ab3dEfGh"),
      +        ("/p/some-participant-token", "/app/p/?t=some-participant-token"),
      +        ("/f/some-admin-token", "/app/f/?t=some-admin-token"),
      +    ],
      +)
      +def test_pretty_redirects_are_307_to_exact_targets(client, path, target):
      +    response = client.get(path, follow_redirects=False)
      +    assert response.status_code == 307
      +    assert response.headers["location"] == target
      diff --git a/tests/integration/test_restart_survival.py b/tests/integration/test_restart_survival.py
      new file mode 100644
      index 0000000..16b7451
      --- /dev/null
      +++ b/tests/integration/test_restart_survival.py
      @@ -0,0 +1,78 @@
      +"""Resilience rule: dispose the engine + app, build a NEW app over the SAME DB file —
      +every piece of state must survive (nothing lives only in process memory)."""
      +
      +from fastapi.testclient import TestClient
      +
      +from tests.conftest import TEST_ADMIN_KEY
      +
      +
      +def _restart(make_app) -> TestClient:
      +    import src.helmsman.config.settings as settings_module
      +    from src.helmsman.db.session import reset_db_state
      +    from src.helmsman.services.snapshots import clear_snapshot_cache
      +
      +    settings_module._settings = None
      +    reset_db_state()
      +    clear_snapshot_cache()
      +    return TestClient(make_app())
      +
      +
      +def test_full_state_survives_server_restart(client, make_app, workshop, join_participant, make_client):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +    browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete")
      +    help_id = browser.post(f"/api/p/{token}/help", json={"message": "stuck on step 2"}).json()[
      +        "data"
      +    ]["help_request"]["id"]
      +    client.post(f"/api/f/{admin_token}/help/{help_id}/answer", json={"answer_md": "try `uv sync`"})
      +    version_before = client.get(f"/api/f/{admin_token}/dashboard").json()["data"]["version"]
      +
      +    fresh_client = _restart(make_app)
      +
      +    # Admin list intact
      +    listing = fresh_client.get(
      +        "/api/admin/workshops", headers={"X-Admin-Key": TEST_ADMIN_KEY}
      +    ).json()["data"]["workshops"]
      +    assert [w["id"] for w in listing] == [workshop["id"]]
      +    assert listing[0]["participant_count"] == 1
      +
      +    # Dashboard intact, version preserved (came from the DB, not memory)
      +    dashboard = fresh_client.get(f"/api/f/{admin_token}/dashboard").json()["data"]
      +    assert dashboard["version"] == version_before
      +    assert dashboard["stats"]["participant_count"] == 1
      +    assert dashboard["stats"]["answered_help_count"] == 1
      +    assert dashboard["help_queue"][0]["answers"][0]["answer_md"] == "try `uv sync`"
      +
      +    # Participant token still works with full state
      +    state = fresh_client.get(f"/api/p/{token}/state").json()["data"]
      +    assert state["me"]["name"] == "Priya"
      +    assert state["me"]["completed_milestone_ids"] == [milestone_id]
      +    assert state["help_requests"][0]["status"] == "answered"
      +
      +    # Unchanged-poll short-circuit still consistent after restart
      +    unchanged = fresh_client.get(f"/api/p/{token}/state?v={state['version']}").json()["data"]
      +    assert unchanged == {
      +        "changed": False,
      +        "version": state["version"],
      +        "content_version": state["content_version"],
      +    }
      +
      +
      +def test_writes_after_restart_continue_the_version_sequence(
      +    client, make_app, workshop, join_participant, make_client
      +):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +
      +    fresh_client = _restart(make_app)
      +    state = fresh_client.get(f"/api/p/{token}/state").json()["data"]
      +    result = fresh_client.post(
      +        f"/api/p/{token}/milestones/{state['milestones'][0]['id']}/complete"
      +    ).json()["data"]
      +    assert result["version"] == state["version"] + 1
      diff --git a/tests/integration/test_undo.py b/tests/integration/test_undo.py
      new file mode 100644
      index 0000000..524bc01
      --- /dev/null
      +++ b/tests/integration/test_undo.py
      @@ -0,0 +1,72 @@
      +"""Undo window enforcement: expiry after 30s, unknown action, double-undo."""
      +
      +from datetime import timedelta
      +
      +from sqlalchemy import update
      +
      +
      +def _backdate_action(action_id: int, seconds: int) -> None:
      +    from src.helmsman.api._common import utcnow
      +    from src.helmsman.db.models import FacilitatorAction
      +    from src.helmsman.db.session import create_db_session
      +
      +    with create_db_session() as session:
      +        session.execute(
      +            update(FacilitatorAction)
      +            .where(FacilitatorAction.id == action_id)
      +            .values(created_at=utcnow() - timedelta(seconds=seconds))
      +        )
      +
      +
      +def test_undo_after_window_expires(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    sent = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "Hello"}
      +    ).json()["data"]
      +
      +    _backdate_action(sent["undoable_action_id"], seconds=31)
      +
      +    result = client.post(f"/api/f/{admin_token}/undo/{sent['undoable_action_id']}", json={})
      +    assert result.status_code == 409
      +    assert result.json()["detail"]["code"] == "undo_expired"
      +
      +
      +def test_undo_unknown_action_id_404(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    result = client.post(f"/api/f/{admin_token}/undo/999999", json={})
      +    assert result.status_code == 404
      +    assert result.json()["detail"]["code"] == "not_found"
      +
      +
      +def test_double_undo_returns_expired(client, workshop):
      +    admin_token = workshop["admin_token"]
      +    sent = client.post(
      +        f"/api/f/{admin_token}/broadcast", json={"message_md": "Hello"}
      +    ).json()["data"]
      +
      +    first = client.post(f"/api/f/{admin_token}/undo/{sent['undoable_action_id']}", json={})
      +    assert first.status_code == 200, first.text
      +
      +    second = client.post(f"/api/f/{admin_token}/undo/{sent['undoable_action_id']}", json={})
      +    assert second.status_code == 409
      +    assert second.json()["detail"]["code"] == "undo_expired"
      +
      +
      +def test_undo_action_from_another_workshop_is_not_found(client, admin_headers):
      +    from tests.conftest import WORKSHOP_BODY
      +
      +    workshop_a = client.post(
      +        "/api/admin/workshops", json=WORKSHOP_BODY, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +    workshop_b = client.post(
      +        "/api/admin/workshops", json=WORKSHOP_BODY, headers=admin_headers
      +    ).json()["data"]["workshop"]
      +
      +    sent = client.post(
      +        f"/api/f/{workshop_a['admin_token']}/broadcast", json={"message_md": "Hello"}
      +    ).json()["data"]
      +
      +    result = client.post(
      +        f"/api/f/{workshop_b['admin_token']}/undo/{sent['undoable_action_id']}", json={}
      +    )
      +    assert result.status_code == 404
      diff --git a/tests/integration/test_versioning.py b/tests/integration/test_versioning.py
      new file mode 100644
      index 0000000..f5d4dd5
      --- /dev/null
      +++ b/tests/integration/test_versioning.py
      @@ -0,0 +1,126 @@
      +"""Version counters: bumped in the same transaction as every mutation type;
      +unchanged polls short-circuit to the tiny payload; content only moves with cv."""
      +
      +
      +def _dashboard_version(client, admin_token: str) -> tuple[int, int]:
      +    data = client.get(f"/api/f/{admin_token}/dashboard").json()["data"]
      +    return data["version"], data["content_version"]
      +
      +
      +def test_new_workshop_starts_at_version_zero(client, workshop):
      +    version, content_version = _dashboard_version(client, workshop["admin_token"])
      +    assert version == 0
      +    assert content_version == 0
      +
      +
      +def test_every_mutation_type_bumps_state_version(client, make_client, workshop, join_participant):
      +    admin_token = workshop["admin_token"]
      +    browser = make_client()
      +
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    assert _dashboard_version(client, admin_token)[0] == 1  # join
      +
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +
      +    completed = browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete").json()["data"]
      +    assert completed["version"] == 2  # complete
      +
      +    uncompleted = browser.post(f"/api/p/{token}/milestones/{milestone_id}/uncomplete").json()["data"]
      +    assert uncompleted["version"] == 3  # uncomplete
      +
      +    help_result = browser.post(f"/api/p/{token}/help", json={"message": "stuck"}).json()["data"]
      +    assert help_result["version"] == 4  # help create
      +    help_id = help_result["help_request"]["id"]
      +
      +    answered = client.post(
      +        f"/api/f/{admin_token}/help/{help_id}/answer", json={"answer_md": "try again"}
      +    ).json()["data"]
      +    assert answered["version"] == 5  # facilitator answer
      +
      +    resolved = browser.post(f"/api/p/{token}/help/{help_id}/resolve").json()["data"]
      +    assert resolved["version"] == 6  # participant resolve
      +
      +    second_help = browser.post(f"/api/p/{token}/help", json={"message": "another"}).json()["data"]
      +    assert second_help["version"] == 7
      +    facilitator_resolved = client.post(
      +        f"/api/f/{admin_token}/help/{second_help['help_request']['id']}/resolve"
      +    ).json()["data"]
      +    assert facilitator_resolved["version"] == 8  # facilitator resolve
      +
      +
      +def test_unchanged_dashboard_poll_short_circuits(client, workshop):
      +    version, content_version = _dashboard_version(client, workshop["admin_token"])
      +    body = client.get(f"/api/f/{workshop['admin_token']}/dashboard?v={version}").json()
      +    assert body == {
      +        "data": {"changed": False, "version": version, "content_version": content_version},
      +        "error": None,
      +    }
      +
      +
      +def test_unchanged_state_poll_short_circuits(make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    version = browser.get(f"/api/p/{token}/state").json()["data"]["version"]
      +    body = browser.get(f"/api/p/{token}/state?v={version}").json()
      +    assert body == {
      +        "data": {"changed": False, "version": version, "content_version": 0},
      +        "error": None,
      +    }
      +
      +
      +def test_stale_v_returns_full_payload(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    version = browser.get(f"/api/p/{token}/state").json()["data"]["version"]
      +
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    milestone_id = state["milestones"][0]["id"]
      +    browser.post(f"/api/p/{token}/milestones/{milestone_id}/complete")
      +
      +    refreshed = browser.get(f"/api/p/{token}/state?v={version}").json()["data"]
      +    assert refreshed["changed"] is True
      +    assert refreshed["version"] == version + 1
      +    assert refreshed["me"]["completed_count"] == 1
      +
      +
      +def test_state_mutations_never_bump_content_version(client, make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    browser.post(f"/api/p/{token}/milestones/{state['milestones'][0]['id']}/complete")
      +    browser.post(f"/api/p/{token}/help", json={"message": "stuck"})
      +
      +    _, content_version = _dashboard_version(client, workshop["admin_token"])
      +    assert content_version == 0
      +
      +
      +def test_content_endpoint_short_circuits_on_matching_cv(make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +
      +    full = browser.get(f"/api/p/{token}/content").json()["data"]
      +    assert full["changed"] is True
      +    cv = full["content_version"]
      +
      +    unchanged = browser.get(f"/api/p/{token}/content?cv={cv}").json()
      +    assert unchanged == {
      +        "data": {"changed": False, "content_version": cv},
      +        "error": None,
      +    }
      +
      +
      +def test_mutation_responses_include_new_version(make_client, workshop, join_participant):
      +    browser = make_client()
      +    joined = join_participant(browser, workshop["join_slug"], "Priya")
      +    token = joined["participant_token"]
      +    state = browser.get(f"/api/p/{token}/state").json()["data"]
      +    result = browser.post(
      +        f"/api/p/{token}/milestones/{state['milestones'][0]['id']}/complete"
      +    ).json()["data"]
      +    assert result["version"] == state["version"] + 1
      diff --git a/tests/unit/test_common.py b/tests/unit/test_common.py
      new file mode 100644
      index 0000000..60d6899
      --- /dev/null
      +++ b/tests/unit/test_common.py
      @@ -0,0 +1,35 @@
      +from datetime import datetime, timezone
      +
      +from fastapi import HTTPException
      +
      +from src.helmsman.api._common import api_error, as_utc, iso_z, ok
      +
      +
      +def test_ok_envelope():
      +    assert ok({"x": 1}) == {"data": {"x": 1}, "error": None}
      +
      +
      +def test_api_error_shape():
      +    exc = api_error("not_found", "Nope.", 404)
      +    assert isinstance(exc, HTTPException)
      +    assert exc.status_code == 404
      +    assert exc.detail == {"code": "not_found", "message": "Nope."}
      +
      +
      +def test_iso_z_formats_aware_utc_at_seconds_precision():
      +    dt = datetime(2026, 7, 20, 14, 3, 22, 987654, tzinfo=timezone.utc)
      +    assert iso_z(dt) == "2026-07-20T14:03:22Z"
      +
      +
      +def test_iso_z_treats_naive_as_utc():
      +    dt = datetime(2026, 7, 20, 14, 3, 22)
      +    assert iso_z(dt) == "2026-07-20T14:03:22Z"
      +
      +
      +def test_as_utc_converts_other_zones():
      +    from datetime import timedelta, timezone as tz
      +
      +    plus_two = tz(timedelta(hours=2))
      +    dt = datetime(2026, 7, 20, 16, 3, 22, tzinfo=plus_two)
      +    assert iso_z(dt) == "2026-07-20T14:03:22Z"
      +    assert as_utc(dt).hour == 14
      diff --git a/tests/unit/test_intelligence.py b/tests/unit/test_intelligence.py
      new file mode 100644
      index 0000000..75f0173
      --- /dev/null
      +++ b/tests/unit/test_intelligence.py
      @@ -0,0 +1,104 @@
      +"""Pure-function tests for proactive intelligence: stuck, bottleneck, pulse."""
      +
      +from datetime import datetime, timedelta, timezone
      +
      +from src.helmsman.services.intelligence import compute_bottleneck, compute_pulse, compute_stuck
      +
      +T0 = datetime(2026, 7, 20, 12, 0, 0, tzinfo=timezone.utc)
      +
      +
      +def _p(pid, name, last_activity_minutes_ago, current_milestone_id, finished=False):
      +    return {
      +        "participant_id": pid,
      +        "name": name,
      +        "last_activity_at": T0 - timedelta(minutes=last_activity_minutes_ago),
      +        "current_milestone_id": current_milestone_id,
      +        "finished": finished,
      +    }
      +
      +
      +def test_stuck_flags_inactive_participants():
      +    participants = [_p(1, "Priya", 20, 5), _p(2, "Arun", 1, 5)]
      +    stuck = compute_stuck(participants, workshop_paused=False, stuck_minutes=10, now=T0)
      +    assert [s["participant_id"] for s in stuck] == [1]
      +    assert stuck[0]["minutes_inactive"] == 20
      +    assert stuck[0]["current_milestone_id"] == 5
      +
      +
      +def test_stuck_excludes_finished_participants():
      +    participants = [_p(1, "Priya", 30, None, finished=True)]
      +    stuck = compute_stuck(participants, workshop_paused=False, stuck_minutes=10, now=T0)
      +    assert stuck == []
      +
      +
      +def test_stuck_empty_when_workshop_paused():
      +    participants = [_p(1, "Priya", 30, 5)]
      +    stuck = compute_stuck(participants, workshop_paused=True, stuck_minutes=10, now=T0)
      +    assert stuck == []
      +
      +
      +def test_stuck_empty_participant_list():
      +    assert compute_stuck([], workshop_paused=False, stuck_minutes=10, now=T0) == []
      +
      +
      +def test_bottleneck_none_when_no_active_participants():
      +    assert compute_bottleneck([], {1: "Setup"}) is None
      +
      +
      +def test_bottleneck_none_when_below_quarter_threshold():
      +    active = [{"current_milestone_id": 1}] + [{"current_milestone_id": i} for i in range(2, 6)]
      +    # milestone 1 has only 1/5 = 20% < 25%
      +    assert compute_bottleneck(active, {1: "Setup", 2: "A", 3: "B", 4: "C", 5: "D"}) is None
      +
      +
      +def test_bottleneck_detected_at_or_above_quarter():
      +    active = [{"current_milestone_id": 1}] * 3 + [{"current_milestone_id": 2}]
      +    result = compute_bottleneck(active, {1: "Setup", 2: "Configure"})
      +    assert result == {"milestone_id": 1, "title": "Setup", "waiting_count": 3}
      +
      +
      +def test_bottleneck_ignores_finished_participants_with_no_current_milestone():
      +    active = [{"current_milestone_id": None}, {"current_milestone_id": None}]
      +    assert compute_bottleneck(active, {}) is None
      +
      +
      +def test_pulse_defaults_when_no_data():
      +    result = compute_pulse([], {}, [], now=T0)
      +    assert result["pace_ratio"] == 1.0
      +    assert result["on_track_pct"] == 0.0
      +    assert result["projected_finish_at"] is None
      +
      +
      +def test_pulse_pace_ratio_faster_than_planned():
      +    result = compute_pulse(
      +        completion_durations_minutes=[10, 10, 10],
      +        planned_minutes_by_milestone={1: 20, 2: 20},
      +        participants_progress=[],
      +        now=T0,
      +    )
      +    assert result["pace_ratio"] == 0.5
      +
      +
      +def test_pulse_on_track_pct_and_projection():
      +    progress = [
      +        {
      +            "joined_at": T0 - timedelta(minutes=5),
      +            "completed_count": 2,
      +            "total_count": 4,
      +            "remaining_planned_minutes": 20,
      +        },
      +        {
      +            "joined_at": T0 - timedelta(minutes=5),
      +            "completed_count": 0,
      +            "total_count": 4,
      +            "remaining_planned_minutes": 40,
      +        },
      +    ]
      +    result = compute_pulse(
      +        completion_durations_minutes=[15],
      +        planned_minutes_by_milestone={1: 10, 2: 10, 3: 10, 4: 10},
      +        participants_progress=progress,
      +        now=T0,
      +    )
      +    assert result["on_track_pct"] == 50.0
      +    assert result["projected_finish_at"] is not None
      diff --git a/tests/unit/test_last_seen_throttle.py b/tests/unit/test_last_seen_throttle.py
      new file mode 100644
      index 0000000..637d74d
      --- /dev/null
      +++ b/tests/unit/test_last_seen_throttle.py
      @@ -0,0 +1,19 @@
      +from datetime import timedelta
      +from types import SimpleNamespace
      +
      +from src.helmsman.api._common import utcnow
      +from src.helmsman.api.participant import LAST_SEEN_TOUCH_SECONDS, _touch_last_seen
      +
      +
      +def test_touch_skipped_within_throttle_window():
      +    recent = utcnow() - timedelta(seconds=LAST_SEEN_TOUCH_SECONDS - 5)
      +    participant = SimpleNamespace(last_seen_at=recent)
      +    _touch_last_seen(participant)
      +    assert participant.last_seen_at == recent
      +
      +
      +def test_touch_applied_after_throttle_window():
      +    stale = utcnow() - timedelta(seconds=LAST_SEEN_TOUCH_SECONDS + 5)
      +    participant = SimpleNamespace(last_seen_at=stale)
      +    _touch_last_seen(participant)
      +    assert participant.last_seen_at > stale
      diff --git a/tests/unit/test_masking.py b/tests/unit/test_masking.py
      new file mode 100644
      index 0000000..a5eb051
      --- /dev/null
      +++ b/tests/unit/test_masking.py
      @@ -0,0 +1,34 @@
      +from src.helmsman.observability.logging import mask_path, mask_token
      +
      +
      +def test_mask_token_shows_first_six_chars_only():
      +    assert mask_token("abcdefghij") == "abcdef…"
      +
      +
      +def test_mask_token_leaves_short_values_alone():
      +    assert mask_token("abc") == "abc"
      +
      +
      +def test_masks_facilitator_api_path():
      +    assert (
      +        mask_path("/api/f/AbCdEfGhIjKlMnOp/dashboard")
      +        == "/api/f/AbCdEf…/dashboard"
      +    )
      +
      +
      +def test_masks_participant_api_path():
      +    assert mask_path("/api/p/tok123456789/state") == "/api/p/tok123…/state"
      +
      +
      +def test_masks_pretty_participant_link():
      +    assert mask_path("/p/tok123456789") == "/p/tok123…"
      +
      +
      +def test_masks_pretty_facilitator_link():
      +    assert mask_path("/f/AbCdEfGhIj") == "/f/AbCdEf…"
      +
      +
      +def test_leaves_non_token_paths_alone():
      +    assert mask_path("/api/health") == "/api/health"
      +    assert mask_path("/api/admin/workshops") == "/api/admin/workshops"
      +    assert mask_path("/api/join/Ab3dEfGh") == "/api/join/Ab3dEfGh"
      diff --git a/tests/unit/test_request_models.py b/tests/unit/test_request_models.py
      new file mode 100644
      index 0000000..590e833
      --- /dev/null
      +++ b/tests/unit/test_request_models.py
      @@ -0,0 +1,83 @@
      +import pytest
      +from pydantic import ValidationError
      +
      +from src.helmsman.api.admin import MilestoneIn, WorkshopCreate
      +from src.helmsman.api.facilitator import AnswerBody
      +from src.helmsman.api.participant import HelpBody, JoinBody
      +
      +VALID_MILESTONE = {"title": "Set up", "content_md": "x", "minutes": 30}
      +
      +
      +def test_workshop_name_is_trimmed():
      +    body = WorkshopCreate(name="  Lab  ", milestones=[VALID_MILESTONE])
      +    assert body.name == "Lab"
      +
      +
      +def test_workshop_name_empty_after_trim_rejected():
      +    with pytest.raises(ValidationError):
      +        WorkshopCreate(name="   ", milestones=[VALID_MILESTONE])
      +
      +
      +def test_workshop_name_over_120_rejected():
      +    with pytest.raises(ValidationError):
      +        WorkshopCreate(name="x" * 121, milestones=[VALID_MILESTONE])
      +
      +
      +def test_workshop_description_over_10000_rejected():
      +    with pytest.raises(ValidationError):
      +        WorkshopCreate(name="Lab", description_md="x" * 10_001, milestones=[VALID_MILESTONE])
      +
      +
      +def test_workshop_requires_at_least_one_milestone():
      +    with pytest.raises(ValidationError):
      +        WorkshopCreate(name="Lab", milestones=[])
      +
      +
      +def test_workshop_rejects_more_than_50_milestones():
      +    with pytest.raises(ValidationError):
      +        WorkshopCreate(name="Lab", milestones=[VALID_MILESTONE] * 51)
      +
      +
      +def test_milestone_title_bounds():
      +    with pytest.raises(ValidationError):
      +        MilestoneIn(title="")
      +    with pytest.raises(ValidationError):
      +        MilestoneIn(title="x" * 201)
      +
      +
      +def test_milestone_content_over_20000_rejected():
      +    with pytest.raises(ValidationError):
      +        MilestoneIn(title="ok", content_md="x" * 20_001)
      +
      +
      +def test_milestone_minutes_bounds():
      +    with pytest.raises(ValidationError):
      +        MilestoneIn(title="ok", minutes=0)
      +    with pytest.raises(ValidationError):
      +        MilestoneIn(title="ok", minutes=481)
      +    assert MilestoneIn(title="ok", minutes=None).minutes is None
      +    assert MilestoneIn(title="ok", minutes=480).minutes == 480
      +
      +
      +def test_join_name_trimmed_and_bounded():
      +    assert JoinBody(name="  Priya ").name == "Priya"
      +    with pytest.raises(ValidationError):
      +        JoinBody(name="   ")
      +    with pytest.raises(ValidationError):
      +        JoinBody(name="x" * 81)
      +
      +
      +def test_help_message_bounds():
      +    assert HelpBody(message="stuck").message == "stuck"
      +    with pytest.raises(ValidationError):
      +        HelpBody(message="")
      +    with pytest.raises(ValidationError):
      +        HelpBody(message="x" * 4001)
      +
      +
      +def test_answer_md_bounds():
      +    assert AnswerBody(answer_md="Check `.env`").answer_md == "Check `.env`"
      +    with pytest.raises(ValidationError):
      +        AnswerBody(answer_md="")
      +    with pytest.raises(ValidationError):
      +        AnswerBody(answer_md="x" * 10_001)
      diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py
      new file mode 100644
      index 0000000..44f99e7
      --- /dev/null
      +++ b/tests/unit/test_security.py
      @@ -0,0 +1,41 @@
      +from src.helmsman.security import (
      +    admin_key_matches,
      +    generate_admin_token,
      +    generate_join_slug,
      +    generate_participant_token,
      +)
      +
      +
      +def test_admin_token_is_43_chars_and_unique():
      +    tokens = {generate_admin_token() for _ in range(50)}
      +    assert len(tokens) == 50
      +    assert all(len(t) == 43 for t in tokens)
      +
      +
      +def test_participant_token_is_22_chars():
      +    assert len(generate_participant_token()) == 22
      +
      +
      +def test_join_slug_is_8_chars():
      +    assert len(generate_join_slug()) == 8
      +
      +
      +def test_tokens_are_url_safe():
      +    token = generate_admin_token()
      +    assert all(c.isalnum() or c in "-_" for c in token)
      +
      +
      +def test_admin_key_matches_equal():
      +    assert admin_key_matches("sekrit-key", "sekrit-key") is True
      +
      +
      +def test_admin_key_matches_rejects_wrong_key():
      +    assert admin_key_matches("wrong", "sekrit-key") is False
      +
      +
      +def test_admin_key_matches_rejects_missing_key():
      +    assert admin_key_matches(None, "sekrit-key") is False
      +
      +
      +def test_admin_key_matches_rejects_empty_expected():
      +    assert admin_key_matches("anything", "") is False
      diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
      new file mode 100644
      index 0000000..4bc40b2
      --- /dev/null
      +++ b/tests/unit/test_settings.py
      @@ -0,0 +1,71 @@
      +import pytest
      +
      +from src.helmsman.config.settings import get_settings
      +
      +
      +def test_defaults(monkeypatch):
      +    monkeypatch.delenv("DATABASE_URL", raising=False)
      +    monkeypatch.delenv("PORT", raising=False)
      +    from src.helmsman.config.settings import Settings
      +
      +    settings = Settings(_env_file=None)
      +    assert settings.database_url == "sqlite:///data/helmsman.db"
      +    assert settings.port == 8001
      +    assert settings.resolved_log_level in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
      +
      +
      +def test_env_overrides(monkeypatch):
      +    monkeypatch.setenv("DATABASE_URL", "sqlite:///elsewhere.db")
      +    monkeypatch.setenv("PORT", "9999")
      +    import src.helmsman.config.settings as m
      +
      +    m._settings = None
      +    settings = get_settings()
      +    assert settings.database_url == "sqlite:///elsewhere.db"
      +    assert settings.port == 9999
      +
      +
      +def test_log_level_tolerates_inline_comment(monkeypatch):
      +    monkeypatch.setenv("HELMSMAN_LOG_LEVEL", "debug   # verbose")
      +    import src.helmsman.config.settings as m
      +
      +    m._settings = None
      +    assert get_settings().resolved_log_level == "DEBUG"
      +
      +
      +def test_invalid_log_level_falls_back_to_info(monkeypatch):
      +    monkeypatch.setenv("HELMSMAN_LOG_LEVEL", "chatty")
      +    import src.helmsman.config.settings as m
      +
      +    m._settings = None
      +    assert get_settings().resolved_log_level == "INFO"
      +
      +
      +def test_base_url_trailing_slash_stripped(monkeypatch):
      +    monkeypatch.setenv("HELMSMAN_BASE_URL", "https://helm.example.com/")
      +    import src.helmsman.config.settings as m
      +
      +    m._settings = None
      +    assert get_settings().resolved_base_url == "https://helm.example.com"
      +
      +
      +def test_missing_admin_key_fails_startup_hard(monkeypatch):
      +    monkeypatch.setenv("HELMSMAN_ADMIN_KEY", "   ")
      +    import src.helmsman.config.settings as m
      +
      +    m._settings = None
      +    from src.helmsman.api import create_app
      +
      +    with pytest.raises(RuntimeError, match="HELMSMAN_ADMIN_KEY"):
      +        create_app()
      +
      +
      +def test_missing_openrouter_key_is_never_an_error(monkeypatch):
      +    monkeypatch.setenv("OPENROUTER_API_KEY", "")
      +    import src.helmsman.config.settings as m
      +
      +    m._settings = None
      +    from src.helmsman.api import create_app
      +
      +    app = create_app()
      +    assert app is not None
      diff --git a/tests/unit/test_smoke.py b/tests/unit/test_smoke.py
      new file mode 100644
      index 0000000..0950ee9
      --- /dev/null
      +++ b/tests/unit/test_smoke.py
      @@ -0,0 +1,5 @@
      +import src.helmsman
      +
      +
      +def test_package_version():
      +    assert src.helmsman.__version__ == "0.2.0"
      diff --git a/tests/unit/test_snapshot_helpers.py b/tests/unit/test_snapshot_helpers.py
      new file mode 100644
      index 0000000..c22e469
      --- /dev/null
      +++ b/tests/unit/test_snapshot_helpers.py
      @@ -0,0 +1,112 @@
      +from datetime import datetime, timedelta, timezone
      +
      +from src.helmsman.services import snapshots
      +from src.helmsman.services.snapshots import (
      +    build_distribution,
      +    clear_snapshot_cache,
      +    median_progress_pct,
      +    progress_pct,
      +    rank_participants,
      +)
      +
      +T0 = datetime(2026, 7, 20, 9, 0, 0, tzinfo=timezone.utc)
      +
      +
      +def _entry(pid: int, name: str, joined_minutes: int, completion_minutes: list[int]) -> dict:
      +    return {
      +        "participant_id": pid,
      +        "name": name,
      +        "joined_at": T0 + timedelta(minutes=joined_minutes),
      +        "completion_times": [T0 + timedelta(minutes=m) for m in completion_minutes],
      +    }
      +
      +
      +def test_progress_pct_rounds_to_one_decimal():
      +    assert progress_pct(1, 8) == 12.5
      +    assert progress_pct(2, 3) == 66.7
      +
      +
      +def test_progress_pct_zero_total_is_zero():
      +    assert progress_pct(0, 0) == 0.0
      +
      +
      +def test_median_progress_even_count_averages_middle_two():
      +    assert median_progress_pct([0.0, 25.0, 50.0, 100.0]) == 37.5
      +
      +
      +def test_median_progress_empty_is_zero():
      +    assert median_progress_pct([]) == 0.0
      +
      +
      +def test_distribution_covers_every_count_with_zeros():
      +    rows = build_distribution([0, 0, 2], total_count=3)
      +    assert rows == [
      +        {"completed_count": 0, "participants": 2},
      +        {"completed_count": 1, "participants": 0},
      +        {"completed_count": 2, "participants": 1},
      +        {"completed_count": 3, "participants": 0},
      +    ]
      +
      +
      +def test_rank_orders_by_completed_count_desc():
      +    ranked = rank_participants(
      +        [_entry(1, "One", 0, [10]), _entry(2, "Two", 1, [10, 20]), _entry(3, "Three", 2, [])]
      +    )
      +    assert [e["participant_id"] for e in ranked] == [2, 1, 3]
      +    assert [e["rank"] for e in ranked] == [1, 2, 3]
      +
      +
      +def test_rank_tie_broken_by_earliest_reach_time():
      +    ranked = rank_participants(
      +        [_entry(1, "Late", 0, [5, 40]), _entry(2, "Early", 1, [5, 20])]
      +    )
      +    assert [e["participant_id"] for e in ranked] == [2, 1]
      +
      +
      +def test_rank_zero_completions_tie_broken_by_joined_at():
      +    ranked = rank_participants([_entry(1, "Second", 5, []), _entry(2, "First", 1, [])])
      +    assert [e["participant_id"] for e in ranked] == [2, 1]
      +
      +
      +def test_rank_has_no_tie_sharing():
      +    ranked = rank_participants(
      +        [_entry(1, "A", 0, [10]), _entry(2, "B", 1, [10])]
      +    )
      +    assert [e["rank"] for e in ranked] == [1, 2]
      +
      +
      +def test_memo_cache_returns_same_object_within_ttl():
      +    clear_snapshot_cache()
      +    builds = []
      +    result_a = snapshots._memoized(("k", 1), lambda: builds.append(1) or {"n": len(builds)})
      +    result_b = snapshots._memoized(("k", 1), lambda: builds.append(1) or {"n": len(builds)})
      +    assert result_a is result_b
      +    assert len(builds) == 1
      +
      +
      +def test_memo_cache_rebuilds_after_ttl(monkeypatch):
      +    clear_snapshot_cache()
      +    fake_now = [100.0]
      +    monkeypatch.setattr(snapshots, "_monotonic", lambda: fake_now[0])
      +    snapshots._memoized(("k", 2), lambda: {"first": True})
      +    fake_now[0] += snapshots.SNAPSHOT_TTL_SECONDS + 0.1
      +    rebuilt = snapshots._memoized(("k", 2), lambda: {"second": True})
      +    assert rebuilt == {"second": True}
      +
      +
      +def test_memo_cache_prunes_expired_entries(monkeypatch):
      +    clear_snapshot_cache()
      +    fake_now = [100.0]
      +    monkeypatch.setattr(snapshots, "_monotonic", lambda: fake_now[0])
      +    snapshots._memoized(("old", 1), lambda: {"v": 1})
      +    fake_now[0] += snapshots.SNAPSHOT_TTL_SECONDS + 0.1
      +    snapshots._memoized(("new", 1), lambda: {"v": 2})
      +    assert ("old", 1) not in snapshots._cache
      +    assert ("new", 1) in snapshots._cache
      +
      +
      +def test_memo_cache_distinct_keys_build_separately():
      +    clear_snapshot_cache()
      +    a = snapshots._memoized(("k", 1), lambda: {"v": 1})
      +    b = snapshots._memoized(("k", 2), lambda: {"v": 2})
      +    assert a != b
      diff --git a/tools/migrate.py b/tools/migrate.py
      deleted file mode 100644
      index 9c1b9fc..0000000
      --- a/tools/migrate.py
      +++ /dev/null
      @@ -1,153 +0,0 @@
      -"""Phase 4 + Phase 6 schema migrations.
      -
      -Idempotent. Run with:
      -
      -    venv/bin/python tools/migrate.py
      -
      -Walks the existing SQLite DB and:
      -  1. Adds `form_template_id` (FK → form_template.id, ON DELETE SET NULL)
      -     and `form_schema_json` columns to `workshop` if missing.
      -  2. Adds `answers_json` (NULL-able TEXT) to `participant` if missing.
      -  3. Creates the `form_template` and `agenda_template` tables if missing
      -     (create_all handles it).
      -  4. Backfills any existing workshop rows whose `form_schema_json` IS NULL
      -     or empty with the Phase-4 default schema (a single `display_name`
      -     text field), so demo data from Phases 1-3 keeps working with no
      -     manual edits.
      -  5. Phase 6: adds `help_request.status` column (default 'open') and
      -     `workshop.help_tips_json` column (nullable TEXT). Backfills any
      -     existing help_requests whose status is NULL/empty to 'open'.
      -
      -Re-running is safe — every step is a no-op when its conditions don't hold.
      -"""
      -
      -from __future__ import annotations
      -
      -import json
      -import sys
      -from pathlib import Path
      -
      -HERE = Path(__file__).resolve().parent.parent
      -sys.path.insert(0, str(HERE))
      -
      -from src.db import engine, init_db  # noqa: E402
      -from src.models import DEFAULT_FORM_SCHEMA  # noqa: E402
      -
      -
      -def _column_exists(conn, table: str, column: str) -> bool:
      -    rows = conn.exec_driver_sql(f"PRAGMA table_info({table})").fetchall()
      -    return any(r[1] == column for r in rows)
      -
      -
      -def _table_exists(conn, table: str) -> bool:
      -    row = conn.exec_driver_sql(
      -        "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
      -        (table,),
      -    ).fetchone()
      -    return row is not None
      -
      -
      -def _ensure_columns(conn) -> list[str]:
      -    """Add Phase-4 + Phase-6 columns if missing. Returns list of columns added."""
      -    added: list[str] = []
      -    # Inspect first (outside any transaction).
      -    need_ftid = not _column_exists(conn, "workshop", "form_template_id")
      -    need_schema = not _column_exists(conn, "workshop", "form_schema_json")
      -    need_answers = not _column_exists(conn, "participant", "answers_json")
      -    # Phase 6:
      -    need_help_status = not _column_exists(conn, "help_request", "status")
      -    need_help_tips = not _column_exists(conn, "workshop", "help_tips_json")
      -
      -    if need_ftid:
      -        conn.exec_driver_sql(
      -            "ALTER TABLE workshop ADD COLUMN form_template_id INTEGER "
      -            "REFERENCES form_template(id) ON DELETE SET NULL"
      -        )
      -        conn.exec_driver_sql(
      -            "CREATE INDEX IF NOT EXISTS ix_workshop_form_template_id "
      -            "ON workshop(form_template_id)"
      -        )
      -        added.append("workshop.form_template_id")
      -    if need_schema:
      -        conn.exec_driver_sql("ALTER TABLE workshop ADD COLUMN form_schema_json TEXT")
      -        added.append("workshop.form_schema_json")
      -    if need_answers:
      -        conn.exec_driver_sql("ALTER TABLE participant ADD COLUMN answers_json TEXT")
      -        added.append("participant.answers_json")
      -    if need_help_tips:
      -        conn.exec_driver_sql("ALTER TABLE workshop ADD COLUMN help_tips_json TEXT")
      -        added.append("workshop.help_tips_json")
      -    if need_help_status:
      -        conn.exec_driver_sql(
      -            "ALTER TABLE help_request ADD COLUMN status VARCHAR(16) "
      -            "NOT NULL DEFAULT 'open'"
      -        )
      -        added.append("help_request.status")
      -    conn.commit()
      -    return added
      -
      -
      -def _backfill_form_schemas(conn) -> int:
      -    """Set form_schema_json to the Phase-4 default on existing workshops.
      -
      -    Only touches rows where the column is NULL or empty — fresh workshops
      -    created post-upgrade are left alone (they carry whatever the creator
      -    set, including empty arrays if they hand-cleared the field list).
      -    Returns the number of rows updated.
      -    """
      -    rows = conn.exec_driver_sql(
      -        "SELECT id FROM workshop WHERE form_schema_json IS NULL OR form_schema_json = ''"
      -    ).fetchall()
      -    default_json = json.dumps(DEFAULT_FORM_SCHEMA)
      -    for (wid,) in rows:
      -        conn.exec_driver_sql(
      -            "UPDATE workshop SET form_schema_json = ? WHERE id = ?",
      -            (default_json, wid),
      -        )
      -    conn.commit()
      -    return len(rows)
      -
      -
      -def _backfill_help_status(conn) -> int:
      -    """Set status='open' on existing rows where the column came up empty.
      -
      -    SQLite ALTER TABLE ADD COLUMN applies the DEFAULT to NEW rows only;
      -    pre-existing rows get whatever default value the kernel fills in
      -    (usually NULL for TEXT, even with NOT NULL DEFAULT in older SQLite
      -    versions). Phase 6 help_request.status needs to be non-NULL 'open'
      -    for every historical row.
      -    """
      -    res = conn.exec_driver_sql(
      -        "UPDATE help_request SET status = 'open' "
      -        "WHERE status IS NULL OR status = ''"
      -    )
      -    conn.commit()
      -    return res.rowcount or 0
      -
      -
      -def main() -> int:
      -    # Ensure tables exist (idempotent).
      -    init_db()
      -
      -    added_columns: list[str] = []
      -    schema_backfilled = 0
      -    status_backfilled = 0
      -
      -    with engine.connect() as conn:
      -        if not _table_exists(conn, "form_template"):
      -            print("[migrate] form_template table created by create_all()")
      -        if not _table_exists(conn, "agenda_template"):
      -            print("[migrate] agenda_template table created by create_all()")
      -        added_columns = _ensure_columns(conn)
      -        schema_backfilled = _backfill_form_schemas(conn)
      -        status_backfilled = _backfill_help_status(conn)
      -
      -    print(f"[migrate] columns added: {added_columns or 'none'}")
      -    print(f"[migrate] workshops backfilled with default form schema: {schema_backfilled}")
      -    print(f"[migrate] help_requests backfilled with status='open': {status_backfilled}")
      -    print("[migrate] done.")
      -    return 0
      -
      -
      -if __name__ == "__main__":
      -    raise SystemExit(main())
      diff --git a/uv.lock b/uv.lock
      new file mode 100644
      index 0000000..57ba756
      --- /dev/null
      +++ b/uv.lock
      @@ -0,0 +1,565 @@
      +version = 1
      +revision = 3
      +requires-python = ">=3.12"
      +
      +[[package]]
      +name = "alembic"
      +version = "1.18.5"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "mako" },
      +    { name = "sqlalchemy" },
      +    { name = "typing-extensions" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" },
      +]
      +
      +[[package]]
      +name = "annotated-doc"
      +version = "0.0.4"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
      +]
      +
      +[[package]]
      +name = "annotated-types"
      +version = "0.7.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
      +]
      +
      +[[package]]
      +name = "anyio"
      +version = "4.14.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "idna" },
      +    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
      +]
      +
      +[[package]]
      +name = "certifi"
      +version = "2026.6.17"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
      +]
      +
      +[[package]]
      +name = "click"
      +version = "8.4.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "colorama", marker = "sys_platform == 'win32'" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
      +]
      +
      +[[package]]
      +name = "colorama"
      +version = "0.4.6"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
      +]
      +
      +[[package]]
      +name = "fastapi"
      +version = "0.139.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "annotated-doc" },
      +    { name = "pydantic" },
      +    { name = "starlette" },
      +    { name = "typing-extensions" },
      +    { name = "typing-inspection" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" },
      +]
      +
      +[[package]]
      +name = "greenlet"
      +version = "3.5.3"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
      +    { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
      +    { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
      +    { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
      +    { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
      +    { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
      +    { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
      +    { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
      +    { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
      +    { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
      +    { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
      +    { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
      +    { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
      +    { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
      +    { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
      +    { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
      +    { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
      +    { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
      +    { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
      +    { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
      +    { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
      +    { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
      +    { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
      +    { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
      +    { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
      +    { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
      +    { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
      +    { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
      +    { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
      +    { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
      +    { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
      +    { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
      +    { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
      +    { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
      +    { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
      +    { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
      +    { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
      +    { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
      +    { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
      +    { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
      +    { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
      +    { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
      +    { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
      +    { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
      +    { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
      +    { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
      +    { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
      +]
      +
      +[[package]]
      +name = "h11"
      +version = "0.16.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
      +]
      +
      +[[package]]
      +name = "httpcore"
      +version = "1.0.9"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "certifi" },
      +    { name = "h11" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
      +]
      +
      +[[package]]
      +name = "httpx"
      +version = "0.28.1"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "anyio" },
      +    { name = "certifi" },
      +    { name = "httpcore" },
      +    { name = "idna" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
      +]
      +
      +[[package]]
      +name = "idna"
      +version = "3.18"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
      +]
      +
      +[[package]]
      +name = "iniconfig"
      +version = "2.3.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
      +]
      +
      +[[package]]
      +name = "mako"
      +version = "1.3.12"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "markupsafe" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
      +]
      +
      +[[package]]
      +name = "markupsafe"
      +version = "3.0.3"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
      +    { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
      +    { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
      +    { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
      +    { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
      +    { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
      +    { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
      +    { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
      +    { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
      +    { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
      +    { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
      +    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
      +    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
      +    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
      +    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
      +    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
      +    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
      +    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
      +    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
      +    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
      +    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
      +    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
      +    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
      +    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
      +    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
      +    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
      +    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
      +    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
      +    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
      +    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
      +    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
      +    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
      +    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
      +    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
      +    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
      +    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
      +    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
      +    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
      +    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
      +    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
      +    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
      +    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
      +    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
      +    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
      +    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
      +    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
      +    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
      +    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
      +    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
      +    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
      +    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
      +    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
      +    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
      +    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
      +    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
      +]
      +
      +[[package]]
      +name = "packaging"
      +version = "26.2"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
      +]
      +
      +[[package]]
      +name = "pluggy"
      +version = "1.6.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
      +]
      +
      +[[package]]
      +name = "pydantic"
      +version = "2.13.4"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "annotated-types" },
      +    { name = "pydantic-core" },
      +    { name = "typing-extensions" },
      +    { name = "typing-inspection" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
      +]
      +
      +[[package]]
      +name = "pydantic-core"
      +version = "2.46.4"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "typing-extensions" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
      +    { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
      +    { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
      +    { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
      +    { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
      +    { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
      +    { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
      +    { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
      +    { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
      +    { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
      +    { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
      +    { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
      +    { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
      +    { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
      +    { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
      +    { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
      +    { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
      +    { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
      +    { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
      +    { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
      +    { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
      +    { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
      +    { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
      +    { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
      +    { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
      +    { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
      +    { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
      +    { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
      +    { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
      +    { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
      +    { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
      +    { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
      +    { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
      +    { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
      +    { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
      +    { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
      +    { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
      +    { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
      +    { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
      +    { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
      +    { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
      +    { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
      +    { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
      +    { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
      +    { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
      +    { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
      +    { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
      +    { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
      +    { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
      +    { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
      +    { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
      +    { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
      +    { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
      +    { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
      +    { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
      +    { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
      +    { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
      +    { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
      +    { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
      +    { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
      +    { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
      +    { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
      +    { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
      +    { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
      +]
      +
      +[[package]]
      +name = "pydantic-settings"
      +version = "2.14.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "pydantic" },
      +    { name = "python-dotenv" },
      +    { name = "typing-inspection" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" },
      +]
      +
      +[[package]]
      +name = "pygments"
      +version = "2.20.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
      +]
      +
      +[[package]]
      +name = "pytest"
      +version = "9.1.1"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "colorama", marker = "sys_platform == 'win32'" },
      +    { name = "iniconfig" },
      +    { name = "packaging" },
      +    { name = "pluggy" },
      +    { name = "pygments" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
      +]
      +
      +[[package]]
      +name = "python-dotenv"
      +version = "1.2.2"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
      +]
      +
      +[[package]]
      +name = "sqlalchemy"
      +version = "2.0.51"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
      +    { name = "typing-extensions" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
      +    { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
      +    { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
      +    { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
      +    { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
      +    { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
      +    { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
      +    { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
      +    { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
      +    { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
      +    { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
      +    { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
      +    { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
      +    { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
      +    { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
      +    { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
      +    { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
      +    { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
      +    { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
      +    { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
      +    { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
      +    { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
      +    { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
      +    { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
      +    { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
      +    { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
      +    { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
      +    { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
      +    { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
      +]
      +
      +[[package]]
      +name = "starlette"
      +version = "1.3.1"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "anyio" },
      +    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
      +]
      +
      +[[package]]
      +name = "structlog"
      +version = "26.1.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
      +]
      +
      +[[package]]
      +name = "typing-extensions"
      +version = "4.16.0"
      +source = { registry = "https://pypi.org/simple" }
      +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
      +]
      +
      +[[package]]
      +name = "typing-inspection"
      +version = "0.4.2"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "typing-extensions" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
      +]
      +
      +[[package]]
      +name = "uvicorn"
      +version = "0.51.0"
      +source = { registry = "https://pypi.org/simple" }
      +dependencies = [
      +    { name = "click" },
      +    { name = "h11" },
      +]
      +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
      +wheels = [
      +    { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
      +]
      +
      +[[package]]
      +name = "workshop-helmsman"
      +version = "0.2.0"
      +source = { virtual = "." }
      +dependencies = [
      +    { name = "alembic" },
      +    { name = "fastapi" },
      +    { name = "httpx" },
      +    { name = "pydantic-settings" },
      +    { name = "sqlalchemy" },
      +    { name = "structlog" },
      +    { name = "uvicorn" },
      +]
      +
      +[package.dev-dependencies]
      +dev = [
      +    { name = "pytest" },
      +]
      +
      +[package.metadata]
      +requires-dist = [
      +    { name = "alembic", specifier = ">=1.13" },
      +    { name = "fastapi", specifier = ">=0.115" },
      +    { name = "httpx", specifier = ">=0.27" },
      +    { name = "pydantic-settings", specifier = ">=2.0" },
      +    { name = "sqlalchemy", specifier = ">=2.0" },
      +    { name = "structlog", specifier = ">=24.1" },
      +    { name = "uvicorn", specifier = ">=0.30" },
      +]
      +
      +[package.metadata.requires-dev]
      +dev = [{ name = "pytest", specifier = ">=8.0" }]