From c6c1ef23f82c80c34be12f2152aaaef12be11f11 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 16 Aug 2026 15:33:28 +0200 Subject: [PATCH 1/2] The free board shows a subject's name, not its primary key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An extension that ships no frontend gets the generic board and subject page for free, and both titled a subject with summary.id — an integer for a row-backed subject. inbox-manager's board read "1", "2", "3". The subject already knew better: both bases carry a label, and every app that has a name for its subject overrides get_label. SubjectSummary carries it now. from_attributes means model_validate(self) picks it up with no app change, which is every implementation across both repos except review's, which builds its summary by hand — one line. Required, not defaulted. A hand-built summary that forgets it raises on its board route, which is the loud version; defaulting to "" would title every row with nothing and say so nowhere. Two test fixtures and two wire-shape assertions needed updating, which is the contract change showing up exactly where it should. The detail page's first fact was "id"; it is now the subject type in words — "pull request: owner/repo#7" rather than "id: owner/repo#7". summaryEntries skips label the way it already skipped id, so the title never repeats itself among the facts. --- .../druks/contrib/review/datastructures.py | 1 + backend/druks/durable/schemas.py | 5 +++++ backend/tests/test_generic_subjects.py | 22 ++++++++++++++----- frontend/src/api/types.ts | 3 +++ frontend/src/lib/summary.ts | 5 +++-- frontend/src/pages/ExtensionHomePage.tsx | 2 +- frontend/src/pages/SubjectPage.tsx | 2 +- 7 files changed, 31 insertions(+), 9 deletions(-) diff --git a/backend/druks/contrib/review/datastructures.py b/backend/druks/contrib/review/datastructures.py index b042cd55..9ea0d7c2 100644 --- a/backend/druks/contrib/review/datastructures.py +++ b/backend/druks/contrib/review/datastructures.py @@ -38,6 +38,7 @@ def url(self) -> str: def get_summary(self) -> ReviewSummary: return ReviewSummary( id=self.id, + label=self.label, repo=self.repo, pr_number=self.number, pull_request_url=self.url, diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index b195e143..14c1e302 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -132,6 +132,11 @@ class SubjectSummary(BaseResponse): model_config = ConfigDict(from_attributes=True) id: SubjectId + # The one line the board row and the detail page show this subject as. Free + # from the subject's own ``label`` when the summary is built with + # ``model_validate(self)``; required, so a hand-built summary that forgets it + # fails on its board route rather than titling every row with a primary key. + label: str class SubjectStatus(BaseResponse): diff --git a/backend/tests/test_generic_subjects.py b/backend/tests/test_generic_subjects.py index 6f48018c..cbe31d2e 100644 --- a/backend/tests/test_generic_subjects.py +++ b/backend/tests/test_generic_subjects.py @@ -26,7 +26,7 @@ class Thing(StoredSubject): __tablename__ = "faketest_things" def get_summary(self) -> _ThingSummary: - return _ThingSummary(id=self.id, title=TITLES[self.id]) + return _ThingSummary(id=self.id, label=self.label, title=TITLES[self.id]) @classmethod def list_summaries(cls) -> list[_ThingSummary]: @@ -44,7 +44,7 @@ def get_for_subject_id(cls, subject_id: str) -> "Ticket | None": return def get_summary(self) -> _ThingSummary: - return _ThingSummary(id=self.id, title=self.id.rpartition("#")[2]) + return _ThingSummary(id=self.id, label=self.label, title=self.id.rpartition("#")[2]) @classmethod def list_summaries(cls) -> list[_ThingSummary]: @@ -128,7 +128,19 @@ def test_a_subject_id_is_a_string_whatever_the_row_is_keyed_by(druks_db): # Only the id widens: a title that arrives as a number is still a mistake. with pytest.raises(ValidationError): - _ThingSummary(id=7, title=7) + _ThingSummary(id=7, label="7", title=7) + + +def test_a_summary_carries_the_subjects_own_label(druks_db): + # Built off the subject, the label comes free from its ``label`` — both subject + # bases have one, so neither board titles its rows with a primary key. + assert SubjectSummary.model_validate(Thing(id=7)).label == "thing 7" + assert SubjectSummary.model_validate(Ticket(id="owner/repo#7")).label == "owner/repo#7" + + # Required, not defaulted: a hand-built summary that forgets it fails here, + # rather than rendering a blank title on every row of its board. + with pytest.raises(ValidationError): + _ThingSummary(id=1, title="First") def test_status_aggregates_across_runs_and_timeline_spans_them(client: TestClient, druks_db): @@ -141,7 +153,7 @@ def test_status_aggregates_across_runs_and_timeline_spans_them(client: TestClien _seed_call(druks_db, live, agent="implement", status="running") detail = client.get("/api/faketest/thing/1").json() - assert detail["summary"] == {"id": "1", "title": "First"} + assert detail["summary"] == {"id": "1", "label": "thing 1", "title": "First"} assert detail["status"]["state"] == "running" assert [entry["kind"] for entry in detail["timeline"]] == ["faketest.prepare", "faketest.flow"] # Calls group under their own run, not the subject at large. @@ -232,7 +244,7 @@ def test_an_id_spanning_separators_reaches_the_board_and_its_page(client: TestCl assert [row["summary"]["id"] for row in board["rows"]] == ["owner/repo#7"] detail = client.get("/api/faketest/ticket/owner/repo%237").json() - assert detail["summary"] == {"id": "owner/repo#7", "title": "7"} + assert detail["summary"] == {"id": "owner/repo#7", "label": "owner/repo#7", "title": "7"} assert detail["status"]["state"] == "parked" assert [entry["kind"] for entry in detail["timeline"]] == ["faketest.flow"] diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a8fafc34..73a0e042 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -34,6 +34,9 @@ export type RunState = // timeline, and detail URL. export interface SubjectSummary { id: string + // The one line this subject shows itself as; the row title and the detail + // page's first fact. + label: string } export interface SubjectStatus { diff --git a/frontend/src/lib/summary.ts b/frontend/src/lib/summary.ts index c09d5c0f..832db480 100644 --- a/frontend/src/lib/summary.ts +++ b/frontend/src/lib/summary.ts @@ -3,12 +3,13 @@ import { relTimeFromIso } from './format' const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/ // A subject summary's scalar fields as label/text pairs — what the generic board -// row and subject facts render. ``id`` is the row key, not a field. Typed on the +// row and subject facts render. ``id`` is the row key and ``label`` is the title, +// so neither is a field here. Typed on the // wire's base shape; the extension's extra fields are what this walks. export function summaryEntries(summary: object): [string, string][] { const entries: [string, string][] = [] for (const [key, value] of Object.entries(summary)) { - if (key === 'id' || value === null || value === undefined || value === '') continue + if (key === 'id' || key === 'label' || value === null || value === undefined || value === '') continue if (!['string', 'number', 'boolean'].includes(typeof value)) continue const label = key.replace(/([A-Z])/g, ' $1').toLowerCase() const text = diff --git a/frontend/src/pages/ExtensionHomePage.tsx b/frontend/src/pages/ExtensionHomePage.tsx index 691ff421..ddd04437 100644 --- a/frontend/src/pages/ExtensionHomePage.tsx +++ b/frontend/src/pages/ExtensionHomePage.tsx @@ -66,7 +66,7 @@ function SubjectBoard({ extension, subjectType }: { extension: string; subjectTy onClick={() => navigate(`/${extension}/${subjectType}/${row.summary.id}`)} > - {row.summary.id} + {row.summary.label} {summaryEntries(row.summary) .map(([key, value]) => `${key} ${value}`) diff --git a/frontend/src/pages/SubjectPage.tsx b/frontend/src/pages/SubjectPage.tsx index 947b574f..54128ed4 100644 --- a/frontend/src/pages/SubjectPage.tsx +++ b/frontend/src/pages/SubjectPage.tsx @@ -74,7 +74,7 @@ export function SubjectPage({ extension, subjectType, subjectId }: Props) { return ( - {data.summary.id} + {data.summary.label} {summaryEntries(data.summary).map(([key, value]) => ( {value} From 53b66bea6d8dc93341aba9d52e744eb26ffa01c7 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 16 Aug 2026 15:41:07 +0200 Subject: [PATCH 2/2] A blank label is rejected, not rendered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit label was required but ``str``, so an empty one passed. That is the exact failure required was chosen to prevent: inbox-manager titles a thread with its subject_line, and an email with no subject stores "" — a board of blank rows, saying nothing anywhere about why. SubjectLabel strips and rejects blank, following NonBlank in mcp/schemas.py. --- backend/druks/durable/schemas.py | 21 +++++++++++++++------ backend/tests/test_generic_subjects.py | 8 ++++---- frontend/src/api/types.ts | 2 -- frontend/src/lib/summary.ts | 6 +++--- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 14c1e302..df69efbf 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -1,7 +1,15 @@ from datetime import datetime from typing import TYPE_CHECKING, Annotated, Any, Literal -from pydantic import AliasPath, BeforeValidator, ConfigDict, Field, SerializeAsAny, computed_field +from pydantic import ( + AliasPath, + BeforeValidator, + ConfigDict, + Field, + SerializeAsAny, + StringConstraints, + computed_field, +) from druks.schemas import BaseResponse @@ -124,6 +132,11 @@ def from_run( # takes either and is always a string. SubjectId = Annotated[str, BeforeValidator(str)] +# The one line a board row and a detail page show a subject as. Blank is rejected +# rather than rendered: a title nobody can read is the thing this field exists to +# prevent. +SubjectLabel = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + class SubjectSummary(BaseResponse): # The base an extension's subject header subclasses; ``id`` keys the subject's @@ -132,11 +145,7 @@ class SubjectSummary(BaseResponse): model_config = ConfigDict(from_attributes=True) id: SubjectId - # The one line the board row and the detail page show this subject as. Free - # from the subject's own ``label`` when the summary is built with - # ``model_validate(self)``; required, so a hand-built summary that forgets it - # fails on its board route rather than titling every row with a primary key. - label: str + label: SubjectLabel class SubjectStatus(BaseResponse): diff --git a/backend/tests/test_generic_subjects.py b/backend/tests/test_generic_subjects.py index cbe31d2e..7757223b 100644 --- a/backend/tests/test_generic_subjects.py +++ b/backend/tests/test_generic_subjects.py @@ -132,15 +132,15 @@ def test_a_subject_id_is_a_string_whatever_the_row_is_keyed_by(druks_db): def test_a_summary_carries_the_subjects_own_label(druks_db): - # Built off the subject, the label comes free from its ``label`` — both subject - # bases have one, so neither board titles its rows with a primary key. assert SubjectSummary.model_validate(Thing(id=7)).label == "thing 7" assert SubjectSummary.model_validate(Ticket(id="owner/repo#7")).label == "owner/repo#7" - # Required, not defaulted: a hand-built summary that forgets it fails here, - # rather than rendering a blank title on every row of its board. + # A title nobody can read is what this field exists to prevent, so a missing + # one and a blank one fail the same way. with pytest.raises(ValidationError): _ThingSummary(id=1, title="First") + with pytest.raises(ValidationError): + _ThingSummary(id=1, label=" ", title="First") def test_status_aggregates_across_runs_and_timeline_spans_them(client: TestClient, druks_db): diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 73a0e042..524e0e56 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -34,8 +34,6 @@ export type RunState = // timeline, and detail URL. export interface SubjectSummary { id: string - // The one line this subject shows itself as; the row title and the detail - // page's first fact. label: string } diff --git a/frontend/src/lib/summary.ts b/frontend/src/lib/summary.ts index 832db480..b765a654 100644 --- a/frontend/src/lib/summary.ts +++ b/frontend/src/lib/summary.ts @@ -3,9 +3,9 @@ import { relTimeFromIso } from './format' const ISO_DATE = /^\d{4}-\d{2}-\d{2}T/ // A subject summary's scalar fields as label/text pairs — what the generic board -// row and subject facts render. ``id`` is the row key and ``label`` is the title, -// so neither is a field here. Typed on the -// wire's base shape; the extension's extra fields are what this walks. +// row and subject facts render. ``id`` and ``label`` are the row key and the title, +// not fields. Typed on the wire's base shape; the extension's extra fields are +// what this walks. export function summaryEntries(summary: object): [string, string][] { const entries: [string, string][] = [] for (const [key, value] of Object.entries(summary)) {