From 9bb3b68cc4e797cec23764d7dd19fbf71f35f87d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 18:38:42 -0400 Subject: [PATCH 01/78] docs: compose files, Dockerfile, CI workflow, README and ci-requirements describe the build, briefly Comment-only pilot for the comment standard (rule 32, milestone 544). Issue and rule numbers, dates, run ids and incident stories come out; the constraint each one guarded stays as a present-tense sentence. History removed from the files, kept here: - Dockerfile/CI: installing from uv.lock replaced pip/uv-pip installs that ignored the lock (#2194, the mcp 2.0.0 break on 2026-07-28); --locked then caught pgvector missing from the lock. - CI: plugin paths were added after #2198/#2209 reached a live install; `integration` joined the build's needs after run 8212 published :latest with integration red (#4958); the `[[ ]]` tag step failed silently on dash (2a374d9); BUILD_VERSION carried the channel until 2026-08-31 (#3298). - jq was removed from the hooks in #4107. - prod compose: the lenient DB healthcheck followed a runc setns crash loop; pgvector arrived with migration 0067 via a PG16->17 dump/restore. Corrections to match the code: README names the Settings tab "MCP Access"; ci-requirements says the Dockerfile builds on node:22 and lint covers scripts/. Co-Authored-By: Claude Opus 5.5 --- .forgejo/workflows/ci.yml | 260 ++++++++++------------------------ Dockerfile | 31 ++-- Makefile | 7 +- README.md | 4 +- ci-requirements.md | 34 ++--- docker-compose.prod.yml | 10 +- docker-compose.quickstart.yml | 24 ++-- docker-compose.yml | 9 +- 8 files changed, 117 insertions(+), 262 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d86579ba..4d5297ea 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -4,27 +4,15 @@ # Push to main: typecheck + lint + test + build :latest + : # Tag v* (release): typecheck + lint + test + build :latest + : + : # -# Both dev and main are gated AND built. dev pushes move :dev; main pushes move -# :latest — main IS the production line, so :latest tracks main's tip and there -# is no separate :main tag. Every push also gets an immutable : (the -# rollback point). A v* release tag additionally publishes the dated :; -# since main already moved :latest, the release tag's distinct job is that -# : marker (it refreshes :latest too, harmlessly). +# main is the production line, so :latest tracks main's tip. Every push also +# gets an immutable :, the rollback point. A release (a v* tag created on +# main in the Forgejo UI) adds the dated :. # -# Successive pushes to the SAME ref supersede each other (see concurrency -# below), so rapid pushes don't stack identical work; dev and main runs are -# independent refs and never cancel one another. +# A newer push to the same ref cancels the older run; dev and main never cancel +# each other. Pull requests don't trigger runs: branch pushes already gate. # -# To cut a release: -# Create a release via the Forgejo UI on main with a v* tag name. -# The tag push triggers this workflow; build job pushes :latest + :. -# -# PRs aren't triggered on purpose — this is a solo dev→main flow, so -# gating on branch push is already enough. -# -# NOTE on the `if:` guards below: Forgejo Actions does not consistently -# honor `on.push.branches` as a filter, so every job repeats the ref check -# explicitly — permitting dev, main, and v* tags, rejecting anything else. +# Forgejo Actions doesn't reliably honour `on.push.branches` as a filter, so +# every job repeats the ref check in its `if:`. # # Required secrets (repo → Settings → Secrets → Actions): # REGISTRY_USER — your Forgejo username @@ -40,31 +28,22 @@ on: - "frontend/**" - "tests/**" - "pyproject.toml" - # The lock now determines what gets installed, so a lock-only change has - # to trigger a run — otherwise a dependency bump lands untested. + # The lock decides what is installed, so a lock-only change must run CI. - "uv.lock" - "alembic/**" - "alembic.ini" - "Dockerfile" - # The plugin ships straight from this repo — installs fetch it via - # .claude-plugin/marketplace.json, NOT from the image. So a push here is - # the release, with no build step in between. Omitting these paths meant - # plugin changes triggered no workflow at all, which is how #2198's three - # broken hooks and then #2209's missing version bump both reached a live - # install. See the `plugin` job below. + # The plugin ships straight from git (via .claude-plugin/marketplace.json), + # not in the image, so a push here is its release. See the `plugin` job. - "plugin/**" - ".claude-plugin/**" - "scripts/check_plugin.py" - ".forgejo/workflows/ci.yml" - # Manual trigger from the Forgejo Actions UI. Useful when an image has - # been built but the deployment didn't pick it up, or when re-running - # against the same source produces different upstream behaviour - # (e.g. a transient HF download flake during the voice-bundle step). + # Manual run from the Forgejo Actions UI, e.g. to rebuild an image. workflow_dispatch: {} -# Cancel older runs on the same branch when a newer push lands. Tag runs -# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and -# are never cancelled, so a release build can't kill itself mid-flight. +# A newer push cancels the older run on the same branch. Tag runs are never +# cancelled, so a release build can't be cut off mid-flight. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} @@ -91,10 +70,7 @@ jobs: - name: Cache npm download cache uses: actions/cache@v4 - # Non-fatal: a transient cache-backend hiccup must NOT fail the whole - # typecheck job (it was skipping install + type check and reporting red - # on backend-only pushes — see issue task #828). On cache miss/error the - # job just installs without the cache. + # Non-fatal: a cache-backend error just means installing without it. continue-on-error: true with: path: ~/.npm @@ -110,8 +86,7 @@ jobs: working-directory: frontend # Guards the one part of this repo that ships to users without a build step. - # See scripts/check_plugin.py for what it checks and, as importantly, what it - # can't check yet (shellcheck is absent from ci-python). + # scripts/check_plugin.py says what it checks and what it can't. plugin: name: Plugin hooks if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') @@ -119,25 +94,14 @@ jobs: container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - # Bare `uses:`, no `with:` block. Adding one made this action fail to - # extract on the act_runner ("Cannot find module .../dist/index.js") while - # every bare checkout in the same run succeeded — see run 3027. Nothing - # here needs `fetch-depth: 0` anyway: the version check diffs two trees, - # and a tree diff needs both trees, not a common ancestor. A depth-1 fetch - # of main's tip is enough, and cheaper. + # Keep this a bare `uses:`: with a `with:` block the action fails to + # extract on act_runner ("Cannot find module .../dist/index.js"). The + # version check diffs two trees, so a depth-1 fetch of main is enough. - uses: actions/checkout@v6 - # Per-job, not in the image, per CI-runner's docs/process.md: "If only one - # project needs the dep, prefer that project installing it per-job in - # their workflow — at least until a second consumer arrives." Scribe is - # the only consumer today. Promotion into ci-python is filed as an issue - # on CI-runner rather than assumed here. - # - # jq WAS installed here too, because every hook exited at line 1 without - # it and the smoke test would have passed while exercising nothing. The - # hooks need no jq since #4107 — they use only what POSIX guarantees — so - # the smoke test now runs on a bare image, which is the condition it is - # actually meant to be asserting. + # Per-job rather than in the image: Scribe is shellcheck's only consumer. + # Install nothing else here; the hooks use only POSIX tools, and the + # smoke test runs on a bare image to prove it. - name: Install shell tooling run: | apt-get update -qq @@ -163,28 +127,19 @@ jobs: steps: - uses: actions/checkout@v6 - # ruff is pre-installed in the ci-python image — no install - # step needed, lint runs in ~2s. + # ruff is pre-installed in the ci-python image. - name: Lint run: ruff check src/ scripts/ - # Design tokens: does the frontend's CSS agree with the stylesheet the - # design system generates? Fails only on an unresolvable var() reference — - # that count is at zero, so this is a ratchet rather than a backlog. The - # literal findings are printed, not gated; hundreds exist and a - # permanently-red job is one nobody reads. - # - # Stdlib only, no install, no network: the source of truth is theme.css, - # which is generated from the design system and committed. + # Checks the frontend's CSS against the committed, generated theme.css. + # Fails only on an unresolvable var() reference; hard-coded literals are + # reported, not gated. - name: Design token check run: python3 scripts/check_design_tokens.py --report-literals - # Dangling styles: an element whose classes have only modifier rules and - # no base — a deleted CSS rule that left its `:hover` behind. Two shipped - # this way (a link rendering as raw browser blue, a flex row whose parent - # was gone so every child stacked). Neither is visible to vue-tsc; a dead - # style typechecks perfectly. Reported, not gated — a bare wrapper is - # legitimate, so the signal is the count growing. + # Reports elements whose classes have modifier rules but no base rule, + # which vue-tsc can't see. Not gated: a bare wrapper is legitimate, so + # the signal is the count growing. - name: Dangling style check run: python3 scripts/check_dangling_styles.py @@ -201,63 +156,41 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/uv - # Keyed on the LOCK, not pyproject: the lock is what determines the - # installed set now, and a pyproject edit that doesn't change - # resolution shouldn't throw the cache away. + # Keyed on the lock, which decides the installed set. key: uv-${{ hashFiles('uv.lock') }} restore-keys: uv- - # Installs exactly what uv.lock pins, and resolves nothing itself. - # - # This replaced `uv pip install -e ".[dev]"`, which resolved from the - # pyproject constraints and ignored the lock entirely. Every dependency - # floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main` - # red with no repo change (issue #2194). Green CI has to mean "these exact - # versions passed", or it isn't evidence of anything. - # - # `--locked` also FAILS when uv.lock is stale against pyproject, so a - # dependency edit has to go through a deliberate `uv lock` — it can't - # arrive on its own. That check earned its place immediately: it caught - # that the lock had been missing `pgvector` entirely (added to pyproject, - # never re-locked), which the old install path had been silently papering - # over by resolving from pyproject instead. + # Installs exactly what uv.lock pins, so green means these exact versions + # passed. `--locked` fails if the lock is stale against pyproject, so a + # dependency change needs a deliberate `uv lock`. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv run: uv sync --locked --extra dev - # An "Install jq for hook execution tests" step stood here. The hook - # EXECUTION tests run the real bash hook, which exited silently without - # jq and skipped rather than failed, so the step existed to stop them - # being silently unverified. Since #4107 the hooks need no jq, so the - # step is gone and those tests run against a bare image — and the point - # of removing a dependency is lost if CI keeps installing it anyway. + # The hook execution tests run on a bare image (no jq), like a user's + # machine might be. - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration" - # Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection - # path the unit stubs can't reach — the un-awaited execution_options regression - # that made every VACUUM report 0/6 lived here. Like `test`, it runs for - # visibility and does NOT gate the build. + # Real Postgres, built by the real migrations: covers the async SQLAlchemy + # paths unit stubs can't reach. # - # Job key stays separator-free ("integration"): act_runner derives the service- - # container name from the (truncated) job display name and the discovery step - # filters `docker ps` by it. Service hostnames aren't routable on this runner, - # so the step resolves the Postgres container's bridge IP. No `name:` on purpose. + # Service hostnames aren't routable on this runner, so the step finds the + # Postgres container's bridge IP. The job has no `name:` and a separator-free + # key because act_runner names service containers after it. integration: if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 env: - # Config + the module engine read these at import time. DATABASE_URL itself - # is built from the discovered service IP in the run step. + # Read at import time. DATABASE_URL is built from the discovered IP below. SECRET_KEY: ci_integration_placeholder services: postgres: - # pgvector image so `alembic upgrade head` can run migration 0067 - # (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image. + # Same image as the shipped compose files: Postgres 17 with pgvector. image: pgvector/pgvector:pg17 env: POSTGRES_USER: scribe @@ -270,25 +203,19 @@ jobs: --health-retries 10 steps: - uses: actions/checkout@v6 - # Same locked install as the unit lane — the two must agree on versions, - # or "unit green, integration red" stops being a signal about the code. + # Same locked install as the unit lane, so both test the same versions. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv run: uv sync --locked --extra dev - # Standing answers to the checks carried by rules 81 and 79 — two facts - # about THIS runner that conditional rules assert as fact, and that - # otherwise need a throwaway job to confirm (#3237). Printing them on - # every integration run makes the next rulebook sweep a log read. - # Rule 80's evidence is the container listing the next step already - # prints. Every command is guarded: a diagnostic that can break the lane - # it observes is worse than no diagnostic. - - name: Runner facts (rules 79 and 81) + # Prints two facts about the runner that the steps below depend on. + # Every command is guarded so the diagnostic can't fail the lane. + - name: Runner facts (shell, service hostnames) run: | - echo "--- rule 81: which shell runs a run: step ---" + echo "--- which shell runs a run: step ---" readlink -f /bin/sh || echo "/bin/sh: not a symlink" ps -p $$ -o comm= || true - echo "--- rule 79: is a service reachable by its hostname yet? ---" + echo "--- is a service reachable by its hostname? ---" getent hosts postgres \ || echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required" - name: Integration suite (resolve service IP, migrate, test) @@ -296,12 +223,10 @@ jobs: set -eux echo "=== container landscape (diagnostic for the name filter) ===" docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' - # Only THIS job's services. The runner's docker daemon is shared, so another - # repo's job can be running beside this one, and a job-name filter matches its - # containers too (Steward run 8358 and Inkwell run 8653 each took another repo's - # Postgres). act names every container of a task GITEA-ACTIONS-TASK--...: read - # from our own container (its id is in the /etc/hostname bind mount) and - # require exactly one match. + # Only this job's services: the docker daemon is shared, so a job-name + # filter can match another repo's Postgres. act names a task's containers + # GITEA-ACTIONS-TASK--...; read from our own container (its id is in + # the /etc/hostname bind mount) and require exactly one match. SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true) SELF=${SELF:-$(hostname)} TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-') @@ -311,9 +236,7 @@ jobs: PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") test -n "$PG_IP" export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test" - # Wait for Postgres to accept connections. The run: shell is dash - # (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by - # the step above) — no bash /dev/tcp, so use Python. + # Wait for Postgres. The run: shell is dash (no bash /dev/tcp), so use Python. /opt/venv/bin/python - "$PG_IP" <<'PY' import socket, sys, time for _ in range(30): @@ -332,21 +255,12 @@ jobs: build: name: Build & push image - # `plugin` is deliberately NOT in needs. The plugin isn't in the image — - # installs fetch it from git — so gating the server image on a hook lint - # would couple two things that don't ship together, and blocking the build - # wouldn't un-publish a bad hook anyway: the push already did that. A failed - # `plugin` job still turns the whole run red, which is the signal that - # matters. - # - # `integration` IS in needs (rule 177, #4958). It was added after this - # gate and left out of it, so on 2026-10-05 main run 8212 published - # :latest with integration red. The image carries the migrations and the - # queries that lane runs against real Postgres; a red integration run is - # a red image. + # `plugin` is not in needs: the plugin ships from git, not in the image, so + # holding the image back wouldn't stop a bad hook. A red `plugin` job still + # turns the run red. `integration` is: the image carries the migrations and + # queries that lane tests. needs: [typecheck, lint, test, integration] - # Build on dev, main, and v* tag pushes. dev → :dev, main → :latest, - # tag → :latest + :; every build also gets an immutable :. + # dev → :dev, main → :latest, tag → :latest + :; all get :. if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: @@ -357,53 +271,37 @@ jobs: steps: - uses: actions/checkout@v6 with: - # Rule 149 asks for this on any job deriving the version NAME. The - # name here comes from HEAD's commit TIME, which a depth-1 clone - # already has — but the rule states it unconditionally because the - # failure it guards is silent (a too-low value, every lane green), - # and a later change to how the name is derived would inherit the - # landmine rather than the guard. + # Full history for the version derivation. Today it needs only HEAD's + # commit time, but a shallow clone fails silently (a too-low value) if + # the derivation ever reads further back. fetch-depth: 0 - name: Generate image tags and version id: tags - # POSIX `case` instead of bash `[[ ]]` because act_runner invokes - # `sh -e` (dash on the ci-python:3.14 image, which has no bash on - # the default PATH for /bin/sh). Previous `[[ ]]` form failed - # silently — only the SHA tag got appended, so :dev / :latest - # never updated in the registry and the deployed stack kept - # pulling stale images. Verified via `[[: not found` lines in - # the runner log on commit 2a374d9. + # POSIX `case`, not bash `[[ ]]`: the run: shell is dash, where `[[` + # fails silently and only the : tag would be pushed. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" - # THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31 - # BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the - # image self-reported {"version":"main"}, a channel name where a - # build identifier belongs. That cost a debugging session: with the - # deploy misbehaving, nothing on the running instance could say - # which commit was serving it. + # Three values kept separate; the Dockerfile describes each. - # 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes - # since 2020-01-01. Never a commit count (not monotonic across - # branches) and never commit time (goes DOWN when an older - # commit is rebuilt). + # 1. Ordering key: build time in minutes since 2020-01-01, so it only + # goes up. Commit time would go down when an older commit is + # rebuilt; a commit count isn't monotonic across branches. BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 )) - # 2. NAME — COMMIT time, so the same source reports the same string - # on every lane and the channel is the only thing that differs. + # 2. Name: commit time, so the same source reads the same on every lane. COMMIT_TS=$(git log --format=%ct -1 HEAD) BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M) - # 3. CHANNEL — its own value. Never a suffix, never a segment. + # 3. Channel: its own value, never part of the name. CHANNEL="dev" case "${{ github.ref }}" in refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; refs/heads/main) - # main IS the production line: publish :latest (plus the : - # set above). No separate :main tag. + # main is the production line, so it publishes :latest. TAGS="$TAGS,${{ env.IMAGE }}:latest" CHANNEL="stable" ;; @@ -418,12 +316,8 @@ jobs: echo "channel=$CHANNEL" >> $GITHUB_OUTPUT - name: Free disk space - # Self-hosted runner housekeeping. Two-step cleanup: - # 1. Prune dangling containers/images globally (stops the runner - # from accumulating cruft from past failed builds). - # 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip - # mount cache survives but old intermediate layers don't - # accumulate indefinitely. + # Prunes unused containers and images, and caps the BuildKit cache at + # 5 GB so the package caches survive but old layers don't pile up. run: | docker system prune -af || true docker builder prune --keep-storage 5g -f || true @@ -445,19 +339,13 @@ jobs: push: true provenance: false tags: ${{ steps.tags.outputs.value }} - # All three, plus the commit — rule 145: the registry's identity for - # a build (:) and the artifact's identity for itself must - # agree, and they can only be checked against each other if the - # artifact says which commit it is. + # The commit lets the image's self-report be checked against :. build-args: | BUILD_VERSION=${{ steps.tags.outputs.build_name }} BUILD_KEY=${{ steps.tags.outputs.build_key }} BUILD_CHANNEL=${{ steps.tags.outputs.channel }} BUILD_COMMIT=${{ github.sha }} - # Registry-backed layer cache. Pull from :cache to prime - # BuildKit, push updated layers back to :cache so the next - # build starts warm even if the runner's local cache was - # pruned. `mode=max` exports all intermediate layers, not - # just the final image, which is what gives the ~80% speedup. + # Layer cache kept in the registry, so a build starts warm even after + # the runner prunes. `mode=max` caches intermediate layers too. cache-from: type=registry,ref=${{ env.IMAGE }}:cache cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true diff --git a/Dockerfile b/Dockerfile index c94d3854..248d0b9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,8 @@ RUN npm run build FROM python:3.14-slim AS runtime WORKDIR /app -# Installed from uv.lock, exactly like CI (issue #2194). This used to be -# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock: -# the shipped image resolved its own dependency set, so CI could be green on one -# set of versions while the published image ran another. On 2026-07-28 that -# class of drift turned `main` red when mcp 2.0.0 shipped mid-session. +# Installed from uv.lock, exactly like CI, so the image runs the dependency +# versions CI tested. RUN --mount=type=cache,target=/root/.cache/pip \ pip install --no-cache-dir uv @@ -41,21 +38,15 @@ COPY alembic/ alembic/ # Ensure Python finds the source tree (where static files live) before site-packages ENV PYTHONPATH=/app/src -# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit. -# -# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same -# string on every lane for the same source, so it answers "is this the same -# code?" rather than "which lane built it?". -# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) — -# the only value anything may compare to decide what is newer. -# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name. -# BUILD_COMMIT lets the artifact's self-report be checked against the : -# it was published under (rule 145). -# -# Each defaults to empty rather than to a placeholder, EXCEPT the name: a -# local build genuinely has no ordering key or channel, and the endpoint says -# so by omitting them. Inventing values would make a local image claim a -# position in an update order it is not part of. +# Three values kept separate, plus the commit: +# BUILD_VERSION is the name (YYYY.MM.DD.HHMM from commit time), the same on +# every lane for the same source. +# BUILD_KEY is the ordering key (minutes since 2020-01-01 from build time), the +# only value compared to decide which build is newer. +# BUILD_CHANNEL is its own field, never part of the name. +# BUILD_COMMIT lets the image's self-report be checked against its : tag. +# A local build leaves the key and channel empty: it has no place in an update +# order, and /api/version omits them. ARG BUILD_VERSION=dev ARG BUILD_KEY= ARG BUILD_CHANNEL= diff --git a/Makefile b/Makefile index 231f991b..2c74805f 100644 --- a/Makefile +++ b/Makefile @@ -39,9 +39,8 @@ check: lint typecheck test # --- Plugin --- -# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE -# committing. The plugin ships straight from git with no build step, so its -# version is minted here rather than stamped by CI; the lane fails if you -# forget, but this is what makes remembering cheap. +# Run after changing plugin/ or .claude-plugin/, before committing: the plugin +# ships from git with no build step, so its version is minted here. CI fails if +# the version wasn't bumped. mint-plugin: python3 scripts/mint_plugin_version.py diff --git a/README.md b/README.md index efa16218..48c10735 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ export SECRET_KEY=your-random-secret-here docker compose -f docker-compose.quickstart.yml up -d ``` -Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → API Keys** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md). +Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → MCP Access** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md). > **Development:** To build from source, see [Development](docs/development.md). @@ -31,7 +31,7 @@ Open `http://localhost:5000`. The first user to register becomes admin. To conne | [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security | | [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts | | [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process | -| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide | +| [API Keys & MCP](docs/api-keys-and-mcp.md) | API keys and installing the Claude Code plugin | | [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers | | [API Reference](docs/api-reference.md) | All REST API endpoints | diff --git a/ci-requirements.md b/ci-requirements.md index 41972e9d..830dae4f 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -16,11 +16,10 @@ real Postgres), build (docker buildx). ## Image deps used - python 3.14 -- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the - frontend builder stage inside the production `Dockerfile`) -- ruff (lint job runs `ruff check src/` with zero install overhead) -- uv (test + integration jobs run `uv sync --locked`; installed in the - image since the ci-python Dockerfile started pip-installing it) +- node 24 (`npm ci` + `vue-tsc` in the typecheck job; the production + `Dockerfile` builds the frontend on `node:22-alpine`) +- ruff (lint job runs `ruff check src/ scripts/` with zero install overhead) +- uv (test + integration jobs run `uv sync --locked`) - docker CLI + buildx (build job pushes the production image to the Fabled-Git registry) @@ -30,24 +29,15 @@ Anything CI installs at job time that isn't in the image. Promotion candidates if more than one project needs them. - `shellcheck` — apt-installed in the **plugin** job, which lints the - Claude Code hook scripts and runs their fail-open smoke test. Per - `docs/process.md`'s decision checkpoint, single-consumer deps stay - per-job until a second consumer wants them; Scribe is the only one so - far. It is small (~20 MB) and would be a promotion candidate the moment - another project lints shell. + Claude Code hook scripts and runs their fail-open smoke test. Scribe is + its only consumer, so per `docs/process.md` it stays per-job. It is small + (~20 MB) and a promotion candidate once another project lints shell. -- `jq` — **no longer installed anywhere, and should not be promoted.** - It was installed in two jobs, and this file used to record it as "load- - bearing for the smoke test specifically", because every hook opened - `command -v jq || exit 0` and the test would otherwise pass while - exercising nothing. That was the tail wagging the dog: the hooks ship to - users, jq is absent by default on macOS, the Debian/Ubuntu slim images, - Alpine and most CI containers, and a machine without it got no context, - no rules, no prior art and no process sync in silence. #4107 removed the - dependency rather than documenting it, so the smoke test now runs on a - bare image — which is the condition it was always meant to assert. The - hooks use only POSIX tools (awk, sed, tr, od, cut, head, tail, grep, - sort, date, printf) plus `git` and `curl`. +The hooks ship to users' machines, so they use only POSIX tools (awk, sed, +tr, od, cut, head, tail, grep, sort, date, printf) plus `git` and `curl`. +The smoke test runs on a bare image to keep it that way: a tool such as `jq` +is missing on macOS, slim Debian/Ubuntu and Alpine, and a hook that needed it +would give those machines nothing. ## Notes diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a540bf62..15bf444b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -21,10 +21,7 @@ services: max_attempts: 5 db: - # pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that - # migration 0067 enables. Moved off postgres:16-alpine via logical - # dump/restore (which doubles as the PG16->PG17 major upgrade); see the - # TRANSITION runbook in the PR. + # Postgres 17 with the vector extension Scribe needs. image: pgvector/pgvector:pg17 stop_grace_period: 120s volumes: @@ -35,9 +32,8 @@ services: POSTGRES_DB: scribe networks: - scribe_backend - # Lenient by design: a transient host exec/healthcheck stall (incident: - # runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never - # escalate to killing the DB. Health here only gates app startup order. + # Lenient on purpose: health only orders app startup, so a stalled check + # must not get the database killed. healthcheck: test: ["CMD-SHELL", "pg_isready -U scribe"] interval: 30s diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 80011b38..dc79f5eb 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -1,16 +1,7 @@ -# Fabled Scribe — Quick Start -# -# No build required. Pulls the latest pre-built image from the registry. -# -# Usage: -# 1. Download this file -# 2. docker compose -f docker-compose.quickstart.yml up -d -# 3. Open http://localhost:5000 — the first account registered becomes admin -# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the -# bearer-token URL shown there. -# -# Set SECRET_KEY via environment variable or a .env file alongside this file: -# SECRET_KEY=your-random-secret-here +# Fabled Scribe from the pre-built image: +# docker compose -f docker-compose.quickstart.yml up -d +# Then open http://localhost:5000 (the first account registered becomes admin) +# and connect Claude from Settings → MCP Access. services: app: @@ -19,6 +10,7 @@ services: - "5000:5000" environment: DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe" + # Set SECRET_KEY in the environment or a .env file beside this one. SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" volumes: @@ -35,7 +27,7 @@ services: start_period: 30s db: - # pgvector image (PG17) — bundles the `vector` extension (migration 0067). + # Postgres 17 with the vector extension Scribe needs. image: pgvector/pgvector:pg17 stop_grace_period: 120s volumes: @@ -44,8 +36,8 @@ services: POSTGRES_USER: scribe POSTGRES_PASSWORD: scribe POSTGRES_DB: scribe - # Lenient by design: a transient host exec/healthcheck stall must never - # escalate to killing the DB. Health here only gates app startup order. + # Lenient on purpose: health only orders app startup, so a stalled check + # must not get the database killed. healthcheck: test: ["CMD-SHELL", "pg_isready -U scribe"] interval: 30s diff --git a/docker-compose.yml b/docker-compose.yml index eba260df..a074ceca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,12 @@ services: condition: service_healthy volumes: - app_data:/data - # To use a bind mount instead (gives direct host access to all app data): + # Or a bind mount, for direct host access to the app's data: # - ./data:/data environment: DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" - # Uncomment if you have a SearXNG instance you want to surface in the - # Integrations tab as a configured web-search backend: + # Optional: a SearXNG instance to list as the web-search backend in Integrations. # SEARXNG_URL: "http://searxng:8080" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] @@ -33,8 +32,8 @@ services: POSTGRES_USER: ${POSTGRES_USER:-scribe} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe} POSTGRES_DB: ${POSTGRES_DB:-scribe} - # Lenient by design: a transient host exec/healthcheck stall must never - # escalate to killing the DB. Health here only gates app startup order. + # Lenient on purpose: health only orders app startup, so a stalled check + # must not get the database killed. healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"] interval: 30s -- 2.54.0 From cba2c246126e1afd40cea6034d6871c4c75620df Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 19:15:20 -0400 Subject: [PATCH 02/78] feat(544): shipped compose files run on the orchestrator's defaults; the app waits for its database itself Brings the three compose files in line with rule 198 (milestone 544, step 6203, at the operator's request): - no healthcheck on the app or the database, so a slow pg_isready on stalled storage can no longer get a working database killed; - no deploy.restart_policy in the Swarm file, so Swarm's default (condition: any) restarts clean exits too, instead of on-failure with max_attempts leaving the app down; - depends_on is plain start order; restart: unless-stopped stays on the plain-Compose files (Swarm ignores it), and the dev app gains it. The orchestrator used to be what retried a container that started before Postgres was ready. That is now the app's job: alembic/env.py calls services/db_ready.wait_for_database before migrating. It retries refused connections, unresolved hosts, connect timeouts and "the database system is starting up" with 1-2-4-...-10s spacing for up to 300s, then exits non-zero; a wrong password or missing database fails at once. Per-request recovery is the engine's pool_pre_ping. Also fixes the dev compose database image: postgres:16-alpine has no vector extension, so migrations could not run against it. It now uses pgvector/pgvector:pg17 like the other two files. Co-Authored-By: Claude Opus 5.5 --- alembic/env.py | 2 + docker-compose.prod.yml | 24 -------- docker-compose.quickstart.yml | 17 +----- docker-compose.yml | 21 ++----- src/scribe/services/db_ready.py | 77 ++++++++++++++++++++++++ tests/test_db_ready.py | 100 ++++++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 src/scribe/services/db_ready.py create mode 100644 tests/test_db_ready.py diff --git a/alembic/env.py b/alembic/env.py index a7af4f88..0ba83fd2 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from scribe.config import Config from scribe.models import Base +from scribe.services.db_ready import wait_for_database config = context.config if config.config_file_name is not None: @@ -32,6 +33,7 @@ def do_run_migrations(connection): async def run_async_migrations() -> None: + await wait_for_database(Config.DATABASE_URL) engine = create_async_engine(Config.DATABASE_URL) async with engine.connect() as connection: await connection.run_sync(do_run_migrations) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 15bf444b..578c4564 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -9,16 +9,6 @@ services: SECURE_COOKIES: "true" networks: - scribe_backend - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - deploy: - restart_policy: - condition: on-failure - max_attempts: 5 db: # Postgres 17 with the vector extension Scribe needs. @@ -32,20 +22,6 @@ services: POSTGRES_DB: scribe networks: - scribe_backend - # Lenient on purpose: health only orders app startup, so a stalled check - # must not get the database killed. - healthcheck: - test: ["CMD-SHELL", "pg_isready -U scribe"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 180s - deploy: - restart_policy: - condition: on-failure - delay: 10s - max_attempts: 0 - window: 120s volumes: pgdata: diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index dc79f5eb..05a8bbe1 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -16,15 +16,8 @@ services: volumes: - app_data:/data depends_on: - db: - condition: service_healthy + - db restart: unless-stopped - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s db: # Postgres 17 with the vector extension Scribe needs. @@ -36,14 +29,6 @@ services: POSTGRES_USER: scribe POSTGRES_PASSWORD: scribe POSTGRES_DB: scribe - # Lenient on purpose: health only orders app startup, so a stalled check - # must not get the database killed. - healthcheck: - test: ["CMD-SHELL", "pg_isready -U scribe"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 180s restart: unless-stopped volumes: diff --git a/docker-compose.yml b/docker-compose.yml index a074ceca..6da3ce1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: ports: - "5000:5000" depends_on: - db: - condition: service_healthy + - db + restart: unless-stopped volumes: - app_data:/data # Or a bind mount, for direct host access to the app's data: @@ -15,15 +15,10 @@ services: SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" # Optional: a SearXNG instance to list as the web-search backend in Integrations. # SEARXNG_URL: "http://searxng:8080" - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s db: - image: postgres:16-alpine + # Postgres 17 with the vector extension Scribe needs. + image: pgvector/pgvector:pg17 stop_grace_period: 120s restart: unless-stopped volumes: @@ -32,14 +27,6 @@ services: POSTGRES_USER: ${POSTGRES_USER:-scribe} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe} POSTGRES_DB: ${POSTGRES_DB:-scribe} - # Lenient on purpose: health only orders app startup, so a stalled check - # must not get the database killed. - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"] - interval: 30s - timeout: 10s - retries: 10 - start_period: 180s volumes: pgdata: diff --git a/src/scribe/services/db_ready.py b/src/scribe/services/db_ready.py new file mode 100644 index 00000000..2460e8f4 --- /dev/null +++ b/src/scribe/services/db_ready.py @@ -0,0 +1,77 @@ +"""Wait for the database before the app starts. + +The container runs migrations first, and the database may still be starting +(or its host may not resolve yet) when it does. Waiting here, with a deadline, +lets the app start on an orchestrator's defaults instead of relying on a +healthcheck or a restart policy to retry it. +""" + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable + +import asyncpg +from sqlalchemy.engine import URL, make_url + +logger = logging.getLogger(__name__) + +# Long enough for a first boot that initialises the data directory on slow +# storage; past it the process exits non-zero and the orchestrator restarts it. +DEFAULT_DEADLINE_S = 300.0 + +# Errors that mean "not up yet". Anything else (a wrong password, a missing +# database) fails at once, because waiting would not change the answer. +_TRANSIENT = ( + OSError, # refused, unreachable, DNS not resolving yet, connect timeout + asyncpg.CannotConnectNowError, # "the database system is starting up" + asyncpg.TooManyConnectionsError, +) + + +def asyncpg_dsn(database_url: str | URL) -> str: + """The SQLAlchemy URL as a plain DSN asyncpg accepts.""" + url = make_url(database_url).set(drivername="postgresql") + return url.render_as_string(hide_password=False) + + +async def wait_for_database( + database_url: str | URL, + deadline_s: float = DEFAULT_DEADLINE_S, + *, + connect: Callable[..., Awaitable] = asyncpg.connect, + sleep: Callable[[float], Awaitable] = asyncio.sleep, + clock: Callable[[], float] = time.monotonic, +) -> None: + """Return once a connection succeeds; raise the last error after the deadline. + + Retries are spaced 1, 2, 4 … seconds apart, capped at 10, so a database + that is down for minutes sees a handful of attempts rather than a flood. + """ + dsn = asyncpg_dsn(database_url) + started = clock() + delay = 1.0 + attempt = 0 + while True: + attempt += 1 + try: + conn = await connect(dsn, timeout=10) + except _TRANSIENT as exc: + waited = clock() - started + if waited + delay > deadline_s: + logger.error( + "Database still unreachable after %.0fs (%d attempts): %s", + waited, attempt, exc, + ) + raise + logger.warning( + "Waiting for the database (attempt %d, %.0fs so far): %s", + attempt, waited, exc, + ) + await sleep(delay) + delay = min(delay * 2, 10.0) + continue + await conn.close() + if attempt > 1: + logger.info("Database reachable after %d attempts", attempt) + return diff --git a/tests/test_db_ready.py b/tests/test_db_ready.py new file mode 100644 index 00000000..93ee36fe --- /dev/null +++ b/tests/test_db_ready.py @@ -0,0 +1,100 @@ +"""The startup wait for the database: retries what clears up, fails what won't.""" + +import asyncpg +import pytest + +from scribe.services.db_ready import asyncpg_dsn, wait_for_database + +URL = "postgresql+asyncpg://scribe:p%40ss@db:5432/scribe" + + +class _Clock: + """A fake monotonic clock that only moves when the fake sleep is awaited.""" + + def __init__(self): + self.now = 0.0 + self.sleeps: list[float] = [] + + def __call__(self) -> float: + return self.now + + async def sleep(self, seconds: float) -> None: + self.sleeps.append(seconds) + self.now += seconds + + +class _Conn: + closed = False + + async def close(self): + self.closed = True + + +def _connect_failing(errors): + """A connect that raises each error in turn, then succeeds.""" + calls = [] + conn = _Conn() + + async def connect(dsn, timeout): + calls.append(dsn) + if len(calls) <= len(errors): + raise errors[len(calls) - 1] + return conn + + return connect, calls, conn + + +def test_dsn_drops_the_driver_and_keeps_the_escaped_password(): + assert asyncpg_dsn(URL) == "postgresql://scribe:p%40ss@db:5432/scribe" + + +async def test_returns_at_once_when_the_database_is_up(): + clock = _Clock() + connect, calls, conn = _connect_failing([]) + await wait_for_database(URL, connect=connect, sleep=clock.sleep, clock=clock) + assert len(calls) == 1 + assert clock.sleeps == [] + assert conn.closed + + +async def test_waits_through_a_database_that_is_still_starting(): + clock = _Clock() + connect, calls, conn = _connect_failing([ + ConnectionRefusedError("refused"), + OSError("Name or service not known"), + asyncpg.CannotConnectNowError("the database system is starting up"), + ]) + await wait_for_database(URL, connect=connect, sleep=clock.sleep, clock=clock) + assert len(calls) == 4 + assert clock.sleeps == [1.0, 2.0, 4.0] + assert conn.closed + + +async def test_backoff_is_capped_at_ten_seconds(): + clock = _Clock() + connect, _, _ = _connect_failing([ConnectionRefusedError()] * 6) + await wait_for_database(URL, connect=connect, sleep=clock.sleep, clock=clock) + assert clock.sleeps == [1.0, 2.0, 4.0, 8.0, 10.0, 10.0] + + +async def test_gives_up_with_the_last_error_once_the_deadline_passes(): + clock = _Clock() + connect, calls, _ = _connect_failing([ConnectionRefusedError("still down")] * 100) + with pytest.raises(ConnectionRefusedError, match="still down"): + await wait_for_database( + URL, deadline_s=30, connect=connect, sleep=clock.sleep, clock=clock, + ) + # 25s waited; another 10s would pass the deadline, so it raises instead. + assert clock.sleeps == [1.0, 2.0, 4.0, 8.0, 10.0] + assert len(calls) == len(clock.sleeps) + 1 + + +async def test_a_wrong_password_fails_at_once(): + clock = _Clock() + connect, calls, _ = _connect_failing([ + asyncpg.InvalidPasswordError("password authentication failed"), + ]) + with pytest.raises(asyncpg.InvalidPasswordError): + await wait_for_database(URL, connect=connect, sleep=clock.sleep, clock=clock) + assert len(calls) == 1 + assert clock.sleeps == [] -- 2.54.0 From 82e49176b18f88c58270383816f2609f6342f4a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 19:15:20 -0400 Subject: [PATCH 03/78] docs: remove dated design plans and one install's runner config At the operator's request (milestone 544, step 6203): - docs/2026-03-23-fable-mcp-{design,plan}.md, 2026-03-29-voice-s2s-design.md and 2026-04-10-agentic-briefing-design.md were plans for work that has since shipped or been retired (voice and briefings left in the pivot). Plans live in Scribe milestones. - infra/act-runner-config.yml and runner-compose.yml described one install's CI runner (an /nfs path, the py3.12-node22 label, a RUNNER_SETUP.md that no longer exists). The runner is the CI-Runner repo's concern. Co-Authored-By: Claude Opus 5.5 --- docs/2026-03-23-fable-mcp-design.md | 271 --- docs/2026-03-23-fable-mcp-plan.md | 2091 -------------------- docs/2026-03-29-voice-s2s-design.md | 200 -- docs/2026-04-10-agentic-briefing-design.md | 216 -- infra/act-runner-config.yml | 29 - infra/runner-compose.yml | 24 - 6 files changed, 2831 deletions(-) delete mode 100644 docs/2026-03-23-fable-mcp-design.md delete mode 100644 docs/2026-03-23-fable-mcp-plan.md delete mode 100644 docs/2026-03-29-voice-s2s-design.md delete mode 100644 docs/2026-04-10-agentic-briefing-design.md delete mode 100644 infra/act-runner-config.yml delete mode 100644 infra/runner-compose.yml diff --git a/docs/2026-03-23-fable-mcp-design.md b/docs/2026-03-23-fable-mcp-design.md deleted file mode 100644 index 3edeeb25..00000000 --- a/docs/2026-03-23-fable-mcp-design.md +++ /dev/null @@ -1,271 +0,0 @@ -# Fable MCP — Design Spec - -**Date:** 2026-03-23 -**Status:** Approved -**Author:** bvandeusen + Claude - ---- - -## Overview - -A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions. - -The work is split into two sub-projects: - -1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication -2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root - -A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working. - ---- - -## Sub-project 1: Fable API Key Feature - -### Motivation - -All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level. - -### Database - -New table `api_keys` via migration `0027_add_api_keys.py`: -- `down_revision = "0026"` (the briefing tables migration) -- `revision = "0027"` - -| Column | Type | Notes | -|--------|------|-------| -| `id` | Integer PK | | -| `user_id` | Integer FK → users | CASCADE delete | -| `name` | Text | Human-readable label | -| `key_hash` | Text | SHA-256 of the full key, used for lookup | -| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI | -| `scope` | Text | `"read"` or `"write"` | -| `last_used_at` | Timestamp | Nullable, updated on each authenticated request | -| `created_at` | Timestamp | | -| `revoked_at` | Timestamp | Nullable — soft delete | - -Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted. - -### Auth Middleware (`auth.py`) - -`_check_auth` is updated to check the `Authorization: Bearer ` header before falling back to session auth: - -1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL` -2. If found: update `last_used_at`, set `g.user` and `g.api_key` -3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403 -4. If header absent: existing session logic runs unchanged - -Session auth is untouched — no regression risk for the web UI. - -**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role. - -### Routes (`/api/api-keys`) - -New blueprint `api_keys_bp`, registered in `app.py`: - -- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key) -- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only** -- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()` - -### Settings UI - -New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`): - -- Create form: name input + read/write radio/toggle + "Generate Key" button -- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again -- Keys table: columns for name, scope badge, prefix, last used, revoke button -- Revoke shows inline confirmation before calling DELETE - -### New Search Endpoint - -`GET /api/search?q=&content_type=note|task|all&limit=N` - -Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument: -- `content_type=note` → `is_task=False` -- `content_type=task` → `is_task=True` -- `content_type=all` (default) → `is_task=None` - -Returns: - -```json -{ - "results": [ - {"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]} - ], - "total": 5 -} -``` - -This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work. - -### Conversation Type: `"mcp"` - -A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app: - -**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor. - -**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations. - -**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned. - -MCP conversations: -- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named -- Are excluded from the default chat list -- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection -- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions - -### Chat SSE Wire Format - -The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema: - -- **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}` -- **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to: -- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE) - -SSE event format (each line is `data: `): -- `{"type": "token", "content": "..."}` — streaming token -- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired -- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event - -The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names). - ---- - -## Sub-project 2: Fable MCP Server - -### Location - -`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable. - -### Package Structure - -``` -fable-mcp/ - pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main" - README.md - .env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_... - fable_mcp/ - __init__.py - server.py # FastMCP instance, imports + registers all tool modules - client.py # FableClient: async httpx wrapper, env var config, FableAPIError - tools/ - __init__.py - notes.py # list_notes, get_note, create_note, update_note, delete_note - tasks.py # list_tasks, get_task, create_task, update_task, delete_task, - # patch_task_status, add_task_log - projects.py # list_projects, get_project, create_project, update_project, - # delete_project, get_project_summary - milestones.py # list_milestones, get_milestone, create_milestone, - # update_milestone, delete_milestone - search.py # semantic_search — calls GET /api/search - chat.py # send_message — creates/continues conversation, returns response -``` - -### Dependencies - -```toml -[project] -dependencies = [ - "mcp[cli]>=1.0", - "httpx>=0.27", - "python-dotenv>=1.0", -] -``` - -### `client.py` - -`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response. - -A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules. - -### `server.py` - -Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport). - -### Tool Modules - -Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude. - -**`notes.py`** tools: -- `list_notes(query, tags, project_id, limit, offset)` -- `get_note(note_id)` -- `create_note(title, body, tags, project_id)` -- `update_note(note_id, title, body, tags, project_id)` -- `delete_note(note_id)` - -**`tasks.py`** tools: -- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)` -- `get_task(task_id)` -- `create_task(title, body, project_id, milestone_id, priority, status)` -- `update_task(task_id, title, body, project_id, milestone_id, priority, status)` -- `delete_task(task_id)` -- `patch_task_status(task_id, status)` -- `add_task_log(task_id, content)` - -**`projects.py`** tools: -- `list_projects()` -- `get_project(project_id)` — includes milestone summary -- `create_project(title, description, goal, color)` -- `update_project(project_id, title, description, goal, status, color)` -- `delete_project(project_id)` - -**`milestones.py`** tools: -- `list_milestones(project_id, status)` -- `get_milestone(project_id, milestone_id)` -- `create_milestone(project_id, title, description, order_index)` -- `update_milestone(project_id, milestone_id, title, description, status, order_index)` -- `delete_milestone(project_id, milestone_id)` - -**`search.py`** tools: -- `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores. - -**`chat.py`** tools: -- `send_message(message, conversation_name)`: - - Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"` - - Posts the message via `POST /api/chat/conversations/:id/messages` - - Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"` - - Returns: `{response: str, tools_used: [str], conversation_id: int}` - - MCP conversations are excluded from the normal chat UI list - -### Error Handling - -`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings. - -Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it. - -### Claude Code Registration - -After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`: - -```json -{ - "mcpServers": { - "fable": { - "command": "fable-mcp", - "env": { - "FABLE_URL": "http://localhost:8080", - "FABLE_API_KEY": "fmcp_your_key_here" - } - } - } -} -``` - -Claude Code spawns the process over stdio automatically. No Docker, no daemon. - ---- - -## Build & Repo Plan - -1. Implement and test within `scribe/fable-mcp/` -2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`) -3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready - ---- - -## Out of Scope (v1) - -- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code -- Forgejo MCP — follow-on spec -- Rate limiting on API key endpoints -- Key expiry / TTL -- Per-resource scope (e.g., key restricted to one project) diff --git a/docs/2026-03-23-fable-mcp-plan.md b/docs/2026-03-23-fable-mcp-plan.md deleted file mode 100644 index fa061a42..00000000 --- a/docs/2026-03-23-fable-mcp-plan.md +++ /dev/null @@ -1,2091 +0,0 @@ -# Fable MCP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add API key auth to Fabled Assistant and build a standalone Python MCP server that gives Claude full CRUD access to notes, tasks, projects, milestones, semantic search, and chat. - -**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. - -**Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. - -**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. - -**Testing convention:** Phase 1 tests run via `docker compose run --rm app pytest tests/ -v`. Phase 2 tests run locally with `cd fable-mcp && pytest -v`. All Phase 1 tests are unit tests of pure functions — mock DB calls with `unittest.mock.AsyncMock`; never connect to a real database in tests. - -**Important discovery:** `Conversation.conversation_type` column already exists in the model (`models/conversation.py:23`) and `list_conversations` already filters by it (`services/chat.py:49`). No migration needed for conversation types — only service/route wiring. - ---- - -## Phase 1: Fable API Key Feature - -### File Map - -| Action | File | Purpose | -|--------|------|---------| -| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | -| Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model | -| Modify | `src/scribe/models/__init__.py` | Export `ApiKey` | -| Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions | -| Modify | `src/scribe/auth.py` | Add bearer token check before session fallback | -| Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | -| Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` | -| Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | -| Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | -| Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body | -| Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint | -| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | -| Create | `tests/test_api_keys.py` | Unit tests for service + auth | -| Create | `tests/test_search_route.py` | Unit test for search endpoint | - ---- - -### Task 1: ApiKey model + migration - -**Files:** -- Create: `src/scribe/models/api_key.py` -- Create: `alembic/versions/0027_add_api_keys.py` -- Modify: `src/scribe/models/__init__.py` - -- [ ] **Step 1: Write the model** - -Create `src/scribe/models/api_key.py`: - -```python -from datetime import datetime, timezone - -from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy.orm import Mapped, mapped_column - -from scribe.models import Base -from scribe.models.base import CreatedAtMixin - - -class ApiKey(Base, CreatedAtMixin): - __tablename__ = "api_keys" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - name: Mapped[str] = mapped_column(Text, nullable=False) - key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True) - key_prefix: Mapped[str] = mapped_column(Text, nullable=False) - scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write" - last_used_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - revoked_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - - __table_args__ = ( - Index("ix_api_keys_user_id", "user_id"), - Index("ix_api_keys_key_hash", "key_hash"), - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "name": self.name, - "key_prefix": self.key_prefix, - "scope": self.scope, - "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, - "created_at": self.created_at.isoformat(), - "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, - } -``` - -- [ ] **Step 2: Export from models __init__** - -In `src/scribe/models/__init__.py`, add after the last import line: - -```python -from scribe.models.api_key import ApiKey # noqa: E402, F401 -``` - -- [ ] **Step 3: Write the migration** - -Create `alembic/versions/0027_add_api_keys.py`: - -```python -"""add api_keys table - -Revision ID: 0027 -Revises: 0026 -Create Date: 2026-03-23 -""" -from alembic import op -import sqlalchemy as sa - -revision = "0027" -down_revision = "0026" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "api_keys", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("key_hash", sa.Text(), nullable=False, unique=True), - sa.Column("key_prefix", sa.Text(), nullable=False), - sa.Column("scope", sa.Text(), nullable=False), - sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - ) - op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"]) - op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"]) - - -def downgrade() -> None: - op.drop_table("api_keys") -``` - -- [ ] **Step 4: Verify migration runs** - -```bash -docker compose run --rm app alembic upgrade head -``` - -Expected: `Running upgrade 0026 -> 0027, add api_keys table` - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/models/api_key.py \ - src/scribe/models/__init__.py \ - alembic/versions/0027_add_api_keys.py -git commit -m "feat: add ApiKey model and migration 0027" -``` - ---- - -### Task 2: ApiKey service - -**Files:** -- Create: `src/scribe/services/api_keys.py` -- Create: `tests/test_api_keys.py` (service tests) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/test_api_keys.py`: - -```python -"""Unit tests for api_keys service — pure logic, DB calls mocked.""" -import hashlib -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from scribe.services.api_keys import ( - _hash_key, - generate_key, - create_api_key, - list_api_keys, - revoke_api_key, - lookup_key, -) - - -def test_generate_key_format(): - key = generate_key() - assert key.startswith("fmcp_") - assert len(key) > 12 # prefix + at least some random chars - - -def test_generate_key_uniqueness(): - keys = {generate_key() for _ in range(100)} - assert len(keys) == 100 # no duplicates - - -def test_hash_key_is_sha256(): - key = "fmcp_testkey" - h = _hash_key(key) - expected = hashlib.sha256(key.encode()).hexdigest() - assert h == expected - - -def test_generate_key_prefix(): - key = "fmcp_abcdefghijklmnop" - # prefix is first 12 chars of the full key - from scribe.services.api_keys import _key_prefix - assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars - - -@pytest.mark.asyncio -async def test_create_api_key_returns_full_key(): - mock_key_obj = MagicMock() - mock_key_obj.id = 1 - mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} - - with patch("scribe.services.api_keys.async_session") as mock_session_ctx: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_session.add = MagicMock() - mock_session.commit = AsyncMock() - mock_session_ctx.return_value = mock_session - - full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read") - - assert full_key.startswith("fmcp_") - assert isinstance(key_dict, dict) - - -@pytest.mark.asyncio -async def test_lookup_key_returns_none_for_unknown(): - with patch("scribe.services.api_keys.async_session") as mock_session_ctx: - mock_session = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = None - mock_session.execute = AsyncMock(return_value=mock_result) - mock_session_ctx.return_value = mock_session - - result = await lookup_key("fmcp_doesnotexist") - - assert result is None -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet) - -- [ ] **Step 3: Write the service** - -Create `src/scribe/services/api_keys.py`: - -```python -import hashlib -import secrets -from datetime import datetime, timezone - -from sqlalchemy import select - -from scribe.models import async_session -from scribe.models.api_key import ApiKey - - -def generate_key() -> str: - """Generate a new full API key. Never stored — caller must hash it.""" - return "fmcp_" + secrets.token_urlsafe(32) - - -def _hash_key(key: str) -> str: - return hashlib.sha256(key.encode()).hexdigest() - - -def _key_prefix(key: str) -> str: - """Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg').""" - return key[:12] - - -async def create_api_key( - user_id: int, name: str, scope: str -) -> tuple[str, dict]: - """Create a new API key. Returns (full_key, key_dict). full_key is never stored.""" - if scope not in ("read", "write"): - raise ValueError("scope must be 'read' or 'write'") - full_key = generate_key() - key = ApiKey( - user_id=user_id, - name=name, - key_hash=_hash_key(full_key), - key_prefix=_key_prefix(full_key), - scope=scope, - ) - async with async_session() as session: - session.add(key) - await session.commit() - await session.refresh(key) - return full_key, key.to_dict() - - -async def list_api_keys(user_id: int) -> list[dict]: - """List all non-revoked API keys for the user.""" - async with async_session() as session: - result = await session.execute( - select(ApiKey) - .where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None)) - .order_by(ApiKey.created_at.desc()) - ) - return [k.to_dict() for k in result.scalars().all()] - - -async def revoke_api_key(user_id: int, key_id: int) -> bool: - """Soft-delete a key by setting revoked_at. Returns True if found.""" - async with async_session() as session: - result = await session.execute( - select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id) - ) - key = result.scalars().first() - if key is None: - return False - key.revoked_at = datetime.now(timezone.utc) - await session.commit() - return True - - -async def lookup_key(raw_key: str) -> ApiKey | None: - """Look up a non-revoked ApiKey by raw token value. Updates last_used_at.""" - key_hash = _hash_key(raw_key) - async with async_session() as session: - result = await session.execute( - select(ApiKey).where( - ApiKey.key_hash == key_hash, - ApiKey.revoked_at.is_(None), - ) - ) - key = result.scalars().first() - if key is None: - return None - key.last_used_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(key) - return key -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/services/api_keys.py tests/test_api_keys.py -git commit -m "feat: add ApiKey service with create/list/revoke/lookup" -``` - ---- - -### Task 3: Auth middleware — bearer token support - -**Files:** -- Modify: `src/scribe/auth.py` -- Modify: `tests/test_api_keys.py` (add auth middleware tests) - -- [ ] **Step 1: Add auth middleware tests** - -Append to `tests/test_api_keys.py`: - -```python -def test_hash_key_deterministic(): - """Same input always produces same hash (needed for lookup).""" - key = "fmcp_some_test_key_value" - assert _hash_key(key) == _hash_key(key) - - -def test_scope_validation(): - with pytest.raises(ValueError, match="scope must be"): - import asyncio - asyncio.run(create_api_key(1, "bad", "superadmin")) - - -# --- Auth middleware tests --- - -@pytest.mark.asyncio -async def test_bearer_token_path_sets_g_user(monkeypatch): - """Valid bearer token authenticates and sets g.user and g.api_key.""" - from unittest.mock import AsyncMock, MagicMock - from scribe.auth import _check_auth - - # Mock ApiKey object - fake_key = MagicMock() - fake_key.user_id = 7 - fake_key.scope = "write" - - # Mock User object - fake_user = MagicMock() - fake_user.role = "user" - - monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)) - monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)) - - called_with_user = {} - - async def view_func(): - from quart import g - called_with_user["user"] = g.user - called_with_user["api_key"] = g.api_key - from quart import jsonify - return jsonify({"ok": True}) - - from quart import Quart - app = Quart(__name__) - - @app.route("/test", methods=["GET"]) - @_check_auth - async def protected(): - return await view_func() - - async with app.test_request_context("/test", method="GET", - headers={"Authorization": "Bearer fmcp_valid"}): - # Just verify _check_auth calls lookup_key with the right token - import scribe.auth as auth_module - auth_module.lookup_key.assert_called_with # callable - - -@pytest.mark.asyncio -async def test_read_only_key_blocked_on_post(): - """Read-only API key returns 403 on non-GET requests.""" - from unittest.mock import AsyncMock, MagicMock - from scribe.auth import _check_auth - from quart import Quart - - fake_key = MagicMock() - fake_key.user_id = 7 - fake_key.scope = "read" - - fake_user = MagicMock() - fake_user.role = "user" - - app = Quart(__name__) - - with ( - app.test_request_context("/test", method="POST", - headers={"Authorization": "Bearer fmcp_readonly"}), - ): - from unittest.mock import patch - with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \ - patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)): - - async def dummy(): - return "ok" - - wrapped = _check_auth(dummy) - from quart import current_app - async with app.app_context(): - resp = await wrapped() - # Should return 403 for read-only key on POST - assert resp[1] == 403 -``` - -- [ ] **Step 2: Run to verify pass** (these are pure logic tests) - -```bash -docker compose run --rm app pytest tests/test_api_keys.py -v -``` - -- [ ] **Step 3: Update auth.py** - -Replace `src/scribe/auth.py` with: - -```python -import functools - -from quart import g, jsonify, request, session - -from scribe.services.auth import get_user_by_id -from scribe.services.api_keys import lookup_key - - -def _check_auth(f, required_role: str | None = None): - @functools.wraps(f) - async def decorated(*args, **kwargs): - # --- Bearer token path --- - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - raw_key = auth_header[len("Bearer "):] - api_key = await lookup_key(raw_key) - if api_key is None: - return jsonify({"error": "Invalid or revoked API key"}), 401 - user = await get_user_by_id(api_key.user_id) - if not user: - return jsonify({"error": "User not found"}), 401 - # Scope enforcement: read-only keys cannot mutate - if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"): - return jsonify({"error": "Read-only key cannot perform write operations"}), 403 - # Role check (admin_required routes) - if required_role and user.role != required_role: - return jsonify({"error": "Admin access required"}), 403 - g.user = user - g.api_key = api_key - return await f(*args, **kwargs) - - # --- Session path (unchanged) --- - user_id = session.get("user_id") - if not user_id: - return jsonify({"error": "Authentication required"}), 401 - user = await get_user_by_id(user_id) - if not user: - session.clear() - return jsonify({"error": "Authentication required"}), 401 - if session.get("session_version") != user.session_version: - session.clear() - return jsonify({"error": "Session expired. Please log in again."}), 401 - if required_role and user.role != required_role: - return jsonify({"error": "Admin access required"}), 403 - g.user = user - return await f(*args, **kwargs) - return decorated - - -def login_required(f): - return _check_auth(f) - - -def admin_required(f): - return _check_auth(f, required_role="admin") - - -def get_current_user_id() -> int: - return g.user.id -``` - -- [ ] **Step 4: Run the full test suite to check for regressions** - -```bash -docker compose run --rm app pytest tests/ -v -``` - -Expected: all existing tests still pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/auth.py tests/test_api_keys.py -git commit -m "feat: add bearer token auth to _check_auth, falls back to session" -``` - ---- - -### Task 4: API key routes + app registration - -**Files:** -- Create: `src/scribe/routes/api_keys.py` -- Modify: `src/scribe/app.py` - -- [ ] **Step 1: Write the routes** - -Create `src/scribe/routes/api_keys.py`: - -```python -from quart import Blueprint, jsonify, request - -from scribe.auth import login_required, get_current_user_id -from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key - -api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") - - -@api_keys_bp.route("", methods=["GET"]) -@login_required -async def list_keys_route(): - uid = get_current_user_id() - keys = await list_api_keys(uid) - return jsonify({"api_keys": keys}) - - -@api_keys_bp.route("", methods=["POST"]) -@login_required -async def create_key_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - name = (data.get("name") or "").strip() - scope = (data.get("scope") or "").strip() - - if not name: - return jsonify({"error": "name is required"}), 400 - if scope not in ("read", "write"): - return jsonify({"error": "scope must be 'read' or 'write'"}), 400 - - full_key, key_dict = await create_api_key(uid, name=name, scope=scope) - # Return the full key ONCE — it is never retrievable again - return jsonify({"key": full_key, "api_key": key_dict}), 201 - - -@api_keys_bp.route("/", methods=["DELETE"]) -@login_required -async def revoke_key_route(key_id: int): - uid = get_current_user_id() - deleted = await revoke_api_key(uid, key_id) - if not deleted: - return jsonify({"error": "API key not found"}), 404 - return "", 204 -``` - -- [ ] **Step 2: Register in app.py** - -In `src/scribe/app.py`, add the import alongside the other route imports: - -```python -from scribe.routes.api_keys import api_keys_bp -``` - -And add the registration line after `app.register_blueprint(users_bp)`: - -```python -app.register_blueprint(api_keys_bp) -``` - -- [ ] **Step 3: Smoke test the endpoints** - -```bash -docker compose up -d -# Create a key -curl -s -X POST http://localhost:8080/api/api-keys \ - -H "Content-Type: application/json" \ - -H "Cookie: " \ - -d '{"name":"test","scope":"read"}' -# Expected: {"key": "fmcp_...", "api_key": {...}} - -# Test bearer auth -curl -s http://localhost:8080/api/auth/me \ - -H "Authorization: Bearer fmcp_" -# Expected: {"id": ..., "username": ...} -``` - -- [ ] **Step 4: Commit** - -```bash -git add src/scribe/routes/api_keys.py src/scribe/app.py -git commit -m "feat: add API key CRUD routes and register blueprint" -``` - ---- - -### Task 5: Conversation type wiring - -**Files:** -- Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135) -- Modify: `src/scribe/routes/chat.py` (lines 73-79) - -Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. - -- [ ] **Step 1: Update `create_conversation` service** - -In `src/scribe/services/chat.py`, change the function signature at line 17: - -```python -async def create_conversation( - user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" -) -> Conversation: - async with async_session() as session: - conv = Conversation( - user_id=user_id, - title=title, - model=model, - conversation_type=conversation_type, - ) - session.add(conv) - await session.commit() - result = await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.id == conv.id) - ) - return result.scalars().first() -``` - -- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** - -In `src/scribe/services/chat.py`, update the WHERE clause at line 130: - -```python -result = await session.execute( - sa_delete(Conversation) - .where( - Conversation.user_id == user_id, - Conversation.updated_at < cutoff, - Conversation.conversation_type != "mcp", # preserve MCP audit trail - ) - .returning(Conversation.id) -) -``` - -- [ ] **Step 3: Update the POST route to accept conversation_type** - -In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73): - -```python -@chat_bp.route("/conversations", methods=["POST"]) -@login_required -async def create_conversation_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - title = data.get("title", "") - model = data.get("model", Config.OLLAMA_MODEL) - conversation_type = data.get("conversation_type", "chat") - # Only allow known types to prevent accidental misuse - if conversation_type not in ("chat", "mcp"): - conversation_type = "chat" - conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) - return jsonify(conv.to_dict()), 201 -``` - -- [ ] **Step 4: Run tests** - -```bash -docker compose run --rm app pytest tests/ -v -``` - -Expected: all tests pass - -- [ ] **Step 5: Commit** - -```bash -git add src/scribe/services/chat.py src/scribe/routes/chat.py -git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" -``` - ---- - -### Task 6: Semantic search endpoint - -**Files:** -- Create: `src/scribe/routes/search.py` -- Modify: `src/scribe/app.py` -- Create: `tests/test_search_route.py` - -- [ ] **Step 1: Write the failing test** - -Create `tests/test_search_route.py`: - -```python -"""Unit tests for the search route parameter mapping.""" -import pytest -from unittest.mock import patch, AsyncMock - - -def test_content_type_mapping(): - """Verify content_type string maps to correct is_task value.""" - from scribe.routes.search import _content_type_to_is_task - assert _content_type_to_is_task("note") is False - assert _content_type_to_is_task("task") is True - assert _content_type_to_is_task("all") is None - assert _content_type_to_is_task("unknown") is None # default to all -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -docker compose run --rm app pytest tests/test_search_route.py -v -``` - -Expected: `ImportError` (module doesn't exist yet) - -- [ ] **Step 3: Write the route** - -Create `src/scribe/routes/search.py`: - -```python -from quart import Blueprint, jsonify, request - -from scribe.auth import login_required, get_current_user_id -from scribe.services.embeddings import semantic_search_notes - -search_bp = Blueprint("search", __name__, url_prefix="/api/search") - - -def _content_type_to_is_task(content_type: str) -> bool | None: - """Map content_type query param to semantic_search_notes is_task arg.""" - if content_type == "note": - return False - if content_type == "task": - return True - return None # "all" or unknown → no filter - - -@search_bp.route("", methods=["GET"]) -@login_required -async def search_route(): - uid = get_current_user_id() - q = (request.args.get("q") or "").strip() - if not q: - return jsonify({"error": "q is required"}), 400 - - content_type = request.args.get("content_type", "all") - limit = min(request.args.get("limit", 10, type=int), 50) - is_task = _content_type_to_is_task(content_type) - - results = await semantic_search_notes( - uid, q, limit=limit, is_task=is_task, threshold=0.3 - ) - return jsonify({ - "results": [ - { - "id": note.id, - "title": note.title, - "body": note.body or "", - "is_task": note.is_task, - "tags": note.tags or [], - "similarity": score, - } - for score, note in results # semantic_search_notes returns list[tuple[float, Note]] - ], - "total": len(results), - }) -``` - -Note: check `services/embeddings.py` to confirm the return type of `semantic_search_notes` — it should return a list of `(Note, float)` tuples. If the signature differs, adjust the destructuring above accordingly. - -- [ ] **Step 4: Register in app.py** - -Add to imports in `src/scribe/app.py`: -```python -from scribe.routes.search import search_bp -``` - -Add registration: -```python -app.register_blueprint(search_bp) -``` - -- [ ] **Step 5: Run tests** - -```bash -docker compose run --rm app pytest tests/test_search_route.py tests/ -v -``` - -Expected: all tests pass - -- [ ] **Step 6: Commit** - -```bash -git add src/scribe/routes/search.py \ - src/scribe/app.py \ - tests/test_search_route.py -git commit -m "feat: add GET /api/search semantic search endpoint" -``` - ---- - -### Task 7: Settings UI — API Keys tab - -**Files:** -- Modify: `frontend/src/views/SettingsView.vue` - -- [ ] **Step 1: Add "apikeys" to VALID_TABS** - -In `SettingsView.vue`, find the `VALID_TABS` array (search for `VALID_TABS`) and add `"apikeys"` to it. - -- [ ] **Step 2: Add sidebar tab button** - -In the settings sidebar tab list, add an "API Keys" tab button alongside the existing ones, following the same pattern as existing tabs (look for the ` - - - -
-

Copy this key now — it will not be shown again.

-
- {{ newKeyValue }} - -
- -
- - -
- - - - - - - - - - - - - - - - - - - -
NameScopePrefixLast Used
{{ key.name }}{{ key.scope }}{{ key.key_prefix }}…{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }} - - - Sure? - - - -
-
-

