diff --git a/backend/druks/api/runs.py b/backend/druks/api/runs.py index 9a5d548a..04eeaa1f 100644 --- a/backend/druks/api/runs.py +++ b/backend/druks/api/runs.py @@ -90,7 +90,7 @@ async def cancel_run( raise RunNotActive(run_id) subject = await run.get_subject() # cancel() flushes the run and expires this computed column, so read it first. - key, title = run.subject_key, run.subject_title + key = run.subject_key await run.cancel(failure=reason) if subject: await Event.emit( @@ -98,7 +98,6 @@ async def cancel_run( type=WorkflowEvent.CANCELLED, subject=subject, key=key, - title=title, run=run.id, kind=run.kind, facts={"failure": reason}, diff --git a/backend/druks/apps/base.py b/backend/druks/apps/base.py index 8f9aa600..bfbd958f 100644 --- a/backend/druks/apps/base.py +++ b/backend/druks/apps/base.py @@ -417,13 +417,15 @@ def _get_page_routes(cls) -> "APIRouter": from druks.ui import Page operations = cls.operations() + declarations = cls.pages() + landing = {declaration.route: declaration for declaration in declarations}.get("/") router = APIRouter(prefix="/pages", tags=[f"{cls.name}:pages"]) - for declaration in cls.pages(): + for declaration in declarations: router.add_api_route( # The landing page's route is "/", and its snapshot answers at # the bare /pages. declaration.route.rstrip("/"), - cls._page_endpoint(declaration, operations), + cls._page_endpoint(declaration, operations, back=declaration.parent or landing), methods=["GET"], response_model=Page, response_model_by_alias=True, @@ -432,17 +434,28 @@ def _get_page_routes(cls) -> "APIRouter": return router @classmethod - def _page_endpoint(cls, declaration: "PageRoute", operations: "dict[str, Operation]"): + def _page_endpoint( + cls, + declaration: "PageRoute", + operations: "dict[str, Operation]", + *, + back: "PageRoute | None", + ): """``wraps`` keeps the page function's signature, so FastAPI still - validates every route parameter.""" - from druks.ui import EmptyState, Page + validates every route parameter. A page whose subject is missing answers + an empty state that links ``back``.""" + from druks.ui import EmptyState, Link, Page + + controls = [] + if back and back is not declaration: + controls.append(Link(back.label, page=back.name)) @wraps(declaration.function) async def read_page(**parameters): try: page = await declaration.function(**parameters) except ObjectNotFound as error: - return Page(str(error), blocks=[EmptyState(str(error))]) + return Page(str(error), blocks=[EmptyState(str(error), controls=controls)]) except Exception as error: raise PageReadError( cls.name, declaration.name, f"its own code raised {type(error).__name__}" diff --git a/backend/druks/contrib/software_factory/datastructures.py b/backend/druks/contrib/software_factory/datastructures.py index bc86cb60..776c045b 100644 --- a/backend/druks/contrib/software_factory/datastructures.py +++ b/backend/druks/contrib/software_factory/datastructures.py @@ -9,10 +9,6 @@ class PullRequest(Subject): is the whole record — ``owner/repo#7`` is what everything else reads back out of, and its status carries the lifecycle.""" - @classmethod - def get(cls, repo: str, number: int) -> Self: - return cls(id=f"{repo}#{number}") - @classmethod async def get_or_none(cls, id: str) -> Self | None: # Ids reach the read side as free text off a URL, so a shape that names no diff --git a/backend/druks/contrib/software_factory/models.py b/backend/druks/contrib/software_factory/models.py index 1be72a17..17133597 100644 --- a/backend/druks/contrib/software_factory/models.py +++ b/backend/druks/contrib/software_factory/models.py @@ -126,7 +126,7 @@ async def get_in_project(cls, *, project_id: int, repo_id: int) -> "ProjectRepo stmt = select(cls).where(cls.id == repo_id, cls.project_id == project_id).limit(1) return (await db_session().scalars(stmt)).first() - def get_key(self) -> str: + def __str__(self) -> str: return self.full_name def get_summary(self) -> "ProjectRepoSummary": @@ -234,8 +234,8 @@ class WorkItem(StoredSubject): # The time of the GitHub verdict, or of the cancel reaction. resolved_at: Mapped[datetime | None] = mapped_column(default=None) - def get_key(self) -> str: - return self.ticket_key + def __str__(self) -> str: + return f"{self.ticket_key} {self.title}".strip() def get_summary(self) -> WorkItemSummary: return WorkItemSummary.model_validate(self) diff --git a/backend/druks/contrib/software_factory/workflows.py b/backend/druks/contrib/software_factory/workflows.py index 233aaa68..1867c158 100644 --- a/backend/druks/contrib/software_factory/workflows.py +++ b/backend/druks/contrib/software_factory/workflows.py @@ -531,7 +531,7 @@ async def dispatch(cls, *, repo: str, pr_number: int, account: Account, note: st # reviewer is connected. The lookup raises a clear error before the run starts a VM. await Github.get() return await cls.start( - subject=PullRequest.get(repo, pr_number), account_id=account.id, note=note + subject=PullRequest(id=f"{repo}#{pr_number}"), account_id=account.id, note=note ) async def run(self, note: str = "") -> None: diff --git a/backend/druks/durable/datastructures.py b/backend/druks/durable/datastructures.py index f75dd62d..79962817 100644 --- a/backend/druks/durable/datastructures.py +++ b/backend/druks/durable/datastructures.py @@ -5,6 +5,7 @@ from druks.db import db_session from druks.durable.schemas import SubjectSummary from druks.events.models import Event +from druks.exceptions import ObjectNotFound from druks.models import snake_name if TYPE_CHECKING: @@ -22,9 +23,6 @@ class Subject: instead.""" subject_type: ClassVar[str] - # The header its board and page show it under. Set a ``SubjectSummary`` - # subclass to add the app's own fields and a descriptive ``title``. - summary_class: ClassVar[type[SubjectSummary]] = SubjectSummary id: str @@ -38,11 +36,14 @@ def __init_subclass__(cls, **kwargs: Any) -> None: def identity(self) -> dict[str, Any]: return {"type": self.subject_type, "id": self.id} + def __str__(self) -> str: + """How the subject shows itself. An identity-only subject is already named by + its id: "owner/repo#7" is the handle, not a surrogate key.""" + return self.id + @property def key(self) -> str: - # An identity-only subject is already named by its id — "owner/repo#7" is - # the handle, not a surrogate key. - return self.id + return str(self) async def announce(self, topic: str, **facts: Any) -> None: """Record and deliver a domain fact in the current transaction.""" @@ -54,12 +55,23 @@ async def get_or_none(cls, id: str) -> Self | None: so override to return None for a shape this subject could never wear.""" return cls(id=id) + @classmethod + async def get(cls, id: str) -> Self: + """The subject this id names. A shape it could never wear raises + ``ObjectNotFound``, which a route answers with 404 and a page with an empty + state.""" + if subject := await cls.get_or_none(id): + return subject + raise ObjectNotFound(cls.subject_type.replace("_", " "), {"id": id}) + def get_summary(self) -> SubjectSummary: - return self.summary_class.model_validate(self) + """The header the platform's own screens show. An app with its own frontend + overrides it to add the fields that frontend reads.""" + return SubjectSummary.model_validate(self) @classmethod async def list_summaries(cls, account_id: str | None) -> Sequence[SubjectSummary]: - """The subjects on this class's board, newest-movement first, each as its domain + """The subjects on this class's board, newest movement first, each as its summary. ``account_id`` is the caller, or None outside a request. A shared board ignores it. Returns a covariant ``Sequence`` so an app can return a ``list`` of its own ``SubjectSummary`` subclass. Required once a workflow diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index a852e910..43f9b177 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -77,9 +77,6 @@ class Run(Base): subject_key: Mapped[str | None] = column_property( subject_attribute_expression(id, "subject_key") ) - subject_title: Mapped[str | None] = column_property( - subject_attribute_expression(id, "subject_title") - ) retry_from: Mapped[str | None] = column_property(retry_from_expression(id)) account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="RESTRICT")) account: Mapped[Account] = relationship(lazy="joined", foreign_keys=[account_id]) @@ -183,11 +180,18 @@ async def list_for_subject( @classmethod async def get_latest_for_subject( - cls, session: AsyncSession, subject_type: str, subject_id: str, kind: str | None = None + cls, + session: AsyncSession, + subject_type: str, + subject_id: str, + kind: str | None = None, + *, + gate: str | None = None, ) -> "Run | None": """The run that speaks for the subject: a subject holds at most one active run per kind (queue dedup) and the next starts only once the last is - terminal, so the newest is the live one whenever anything is live.""" + terminal, so the newest is the live one whenever anything is live. ``gate`` + narrows to the newest run parked on that gate.""" stmt = ( select(cls) .where(subject_filter(cls.id, subject_type, subject_id)) @@ -197,6 +201,8 @@ async def get_latest_for_subject( ) if kind: stmt = stmt.where(cls.kind == kind) + if gate: + stmt = stmt.where(cls.input_gate == gate, cls.state == RunState.PARKED.value) return (await session.scalars(stmt)).first() @classmethod @@ -734,7 +740,6 @@ async def record( type=event["topic"], subject=await run.get_subject(), key=run.subject_key, - title=run.subject_title, run=run.id, kind=run.kind, facts=facts, diff --git a/backend/druks/durable/schemas.py b/backend/druks/durable/schemas.py index 1512692d..f549fd2a 100644 --- a/backend/druks/durable/schemas.py +++ b/backend/druks/durable/schemas.py @@ -140,14 +140,13 @@ def from_run( class SubjectSummary(Schema): - # The base an app's subject header subclasses; ``id`` keys the subject's - # status, timeline and detail URL, and ``from_attributes`` builds the header - # straight off the subject. + # The header the platform shows a subject under: ``id`` keys its status, + # timeline and detail URL, ``key`` is its name, and ``from_attributes`` builds + # it straight off the subject. An app with its own frontend subclasses it. model_config = ConfigDict(from_attributes=True) id: SubjectId key: SubjectKey - title: str | None = None class SubjectStatus(Schema): diff --git a/backend/druks/events/models.py b/backend/druks/events/models.py index 4308e1f6..adc47490 100644 --- a/backend/druks/events/models.py +++ b/backend/druks/events/models.py @@ -66,7 +66,7 @@ def get_history( until: datetime | None = None, ) -> Select[tuple["Event"]]: """The recorded Activity that matches these filters, as a query. Search reads the - recorded key and title literally; from is inclusive and until is exclusive.""" + recorded key literally; from is inclusive and until is exclusive.""" # The durable package imports this module. from druks.durable.enums import WorkflowEvent @@ -90,12 +90,7 @@ def get_history( if app: statement = statement.where(cls.app == app) if search and search.strip(): - statement = statement.where( - or_( - cls.subject_key.icontains(search.strip(), autoescape=True), - cls.payload["title"].as_string().icontains(search.strip(), autoescape=True), - ) - ) + statement = statement.where(cls.subject_key.icontains(search.strip(), autoescape=True)) if topic: statement = statement.where(cls.type == topic) if from_at: @@ -131,7 +126,6 @@ async def emit( type: str, subject: dict[str, Any] | None = None, key: str | None = None, - title: str | None = None, run: str | None = None, kind: str | None = None, facts: dict[str, Any] | None = None, @@ -143,7 +137,7 @@ async def emit( subject = subject or {} facts = facts or {} - recorded = {"run": run, "kind": kind, "title": title} + recorded = {"run": run, "kind": kind} if taken := recorded.keys() & facts.keys(): raise WorkflowError(f"{type} facts {sorted(taken)} belong to Druks. Rename them.") session.add( @@ -184,7 +178,6 @@ async def announce( type=topic, subject=subject.identity, key=subject.key, - title=subject.get_summary().title, facts=facts, app=app, ) diff --git a/backend/druks/exceptions.py b/backend/druks/exceptions.py index aab13813..c2a4557d 100644 --- a/backend/druks/exceptions.py +++ b/backend/druks/exceptions.py @@ -43,7 +43,7 @@ def __init__(self) -> None: class ObjectNotFound(DruksError): - """No row holds the values a read asked for.""" + """Nothing matches the values a read asked for.""" def __init__(self, model: str, fields: dict[str, object]) -> None: match = ", ".join(f"{name} {value}" for name, value in fields.items()) diff --git a/backend/druks/models.py b/backend/druks/models.py index 6d4786c5..6aac13c5 100644 --- a/backend/druks/models.py +++ b/backend/druks/models.py @@ -2,10 +2,20 @@ import re from collections.abc import Sequence from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self from pydantic import TypeAdapter -from sqlalchemy import DateTime, Enum, Integer, Select, cast, false, select +from sqlalchemy import ( + DateTime, + Enum, + Integer, + Select, + UnaryExpression, + cast, + desc, + false, + select, +) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_object_session from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -26,6 +36,17 @@ def snake_name(name: str) -> str: return _CAMEL_BOUNDARY.sub("_", name).lower() +# A StrEnum or Literal column stores its value as text under a CHECK of the allowed +# values, so the database refuses what the Python type refuses. +_CHOICES = Enum( + enum.StrEnum, + native_enum=False, + create_constraint=True, + length=64, + values_callable=lambda members: [member.value for member in members], +) + + class _UtcDateTime(TypeDecorator): impl = DateTime(timezone=True) cache_ok = True @@ -36,16 +57,11 @@ def process_result_value(self, value: datetime | None, dialect: Any) -> datetime class Base(AsyncAttrs, DeclarativeBase): # A model declares the Python type and no column type: datetimes are tz-aware - # UTC, a StrEnum is text under a CHECK named after the enum, list and dict are JSONB. + # UTC, a StrEnum or Literal is checked text, and list and dict are JSONB. type_annotation_map = { datetime: _UtcDateTime(), - enum.StrEnum: Enum( - enum.StrEnum, - native_enum=False, - create_constraint=True, - length=64, - values_callable=lambda members: [member.value for member in members], - ), + enum.StrEnum: _CHOICES, + Literal: _CHOICES, list: JSONB, dict: JSONB, } @@ -68,7 +84,13 @@ class Model(Base): __abstract__ = True - def __init_subclass__(cls, **kwargs: Any) -> None: + # The order ``filter`` and a subject's board read rows in, as the class declared + # it: ``class Report(Model, ordering=("-created_at",))`` is newest first. + _ordering: ClassVar[tuple[str, ...]] = () + + def __init_subclass__(cls, ordering: tuple[str, ...] = (), **kwargs: Any) -> None: + if ordering: + cls._ordering = ordering # A table is named for its app and its class unless the class names it. if "__tablename__" not in cls.__dict__ and not cls.__dict__.get("__abstract__"): # Cycle: the loader is built on this module's Base. @@ -114,12 +136,21 @@ async def get_or_none(cls, **fields: object) -> Self | None: @classmethod async def filter(cls, **fields: object) -> list[Self]: - """The rows whose fields hold these values, in primary key order.""" + """The rows whose fields hold these values, in the class's ordering, or in + primary key order when it declares none.""" from druks.db import db_session - statement = cls._select_matching(fields).order_by(*cls.__mapper__.primary_key) + ordering = cls._get_order_by() or cls.__mapper__.primary_key + statement = cls._select_matching(fields).order_by(*ordering) return list(await db_session().scalars(statement)) + @classmethod + def _get_order_by(cls) -> list[str | UnaryExpression[object]]: + # Django's form: a leading "-" sorts that column descending. + return [ + desc(name.removeprefix("-")) if name.startswith("-") else name for name in cls._ordering + ] + @classmethod def _select_matching(cls, fields: dict[str, object]) -> Select[tuple[Self]]: # Ids arrive as text off a URL or an event, so a text value is read as its @@ -143,9 +174,6 @@ class StoredSubject(Model): __abstract__ = True subject_type: ClassVar[str] - # The header its board and page show it under. Set a ``SubjectSummary`` - # subclass to add the app's own fields and a descriptive ``title``. - summary_class: ClassVar["type[SubjectSummary]"] id: Mapped[int] = mapped_column(primary_key=True) created_at: Mapped[datetime] = mapped_column(default=Base.utc_now) @@ -153,11 +181,6 @@ class StoredSubject(Model): def __init_subclass__(cls, **kwargs: Any) -> None: cls.subject_type = snake_name(cls.__name__) - if "summary_class" not in cls.__dict__: - # Cycle: the durable read side is built on this module's Base. - from druks.durable.schemas import SubjectSummary - - cls.summary_class = SubjectSummary super().__init_subclass__(**kwargs) @property @@ -168,14 +191,14 @@ def identity(self) -> dict[str, Any]: return {"type": self.subject_type, "id": self.id} raise ValueError(f"unsaved {type(self).__name__} has no identity — flush it first") - def get_key(self) -> str: - """The stable work key, such as a ticket key or PR number. Events record - the descriptive title from get_summary() beside this key.""" + def __str__(self) -> str: + """How the subject shows itself on runs, events, the Activity feed, and its + board. Its type and id identify it, so the name need not be unique.""" return f"{self.subject_type.replace('_', ' ')} {self.id}" @property def key(self) -> str: - return self.get_key() + return str(self) async def announce(self, topic: str, **facts: Any) -> None: """Record and deliver a domain fact in the current transaction.""" @@ -186,18 +209,24 @@ async def announce(self, topic: str, **facts: Any) -> None: await Event.announce(db_session(), self, topic, facts) def get_summary(self) -> "SubjectSummary": - return self.summary_class.model_validate(self) + """The header the platform's own screens show. An app with its own frontend + overrides it to add the fields that frontend reads.""" + # Cycle: the durable read side is built on this module's Base. + from druks.durable.schemas import SubjectSummary + + return SubjectSummary.model_validate(self) @classmethod async def list_summaries(cls, account_id: str | None) -> "Sequence[SubjectSummary]": - """The rows on this class's board, newest movement first, each as its domain - summary. ``account_id`` is the caller, or None outside a request; this shared - board ignores it. Override to scope the board by caller or to select - differently.""" + """The rows on this class's board, in the class's ordering or newest movement + first, each as its summary. ``account_id`` is the caller, or None outside a + request; this shared board ignores it. Override to scope the board by caller + or to select differently.""" from druks.db import db_session - # The newest hundred cover a board; a bigger one selects for itself. - statement = select(cls).order_by(cls.updated_at.desc(), cls.id.desc()).limit(100) + ordering = cls._get_order_by() or [cls.updated_at.desc(), cls.id.desc()] + # The first hundred cover a board; a bigger one selects for itself. + statement = select(cls).order_by(*ordering).limit(100) return [row.get_summary() for row in await db_session().scalars(statement)] async def get_status(self, *, workflow: "type[Workflow] | None" = None) -> "SubjectStatus": diff --git a/backend/druks/scaffolding/app_template/package/models.py-tpl b/backend/druks/scaffolding/app_template/package/models.py-tpl index af74c569..e2f3a084 100644 --- a/backend/druks/scaffolding/app_template/package/models.py-tpl +++ b/backend/druks/scaffolding/app_template/package/models.py-tpl @@ -4,11 +4,12 @@ from druks.db import Model, StoredSubject # app and the class ("{{ name }}_report" for ``Report``), keeps the app's own migration # history in ``alembic_version_{{ name }}``, and enforces the "{{ name }}_" prefix at boot. # A model reads by field (``get``, ``get_or_none``, ``filter``) and writes with -# ``create``, ``save``, and ``delete``. +# ``create``, ``save``, and ``delete``; ``class Report(Model, ordering=("-created_at",))`` +# sets the order ``filter`` reads in. # One of these models is usually the thing your runs work on — a note, a repo, a # ticket. Subclass ``StoredSubject`` for that one and point a workflow at it with # ``subject = ThatModel``: it also gets an id, created_at, updated_at, a board, and a -# page. Set ``summary_class`` to put its own fields on the board, and override +# page. Its ``__str__`` is its name on runs and the Activity feed. Override # ``list_summaries()`` to scope the board by caller. Something you keep no row for # subclasses ``druks.workflows.Subject`` instead and implements ``list_summaries()`` # itself. diff --git a/backend/druks/testing.py b/backend/druks/testing.py index b7d0aebf..74de02b7 100644 --- a/backend/druks/testing.py +++ b/backend/druks/testing.py @@ -400,15 +400,9 @@ async def seed_run( await session.refresh(run, ["account"]) identity = None if subject: - identity = { - **subject.identity, - "key": subject.key, - "title": subject.get_summary().title, - } + identity = {**subject.identity, "key": subject.key} await seed_dbos_status(session, run.id, state, subject=identity) - await session.refresh( - run, ["state", "updated_at", "subject_key", "subject_title", "retry_from"] - ) + await session.refresh(run, ["state", "updated_at", "subject_key", "retry_from"]) return run @@ -437,7 +431,6 @@ async def seed_dbos_status( # start() always stamps a key; an identity dict without one is named # by its id, Subject.key's own rule. "subject_key": subject.get("key") or str(subject["id"]), - "subject_title": subject.get("title"), } await session.execute( workflow_status.insert().values( diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index b2aeb431..e3eb30a8 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -294,9 +294,9 @@ async def answer(cls, subject: Subject | StoredSubject, **reply: Any) -> None: f"{cls.__name__}.answer() takes the subject whose run is parked on it, " f"not {type(subject).__name__}" ) - runs = await Run.list_for_subject(db_session(), subject.subject_type, str(subject.id)) - parked = next((run for run in runs if run.is_parked and run.input_gate == cls.name), None) - if parked: + if parked := await Run.get_latest_for_subject( + db_session(), subject.subject_type, str(subject.id), gate=cls.name + ): await parked.resume(**reply) return raise WorkflowError( @@ -407,11 +407,10 @@ async def _create() -> str | None: notification_id = await DBOS.run_step_async( StepOptions(name="notifications.gate_park", **_IO_RETRIES), _create ) - if not notification_id: - return - # The step memoized the row (one per parked round); this body-level enqueue - # is DBOS's deterministic child-start, so a replayed park never double-sends. - await notifications_queue.enqueue_async(send_notification, notification_id) + if notification_id: + # The step memoized the row (one per parked round); this body-level enqueue + # is DBOS's deterministic child-start, so a replayed park never double-sends. + await notifications_queue.enqueue_async(send_notification, notification_id) async def _post_to_chat(workflow_id: str, name: str, post: Callable) -> None: @@ -592,7 +591,7 @@ async def _transition() -> dict[str, Any] | None: run = await session.get(Run, workflow_id) # Read before the flush: flushing the update unloads the row's # computed columns, and reading one back would be implicit IO. - key, title = run.subject_key, run.subject_title + key = run.subject_key gate = run.input_gate if state == RunState.RUNNING and result else None if facts: for field, value in facts.items(): @@ -604,7 +603,7 @@ async def _transition() -> dict[str, Any] | None: "kind": run.kind, "subject": subject, "payload": await _log_run_event( - session, run, state, subject, key, title, result, gate + session, run, state, subject, key, result, gate ), } @@ -634,7 +633,6 @@ async def _log_run_event( state: RunState, subject: dict[str, Any], key: str | None, - title: str | None, result: Any = None, gate: str | None = None, ) -> dict[str, Any]: @@ -662,7 +660,6 @@ async def _log_run_event( type=WorkflowEvent.for_state(state), subject=subject, key=key, - title=title, run=run.id, kind=run.kind, facts=facts, @@ -870,7 +867,6 @@ async def record() -> None: type=topic, subject=self._subject, key=run.subject_key, - title=run.subject_title, run=self.workflow_id, kind=self.kind, facts=facts, @@ -937,21 +933,17 @@ async def _lease_host(self, session: AsyncSession, config: "AgentConfig") -> str return refs = [*config.secret_refs, *await self.get_secret_refs(session)] # A crashed process left its box behind. Its identity finds it again. - if ( - not self._host - and refs - and ( - identity := await SandboxIdentity.lookup( - session, - account_id=self.account_id, - run_id=self._workflow_id, - scoped_to="workflow", - secret_refs=refs, - ) + if not self._host and refs: + identity = await SandboxIdentity.lookup( + session, + account_id=self.account_id, + run_id=self._workflow_id, + scoped_to="workflow", + secret_refs=refs, ) - ): - self._host = await sandbox_client.reattach(host_id=identity.host_id) - self._host_secrets_id = config.secrets_id + if identity: + self._host = await sandbox_client.reattach(host_id=identity.host_id) + self._host_secrets_id = config.secrets_id if self._host and self._host.expires_at: remaining = (self._host.expires_at - datetime.now(UTC)).total_seconds() if remaining < SANDBOX_HOST_ROTATE_BEFORE_SECONDS: @@ -990,10 +982,9 @@ async def _lease_host(self, session: AsyncSession, config: "AgentConfig") -> str return self._host.id async def _reap_run(self) -> None: - if not self._host: - return - host, self._host = self._host, None - await sandbox_client.release(host_id=host.id) + if self._host: + host, self._host = self._host, None + await sandbox_client.release(host_id=host.id) @property def workflow_id(self) -> str: @@ -1062,11 +1053,10 @@ def _validate_subject(cls, subject: "Subject | StoredSubject | None") -> None: @classmethod async def cancel(cls, subject: Subject | StoredSubject, *, failure: str | None = None) -> None: cls._validate_subject(subject) - runs = await Run.list_for_subject( + run = await Run.get_latest_for_subject( db_session(), subject.subject_type, str(subject.id), kind=cls.kind ) - run = next((run for run in runs if run.is_active), None) - if run: + if run and run.is_active: await run.cancel(failure=failure) @classmethod @@ -1124,7 +1114,6 @@ async def start( "subject_type": subject.subject_type, "subject_id": str(subject.id), "subject_key": subject.key, - "subject_title": subject.get_summary().title, } subject_record = subject.identity if subject else None with ( @@ -1151,7 +1140,6 @@ async def start( type=WorkflowEvent.SCHEDULED, subject=subject.identity, key=attributes["subject_key"], - title=attributes["subject_title"], run=workflow_id, kind=cls.kind, app=cls.app, diff --git a/backend/tests/druks-field_notes/druks_field_notes/models.py b/backend/tests/druks-field_notes/druks_field_notes/models.py index 725824a6..85448985 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/models.py +++ b/backend/tests/druks-field_notes/druks_field_notes/models.py @@ -1,13 +1,10 @@ from druks.db import StoredSubject, db_session +from druks.workflows import SubjectSummary from sqlalchemy import select from sqlalchemy.orm import Mapped, mapped_column -from druks_field_notes.schemas import NoteSummary, RepositorySummary - - -class Note(StoredSubject): - summary_class = NoteSummary +class Note(StoredSubject, ordering=("-created_at", "-id")): # What the note is about — the raw observation an operator jotted down. A run's # agent reads this and writes back its gist. body: Mapped[str] @@ -24,7 +21,7 @@ async def save_gist(self, gist: str) -> None: await self.save() @classmethod - async def list_summaries(cls, account_id: str | None) -> list[NoteSummary]: + async def list_summaries(cls, account_id: str | None) -> list[SubjectSummary]: # How many the board shows is an operator knob, so it lives on the app. from druks_field_notes.app import FieldNotes @@ -33,8 +30,6 @@ async def list_summaries(cls, account_id: str | None) -> list[NoteSummary]: class Repository(StoredSubject): - summary_class = RepositorySummary - # ``owner/name``; a Survey run's RepoWorkspace clones it. repo: Mapped[str] = mapped_column(unique=True) gist: Mapped[str | None] @@ -43,5 +38,5 @@ async def save_gist(self, gist: str) -> None: self.gist = gist await self.save() - def get_key(self) -> str: + def __str__(self) -> str: return self.repo diff --git a/backend/tests/druks-field_notes/druks_field_notes/routes.py b/backend/tests/druks-field_notes/druks_field_notes/routes.py index 447dce41..5195c217 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/routes.py +++ b/backend/tests/druks-field_notes/druks_field_notes/routes.py @@ -12,8 +12,8 @@ @router.get("", response_model=list[NoteSummary], response_model_by_alias=True) -async def list_notes() -> list[NoteSummary]: - return [note.get_summary() for note in await Note.list_recent()] +async def list_notes() -> list[Note]: + return await Note.list_recent() @router.post("", status_code=status.HTTP_201_CREATED, operation_id="write_note") diff --git a/backend/tests/druks-field_notes/druks_field_notes/schemas.py b/backend/tests/druks-field_notes/druks_field_notes/schemas.py index f38a63b3..04361da5 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/schemas.py +++ b/backend/tests/druks-field_notes/druks_field_notes/schemas.py @@ -1,16 +1,13 @@ from datetime import datetime -from druks.workflows import SubjectSummary +from druks.schemas import Schema +from pydantic import ConfigDict -class NoteSummary(SubjectSummary): - # The note's domain header — what only field_notes knows. The platform's subject - # read-side composes it with the generic status + timeline. +class NoteSummary(Schema): + model_config = ConfigDict(from_attributes=True) + + id: int body: str gist: str | None = None created_at: datetime - - -class RepositorySummary(SubjectSummary): - repo: str - gist: str | None = None diff --git a/backend/tests/druks-field_notes/tests/test_app.py b/backend/tests/druks-field_notes/tests/test_app.py index 682657ed..2f885b14 100644 --- a/backend/tests/druks-field_notes/tests/test_app.py +++ b/backend/tests/druks-field_notes/tests/test_app.py @@ -14,7 +14,6 @@ async def test_the_board_honors_the_board_size(druks_db): summaries = await Note.list_summaries(None) assert [summary.id for summary in summaries] == [str(newest.id)] - assert summaries[0].body == "second" def test_settings_validate_the_sync_token(): diff --git a/backend/tests/druks-field_notes/tests/test_models.py b/backend/tests/druks-field_notes/tests/test_models.py index 44ecdeb9..c3d36f36 100644 --- a/backend/tests/druks-field_notes/tests/test_models.py +++ b/backend/tests/druks-field_notes/tests/test_models.py @@ -19,11 +19,11 @@ async def test_note_create_list_and_save_gist(druks_db): assert (await Note.get(id=first.id)).gist == "The pump ran hot." -async def test_rows_read_by_field(druks_db): +async def test_rows_read_by_field_in_the_declared_order(druks_db): first = await Note.create(body="the pump ran hot") second = await Note.create(body="the pump ran hot") - assert await Note.filter(body="the pump ran hot") == [first, second] + assert await Note.filter(body="the pump ran hot") == [second, first] assert await Note.get(id=str(second.id)) == second assert await Note.get_or_none(id="not an id") is None with pytest.raises(ObjectNotFound, match="No note with id 0"): diff --git a/backend/tests/druks-field_notes/tests/test_routes.py b/backend/tests/druks-field_notes/tests/test_routes.py index 746c135a..18163dac 100644 --- a/backend/tests/druks-field_notes/tests/test_routes.py +++ b/backend/tests/druks-field_notes/tests/test_routes.py @@ -38,15 +38,9 @@ async def test_repository_board_and_detail_read_the_subject(druks_client): detail = await druks_client.get(f"/api/field_notes/repository/{repository.id}") assert board.status_code == 200 - assert [row["summary"]["repo"] for row in board.json()["rows"]] == [newer.repo, repository.repo] + assert [row["summary"]["key"] for row in board.json()["rows"]] == [newer.repo, repository.repo] assert detail.status_code == 200 - assert detail.json()["summary"] == { - "id": str(repository.id), - "key": "acme/widgets", - "title": None, - "repo": "acme/widgets", - "gist": None, - } + assert detail.json()["summary"] == {"id": str(repository.id), "key": "acme/widgets"} async def test_a_missing_note_is_a_404_on_a_route_and_an_empty_state_on_a_page(druks_client): @@ -57,4 +51,6 @@ async def test_a_missing_note_is_a_404_on_a_route_and_an_empty_state_on_a_page(d assert route.json() == {"error": "HTTP_404", "detail": "No note with id 999"} assert page.status_code == 200 assert page.json()["title"] == "No note with id 999" - assert page.json()["blocks"][0]["block"] == "empty_state" + [empty_state] = page.json()["blocks"] + assert empty_state["block"] == "empty_state" + assert [control["page"] for control in empty_state["controls"]] == ["notes"] diff --git a/backend/tests/software_factory/test_activity.py b/backend/tests/software_factory/test_activity.py index 2d955ba3..77de85b5 100644 --- a/backend/tests/software_factory/test_activity.py +++ b/backend/tests/software_factory/test_activity.py @@ -72,12 +72,11 @@ async def run_step(options, operation): assert response.status_code == 200 events = response.json()["items"] assert len(events) == 1 - assert events[0]["subjectKey"] == "ACME-42" + assert events[0]["subjectKey"] == "ACME-42 Repair the queue" assert events[0]["payload"] == { "repo": "acme/widget", "pr_number": 42, "branch": "agent/queue", - "title": "Repair the queue", "run": run.id, "kind": Build.kind, } @@ -107,7 +106,7 @@ async def test_review_result_belongs_to_the_identity_only_pull_request( druks_db, druks_client, tmp_path ): db_session.registry.set(druks_db) - subject = PullRequest.get("acme/widget", 42) + subject = PullRequest(id="acme/widget#42") run = await seed_run(druks_db, kind=PullRequestReview.kind, subject=subject) key = await installation_key() call = AgentCall( @@ -252,13 +251,13 @@ async def test_owner_outcome_records_once_without_an_operator_stop(druks_db, sta events = list(await druks_db.scalars(select(Event).where(Event.subject_id == str(item.id)))) outcome = "merged" if merged else "closed" assert [event.type for event in events] == [outcome] - assert events[0].payload == {"repo": "acme/widget", "pr_number": 42, "title": "Merged work"} + assert events[0].payload == {"repo": "acme/widget", "pr_number": 42} assert item.resolution == outcome await item.start_attempt() await item.update(pr_number=43, branch="agent/next", title="Next attempt") druks_db.expunge_all() recorded = (await druks_db.scalars(select(Event).where(Event.id == events[0].id))).one() - assert recorded.payload == {"repo": "acme/widget", "pr_number": 42, "title": "Merged work"} + assert recorded.payload == {"repo": "acme/widget", "pr_number": 42} async def test_owner_merge_replaces_an_operator_cancel(druks_db, druks_client): @@ -281,7 +280,6 @@ async def test_owner_merge_replaces_an_operator_cancel(druks_db, druks_client): assert events[1].payload == { "repo": "acme/widget", "pr_number": 42, - "title": "Merged after a cancel", } druks_db.expunge_all() assert (await WorkItem.get_or_none(id=item.id)).resolution == Resolution.MERGED diff --git a/backend/tests/software_factory/test_review.py b/backend/tests/software_factory/test_review.py index 03e33c45..d44e3f54 100644 --- a/backend/tests/software_factory/test_review.py +++ b/backend/tests/software_factory/test_review.py @@ -36,7 +36,7 @@ def client(tmp_path: Path, druks_db, monkeypatch): def test_a_pull_requests_identity_is_its_handle(): # This spelling is the dedup key, the route, the event identity and the replay # lookup all at once — normalising or rearranging it splits one review in two. - pull_request = PullRequest.get("acme/app", 7) + pull_request = PullRequest(id="acme/app#7") assert pull_request.identity == {"type": "pull_request", "id": "acme/app#7"} assert (pull_request.repo, pull_request.number) == ("acme/app", 7) @@ -59,7 +59,7 @@ async def test_a_pull_request_heads_its_own_page(): async def test_the_pull_request_board_and_page_mount(client: TestClient, druks_db): # PullRequestReview declares PullRequest, so the app mounts its board and # page — keyed by a handle that carries both a path separator and a `#`. - pull_request = PullRequest.get("acme/app", 7) + pull_request = PullRequest(id="acme/app#7") await seed_run(druks_db, kind=PullRequestReview.kind, subject=pull_request, state="running") (row,) = client.get("/api/software_factory/pull_request").json()["rows"] @@ -84,7 +84,7 @@ async def test_a_queued_run_replays_through_its_subject(): # separately, so the body binds and reads the pull request off the declaration. instance, run_kwargs = _bind_instance( PullRequestReview, - PullRequest.get("acme/app", 7).identity, + PullRequest(id="acme/app#7").identity, {"repo": "acme/app", "pr_number": 7, "requested_by": "dev@example.com"}, account_id="review-account", ) @@ -96,7 +96,7 @@ async def test_a_queued_run_replays_through_its_subject(): async def test_the_reviewer_prompt_names_the_pull_request_it_is_about(): workflow = SimpleNamespace( - subject=PullRequest.get("acme/app", 7), + subject=PullRequest(id="acme/app#7"), input=SimpleNamespace(note=""), ) workspace = SimpleNamespace( @@ -121,7 +121,7 @@ async def test_comment_mode_reviews_publish_as_comments(): # Unset review identity: the operator authors druks's own pull requests, so # its reviews publish as comments — the prompt carries that rule. workflow = SimpleNamespace( - subject=PullRequest.get("acme/app", 7), + subject=PullRequest(id="acme/app#7"), input=SimpleNamespace(note=""), ) workspace = SimpleNamespace( @@ -163,7 +163,7 @@ async def test_the_requester_goes_by_their_github_login_once_linked(druks_db, mo monkeypatch.setattr(ProjectRepo, "siblings", AsyncMock(return_value=[])) dev = await Account.get_or_create(druks_db, "dev@example.com") review, _ = _bind_instance( - PullRequestReview, PullRequest.get("acme/app", 7).identity, {}, account_id=dev.id + PullRequestReview, PullRequest(id="acme/app#7").identity, {}, account_id=dev.id ) assert (await review.get_prompt_context())["requested_by"] == "dev@example.com" diff --git a/backend/tests/software_factory/test_subject_summary.py b/backend/tests/software_factory/test_subject_summary.py index ebd9a659..fa56ebd5 100644 --- a/backend/tests/software_factory/test_subject_summary.py +++ b/backend/tests/software_factory/test_subject_summary.py @@ -13,6 +13,6 @@ async def test_new_work_item_summary_needs_no_database_read(druks_db): summary = item.get_summary() - assert summary.key == "ACME-1" + assert summary.key == "ACME-1 Keep the recorded title" assert summary.title == "Keep the recorded title" assert summary.project_name == "Acme" diff --git a/backend/tests/test_activity_payload.py b/backend/tests/test_activity_payload.py index a23e48d2..341a5b36 100644 --- a/backend/tests/test_activity_payload.py +++ b/backend/tests/test_activity_payload.py @@ -5,7 +5,7 @@ from druks_field_notes.models import Repository -async def test_recorded_titles_and_facts_survive_rename_and_deletion(druks_db, druks_client): +async def test_recorded_names_and_facts_survive_rename_and_deletion(druks_db, druks_client): db_session.registry.set(druks_db) project = await Project.create(name="Pumps") item = await WorkItem.create( @@ -25,8 +25,8 @@ async def test_recorded_titles_and_facts_survive_rename_and_deletion(druks_db, d page = (await druks_client.get("/api/events", params={**filters, "limit": 1})).json() [recorded] = page["items"] assert page["nextCursor"] is None - assert recorded["subjectKey"] == "PUMP-1" - assert recorded["payload"] == {**facts, "title": "Pump 50%_ hot"} + assert recorded["subjectKey"] == "PUMP-1 Pump 50%_ hot" + assert recorded["payload"] == facts assert set(recorded) == { "id", "seq", @@ -44,7 +44,7 @@ async def test_recorded_titles_and_facts_survive_rename_and_deletion(druks_db, d ] -async def test_a_summary_without_a_title_keeps_the_work_key_and_facts(druks_db, druks_client): +async def test_an_announcement_records_the_subject_name_and_facts(druks_db, druks_client): db_session.registry.set(druks_db) repository = await Repository.create(repo="acme/observations") await repository.announce("repository.inspected", branch_name="main") @@ -57,5 +57,5 @@ async def test_a_summary_without_a_title_keeps_the_work_key_and_facts(druks_db, async def test_an_announcement_cannot_supply_what_druks_records(druks_db): db_session.registry.set(druks_db) repository = await Repository.create(repo="acme/observations") - with pytest.raises(WorkflowError, match="title"): - await repository.announce("repository.inspected", title="Forged title") + with pytest.raises(WorkflowError, match="kind"): + await repository.announce("repository.inspected", kind="forged.kind") diff --git a/backend/tests/test_durable_sdk.py b/backend/tests/test_durable_sdk.py index 1a9a4fb2..a2186172 100644 --- a/backend/tests/test_durable_sdk.py +++ b/backend/tests/test_durable_sdk.py @@ -91,7 +91,7 @@ def to_event(self) -> dict[str, str]: class Widget(StoredSubject): __tablename__ = "test_widgets" - def get_key(self) -> str: + def __str__(self) -> str: return f"W-{self.id}" @@ -428,7 +428,6 @@ async def test_attribution_rides_the_run_and_survives_resume(runtime): "subject_type": "widget", "subject_id": "878787", "subject_key": "W-878787", - "subject_title": None, } assert parked.account_id == account_id assert f"acct-before:{account_id}" in SINK @@ -651,7 +650,6 @@ async def test_subject_gate_parks_unchanged(runtime): "subject_type": "widget", "subject_id": "636363", "subject_key": "W-636363", - "subject_title": None, } await parked.resume(action="go") diff --git a/backend/tests/test_events_feed.py b/backend/tests/test_events_feed.py index 962dc400..6d9431d0 100644 --- a/backend/tests/test_events_feed.py +++ b/backend/tests/test_events_feed.py @@ -7,7 +7,7 @@ class Crate(StoredSubject): __tablename__ = "faketest_crates" - def get_key(self) -> str: + def __str__(self) -> str: return f"CRATE-{self.id}" diff --git a/backend/tests/test_run_state.py b/backend/tests/test_run_state.py index 0187291d..579f0f2b 100644 --- a/backend/tests/test_run_state.py +++ b/backend/tests/test_run_state.py @@ -394,9 +394,9 @@ async def run_inline(options, fn): assert event.payload == {"pr_number": 12, "run": run.id, "kind": workflow.kind} with ( mock.patch("druks.workflows.DBOS.run_step_async", side_effect=run_inline), - pytest.raises(WorkflowError, match="title"), + pytest.raises(WorkflowError, match="kind"), ): - await workflow.announce("test.announced", title="Forged title") + await workflow.announce("test.announced", kind="forged.kind") @pytest.mark.asyncio diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index 80dfdffa..07e941ed 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -859,7 +859,7 @@ A workflow with a subject starts with an instance of that class. A workflow without a subject passes `subject=None`. When the subject is a row you keep — one you list, edit, and show fields from — -subclass `StoredSubject` instead of `Base`. The class name is the subject type: +subclass `StoredSubject` instead of `Model`. The class name is the subject type: `Repository` becomes `repository`. ```python @@ -870,34 +870,20 @@ from sqlalchemy.orm import Mapped class Repository(StoredSubject): full_name: Mapped[str] - def get_key(self) -> str: + def __str__(self) -> str: return self.full_name ``` -Druks supplies the rest: the table `night_watch_repository`, an `id`, -`created_at` and `updated_at`, `create()`, `save()`, `delete()`, and a board of -the newest hundred rows by `updated_at`. Each subject already supplies its ID -and `key`. The key is its stable work key. The summary has an optional -descriptive `title`; a subject without one leaves it absent. For a title or more -fields, add a custom summary: - -```python -from druks.workflows import SubjectSummary - - -class RepositorySummary(SubjectSummary): - open_findings: int - - -class Repository(StoredSubject): - summary_class = RepositorySummary -``` - -To scope the board by caller, or to select other rows, override -`list_summaries()`. +A subject's `__str__` is its name on runs, the Activity feed, and its board. The +default is its type and id, such as `repository 7`. Its type and id identify it, +so the name need not be unique. Druks supplies the rest: the table +`night_watch_repository`, an `id`, `created_at` and `updated_at`, `create()`, +`save()`, `delete()`, and a board of the newest hundred rows by `updated_at`, or +in the class's declared ordering. To scope the board by caller, or to select +other rows, override `list_summaries()`. If you keep no row for a subject, subclass `Subject`. The platform requires only -an identity. The ID is the full record and its label: +an identity. The ID is the full record and its name: ```python from druks.workflows import Subject, SubjectSummary @@ -911,7 +897,10 @@ class PullRequest(Subject): Each ID names one of these subjects, so a detail read always answers. Override `get_or_none(id)` to reject an invalid shape. For example, -`owner/repo#7` is a pull request and `nonsense` returns a 404. +`owner/repo#7` is a pull request and `nonsense` returns a 404: +`await PullRequest.get(id=subject_id)` raises `ObjectNotFound` for it, the same +as `Model.get`. Code that already knows the parts builds the subject directly: +`PullRequest(id=f"{repo}#{number}")`. An identity-only `Subject` a workflow declares must implement `list_summaries()`; a `StoredSubject` has a board by default. The board reads @@ -994,15 +983,14 @@ Druks records these workflow facts without app calls: An external owner can announce an outcome after the run stops. Record that outcome when the owner reports it. Do not infer it from the run state. -Each Activity row keeps its recorded work key and optional descriptive title. -`start()` reads only the title from the supplied subject's `get_summary()`. It -stores the title beside the key in the workflow attributes. Admission, -transitions, workflow announcements, output artifacts, and operator cancellation -use that run's recorded title. A rename during the run applies to the next run. -A subject announcement reads its own summary when it records the event. +Each Activity row keeps the subject's name as it was recorded. `start()` stores +`str(subject)` in the workflow attributes. Admission, transitions, workflow +announcements, output artifacts, and operator cancellation use that run's +recorded name. A rename during the run applies to the next run. A subject +announcement records the subject's name when it records the event. -Druks records `payload.title`, `payload.run`, and `payload.kind`. An announcement -that names one of them raises `WorkflowError`. A missing title leaves the key available. +Druks records `payload.run` and `payload.kind`. An announcement that names one of +them raises `WorkflowError`. A later rename or deletion does not change history. Search matches a literal, case-insensitive part of the recorded key or title. It does not search current subjects, failure text, or artifacts. @@ -1106,14 +1094,32 @@ await report.delete() page with an empty state, so a route or page that names a row by id never spells either. `get_or_none` answers None instead. Both expect one row: two raise SQLAlchemy's `MultipleResultsFound`, so back the fields they read with a -unique constraint. `filter` returns the matching rows in primary key order. A -text value is read as its column's type, so an id straight off a URL finds its -row. A read that needs another order, a limit, or anything but equality writes -`select()`. - -A `Mapped[datetime]` column stores UTC. A `Mapped[SomeStrEnum]` column stores -the member's value as text under a CHECK constraint named after the enum. A -`Mapped[list]` or `Mapped[dict]` column is JSONB. Encrypted columns come from +unique constraint. `filter` returns the matching rows in primary key order, or in +the order the class declares on its class line, in Django's form: + +```python +class Report(Model, ordering=("-created_at",)): + ... +``` + +A text value is read as its column's type, so an id straight off a URL finds its +row. A read that needs a limit, a different order, or anything but equality +writes `select()`: + +```python +reports = await db_session().scalars( + select(Report).where(Report.status != "closed").order_by(Report.created_at).limit(20) +) +``` + +A page that names a missing row by id answers an empty state that links back to +its parent page, or to the app's landing page. + +A `Mapped[datetime]` column stores UTC. A `Mapped[SomeStrEnum]` or +`Mapped[SomeLiteral]` column stores its value as text under a CHECK constraint of +the allowed values. A `Mapped[list]` or `Mapped[dict]` column is JSONB; a typed +one such as `Mapped[list[dict[str, Any]]]` names the type, +`mapped_column(JSONB)`. Encrypted columns come from `druks.db.fields`: `EncryptedTextField` and `EncryptedJsonField`, with their value types `Secret` and `SecretsMapping`. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 921417a0..0673b02e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -69,12 +69,11 @@ export interface DashboardSchedules { rows: DashboardSchedule[] } -// The base every app's subject summary satisfies; ``id`` keys its status, -// timeline, and detail URL. +// The header the platform shows a subject under; ``id`` keys its status, +// timeline, and detail URL, and ``key`` is its name. export interface SubjectSummary { id: string key: string - title?: string | null } export interface SubjectStatus { @@ -870,7 +869,6 @@ export interface FeedItem { subjectKey?: string | null payload: { [fact: string]: unknown - title?: string | null kind?: string | null run?: string | null gate?: string | null diff --git a/frontend/src/apps/software_factory/activity.test.ts b/frontend/src/apps/software_factory/activity.test.ts index 791af5a7..9e56fe80 100644 --- a/frontend/src/apps/software_factory/activity.test.ts +++ b/frontend/src/apps/software_factory/activity.test.ts @@ -37,10 +37,10 @@ it.each([ ['pr.opened', GitPullRequest], ['merged', GitMerge], ['closed', GitPullRequestClosed], ])('shows the recorded PR for %s', (topic, icon) => { const line = eventLine({ id: 'event:1', seq: 1, at: '2026-09-09T12:00:00Z', topic, - app: 'software_factory', subjectKey: 'DRU-42', payload: { title: 'Recorded work', repo: 'acme/widgets', pr_number: 42 } }) + app: 'software_factory', subjectKey: 'DRU-42 Recorded work', payload: { repo: 'acme/widgets', pr_number: 42 } }) expect(line.context).toBe('acme/widgets · #42') expect(line.icon).toBe(icon) - expect(line.title).toBe('Recorded work') + expect(line.key).toBe('DRU-42 Recorded work') }) it.each([ diff --git a/frontend/src/lib/feed.test.ts b/frontend/src/lib/feed.test.ts index 66f9c7cc..533adfcf 100644 --- a/frontend/src/lib/feed.test.ts +++ b/frontend/src/lib/feed.test.ts @@ -127,8 +127,8 @@ it('uses another app formatter for the same topic', () => { activity: ({ payload }) => ({ label: 'Notes combined', context: String(payload?.summary), icon: FileCheck2, tone: 'positive' }), }) const line = eventLine(event({ app: 'publishing', topic: 'merged', subjectKey: 'NOTE-7', - payload: { title: 'Recorded note title', summary: 'Three notes combined.' } })) - expect(line).toMatchObject({ key: 'NOTE-7', title: 'Recorded note title', label: 'Notes combined', + payload: { summary: 'Three notes combined.' } })) + expect(line).toMatchObject({ key: 'NOTE-7', label: 'Notes combined', context: 'Three notes combined.', icon: FileCheck2, tone: 'positive' }) expect(activityTypeLabel({ app: 'publishing', topic: 'merged' })).toBe('Notes combined') }) @@ -137,7 +137,6 @@ it('keeps generic facts useful without inventing missing context or a destinatio const line = eventLine(event({ app: 'unregistered', topic: 'gist.prepared', payload: { summary: 'The pump ran hot.' } })) expect(line).toMatchObject({ label: 'Gist prepared', context: 'The pump ran hot.', icon: CircleDot, tone: 'neutral' }) - expect(line.title).toBeUndefined() expect(line.path).toBeUndefined() expect(eventLine(event({ topic: 'unknown.topic', payload: {} })).context).toBeUndefined() }) diff --git a/frontend/src/lib/feed.ts b/frontend/src/lib/feed.ts index 9643f535..3b6b10f0 100644 --- a/frontend/src/lib/feed.ts +++ b/frontend/src/lib/feed.ts @@ -22,7 +22,6 @@ const LIFECYCLE_ICONS: Record = { export interface EventLine { label: string key: string - title?: string context?: string guidance?: string icon: LucideIcon @@ -36,7 +35,6 @@ export function eventLine(event: FeedItem): EventLine { return { label: own.label || label(event), key: event.subjectKey ?? '', - title: event.payload.title || undefined, context: failure ? failure.context : own.context || event.payload.summary || event.payload.reason || undefined, guidance: failure?.guidance, icon: own.icon ?? LIFECYCLE_ICONS[event.topic] ?? CircleDot, diff --git a/frontend/src/pages/EventsPage.test.tsx b/frontend/src/pages/EventsPage.test.tsx index cc627172..97ff72a0 100644 --- a/frontend/src/pages/EventsPage.test.tsx +++ b/frontend/src/pages/EventsPage.test.tsx @@ -309,11 +309,11 @@ it('shows a classified failure and preserves its original message in a disclosur const failure = 'codex exited with 1. You hit your spend cap set by the owner of your workspace.' history.mockResolvedValue({ items: [{ ...result, app: 'software_factory', topic: 'workflow.failed', subjectType: 'work_item', subjectId: '42', subjectKey: 'DRU-42', payload: { - kind: 'software_factory.build', run: 'failed-attempt', title: 'Keep the recorded title', failure_code: 'spend_limit', failure, + kind: 'software_factory.build', run: 'failed-attempt', failure_code: 'spend_limit', failure, }, }], cursor: '10:20:10', nextCursor: null }) mount() - fireEvent.click(await screen.findByRole('button', { name: /DRU-42.*Keep the recorded title.*Build failed/ })) + fireEvent.click(await screen.findByRole('button', { name: /DRU-42.*Build failed/ })) const details = screen.getByRole('complementary', { name: 'Activity details' }) expect(within(details).getByText('Spend limit reached')).toBeTruthy() expect(within(details).getByText('Ask the account owner to raise the spend limit before continuing.')).toBeTruthy() @@ -416,16 +416,14 @@ it('groups consecutive rows by the account day across daylight saving and pagina expect(screen.getByText('23:30')).toBeTruthy() }) -it('keeps the full recorded title and failure in details when the row uses an excerpt', async () => { - const title = 'Keep all of this recorded title '.repeat(8) +it('keeps the full failure in details when the row uses an excerpt', async () => { const failure = 'The repository cannot be opened. '.repeat(16) - history.mockResolvedValue({ items: [{ ...result, topic: 'workflow.failed', payload: { title, failure } }], cursor: '10:20:10', nextCursor: null }) + history.mockResolvedValue({ items: [{ ...result, topic: 'workflow.failed', payload: { failure } }], cursor: '10:20:10', nextCursor: null }) mount() - const row = await screen.findByRole('button', { name: /Pump A.*Keep all of this recorded title/ }) + const row = await screen.findByRole('button', { name: /Pump A/ }) expect(row.textContent).not.toContain(failure) fireEvent.click(row) const details = screen.getByRole('complementary') - expect(within(details).getByText(title.trim())).toBeTruthy() const technical = within(details).getByText('Technical details').closest('details')! expect(technical.textContent).toContain(failure) expect(technical.open).toBe(false) diff --git a/frontend/src/pages/EventsPage.tsx b/frontend/src/pages/EventsPage.tsx index f768005a..6e9fe39f 100644 --- a/frontend/src/pages/EventsPage.tsx +++ b/frontend/src/pages/EventsPage.tsx @@ -348,9 +348,7 @@ function ActivityFeed({ filters, params }: { filters: EventFilters; params: URLS onClick={() => updateParams('selected', String(event.seq))}>