diff --git a/AGENTS.md b/AGENTS.md
index 7d32ec4..0bc828a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -49,8 +49,8 @@ For extension-surface changes, inspect the proof extension at
`druks.durable`, `druks.extensions`, and `druks.webhooks` are what an author imports;
a reference to `druks.build` or any other extension inside them inverts the platform.
- Liveness — is this subject still being worked — derives from run state; never mirror
- it in a column. An extension's own outcome, such as shipped or cancelled, is a domain
- fact it does own, and it records that by reacting to run lifecycle.
+ it in a column. An externally owned PR outcome is stored from the provider event,
+ never inferred from run lifecycle.
- `@step` marks a replay checkpoint, not an expensive call. On replay the body
re-executes from the top and completed steps return cached results, so code outside a
step runs again. Moving the boundary changes correctness, not performance.
diff --git a/backend/druks/contrib/ship/enums.py b/backend/druks/contrib/ship/enums.py
index ad0c890..1893265 100644
--- a/backend/druks/contrib/ship/enums.py
+++ b/backend/druks/contrib/ship/enums.py
@@ -19,8 +19,3 @@ class HumanFeedbackAction(StrEnum):
CONTRACT_CHANGE_REQUIRED = "contract_change_required"
QUESTION = "question"
CLOSE = "close"
-
-
-class HandoffStatus(StrEnum):
- SHIPPED = "shipped"
- CANCELLED = "cancelled"
diff --git a/backend/druks/contrib/ship/extension.py b/backend/druks/contrib/ship/extension.py
index f0ed57e..d504140 100644
--- a/backend/druks/contrib/ship/extension.py
+++ b/backend/druks/contrib/ship/extension.py
@@ -13,6 +13,7 @@
)
from druks.contrib.ship.models import WorkItem
from druks.contrib.ship.schemas import WorkItemSummary
+from druks.durable import Run, RunState
from druks.extensions import Extension
from druks.workflows import Subject, SubjectActivity
@@ -134,9 +135,15 @@ def get_subject_summary(cls, subject: Subject) -> WorkItemSummary | None:
@classmethod
def list_subjects(cls) -> list[WorkItemSummary]:
- # The active board: whatever hasn't handed off yet. The 500 most-recent
- # cover it; paginate if a board outgrows it.
- return [WorkItemSummary.from_work_item(item) for item in WorkItem.list_open(limit=500)]
+ states = Run.subject_states(cls.subject_type, limit=500)
+ items = WorkItem.list_recent(limit=500)
+ return [
+ WorkItemSummary.from_work_item(item)
+ for item in items
+ if str(item.id) in states
+ and item.pr_resolved_at is None
+ and states[str(item.id)] not in (RunState.CANCELLED, RunState.ORPHANED)
+ ]
@classmethod
async def get_subject_activity(cls, subject: Subject) -> SubjectActivity | None:
diff --git a/backend/druks/contrib/ship/models.py b/backend/druks/contrib/ship/models.py
index 1c3aa4e..a766cdd 100644
--- a/backend/druks/contrib/ship/models.py
+++ b/backend/druks/contrib/ship/models.py
@@ -6,7 +6,6 @@
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
-from druks.contrib.ship.enums import HandoffStatus
from druks.contrib.ship.policy import RepoPolicy
from druks.core.apis.github import get_github_client
from druks.db import Base, StoredSubject, db_session
@@ -181,7 +180,6 @@ class WorkItem(StoredSubject):
# don't collide once we support multiple providers.
Index("work_items_ticket_unique", "source", "ticket_key", unique=True),
Index("work_items_project_idx", "project_id"),
- Index("work_items_status_idx", "status"),
)
project_id: Mapped[int] = mapped_column(
@@ -209,7 +207,8 @@ class WorkItem(StoredSubject):
build_run_id: Mapped[str | None] = mapped_column(
ForeignKey("durable_runs.id", ondelete="SET NULL"), default=None
)
- status: Mapped[str | None] = mapped_column(default=None)
+ pr_merged: Mapped[bool | None] = mapped_column(default=None)
+ pr_resolved_at: Mapped[datetime | None] = mapped_column(default=None)
created_at: Mapped[datetime] = mapped_column(default=Base.utc_now)
updated_at: Mapped[datetime] = mapped_column(default=Base.utc_now)
@@ -277,7 +276,6 @@ async def ship(self) -> None:
# one converges on its own, its merge step finding the PR already closed.
from druks.contrib.ship.workflows import Build
- self.set_status(HandoffStatus.SHIPPED)
build = self.get_status(workflow=Build)
if build.is_parked:
await Build.cancel(self, failure="pr merged while parked")
@@ -289,7 +287,6 @@ async def close_external(self) -> None:
# must not strand it there.
from druks.contrib.ship.workflows import Build
- self.set_status(HandoffStatus.CANCELLED, event_payload={"external": True})
await Build.cancel(self, failure="pr closed without merge")
db_session().flush()
try:
@@ -320,27 +317,15 @@ def list_recent(cls, *, limit: int = 50, offset: int = 0) -> list["WorkItem"]:
@classmethod
def list_handoff(cls, *, limit: int = 10) -> list["WorkItem"]:
- # The history list: items at rest in a handoff lane, newest first.
+ # The history list: resolved pull requests, newest first.
stmt = (
- select(cls).where(cls.status.is_not(None)).order_by(cls.updated_at.desc()).limit(limit)
+ select(cls)
+ .where(cls.pr_resolved_at.is_not(None))
+ .order_by(cls.pr_resolved_at.desc())
+ .limit(limit)
)
return list(db_session().scalars(stmt))
- def set_status(
- self, status: HandoffStatus | None, *, event_payload: dict[str, Any] | None = None
- ) -> None:
- """The handoff-lane write. A non-None status is a milestone, so the
- matching build event records first — the pairing is structural, not a
- call-site convention. None clears the lane on (re)dispatch: no event."""
- if status:
- # cycle: the extension imports this module at file scope.
- import druks.contrib.ship.extension as ship_extension
-
- ship_extension.Ship.record_event(type=status, subject=self, payload=event_payload)
- self.status = status
- self.updated_at = Base.utc_now()
- db_session().flush()
-
async def set_ticket_status(self, status: TicketStatus) -> None:
# No-op for sources without a configured tracker (github, absent creds).
if not is_tracker_source(self.source):
@@ -377,6 +362,8 @@ def update(
pr_number: int | None = _KEEP,
branch: str | None = _KEEP,
build_run_id: str | None = _KEEP,
+ pr_merged: bool | None = _KEEP,
+ pr_resolved_at: datetime | None = _KEEP,
project_id: int = _KEEP,
) -> None:
if title is not _KEEP:
@@ -389,6 +376,10 @@ def update(
self.branch = branch
if build_run_id is not _KEEP:
self.build_run_id = build_run_id
+ if pr_merged is not _KEEP:
+ self.pr_merged = pr_merged
+ if pr_resolved_at is not _KEEP:
+ self.pr_resolved_at = pr_resolved_at
if project_id is not _KEEP:
self.project_id = project_id
self.updated_at = Base.utc_now()
diff --git a/backend/druks/contrib/ship/routes.py b/backend/druks/contrib/ship/routes.py
index a4e70cb..878516f 100644
--- a/backend/druks/contrib/ship/routes.py
+++ b/backend/druks/contrib/ship/routes.py
@@ -250,7 +250,6 @@ async def list_work_items_history(
) -> WorkItemsHistoryResponse:
response.headers["Cache-Control"] = "no-store"
clamped = max(1, min(limit, _HISTORY_MAX_LIMIT))
- # Recent-history aggregation. History is "handoff" — druks finished a
- # unit and handed it off as shipped or cancelled.
+ # Recent PR outcomes, ordered by the provider's resolution timestamp.
items = [DashboardItem.from_work_item(wi) for wi in WorkItem.list_handoff(limit=clamped)]
return WorkItemsHistoryResponse(items=items)
diff --git a/backend/druks/contrib/ship/schemas.py b/backend/druks/contrib/ship/schemas.py
index de57890..ffc39e7 100644
--- a/backend/druks/contrib/ship/schemas.py
+++ b/backend/druks/contrib/ship/schemas.py
@@ -7,9 +7,14 @@
from druks.schemas import BaseResponse
from druks.workflows import SubjectSummary
-from .enums import HandoffStatus
-
ProfileState = Literal["unprofiled", "running", "ready", "failed"]
+PRResolution = Literal["merged", "closed"]
+
+
+def _pr_resolution(merged: bool | None) -> PRResolution | None:
+ if merged is None:
+ return
+ return "merged" if merged else "closed"
class ProjectRepoSummary(BaseResponse):
@@ -112,6 +117,7 @@ class WorkItemSummary(SubjectSummary):
ticket_url: str | None = None
pr_number: int | None = None
branch: str | None = None
+ resolution: PRResolution | None = None
created_at: datetime
updated_at: datetime
links: Links
@@ -128,6 +134,7 @@ def from_work_item(cls, item: WorkItem) -> "WorkItemSummary":
ticket_url=item.ticket_url,
pr_number=item.pr_number,
branch=item.branch,
+ resolution=_pr_resolution(item.pr_merged),
created_at=item.created_at,
updated_at=item.updated_at,
links=Links.from_work_item(item),
@@ -142,15 +149,18 @@ class DashboardItem(BaseResponse):
repo: str | None = None
pr_number: int | None = None
project_name: str | None = None
- # The stored handoff lane, verbatim — the FE words and colors it.
- status: HandoffStatus
+ resolution: PRResolution
created_at: datetime
updated_at: datetime
links: Links
@classmethod
def from_work_item(cls, item: WorkItem) -> "DashboardItem":
- # History is terminal-only, so the stored lane is always set.
+ if item.pr_merged is None or item.pr_resolved_at is None:
+ raise ValueError(f"work item {item.id} has no stored PR resolution")
+ resolution = _pr_resolution(item.pr_merged)
+ if resolution is None:
+ raise ValueError(f"work item {item.id} resolution could not be derived")
return cls(
key=f"code:{item.id}",
source_id=item.id,
@@ -161,9 +171,9 @@ def from_work_item(cls, item: WorkItem) -> "DashboardItem":
# Druks Project is required on WorkItem, so the dashboard
# always has a curated project name to render.
project_name=item.project.name,
- status=HandoffStatus(item.status),
+ resolution=resolution,
created_at=item.created_at,
- updated_at=item.updated_at,
+ updated_at=item.pr_resolved_at,
links=Links.from_work_item(item),
)
diff --git a/backend/druks/contrib/ship/subscribers.py b/backend/druks/contrib/ship/subscribers.py
index 528eadf..b59058a 100644
--- a/backend/druks/contrib/ship/subscribers.py
+++ b/backend/druks/contrib/ship/subscribers.py
@@ -1,20 +1,15 @@
+from datetime import datetime
+
from druks.contrib.ship.contracts import ReviewWork
-from druks.contrib.ship.enums import HandoffStatus
from druks.contrib.ship.extension import Ship
from druks.contrib.ship.models import ProjectRepo, WorkItem
from druks.contrib.ship.workflows import Build, Profile
+from druks.db import db_session
from druks.signals import subscribe
from druks.ticketing.enums import TicketStatus
from druks.workflows import WorkflowEvent
-@subscribe(WorkflowEvent.RUNNING, subject=WorkItem)
-async def any_workflow_start_returns_item_to_board(*, subject: WorkItem, **_: object) -> None:
- # Any workflow starting for a work item puts it back on the active board —
- # a new build or resume means druks has it in court again.
- subject.set_status(None)
-
-
@subscribe("pr.opened", workflow=Build, subject=WorkItem)
async def pr_open_mirrors_onto_item(
*, subject: WorkItem, pr_number: int, branch: str, **_: object
@@ -36,15 +31,6 @@ async def review_park_marks_ticket_in_review(*, subject: WorkItem, **_: object)
await subject.set_ticket_status(TicketStatus.IN_REVIEW)
-@subscribe(WorkflowEvent.FAILED, workflow=Build, subject=WorkItem)
-@subscribe(WorkflowEvent.CANCELLED, workflow=Build, subject=WorkItem)
-async def build_end_settles_the_item(*, subject: WorkItem, **_: object) -> None:
- # Nothing merged, so the attempt was abandoned — unless the PR already spoke:
- # ship() cancels the run it just shipped, and that cancel arrives here.
- if not subject.status:
- subject.set_status(HandoffStatus.CANCELLED)
-
-
@subscribe("repo.pushed", to_default_branch=True)
async def policy_push_reprofiles_the_repo(*, repo: str, paths: list, **_: object) -> None:
# The operator edited the repo's build policy — re-apply it over the
@@ -73,15 +59,18 @@ async def pr_review_answers_the_gate(*, repo: str, pr_number: int, payload: dict
@subscribe("pr.closed")
async def pr_close_settles_the_item(*, repo: str, pr_number: int, payload: dict) -> None:
- """A PR druks owns closed on GitHub — the owner announcing the outcome.
- One path for every merge, druks's own included: GitHub says merged, druks
- ships the item. The status guards are redelivery idempotency."""
+ """Store GitHub's outcome once, then apply its operational side effects."""
item = WorkItem.get_for_pr(repo=repo, pr_number=pr_number, branch=payload["branch"])
- if not item or item.status == HandoffStatus.SHIPPED:
+ if not item or item.pr_merged is not None:
return
+ item.pr_merged = payload["merged"]
+ item.pr_resolved_at = datetime.fromisoformat(payload["resolved_at"].replace("Z", "+00:00"))
+ db_session().flush()
if payload["merged"]:
+ Ship.record_event(type="shipped", subject=item)
await item.ship()
- elif item.status != HandoffStatus.CANCELLED:
+ else:
+ Ship.record_event(type="cancelled", subject=item)
await item.close_external()
diff --git a/backend/druks/contrib/ship/workflows.py b/backend/druks/contrib/ship/workflows.py
index 9b563ea..59c780c 100644
--- a/backend/druks/contrib/ship/workflows.py
+++ b/backend/druks/contrib/ship/workflows.py
@@ -125,12 +125,16 @@ async def dispatch(cls, *, ticket: dict) -> str | None:
task_owner_name=ticket["assignee_name"],
)
# A duplicate dispatch gets the live run back; only a genuinely new run
- # resets routing — drop the old branch/PR so a late close for the prior PR
- # can't resolve this item onto the new run and cancel it.
+ # resets the attempt — drop the old branch/PR so a late close for the prior PR
+ # can't resolve this item onto the new run, and clear that prior PR's outcome.
if item.build_run_id != run_id:
- item.update(build_run_id=run_id, branch=None, pr_number=None)
- # (Re)dispatched → back in flight; clear the handoff lane.
- item.set_status(None)
+ item.update(
+ build_run_id=run_id,
+ branch=None,
+ pr_number=None,
+ pr_merged=None,
+ pr_resolved_at=None,
+ )
return run_id
async def run_multistep(
@@ -313,7 +317,7 @@ async def _work_gate(self) -> bool:
return False
async def _approved_work(self) -> bool:
- # GitHub announces the merge; the pr.closed reaction settles shipped.
+ # GitHub announces the merge; the pr.closed reaction stores its outcome.
if self._policy.on_approval == "merge":
if await self.declare_merge_intent():
return True
diff --git a/backend/druks/core/webhooks/github.py b/backend/druks/core/webhooks/github.py
index 8886bea..700f680 100644
--- a/backend/druks/core/webhooks/github.py
+++ b/backend/druks/core/webhooks/github.py
@@ -79,6 +79,11 @@ async def on_pull_request_closed(self) -> Response:
payload={
"branch": pull_request["head"]["ref"],
"merged": pull_request["merged"],
+ "resolved_at": (
+ pull_request["merged_at"]
+ if pull_request["merged"]
+ else pull_request["closed_at"]
+ ),
},
)
return _accepted()
diff --git a/backend/druks/durable/datastructures.py b/backend/druks/durable/datastructures.py
index 62134c3..cbdac47 100644
--- a/backend/druks/durable/datastructures.py
+++ b/backend/druks/durable/datastructures.py
@@ -53,8 +53,12 @@ def list_open(cls, subject_type: str, *, limit: int = 50) -> list["Subject"]:
the subject is identity alone; a subject with rows of its own lists them with
``StoredSubject.list_open``."""
# Cycle: the durable read side is built on this package's models.
- from druks.database import db_session
+ from druks.durable.enums import OPEN_STATES
from druks.durable.models import Run
- open_ids = db_session().scalars(Run.open_subject_ids(subject_type).limit(limit))
- return [cls(id=subject_id, subject_type=subject_type) for subject_id in open_ids]
+ states = Run.subject_states(subject_type, limit=limit)
+ return [
+ cls(id=subject_id, subject_type=subject_type)
+ for subject_id, state in states.items()
+ if state in OPEN_STATES
+ ][:limit]
diff --git a/backend/druks/durable/models.py b/backend/druks/durable/models.py
index ea6cf1d..cae9a02 100644
--- a/backend/druks/durable/models.py
+++ b/backend/druks/durable/models.py
@@ -4,7 +4,7 @@
from typing import TYPE_CHECKING, Any, Literal
from dbos import DBOS
-from sqlalchemy import ForeignKey, Index, Select, String, func, select, update
+from sqlalchemy import ForeignKey, Index, String, func, select, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Mapped, column_property, mapped_column, relationship
@@ -22,7 +22,6 @@
)
from druks.durable.enums import (
ACTIVE_STATES,
- OPEN_STATES,
AgentCallStatus,
RunState,
WorkflowEvent,
@@ -150,9 +149,8 @@ def get_latest_for_subject(
return db_session().scalars(stmt).first()
@classmethod
- def open_subject_ids(cls, subject_type: str) -> Select:
- """The subjects of ``subject_type`` whose newest run hasn't handed off, that
- run's most recent first — a subquery their own read composes."""
+ def subject_states(cls, subject_type: str, *, limit: int = 500) -> dict[str, RunState]:
+ """Every subject's newest driving run state, newest run first."""
state = state_expression(cls.id, cls.input_gate, cls.created_at).label("state")
subject_id = workflow_status.c.attributes["subject_id"].as_string().label("subject_id")
driving = (
@@ -171,14 +169,16 @@ def open_subject_ids(cls, subject_type: str) -> Select:
.where(workflow_status.c.attributes["subject_type"].as_string() == subject_type)
.subquery()
)
- return (
- select(driving.c.subject_id)
- .where(
- driving.c.rank == 1,
- driving.c.state.in_([run_state.value for run_state in OPEN_STATES]),
- )
+ stmt = (
+ select(driving.c.subject_id, driving.c.state)
+ .where(driving.c.rank == 1)
.order_by(driving.c.created_at.desc())
+ .limit(limit)
)
+ return {
+ subject_id: RunState(state)
+ for subject_id, state in db_session().execute(stmt)
+ }
def get_ask(self) -> dict[str, Any]:
# The parked ask, ready to serve. An in-app review's ask names neither
diff --git a/backend/druks/models.py b/backend/druks/models.py
index d4c7812..8d779df 100644
--- a/backend/druks/models.py
+++ b/backend/druks/models.py
@@ -2,7 +2,7 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar, Self
-from sqlalchemy import DateTime, Integer, cast, select
+from sqlalchemy import DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import TypeDecorator
@@ -101,22 +101,3 @@ def get_timeline(self) -> "list[RunResponse]":
async def get_phase(self) -> str | None:
return await self.subject.get_phase()
-
- @classmethod
- def list_open(cls, *, limit: int = 50) -> list[Self]:
- """The rows whose newest run hasn't handed off — still going, or failed
- and wanting the operator. What an extension's active view lists."""
- # Cycle: the durable read side is built on this module's Base.
- from druks.database import db_session
- from druks.durable.models import Run
-
- # The durable layer keys subjects by string, so the open ids come back as
- # text and cast to this table's integer key.
- open_ids = Run.open_subject_ids(cls.subject_type).subquery()
- stmt = (
- select(cls)
- .where(cls.id.in_(select(cast(open_ids.c.subject_id, Integer))))
- .order_by(cls.id.desc())
- .limit(limit)
- )
- return list(db_session().scalars(stmt))
diff --git a/backend/migrations/versions/a4c9e2f7b1d6_store_pr_resolution.py b/backend/migrations/versions/a4c9e2f7b1d6_store_pr_resolution.py
new file mode 100644
index 0000000..228c1b3
--- /dev/null
+++ b/backend/migrations/versions/a4c9e2f7b1d6_store_pr_resolution.py
@@ -0,0 +1,60 @@
+"""store pull request resolution
+
+Revision ID: a4c9e2f7b1d6
+Revises: f2c8b81a9d4e
+Create Date: 2026-07-27 16:00:00.000000
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "a4c9e2f7b1d6"
+down_revision: str | Sequence[str] | None = "f2c8b81a9d4e"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column("work_items", sa.Column("pr_merged", sa.Boolean(), nullable=True))
+ op.add_column(
+ "work_items",
+ sa.Column("pr_resolved_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.execute(
+ sa.text(
+ """
+ UPDATE work_items
+ SET pr_merged = CASE status
+ WHEN 'shipped' THEN true
+ WHEN 'cancelled' THEN false
+ END,
+ pr_resolved_at = updated_at
+ WHERE status IN ('shipped', 'cancelled')
+ """
+ )
+ )
+ op.drop_index("work_items_status_idx", table_name="work_items")
+ op.drop_column("work_items", "status")
+
+
+def downgrade() -> None:
+ op.add_column("work_items", sa.Column("status", sa.String(), nullable=True))
+ op.execute(
+ sa.text(
+ """
+ UPDATE work_items
+ SET status = CASE
+ WHEN pr_merged = true THEN 'shipped'
+ WHEN pr_merged = false THEN 'cancelled'
+ END
+ WHERE pr_merged IS NOT NULL
+ """
+ )
+ )
+ op.create_index("work_items_status_idx", "work_items", ["status"], unique=False)
+ op.drop_column("work_items", "pr_resolved_at")
+ op.drop_column("work_items", "pr_merged")
diff --git a/backend/tests/ship/test_api_work_items.py b/backend/tests/ship/test_api_work_items.py
index f84cea3..108ce94 100644
--- a/backend/tests/ship/test_api_work_items.py
+++ b/backend/tests/ship/test_api_work_items.py
@@ -2,6 +2,8 @@
import pytest
from druks.contrib.ship.models import WorkItem
+from druks.database import db_session
+from druks.models import Base
from druks.testing import seed_call
from druks.workflows import Subject
from fastapi.testclient import TestClient
@@ -54,12 +56,12 @@ def _seed_op(druks_db, work_item_id, *, kind="implement", state, input_gate=None
seed_call(druks_db, run, kind)
-def _ship(repo, pr_number):
- """Merge a work item's PR — the 'shipped' log event that lands it in
- History, mirroring the merge handler."""
+def _resolve_pr(repo, pr_number, *, merged=True):
item = WorkItem.get_for_pr(repo=repo, pr_number=pr_number)
if item:
- item.set_status("shipped")
+ item.pr_merged = merged
+ item.pr_resolved_at = Base.utc_now()
+ db_session().flush()
# The generic subject read-side — Ship declares subject=WorkItem, so the
@@ -76,12 +78,13 @@ def test_subject_list_shows_active_and_excludes_handed_off(client: TestClient, d
done = make_test_work_item(title="shipped one", repo=repo).id
WorkItem.get(done).update(pr_number=1)
_seed_op(druks_db, done, state="finished")
- _ship(repo, 1)
+ _resolve_pr(repo, 1)
rows = {r["summary"]["title"]: r for r in client.get("/api/ship/work_item").json()["rows"]}
assert "building" in rows
assert "shipped one" not in rows
assert rows["building"]["status"]["state"] == "running"
+ assert rows["building"]["summary"]["resolution"] is None
def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, druks_db):
@@ -162,7 +165,7 @@ def test_history_returns_only_done_work_items(client: TestClient, druks_db):
done_id = make_test_work_item(title="shipped one", repo=repo).id
WorkItem.get(done_id).update(pr_number=1)
_seed_op(druks_db, done_id, state="finished")
- _ship(repo, 1)
+ _resolve_pr(repo, 1)
# Running → active.
running_id = make_test_work_item(title="still running", repo=repo).id
_seed_op(druks_db, running_id, state="running")
@@ -176,19 +179,22 @@ def test_history_returns_only_done_work_items(client: TestClient, druks_db):
assert "shipped one" in titles
assert "still running" not in titles
assert "broke" not in titles # failed items stay active, not history
+ resolved = next(item for item in items if item["title"] == "shipped one")
+ assert resolved["resolution"] == "merged"
+ assert "status" not in resolved
-def test_pr_closed_without_merge_is_cancelled_in_history(client: TestClient, druks_db):
+def test_pr_closed_without_merge_is_closed_in_history(client: TestClient, druks_db):
repo = "ClawHaven/acme-app"
# A build parked on the operator, whose PR was then closed without merging.
wid = make_test_work_item(title="abandoned", repo=repo).id
WorkItem.get(wid).update(pr_number=7)
_seed_op(druks_db, wid, state="finished")
- WorkItem.get(wid).set_status("cancelled")
+ _resolve_pr(repo, 7, merged=False)
items = client.get("/api/ship/work-items/history").json()["items"]
row = next(it for it in items if it["title"] == "abandoned")
- assert row["status"] == "cancelled"
+ assert row["resolution"] == "closed"
def test_history_clamps_limit(client: TestClient, druks_db):
@@ -196,7 +202,7 @@ def test_history_clamps_limit(client: TestClient, druks_db):
wid = make_test_work_item(title=f"shipped {i}", repo="ClawHaven/acme-app").id
WorkItem.get(wid).update(pr_number=i + 1)
_seed_op(druks_db, wid, state="finished")
- _ship("ClawHaven/acme-app", i + 1)
+ _resolve_pr("ClawHaven/acme-app", i + 1)
# limit > cap → clamps down, doesn't 400.
response = client.get("/api/ship/work-items/history?limit=10000")
diff --git a/backend/tests/ship/test_build_board_membership.py b/backend/tests/ship/test_build_board_membership.py
index c999a5a..3873fa3 100644
--- a/backend/tests/ship/test_build_board_membership.py
+++ b/backend/tests/ship/test_build_board_membership.py
@@ -1,8 +1,12 @@
+from datetime import UTC, datetime, timedelta
+
import druks.contrib.ship.workflows # noqa: F401 # registers ship.build, the seeded kind
import pytest
-from druks.contrib.ship.enums import HandoffStatus
from druks.contrib.ship.extension import Ship
from druks.contrib.ship.models import WorkItem
+from druks.durable.dbos_state import workflow_status
+from druks.durable.enums import RunState
+from druks.durable.models import Run
from ship.factories import make_test_work_item, seed_build_run
@@ -12,71 +16,72 @@ def _board_ids(druks_db):
return {row.id for row in Ship.list_subjects()}
-@pytest.mark.parametrize("state", ["scheduled", "running", "parked", "failed"])
-def test_a_live_or_failed_build_holds_the_board(druks_db, state):
- # A failed run still wants the operator, so it stays alongside the live ones.
+@pytest.mark.parametrize("state", ["scheduled", "running", "parked", "failed", "finished"])
+def test_an_unresolved_build_holds_the_board(druks_db, state):
item = make_test_work_item(repo="ClawHaven/acme-app", title=f"build {state}")
seed_build_run(druks_db, work_item_id=item.id, state=state)
+
assert str(item.id) in _board_ids(druks_db)
-@pytest.mark.parametrize("state", ["finished", "cancelled"])
-def test_an_ended_build_leaves_the_board(druks_db, state):
- # The strand this fixes: membership used to read work_items.status, which a
- # cancelled or errored run never wrote, so the item lingered forever.
- item = make_test_work_item(repo="ClawHaven/acme-app", title=f"build {state}")
+@pytest.mark.parametrize("state", ["running", "failed", "finished"])
+def test_a_resolved_pr_leaves_the_board_before_run_state_is_considered(druks_db, state):
+ item = make_test_work_item(repo="ClawHaven/acme-app", title=f"resolved {state}")
seed_build_run(druks_db, work_item_id=item.id, state=state)
- druks_db.expire_all()
- assert item.status is None
- assert str(item.id) not in _board_ids(druks_db)
+ item.pr_merged = True
+ item.pr_resolved_at = datetime.now(UTC)
+ druks_db.flush()
-
-def test_a_redispatched_item_returns_to_the_board(druks_db):
- # Its newest run drives it, so a fresh build outranks the handed-off one.
- item = make_test_work_item(repo="ClawHaven/acme-app", title="rebuilt")
- seed_build_run(druks_db, work_item_id=item.id, state="finished")
- seed_build_run(druks_db, work_item_id=item.id, state="running")
- assert str(item.id) in _board_ids(druks_db)
+ assert str(item.id) not in _board_ids(druks_db)
def test_an_item_without_runs_stays_off_the_board(druks_db):
item = make_test_work_item(repo="ClawHaven/acme-app", title="never dispatched")
- assert str(item.id) not in _board_ids(druks_db)
-
-
-async def test_a_cancelled_build_settles_as_cancelled(druks_db):
- # Nothing merged, so History records the abandonment.
- from druks.contrib.ship.subscribers import build_end_settles_the_item
- item = make_test_work_item(repo="ClawHaven/acme-app", title="cancelled build")
- await build_end_settles_the_item(subject=item)
- assert item.status == HandoffStatus.CANCELLED
+ assert str(item.id) not in _board_ids(druks_db)
-async def test_a_shipped_item_keeps_its_outcome(druks_db):
- # ship() lands first and owns the verdict; the run's own cancel must not
- # overwrite it on the way out.
- from druks.contrib.ship.subscribers import build_end_settles_the_item
+@pytest.mark.parametrize("state", ["cancelled", "orphaned"])
+def test_a_cancelled_or_orphaned_build_with_no_resolution_leaves_the_board(
+ druks_db, state
+):
+ item = make_test_work_item(repo="ClawHaven/acme-app", title=f"{state} build")
+ if state == "cancelled":
+ seed_build_run(druks_db, work_item_id=item.id, state=state)
+ else:
+ run = seed_build_run(druks_db, work_item_id=item.id)
+ druks_db.execute(
+ workflow_status.delete().where(workflow_status.c.workflow_uuid == run.id)
+ )
+ run.created_at = datetime.now(UTC) - timedelta(minutes=10)
+ druks_db.flush()
+ druks_db.expire(run, ["state"])
+ assert Run.get(run.id).state == RunState.ORPHANED
- item = make_test_work_item(repo="ClawHaven/acme-app", title="merged build")
- item.set_status(HandoffStatus.SHIPPED)
- await build_end_settles_the_item(subject=item)
- assert item.status == HandoffStatus.SHIPPED
+ assert str(item.id) not in _board_ids(druks_db)
-def test_history_holds_the_handed_off(druks_db):
- shipped = make_test_work_item(repo="ClawHaven/acme-app", title="shipped")
- shipped.set_status(HandoffStatus.SHIPPED)
+def test_history_holds_resolved_items_in_resolution_order(druks_db):
+ older = make_test_work_item(repo="ClawHaven/acme-app", title="older")
+ older.pr_merged = False
+ older.pr_resolved_at = datetime.now(UTC) - timedelta(minutes=1)
+ newer = make_test_work_item(repo="ClawHaven/acme-app", title="newer")
+ newer.pr_merged = True
+ newer.pr_resolved_at = datetime.now(UTC)
make_test_work_item(repo="ClawHaven/acme-app", title="still open")
- assert [item.id for item in WorkItem.list_handoff()] == [shipped.id]
+ druks_db.flush()
+ assert [item.id for item in WorkItem.list_handoff()] == [newer.id, older.id]
-def test_the_newest_run_speaks_for_the_item(druks_db):
- # One rule, two implementations — the bulk board query and the per-subject
- # status must name the same driving run or the board and its lanes disagree.
+
+def test_the_newest_run_state_speaks_for_the_item(druks_db):
item = make_test_work_item(repo="ClawHaven/acme-app", title="two runs")
- seed_build_run(druks_db, work_item_id=item.id, state="cancelled")
+ seed_build_run(druks_db, work_item_id=item.id, state="finished")
seed_build_run(druks_db, work_item_id=item.id, state="parked", input_gate="review")
druks_db.expire_all()
- assert item.get_status().state == "parked"
+
+ states = Run.subject_states(WorkItem.subject_type)
+
+ assert states[str(item.id)] == RunState.PARKED
+ assert item.get_status().state == RunState.PARKED
assert str(item.id) in _board_ids(druks_db)
diff --git a/backend/tests/ship/test_build_dispatch.py b/backend/tests/ship/test_build_dispatch.py
index 396f662..8b671ed 100644
--- a/backend/tests/ship/test_build_dispatch.py
+++ b/backend/tests/ship/test_build_dispatch.py
@@ -1,17 +1,16 @@
-from druks.contrib.ship.enums import HandoffStatus
+from druks.contrib.ship.extension import Ship
from druks.contrib.ship.workflows import Build
+from druks.models import Base
from druks.testing import seed_run
from ship.factories import make_test_work_item
async def test_dispatch_pulls_cancelled_item_back_onto_the_board(druks_db, monkeypatch) -> None:
- """A cancelled item rests in History; dispatching its build must clear the
- handoff status so the active board shows the run (and its gates) instead
- of a stale "Cancelled" row."""
item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-1")
- item.set_status(HandoffStatus.CANCELLED)
- seed_run(druks_db, kind=Build.kind, run_id="run-1")
+ item.pr_merged = True
+ item.pr_resolved_at = Base.utc_now()
+ seed_run(druks_db, kind=Build.kind, subject=item, run_id="run-1")
started = {}
async def fake_start(cls, **kwargs):
@@ -35,7 +34,9 @@ async def fake_start(cls, **kwargs):
assert run_id == "run-1"
assert item.build_run_id == "run-1"
- assert item.status is None
+ assert item.pr_merged is None
+ assert item.pr_resolved_at is None
+ assert str(item.id) in {summary.id for summary in Ship.list_subjects()}
assert started["ticket_ref"] == "ACME-1"
assert started["ticket_title"] == "t"
assert started["ticket_url"] == "https://tracker.test/ACME-1"
@@ -81,6 +82,9 @@ async def test_duplicate_dispatch_keeps_the_live_attempt_routing(druks_db, monke
seed_run(druks_db, kind=Build.kind, run_id="run-live")
item = make_test_work_item(repo="o/r", title="t", ticket_key="ACME-3")
item.update(build_run_id="run-live", pr_number=7, branch="agent/live")
+ resolved_at = Base.utc_now()
+ item.pr_merged = False
+ item.pr_resolved_at = resolved_at
async def dedup_start(cls, **kwargs):
return "run-live"
@@ -103,6 +107,8 @@ async def dedup_start(cls, **kwargs):
assert item.build_run_id == "run-live"
assert item.pr_number == 7
assert item.branch == "agent/live"
+ assert item.pr_merged is False
+ assert item.pr_resolved_at == resolved_at
def test_update_clears_nullable_with_none_and_skips_omitted(druks_db) -> None:
diff --git a/backend/tests/ship/test_build_durable.py b/backend/tests/ship/test_build_durable.py
index fb51ede..4875495 100644
--- a/backend/tests/ship/test_build_durable.py
+++ b/backend/tests/ship/test_build_durable.py
@@ -250,9 +250,8 @@ async def test_happy_path_declares_merge_intent(rt, monkeypatch):
refreshed = session.get(WorkItem, item.id)
assert (refreshed.pr_number, refreshed.branch) == (42, "agent/acme-1")
- # Shipped settles via GitHub's pr.closed webhook (test_webhooks_pull_request),
- # not the run — the run's job ends when GitHub accepts the merge intent. The durable
- # residue is the event log of its state transitions.
+ # GitHub's pr.closed webhook stores the PR outcome (test_webhooks_pull_request),
+ # not the run — the run's job ends when GitHub accepts the merge intent.
from druks.events.models import Event
session = get_session(rt.engine)
diff --git a/backend/tests/ship/test_lane_reactions.py b/backend/tests/ship/test_lane_reactions.py
index 65445d8..5222ced 100644
--- a/backend/tests/ship/test_lane_reactions.py
+++ b/backend/tests/ship/test_lane_reactions.py
@@ -1,7 +1,6 @@
import druks.contrib.ship.subscribers # noqa: F401 — registers the lane reactions
import pytest
from druks.contrib.ship.contracts import ReviewWork
-from druks.contrib.ship.enums import HandoffStatus
from druks.contrib.ship.models import WorkItem
from druks.contrib.ship.workflows import Build
from druks.durable import Run
@@ -16,15 +15,6 @@
pytestmark = pytest.mark.asyncio
-async def test_run_running_puts_item_back_on_board(druks_db):
- item = make_test_work_item(repo="acme/widget", title="t", source="linear", ticket_key="ACME-1")
- item.set_status(HandoffStatus.CANCELLED)
-
- await publish(WorkflowEvent.RUNNING, subject=item.identity, kind=Build.kind)
-
- assert WorkItem.get(item.id).status is None
-
-
async def test_build_lifecycle_reaches_the_tracker(druks_db, monkeypatch):
pushed = []
diff --git a/backend/tests/ship/test_webhooks_pull_request.py b/backend/tests/ship/test_webhooks_pull_request.py
index 015cca2..3974e13 100644
--- a/backend/tests/ship/test_webhooks_pull_request.py
+++ b/backend/tests/ship/test_webhooks_pull_request.py
@@ -1,3 +1,4 @@
+from datetime import datetime
from types import SimpleNamespace
import druks.contrib.ship.subscribers # noqa: F401 — registers the pr.closed subscriber
@@ -6,11 +7,14 @@
from druks.core.webhooks.github import GitHubEvents
from druks.durable import Run
from druks.events.models import Event
-from druks.testing import make_settings
-from sqlalchemy import func, select
+from druks.testing import configure_app_for_test, make_settings
+from fastapi.testclient import TestClient
from ship.factories import make_test_work_item, seed_build_run
+_MERGED_AT = "2026-07-25T21:59:09Z"
+_CLOSED_AT = "2026-07-26T13:30:30Z"
+
@pytest.fixture(autouse=True)
def _stub_config_fetch(monkeypatch):
@@ -22,26 +26,23 @@ async def _fetch(*, repo, path):
monkeypatch.setattr("druks.extensions.config.fetch_file", _fetch)
-def _milestone_count(work_item_id, milestone):
- from druks.database import db_session
-
- return db_session().scalar(
- select(func.count())
- .select_from(Event)
- .where(
- Event.subject_type == "work_item",
- Event.subject_id == str(work_item_id),
- Event.type == milestone,
- )
- )
-
-
-async def _fire_closed(*, repo, pr_number, branch, tmp_path, merged=True):
+async def _fire_closed(
+ *,
+ repo,
+ pr_number,
+ branch,
+ tmp_path,
+ merged=True,
+ merged_at=_MERGED_AT,
+ closed_at=_CLOSED_AT,
+):
payload = {
"repository": {"full_name": repo},
"pull_request": {
"number": pr_number,
"merged": merged,
+ "merged_at": merged_at,
+ "closed_at": closed_at,
"head": {"ref": branch},
},
}
@@ -79,15 +80,19 @@ def _fresh_run(run_id):
@pytest.mark.asyncio
-async def test_external_merge_records_event_and_ends_involvement(druks_db, tmp_path):
+async def test_external_merge_stores_resolution_and_ends_involvement(druks_db, tmp_path):
repo, pr_number, branch = "ClawHaven/acme-app", 42, "agent/eng-1"
work_item_id, run_id = _park_work_item(repo=repo, pr_number=pr_number, branch=branch)
await _fire_closed(repo=repo, pr_number=pr_number, branch=branch, tmp_path=tmp_path)
- # Ship-ness is recorded as the 'shipped' milestone...
- assert _milestone_count(work_item_id, "shipped") == 1
- # ...and involvement ended: the parked build run is cancelled.
+ resolved = WorkItem.get(work_item_id)
+ assert resolved.pr_merged is True
+ assert resolved.pr_resolved_at == datetime.fromisoformat(
+ _MERGED_AT.replace("Z", "+00:00")
+ )
+ events = druks_db.query(Event).filter_by(subject_id=str(work_item_id)).all()
+ assert "shipped" in {event.type for event in events}
assert not _fresh_run(run_id).is_active
@@ -107,26 +112,58 @@ async def test_merge_ships_but_leaves_a_running_build_to_converge(druks_db, tmp_
await _fire_closed(repo=repo, pr_number=pr_number, branch=branch, tmp_path=tmp_path)
assert Run.get(run_id).state == "running" # not cancelled from under druks
- assert _milestone_count(work_item_id, "shipped") == 1
+ assert WorkItem.get(work_item_id).pr_merged is True
@pytest.mark.asyncio
-async def test_redelivered_merge_webhook_does_not_double_record(druks_db, tmp_path):
- """GitHub redelivers webhooks; a shipped item stays shipped once."""
+async def test_redelivered_webhook_does_not_rewrite_resolution(druks_db, tmp_path):
repo, pr_number, branch = "ClawHaven/acme-app", 44, "agent/eng-3"
work_item_id, _ = _park_work_item(repo=repo, pr_number=pr_number, branch=branch)
await _fire_closed(repo=repo, pr_number=pr_number, branch=branch, tmp_path=tmp_path)
+ resolved_at = WorkItem.get(work_item_id).pr_resolved_at
+
+ await _fire_closed(
+ repo=repo,
+ pr_number=pr_number,
+ branch=branch,
+ tmp_path=tmp_path,
+ merged=False,
+ )
+
+ item = WorkItem.get(work_item_id)
+ assert item.pr_merged is True
+ assert item.pr_resolved_at == resolved_at
+
+
+@pytest.mark.asyncio
+async def test_failed_build_later_merged_moves_from_board_to_history(druks_db, tmp_path):
+ repo, pr_number, branch = "ClawHaven/acme-app", 126, "agent/eng-760"
+ work_item_id, _ = _park_work_item(
+ repo=repo,
+ pr_number=pr_number,
+ branch=branch,
+ state="failed",
+ )
await _fire_closed(repo=repo, pr_number=pr_number, branch=branch, tmp_path=tmp_path)
- assert _milestone_count(work_item_id, "shipped") == 1
+ item = WorkItem.get(work_item_id)
+ assert item.pr_merged is True
+ settings = make_settings(tmp_path)
+ with TestClient(configure_app_for_test(settings=settings)) as client:
+ active = client.get("/api/ship/work_item").json()["rows"]
+ history = client.get("/api/ship/work-items/history").json()["items"]
+
+ assert str(work_item_id) not in {row["summary"]["id"] for row in active}
+ resolved = next(row for row in history if row["sourceId"] == work_item_id)
+ assert resolved["resolution"] == "merged"
+ resolved_at = datetime.fromisoformat(resolved["updatedAt"].replace("Z", "+00:00"))
+ assert resolved_at == item.pr_resolved_at
@pytest.mark.asyncio
async def test_closed_unmerged_records_close_and_ends_involvement(druks_db, tmp_path):
- """A PR closed *without* merging — the operator abandoned it (e.g. deleted
- the branch). Emit 'cancelled' and un-park so the item derives as cancelled
- and leaves the active board, rather than being ignored."""
+ """A PR closed without merging stores GitHub's outcome and cancels the run."""
repo, pr_number, branch = "ClawHaven/acme-app", 45, "agent/eng-4"
work_item_id, run_id = _park_work_item(repo=repo, pr_number=pr_number, branch=branch)
@@ -138,8 +175,13 @@ async def test_closed_unmerged_records_close_and_ends_involvement(druks_db, tmp_
merged=False,
)
- assert _milestone_count(work_item_id, "cancelled") == 1
- assert _milestone_count(work_item_id, "shipped") == 0
+ resolved = WorkItem.get(work_item_id)
+ assert resolved.pr_merged is False
+ assert resolved.pr_resolved_at == datetime.fromisoformat(
+ _CLOSED_AT.replace("Z", "+00:00")
+ )
+ events = druks_db.query(Event).filter_by(subject_id=str(work_item_id)).all()
+ assert "cancelled" in {event.type for event in events}
assert not _fresh_run(run_id).is_active
@@ -158,43 +200,7 @@ async def test_closed_unmerged_cancels_in_flight_run(druks_db, tmp_path):
)
assert _fresh_run(run_id).state == "cancelled"
- assert _milestone_count(work_item_id, "cancelled") == 1
-
-
-@pytest.mark.asyncio
-async def test_remerge_after_retrigger_records_a_fresh_shipped(druks_db, tmp_path):
- """A prior round's 'shipped' must not swallow a second merge after the item
- was re-triggered — the lane is active again, so the webhook ships it again."""
- from datetime import UTC, datetime, timedelta
-
- from druks.database import db_session as ds
-
- repo, pr_number, branch = "ClawHaven/acme-app", 77, "agent/eng-9"
- work_item_id, _ = _park_work_item(repo=repo, pr_number=pr_number, branch=branch)
- # Round 1: shipped.
- WorkItem.get(work_item_id).set_status("shipped")
- # Re-trigger: a newer RUNNING build run; dispatch cleared the handoff status.
- new_run = seed_build_run(ds(), work_item_id=work_item_id, state="running")
- new_run.created_at = datetime.now(UTC) + timedelta(seconds=5)
- WorkItem.get(work_item_id).set_status(None)
- ds().flush()
-
- await _fire_closed(repo=repo, pr_number=pr_number, branch=branch, tmp_path=tmp_path)
-
- assert _milestone_count(work_item_id, "shipped") == 2
-
-
-@pytest.mark.asyncio
-async def test_merge_echo_with_no_newer_activity_still_dedups(druks_db, tmp_path):
- """The echo case: druks's own merge emits 'shipped' and GitHub's closed
- webhook arrives with nothing newer — still dropped."""
- repo, pr_number, branch = "ClawHaven/acme-app", 78, "agent/eng-10"
- work_item_id, _ = _park_work_item(repo=repo, pr_number=pr_number, branch=branch)
- WorkItem.get(work_item_id).set_status("shipped")
-
- await _fire_closed(repo=repo, pr_number=pr_number, branch=branch, tmp_path=tmp_path)
-
- assert _milestone_count(work_item_id, "shipped") == 1 # deduped
+ assert WorkItem.get(work_item_id).pr_merged is False
@pytest.mark.asyncio
@@ -336,7 +342,7 @@ async def _record(self, status):
assert deleted == [] # cleanup skipped when policy can't be resolved
assert pushed == [TicketStatus.READY_FOR_AGENT] # ticket still reset
- assert _milestone_count(work_item_id, "cancelled") == 1
+ assert WorkItem.get(work_item_id).pr_merged is False
@pytest.mark.asyncio
@@ -356,4 +362,5 @@ async def test_stale_close_after_redispatch_spares_the_new_run(druks_db, tmp_pat
await _fire_closed(repo=repo, pr_number=pr_a, branch=branch_a, tmp_path=tmp_path, merged=False)
assert _fresh_run(new_run.id).state == "running" # the live attempt is untouched
- assert _milestone_count(item.id, "cancelled") == 0
+ assert item.pr_merged is None
+ assert item.pr_resolved_at is None
diff --git a/frontend/src/extensions/ship/HistoryPage.test.tsx b/frontend/src/extensions/ship/HistoryPage.test.tsx
new file mode 100644
index 0000000..280807f
--- /dev/null
+++ b/frontend/src/extensions/ship/HistoryPage.test.tsx
@@ -0,0 +1,74 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { HistoryPage } from './HistoryPage'
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+describe('HistoryPage', () => {
+ it('counts, labels, and filters canonical PR resolutions', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ items: [
+ {
+ key: 'code:1',
+ sourceId: 1,
+ ticketKey: 'ENG-1',
+ title: 'Merged title',
+ repo: 'czpython/druks',
+ prNumber: 1,
+ projectName: 'druks',
+ resolution: 'merged',
+ createdAt: '2026-07-27T12:00:00Z',
+ updatedAt: '2026-07-27T13:00:00Z',
+ links: { repo: 'https://github.com/czpython/druks' },
+ },
+ {
+ key: 'code:2',
+ sourceId: 2,
+ ticketKey: 'ENG-2',
+ title: 'Closed title',
+ repo: 'czpython/druks',
+ prNumber: 2,
+ projectName: 'druks',
+ resolution: 'closed',
+ createdAt: '2026-07-27T12:00:00Z',
+ updatedAt: '2026-07-27T12:30:00Z',
+ links: { repo: 'https://github.com/czpython/druks' },
+ },
+ ],
+ }),
+ { status: 200 },
+ ),
+ ),
+ )
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('Merged title')).toBeTruthy()
+ expect(screen.getByText('Closed title')).toBeTruthy()
+ expect(screen.getByText('merged (1)')).toBeTruthy()
+ expect(screen.getByText('closed (1)')).toBeTruthy()
+ expect(screen.getByTitle('merged').textContent).toBe('✓')
+ expect(screen.getByTitle('closed').textContent).toBe('◯')
+
+ fireEvent.click(screen.getByText('closed (1)'))
+
+ expect(screen.queryByText('Merged title')).toBeNull()
+ expect(screen.getByText('Closed title')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/extensions/ship/HistoryPage.tsx b/frontend/src/extensions/ship/HistoryPage.tsx
index ee69363..a205e33 100644
--- a/frontend/src/extensions/ship/HistoryPage.tsx
+++ b/frontend/src/extensions/ship/HistoryPage.tsx
@@ -3,7 +3,7 @@ import { useState } from 'react'
import { useLocation } from 'wouter'
import { buildApi } from './api'
-import type { HandoffStatus } from './api'
+import type { PRResolution } from './api'
import { EmptyState } from '../../components/EmptyState'
import { FilterChip } from '../../components/FilterChip'
import { StatusTag } from './StatusTag'
@@ -15,7 +15,7 @@ import { RepoCell } from '../../components/RepoCell'
import { relTime, secondsSince, updatedAtSortKey } from '../../lib/format'
import { dashboardItemPath } from './slug'
-type StatusFilter = 'all' | HandoffStatus
+type ResolutionFilter = 'all' | PRResolution
function repoBareName(repo: string | null | undefined): string | null {
if (!repo) return null
@@ -27,7 +27,7 @@ export function HistoryPage() {
queryKey: ['work-items-history'],
queryFn: () => buildApi.history(),
})
- const [statusFilter, setStatusFilter] = useState('all')
+ const [resolutionFilter, setResolutionFilter] = useState('all')
const [query, setQuery] = useState('')
const [, navigate] = useLocation()
@@ -35,14 +35,14 @@ export function HistoryPage() {
if (gate) return {gate}
const items = historyQuery.data!.items
- const counts: Record = {
- shipped: items.filter((i) => i.status === 'shipped').length,
- cancelled: items.filter((i) => i.status === 'cancelled').length,
+ const counts: Record = {
+ merged: items.filter((item) => item.resolution === 'merged').length,
+ closed: items.filter((item) => item.resolution === 'closed').length,
}
const filtered = items
.filter((item) => {
- if (statusFilter !== 'all' && item.status !== statusFilter) return false
+ if (resolutionFilter !== 'all' && item.resolution !== resolutionFilter) return false
if (query.trim()) {
const q = query.toLowerCase()
if (!`${item.title} ${item.ticketKey}`.toLowerCase().includes(q)) return false
@@ -58,11 +58,11 @@ export function HistoryPage() {
meta={
<>
- ✓ {counts.shipped} shipped
+ ✓ {counts.merged} merged
·
- ◯ {counts.cancelled} cancelled
+ ◯ {counts.closed} closed
>
}
@@ -76,23 +76,23 @@ export function HistoryPage() {
onChange={(e) => setQuery(e.target.value)}
/>
·
-
+
value="all"
- current={statusFilter}
- onSelect={setStatusFilter}
+ current={resolutionFilter}
+ onSelect={setResolutionFilter}
label="all"
/>
-
- value="shipped"
- current={statusFilter}
- onSelect={setStatusFilter}
- label={`shipped (${counts.shipped})`}
+
+ value="merged"
+ current={resolutionFilter}
+ onSelect={setResolutionFilter}
+ label={`merged (${counts.merged})`}
/>
-
- value="cancelled"
- current={statusFilter}
- onSelect={setStatusFilter}
- label={`cancelled (${counts.cancelled})`}
+
+ value="closed"
+ current={resolutionFilter}
+ onSelect={setResolutionFilter}
+ label={`closed (${counts.closed})`}
/>
}
@@ -124,14 +124,14 @@ export function HistoryPage() {
className="row row-history"
onClick={() => navigate(dashboardItemPath(item))}
>
-
+
{item.ticketKey}
{item.title}
- {item.status}
+ {item.resolution}
{relTime(secondsSince(item.updatedAt))}
diff --git a/frontend/src/extensions/ship/StatusTag.tsx b/frontend/src/extensions/ship/StatusTag.tsx
index 8d73169..895e278 100644
--- a/frontend/src/extensions/ship/StatusTag.tsx
+++ b/frontend/src/extensions/ship/StatusTag.tsx
@@ -1,18 +1,18 @@
-import type { HandoffStatus } from './api'
+import type { PRResolution } from './api'
-const GLYPH: Record = {
- shipped: '✓',
- cancelled: '◯',
+const GLYPH: Record = {
+ merged: '✓',
+ closed: '◯',
}
interface Props {
- status: HandoffStatus
+ resolution: PRResolution
}
-export function StatusTag({ status }: Props) {
+export function StatusTag({ resolution }: Props) {
return (
-
- {GLYPH[status]}
+
+ {GLYPH[resolution]}
)
}
diff --git a/frontend/src/extensions/ship/WorkItemsPage.test.tsx b/frontend/src/extensions/ship/WorkItemsPage.test.tsx
new file mode 100644
index 0000000..144f99d
--- /dev/null
+++ b/frontend/src/extensions/ship/WorkItemsPage.test.tsx
@@ -0,0 +1,93 @@
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { RunState, SubjectStatus } from '../../api/types'
+import type { PRResolution, WorkItemRow } from './api'
+import { WorkItemsPage } from './WorkItemsPage'
+
+const sse = vi.hoisted(() => ({
+ handlers: null as null | Record void>,
+}))
+
+vi.mock('../../api/sse', () => ({
+ useSSE: (
+ _url: string,
+ options: { handlers: Record void> },
+ ) => {
+ sse.handlers = options.handlers
+ },
+}))
+
+function row(id: string, state: RunState, resolution: PRResolution | null): WorkItemRow {
+ const status: SubjectStatus = {
+ state,
+ kind: 'ship.build',
+ agent: null,
+ gate: state === 'parked' ? 'review_work' : null,
+ failure: state === 'failed' ? 'tests failed' : null,
+ reason: null,
+ triggeredAt: '2026-07-27T12:01:00Z',
+ accountUsername: 'system',
+ }
+ return {
+ summary: {
+ id,
+ source: 'linear',
+ repo: 'czpython/druks',
+ projectName: 'druks',
+ title: `${state}-${id}`,
+ ticketKey: `ENG-${id}`,
+ ticketUrl: `https://linear.app/fellaworks/issue/ENG-${id}`,
+ prNumber: Number(id),
+ branch: `agent/eng-${id}`,
+ resolution,
+ createdAt: '2026-07-27T12:00:00Z',
+ updatedAt: `2026-07-27T12:0${id}:00Z`,
+ links: {
+ repo: 'https://github.com/czpython/druks',
+ pr: `https://github.com/czpython/druks/pull/${id}`,
+ ticket: `https://linear.app/fellaworks/issue/ENG-${id}`,
+ },
+ },
+ status,
+ }
+}
+
+afterEach(() => {
+ cleanup()
+ sse.handlers = null
+})
+
+describe('WorkItemsPage', () => {
+ it('filters resolution first and renders every active bucket in its count', () => {
+ const { container } = render()
+ const snapshot = sse.handlers?.snapshot
+ if (!snapshot) {
+ throw new Error('snapshot handler was not registered')
+ }
+
+ act(() => {
+ snapshot({
+ rows: [
+ row('1', 'parked', null),
+ row('2', 'failed', null),
+ row('3', 'finished', null),
+ row('4', 'scheduled', null),
+ row('5', 'running', null),
+ row('6', 'running', 'merged'),
+ ],
+ })
+ })
+
+ expect(screen.getByText('finished-3')).toBeTruthy()
+ expect(screen.queryByText('running-6')).toBeNull()
+ expect(container.querySelector('.dash-h1-count')?.textContent).toBe('(5)')
+ expect(
+ Array.from(container.querySelectorAll('.wi-group-count')).map(
+ (node) => node.textContent,
+ ),
+ ).toEqual(['(3)', '(2)'])
+ expect(screen.getByText('3 needs you')).toBeTruthy()
+ expect(screen.getByText('2 in flight')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/extensions/ship/WorkItemsPage.tsx b/frontend/src/extensions/ship/WorkItemsPage.tsx
index 6f02902..8dddb67 100644
--- a/frontend/src/extensions/ship/WorkItemsPage.tsx
+++ b/frontend/src/extensions/ship/WorkItemsPage.tsx
@@ -38,8 +38,8 @@ function WorkItemRowView({
row: WorkItemRow
onOpen: (row: WorkItemRow) => void
}) {
- // Active lanes: parked → parked on you, failed → needs you to retry or
- // cancel, running/scheduled → a step is live.
+ // Active lanes: parked/failed/finished need the operator; running/scheduled
+ // mean a step is live.
const { summary: wi, status } = row
const failed = status.state === 'failed'
const parked = status.state === 'parked'
@@ -124,15 +124,17 @@ export function WorkItemsPage() {
)
}
- // Two lanes: needs-you — parked on the operator OR failed (a failure still
- // needs you) — and in-flight (a step running or about to). Newest
- // movement first.
- const active = [...rows].sort(
+ // Resolution wins before liveness: a provider-resolved PR is History even if
+ // its driving run still looks active or failed.
+ const unresolved = rows.filter((row) => row.summary.resolution === null)
+ const active = [...unresolved].sort(
(a, b) => updatedAtSortKey(b.summary) - updatedAtSortKey(a.summary),
)
const needsYou = active.filter(
(row) =>
- (row.status.state === 'parked' || row.status.state === 'failed') &&
+ (row.status.state === 'parked' ||
+ row.status.state === 'failed' ||
+ row.status.state === 'finished') &&
matchesQuery(row, query),
)
const inFlight = active.filter(
diff --git a/frontend/src/extensions/ship/api.ts b/frontend/src/extensions/ship/api.ts
index dd2a670..2538dd1 100644
--- a/frontend/src/extensions/ship/api.ts
+++ b/frontend/src/extensions/ship/api.ts
@@ -28,8 +28,7 @@ export const buildApi = {
},
}
-// The stored handoff lane, verbatim from the backend's HandoffStatus.
-export type HandoffStatus = 'shipped' | 'cancelled'
+export type PRResolution = 'merged' | 'closed'
export interface Links {
repo: string
@@ -46,6 +45,7 @@ export interface WorkItemSummary extends SubjectSummary {
ticketUrl?: string | null
prNumber?: number | null
branch?: string | null
+ resolution: PRResolution | null
createdAt: string
updatedAt: string
links: Links
@@ -60,7 +60,7 @@ export interface DashboardItem {
repo?: string | null
prNumber?: number | null
projectName?: string | null
- status: HandoffStatus
+ resolution: PRResolution
createdAt: string
updatedAt: string
links: Links
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index efd31ac..e0e2da4 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -396,10 +396,9 @@ button { background: none; border: none; color: inherit; cursor: pointer; font:
font-size: 11px;
}
.outcome-merged { color: var(--outcome-merged); }
-.outcome-shipped { color: var(--outcome-merged); }
.outcome-failed { color: var(--outcome-failed); }
.outcome-abandoned { color: var(--outcome-abandoned); }
-.outcome-cancelled { color: var(--outcome-abandoned); }
+.outcome-closed { color: var(--outcome-abandoned); }
/* ============================================================
Empty states