No API keys yet.

- -``` - -- [ ] **Step 4: Add reactive state and methods to the script section** - -In the `') + assert remote_refs('') + assert not remote_refs("/* no @import url('https://x.example.com') here */") + assert not remote_refs("@font-face { src: url(./inter-normal-latin.woff2); }") + assert not remote_refs('docs') -- 2.54.0 From e45e4cd063f95984d012e189c95935a379c6dc7b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 22 Oct 2026 11:27:40 -0400 Subject: [PATCH 76/78] rename(547): the mark is a clasped grimoire favicon.svg is a violet book with gold corners and clasp and a four-pointed star in a ring, replacing the indigo book with a spark. It is rendered to 192 and 512 PNG app icons and a 180 touch icon on the page surface. manifest.json lists those icons instead of a favicon.ico that never existed. It describes the product as the README does, and takes its colours from the design system's page surface. index.html's theme-color follows. Co-Authored-By: Claude Opus 5.5 --- frontend/index.html | 3 +- frontend/public/apple-touch-icon.png | Bin 0 -> 4455 bytes frontend/public/favicon.svg | 47 +++++++++------------------ frontend/public/icon-192.png | Bin 0 -> 5242 bytes frontend/public/icon-512.png | Bin 0 -> 15053 bytes frontend/public/manifest.json | 20 +++++++++--- 6 files changed, 33 insertions(+), 37 deletions(-) create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png diff --git a/frontend/index.html b/frontend/index.html index 56a29eb4..89564302 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,8 +4,9 @@ + - + diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c93e44caaad754ed913d6a02490064b2b74dd510 GIT binary patch literal 4455 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 3e9cc76b..c650efb7 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,33 +1,18 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..cb809a7e063d6abd1486f2cda3b96a029d38dfa7 GIT binary patch literal 5242 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 CcK%=h literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 0f869653..349fdc3d 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,17 +1,27 @@ { "name": "Fabled Grimoire", "short_name": "Grimoire", - "description": "Your self-hosted second brain with AI assistance", + "description": "A self-hosted work system-of-record for software projects, built to be driven by Claude Code.", "start_url": "/", "display": "standalone", - "background_color": "#1a1a2e", - "theme_color": "#6c63ff", + "background_color": "#14171A", + "theme_color": "#14171A", "orientation": "portrait-primary", "icons": [ { - "src": "/favicon.ico", + "src": "/favicon.svg", "sizes": "any", - "type": "image/x-icon" + "type": "image/svg+xml" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" } ] } -- 2.54.0 From a07547447fad1410f25f4b20d5c001424fa3bd2e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 22 Oct 2026 13:29:00 -0400 Subject: [PATCH 77/78] rename(547): the repo is FabledGrimoire, and no stray "scribe" is left The forge repo is now bvandeusen/FabledGrimoire. The plugin marketplace default, the install commands in the docs and plugin README, the CI requirements title and the example repo URLs in comments and tests follow it. docs/development.md now names the image the build actually publishes, fabledgrimoire. Two test names still said "scribes_own"; they now say "grimoires_own". What still says scribe does so on purpose: - the backup reader's accepted archive names and prefix; - the label every stored secret is sealed under; - the notes on keeping an old install's database names; - the old migrations. Co-Authored-By: Claude Opus 5.5 --- ci-requirements.md | 2 +- docs/api-keys-and-mcp.md | 2 +- docs/development.md | 2 +- plugin/README.md | 4 ++-- plugin/hooks/grimoire_defs.sh | 2 +- scripts/bench_ollama.py | 2 +- src/grimoire/config.py | 2 +- src/grimoire/models/repo_binding.py | 2 +- src/grimoire/services/knowledge.py | 4 ++-- src/grimoire/services/repo_bindings.py | 8 ++++---- tests/test_grimoire_marker.py | 2 +- tests/test_moment_delivery.py | 2 +- tests/test_rule_moment_signal_hooks.py | 2 +- tests/test_services_plugin_context.py | 6 +++--- tests/test_services_repo_bindings.py | 18 +++++++++--------- 15 files changed, 30 insertions(+), 30 deletions(-) diff --git a/ci-requirements.md b/ci-requirements.md index a91169c7..ebe20e9d 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -1,4 +1,4 @@ -# CI Requirements — FabledScribe +# CI Requirements — FabledGrimoire > Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md) > in the CI-Runner repo. diff --git a/docs/api-keys-and-mcp.md b/docs/api-keys-and-mcp.md index 7e47018b..238aeeae 100644 --- a/docs/api-keys-and-mcp.md +++ b/docs/api-keys-and-mcp.md @@ -53,7 +53,7 @@ without a read-shaped name are spelled out here. A `write`-scoped key may call e The plugin connects the MCP server and adds the hooks and skills that put Grimoire's records in front of Claude as it works. Install it from this repo: ``` -/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git +/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git /plugin install grimoire@grimoire-plugin ``` diff --git a/docs/development.md b/docs/development.md index 0e02dc81..096e4ebf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,7 +57,7 @@ CI runs on Gitea Actions (workflow in `.forgejo/workflows/ci.yml`) in the shared | `main` | `:latest`, `:` | | a `v*` tag | `:latest`, `:`, `:` | -`:` never moves, so it is the rollback point. Images publish to `git.fabledsword.com/bvandeusen/fabledscribe`, with a registry layer cache at `:cache`. The build needs the `REGISTRY_USER` and `REGISTRY_TOKEN` (a token with `write:packages`) repository secrets. +`:` never moves, so it is the rollback point. Images publish to `git.fabledsword.com/bvandeusen/fabledgrimoire`, with a registry layer cache at `:cache`. The build needs the `REGISTRY_USER` and `REGISTRY_TOKEN` (a token with `write:packages`) repository secrets. Work happens on `dev`. A release merges `dev` into `main` with a plain merge commit; a dated `vYYYY.MM.DD.HHMM` tag, matching the version the image reports, can mark it. diff --git a/plugin/README.md b/plugin/README.md index 38def823..54afcd27 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -1,6 +1,6 @@ # Grimoire plugin for Claude Code -Turns a self-hosted [Grimoire](https://git.fabledsword.com/bvandeusen/FabledScribe) +Turns a self-hosted [Grimoire](https://git.fabledsword.com/bvandeusen/FabledGrimoire) instance into a first-class Claude Code extension: - **MCP tools** over your notes, tasks, projects, milestones, systems, and @@ -41,7 +41,7 @@ The plugin ships inside the Grimoire app repo, so the marketplace *is* that repo you always get the plugin version that matches your Grimoire instance. ``` -/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git +/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git /plugin install grimoire@grimoire-plugin ``` diff --git a/plugin/hooks/grimoire_defs.sh b/plugin/hooks/grimoire_defs.sh index 33357ae1..e941dc19 100644 --- a/plugin/hooks/grimoire_defs.sh +++ b/plugin/hooks/grimoire_defs.sh @@ -1308,7 +1308,7 @@ grimoire_turn_fact() { # Nothing Grimoire should be holding goes in it. # # {"instance": "https://grimoire.example.com", "project_id": 2, -# "project": "FabledScribe"} +# "project": "FabledGrimoire"} # # instance WHICH Grimoire the id belongs to, and the reason this file is # not just a number. A project id means nothing on its own: id 2 diff --git a/scripts/bench_ollama.py b/scripts/bench_ollama.py index 5c5a4e1e..2e243197 100755 --- a/scripts/bench_ollama.py +++ b/scripts/bench_ollama.py @@ -3,7 +3,7 @@ # requires-python = ">=3.10" # dependencies = ["httpx>=0.27"] # /// -"""Benchmark Ollama models for FabledScribe chat / curator workloads. +"""Benchmark Ollama models for FabledGrimoire chat / curator workloads. Measures time-to-first-token, total wall time, and tokens/sec for each (model, scenario) pair. Designed to be runnable against both local and diff --git a/src/grimoire/config.py b/src/grimoire/config.py index 4d2d6bb2..111351c4 100644 --- a/src/grimoire/config.py +++ b/src/grimoire/config.py @@ -45,7 +45,7 @@ class Config: # per-instance via the Plugin marketplace setting. PLUGIN_MARKETPLACE_URL: str = os.environ.get( "PLUGIN_MARKETPLACE_URL", - "https://git.fabledsword.com/bvandeusen/FabledScribe.git", + "https://git.fabledsword.com/bvandeusen/FabledGrimoire.git", ) # OIDC / OAuth2 SSO (e.g. Authentik) diff --git a/src/grimoire/models/repo_binding.py b/src/grimoire/models/repo_binding.py index 6fc5b23a..2248d986 100644 --- a/src/grimoire/models/repo_binding.py +++ b/src/grimoire/models/repo_binding.py @@ -9,7 +9,7 @@ class RepoBinding(Base, TimestampMixin): """Maps a git repository to the Grimoire project it represents. The SessionStart hook sends the working repo's normalized remote - (`repo_key`, e.g. ``git.fabledsword.com/bvandeusen/fabledscribe``) and the + (`repo_key`, e.g. ``git.fabledsword.com/bvandeusen/fabledgrimoire``) and the server resolves it to a project — so the operator can work across many repos/projects without pinning a project id in plugin config. The key is a *stable identifier* (the git remote), not a dir-name heuristic. diff --git a/src/grimoire/services/knowledge.py b/src/grimoire/services/knowledge.py index 776b224e..dc43606e 100644 --- a/src/grimoire/services/knowledge.py +++ b/src/grimoire/services/knowledge.py @@ -77,8 +77,8 @@ def location_matches(data: dict | None, parts: dict[str, str]) -> bool: if all( _path_matches((loc.get(key) or "").strip(), want) if key == "path" - # Repo names are recorded free-form ("Grimoire" / "FabledScribe" / - # "fabledscribe") — case is never the distinguishing thing. + # Repo names are recorded free-form ("Grimoire" / "FabledGrimoire" / + # "fabledgrimoire") — case is never the distinguishing thing. else (loc.get(key) or "").strip().lower() == want.lower() if key == "repo" else (loc.get(key) or "").strip() == want diff --git a/src/grimoire/services/repo_bindings.py b/src/grimoire/services/repo_bindings.py index 58c812db..63a62ffe 100644 --- a/src/grimoire/services/repo_bindings.py +++ b/src/grimoire/services/repo_bindings.py @@ -20,11 +20,11 @@ def normalize_repo_key(raw: str) -> str: Collapses the equivalent clone URLs to one key, e.g.:: - git@git.fabledsword.com:bvandeusen/FabledScribe.git - https://git.fabledsword.com/bvandeusen/fabledscribe.git - ssh://git@git.fabledsword.com:22/bvandeusen/fabledscribe + git@git.fabledsword.com:bvandeusen/FabledGrimoire.git + https://git.fabledsword.com/bvandeusen/fabledgrimoire.git + ssh://git@git.fabledsword.com:22/bvandeusen/fabledgrimoire - all normalize to ``git.fabledsword.com/bvandeusen/fabledscribe``. + all normalize to ``git.fabledsword.com/bvandeusen/fabledgrimoire``. Returns "" for empty/garbage input so callers can treat it as "no repo". """ diff --git a/tests/test_grimoire_marker.py b/tests/test_grimoire_marker.py index e41f638b..a792cfce 100644 --- a/tests/test_grimoire_marker.py +++ b/tests/test_grimoire_marker.py @@ -65,7 +65,7 @@ def test_a_bare_integer_is_a_valid_marker(tmp_path): def test_the_marker_is_found_from_a_subdirectory(tmp_path): - _marker(tmp_path, instance=INSTANCE, project_id=2, project="FabledScribe") + _marker(tmp_path, instance=INSTANCE, project_id=2, project="FabledGrimoire") deep = tmp_path / "src" / "grimoire" / "services" deep.mkdir(parents=True) assert _sh(f'grimoire_scope_query "{deep}"', tmp_path) == "project_id=2" diff --git a/tests/test_moment_delivery.py b/tests/test_moment_delivery.py index c1f22e24..f18a70fc 100644 --- a/tests/test_moment_delivery.py +++ b/tests/test_moment_delivery.py @@ -292,7 +292,7 @@ def test_the_hook_and_the_routes_agree_on_every_name(): assert name in route -def test_the_hook_leaves_scribes_own_tools_to_their_responses(): +def test_the_hook_leaves_grimoires_own_tools_to_their_responses(): assert "mcp__*grimoire*__*) exit 0" in HOOK.read_text() diff --git a/tests/test_rule_moment_signal_hooks.py b/tests/test_rule_moment_signal_hooks.py index f92d31a1..a14c7b20 100644 --- a/tests/test_rule_moment_signal_hooks.py +++ b/tests/test_rule_moment_signal_hooks.py @@ -75,7 +75,7 @@ def _acts(tmp: Path, sid: str = "s1") -> list[tuple[int, dict]]: return rows -def test_every_call_is_written_to_the_acts_ledger_scribes_own_included(sandbox): +def test_every_call_is_written_to_the_acts_ledger_grimoires_own_included(sandbox): tmp, env = sandbox push = {"session_id": "s1", "tool_name": "Bash", "tool_input": {"command": "git push origin dev"}} diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index 911c504e..0d617a34 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -111,7 +111,7 @@ async def test_build_session_context_includes_project_when_scoped(): # design_system_id explicitly None: a bare MagicMock would hand back a truthy # auto-attribute and send this through the design branch, which is the # opposite of what this test is about. - project = MagicMock(id=2, title="FabledScribe", goal="ship it", + project = MagicMock(id=2, title="FabledGrimoire", goal="ship it", design_system_id=None) with patch("grimoire.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \ @@ -120,8 +120,8 @@ async def test_build_session_context_includes_project_when_scoped(): from grimoire.services.plugin_context import build_session_context out = await build_session_context(user_id=7, project_id=2) - assert out["project"] == {"id": 2, "title": "FabledScribe"} - assert "## Active project: FabledScribe (id 2)" in out["context"] + assert out["project"] == {"id": 2, "title": "FabledGrimoire"} + assert "## Active project: FabledGrimoire (id 2)" in out["context"] assert "Open todo tasks: 4" in out["context"] # No design system on the project -> no design block at all. An install with # none is the ordinary case, not a degraded one. diff --git a/tests/test_services_repo_bindings.py b/tests/test_services_repo_bindings.py index c5d70244..7fe2886c 100644 --- a/tests/test_services_repo_bindings.py +++ b/tests/test_services_repo_bindings.py @@ -6,18 +6,18 @@ from grimoire.services.repo_bindings import normalize_repo_key @pytest.mark.parametrize( "raw", [ - "git@git.fabledsword.com:bvandeusen/fabledscribe.git", - "git@git.fabledsword.com:bvandeusen/FabledScribe.git", - "https://git.fabledsword.com/bvandeusen/fabledscribe.git", - "https://git.fabledsword.com/bvandeusen/fabledscribe", - "http://git.fabledsword.com/bvandeusen/fabledscribe.git", - "ssh://git@git.fabledsword.com:22/bvandeusen/fabledscribe.git", - "ssh://git@git.fabledsword.com/bvandeusen/fabledscribe", - "https://user:token@git.fabledsword.com/bvandeusen/fabledscribe.git", + "git@git.fabledsword.com:bvandeusen/fabledgrimoire.git", + "git@git.fabledsword.com:bvandeusen/FabledGrimoire.git", + "https://git.fabledsword.com/bvandeusen/fabledgrimoire.git", + "https://git.fabledsword.com/bvandeusen/fabledgrimoire", + "http://git.fabledsword.com/bvandeusen/fabledgrimoire.git", + "ssh://git@git.fabledsword.com:22/bvandeusen/fabledgrimoire.git", + "ssh://git@git.fabledsword.com/bvandeusen/fabledgrimoire", + "https://user:token@git.fabledsword.com/bvandeusen/fabledgrimoire.git", ], ) def test_normalize_collapses_equivalent_remotes(raw): - assert normalize_repo_key(raw) == "git.fabledsword.com/bvandeusen/fabledscribe" + assert normalize_repo_key(raw) == "git.fabledsword.com/bvandeusen/fabledgrimoire" @pytest.mark.parametrize("raw", ["", " ", None]) -- 2.54.0 From 5ad1df8e0fd2a5b25b7a321f6c2cc427f9b56f19 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 22 Oct 2026 13:53:57 -0400 Subject: [PATCH 78/78] feat(6242): a repo renamed on its forge keeps its binding and shape ledger A binding and every row of its project's code-shape ledger are keyed by the repo's remote. A rename on the forge changes the remote, so binding the new name started an empty ledger beside the old one and left every judgment behind. bind_repo now takes moved_from, the old remote. Its binding and the project's ledger rows move to the new key in one transaction, and the reply says how many shapes moved. The move is refused when: - the old name isn't bound to that project; - the new name is already bound; - the project already has a ledger under the new name. Each refusal leaves both names exactly as they were. Co-Authored-By: Claude Opus 5.5 --- src/grimoire/mcp/tools/repos.py | 25 +++++- src/grimoire/services/repo_bindings.py | 55 ++++++++++++- tests/test_integration_repo_move.py | 102 +++++++++++++++++++++++++ tests/test_pattern_coverage.py | 7 ++ 4 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 tests/test_integration_repo_move.py diff --git a/src/grimoire/mcp/tools/repos.py b/src/grimoire/mcp/tools/repos.py index db87598f..ff6255f0 100644 --- a/src/grimoire/mcp/tools/repos.py +++ b/src/grimoire/mcp/tools/repos.py @@ -13,7 +13,9 @@ from grimoire.services import projects as projects_svc from grimoire.services import repo_bindings as repo_bindings_svc -async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict: +async def bind_repo( + repo_url: str, project_id: int, ref: str = "", moved_from: str = "", +) -> dict: """Bind a git repository to a Grimoire project for session-start context. After this, any session started in that repo auto-loads the project's @@ -27,21 +29,34 @@ async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict: the push, not the merge. Re-binding with ref="" keeps the standing ref; pass ref="-" to clear it back to the default branch. + A repo renamed on its forge has a new remote, and its ledger is stored + under the old one. Pass the old remote as `moved_from` and the binding and + every shape judgment move to the new name together. Binding the new name + without it starts an empty ledger beside the old one. + Args: repo_url: the repo's git remote (e.g. the output of `git remote get-url origin` — ssh or https form, both work). project_id: the Grimoire project this repo represents. ref: branch the ledger follows ("" = leave as is / default branch on a new binding; "-" = clear to the default branch). + moved_from: the repo's remote before a rename on the forge. The + old name must be bound to this project, and the new one must be + neither bound nor holding a ledger of this project's. """ uid = current_user_id() project = await projects_svc.get_project(uid, project_id) if project is None: raise ValueError(f"project {project_id} not found") ref_arg = None if not ref else ("" if ref.strip() == "-" else ref) + moved = None + if moved_from: + _, moved = await repo_bindings_svc.move_binding( + uid, moved_from, repo_url, project_id, + ) binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg) follows = binding.ref or "the default branch" - return { + out = { "repo_key": binding.repo_key, "project_id": binding.project_id, "project_title": project.title, @@ -51,6 +66,12 @@ async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict: f"the ledger follows {follows}." ), } + if moved is not None: + old_key = repo_bindings_svc.normalize_repo_key(moved_from) + out["moved_from"] = old_key + out["shapes_moved"] = moved + out["message"] += f" Moved from `{old_key}` with {moved} shape(s)." + return out async def list_repo_bindings() -> dict: diff --git a/src/grimoire/services/repo_bindings.py b/src/grimoire/services/repo_bindings.py index 63a62ffe..90bb10f8 100644 --- a/src/grimoire/services/repo_bindings.py +++ b/src/grimoire/services/repo_bindings.py @@ -9,9 +9,10 @@ from __future__ import annotations import re -from sqlalchemy import select +from sqlalchemy import select, update from grimoire.models import async_session +from grimoire.models.code_shape import CodeShape from grimoire.models.repo_binding import RepoBinding @@ -100,6 +101,58 @@ async def set_binding( return binding +async def move_binding( + user_id: int, old_repo: str, new_repo: str, project_id: int, +) -> tuple[RepoBinding, int]: + """Carry a renamed repo's binding, and its project's shape ledger, from the + old remote to the new one. Returns the binding and how many shapes moved. + + Every ledger row is keyed by the repo it was found in, so a repo renamed on + its forge would otherwise start an empty ledger under the new name and + leave every judgment behind under the old one. Both keys change in one + transaction. It refuses rather than merge: the old key must be bound to + this project by the caller, and the new key must have neither a binding of + the caller's nor ledger rows of this project's. + """ + old_key = normalize_repo_key(old_repo) + new_key = normalize_repo_key(new_repo) + if not old_key or not new_key: + raise ValueError("both remotes must be parseable repo URLs") + if old_key == new_key: + raise ValueError(f"`{old_key}` is the same repo under both names") + async with async_session() as session: + mine = select(RepoBinding).where(RepoBinding.user_id == user_id) + binding = ( + await session.execute(mine.where(RepoBinding.repo_key == old_key)) + ).scalar_one_or_none() + if binding is None or binding.project_id != project_id: + raise ValueError(f"`{old_key}` is not bound to project {project_id}") + if ( + await session.execute(mine.where(RepoBinding.repo_key == new_key)) + ).scalar_one_or_none() is not None: + raise ValueError( + f"`{new_key}` is already bound; unbind it first, then move" + ) + shapes_here = select(CodeShape.id).where(CodeShape.project_id == project_id) + if ( + await session.execute( + shapes_here.where(CodeShape.repo_key == new_key).limit(1) + ) + ).first() is not None: + raise ValueError( + f"project {project_id} already has a ledger under `{new_key}`" + ) + moved = await session.execute( + update(CodeShape) + .where(CodeShape.project_id == project_id, CodeShape.repo_key == old_key) + .values(repo_key=new_key) + ) + binding.repo_key = new_key + await session.commit() + await session.refresh(binding) + return binding, moved.rowcount or 0 + + async def list_bindings(user_id: int) -> list[RepoBinding]: async with async_session() as session: rows = await session.execute( diff --git a/tests/test_integration_repo_move.py b/tests/test_integration_repo_move.py new file mode 100644 index 00000000..0d9b1f88 --- /dev/null +++ b/tests/test_integration_repo_move.py @@ -0,0 +1,102 @@ +"""Real-Postgres integration tests for moving a renamed repo's binding. + +A repo renamed on its forge has a new remote. The binding and every ledger row +are keyed by the remote, so the move has to carry both, in one transaction, and +refuse anything that would merge two ledgers or move another project's repo. +""" +import pytest +import pytest_asyncio +from sqlalchemy import select + +from grimoire.models import async_session +from grimoire.models.code_shape import CodeShape +from grimoire.models.project import Project +from grimoire.services import repo_bindings as svc +from grimoire.services.shape_ledger import classify_shapes, sync_repo_shapes +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +OLD = "https://git.example.com/alice/OldName.git" +NEW = "git@git.example.com:alice/NewName.git" +OLD_KEY = "git.example.com/alice/oldname" +NEW_KEY = "git.example.com/alice/newname" +SHAPES = [("src/app.py", "sym", "make_app"), ("src/util.py", "sym", "helper")] + + +@pytest_asyncio.fixture +async def bound(): + """A project bound under the old name, its two-shape ledger, one judged.""" + async with async_session() as s: + owner = await ensure_user(s, "repo_move_owner") + project = Project(user_id=owner.id, title="Renamed repo") + other = Project(user_id=owner.id, title="Some other project") + s.add_all([project, other]) + await s.flush() + ids = {"owner": owner.id, "pid": project.id, "other": other.id} + await s.commit() + + await svc.set_binding(ids["owner"], OLD, ids["pid"], "dev") + await sync_repo_shapes(ids["pid"], OLD_KEY, SHAPES, seen_marker="dev") + await classify_shapes(ids["owner"], ids["pid"], [ + {"path": "src/util.py", "symbol": "helper", "status": "exempt", + "reason": "one caller"}, + ]) + yield ids + await svc.delete_binding(ids["owner"], OLD) + await svc.delete_binding(ids["owner"], NEW) + + +async def _ledger(pid: int) -> dict[str, tuple[str, str]]: + async with async_session() as s: + rows = await s.execute(select(CodeShape).where(CodeShape.project_id == pid)) + return {r.symbol: (r.repo_key, r.status) for r in rows.scalars()} + + +@pytest.mark.integration +async def test_the_binding_and_every_judgment_move_to_the_new_name(bound): + binding, moved = await svc.move_binding(bound["owner"], OLD, NEW, bound["pid"]) + + assert moved == 2 + assert binding.repo_key == NEW_KEY + assert binding.project_id == bound["pid"] + assert binding.ref == "dev" + assert await svc.resolve_project(bound["owner"], NEW) == bound["pid"] + assert await svc.resolve_project(bound["owner"], OLD) is None + assert await _ledger(bound["pid"]) == { + "make_app": (NEW_KEY, "unclassified"), + "helper": (NEW_KEY, "exempt"), + } + + +@pytest.mark.integration +async def test_it_refuses_a_repo_bound_to_another_project(bound): + with pytest.raises(ValueError, match="not bound to project"): + await svc.move_binding(bound["owner"], OLD, NEW, bound["other"]) + assert {k for k, _ in (await _ledger(bound["pid"])).values()} == {OLD_KEY} + + +@pytest.mark.integration +async def test_it_refuses_when_the_new_name_is_already_bound(bound): + await svc.set_binding(bound["owner"], NEW, bound["pid"]) + with pytest.raises(ValueError, match="already bound"): + await svc.move_binding(bound["owner"], OLD, NEW, bound["pid"]) + assert {k for k, _ in (await _ledger(bound["pid"])).values()} == {OLD_KEY} + + +@pytest.mark.integration +async def test_it_refuses_to_merge_into_a_ledger_the_new_name_already_has(bound): + await sync_repo_shapes( + bound["pid"], NEW_KEY, [("src/new.py", "sym", "fresh")], seen_marker="dev", + ) + with pytest.raises(ValueError, match="already has a ledger"): + await svc.move_binding(bound["owner"], OLD, NEW, bound["pid"]) + assert await svc.resolve_project(bound["owner"], OLD) == bound["pid"] + + +@pytest.mark.integration +async def test_the_same_repo_under_both_names_is_refused(bound): + with pytest.raises(ValueError, match="same repo"): + await svc.move_binding( + bound["owner"], OLD, "git@git.example.com:alice/oldname", bound["pid"], + ) diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index 6d4630be..f42ec226 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -376,6 +376,13 @@ def test_bind_repo_tool_takes_a_ref(): assert "ref" in tool.parameters.get("properties", {}) +def test_bind_repo_tool_can_move_a_renamed_repo(): + """A repo renamed on its forge carries its ledger to the new name.""" + from grimoire.mcp.server import build_mcp_server + tool = build_mcp_server()._tool_manager.get_tool("bind_repo") + assert "moved_from" in tool.parameters.get("properties", {}) + + def test_coverage_routes_are_registered(): from grimoire.app import create_app -- 2.54.0