From e93375053a6a38b193d04312648864c682924aa5 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 27 Sep 2026 20:58:39 +0200 Subject: [PATCH] DRU-665 -- Take the page, route, and agent boilerplate out of apps --- backend/druks/agents.py | 22 +- backend/druks/apps/base.py | 25 ++- backend/druks/cli.py | 17 ++ .../druks/contrib/software_factory/routes.py | 2 +- backend/druks/durable/models.py | 37 ++-- backend/druks/durable/reads.py | 15 +- backend/druks/mcp/server.py | 38 ++-- .../scaffolding/app_template/AGENTS.md-tpl | 1 + backend/druks/services/__init__.py | 3 +- backend/druks/services/base.py | 6 + backend/druks/ui/__init__.py | 2 + backend/druks/ui/blocks.py | 188 +++++++++--------- backend/druks/ui/schemas.py | 23 ++- backend/druks/workflows.py | 1 + backend/druks/workspaces.py | 6 +- .../druks_field_notes/pages.py | 32 ++- backend/tests/test_agent_routes.py | 6 + backend/tests/test_agents.py | 21 ++ backend/tests/test_author_surface.py | 3 + backend/tests/test_generic_subjects.py | 16 +- backend/tests/test_ui_actions.py | 27 ++- backend/tests/test_ui_data_blocks.py | 25 ++- backend/tests/test_ui_followed_regions.py | 17 +- backend/tests/test_ui_page_api.py | 26 ++- backend/tests/test_workspaces.py | 17 ++ docs/druks-ui.md | 115 ++++++++--- docs/writing-an-app.md | 87 ++++++-- frontend/src/api/types.ts | 25 ++- frontend/src/druksui/AppPage.tsx | 13 ++ frontend/src/druksui/Blocks.tsx | 17 +- frontend/src/druksui/DataBlocks.test.tsx | 87 ++++++-- frontend/src/druksui/DataBlocks.tsx | 39 +++- frontend/src/druksui/accessibility.test.tsx | 16 +- frontend/src/druksui/catalog.json | 48 ++++- frontend/src/druksui/decision.test.tsx | 25 ++- frontend/src/druksui/followed.test.tsx | 16 ++ frontend/src/druksui/pages.ts | 4 +- 37 files changed, 786 insertions(+), 282 deletions(-) diff --git a/backend/druks/agents.py b/backend/druks/agents.py index 8b85875b..eb5f7dda 100644 --- a/backend/druks/agents.py +++ b/backend/druks/agents.py @@ -59,17 +59,18 @@ async def _runner( # The agent always runs in a Workspace. A warm run attaches the run's held VM; the # rest get a fresh ephemeral VM. Either way workflow.get_workspace() turns the VM into # the runner — fresh per call, so nothing (connection or credential) is held across steps. - if host_id: - vm = sandbox_client.attach(host_id=host_id) - elif refs and ( - identity := await SandboxIdentity.lookup( + identity = None + if refs and not host_id: + identity = await SandboxIdentity.lookup( session, account_id=workflow.account_id, run_id=workflow_id, scoped_to=step, secret_refs=refs, ) - ): + if host_id: + vm = sandbox_client.attach(host_id=host_id) + elif identity: # A crashed attempt left its box behind. Its identity finds it again. vm = sandbox_client.resume(host_id=identity.host_id) else: @@ -146,6 +147,9 @@ class Agent: # ``include_plugins=False`` skips the operator's plugin state for prompts # that hit no MCP server. include_plugins: bool = True + # ``include_mcp=False`` gives the call no MCP server and its sandbox no + # server entry, for an agent that reads untrusted content. + include_mcp: bool = True # ``id`` is the agent's durable key (settings, timeline, registry, step name): # ``.`` for an agent declared on an App, or the explicit ``id=`` # of a standalone agent (a test, a one-off). ``app`` is the owning App's name, @@ -334,9 +338,11 @@ async def _run( # same servers. subject = await workflow.subject workspace_class = workflow.workspace_class - mcp_servers, mcp_refs = await workspace_class.get_all_mcp_servers( - session, subject, workflow.account_id - ) + mcp_servers, mcp_refs = (), [] + if self.include_mcp: + mcp_servers, mcp_refs = await workspace_class.get_all_mcp_servers( + session, subject, workflow.account_id + ) refs = [ *config.secret_refs, *( diff --git a/backend/druks/apps/base.py b/backend/druks/apps/base.py index bfbd958f..0ce083bc 100644 --- a/backend/druks/apps/base.py +++ b/backend/druks/apps/base.py @@ -391,6 +391,12 @@ def operations(cls) -> "dict[str, Operation]": name = getattr(route, "operation_id", "") or "" if not name: continue + if name.startswith(f"{cls.name}_"): + raise AppRouteConflict( + f"app {cls.name!r} declares operation {name!r}. Druks adds the app " + f"name to every operation id. Declare " + f"{name.removeprefix(f'{cls.name}_')!r}." + ) # An APIRoute's own path already carries its router's prefix. path = f"/api/{cls.name}{getattr(route, 'path', '')}" if name in found: @@ -443,8 +449,12 @@ def _page_endpoint( ): """``wraps`` keeps the page function's signature, so FastAPI still validates every route parameter. A page whose subject is missing answers - an empty state that links ``back``.""" - from druks.ui import EmptyState, Link, Page + an empty state that links ``back``. The page serializes with where the + work on each of its subjects stands, from one read.""" + from fastapi.responses import JSONResponse + + from druks.durable import reads + from druks.ui import Action, EmptyState, GateControls, Link, Page, SubjectStatus controls = [] if back and back is not declaration: @@ -467,11 +477,18 @@ async def read_page(**parameters): f"it answered with {type(page).__name__}, not a Page", ) try: - for action in page.iter_actions(): + for action in page.iter_parts(Action): action.check_operation(cls.name, operations) except ValueError as error: raise PageContractError(cls.name, declaration.name, str(error)) from error - return page + subjects = [ + (part.subject.subject_type, part.subject.subject_id) + for part in page.iter_parts(SubjectStatus, GateControls) + ] + statuses = await reads.get_statuses_for_subjects(db_session(), subjects) + return JSONResponse( + page.model_dump(mode="json", by_alias=True, context={"statuses": statuses}) + ) return read_page diff --git a/backend/druks/cli.py b/backend/druks/cli.py index 2a9ce602..d87d44da 100644 --- a/backend/druks/cli.py +++ b/backend/druks/cli.py @@ -1,6 +1,7 @@ import argparse from .database import make_app_migration, run_migrations +from .exceptions import DruksError from .settings import ensure_data_dirs, load_settings, setup_logging @@ -14,6 +15,11 @@ def main() -> None: ) makemigrations.add_argument("app", help="The installed app's name.") makemigrations.add_argument("-m", "--message", default="", help="Revision message (slug).") + check_app = subparsers.add_parser( + "check-app", + help="Load one installed app and check its contracts. Needs no database.", + ) + check_app.add_argument("app", help="The installed app's name.") doctor_parser = subparsers.add_parser( "doctor", help=( @@ -118,6 +124,17 @@ def main() -> None: print(f"Next: cd {target.name} && uv sync && uv run pytest") return + # An app's CI checks it with no configured install. + if args.command == "check-app": + from .apps.loader import load_app + + try: + load_app(args.app).routers() + except DruksError as error: + raise SystemExit(f"druks check-app: {error}") from error + print(f"{args.app}: ok") + return + settings = load_settings() setup_logging(settings) ensure_data_dirs(settings) diff --git a/backend/druks/contrib/software_factory/routes.py b/backend/druks/contrib/software_factory/routes.py index ec9cef80..d782df04 100644 --- a/backend/druks/contrib/software_factory/routes.py +++ b/backend/druks/contrib/software_factory/routes.py @@ -274,7 +274,7 @@ async def list_work_items_history( @work_items_router.post( "/{ticket}/start", status_code=status.HTTP_202_ACCEPTED, - operation_id="software_factory_start", + operation_id="start", tags=["agent"], responses=agent_error_responses(TicketNotFound("ENG-9999", "Linear"), TrackerNotConfigured()), ) diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py index 43f9b177..3477091c 100644 --- a/backend/druks/durable/models.py +++ b/backend/druks/durable/models.py @@ -5,7 +5,17 @@ from typing import TYPE_CHECKING, Any, Literal from dbos import DBOS -from sqlalchemy import CheckConstraint, ForeignKey, Index, Select, String, func, select, update +from sqlalchemy import ( + CheckConstraint, + ForeignKey, + Index, + Select, + String, + func, + select, + tuple_, + update, +) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession @@ -207,38 +217,39 @@ async def get_latest_for_subject( @classmethod async def get_latest_for_subjects( - cls, session: AsyncSession, subject_type: str, subject_ids: list[str] - ) -> dict[str, "Run"]: - """The driving run of each subject, keyed by subject id — get_latest_for_subject - for a whole board in one statement. Agent calls come with it: the status read - needs the latest agent of every running row.""" + cls, session: AsyncSession, identities: list[tuple[str, str]] + ) -> dict[tuple[str, str], "Run"]: + """The driving run of each subject, keyed by its ``(subject_type, subject_id)``: + get_latest_for_subject for subjects of any type in one statement. Agent calls + come with it: the status read needs the latest agent of every running row.""" + subject_type = ( + workflow_status.c.attributes["subject_type"].as_string().label("subject_type") + ) subject_id = workflow_status.c.attributes["subject_id"].as_string().label("subject_id") driving = ( select( + subject_type, subject_id, cls.id.label("run_id"), func.row_number() .over( - partition_by=subject_id, + partition_by=(subject_type, subject_id), order_by=(cls.created_at.desc(), cls.id.desc()), ) .label("rank"), ) .join_from(cls, workflow_status, workflow_status.c.workflow_uuid == cls.id) - .where( - workflow_status.c.attributes["subject_type"].as_string() == subject_type, - subject_id.in_(subject_ids), - ) + .where(tuple_(subject_type, subject_id).in_(identities)) .subquery() ) stmt = ( - select(driving.c.subject_id, cls) + select(driving.c.subject_type, driving.c.subject_id, cls) .join_from(cls, driving, driving.c.run_id == cls.id) .where(driving.c.rank == 1) .options(selectinload(cls.agent_calls)) ) rows = await session.execute(stmt) - return {found_id: run for found_id, run in rows} + return {(found_type, found_id): run for found_type, found_id, run in rows} @classmethod def open_subject_ids(cls, subject_type: str) -> Select: diff --git a/backend/druks/durable/reads.py b/backend/druks/durable/reads.py index d66ce269..a075c5c5 100644 --- a/backend/druks/durable/reads.py +++ b/backend/druks/durable/reads.py @@ -93,8 +93,19 @@ async def get_subject_statuses( ) -> dict[str, SubjectStatus]: """The status of every subject on a board, keyed by subject id — one driving-run read for the whole page.""" - driving_runs = await Run.get_latest_for_subjects(session, subject_type, subject_ids) - return {subject_id: await _status(driving_runs.get(subject_id)) for subject_id in subject_ids} + statuses = await get_statuses_for_subjects( + session, [(subject_type, subject_id) for subject_id in subject_ids] + ) + return {subject_id: statuses[(subject_type, subject_id)] for subject_id in subject_ids} + + +async def get_statuses_for_subjects( + session: AsyncSession, identities: list[tuple[str, str]] +) -> dict[tuple[str, str], SubjectStatus]: + """The status of each ``(subject_type, subject_id)``, keyed by it — one + driving-run read for subjects of any type.""" + driving_runs = await Run.get_latest_for_subjects(session, identities) + return {identity: await _status(driving_runs.get(identity)) for identity in identities} async def get_subject_phase( diff --git a/backend/druks/mcp/server.py b/backend/druks/mcp/server.py index 063419bd..7d6fc2e9 100644 --- a/backend/druks/mcp/server.py +++ b/backend/druks/mcp/server.py @@ -86,7 +86,7 @@ def _validate_agent_tools(api: FastAPI) -> None: # is the one view with every route's merged tags. Validation owns only the # two demands the author owns — an explicit operation_id and a non-empty # docstring; the app prefix is the framework's to derive, not the - # author's to repeat (see _namespace_agent_operations). + # author's to repeat (see _namespace_app_operations). mounted_tags: set[str] = set() bot_operations: defaultdict[str, set[str]] = defaultdict(set) for route in iter_route_contexts(api.routes): @@ -117,25 +117,25 @@ def _validate_agent_tools(api: FastAPI) -> None: def get_tool_name(operation_id: str, tags: list[str], app_names: set[str]) -> str: - # An app-owned agent operation's tool is f"{app}_{operation_id}", so the - # author never repeats the prefix. The loader tags every app route with its - # app's name, so among an agent operation's tags the one naming an - # installed app is the owner; platform agent operations carry no such tag - # and keep their declared ids. An already-prefixed id passes through, so - # stable names like software_factory_start never double. + # An app-owned operation's id is f"{app}_{operation_id}", so the author + # never repeats the prefix. The loader tags every app route with its app's + # name, so among an operation's tags the one naming an installed app is the + # owner; platform operations carry no such tag and keep their declared ids. + # Boot refuses an app id that already carries the prefix, so a prefixed id + # here is one this derived before, and it passes through unchanged. app = next((tag for tag in tags if tag in app_names), None) if app and not operation_id.startswith(f"{app}_"): return f"{app}_{operation_id}" return operation_id -def _namespace_agent_operations(spec: dict, app_names: set[str]) -> None: - # Rename each agent operation to its tool name: the provider reads the tool - # name off the spec. The namespace is what makes the merged document's - # operation ids globally unique. A derived id that would collide with another - # route's explicit id is rejected: before this derivation the clash was - # visible in the author's code, so the framework must surface it now that it - # owns the naming. +def _namespace_app_operations(spec: dict, app_names: set[str]) -> None: + # Rename each app operation to its namespaced id: the provider reads an + # agent operation's tool name off the spec. The namespace is what makes the + # merged document's operation ids globally unique. A derived id that would + # collide with another route's explicit id is rejected: before this + # derivation the clash was visible in the author's code, so the framework + # must surface it now that it owns the naming. existing_ids = { op.get("operationId") for ops in spec.get("paths", {}).values() @@ -144,12 +144,12 @@ def _namespace_agent_operations(spec: dict, app_names: set[str]) -> None: } for path, operations in spec.get("paths", {}).items(): for operation in operations.values(): - if not isinstance(operation, dict) or not _TOOL_TAGS & set(operation.get("tags", [])): + if not isinstance(operation, dict): continue operation_id = operation.get("operationId") if not operation_id: continue - derived = get_tool_name(operation_id, operation["tags"], app_names) + derived = get_tool_name(operation_id, operation.get("tags", []), app_names) if derived != operation_id: if derived in existing_ids: raise InvalidAgentToolError( @@ -160,7 +160,7 @@ def _namespace_agent_operations(spec: dict, app_names: set[str]) -> None: operation["operationId"] = derived -def _install_agent_namespacing(api: FastAPI) -> None: +def _install_app_namespacing(api: FastAPI) -> None: # The tool name comes from the spec's operation id, so the namespace must # land on the document api.openapi() builds — not on FastAPI's cached, merged # route contexts, which later generation silently discards. Wrap the app's @@ -182,7 +182,7 @@ def _install_agent_namespacing(api: FastAPI) -> None: def namespaced() -> dict: spec = generate() - _namespace_agent_operations(spec, app_names) + _namespace_app_operations(spec, app_names) return spec api.openapi = namespaced @@ -209,7 +209,7 @@ def _is_visible(context: AuthContext) -> bool: def create_mcp_app(api: FastAPI) -> StarletteWithLifespan: _validate_agent_tools(api) - _install_agent_namespacing(api) + _install_app_namespacing(api) # Built directly rather than via from_fastapi, which owns the transport: # raise_app_exceptions=False makes an app crash reach the tool as the # app's sanitized 500, so no masking is needed and the taxonomy travels. diff --git a/backend/druks/scaffolding/app_template/AGENTS.md-tpl b/backend/druks/scaffolding/app_template/AGENTS.md-tpl index 91e331a9..5f10262a 100644 --- a/backend/druks/scaffolding/app_template/AGENTS.md-tpl +++ b/backend/druks/scaffolding/app_template/AGENTS.md-tpl @@ -41,6 +41,7 @@ webhooks, and the dashboard. ## Verify ```bash +uv run druks check-app {{ name }} uv run pytest ``` diff --git a/backend/druks/services/__init__.py b/backend/druks/services/__init__.py index 0ee081b9..5e37fcfe 100644 --- a/backend/druks/services/__init__.py +++ b/backend/druks/services/__init__.py @@ -1,4 +1,4 @@ -from .base import Service +from .base import Connection, Service from .exceptions import ( OauthExchangeError, OauthRefreshError, @@ -8,6 +8,7 @@ from .oauth import OauthClient __all__ = [ + "Connection", "OauthClient", "OauthExchangeError", "OauthRefreshError", diff --git a/backend/druks/services/base.py b/backend/druks/services/base.py index 51f1f0cf..11e05166 100644 --- a/backend/druks/services/base.py +++ b/backend/druks/services/base.py @@ -78,6 +78,12 @@ def __set_name__(self, owner: type, name: str) -> None: def label(self) -> str: return f"{self.owner.name}.{self.name}" + @property + def connect_url(self) -> str: + """Where an operator connects an account to this service. The provider + sends them back to the app.""" + return f"/api/oauth/{self.service.slug}/connect?next=/{self.owner.name}" + async def list_for_account(self, account_id: str) -> list[Connection]: return [ Connection(self.service, row) diff --git a/backend/druks/ui/__init__.py b/backend/druks/ui/__init__.py index 70fcf636..646258d6 100644 --- a/backend/druks/ui/__init__.py +++ b/backend/druks/ui/__init__.py @@ -31,6 +31,7 @@ Section, Stack, StatusValue, + SubjectStatus, Table, TableColumn, TableRow, @@ -101,6 +102,7 @@ "SelectField", "Stack", "StatusValue", + "SubjectStatus", "Table", "TableColumn", "TableRow", diff --git a/backend/druks/ui/blocks.py b/backend/druks/ui/blocks.py index 107ba46f..a92c90a0 100644 --- a/backend/druks/ui/blocks.py +++ b/backend/druks/ui/blocks.py @@ -1,14 +1,17 @@ -from collections.abc import Iterable from typing import Annotated, Any, Literal from pydantic import ( + AfterValidator, AwareDatetime, BeforeValidator, ConfigDict, Discriminator, Field, + SerializationInfo, + SerializerFunctionWrapHandler, StringConstraints, computed_field, + model_serializer, model_validator, ) @@ -21,7 +24,7 @@ def _subject_identity(value): """``follows=`` takes the subject a page or a region watches, or a subject class for every subject of that type. Druks streams what it names and rereads the page on every snapshot it sends.""" - if isinstance(value, dict | Follows) or value is None: + if not value or isinstance(value, dict | Follows): return value if isinstance(value, type): subject_type = getattr(value, "subject_type", "") @@ -50,6 +53,25 @@ class Follows(Schema): Watched = Annotated[Follows | None, BeforeValidator(_subject_identity)] +def _one_subject(follows: Follows) -> Follows: + if follows.subject_id: + return follows + raise ValueError( + f"this shows the work on one {follows.subject_type}, not on every one. " + "Give the subject itself." + ) + + +Subject = Annotated[Follows, BeforeValidator(_subject_identity), AfterValidator(_one_subject)] + + +def _get_status(subject: Follows, info: SerializationInfo) -> dict[str, Any]: + # The page endpoint reads where the work on every subject of the page stands, + # and hands it to the serialization of the page. + status = info.context["statuses"][(subject.subject_type, subject.subject_id)] + return status.model_dump(mode=info.mode, by_alias=info.by_alias) + + def _check_field_names(*, owner: str, fields: list[FormField], arguments: dict[str, Any]) -> None: names = [field.name for field in fields] repeated = sorted({name for name in names if names.count(name) > 1}) @@ -77,10 +99,6 @@ def check_placement(self, *, followed: bool, regions: set[str], region: str = "" says whether any ancestor watches a subject, ``region`` names the nearest one around it, and ``regions`` collects the region names already taken.""" - def iter_actions(self) -> "Iterable[Action]": - """Every action this block offers, however deep.""" - return () - class BlockParent(PageBlock): """A block that holds other blocks.""" @@ -91,25 +109,30 @@ def check_placement(self, *, followed: bool, regions: set[str], region: str = "" for block in self.blocks: block.check_placement(followed=followed, regions=regions, region=region) - def iter_actions(self) -> "Iterable[Action]": - for block in self.blocks: - yield from block.iter_actions() - class Link(PageBlock): """A control that navigates: to another page of this app, to the subject's - own platform page, or outside.""" + own platform page, or outside. A link on a value takes the value's words, + so it needs no label of its own.""" + + # A route argument travels in a URL, so an id reads as its text. + model_config = ConfigDict(coerce_numbers_to_str=True) block: Literal["link"] = "link" - label: str + label: str = "" page: str = "" arguments: dict[str, str] = Field(default_factory=dict) url: str = "" subject: Watched = None - def __init__(self, label: str, **data): + def __init__(self, label: str = "", **data): super().__init__(label=label, **data) + def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: + if self.label: + return + raise ValueError("a Link that stands on its own has no label. Give it the words it shows.") + @model_validator(mode="after") def _one_destination(self) -> "Link": if self.subject and not self.subject.subject_id: @@ -160,9 +183,6 @@ def _one_name_for_each_value(self) -> "Action": ) return self - def iter_actions(self) -> "Iterable[Action]": - yield self - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: if self.refresh != "region" or region: return @@ -202,10 +222,6 @@ class Form(PageBlock): submit: Literal["button", "change"] = "button" layout: Literal["stack", "prose", "row"] = "stack" - def iter_actions(self) -> "Iterable[Action]": - yield self.action - yield from self.extra_actions - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: self.action.check_placement(followed=followed, regions=regions, region=region) for extra in self.extra_actions: @@ -279,10 +295,6 @@ class Callout(PageBlock): text: str controls: list[Action | Link] = Field(default_factory=list) - def iter_actions(self) -> "Iterable[Action]": - for control in self.controls: - yield from control.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for control in self.controls: control.check_placement(followed=followed, regions=regions, region=region) @@ -303,10 +315,6 @@ class EmptyState(PageBlock): description: str = "" controls: list[Action | Link] = Field(default_factory=list) - def iter_actions(self) -> "Iterable[Action]": - for control in self.controls: - yield from control.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for control in self.controls: control.check_placement(followed=followed, regions=regions, region=region) @@ -315,32 +323,9 @@ def __init__(self, title: str, **data): super().__init__(title=title, **data) -class GateControls(PageBlock): - """The operator's answer to a parked run, derived from the run itself. The - shell reads the ask, the options, and the artifact from the gate, and - submits the answer with the run's ``parkedAt``.""" - - block: Literal["gate_controls"] = "gate_controls" - run: str - - def __init__(self, run: str, **data): - super().__init__(run=run, **data) - - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: - if followed: - return - raise ValueError( - f"GateControls for run {self.run!r} sits in nothing that follows a subject, so an " - "answered gate would stay on screen. Put it in a Page or Section with follows=." - ) - - class PageValue(Schema): """What every value shares.""" - def iter_actions(self) -> Iterable[Action]: - return () - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: """Raise when this value cannot sit where the page put it.""" @@ -381,9 +366,57 @@ def __init__(self, label: str, **data): super().__init__(label=label, **data) +class SubjectStatus(PageValue): + """Where the work on one subject stands. The shell shows "needs you" when + the subject waits on the operator, ``working`` while Druks works on it, what + stopped it, or "idle".""" + + value: Literal["subject_status"] = "subject_status" + subject: Subject + working: str = "working" + + def __init__(self, subject, **data): + super().__init__(subject=subject, **data) + + @model_serializer(mode="wrap") + def _with_status( + self, serialize: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict[str, Any]: + return {**serialize(self), "status": _get_status(self.subject, info)} + + +class GateControls(PageBlock): + """The operator's answer to what a subject waits on. The shell reads the + ask, the options, and the artifact, and shows nothing while the subject + waits on nothing.""" + + block: Literal["gate_controls"] = "gate_controls" + subject: Subject + + def __init__(self, subject, **data): + super().__init__(subject=subject, **data) + + def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: + if followed: + return + raise ValueError( + "GateControls sits in nothing that follows a subject, so an answered gate would " + "stay on screen. Put it in a Page or Section with follows=." + ) + + @model_serializer(mode="wrap") + def _with_status( + self, serialize: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict[str, Any]: + return {**serialize(self), "status": _get_status(self.subject, info)} + + class TimeValue(PageValue): + """A moment. ``empty`` is the word the shell shows when there is none.""" + value: Literal["time"] = "time" - when: AwareDatetime + when: AwareDatetime | None + empty: str = "" def __init__(self, when, **data): super().__init__(when=when, **data) @@ -398,17 +431,13 @@ def __init__(self, controls=(), **data): value: Literal["controls"] = "controls" controls: list[Action | Link] = Field(default_factory=list) - def iter_actions(self) -> Iterable[Action]: - for control in self.controls: - yield from control.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for control in self.controls: control.check_placement(followed=followed, regions=regions, region=region) Value = Annotated[ - TextValue | NumberValue | StatusValue | TimeValue | ControlsValue, + TextValue | NumberValue | StatusValue | SubjectStatus | TimeValue | ControlsValue, Discriminator("value"), ] @@ -569,10 +598,6 @@ class Metrics(PageBlock): def __init__(self, metrics=(), **data): super().__init__(metrics=metrics, **data) - def iter_actions(self) -> "Iterable[Action]": - for metric in self.metrics: - yield from metric.value.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for metric in self.metrics: metric.value.check_placement(followed=followed, regions=regions, region=region) @@ -596,10 +621,6 @@ class Facts(PageBlock): def __init__(self, facts=(), **data): super().__init__(facts=facts, **data) - def iter_actions(self) -> "Iterable[Action]": - for fact in self.facts: - yield from fact.value.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for fact in self.facts: fact.value.check_placement(followed=followed, regions=regions, region=region) @@ -625,7 +646,7 @@ def __init__(self, cells=(), **data): class Table(PageBlock): """Rows of values under named columns. Every row carries one cell for each - column; with no rows the shell shows ``empty_text``. ``select`` names the + column; with no rows the shell shows ``empty``. ``select`` names the argument the selected keys fill, and ``actions`` are what run on that list.""" @@ -633,7 +654,7 @@ class Table(PageBlock): title: str = "" columns: list[TableColumn] = Field(default_factory=list) rows: list[TableRow] = Field(default_factory=list) - empty_text: str = "" + empty: EmptyState | None = None select: str = "" actions: list[Action] = Field(default_factory=list) @@ -686,19 +707,14 @@ def _select_and_actions_go_together(self) -> "Table": ) return self - def iter_actions(self) -> "Iterable[Action]": - for action in self.actions: - yield from action.iter_actions() - for row in self.rows: - for cell in row.cells: - yield from cell.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for action in self.actions: action.check_placement(followed=followed, regions=regions, region=region) for row in self.rows: for cell in row.cells: cell.check_placement(followed=followed, regions=regions, region=region) + if self.empty: + self.empty.check_placement(followed=followed, regions=regions, region=region) class List(PageBlock): @@ -709,10 +725,6 @@ class List(PageBlock): def __init__(self, items=(), **data): super().__init__(items=items, **data) - def iter_actions(self) -> "Iterable[Action]": - for item in self.items: - yield from item.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: for item in self.items: item.check_placement(followed=followed, regions=regions, region=region) @@ -751,17 +763,10 @@ class Card(BlockParent): link: Link | None = None drag: dict[str, Any] = Field(default_factory=dict) - def iter_actions(self) -> "Iterable[Action]": - yield from super().iter_actions() - for control in self.controls: - yield from control.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: super().check_placement(followed=followed, regions=regions, region=region) for control in self.controls: control.check_placement(followed=followed, regions=regions, region=region) - if self.link: - self.link.check_placement(followed=followed, regions=regions, region=region) class Cards(PageBlock): @@ -782,14 +787,6 @@ def _drop_is_immediate(self) -> "Cards": raise ValueError("Cards.drop cannot collect fields or confirm — the drop is the submit") return self - def iter_actions(self) -> "Iterable[Action]": - if self.drop: - yield from self.drop.iter_actions() - for card in self.cards: - yield from card.iter_actions() - if self.empty: - yield from self.empty.iter_actions() - def check_placement(self, *, followed: bool, regions: set[str], region: str = "") -> None: if self.drop: self.drop.check_placement(followed=followed, regions=regions, region=region) @@ -830,11 +827,6 @@ def check_placement(self, *, followed: bool, regions: set[str], region: str = "" region=inside, ) - def iter_actions(self) -> "Iterable[Action]": - for control in self.controls: - yield from control.iter_actions() - yield from super().iter_actions() - @model_validator(mode="after") def _named_when_followed(self) -> "Section": if self.follows and not self.name: diff --git a/backend/druks/ui/schemas.py b/backend/druks/ui/schemas.py index e36c1b0a..88020d51 100644 --- a/backend/druks/ui/schemas.py +++ b/backend/druks/ui/schemas.py @@ -1,4 +1,5 @@ -from collections.abc import Iterable +from collections.abc import Iterator +from typing import TypeVar from pydantic import Field, model_validator @@ -7,6 +8,8 @@ from .blocks import Action, Block, Link, Watched from .fields import Field as PageField +Part = TypeVar("Part", bound=Schema) + class Page(Schema): """One screen, as a page function projects it. The shared dashboard renders @@ -31,8 +34,16 @@ def _blocks_sit_where_they_work(self) -> "Page": block.check_placement(followed=bool(self.follows), regions=regions) return self - def iter_actions(self) -> "Iterable[Action]": - for control in self.controls: - yield from control.iter_actions() - for block in self.blocks: - yield from block.iter_actions() + def iter_parts(self, *kinds: type[Part]) -> Iterator[Part]: + """Every block, value, and control of ``kinds`` on the page, however deep, + in the order the page holds them.""" + + def walk(part: Schema) -> Iterator[Part]: + for _, value in part: + for child in value if isinstance(value, list) else [value]: + if isinstance(child, Schema): + if isinstance(child, kinds): + yield child + yield from walk(child) + + return walk(self) diff --git a/backend/druks/workflows.py b/backend/druks/workflows.py index d6022f6c..67d0e477 100644 --- a/backend/druks/workflows.py +++ b/backend/druks/workflows.py @@ -81,6 +81,7 @@ "AgentCallStatus", "FatalError", "Gate", + "GateTimeout", "Journal", "OperatorReply", "RunResponse", diff --git a/backend/druks/workspaces.py b/backend/druks/workspaces.py index 2a914a69..877c0a41 100644 --- a/backend/druks/workspaces.py +++ b/backend/druks/workspaces.py @@ -50,6 +50,10 @@ def get_agent_run_kwargs(self, **kwargs: Any) -> dict[str, Any]: # Override to add what the run needs on this workspace (add_dirs, skills). return kwargs + def get_env(self) -> dict[str, str]: + """Environment variables every agent call on this workspace gets.""" + return {} + @classmethod async def get_mcp_servers(cls, subject: Any) -> tuple[SandboxMcpServer, ...]: # Override to declare this workspace's servers and the vault row each @@ -154,7 +158,7 @@ async def _upload_input_file( return remote async def run_agent(self, *, account_id: str | None, **kwargs: Any) -> AgentResult: - run_kwargs = self.get_agent_run_kwargs(**kwargs) + run_kwargs = self.get_agent_run_kwargs(extra_env=self.get_env(), **kwargs) # Commit so the step's connection isn't held idle through the minutes # the agent runs. await db_session().commit() diff --git a/backend/tests/druks-field_notes/druks_field_notes/pages.py b/backend/tests/druks-field_notes/druks_field_notes/pages.py index ff0f196e..98953e96 100644 --- a/backend/tests/druks-field_notes/druks_field_notes/pages.py +++ b/backend/tests/druks-field_notes/druks_field_notes/pages.py @@ -39,11 +39,7 @@ async def notes(): description=note.gist or "Waiting for its gist.", blocks=[ui.Text(note.body)], controls=[ - ui.Link( - "Open", - page="note", - arguments={"note_id": str(note.id)}, - ) + ui.Link("Open", page="note", arguments={"note_id": note.id}) ], ) for note in recent @@ -96,6 +92,7 @@ async def recent_notes(): columns=[ ui.TableColumn("Note"), ui.TableColumn("Gist"), + ui.TableColumn("State"), ui.TableColumn("Captured", align="end"), ], rows=[ @@ -103,22 +100,19 @@ async def recent_notes(): [ ui.TextValue( f"Note {note.id}", - link=ui.Link( - f"Note {note.id}", - page="note", - arguments={"note_id": str(note.id)}, - ), + link=ui.Link(page="note", arguments={"note_id": note.id}), ), ui.StatusValue( "summarized" if note.gist else "waiting", tone="success" if note.gist else "warning", ), + ui.SubjectStatus(note, working="summarizing"), ui.TimeValue(note.created_at), ] ) for note in recent ], - empty_text="No notes yet.", + empty=ui.EmptyState("No notes yet."), ), ui.List([ui.TextValue(note.body) for note in recent], title="Bodies"), ] @@ -160,13 +154,6 @@ async def new_note(): @ui.page("/notes/{note_id}", subject=Note) async def note(note_id: int): found = await Note.get(id=note_id) - status = await found.get_status() - # The region follows the note, so answering the gate refreshes it and - # the controls go away. - if status.gate: - decision = [ui.GateControls(status.run)] - else: - decision = [ui.Text("Nothing is waiting on you.")] return ui.Page( f"Note {note_id}", description=found.gist or "Waiting for its gist.", @@ -185,7 +172,14 @@ async def note(note_id: int): ) ], ), - ui.Section(title="Your decision", name="decision", follows=found, blocks=decision), + # The region follows the note, so answering the gate refreshes it and + # the controls go away. + ui.Section( + title="Your decision", + name="decision", + follows=found, + blocks=[ui.GateControls(found)], + ), ], ) diff --git a/backend/tests/test_agent_routes.py b/backend/tests/test_agent_routes.py index 0d8c8ef6..418c28ab 100644 --- a/backend/tests/test_agent_routes.py +++ b/backend/tests/test_agent_routes.py @@ -88,6 +88,12 @@ async def _park(druks_db, note, *, context: str = ""): return run +def test_openapi_names_every_app_operation_for_its_app(client: TestClient): + operation = app.openapi()["paths"]["/api/field_notes/notes"]["post"] + + assert operation["operationId"] == "field_notes_write_note" + + def test_openapi_pins_platform_and_app_agent_routes(client: TestClient): schema = app.openapi() found = { diff --git a/backend/tests/test_agents.py b/backend/tests/test_agents.py index 7d4071ed..ac2b345c 100644 --- a/backend/tests/test_agents.py +++ b/backend/tests/test_agents.py @@ -17,6 +17,7 @@ from druks.secrets.models import VaultSecret from druks.usage.models import UsageScrape from druks.user_settings.models import SettingsOverride +from druks.workspaces import Workspace from sqlalchemy import select @@ -196,6 +197,26 @@ async def test_declared_plugin_choice_is_forwarded(druks_db, tmp_path, monkeypat assert sandbox.run_agent.await_args.kwargs["include_plugins"] is False +async def test_an_agent_without_mcp_resolves_no_server( + druks_db, tmp_path, monkeypatch, current_run +): + agent = agents.Agent( + id="sealed_probe", + prompt="dummy/agent.md", + contract=DummyOutput, + include_mcp=False, + ) + sandbox = _patch_runtime(monkeypatch, tmp_path, {"ok": True}) + _patch_ephemeral(monkeypatch, sandbox) + resolve = AsyncMock() + monkeypatch.setattr(Workspace, "get_all_mcp_servers", resolve) + + await agent._run(db_session(), workflow_id="wf-9") + + resolve.assert_not_awaited() + assert sandbox.run_agent.await_args.kwargs["mcp_servers"] == () + + async def test_runner_comes_from_workflow_workspace_factory( druks_db, tmp_path, monkeypatch, current_run ): diff --git a/backend/tests/test_author_surface.py b/backend/tests/test_author_surface.py index 2258f2f3..c3e2d942 100644 --- a/backend/tests/test_author_surface.py +++ b/backend/tests/test_author_surface.py @@ -8,6 +8,7 @@ "druks.apps": {"App", "AppSettings", "Choices", "Secret"}, "druks.browser": {"BrowserSession", "BrowserSessionSignedOutError", "BrowserSessionStatus"}, "druks.services": { + "Connection", "OauthClient", "OauthExchangeError", "OauthRefreshError", @@ -23,6 +24,7 @@ "AgentCallStatus", "FatalError", "Gate", + "GateTimeout", "Journal", "OperatorReply", "RunResponse", @@ -82,6 +84,7 @@ "SelectField", "Stack", "StatusValue", + "SubjectStatus", "Table", "TableColumn", "TableRow", diff --git a/backend/tests/test_generic_subjects.py b/backend/tests/test_generic_subjects.py index d6392b8d..c23eb141 100644 --- a/backend/tests/test_generic_subjects.py +++ b/backend/tests/test_generic_subjects.py @@ -8,7 +8,7 @@ from druks.db import db_session from druks.durable import AgentCall, Run from druks.durable.datastructures import Subject -from druks.durable.reads import get_subject_statuses +from druks.durable.reads import get_statuses_for_subjects, get_subject_statuses from druks.durable.schemas import SubjectSummary from druks.models import StoredSubject from druks.testing import asgi_client, seed_dbos_status @@ -279,6 +279,20 @@ async def test_the_board_status_read_answers_for_every_id_it_is_given(druks_db): assert statuses["2"].run is None +async def test_one_status_read_answers_for_subjects_of_every_type(druks_db): + # A page can show two subject types, and one id under each names two subjects. + thing = await _seed_run(druks_db, subject_id="1", state="running") + ticket = await _seed_run(druks_db, subject_type="ticket", subject_id="1", state="parked") + + statuses = await get_statuses_for_subjects( + druks_db, [("ticket", "1"), ("thing", "1"), ("thing", "2")] + ) + + assert statuses[("ticket", "1")].run == ticket.id + assert statuses[("thing", "1")].run == thing.id + assert statuses[("thing", "2")].run is None + + async def test_a_page_reads_a_whole_board_through_the_subject_class(druks_db): # What a declared page calls to fill a list of rows: the read the platform's # own board makes, reached without importing the durable read side. diff --git a/backend/tests/test_ui_actions.py b/backend/tests/test_ui_actions.py index 05acf82a..b6fd648e 100644 --- a/backend/tests/test_ui_actions.py +++ b/backend/tests/test_ui_actions.py @@ -327,7 +327,7 @@ def test_a_form_that_submits_on_change_cannot_have_extra_actions(): def check(page: Page) -> None: - for action in page.iter_actions(): + for action in page.iter_parts(Action): action.check_operation("field_notes", OPERATIONS) @@ -362,7 +362,7 @@ def test_every_action_on_the_page_is_checked(): ], ) - assert [action.operation for action in page.iter_actions()] == [ + assert [action.operation for action in page.iter_parts(Action)] == [ "write_note", "write_note", "write_note", @@ -397,6 +397,19 @@ async def stub() -> dict[str, str]: app.operations() +def test_an_operation_id_leaves_the_app_name_to_druks(monkeypatch): + async def stub() -> dict[str, str]: + return {} + + app = load_app("field_notes") + router = APIRouter(prefix="/prefixed") + router.post("/one", operation_id="field_notes_write_note")(stub) + monkeypatch.setattr(app, "_declared_routers", classmethod(lambda cls, modules=None: [router])) + + with pytest.raises(AppRouteConflict, match="Declare 'write_note'"): + app.operations() + + def test_an_action_that_refreshes_its_region_needs_one(): with pytest.raises(ValueError, match="refreshes its region, and it sits in none"): Page("x", blocks=[Action(label="Go", operation="write_note", refresh="region")]) @@ -415,7 +428,7 @@ def test_a_named_section_is_a_region_an_action_can_refresh(): ], ) - assert [action.label for action in page.iter_actions()] == ["Go"] + assert [action.label for action in page.iter_parts(Action)] == ["Go"] def test_a_section_action_belongs_to_its_region(): @@ -431,7 +444,7 @@ def test_a_section_action_belongs_to_its_region(): ], ) - assert list(page.iter_actions()) == [action] + assert list(page.iter_parts(Action)) == [action] section = page.model_dump(by_alias=True, mode="json")["blocks"][0] (control,) = section["controls"] assert control["label"] == "Go" @@ -458,7 +471,7 @@ def test_a_table_bulk_action_is_checked(): ], ) - assert [action.operation for action in page.iter_actions()] == ["nowhere"] + assert [action.operation for action in page.iter_parts(Action)] == ["nowhere"] with pytest.raises(ValueError, match="nowhere"): check(page) @@ -474,7 +487,7 @@ def test_a_table_cell_action_is_checked(): ], ) - assert [action.operation for action in page.iter_actions()] == ["nowhere"] + assert [action.operation for action in page.iter_parts(Action)] == ["nowhere"] with pytest.raises(ValueError, match="nowhere"): check(page) @@ -530,4 +543,4 @@ def test_a_named_section_can_refresh_from_a_table_cell(): ], ) - assert [action.label for action in page.iter_actions()] == ["Go"] + assert [action.label for action in page.iter_parts(Action)] == ["Go"] diff --git a/backend/tests/test_ui_data_blocks.py b/backend/tests/test_ui_data_blocks.py index 94d0931a..65f77705 100644 --- a/backend/tests/test_ui_data_blocks.py +++ b/backend/tests/test_ui_data_blocks.py @@ -77,14 +77,12 @@ def test_a_table_cell_can_reach_another_page(): rows=[ TableRow( [ - TextValue( - "peer-7", link=Link("peer-7", page="peer", arguments={"peer_id": "7"}) - ), + TextValue("peer-7", link=Link(page="peer", arguments={"peer_id": 7})), NumberValue(12), ] ) ], - empty_text="No peers yet.", + empty=EmptyState("No peers yet."), ) ) @@ -93,7 +91,8 @@ def test_a_table_cell_can_reach_another_page(): {"label": "Answers", "align": "end"}, ] assert block["rows"][0]["cells"][0]["link"]["page"] == "peer" - assert block["emptyText"] == "No peers yet." + assert block["rows"][0]["cells"][0]["link"]["arguments"] == {"peer_id": "7"} + assert block["empty"]["title"] == "No peers yet." def test_a_table_can_select_rows_for_its_actions(): @@ -238,7 +237,11 @@ def test_every_value_carries_its_own_discriminator(): "unit": "ms", "tone": "neutral", } - assert facts["facts"][3]["value"] == {"value": "time", "when": "2026-08-29T09:14:02Z"} + assert facts["facts"][3]["value"] == { + "value": "time", + "when": "2026-08-29T09:14:02Z", + "empty": "", + } def test_metrics_hold_metrics_and_a_list_holds_values(): @@ -286,10 +289,12 @@ def test_cards_finds_an_action_in_a_card_and_in_its_empty_state(): drop=Action(label="Move", operation="move_peer"), ) - assert [action.operation for action in block.iter_actions()] == [ - "move_peer", + page = Page("x", blocks=[block]) + + assert [action.operation for action in page.iter_parts(Action)] == [ "retire_peer", "scan", + "move_peer", ] @@ -304,7 +309,9 @@ def test_a_callout_carries_its_next_step(): ) assert wire(block)[0]["controls"][0]["url"] == "/settings/connections" - assert [action.operation for action in block.iter_actions()] == ["retry_connect"] + page = Page("x", blocks=[block]) + + assert [action.operation for action in page.iter_parts(Action)] == ["retry_connect"] def test_cards_drop_cannot_collect_fields_or_confirm(): diff --git a/backend/tests/test_ui_followed_regions.py b/backend/tests/test_ui_followed_regions.py index 78e7c2fd..5d9df659 100644 --- a/backend/tests/test_ui_followed_regions.py +++ b/backend/tests/test_ui_followed_regions.py @@ -47,13 +47,13 @@ def test_a_followed_region_needs_a_name(note: Note): def test_gate_controls_need_something_that_follows(note: Note): with pytest.raises(ValueError, match="follows a subject"): - Page(title="Note", blocks=[GateControls("run-6f0a")]) + Page(title="Note", blocks=[GateControls(note)]) def test_a_following_page_is_enough_for_gate_controls(note: Note): - page = Page(title="Note", follows=note, blocks=[GateControls("run-6f0a")]) + page = Page(title="Note", follows=note, blocks=[GateControls(note)]) - assert page.blocks[0].run == "run-6f0a" + assert page.blocks[0].subject.subject_id == str(note.id) def test_a_following_region_covers_the_blocks_under_it(note: Note): @@ -63,19 +63,19 @@ def test_a_following_region_covers_the_blocks_under_it(note: Note): Section( name="decision", follows=note, - blocks=[Card(blocks=[GateControls("run-6f0a")])], + blocks=[Card(blocks=[GateControls(note)])], ) ], ) - assert page.blocks[0].blocks[0].blocks[0].run == "run-6f0a" + assert list(page.iter_parts(GateControls)) == [page.blocks[0].blocks[0].blocks[0]] def test_a_region_that_follows_nothing_does_not_cover_gate_controls(note: Note): with pytest.raises(ValueError, match="follows a subject"): Page( title="Note", - blocks=[Section(name="decision", blocks=[GateControls("run-6f0a")])], + blocks=[Section(name="decision", blocks=[GateControls(note)])], ) @@ -125,6 +125,11 @@ def test_a_link_takes_exactly_one_destination(note: Note): Link("Everything druks did", page="notes", subject=note) +def test_a_link_on_its_own_needs_a_label(): + with pytest.raises(ValueError, match="has no label"): + Page(title="Note", controls=[Link(page="notes")]) + + def test_a_link_refuses_a_subject_type(): with pytest.raises(ValueError, match="opens one subject's page"): Link("Everything druks did", subject=Note) diff --git a/backend/tests/test_ui_page_api.py b/backend/tests/test_ui_page_api.py index b71be232..e7c07167 100644 --- a/backend/tests/test_ui_page_api.py +++ b/backend/tests/test_ui_page_api.py @@ -127,7 +127,9 @@ async def test_a_page_carries_the_region_that_follows_its_subject( assert region["block"] == "section" assert region["name"] == "decision" assert region["follows"] == {"subjectType": "note", "subjectId": str(note.id)} - assert region["blocks"][0]["block"] == "text" + [controls] = region["blocks"] + assert controls["block"] == "gate_controls" + assert not controls["status"]["gate"] async def test_a_parked_run_puts_gate_controls_in_the_followed_region( @@ -146,9 +148,25 @@ async def test_a_parked_run_puts_gate_controls_in_the_followed_region( page = (await druks_client.get(f"/api/field_notes/pages/notes/{note.id}")).json() - region = page["blocks"][2] - assert region["follows"] == {"subjectType": "note", "subjectId": str(note.id)} - assert region["blocks"] == [{"block": "gate_controls", "run": run.id}] + [controls] = page["blocks"][2]["blocks"] + assert controls["status"]["run"] == run.id + assert controls["status"]["gate"] == "review" + + +async def test_a_page_shows_where_the_work_on_each_subject_stands( + druks_client: httpx.AsyncClient, druks_db, note: Note +): + await seed_run( + druks_db, kind=Summarize.kind, subject=note, state="failed", failure="The model timed out." + ) + + page = (await druks_client.get("/api/field_notes/pages/recent")).json() + + table = next(block for block in page["blocks"][0]["blocks"] if block["block"] == "table") + status = table["rows"][0]["cells"][2] + assert status["working"] == "summarizing" + assert status["status"]["state"] == "failed" + assert status["status"]["failure"] == "The model timed out." async def test_the_history_page_shows_the_domain_and_points_at_the_platform( diff --git a/backend/tests/test_workspaces.py b/backend/tests/test_workspaces.py index db9087ac..8620f93e 100644 --- a/backend/tests/test_workspaces.py +++ b/backend/tests/test_workspaces.py @@ -22,6 +22,23 @@ def __init__(self) -> None: self.events: list[tuple[Any, ...]] = [] +async def test_every_agent_call_gets_the_workspace_env(druks_db): + class DeployWorkspace(Workspace): + def get_env(self) -> dict[str, str]: + return {"DEPLOY_TOKEN": "token"} + + calls: list[dict[str, Any]] = [] + + class _Host: + async def run_agent(self, session, **kwargs: Any) -> str: + calls.append(kwargs) + return "result" + + await DeployWorkspace(host=_Host()).run_agent(account_id=None, prompt="p") # type: ignore[arg-type] + + assert calls == [{"extra_env": {"DEPLOY_TOKEN": "token"}, "prompt": "p"}] + + async def test_repo_workspace_clones_before_every_agent_call_and_writes_no_token( monkeypatch: pytest.MonkeyPatch, ): diff --git a/docs/druks-ui.md b/docs/druks-ui.md index 8871843c..b5be298e 100644 --- a/docs/druks-ui.md +++ b/docs/druks-ui.md @@ -59,7 +59,7 @@ Chart ChartSeries ImageGallery rich data blocks Metrics Metric Facts Fact Table TableColumn TableRow List TextValue NumberValue StatusValue values -TimeValue ControlsValue +SubjectStatus TimeValue ControlsValue Option TextField TextAreaField fields NumberField SelectField MultiSelectField RadioField CheckboxField UploadField MultiUploadField @@ -322,11 +322,6 @@ A `Page` or a named region declares what it watches: @ui.page("/peers/{peer_id}") async def peer(peer_id: int): watched = await Peer.get(id=peer_id) - status = await watched.get_status() - if status.gate: - decision = [ui.GateControls(status.run)] - else: - decision = [ui.Text("No decision is waiting.")] return ui.Page( title=watched.name, blocks=[ @@ -334,7 +329,7 @@ async def peer(peer_id: int): name="decision", title="Decision", follows=watched, - blocks=decision, + blocks=[ui.GateControls(watched)], ) ], ) @@ -386,21 +381,22 @@ The shell owns the `EventSource`, the reconnect, the retry, and the stale-response protection. A response from an older read never replaces a newer one. -A `follows=` on the `Page` itself replaces the whole page body. +A `follows=` on the `Page` itself replaces the whole page body. When the page +follows one subject, the shell also links it to that subject's own page, where +the timeline of every run lives. ## Gates -`GateControls` declares only the run: +`GateControls` takes the subject that waits on the operator: ```python -status = await peer.get_status() -if status.gate: - decision = ui.GateControls(status.run) +ui.GateControls(peer) ``` -The shell derives everything else from the parked run: the questions, the -options, the recommended choice, the context, the controls, the note, and the -artifact. The note box shows only when the gate declares `note`. +The shell shows nothing while the subject waits on nothing. Otherwise it shows +the questions, the options, the recommended choice, the context, the controls, +the note, and the artifact. The note box shows only when the gate declares +`note`. - The shell reads `GET /api/gates/{run}`. - The shell submits `POST /api/gates/{run}/answer`. @@ -414,7 +410,24 @@ that follows a subject. Druks rejects a `GateControls` block with no such ancestor when it builds the page. Without the follow, an answered gate would stay on screen. -When the run resumes, the followed region refreshes and the controls go away. +When the operator answers, the followed region refreshes and the controls go +away. + +## Where the work stands + +`SubjectStatus` shows where the work on one subject stands: + +```python +ui.TableRow([ui.TextValue(target.name), ui.SubjectStatus(target, working="auditing")]) +``` + +When Druks serves the page, one read gets the status of every subject on it, +whatever their types. The shell writes the word: + +- "needs you" while the subject waits on the operator, +- "failed", "cancelled", or "orphaned" when the work stopped, with its message, +- the `working` word while Druks works on the subject, +- "idle" otherwise. ## Actions and links @@ -536,7 +549,17 @@ ui.Link("Provider status", url="https://status.example.com") ``` A `Link` sets `page` or `url`, never both and never neither. Druks rejects a -`Link` that sets neither or both when it builds the page. +`Link` that sets neither or both when it builds the page. An argument can be a +number, and the link carries it as text. + +A link on a value shows the value's words, so it needs no label: + +```python +ui.TextValue(peer.name, link=ui.Link(page="peer", arguments={"peer_id": peer.id})) +``` + +A link that stands on its own needs a label. Druks rejects one without a label +when it builds the page. A `page` names a declared page of the same app, and `arguments` fills that page's route parameters. The shell resolves both against the page table. It @@ -588,7 +611,10 @@ Block = Annotated[ Discriminator("block"), ] -Value = Annotated[TextValue | NumberValue | StatusValue | TimeValue | ControlsValue, Discriminator("value")] +Value = Annotated[ + TextValue | NumberValue | StatusValue | SubjectStatus | TimeValue | ControlsValue, + Discriminator("value"), +] Field = Annotated[ TextField | TextAreaField | NumberField | SelectField | MultiSelectField @@ -771,7 +797,7 @@ ui.Cards( ui.Card( title=peer.name, blocks=[...], - link=ui.Link(peer.name, page="peer", arguments={"peer_id": str(peer.id)}), + link=ui.Link(peer.name, page="peer", arguments={"peer_id": peer.id}), ) for peer in peers ], @@ -848,7 +874,7 @@ class EmptyState: ```python class Link: block: Literal["link"] = "link" - label: str + label: str = "" page: str = "" arguments: dict[str, str] = {} url: str = "" @@ -867,6 +893,15 @@ full story of what druks did about it, which no app page recomposes: ui.Link("Everything druks did", subject=found) ``` +The shell adds that link to every page that follows one subject. + +A service an app declares with `with_scopes()` knows where an operator +connects it, and the provider sends the operator back to the app: + +```python +ui.Link("Add an account", url=FieldNotes.calendar.connect_url) +``` + ### Action ```python @@ -1124,13 +1159,16 @@ The shell previews an image. Every file gets a download through ```python class GateControls: block: Literal["gate_controls"] = "gate_controls" - run: str + subject: Follows ``` ```json -{"block": "gate_controls", "run": "run-6f0a"} +{"block": "gate_controls", "subject": {"subjectType": "peer", "subjectId": "7"}, "status": {"state": "parked", "run": "run-6f0a", "kind": "reports.audit", "agent": null, "gate": "review", "failure": null, "reason": null, "triggeredAt": "2026-08-29T09:14:02Z", "accountUsername": "operator"}} ``` +The author writes `ui.GateControls(peer)`. Druks adds `status`, the status the +board reads, when it serves the page. + The name is `GateControls`. `druks.ui` has no type named `Gate`. `Gate` is the workflow-side declaration in `druks.workflows`. @@ -1263,7 +1301,7 @@ class Table: title: str = "" columns: list[TableColumn] = [] rows: list[TableRow] = [] - empty_text: str = "" + empty: EmptyState | None = None select: str = "" actions: list[Action] = [] ``` @@ -1281,12 +1319,12 @@ class Table: ] } ], - "emptyText": "No peers yet." + "empty": {"block": "empty_state", "title": "No peers yet.", "description": "", "controls": []} } ``` Every row must have one cell for each column. With no rows the shell shows -`empty_text`, and nothing of its own. A wide table scrolls inside its own +`empty`, and nothing of its own. A wide table scrolls inside its own container, on a narrow screen as well: a stacked row would lose the header each cell belongs to. @@ -1409,20 +1447,41 @@ The app writes the word. The tone selects the presentation. The contract has no type named `Status`. `active` reads as work in flight, so a settled fact takes another tone. `link` reaches the thing it names. +### SubjectStatus + +```python +class SubjectStatus: + value: Literal["subject_status"] = "subject_status" + subject: Follows + working: str = "working" +``` + +```json +{"value": "subject_status", "subject": {"subjectType": "target", "subjectId": "7"}, "working": "auditing", "status": {"state": "failed", "run": "run-6f0a", "kind": "reports.audit", "agent": null, "gate": null, "failure": "The crawl timed out.", "reason": null, "triggeredAt": "2026-08-29T09:14:02Z", "accountUsername": "operator"}} +``` + +The author writes `ui.SubjectStatus(target, working="auditing")`. Druks adds +`status`, the status the board reads, when it serves the page. + ### TimeValue ```python class TimeValue: value: Literal["time"] = "time" - when: AwareDatetime + when: AwareDatetime | None + empty: str = "" ``` ```json -{"value": "time", "when": "2026-08-29T09:14:02Z"} +{"value": "time", "when": "2026-08-29T09:14:02Z", "empty": ""} ``` `when` must name an offset. The shell shows a relative time, and the exact -time in the title attribute. +time in the title attribute. With no `when`, it shows `empty`: + +```python +ui.TimeValue(target.last_audit_at, empty="never") +``` ### ControlsValue diff --git a/docs/writing-an-app.md b/docs/writing-an-app.md index 8dd768e3..a840767b 100644 --- a/docs/writing-an-app.md +++ b/docs/writing-an-app.md @@ -454,6 +454,22 @@ class NightWatch(App): The app name and the attribute name form the agent's id: `night_watch.report`. Settings overrides, the timeline, and the step name use that id. +An agent that reads untrusted content, such as email or a web page, gets no +plugin state and no MCP server: + +```python + triage = Agent( + prompt="triage.md", + contract=TriageOutput, + include_plugins=False, + include_mcp=False, + ) +``` + +A workflow that sets `steps_reuse_sandbox = True` keeps one sandbox for all its +agents, and the sandbox takes its entries from the agent call that creates it. +Give such a workflow agents that all include MCP servers, or none that do. + Call it only inside a workflow: ```python @@ -668,8 +684,18 @@ those two, so a request cannot select another repo or identity. Override `Workflow.get_workspace_kwargs()` to pass `branch` or the fields a subclass adds. Extend `RepoWorkspace` by adding fields, not by cloning again. -Override `run_agent()` to prepare the VM before the call, and -`get_agent_run_kwargs()` to grant directories or skills. +Override `run_agent()` to prepare the VM before the call, +`get_agent_run_kwargs()` to grant directories or skills, and `get_env()` to give +every agent call environment variables: + +```python +@dataclass(frozen=True, kw_only=True) +class DeployWorkspace(RepoWorkspace): + deploy_token: str + + def get_env(self) -> dict[str, str]: + return {"DEPLOY_TOKEN": self.deploy_token} +``` Override `get_secrets(subject)` to give the sandbox a secret of its own: @@ -729,8 +755,8 @@ sandbox. The harness configuration names the variable, and the sandbox never holds the token. A workspace server owns its name, so a same-named registry server is not delivered. `Workspace.get_all_mcp_servers(subject, account_id)` returns the harness shapes and the secret refs for every MCP server of a -sandbox: the workspace's servers and the enabled registry servers. Override it -to give the sandbox none. +sandbox: the workspace's servers and the enabled registry servers. An agent +declared with `include_mcp=False` gets none of them. Keep durable state outside the VM. A workflow can set `steps_reuse_sandbox = True` to retain one host across a segment. Druks releases @@ -992,6 +1018,11 @@ statuses = await Repository.get_statuses([summary.id for summary in summaries]) This is the read the platform's own board uses, so a declared page listing fifty rows costs one query rather than fifty. +A page needs neither read to show where the work stands. +`ui.SubjectStatus(repository)` takes the subject, and Druks reads every status +on the page when it serves the page. See +[Where the work stands](druks-ui.md#where-the-work-stands). + ## Activity facts and signals Use [announcements](#announcing-domain-events) for facts the app owns. @@ -1200,10 +1231,14 @@ def list_reviews() -> list[ReviewResponse]: return Review.list_for_account(current_account_id.get()) ``` +Druks prefixes every operation id of an app with the app name, so write the +bare verb. For example, `operation_id="write_note"` in `field_notes` becomes +`field_notes_write_note` in the OpenAPI document. Startup refuses an id that +already starts with the app name. An `Action` names the bare id. + Tag a route with `agent` to create an MCP tool from it. Give the route an -explicit `operation_id`. Druks prefixes this value with the app name. For -example, `operation_id="add_peer"` in `peer_tracker` becomes -`peer_tracker_add_peer`. The docstring supplies the description. +explicit `operation_id`, and the tool takes the prefixed name. The docstring +supplies the description. A `GET` route is read-only. If a write is non-destructive, declare `x-destructive: false`. If a write is idempotent, declare `x-idempotent: true`. @@ -1584,6 +1619,14 @@ fixtures directly without a `conftest.py` or `pytest_plugins` declaration: The fixtures are not autouse. A test that requests `druks_client` also gets `druks_db`. A test accesses Redis only if it requests `druks_redis`. +`druks check-app` loads one installed app and checks its subjects, pages, +operations, and routers. It needs no database and no configured install, so an +app's CI can run it. It exits non-zero on the first contract the app breaks: + +```bash +druks check-app field_notes +``` + Run a workflow's body against a subject with no durable engine — no checkpoints, no lifecycle events, no retries: @@ -1725,14 +1768,16 @@ shell rereads the page on each snapshot: @ui.page("/notes/{note_id}") async def note(note_id: int): found = await Note.get(id=note_id) - status = await found.get_status() - if status.gate: - decision = [ui.GateControls(status.run)] - else: - decision = [ui.Text("Nothing is waiting on you.")] return ui.Page( title=f"Note {note_id}", - blocks=[ui.Section(title="Your decision", name="decision", follows=found, blocks=decision)], + blocks=[ + ui.Section( + title="Your decision", + name="decision", + follows=found, + blocks=[ui.GateControls(found)], + ) + ], ) ``` @@ -1740,10 +1785,11 @@ The shell replaces the named region and leaves the rest of the page alone, so scroll position, focus, and half-filled inputs outside it survive. A region that follows a subject must have a name. That is how the shell finds it. -`GateControls` names only the run. The shell reads the ask, its options, its -context, and its artifact from the parked run, and submits the operator's -answer with the run's `parkedAt`. A `GateControls` block must sit inside -something that follows a subject, or an answered gate would stay on screen. +`GateControls` takes the subject. The shell shows nothing while the subject +waits on nothing. Otherwise it shows the ask, its options, its context, and its +artifact, and sends the operator's answer. A `GateControls` block must sit +inside something that follows a subject, or an answered gate would stay on +screen. ### Let an operator act @@ -1877,16 +1923,17 @@ Import from concern namespaces, not from `druks.durable` or internal modules: | --- | --- | | `druks.accounts` | `current_account_id` | | `druks.apps` | `App`, `AppSettings`, `Choices`, `Secret` | -| `druks.services` | `Service`, `ServiceConnectError`, `ServiceNotConnectedError`, `OauthClient`, `OauthExchangeError`, `OauthRefreshError` | +| `druks.services` | `Service`, `Connection`, `ServiceConnectError`, `ServiceNotConnectedError`, `OauthClient`, `OauthExchangeError`, `OauthRefreshError` | +| `druks.browser` | `BrowserSession`, `BrowserSessionSignedOutError`, `BrowserSessionStatus` | | `druks.agents` | `Agent`, `AgentOutput`, `Bot`, `BotUser` | -| `druks.workflows` | `Workflow`, `Gate`, `step`, run/agent response types, lifecycle enums and workflow errors | +| `druks.workflows` | `Workflow`, `Gate`, `GateTimeout`, `step`, run/agent response types, lifecycle enums and workflow errors | | `druks.sandbox` | `Sandbox`, `SandboxMcpServer`, `SandboxSecret` | | `druks.workspaces` | `Workspace`, `RepoWorkspace` | | `druks.db` | `Model`, `StoredSubject`, `db_session` | | `druks.db.fields` | `EncryptedJsonField`, `EncryptedTextField`, `Secret`, `SecretsMapping` | | `druks.exceptions` | `DruksError`, `ObjectNotFound` | | `druks.schemas` | `Schema` | -| `druks.ui` | `Action`, `Block`, `Callout`, `Card`, `Cards`, `Chart`, `ChartSeries`, `CheckboxField`, `Columns`, `ControlsValue`, `Divider`, `EmptyState`, `Fact`, `Facts`, `Field`, `FileSummary`, `Files`, `Follows`, `Form`, `GateControls`, `Image`, `ImageGallery`, `Link`, `List`, `Markdown`, `Metric`, `Metrics`, `MultiSelectField`, `MultiUploadField`, `NumberField`, `NumberValue`, `Option`, `Page`, `Progress`, `ProgressStep`, `Quote`, `RadioField`, `Section`, `SecretField`, `SelectField`, `Stack`, `StatusValue`, `Table`, `TableColumn`, `TableRow`, `Text`, `TextAreaField`, `TextField`, `TextValue`, `TimeValue`, `Timeline`, `TimelineItem`, `UploadField`, `Value`, `page` | +| `druks.ui` | `Action`, `Block`, `Callout`, `Card`, `Cards`, `Chart`, `ChartSeries`, `CheckboxField`, `Columns`, `ControlsValue`, `Divider`, `EmptyState`, `Fact`, `Facts`, `Field`, `FileSummary`, `Files`, `Follows`, `Form`, `GateControls`, `Image`, `ImageGallery`, `Link`, `List`, `Markdown`, `Metric`, `Metrics`, `MultiSelectField`, `MultiUploadField`, `NumberField`, `NumberValue`, `Option`, `Page`, `Progress`, `ProgressStep`, `Quote`, `RadioField`, `Section`, `SecretField`, `SelectField`, `Stack`, `StatusValue`, `SubjectStatus`, `Table`, `TableColumn`, `TableRow`, `Text`, `TextAreaField`, `TextField`, `TextValue`, `TimeValue`, `Timeline`, `TimelineItem`, `UploadField`, `Value`, `page` | | `druks.signals` | `subscribe` | | `druks.events` | `Event` | | `druks.files` | `File`, `FileField` | diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 0673b02e..bf86ade1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -296,7 +296,18 @@ export interface NumberValue { export interface TimeValue { value: 'time' - when: string + when: string | null + // The word the shell shows when there is no moment. + empty: string +} + +// Where the work on one subject stands. Druks reads the status when it serves +// the page; the shell writes the word. +export interface SubjectStatusValue { + value: 'subject_status' + subject: Follows + working: string + status: SubjectStatus } export interface ControlsValue { @@ -305,7 +316,13 @@ export interface ControlsValue { } // One rendered datum. It reads the same way in Facts, Metrics, List, and Table. -export type Value = TextValue | NumberValue | StatusValue | TimeValue | ControlsValue +export type Value = + | TextValue + | NumberValue + | StatusValue + | SubjectStatusValue + | TimeValue + | ControlsValue export interface ChartSeries { label: string @@ -438,7 +455,7 @@ export type Block = blocks: Block[] follows: Follows | null } - | { block: 'gate_controls'; run: string } + | { block: 'gate_controls'; subject: Follows; status: SubjectStatus } | { block: 'timeline'; title: string; items: TimelineItem[] } | { block: 'progress' @@ -466,7 +483,7 @@ export type Block = title: string columns: TableColumn[] rows: TableRow[] - emptyText: string + empty: EmptyStateBlock | null select: string actions: Action[] } diff --git a/frontend/src/druksui/AppPage.tsx b/frontend/src/druksui/AppPage.tsx index 2b632150..0d751ffc 100644 --- a/frontend/src/druksui/AppPage.tsx +++ b/frontend/src/druksui/AppPage.tsx @@ -12,6 +12,7 @@ import { AppSurface } from './AppSurface' import { Blocks } from './Blocks' import { Controls } from './DataBlocks' import { Fields } from './Fields' +import { LinkControl } from './LinkControl' import { followedSubjects, gateRuns, @@ -160,12 +161,24 @@ export function AppPage({ app, page }: { app: string; page: string }) {

This input request is unavailable. Return to the Dashboard to open the current request.

)} + {snapshot.data.follows?.subjectId && } ) } +// Every page about one subject reaches that subject's own page, where the +// timeline of its runs lives. +function TimelineLink({ subject }: { subject: Follows }) { + const label = `Everything Druks did about this ${subject.subjectType.replaceAll('_', ' ')}` + return ( + + ) +} + // The frame a page wears whether or not its body has arrived: where it sits, // what it is called, and the tabs beside it. function PageChrome({ diff --git a/frontend/src/druksui/Blocks.tsx b/frontend/src/druksui/Blocks.tsx index c2325244..3dd03b11 100644 --- a/frontend/src/druksui/Blocks.tsx +++ b/frontend/src/druksui/Blocks.tsx @@ -111,8 +111,15 @@ function BlockContent({ block }: { block: Block }) { /> ) case 'gate_controls': - if (target && target.run !== block.run) return null - return + if (!block.status.gate || !block.status.run) return null + if (target && target.run !== block.status.run) return null + return ( + + ) case 'timeline': return case 'progress': @@ -153,7 +160,7 @@ function BlockContent({ block }: { block: Block }) { title={block.title} columns={block.columns} rows={block.rows} - emptyText={block.emptyText} + empty={block.empty && } select={block.select} actions={block.actions} /> @@ -200,7 +207,9 @@ function BlockContent({ block }: { block: Block }) { if (!block.drop) return return case 'section': { - const decision = block.blocks.some((insideBlock) => insideBlock.block === 'gate_controls') + const decision = block.blocks.some( + (insideBlock) => insideBlock.block === 'gate_controls' && insideBlock.status.gate, + ) return (
): SubjectStatus { + return { + state: null, + run: null, + kind: null, + agent: null, + gate: null, + failure: null, + reason: null, + triggeredAt: null, + accountUsername: null, + ...facts, + } +} + +function subjectStatus(facts: Partial): SubjectStatusValue { + return { + value: 'subject_status', + subject: { subjectType: 'note', subjectId: '7' }, + working: 'auditing', + status: status(facts), + } +} describe('values', () => { it('read the same way in facts, metrics, a list, and a table', () => { @@ -62,7 +86,7 @@ describe('values', () => { title: '', columns: cells.map((_value, index) => ({ label: `c${index}`, align: 'start' as const })), rows: [{ cells, detail: '', key: '' }], - emptyText: '', + empty: null, select: '', actions: [], }, @@ -100,7 +124,7 @@ describe('values', () => { key: '', }, ], - emptyText: '', + empty: null, select: '', actions: [], }, @@ -169,7 +193,7 @@ describe('values', () => { key: '', }, ], - emptyText: '', + empty: null, select: '', actions: [], }, @@ -208,7 +232,7 @@ describe('values', () => { key: '9', }, ], - emptyText: '', + empty: null, select: 'peer_ids', actions: [action], }, @@ -269,10 +293,49 @@ describe('values', () => { expect(screen.getByText('1,234,567.25')).toBeTruthy() }) + it('writes the words for where the work on a subject stands', () => { + renderBlocks([ + { + block: 'list', + title: '', + items: [ + subjectStatus({ state: 'parked', gate: 'review', run: 'run-1' }), + subjectStatus({ state: 'failed', run: 'run-2', failure: 'The crawl timed out.' }), + subjectStatus({ state: 'running', run: 'run-3' }), + subjectStatus({ state: 'finished', run: 'run-4' }), + ], + }, + ]) + + expect(screen.getByText('needs you')).toBeTruthy() + expect(screen.getByText('failed')).toBeTruthy() + expect(screen.getByText('The crawl timed out.')).toBeTruthy() + expect(screen.getByText('auditing')).toBeTruthy() + expect(screen.getByText('idle')).toBeTruthy() + }) + + it('shows the app word for a time that has no moment', () => { + renderBlocks([{ block: 'list', title: '', items: [{ value: 'time', when: null, empty: 'never' }] }]) + + expect(screen.getByText('never')).toBeTruthy() + }) + + it('shows no gate controls while no gate is parked', () => { + const { container } = renderBlocks([ + { + block: 'gate_controls', + subject: { subjectType: 'note', subjectId: '7' }, + status: status({ state: 'running', run: 'run-3' }), + }, + ]) + + expect(container.textContent).toBe('') + }) + it('shows a time still to come as still to come', () => { // Mid-bucket, so the minutes elapsed while the test runs change nothing. const ahead = new Date(Date.now() + 90 * 60 * 1000).toISOString() - renderBlocks([{ block: 'list', title: '', items: [{ value: 'time', when: ahead }] }]) + renderBlocks([{ block: 'list', title: '', items: [{ value: 'time', when: ahead, empty: '' }] }]) expect(screen.getByTitle(ahead).textContent).toBe('in 1h') }) @@ -336,7 +399,7 @@ describe('Table', () => { title: 'Peers', columns: [{ label: 'Peer', align: 'start' }], rows: [], - emptyText: 'No peers yet.', + empty: { block: 'empty_state', title: 'No peers yet.', description: '', controls: [] }, select: '', actions: [], }, @@ -353,7 +416,7 @@ describe('Table', () => { title: 'Peers', columns: [{ label: 'Peer', align: 'start' }], rows: [], - emptyText: '', + empty: null, select: '', actions: [], }, @@ -370,7 +433,7 @@ describe('Table', () => { title: 'Peers', columns: [{ label: 'Peer', align: 'start' }], rows: [{ cells: [TEXT], detail: '', key: '' }], - emptyText: '', + empty: null, select: '', actions: [], }, @@ -394,7 +457,7 @@ describe('Table', () => { { label: 'Answers', align: 'end' }, ], rows: [{ cells: [TEXT, NUMBER], detail: '', key: '' }], - emptyText: '', + empty: null, select: '', actions: [], }, @@ -422,7 +485,7 @@ describe('Table', () => { key: '', }, ], - emptyText: '', + empty: null, select: '', actions: [], }, diff --git a/frontend/src/druksui/DataBlocks.tsx b/frontend/src/druksui/DataBlocks.tsx index adff27f9..5069a99b 100644 --- a/frontend/src/druksui/DataBlocks.tsx +++ b/frontend/src/druksui/DataBlocks.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState, type ReactNode } from 'react' import type { Action, @@ -7,6 +7,8 @@ import type { ImageBlock, Link, Metric, + StatusValue, + SubjectStatusValue, TableColumn, TableRow, Value, @@ -37,7 +39,10 @@ export function Datum({ value }: { value: Value }) { ) case 'status': return + case 'subject_status': + return case 'time': + if (!value.when) return {value.empty} return ( @@ -54,6 +59,30 @@ export function Datum({ value }: { value: Value }) { } } +const STOPPED = ['failed', 'cancelled', 'orphaned'] +const WORKING = ['scheduled', 'running'] + +function subjectStatusWord({ status: { state, gate }, working }: SubjectStatusValue): [string, StatusValue['tone']] { + if (gate) return ['needs you', 'warning'] + if (state && STOPPED.includes(state)) return [state, 'danger'] + if (state && WORKING.includes(state)) return [working, 'active'] + return ['idle', 'neutral'] +} + +/** Where the work on one subject stands: the shell's word for the run, the + * app's verb while it works, and the message of a run that stopped. */ +function SubjectStatus({ value }: { value: SubjectStatusValue }) { + const [label, tone] = subjectStatusWord(value) + const status = {label} + if (!value.status.failure) return status + return ( + + {status} + {value.status.failure} + + ) +} + function TextDatum({ text, description, @@ -283,14 +312,14 @@ export function Table({ title, columns, rows, - emptyText, + empty, select, actions, }: { title: string columns: TableColumn[] rows: TableRow[] - emptyText: string + empty: ReactNode select: string actions: Action[] }) { @@ -333,11 +362,11 @@ export function Table({ if (rows.length === 0) { // Nothing to show and nothing to say about it: a heading over an empty box // is worse than no block at all. - if (!emptyText) return null + if (!empty) return null return (
{title ?
{heading}
: null} -
{emptyText}
+ {empty}
) } diff --git a/frontend/src/druksui/accessibility.test.tsx b/frontend/src/druksui/accessibility.test.tsx index 22944036..12570a3a 100644 --- a/frontend/src/druksui/accessibility.test.tsx +++ b/frontend/src/druksui/accessibility.test.tsx @@ -35,7 +35,21 @@ const OPERATIONS: Operation[] = [ const CATALOG: Block[] = [ ...(catalog as PageSnapshot).blocks, - { block: 'gate_controls', run: 'run-6f0a' }, + { + block: 'gate_controls', + subject: { subjectType: 'note', subjectId: '7' }, + status: { + state: 'parked', + run: 'run-6f0a', + kind: 'field_notes.summarize', + agent: null, + gate: 'review', + failure: null, + reason: null, + triggeredAt: null, + accountUsername: null, + }, + }, ] vi.mocked(api.getGate).mockImplementation(async (run) => ({ diff --git a/frontend/src/druksui/catalog.json b/frontend/src/druksui/catalog.json index ae0cd969..6a017b30 100644 --- a/frontend/src/druksui/catalog.json +++ b/frontend/src/druksui/catalog.json @@ -180,11 +180,34 @@ "link": null } }, + { + "label": "Subject status", + "value": { + "value": "subject_status", + "subject": { + "subjectType": "note", + "subjectId": "7" + }, + "working": "summarizing", + "status": { + "state": "failed", + "run": "run-1", + "kind": "field_notes.summarize", + "agent": null, + "gate": null, + "failure": "The scout could not reach the repository.", + "reason": null, + "triggeredAt": null, + "accountUsername": null + } + } + }, { "label": "Time", "value": { "value": "time", - "when": "2026-08-29T09:14:02Z" + "when": "2026-08-29T09:14:02Z", + "empty": "" } } ] @@ -232,7 +255,12 @@ "key": "one" } ], - "emptyText": "No notes yet.", + "empty": { + "block": "empty_state", + "title": "No notes yet.", + "description": "", + "controls": [] + }, "select": "note_ids", "actions": [ { @@ -361,7 +389,21 @@ "blocks": [ { "block": "gate_controls", - "run": "run-example" + "subject": { + "subjectType": "note", + "subjectId": "7" + }, + "status": { + "state": "parked", + "run": "run-example", + "kind": "field_notes.summarize", + "agent": null, + "gate": "review", + "failure": null, + "reason": null, + "triggeredAt": null, + "accountUsername": null + } } ], "title": "Decision", diff --git a/frontend/src/druksui/decision.test.tsx b/frontend/src/druksui/decision.test.tsx index bfce9b4a..1a471339 100644 --- a/frontend/src/druksui/decision.test.tsx +++ b/frontend/src/druksui/decision.test.tsx @@ -16,6 +16,21 @@ vi.mock('../api/sse', () => ({ useSSE: vi.fn() })) const parkedAt = '2026-09-06T00:00:00.123456Z' const run = 'run%?#é' +const gateControls = (id: string): Block => ({ + block: 'gate_controls', + subject: { subjectType: 'note', subjectId: 'a' }, + status: { + state: 'parked', + run: id, + kind: 'field_notes.summarize', + agent: null, + gate: 'review', + failure: null, + reason: null, + triggeredAt: null, + accountUsername: null, + }, +}) const gate: Gate = { run, gate: 'review', @@ -64,7 +79,7 @@ afterEach(() => { it('opens the exact decision and decodes request identifiers only once', async () => { vi.mocked(api.getGate).mockResolvedValue(gate) - mount([{ block: 'gate_controls', run }]) + mount([gateControls(run)]) expect(await screen.findByRole('button', { name: 'Approve' })).toBeTruthy() expect(api.getGate).toHaveBeenCalledWith(run) expect(api.readPage).toHaveBeenCalledWith('notes', '/items/a%2520b%2Fc%3F%23%C3%A9') @@ -72,14 +87,14 @@ it('opens the exact decision and decodes request identifiers only once', async ( it('does not expose a new round through an old decision link', async () => { vi.mocked(api.getGate).mockResolvedValue({ ...gate, parkedAt: '2026-09-07T00:00:00Z' }) - mount([{ block: 'gate_controls', run }]) + mount([gateControls(run)]) expect((await screen.findByRole('alert')).textContent).toContain('has changed') expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull() }) it.each<{ blocks: Block[] }>([ { blocks: [] }, - { blocks: [{ block: 'gate_controls', run: 'new-run' }] }, + { blocks: [gateControls('new-run')] }, ])('does not substitute a missing decision with other controls: %j', async ({ blocks }) => { mount(blocks) expect((await screen.findByRole('alert')).textContent).toContain('unavailable') @@ -94,7 +109,7 @@ it('does not report an answered decision as unavailable when its region rereads' }) vi.mocked(api.getGate).mockResolvedValue(gate) vi.mocked(api.answerGate).mockResolvedValue({ run, parkedAt, result: 'answered' }) - mount([decision([{ block: 'gate_controls', run }])]) + mount([decision([gateControls(run)])]) fireEvent.click(await screen.findByRole('button', { name: 'Approve' })) await screen.findByText('Answer sent.') @@ -110,6 +125,6 @@ it('does not report an answered decision as unavailable when its region rereads' it('keeps direct app visits actionable', async () => { vi.mocked(api.getGate).mockResolvedValue(gate) - mount([{ block: 'gate_controls', run }], false) + mount([gateControls(run)], false) await waitFor(() => expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()) }) diff --git a/frontend/src/druksui/followed.test.tsx b/frontend/src/druksui/followed.test.tsx index 22144b8e..93093bb9 100644 --- a/frontend/src/druksui/followed.test.tsx +++ b/frontend/src/druksui/followed.test.tsx @@ -191,6 +191,22 @@ describe('a followed region', () => { }) }) +describe('the timeline link', () => { + it('takes a page about one subject to that subject', async () => { + renderPage(snapshot([{ block: 'text', text: 'body' }], NOTE_7)) + + const link = await screen.findByRole('link', { name: 'Everything Druks did about this note' }) + expect(link.getAttribute('href')).toBe('/field_notes/note/7') + }) + + it('stays off a page that follows every subject of a type', async () => { + renderPage(snapshot([{ block: 'text', text: 'board' }], { subjectType: 'note', subjectId: '' })) + + await waitFor(() => expect(screen.getByText('board')).toBeTruthy()) + expect(screen.queryByRole('link', { name: /Everything Druks did/ })).toBeNull() + }) +}) + describe('a snapshot from one subject', () => { const NOTE_9 = { subjectType: 'note', subjectId: '9' } diff --git a/frontend/src/druksui/pages.ts b/frontend/src/druksui/pages.ts index d51ad3e3..d9c0fbef 100644 --- a/frontend/src/druksui/pages.ts +++ b/frontend/src/druksui/pages.ts @@ -225,7 +225,9 @@ function replaceRegions(blocks: Block[], replacements: Map): Blo /** Runs with a decision control in this page, including nested cards and regions. */ export function gateRuns(blocks: Block[]): string[] { return blocks.flatMap((block) => { - if (block.block === 'gate_controls') return [block.run] + if (block.block === 'gate_controls') { + return block.status.gate && block.status.run ? [block.status.run] : [] + } if (block.block === 'cards') return gateRuns(block.cards) const nested = inside(block) return nested ? gateRuns(nested) : []