ui: lead task detail with diagnosis

This commit is contained in:
kami
2026-07-29 23:31:25 +04:00
parent 1ca9d64e89
commit f7027cb9a7
8 changed files with 134 additions and 117 deletions
+9 -5
View File
@@ -3,12 +3,13 @@ import {createRoot} from 'react-dom/client'
import {BrowserRouter,Link,NavLink,Route,Routes,useLocation,useNavigate,useParams} from 'react-router-dom'
import {QueryClient,QueryClientProvider,useMutation,useQuery,useQueryClient} from '@tanstack/react-query'
import {api} from './api/client'
import type {Action,Capture,Detail,Overview,PendingApproval,Task,TaskState} from './api/types'
import type {Action,BlockReason,Capture,Detail,Overview,PendingApproval,Task,TaskState} from './api/types'
import './style.css'
const client=new QueryClient()
const states:TaskState[]=['queued','leased','blocked','completed','failed']
const states:TaskState[]=['queued','leased','completed','failed']
const label:Record<TaskState,string>={queued:'Queued',leased:'In session',blocked:'Blocked',completed:'Complete',failed:'Failed'}
const blockLabel:Record<BlockReason,string>={lease_failure:'Lease failure',worker_offline:'Worker offline',lease_expired:'Expired lease',approval:'Approval needed',handoff_validation:'Handoff validation',operator_block:'Operator block',system_error:'System error',unknown:'Unknown'}
const actionLabel:Record<string,string>={handoff:'Request handoff',release:'Release task',complete:'Complete task',block:'Mark blocked'}
const date=(value?:string)=>value?new Date(value).toLocaleString():'—'
@@ -16,8 +17,9 @@ function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=use
function Shell({children,onLogout}:{children:React.ReactNode;onLogout:()=>void}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false),[accountOpen,setAccountOpen]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions?.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{const editable=e.target instanceof HTMLInputElement||e.target instanceof HTMLTextAreaElement;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!editable){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(e.key==='r'&&!e.metaKey&&!e.ctrlKey&&!editable)overview.refetch()};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav,overview]);return <div className="app-shell" data-app="orchestra"><aside className="rail"><Link className="mark" to="/" aria-label="Orchestra home"><i className="branch-mark"/><span>OR</span></Link><nav className="rail-nav" aria-label="Primary navigation"><NavLink end className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/"><span>Board</span></NavLink><NavLink className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/workers"><span>Workers</span></NavLink></nav><span className="rail-footer">v0.1</span></aside><header className="topbar"><div className="topbar-title">Orchestra <small>{where.pathname==='/'?'dispatch board':where.pathname==='/workers'?'worker pool':'task record'}</small></div><button className="command" onClick={()=>setPalette(true)} aria-label="Open command palette"><span>Command</span><kbd> K</kbd></button><div className="readout"><span>SESSIONS <b>{sessions}</b></span><span>SYNC <b>5s</b></span></div><div className="account"><button className="account-button" onClick={()=>setAccountOpen(v=>!v)} aria-expanded={accountOpen}>Signed in <span className="status-dot"/></button>{accountOpen&&<div className="account-menu"><span>Browser session active</span><button onClick={onLogout}>Sign out</button></div>}</div></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
function taskExplanation(task:Task,overview:Overview){const session=(overview.sessions??[]).find(s=>s.capture?.task_id===task.id);if(task.state==='queued'){const online=(overview.workers??[]).filter(w=>w.online);return online.length?`Waiting for a worker to lease it · ${online.length} worker${online.length===1?'':'s'} online`:'No registered worker is currently reachable'}if(task.state==='leased'){if(session?.pending_approval)return 'Waiting for an operator approval';if(task.lease&&new Date(task.lease.until)<new Date())return 'Lease has expired; waiting for reconciliation';if(session?.blocker)return session.blocker;return session?.capture?'Harness session is active':'Lease is active; live capture is unavailable'}if(task.state==='blocked')return task.blocker||'Unknown — legacy record has no retained blocker or pane evidence';if(task.state==='failed')return 'The task needs review before it can be retried';return 'Terminal task record'}
function Board({tasks,overview}:{tasks:Task[];overview:Overview}){return <div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><Link className="card" to={'/tasks/'+t.id} key={t.id}><b>{t.title||t.id}</b><small><span>{t.project}</span><span className="machine">{t.id.slice(-5)}</span></small><em>{taskExplanation(t,overview)}</em></Link>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>}
function taskExplanation(task:Task,overview:Overview){const session=(overview.sessions??[]).find(s=>s.capture?.task_id===task.id);if(task.state==='queued'){const online=(overview.workers??[]).filter(w=>w.online);return online.length?`Waiting for a worker to lease it · ${online.length} worker${online.length===1?'':'s'} online`:'No registered worker is currently reachable'}if(task.state==='leased'){if(session?.pending_approval)return 'Waiting for an operator approval';if(task.lease&&new Date(task.lease.until)<new Date())return 'Lease has expired; waiting for reconciliation';if(session?.blocker)return session.blocker;return session?.capture?'Harness session is active':'Lease is active; live capture is unavailable'}if(task.state==='blocked'){const evidence=task.last_session;const observed=evidence?.captured_at||evidence?.checked_at;const suffix=evidence?` · ${evidence.source||'unknown'} observed ${date(observed)}`:'';return (task.blocker||'Unknown — legacy record has no retained blocker or pane evidence')+suffix}if(task.state==='failed')return 'The task needs review before it can be retried';return 'Terminal task record'}
function TaskCard({task,overview}:{task:Task;overview:Overview}){return <Link className="card" to={'/tasks/'+task.id}><b>{task.title||task.id}</b><small><span>{task.project}</span><span className="machine">{task.id.slice(-5)}</span></small><em>{taskExplanation(task,overview)}</em></Link>}
function Board({tasks,overview}:{tasks:Task[];overview:Overview}){const blocked=tasks.filter(t=>t.state==='blocked').sort((a,b)=>new Date(a.blocked_at||0).getTime()-new Date(b.blocked_at||0).getTime());const groups=new Map<BlockReason,Task[]>;for(const task of blocked){const reason=task.block_reason||'unknown';groups.set(reason,[...(groups.get(reason)||[]),task])}return <><div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><TaskCard task={t} overview={overview} key={t.id}/>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>{blocked.length>0&&<section className="blocked-groups" aria-label="Blocked work by diagnosis"><div className="section-title"><div><h2>Needs attention</h2><p>Blocked work is grouped by diagnosis and ordered oldest first.</p></div><span className="machine">{blocked.length} blocked</span></div><div className="blocked-grid">{[...groups.entries()].map(([reason,group])=><section key={reason}><div className="lane-head"><span>{blockLabel[reason]}</span><span className="lane-count">{group.length}</span></div>{group.map(task=><TaskCard task={task} overview={overview} key={task.id}/>)}</section>)}</div></section>}</>}
function OverviewPage(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000}),[createOpen,setCreateOpen]=useState(false);useEffect(()=>{const open=()=>setCreateOpen(true);window.addEventListener('orchestra:new-task',open);return()=>window.removeEventListener('orchestra:new-task',open)},[]);if(q.isLoading)return <main className="page loading"><p className="eyebrow">Dispatch board</p><p>Fetching queue state from the coordinator</p></main>;if(q.error)return <main className="page error" role="alert">Queue unavailable: {String(q.error)}</main>;const d=q.data!,sessions=d.sessions??[],active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(sessions.filter(s=>s.pending_approval).map(s=>s.capture?.task_id));return <main className="page"><div className="page-heading"><div><p className="eyebrow">Agent dispatch</p><h1>Keep the work moving.</h1></div><div className="heading-actions"><p>Live task state across every connected harness. The board refreshes every five seconds.</p><button onClick={()=>setCreateOpen(true)}>New task <kbd>N</kbd></button></div></div><div className="status-strip" aria-label="Queue summary"><div><span>Tasks</span><b>{d.tasks.length}</b></div><div><span>In session</span><b>{active}</b></div><div className={attention?'attention':''}><span>Needs attention</span><b>{attention}</b></div><div className={approvals.size?'attention':''}><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><div><h2>Task flow</h2><p>Each card states the current reason it is waiting or running.</p></div><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><Board tasks={d.tasks} overview={d}/>{createOpen&&<Create overview={d} close={()=>setCreateOpen(false)}/>}</main>}
@@ -30,7 +32,9 @@ function Lifecycle({action,mutate,pending}:{action:Action;mutate:(action:string,
function CaptureView({capture,blocker}:{capture?:Capture;blocker?:string}){const [expanded,setExpanded]=useState(false),[filter,setFilter]=useState('');if(!capture)return <div className="capture-empty"><b>Live capture unavailable</b><span>{blocker||'The session has not published a recent pane capture.'}</span></div>;const lines=capture.text.split('\n'),visible=filter?lines.filter(l=>l.toLowerCase().includes(filter.toLowerCase())):lines;const preview=expanded?visible:visible.slice(-18);return <section className="capture"><div className="capture-head"><div><h2>Live capture</h2><span>{capture.source} · updated {date(capture.at)} · {lines.length} lines{capture.truncated?' · truncated':''}</span></div><button className="quiet-button" onClick={()=>setExpanded(v=>!v)}>{expanded?'Show recent':'Expand'}</button></div><input className="capture-filter" value={filter} onChange={e=>setFilter(e.target.value)} placeholder="Filter visible capture" aria-label="Filter live capture"/><pre>{preview.join('\n')||'No lines match this filter.'}</pre>{!expanded&&visible.length>preview.length&&<p className="capture-more">Showing the most recent {preview.length} matching lines.</p>}</section>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient(),[notice,setNotice]=useState('');const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:(result:any,vars)=>{setNotice(vars.action.includes('approval')?'Approval request has been queued; waiting for the worker acknowledgement.':'Action recorded.');qc.invalidateQueries({queryKey:['task',taskID]});qc.invalidateQueries({queryKey:['overview']})}});if(q.isLoading)return <main className="page loading">Fetching task record</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!,events=d.events??[];return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div><div className={'task-banner state-'+d.task.state}><div><span className="task-state">{label[d.task.state]}</span><p>{taskDetailExplanation(d)}</p></div>{d.session?.lease_until&&<span>Lease ends <b>{date(d.session.lease_until)}</b></span>}</div>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} notice={notice} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><CaptureView capture={d.session?.capture} blocker={d.session?.blocker}/><h2 className="actions-title">Recovery & lifecycle</h2><p className="section-copy">Actions are only enabled when their server-side requirements are met. Consequences are shown before the final submission.</p><div className="actions">{d.actions.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div>{m.error&&<p className="error" role="alert">{String(m.error)}</p>}{notice&&!d.session?.pending_approval&&<p className="notice" role="status">{notice}</p>}</div><aside className="surface"><h2>Session</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>State</span><span className="machine">{d.task.state}</span></li><li><span>Harness</span><span className="machine">{d.session?.harness_id||''}</span></li><li><span>Pane</span><span className="machine">{d.session?.pane_id||'—'}</span></li><li><span>Lease ends</span><span className="machine">{date(d.session?.lease_until)}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff </Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report </Link></p>}</aside></div><div className="surface timeline-surface"><div className="timeline-heading"><div><h2>Timeline</h2><p>Recorded operator and lifecycle decisions.</p></div><span>{events.length} events</span></div><ol className="timeline">{events.map(e=><li key={e.id}><span>{e.type}</span><time>{date(e.at)}</time></li>)}</ol></div></main>}
function TaskDiagnosis({detail}:{detail:Detail}){const evidence=detail.task.last_session,approval=detail.session?.pending_approval,action=detail.actions.find(a=>a.enabled),observed=evidence?.captured_at||evidence?.checked_at;const next=approval?{title:'Review the permission request',copy:'This harness is paused at a permission boundary. Approve only the exact request shown.'}:action?{title:actionLabel[action.id]||action.id,copy:actionHelp[action.id]||'This action is currently safe to submit.'}:{title:'No safe lifecycle action is available',copy:'Review the retained diagnosis and pane evidence. Unavailable actions explain their requirements below.'};return <section className={'diagnosis state-'+detail.task.state} aria-label="Task diagnosis"><div className="diagnosis-heading"><div><p className="eyebrow">Current diagnosis</p><h2>{detail.task.state==='blocked'?blockLabel[detail.task.block_reason||'unknown']:label[detail.task.state]}</h2><p>{taskDetailExplanation(detail)}</p></div><span className="task-state">{label[detail.task.state]}</span></div><dl className="diagnosis-evidence"><div><dt>Last activity</dt><dd>{date(observed||detail.session?.capture?.at)}</dd></div><div><dt>Pane state</dt><dd>{evidence?.pane_state||detail.session?.agent_status||'unknown'}</dd></div><div><dt>Pane</dt><dd className="machine">{evidence?.pane_id||detail.session?.pane_id||'—'}</dd></div><div><dt>Source</dt><dd>{evidence?.source||detail.session?.capture?.source||'unknown'}</dd></div></dl><div className="next-action"><div><span>Next safe action</span><b>{next.title}</b><p>{next.copy}</p></div>{detail.session?.lease_until&&<small>Lease ends {date(detail.session.lease_until)}</small>}</div></section>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient(),[notice,setNotice]=useState('');const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:(result:any,vars)=>{setNotice(vars.action.includes('approval')?'Approval request has been queued; waiting for the worker acknowledgement.':'Action recorded.');qc.invalidateQueries({queryKey:['task',taskID]});qc.invalidateQueries({queryKey:['overview']})}});if(q.isLoading)return <main className="page loading">Fetching task record</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!,events=d.events??[],available=d.actions.filter(a=>a.enabled),unavailable=d.actions.filter(a=>!a.enabled),evidence=d.task.last_session;return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div><TaskDiagnosis detail={d}/>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} notice={notice} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><CaptureView capture={d.session?.capture} blocker={d.session?.blocker||d.task.blocker}/>{available.length>0&&<><h2 className="actions-title">Available actions</h2><p className="section-copy">Only actions whose server-side safety requirements are met are shown here.</p><div className="actions">{available.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div></>}{unavailable.length>0&&<details className="unavailable-actions"><summary>Unavailable actions ({unavailable.length})</summary><p>These controls are disabled until their stated server-side requirements are met.</p><div className="actions">{unavailable.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div></details>}{m.error&&<p className="error" role="alert">{String(m.error)}</p>}{notice&&!d.session?.pending_approval&&<p className="notice" role="status">{notice}</p>}</div><aside className="surface"><h2>Session evidence</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>Harness</span><span className="machine">{evidence?.harness_id||d.session?.harness_id||'—'}</span></li><li><span>Pane</span><span className="machine">{evidence?.pane_id||d.session?.pane_id||'—'}</span></li><li><span>Observed</span><span className="machine">{date(evidence?.captured_at||evidence?.checked_at)}</span></li><li><span>Lease ends</span><span className="machine">{date(d.session?.lease_until)}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff </Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report </Link></p>}</aside></div><div className="surface timeline-surface"><div className="timeline-heading"><div><h2>Timeline</h2><p>Recorded operator and lifecycle decisions.</p></div><span>{events.length} events</span></div><ol className="timeline">{events.map(e=><li key={e.id}><span>{e.type}</span><time>{date(e.at)}</time></li>)}</ol></div></main>}
function taskDetailExplanation(d:Detail){if(d.session?.pending_approval)return 'This harness is paused at a permission boundary. Review the exact request before allowing it.';if(d.task.state==='leased')return d.session?.capture?'The harness is active and publishing a recent capture.':'The task is leased, but its live capture is unavailable.';if(d.task.state==='blocked')return d.task.blocker||'Unknown — legacy record has no retained blocker or pane evidence.';return label[d.task.state]+' task record.'}
function Workers(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});if(q.isLoading)return <main className="page loading">Fetching worker heartbeats</main>;if(q.error)return <main className="page error" role="alert">Worker pool unavailable: {String(q.error)}</main>;const d=q.data!,workers=d.workers;const herdr=(status?:string)=>status==='reachable'?'local herdr reachable':status==='unreachable'?'local herdr unreachable':'local herdr not yet checked';return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Worker pool</p><h1>Available capacity.</h1></div><p>{workers.filter(w=>w.online).length} of {workers.length} registered workers are reachable right now.</p></div>{workers.length?<><p className="worker-note">Heartbeat proves the worker can reach Orchestra. Local herdr status is separately reported by that worker; it is never inferred from legacy coordinator TCP probes.</p><table><thead><tr><th>Worker</th><th>Heartbeat</th><th>Local herdr</th><th>Active work</th><th>Last error</th><th>Last heartbeat</th></tr></thead><tbody>{workers.map(w=><tr key={w.id}><td>{w.id}</td><td className={w.online?'online':'offline'}><span className="status-dot"/> {w.online?'reachable':'overdue'}</td><td className={w.health?.herdr_status==='reachable'?'online':w.health?.herdr_status==='unreachable'?'offline':''}>{herdr(w.health?.herdr_status)}<small>{date(w.health?.checked_at)}</small></td><td>{w.health?.active_task_id?<><span>{w.health.active_task_id}</span><small>{w.health.active_pane_id||'pane unknown'}</small></>:'idle'}</td><td>{w.health?.last_error?<><span>{w.health.last_error}</span><small>{date(w.health.error_at)}</small></>:'—'}</td><td>{date(w.last_seen)}</td></tr>)}</tbody></table></>:<section className="empty-panel"><i className="branch-mark"/><h2>No workers registered</h2><p>Connect a worker to begin leasing queued tasks.</p></section>}</main>}