diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 2acbc37..3d11996 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -46,8 +46,6 @@ on: - "alembic/**" - "alembic.ini" - "Dockerfile" - - "assets/**" - - "fable-mcp/**" # 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 @@ -279,6 +277,21 @@ jobs: 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) + run: | + echo "--- rule 81: 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? ---" + getent hosts postgres \ + || echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required" - name: Integration suite (resolve service IP, migrate, test) run: | set -eux @@ -289,8 +302,9 @@ 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 (busybox sh — the runner - # default — has no bash /dev/tcp, so use Python). + # 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. /opt/venv/bin/python - "$PG_IP" <<'PY' import socket, sys, time for _ in range(30): @@ -327,6 +341,14 @@ jobs: packages: write 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. + fetch-depth: 0 - name: Generate image tags and version id: tags @@ -339,7 +361,27 @@ jobs: # the runner log on commit 2a374d9. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" - BUILD_VERSION="dev" + + # 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. + + # 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). + 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. + 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. + CHANNEL="dev" case "${{ github.ref }}" in refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" @@ -348,15 +390,17 @@ jobs: # main IS the production line: publish :latest (plus the : # set above). No separate :main tag. TAGS="$TAGS,${{ env.IMAGE }}:latest" - BUILD_VERSION="main" + CHANNEL="stable" ;; refs/tags/*) TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" - BUILD_VERSION="${{ github.ref_name }}" + CHANNEL="stable" ;; esac echo "value=$TAGS" >> $GITHUB_OUTPUT - echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT + echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT + echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT + echo "channel=$CHANNEL" >> $GITHUB_OUTPUT - name: Free disk space # Self-hosted runner housekeeping. Two-step cleanup: @@ -386,7 +430,15 @@ jobs: push: true provenance: false tags: ${{ steps.tags.outputs.value }} - build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }} + # 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. + 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 diff --git a/Dockerfile b/Dockerfile index ffe1e79..c94d385 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,10 +41,29 @@ COPY alembic/ alembic/ # Ensure Python finds the source tree (where static files live) before site-packages ENV PYTHONPATH=/app/src -# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N -# Falls back to "dev" for local / untagged builds +# 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. ARG BUILD_VERSION=dev +ARG BUILD_KEY= +ARG BUILD_CHANNEL= +ARG BUILD_COMMIT= ENV APP_VERSION=$BUILD_VERSION +ENV APP_BUILD_KEY=$BUILD_KEY +ENV APP_CHANNEL=$BUILD_CHANNEL +ENV APP_COMMIT=$BUILD_COMMIT EXPOSE 5000 CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] diff --git a/Makefile b/Makefile index f9d672c..231f991 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build up down logs health migrate lint typecheck test fmt +.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin # --- Docker --- @@ -36,3 +36,12 @@ test: # Run all checks in one shot (mirrors what CI does) 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. +mint-plugin: + python3 scripts/mint_plugin_version.py diff --git a/alembic/versions/0094_rule_usage_events.py b/alembic/versions/0094_rule_usage_events.py new file mode 100644 index 0000000..365650d --- /dev/null +++ b/alembic/versions/0094_rule_usage_events.py @@ -0,0 +1,86 @@ +"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1) + +Revision ID: 0094 +Revises: 0093 +Create Date: 2026-09-02 + +The sibling `note_usage_events` has had since 0071, and the third rule-side +table to arrive after `rule_embeddings` and `rule_versions` — each one added +because the rule side kept inheriting machinery built for notes and getting +the weaker version of it. + +WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval +surface in Scribe whose usefulness cannot be observed, and — not coincidentally +— the only one that has never declined to fire. Over 30 days it took 296 calls, +returned something on every one, and cleared its threshold 100% of the time, +while every other surface declines most of the time (#3311). That is either a +perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs` +cannot tell them apart: it records what the ranker scored, never whether the +hint was any use. + +WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific +fields and the aggregate readout is the same shape, which is the strongest case +for sharing that note #3163 admits. What decides against it is identity at +RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map` +and drops what does not resolve. A rule id parked in that column would come +back from a backup silently reattached to whatever note took that number — +telemetry not merely lost but wrong, and wrong in a way nothing downstream +could detect. `rule_versions` made the same call for the same reason. + +FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs +and app_logs — and deliberately unlike `rule_versions`, which does carry FKs. +The difference is what the row is for: a version belongs to a rule's history +and dies with it; telemetry outlives the row it describes. Deleting a rule must +not erase the evidence that it was surfaced forty times and opened never, since +that evidence is exactly the case for having deleted it. + +No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to +a column that is already gated; it does not require gating one that never was, +and a two-member enum whose members are written by two functions in one module +is not where that discipline earns its cost. + +Downgrade drops the table outright. The data is purely observational — nothing +reads it for correctness, so losing it costs history and no behaviour. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0094" +down_revision = "0093" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "rule_usage_events", + # BigInteger throughout where the note twin uses Integer: rules.id is + # BigInteger, so rule_id must be, and a high-churn append-only table is + # a poor place to discover an id ceiling. + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column("rule_id", sa.BigInteger(), nullable=False), + sa.Column("event", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + ) + # Every readout is "these rule ids, split by event", so the composite is the + # one that actually gets used; the others serve pruning and per-user views. + op.create_index( + "ix_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"] + ) + op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"]) + op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events") + op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events") + op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events") + op.drop_table("rule_usage_events") diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2efab18..278b2f4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,12 +7,20 @@ import { useTheme } from "@/composables/useTheme"; import { useShortcuts } from "@/composables/useShortcuts"; import { useAuthStore } from "@/stores/auth"; import { useSettingsStore } from "@/stores/settings"; -import { apiGet, apiPut } from "@/api/client"; +import { apiPut } from "@/api/client"; +import { fetchVersion } from "@/api/version"; useTheme(); const router = useRouter(); -const appVersion = ref("dev"); +// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and +// renders nothing; a string renders; `appVersionFailed` renders its own thing. +// This used to default to the literal "dev" and swallow the error, which meant +// an instance that could not answer was indistinguishable from a local build +// that genuinely reports "dev" — a blank standing in for `unknown`, in the one +// readout whose whole job is to say what is running. +const appVersion = ref(null); +const appVersionFailed = ref(false); const authStore = useAuthStore(); const settingsStore = useSettingsStore(); const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts(); @@ -119,10 +127,12 @@ onMounted(async () => { startAppServices(); } try { - const data = await apiGet<{ version: string }>("/api/version"); - appVersion.value = data.version; + appVersion.value = (await fetchVersion()).version; } catch { - // silent — version display is non-critical + // Not silent any more: the footer says it could not find out, rather than + // showing a version it never received. The full readout (version, channel, + // commit, build) lives in Settings → Config. + appVersionFailed.value = true; } }); @@ -151,7 +161,10 @@ onUnmounted(() => {
-
v{{ appVersion }}
+
+ v{{ appVersion }} + version unknown +
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 286d247..2f4c110 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -52,41 +52,121 @@ export function apiErrorMessage(e: unknown, fallback: string): string { return fallback; } -export async function apiGet(path: string): Promise { - const res = await fetch(path); +/** + * How long an ordinary JSON call may wait before it is declared failed. + * + * Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait + * as long as the browser will, which is not a deadline — it is the absence of + * one, and it renders as a spinner that never resolves. There is no state a + * surface can show for "pending forever" that is not a lie. + * + * 30s is chosen to be longer than anything healthy: it has to clear a cold + * embedding call and a list view under connection-pool contention (#2384 had + * /api/projects fanning 25 concurrent sessions at a 15-connection pool), so + * tripping it means something is genuinely wrong rather than merely busy. Slow + * BY DESIGN is a different case and passes its own value — see the callers in + * SettingsView that do. + */ +const DEFAULT_TIMEOUT_MS = 30_000; + +/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means + * "the client gave up" rather than anything the server said. */ +const CLIENT_TIMEOUT_STATUS = 408; + +/** + * How long a STREAM may take to answer with its headers. + * + * Streams are the one case a wall-clock deadline would break: a long-lived SSE + * connection is *supposed* to stay open, and `AbortSignal.timeout` would kill + * it mid-flight along with the body. But that does not exempt them from rule + * 156 — it relocates the deadline. Two different waits are involved: + * + * connect — the server answering with headers. CAN fail to answer, so it + * carries this deadline, cleared the moment headers arrive. + * stream — the body, open indefinitely on purpose. Its failure mode is + * going quiet, which a timeout cannot tell from being idle; that + * is what reconnection and Last-Event-ID are for, not this. + * + * Reading the connect as exempt because "the stream is long-lived" is the easy + * mistake here, and it leaves an unreachable server looking like a quiet one. + */ +const STREAM_CONNECT_TIMEOUT_MS = 15_000; + +/** + * A signal that aborts if headers do not arrive in time, plus the `settle` to + * call once they do. After `settle()` the returned signal never fires, so the + * stream body runs unbounded — which is the intent. + */ +function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } { + const gate = new AbortController(); + const timer = setTimeout( + () => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")), + STREAM_CONNECT_TIMEOUT_MS, + ); + return { + signal: AbortSignal.any([base, gate.signal]), + settle: () => clearTimeout(timer), + }; +} + +export interface RequestOpts { + /** Override the deadline. Pass one when the call is slow BY DESIGN. */ + timeoutMs?: number; +} + +/** + * The one place a request is actually made — every verb below goes through + * here, so the deadline cannot be forgotten by adding a sixth. + * + * EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the + * failure has to arrive in the shape the caller already handles. A bare + * `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as + * an object with no `body`, so every catch site in the app would report its + * generic fallback and the timeout would be invisible in the very situation it + * exists to expose. Rethrowing as `ApiError` means ~330 existing call sites + * report it correctly without being touched. + * + * Only a TIMEOUT is converted. A deliberate cancellation aborts with + * `AbortError` and is left alone — a caller that cancelled its own request + * does not want it reported as a server failure. + */ +async function request(path: string, init: RequestInit, opts?: RequestOpts): Promise { + const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + let res: Response; + try { + res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + } catch (e) { + if (e instanceof DOMException && e.name === "TimeoutError") { + throw new ApiError(CLIENT_TIMEOUT_STATUS, { + error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`, + }); + } + throw e; + } return handleResponse(res, path); } -export async function apiPost(path: string, body: unknown): Promise { - const res = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return handleResponse(res, path); +/** JSON body headers — the three write verbs sent an identical literal each. */ +const JSON_HEADERS = { "Content-Type": "application/json" }; + +export function apiGet(path: string, opts?: RequestOpts): Promise { + return request(path, {}, opts); } -export async function apiPut(path: string, body: unknown): Promise { - const res = await fetch(path, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return handleResponse(res, path); +export function apiPost(path: string, body: unknown, opts?: RequestOpts): Promise { + return request(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts); } -export async function apiPatch(path: string, body: unknown): Promise { - const res = await fetch(path, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - return handleResponse(res, path); +export function apiPut(path: string, body: unknown, opts?: RequestOpts): Promise { + return request(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts); } -export async function apiDelete(path: string): Promise { - const res = await fetch(path, { method: "DELETE" }); - return handleResponse(res, path); +export function apiPatch(path: string, body: unknown, opts?: RequestOpts): Promise { + return request(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts); +} + +export function apiDelete(path: string, opts?: RequestOpts): Promise { + return request(path, { method: "DELETE" }, opts); } // --------------------------------------------------------------------------- @@ -221,7 +301,14 @@ export function apiSSEStream( } const done = (async () => { - const res = await fetch(path, { headers, signal: combinedSignal }); + // Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS. + const connect = connectDeadline(combinedSignal); + let res: Response; + try { + res = await fetch(path, { headers, signal: connect.signal }); + } finally { + connect.settle(); + } if (!res.ok) { let body: Record = {}; try { @@ -318,11 +405,19 @@ export async function apiStreamPost( body: unknown, onChunk: (data: Record) => void ): Promise { - const res = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + // Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS. + const connect = connectDeadline(new AbortController().signal); + let res: Response; + try { + res = await fetch(path, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(body), + signal: connect.signal, + }); + } finally { + connect.settle(); + } if (!res.ok) { let errBody: Record = {}; try { diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 9665f42..1a8c175 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -1,3 +1,5 @@ +import type { RecordUsage } from "@/types/usage"; + import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; /** How a rule reaches a session (milestone 307). */ @@ -96,6 +98,13 @@ export interface RuleHeader { * A date (YYYY-MM-DD), or the literal "never". */ last_verified?: string; + /** + * Surfaced-vs-opened counts from `rule_usage_events` (milestone 333). + * Zero-filled by the list route, so a rule predating the table reads as + * "never surfaced" rather than as a missing field — which for a while is + * every rule on every install. + */ + usage?: RecordUsage; } export interface ApplicableRules { diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 25bd2d8..0931e14 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -1,3 +1,5 @@ +import type { RecordUsage } from "@/types/usage"; + import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; /** One canonical location of a reusable thing. A snippet that unifies several @@ -50,15 +52,11 @@ export interface Snippet { owner?: string | null; } -/** How often a record was put in front of an agent versus actually opened. - * A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a - * slot in every future auto-inject menu while never being used. */ -export interface SnippetUsage { - surfaced_count: number; - pull_count: number; - last_surfaced_at: string | null; - last_pulled_at: string | null; -} +/** Kept as a name because every consumer here says "snippet usage" — but it IS + * the shared shape, since rules answer the same question off their own table + * (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four + * fields here is how the two drift. */ +export type SnippetUsage = RecordUsage; /** Result of the last drift check — does the recorded location and code still * match source? The check runs agent-side (Scribe has no checkout); this is the diff --git a/frontend/src/api/version.ts b/frontend/src/api/version.ts new file mode 100644 index 0000000..79e89f0 --- /dev/null +++ b/frontend/src/api/version.ts @@ -0,0 +1,44 @@ +import { apiGet } from "./client"; + +/** + * What `/api/version` answers — the client's half of `build_version_payload` + * (`src/scribe/routes/api.py`), which is where the reasoning for the shape is + * written down. + * + * EVERY FIELD BUT `version` IS OPTIONAL, and an absent one means "this build + * does not know", not "empty". A local build has no ordering key and no + * channel, and the server says so by omitting the keys rather than sending + * `""` — emitting a placeholder would let it claim a position in an update + * order it is not part of. + * + * So a renderer must read ABSENCE, never falsiness. `build` is a number and + * `0` is a legitimate ordering key, so `v.build || "unknown"` would report a + * real value as unknown; `v.build ?? "unknown"` is the correct form. + */ +export interface VersionPayload { + /** The NAME — `YYYY.MM.DD.HHMM` from commit time. Answers "is this the same code?" */ + version: string; + /** The ORDERING KEY — minutes since 2020-01-01, from build time. Absent on a local build. */ + build?: number; + /** `dev` / `main` / a tag. Its own field, never folded into the name. */ + channel?: string; + /** The commit the artifact was published under, so its claim can be checked against the registry. */ + commit?: string; +} + +/** + * SHORTER than the client's 30s default, deliberately. + * + * This readout answers "what is running?" during an incident, which is exactly + * when the server may be the thing that is unwell — and it is one static field + * off a route that does no work, so a healthy instance answers it immediately. + * Waiting the full default before saying so would leave a person staring at + * "still loading" for half a minute in the moment they are trying to find out + * whether the instance is alive at all. Eight seconds clears a slow-but-alive + * instance and tells them something quickly when it is not. + */ +const VERSION_TIMEOUT_MS = 8000; + +export function fetchVersion(): Promise { + return apiGet("/api/version", { timeoutMs: VERSION_TIMEOUT_MS }); +} diff --git a/frontend/src/assets/components.css b/frontend/src/assets/components.css index 0d76513..c886ef0 100644 --- a/frontend/src/assets/components.css +++ b/frontend/src/assets/components.css @@ -351,3 +351,29 @@ .required { color: var(--fs-error); } .field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); } + +/* --- usage badge ---------------------------------------------------------- + "surfaced N×, opened M×" on a list row, for any record kind the retrieval + surfaces can choose: snippets and notes from note_usage_events, rules from + rule_usage_events. Promoted here from SnippetListView's scoped block when + the rule list needed the same chip (milestone 333 step 5) — a second scoped + copy is how the ninth duplicated CSS family starts (#3207). + + Geometry and colour only. A view keeps its own spacing as a scoped + remainder, the way it does for every other recipe in this file. */ +.usage-tag { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); + color: var(--fs-text-tertiary-fg); +} + +/* Dead weight is a nudge, not an error — it warns in the warning colour rather + than the danger one, because the record isn't broken, just unearned. */ +.usage-tag.usage-dead { + background: color-mix(in srgb, var(--fs-warning) 18%, transparent); + color: var(--fs-warning-fg); +} diff --git a/frontend/src/components/UsageBadge.vue b/frontend/src/components/UsageBadge.vue new file mode 100644 index 0000000..47fabbb --- /dev/null +++ b/frontend/src/components/UsageBadge.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue index 6c2a662..32d08f7 100644 --- a/frontend/src/components/rules/RuleListPane.vue +++ b/frontend/src/components/rules/RuleListPane.vue @@ -1,5 +1,16 @@