What the admin does today
One route answers two questions. /admin/durable/dashboard renders the run list and one run's history side by side, and ?run= picks which run fills the right column. When nobody picks, RunDashboard::pick() falls back to $runs[0].
Two problems follow from the single route, and neither is a styling choice:
- The run has no address. A run page is a thing an operator sends to a colleague: "look at order 4711". Today the link is
?status=…&run=…&cursor=…, and it only resolves while that run is still on that page of that filter. Pass a cursor from yesterday and the link shows a different run.
- Both panels pay for each other. The list is squeezed into a third of the width, so it shows a workflow name, a run id and a date and nothing else. The history gets two thirds, which is not enough for a frieze plus its events. And every list load reads a history nobody asked for -
build() always calls readHistory() for the fallback run.
What we built instead
Two routes in our own Sylius app, over the same RunDashboard facts:
/admin/durable/runs - the list, full width.
/admin/durable/runs/{runId} - one run, full width.
What got better, concretely
- A run id is a URL.
/admin/durable/runs/order-000000017 resolves on its own, forever. No cursor, no filter, no page. It is the link you paste into a ticket.
- The list became a real grid. Full width paid for two more columns (Ended, and an Actions column) and for the Sylius grid shell:
table card-table table-vcenter, the same badge palette the Orders grid uses, the same btn btn-icon + tabler:eye action. A shop admin reads it without learning anything new.
- Filters became worth having. With the width, we added Workflow and Run alongside Outcome, in the Sylius filter accordion. Finding a run stopped meaning "page until it appears".
- The run page can be semantic. Freed of the list, it opens with four fact cards - outcome, started, ended, took - then the frieze, then "Step by step". The frieze labels stop being truncated at 12rem.
- One wasted query per list load disappeared. No fallback run means no
readHistory() nobody asked for.
- 404 became possible. An unknown run id is an unknown run id. Under
?run=, an id that matches nothing silently shows the first run of the page instead.
What the port made hard
Both gaps are in WorkflowRunCatalogInterface, and both pushed us out of the library:
- No
findRun(string $runId). readHistory() needs a WorkflowRunDescription, and only listRuns() produces one. Our first version of the show route paged through listRuns() until the id matched, capped at ten pages - so a run older than 200 runs was a 404 that should have been a page.
listRuns() filters by outcome only. Filtering a cursor page in PHP shows an empty page while the matches wait on the next one, so a workflow or run-id filter cannot be built on the port at all.
We ended up querying durable_workflow_runs directly - same ordering, same cursor format, same UTC reading of started_at as DbalWorkflowRunCatalog, copied so the two pages agree. That is ~160 lines of application code duplicating bridge SQL, and it only works because we happen to run the DBAL backend. A Temporal shop could not copy it.
Proposal
- Split the plugin into
gplanchat_durable_plugin_admin_run_index (/admin/durable/runs) and gplanchat_durable_plugin_admin_run_show (/admin/durable/runs/{runId}). Keep /admin/durable/dashboard as a redirect to the list so existing bookmarks survive.
- Add
findRun(string $runId): ?WorkflowRunDescription to WorkflowRunCatalogInterface. One query on DBAL, one DescribeWorkflowExecution on Temporal.
- Widen
listRuns(): ?string $workflowName and ?string $runIdFragment next to $status. Both are one WHERE clause on DBAL and both exist in Temporal's list filter.
- Split
RunDashboard::build() in two - listing() without the selected run, run() for one - so a host that renders two pages does not read a history it discards. outcomeCounters() is already public and already reusable as is.
Point 1 alone is a plugin change. Points 2 to 4 are what make it buildable by anyone else without writing SQL against your tables.
Happy to send the split as a PR against the plugin if the shape above is the one you want.
What the admin does today
One route answers two questions.
/admin/durable/dashboardrenders the run list and one run's history side by side, and?run=picks which run fills the right column. When nobody picks,RunDashboard::pick()falls back to$runs[0].Two problems follow from the single route, and neither is a styling choice:
?status=…&run=…&cursor=…, and it only resolves while that run is still on that page of that filter. Pass a cursor from yesterday and the link shows a different run.build()always callsreadHistory()for the fallback run.What we built instead
Two routes in our own Sylius app, over the same
RunDashboardfacts:/admin/durable/runs- the list, full width./admin/durable/runs/{runId}- one run, full width.What got better, concretely
/admin/durable/runs/order-000000017resolves on its own, forever. No cursor, no filter, no page. It is the link you paste into a ticket.table card-table table-vcenter, the same badge palette the Orders grid uses, the samebtn btn-icon+tabler:eyeaction. A shop admin reads it without learning anything new.readHistory()nobody asked for.?run=, an id that matches nothing silently shows the first run of the page instead.What the port made hard
Both gaps are in
WorkflowRunCatalogInterface, and both pushed us out of the library:findRun(string $runId).readHistory()needs aWorkflowRunDescription, and onlylistRuns()produces one. Our first version of the show route paged throughlistRuns()until the id matched, capped at ten pages - so a run older than 200 runs was a 404 that should have been a page.listRuns()filters by outcome only. Filtering a cursor page in PHP shows an empty page while the matches wait on the next one, so a workflow or run-id filter cannot be built on the port at all.We ended up querying
durable_workflow_runsdirectly - same ordering, same cursor format, same UTC reading ofstarted_atasDbalWorkflowRunCatalog, copied so the two pages agree. That is ~160 lines of application code duplicating bridge SQL, and it only works because we happen to run the DBAL backend. A Temporal shop could not copy it.Proposal
gplanchat_durable_plugin_admin_run_index(/admin/durable/runs) andgplanchat_durable_plugin_admin_run_show(/admin/durable/runs/{runId}). Keep/admin/durable/dashboardas a redirect to the list so existing bookmarks survive.findRun(string $runId): ?WorkflowRunDescriptiontoWorkflowRunCatalogInterface. One query on DBAL, oneDescribeWorkflowExecutionon Temporal.listRuns():?string $workflowNameand?string $runIdFragmentnext to$status. Both are oneWHEREclause on DBAL and both exist in Temporal's list filter.RunDashboard::build()in two -listing()without the selected run,run()for one - so a host that renders two pages does not read a history it discards.outcomeCounters()is already public and already reusable as is.Point 1 alone is a plugin change. Points 2 to 4 are what make it buildable by anyone else without writing SQL against your tables.
Happy to send the split as a PR against the plugin if the shape above is the one you want.