Verifying the live dashboard (PR #11) against the cluster's SQL (standard) Temporal visibility turned the Temporal panel red with operation is not supported: 'order by' clause. The poll-heartbeat query asked the visibility store to ORDER BY StartTime DESC, which standard visibility (unlike Elasticsearch) does not support. Worse, because fetch wrapped all the Temporal sub-reads in one try, that single bad query blanked the whole panel — heartbeat, lifecycle, autonomy ladder, everything.
No ORDER BY: take a bounded page, pick the latest tick client-side.
src/ynab_agent/dashboard/temporal_source.py · 346 lines src/ynab_agent/dashboard/temporal_source.py 346 lines · Python expand all
⋯ 76 lines hidden (lines 1–76) 1 """Temporal reader: the live run ledger (reuses the worker's client). 3 Reads the agent's durable state straight from Temporal: the poll heartbeat, the 4 in-flight W2 transactions counted by lifecycle state (each running workflow's 5 ``state`` query), the rule registry's autonomy ladder (its ``view`` query), the 6 live autonomy offers, the recent inbound-dispatch tally, and any 7 terminated/failed workflows with their recovered reason. Everything is bounded 8 and best-effort: a failure returns whatever was gathered plus an error string, 9 never raising into the page. 12 from __future__ import annotations 15 from datetime import datetime 16 from typing import TYPE_CHECKING , Final 18 from temporalio . client import WorkflowExecutionStatus 20 from ynab_agent . dashboard . model import ( 28 from ynab_agent . domain . base import Frozen 31 from collections . abc import AsyncIterator 33 from temporalio . client import Client , WorkflowExecution 35 _MAX_PER_TYPE : Final = 500 36 _MAX_STATE_QUERIES : Final = 200 37 _REGISTRY_ID : Final = " ynab-rule-registry " 40 class TemporalReadout ( Frozen ) : 41 """The Temporal-derived pieces of the dashboard, ready to slot in.""" 43 poll_status : str = " none " 44 poll_live : bool = False 45 poll_last_start : datetime | None = None 46 lifecycle_states : tuple [ StateCount , . . . ] = ( ) 50 rules : tuple [ RuleRow , . . . ] = ( ) 54 offers : tuple [ OfferRow , . . . ] = ( ) 55 awaiting : tuple [ QueueItem , . . . ] = ( ) 56 dispatch : DispatchTally = DispatchTally ( ) 57 failures : tuple [ Failure , . . . ] = ( ) 60 def _status ( execution : WorkflowExecution ) - > str : 61 status = execution . status 62 return status . name . lower ( ) if status is not None else " unknown " 66 iterator : AsyncIterator [ WorkflowExecution ] , cap : int = _MAX_PER_TYPE 67 ) - > AsyncIterator [ WorkflowExecution ] : 68 """Yield at most ``cap`` executions (a runaway-visibility backstop).""" 70 async for execution in iterator : 77 async def _poll ( client : Client ) - > tuple [ str , bool , datetime | None ] : 78 """The most recent poll tick: (status, live, last_start). 80 Standard (SQL) Temporal visibility rejects an ``ORDER BY`` clause, so we 81 take a bounded page and pick the latest start client-side rather than 84 latest : WorkflowExecution | None = None 85 async for execution in _take ( 86 client . list_workflows ( " WorkflowType = ' PollWorkflow ' " ) , cap = 100 88 start = execution . start_time 89 if latest is None or ( 91 and ( latest . start_time is None or start > latest . start_time ) 95 return " none " , False , None 96 live = latest . status in ( 97 WorkflowExecutionStatus . RUNNING , 98 WorkflowExecutionStatus . COMPLETED , 99 WorkflowExecutionStatus . CONTINUED_AS_NEW , 101 return _status ( latest ) , live , latest . start_time ⋯ 245 lines hidden (lines 102–346) 106 ) - > tuple [ tuple [ StateCount , . . . ] , int , tuple [ QueueItem , . . . ] ] : 107 """Count running W2s by state; collect the awaiting-human queue.""" 108 counts : dict [ str , int ] = { } 109 awaiting : list [ QueueItem ] = [ ] 111 async for execution in _take ( 112 client . list_workflows ( 113 " WorkflowType = ' TransactionWorkflow ' " 114 " AND ExecutionStatus = ' Running ' " 116 cap = _MAX_STATE_QUERIES , 120 handle = client . get_workflow_handle ( 121 execution . id , run_id = execution . run_id 123 state = await handle . query ( " state " , result_type = str ) 124 except Exception : # a busy/odd workflow shouldn't drop the whole panel 126 counts [ state ] = counts . get ( state , 0 ) + 1 127 if state == " awaiting_human " : 133 since = execution . start_time , 137 StateCount ( state = name , count = counts [ name ] ) for name in sorted ( counts ) 139 return states , seen , tuple ( awaiting ) 144 ) - > tuple [ tuple [ RuleRow , . . . ] , int , int , int ] : 145 """The rule table + (observe, eligible, blessed) counts (view query).""" 146 from ynab_agent . domain . allocations import ProposedCategory 147 from ynab_agent . domain . enums import RuleSource , TrustState 148 from ynab_agent . workflow . registry_types import RegistryView 150 handle = client . get_workflow_handle ( _REGISTRY_ID ) 151 view = await handle . query ( " view " , result_type = RegistryView ) 152 rows : list [ RuleRow ] = [ ] 153 observe = eligible = blessed = 0 154 for rule in view . rules : 155 allocation = rule . action . allocation 157 str ( allocation . category ) 158 if isinstance ( allocation , ProposedCategory ) 163 payee = rule . match . payee_pattern , 165 trust = rule . trust . value , 166 source = rule . source . value , 168 offered = rule . offered_at is not None , 169 last_confirmed_at = rule . last_confirmed_at , 172 if rule . source is RuleSource . HUMAN_EXPLICIT : 174 elif rule . trust is TrustState . TRUSTED : 178 return tuple ( rows ) , observe , eligible , blessed 181 async def _offers ( client : Client ) - > tuple [ OfferRow , . . . ] : 182 """The live autonomy-offer workflows (awaiting the owner's yes/no).""" 183 offers : list [ OfferRow ] = [ ] 184 async for execution in _take ( 185 client . list_workflows ( 186 " WorkflowType = ' AutonomyOfferWorkflow ' " 187 " AND ExecutionStatus = ' Running ' " 192 rule_id = execution . id . removeprefix ( " autonomy-offer- " ) , 194 status = _status ( execution ) , 195 started_at = execution . start_time , 201 async def _dispatch ( client : Client ) - > DispatchTally : 202 """Tally recent inbound dispatch results by routing action.""" 203 counts : dict [ str , int ] = { } 205 async for execution in _take ( 206 client . list_workflows ( 207 " WorkflowType = ' DispatchWorkflow ' " 208 " AND ExecutionStatus = ' Completed ' " 214 handle = client . get_workflow_handle ( 215 execution . id , run_id = execution . run_id 217 result = await handle . result ( ) 218 got = getattr ( result , " action " , None ) 219 if isinstance ( got , str ) : 221 except Exception : # a bad decode shouldn't drop the tally 223 counts [ action ] = counts . get ( action , 0 ) + 1 224 return DispatchTally ( 225 transaction = counts . get ( " transaction " , 0 ) , 226 offer = counts . get ( " offer " , 0 ) , 227 receipt = counts . get ( " receipt " , 0 ) , 228 command = counts . get ( " command " , 0 ) , 229 quarantine = counts . get ( " quarantine " , 0 ) , 230 ignore = counts . get ( " ignore " , 0 ) , 235 async def _reason ( client : Client , execution : WorkflowExecution ) - > str | None : 236 """Recover a terminated/failed workflow's human reason from history.""" 238 handle = client . get_workflow_handle ( 239 execution . id , run_id = execution . run_id 241 found : str | None = None 242 async for event in handle . fetch_history_events ( ) : 243 terminated = event . workflow_execution_terminated_event_attributes 244 if terminated . reason : 245 found = terminated . reason 246 failed = event . workflow_execution_failed_event_attributes 247 if failed . failure . message : 248 found = failed . failure . message 250 except Exception : # the reason is a nicety — never fail the panel for it 254 async def _terminal ( client : Client ) - > tuple [ int , int , tuple [ Failure , . . . ] ] : 255 """Recent archived (completed W2) + terminated/failed counts + failures.""" 256 archived = terminated = 0 257 failures : list [ Failure ] = [ ] 258 async for _completed in _take ( 259 client . list_workflows ( 260 " WorkflowType = ' TransactionWorkflow ' " 261 " AND ExecutionStatus = ' Completed ' " 265 async for execution in _take ( 266 client . list_workflows ( 267 " ExecutionStatus = ' Terminated ' OR ExecutionStatus = ' Failed ' " 271 if len ( failures ) < 25 : 274 workflow_id = execution . id , 275 kind = _status ( execution ) , 276 reason = await _reason ( client , execution ) , 277 when = execution . close_time , 280 return archived , terminated , tuple ( failures ) 283 async def fetch ( client : Client ) - > tuple [ TemporalReadout , str | None ] : 284 """Read the agent's Temporal state; each panel degrades independently. 286 Every sub-read is guarded on its own, so one unsupported visibility query 287 (or a transient failure) reddens only its panel and names itself in the 288 error — the rest of the page still fills in. 292 poll_last : datetime | None = None 293 states : tuple [ StateCount , . . . ] = ( ) 295 awaiting : tuple [ QueueItem , . . . ] = ( ) 296 rules : tuple [ RuleRow , . . . ] = ( ) 297 observe = eligible = blessed = 0 298 offers : tuple [ OfferRow , . . . ] = ( ) 299 dispatch = DispatchTally ( ) 300 archived = terminated = 0 301 failures : tuple [ Failure , . . . ] = ( ) 302 errors : list [ str ] = [ ] 305 poll_status , poll_live , poll_last = await _poll ( client ) 306 except Exception as exc : 307 errors . append ( f " poll: { type ( exc ) . __name__ } " ) 309 states , in_flight , awaiting = await _lifecycle ( client ) 310 except Exception as exc : 311 errors . append ( f " lifecycle: { type ( exc ) . __name__ } " ) 312 # The registry may not exist until the first learning signal — a normal 313 # state, not an error, so its absence is simply suppressed. 314 with contextlib . suppress ( Exception ) : 315 rules , observe , eligible , blessed = await _registry ( client ) 317 offers = await _offers ( client ) 318 except Exception as exc : 319 errors . append ( f " offers: { type ( exc ) . __name__ } " ) 321 dispatch = await _dispatch ( client ) 322 except Exception as exc : 323 errors . append ( f " dispatch: { type ( exc ) . __name__ } " ) 325 archived , terminated , failures = await _terminal ( client ) 326 except Exception as exc : 327 errors . append ( f " terminal: { type ( exc ) . __name__ } " ) 329 readout = TemporalReadout ( 330 poll_status = poll_status , 332 poll_last_start = poll_last , 333 lifecycle_states = states , 336 terminated = terminated , 346 return readout , " ; " . join ( errors ) if errors else None