feat(frontend): the app says what it is running, and says so honestly when it cannot find out (#3329)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 38s

#3127 checklist 12, plus rule 27 — a capability with no surface the operator
can touch is not shipped.

The step was planned on the premise that nothing read `/api/version`. Two
things did, and the state was worse than nothing:

- `App.vue` fetched it, wrote `version` into a ref initialised to the literal
  `"dev"`, and swallowed the error. An instance that could not answer rendered
  EXACTLY what a healthy local build renders. That is checklist 12's named
  failure — a blank standing in for `unknown` — in the one readout whose whole
  job is to say what is running, and it would have made #3298's debugging
  session no cheaper.
- `SettingsView.vue` fetched the same endpoint again on every mount and wrote
  the result into a local ref no template ever read. A duplicate request whose
  answer was discarded.

So this is not "add a readout"; it is "make the existing one honest, and give
it the three fields nobody could see."

The readout — Settings → Config, first section, beside the other "what is this
instance doing" facts. Three states kept apart, because collapsing any two of
them is the defect:

  not asked yet (tab unopened)   nothing
  answered                       the values, each ABSENT field as "unknown"
  the fetch itself failed        its own message, with a retry

`version` and `channel` prominent, `commit` in full with a copy button so it
can be pasted into a `:sha` lookup (rule 145 — the registry's identity and the
artifact's own must be checkable against each other), `build` kept because its
ABSENCE is the diagnostic part: no ordering key means this build is not in any
update order, which is what a local or hand-built image looks like.

Absence, not falsiness. The payload omits what it does not know rather than
sending `""` or `0` (see `build_version_payload`), so the renderer uses `??`
throughout — `build` is a number and `0` is a legitimate ordering key, which
`||` would report as unknown. `tests/test_version_readout.py` pins that
operator specifically, along with the "no plausible default" property, because
`||` is the form a person reaches for by habit.

Rule 156 — the fetch carries a deadline. This readout is consulted when an
instance is misbehaving, which is exactly when it may never answer; without one
the surface sits on "still loading" forever, which is the same blank arrived at
from the other direction. `apiGet` gains an OPT-IN `timeoutMs` rather than a
default, so no existing call site's behaviour moves. Every other call in the
client still has no deadline — reported separately, not fixed here.

No frontend test runner exists, so verification is the typecheck lane plus four
source-inspection guards in the unit lane, each pinning one property.

Also folded in: `plugin/README.md` now leads with the mint script and offers
`make` second, since `make` is not installed on every workstation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
This commit is contained in:
2026-09-02 11:23:43 -04:00
co-authored by Claude Opus 5
parent f5a3643da8
commit 9bb59b73ba
6 changed files with 283 additions and 16 deletions
+19 -6
View File
@@ -7,12 +7,20 @@ import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts"; import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { apiGet, apiPut } from "@/api/client"; import { apiPut } from "@/api/client";
import { fetchVersion } from "@/api/version";
useTheme(); useTheme();
const router = useRouter(); 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<string | null>(null);
const appVersionFailed = ref(false);
const authStore = useAuthStore(); const authStore = useAuthStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts(); const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
@@ -119,10 +127,12 @@ onMounted(async () => {
startAppServices(); startAppServices();
} }
try { try {
const data = await apiGet<{ version: string }>("/api/version"); appVersion.value = (await fetchVersion()).version;
appVersion.value = data.version;
} catch { } 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(() => {
<div id="main-content" class="app-content"> <div id="main-content" class="app-content">
<router-view /> <router-view />
</div> </div>
<footer class="app-footer">v{{ appVersion }}</footer> <footer class="app-footer">
<span v-if="appVersion">v{{ appVersion }}</span>
<span v-else-if="appVersionFailed">version unknown</span>
</footer>
</div> </div>
<!-- Keyboard shortcuts overlay --> <!-- Keyboard shortcuts overlay -->
+19 -2
View File
@@ -52,8 +52,25 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
return fallback; return fallback;
} }
export async function apiGet<T>(path: string): Promise<T> { /**
const res = await fetch(path); * A GET, optionally with a deadline.
*
* `timeoutMs` is OPT-IN rather than defaulted, deliberately. Every existing
* caller was written against a `fetch` that waits as long as the browser will,
* and handing them all a deadline in one change would alter behaviour at every
* call site at once, including ones nobody has looked at. New callers should
* pass one.
*
* Why a caller should want it: a wait with no deadline cannot report that it
* failed. It can only stay pending — which is indistinguishable, to anything
* rendering it, from "still loading". A surface that has to tell those two
* apart needs the request to give up on its own.
*/
export async function apiGet<T>(path: string, opts?: { timeoutMs?: number }): Promise<T> {
const res = await fetch(
path,
opts?.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : undefined,
);
return handleResponse<T>(res, path); return handleResponse<T>(res, path);
} }
+42
View File
@@ -0,0 +1,42 @@
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;
}
/**
* The readout exists to answer "what is running?" during an incident, which is
* exactly when the server may be the thing that is unwell. Without a deadline
* a failing instance leaves the request pending forever and the surface sits
* on "still loading" — a blank standing in for `unknown`, which is the failure
* mode #3127 checklist 12 names by hand. Eight seconds is long enough for a
* slow-but-alive instance and short enough that a person watching it learns
* something.
*/
const VERSION_TIMEOUT_MS = 8000;
export function fetchVersion(): Promise<VersionPayload> {
return apiGet<VersionPayload>("/api/version", { timeoutMs: VERSION_TIMEOUT_MS });
}
+114 -5
View File
@@ -9,6 +9,7 @@ import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue"; import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat"; import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
import { fetchVersion, type VersionPayload } from "@/api/version";
const store = useSettingsStore(); const store = useSettingsStore();
const authStore = useAuthStore(); const authStore = useAuthStore();
@@ -187,7 +188,37 @@ const changingPassword = ref(false);
const invalidatingSessions = ref(false); const invalidatingSessions = ref(false);
const exporting = ref(false); const exporting = ref(false);
const restoring = ref(false); const restoring = ref(false);
const appVersion = ref('dev'); // ── What's running (#3127 checklist 12) ─────────────────────────────────
// Three states kept apart, because collapsing any two of them is the defect
// this readout exists to remove: `null` + no error = not asked yet (the Config
// tab has not been opened); a payload = answered, with each ABSENT field shown
// as "unknown"; `versionError` = the fetch itself failed, which is its own
// thing and must never render as a blank or as a plausible-looking value.
const versionInfo = ref<VersionPayload | null>(null);
const versionLoading = ref(false);
const versionError = ref("");
const commitCopied = ref(false);
async function loadVersionPanel() {
if (versionLoading.value) return;
versionLoading.value = true;
versionError.value = "";
try {
versionInfo.value = await fetchVersion();
} catch (e) {
versionInfo.value = null;
versionError.value = apiErrorMessage(e, "Could not reach the instance to ask what it is running.");
} finally {
versionLoading.value = false;
}
}
async function copyCommit() {
if (!versionInfo.value?.commit) return;
await copyToClipboard(versionInfo.value.commit);
commitCopied.value = true;
setTimeout(() => { commitCopied.value = false; }, 2000);
}
const restoreFileInput = ref<HTMLInputElement | null>(null); const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general" // Migrate stored "admin" → "config"; unknown tabs fall back to "general"
@@ -201,6 +232,7 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel(); else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel(); else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true); else if (tab === "areas") canonStore.fetchCatalog(true);
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
} }
if (tab === "apikeys") { fetchApiKeys(); } if (tab === "apikeys") { fetchApiKeys(); }
} }
@@ -554,10 +586,6 @@ function toggleProfileWorkDay(day: string) {
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) } function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
onMounted(async () => { onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
appVersion.value = v.version
} catch { /* non-critical */ }
await store.fetchSettings(); await store.fetchSettings();
newEmail.value = authStore.user?.email ?? ""; newEmail.value = authStore.user?.email ?? "";
@@ -2109,6 +2137,48 @@ async function deleteUser(userId: number) {
<!-- ── Admin ── --> <!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid"> <div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
<section class="settings-section full-width">
<h2>What's running</h2>
<p class="section-desc">
The build serving this page. Paste the commit into a <code>:sha</code> image
lookup to check the registry and the app agree about what was published.
</p>
<div v-if="versionLoading" class="state-msg">Reading the ledger&hellip;</div>
<div v-else-if="versionError" class="error-msg">
{{ versionError }}
<button class="btn-ghost btn-compact version-retry" @click="loadVersionPanel">Try again</button>
</div>
<dl v-else-if="versionInfo" class="version-grid">
<dt>Version</dt>
<dd class="version-value">{{ versionInfo.version }}</dd>
<dt>Channel</dt>
<dd :class="versionInfo.channel === undefined ? 'version-unknown' : 'version-value'">
{{ versionInfo.channel ?? "unknown" }}
</dd>
<dt>Commit</dt>
<dd v-if="versionInfo.commit" class="version-value version-commit">
<span class="version-sha">{{ versionInfo.commit }}</span>
<button class="btn-ghost btn-compact" @click="copyCommit">
{{ commitCopied ? "Copied" : "Copy" }}
</button>
</dd>
<dd v-else class="version-unknown">unknown</dd>
<dt>Build</dt>
<!-- The ordering key, kept because its ABSENCE is the diagnostic one:
no key means this build is not part of any update order, which is
what a local or hand-built image looks like. `??` not `||` 0 is
a legitimate key. -->
<dd :class="versionInfo.build === undefined ? 'version-unknown' : 'version-value'">
{{ versionInfo.build ?? "unknown" }}
</dd>
</dl>
<div v-else class="empty-msg">Nothing asked yet.</div>
</section>
<section class="settings-section full-width"> <section class="settings-section full-width">
<h2>Application URL</h2> <h2>Application URL</h2>
<p class="section-desc"> <p class="section-desc">
@@ -2768,6 +2838,45 @@ async function deleteUser(userId: number) {
letter-spacing: 0.07em; letter-spacing: 0.07em;
color: var(--fs-text-tertiary); color: var(--fs-text-tertiary);
} }
/* What's running — a definition list of instance facts. Spacing/geometry only;
colour and type come from the tokens. */
.version-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1rem;
margin: 0;
align-items: baseline;
}
.version-grid dt {
font-size: 0.8rem;
color: var(--fs-text-secondary);
}
.version-grid dd {
margin: 0;
font-size: 0.875rem;
font-family: var(--fs-font-mono);
color: var(--fs-text-primary);
}
/* An absent field reads as absent — never as a blank, and never styled to look
like a value it does not have (#3127 checklist 12). */
.version-grid dd.version-unknown {
font-family: inherit;
font-style: italic;
color: var(--fs-text-tertiary);
}
.version-commit {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.version-sha {
overflow-wrap: anywhere;
}
.version-retry {
margin-left: 0.5rem;
}
.section-desc { .section-desc {
margin: 0 0 1rem; margin: 0 0 1rem;
font-size: 0.875rem; font-size: 0.875rem;
+3 -3
View File
@@ -79,9 +79,9 @@ On install you'll be asked for:
## Notes ## Notes
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted - **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
from the clock — run `make mint-plugin` (or `python3 from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
scripts/mint_plugin_version.py`) after changing anything under `plugin/`, and mint-plugin`, where `make` is installed) after changing anything under
commit the result. The installer decides whether to refresh the cache it `plugin/`, and commit the result. The installer decides whether to refresh the cache it
executes from by comparing that string, so content that ships without a new executes from by comparing that string, so content that ships without a new
version reaches the repo and stops there (#2209). CI fails the lane if you version reaches the repo and stops there (#2209). CI fails the lane if you
forget. forget.
+86
View File
@@ -0,0 +1,86 @@
"""The app must SAY what it is running, and must not lie when it cannot find out.
There is no frontend test runner in this repo, so these are source-inspection
guards in the unit lane — the same idiom `check_plugin.py` uses on the hook
shells. They are deliberately few and deliberately about ONE property each,
because a grep-shaped test that asserts a whole file's contents fails on every
refactor and gets deleted.
WHY THIS FILE EXISTS. #3298: with a deploy misbehaving, nothing on the instance
could say which commit was serving it, and the one endpoint whose job that is
answered with the name of a branch. The value was fixed then. This is the other
half — the value reaching a person — and #3127 checklist 12 is specific about
the way it goes wrong: *never let a blank stand in for `unknown`*. A readout
that renders a plausible value it never received is worse than one that renders
nothing, because it ends the investigation instead of starting it.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
def test_something_actually_reads_the_version_endpoint():
"""The endpoint is not enough; something must ask it.
`/api/version` answered correctly for weeks with no caller — an endpoint
reachable only by someone who already knew to curl it. Rule 27: a
capability with no surface the operator can touch is not shipped.
"""
hits = [p for p in FRONTEND.rglob("*.ts") if "/api/version" in p.read_text()]
assert hits, "nothing under frontend/src fetches /api/version"
def test_the_footer_does_not_default_to_a_plausible_version():
"""The regression this readout was built to remove.
`appVersion` used to start life as the literal `"dev"` and the fetch
swallowed its own failure, so an instance that could not answer rendered
exactly what a healthy local build renders. Two very different states, one
string, and no way to tell them apart from the page.
Pinned as "the ref does not start at a version-shaped literal" rather than
as an exact initialiser, so a later refactor can change how the state is
held without failing here — what must not come back is the plausible
default.
"""
app = (FRONTEND / "App.vue").read_text()
match = re.search(r"const appVersion = ref[^;]*;", app)
assert match, "App.vue no longer declares appVersion — update this guard"
decl = match.group(0)
assert '"dev"' not in decl and "'dev'" not in decl, (
f"appVersion defaults to a version-shaped literal: {decl}\n"
"A failed fetch would render as a real-looking version (#3127 "
"checklist 12). Start from a not-answered-yet value instead."
)
def test_optional_version_fields_are_read_by_absence_not_falsiness():
"""`build` is a number and 0 is a legitimate ordering key.
The payload omits what it does not know rather than sending `""` or `0`, so
the renderer's job is to distinguish ABSENT from present. `||` cannot: it
would report a real `build` of 0 as unknown, and it is the form a person
reaches for by habit. `??` is the correct one, which is why this pins the
operator rather than the rendered output.
"""
view = (FRONTEND / "views" / "SettingsView.vue").read_text()
for field in ("channel", "build"):
assert f'versionInfo.{field} ?? "unknown"' in view, (
f"the {field} readout must use `?? \"unknown\"`, never `|| \"unknown\"` — "
"an absent field and a falsy one are different answers"
)
def test_the_version_request_carries_a_deadline():
"""Rule 156. A wait with no deadline cannot report that it failed.
This readout is consulted when an instance is misbehaving, which is exactly
when it may never answer. Without a deadline the surface sits on "still
loading" forever — the blank standing in for `unknown` again, arrived at
from the other direction.
"""
src = (FRONTEND / "api" / "version.ts").read_text()
assert "timeoutMs" in src, "the version fetch must pass a deadline"