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
+53 -45
View File
@@ -28,22 +28,23 @@ is not an operationally useful control plane yet.
- One `workpc-opencode` worker is heartbeating and reports capacity 1. Its
Unix-socket herdr path is the meaningful reachability signal; legacy
coordinator TCP probes are not.
coordinator TCP probes are not. The coordinator now skips remote herdr
protocol probes entirely and logs them as worker-owned; local herdr probes
remain separate.
- The API reports 28 tasks: **22 blocked, 5 completed, 1 failed, and no
queued, leased, approval-pending, or captured sessions**. The visible queue
is therefore historical E2E residue, not current work.
- A real browser loaded the deployed SPA with a valid Web session and crashed
before painting: `overview.sessions` is JSON `null`, while the bundle calls
`sessions.length`. Task detail has the same fault for `events: null`.
Source now emits empty arrays and the frontend is defensive; the current
local build renders the live board and task record. **The deployment still
needs a rebuild/redeploy.**
- The API and SPA were rebuilt/redeployed on 2026-07-29. API health and the
browser shell returned `200`; the deployed logout endpoint returns `204`.
This ships the empty-collection fixes that prevent the prior
`sessions.length`/`events` null crashes. A real authenticated browser smoke
is still outstanding.
- The task record for a blocked task and the failed E2E task had no retained
session and no lifecycle events. Every lifecycle control was correctly
disabled, but neither record could explain its state.
- The live image predates the source session/logout work: `DELETE
/v1/ui/session` returns `405`. Do not treat token rotation as session
revocation until that endpoint is deployed.
- ntfy delivery now has a configured publisher token. A read-only
authenticated account check returned `200`, resolving the prior publish
`403 Forbidden`; no test notification was sent.
Earlier successful OpenCode approval and worker-flow checks remain useful
historical evidence, but they do not prove the current deployment or a
@@ -56,21 +57,25 @@ checkout.
The current UI should become a diagnosis surface, not a five-column task
catalogue.
1. **Persist and show a blocked diagnosis.** Project the structured blocking
reason/error into the task read model. Group the board by reason and age
(for example: lease failure, worker offline, expired lease, approval,
handoff validation, operator block) rather than rendering a giant generic
“Blocked” lane.
2. **Persist last-session and pane evidence.** Before a worker/coordinator
drops a terminal session mapping, retain harness, pane ID, last successful
capture/check, and a pane status of `open`, `closed`, `unreachable`, or
`unknown`. The UI must show the source and timestamp. For old records that
lack this evidence, say **unknown — legacy record has no retained
blocker/pane evidence**, never imply a live or closed pane.
3. **Make the task page lead with the diagnosis.** Put reason, last activity,
pane state, and next safe action first. Hide unavailable lifecycle forms
behind an “Unavailable actions” disclosure; an empty session must not
consume most of the page with disabled controls.
1. ~~**Persist and show a blocked diagnosis.**~~ **Closed in code,
deployment verification outstanding (2026-07-29).** `TaskBlocked` now
carries a validated `block_reason`; coordinator and browser-created blocks
set it, while older events receive a deterministic projection fallback.
The board groups blocked tasks by diagnosis and orders each group oldest
first instead of rendering one generic “Blocked” lane.
2. ~~**Persist last-session and pane evidence.**~~ **Closed in code,
deployment verification outstanding (2026-07-29).** Before worker
completion/release cleanup, the worker captures a `session_evidence`
snapshot (harness, pane, observed pane status, capture/check timestamps,
and source); coordinator blocks record their own check snapshot. The task
projection retains it and blocked cards state the source and timestamp.
Legacy records still say **unknown — legacy record has no retained
blocker/pane evidence**, never implying a live or closed pane.
3. ~~**Make the task page lead with the diagnosis.**~~ **Closed in code,
deployment verification outstanding (2026-07-29).** The task detail now
leads with reason, last activity, pane state, retained evidence, and the
next safe action. Only server-enabled lifecycle actions are shown directly;
unavailable actions are kept in an “Unavailable actions” disclosure.
4. **Separate active work from history.** Default the board to active,
waiting, and needs-attention work; move completed/failed/test residue to
filters or a compact history view. Add search, project filtering, and an
@@ -334,7 +339,7 @@ Delivery` asserting the prompt target is the unique name. Nothing yet starts
two same-harness sessions in distinct panes and verifies both attach, so the
`agent_name_taken` failure mode itself is untested end-to-end.
### B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — closed in code; live success still unproven
### B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — closed; worker path live-verified
The real OpenCode healthcheck run showed that prompting an opaque agent to
write the full `continuity.Handoff` schema is the wrong ownership boundary.
@@ -401,22 +406,32 @@ correction documented at the end of this file supplies the parsing and
validation half.
The coordinator no longer accepts a non-local herdr operation; the standing
rule is enforced rather than advisory. A successful live worker handoff,
release, and pickup remains the required operational proof.
rule is enforced rather than advisory.
**QA attempt (2026-07-29):** the bounded disposable task
**Failed QA fixture (2026-07-29):** the bounded disposable task
`06FTSHBPYHQXN8MM849PFA1V6M` reached the worker and wrote its semantic
report, but publication correctly refused it. Its prescribed fields combined
to the canonical action `inspect the marker file — verify worker-owned
handoff construction`; the circular-action guard rejects that word even when
it occurs in the explanatory `WHY` text. This is an invalid QA fixture, not a
successful handoff or a release-path regression. The worker must be given a
fresh fixture whose combined `NEXT`/`WHY` action does not mention a handoff
(for example, `WHY: verify canonical artifact construction`), then the
release and successor pickup must be observed. The coordinator is token-gated
and this host cannot read the protected worker credential, so this session
could not enqueue that replacement task. The failed disposable pane was left
untouched; no destructive herdr calls were issued.
release-path regression.
**Live success (2026-07-29):** after configuring `test-e2e` on the
`workpc-opencode` worker, disposable task `06FTY8ZA45CCZZHQ7H44SMQG14`
completed the full path. Its first pane (`w1B:p1`) created the marker and the
bounded semantic report. The worker, not the harness, published canonical
handoff `5e9e942f269f6d6394896186377e35d596d403bd04a58f0c307818af0499b208`:
its anchor was worker-derived (`git_sha`
`d73b576882b07bac34abaf2beabeacfaa8f79529`, branch
`orchestra/scratch/oc-06fty8za45cczzhq7h44smqg14`) and its semantic action
was `Verify the B17 probe marker file contents. — Verify canonical artifact
construction.` The worker recorded `TaskReleased`, routed a successor lease
with that handoff reference to a new pane (`w1C:p1`), and the successor
verified the marker through the scratch checkpoint. It then completed the
task with report artifact
`9a41950548118a47232747ff57fb7ac109dbdb2e45e0b8a8141d4828a6351f18`.
After `TaskCompleted`, the worker state had no sessions or leases. The
disposable worktree was intentionally retained; pane/session cleanup passed.
### B13 — `agent.start` silently no-ops under back-to-back leases (found live 2026-07-28) — closed (code fix; not yet re-verified live)
@@ -745,16 +760,9 @@ bypass is closed by construction rather than by the header check alone.
In dependency order, not importance order:
1. **Prove a successful B17 worker handoff.** Use a bounded disposable task
with a concrete next action and verify canonical report upload, release,
follow-up pickup, and cleanup. The rejection path is proven; success is
not.
2. **Fix health semantics and ntfy.** Report worker/local-herdr health
separately from legacy TCP registry probes, and correct the ntfy server
credential causing `403 Forbidden`.
3. **Address the Web UI backlog above**, starting with queue explanations,
1. **Address the Web UI backlog above**, starting with queue explanations,
actionable worker health, and a usable approval/recovery workflow.
4. **Record a browser smoke.** Confirm login, refresh, invalid token, and
2. **Record a browser smoke.** Confirm login, refresh, invalid token, and
expired-session behavior in an actual browser after the deployed assets
settle.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<script type="module" crossorigin src="/assets/index-Dqz7-YV3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzSA27i7.css">
<script type="module" crossorigin src="/assets/index-BlmzmqVL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0JjPpb-.css">
<div id="root"></div>
+3 -1
View File
@@ -1,5 +1,7 @@
export type TaskState='queued'|'leased'|'blocked'|'completed'|'failed'
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string; blocker?:string; blocked_at?:string; last_pane_id?:string; last_harness_id?:string; pane_state?:string }
export type BlockReason='lease_failure'|'worker_offline'|'lease_expired'|'approval'|'handoff_validation'|'operator_block'|'system_error'|'unknown'
export interface SessionEvidence { pane_id?:string; harness_id?:string; pane_state?:string; source?:string; captured_at?:string; checked_at?:string }
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string; blocker?:string; block_reason?:BlockReason; blocked_at?:string; last_pane_id?:string; last_harness_id?:string; pane_state?:string; last_session?:SessionEvidence }
export interface PendingApproval { kind:'shell'|'opencode_once'|'edit'|'unknown'; summary:string; command?:string; diff?:string; pane_id:string; capture_revision:number; detected_at:string }
export interface Capture { task_id:string; source:string; text:string; revision:number; at:string; truncated:boolean }
export interface Session { pane_id?:string; harness_id?:string; agent_status?:string; blocker?:string; lease_until?:string; capture?:Capture; pending_approval?:PendingApproval }
+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>}
+4 -1
View File
@@ -9,11 +9,14 @@
.heading-actions{display:flex;align-items:end;gap:16px}.heading-actions button{white-space:nowrap}.modal-backdrop{position:fixed;inset:0;z-index:15;display:grid;place-items:center;padding:20px;background:rgba(8,7,5,.72)}.create.modal{width:min(680px,100%);max-height:calc(100vh - 40px);overflow:auto}.modal-heading{display:flex;align-items:start;justify-content:space-between;gap:12px}.modal-heading h2{margin:0}.icon-button{display:grid;width:30px;height:30px;place-items:center;padding:0;border-color:var(--line-hi);color:var(--text-mid);background:transparent;font-size:21px;font-weight:400}.create form{display:grid;gap:9px}.create form label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}.create form input,.create form textarea{margin:0}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}.quiet-button{border-color:var(--line-hi);color:var(--text-mid);background:transparent}
.login-page{display:grid;min-height:100vh;place-items:center;padding:20px;color:var(--text-hi)}.login-card{width:min(420px,100%);padding:28px;background:var(--bg-1);border:1px solid var(--line-hi);border-radius:var(--r-lg);box-shadow:var(--shadow-soft)}.login-card h1{margin:0 0 8px;font-size:31px;letter-spacing:-.04em}.login-card>p:not(.eyebrow){margin:0 0 22px;color:var(--text-mid);line-height:1.5}.login-card form{display:grid;gap:12px}.login-card label{display:grid;gap:6px;color:var(--text-mid);font-size:12px}
@media(max-width:760px){.heading-actions{width:100%;align-items:stretch;flex-direction:column}.heading-actions button{align-self:flex-start}.modal-backdrop{padding:12px}.create.modal{max-height:calc(100vh - 24px)}}
.blocked-groups{margin-top:32px}.blocked-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}.blocked-grid>section{min-height:120px;padding:12px;background:var(--bg-1);border:1px solid rgba(213,160,146,.34);border-radius:var(--r-md)}
/* Operator comfort pass: make live state scannable before the raw data. */
.account{position:relative}.account-button{display:flex;align-items:center;gap:7px;padding:7px 9px;border-color:var(--line);color:var(--text-mid);background:transparent;font-size:11px;font-weight:500}.account-menu{position:absolute;top:calc(100% + 8px);right:0;z-index:10;display:grid;min-width:190px;gap:8px;padding:10px;border:1px solid var(--line-hi);border-radius:var(--r-sm);background:var(--bg-2);box-shadow:var(--shadow-soft);color:var(--text-lo);font-size:11px}.account-menu button{padding:7px 9px;text-align:left}.status-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent-hi);box-shadow:0 0 0 3px var(--accent-dim)}
.section-title>div{display:grid;gap:3px}.section-title p,.section-copy,.worker-note{margin:0;color:var(--text-lo);font-size:12px;line-height:1.5}.card em{display:block;overflow:hidden;margin-top:9px;color:var(--text-lo);font-size:11px;font-style:normal;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.card:hover em{color:var(--text-mid)}.eligibility{display:grid;gap:3px;padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);background:var(--accent-dim);color:var(--text-mid);font-size:12px}.eligibility b{color:var(--accent-hi);font-size:12px}.eligibility.warning{border-color:rgba(213,160,146,.45);background:rgba(213,160,146,.09)}.eligibility.warning b{color:#d5a092}
.task-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:-8px 0 20px;padding:13px 16px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.task-banner>div{display:flex;align-items:center;gap:12px}.task-banner .task-state{margin:0;color:var(--accent-hi);font-weight:600}.task-banner p{margin:0;color:var(--text-mid);font-size:13px;line-height:1.45}.task-banner>span{color:var(--text-lo);font:11px var(--mono);white-space:nowrap}.task-banner>span b{color:var(--text-machine);font-weight:500}.task-banner.state-blocked,.task-banner.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}
.diagnosis{display:grid;gap:17px;margin:-8px 0 20px;padding:20px;border:1px solid var(--accent-line);border-radius:var(--r-md);background:var(--accent-dim)}.diagnosis.state-blocked,.diagnosis.state-failed{border-color:rgba(213,160,146,.42);background:rgba(213,160,146,.08)}.diagnosis-heading{display:flex;align-items:start;justify-content:space-between;gap:20px}.diagnosis .eyebrow{margin-bottom:6px}.diagnosis h2{margin:0 0 7px;font-size:22px;letter-spacing:-.03em}.diagnosis-heading p:not(.eyebrow){max-width:780px;margin:0;color:var(--text-mid);font-size:13px;line-height:1.5}.diagnosis .task-state{flex:none;margin:0;color:var(--accent-hi);font-weight:600}.diagnosis-evidence{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:0}.diagnosis-evidence div{min-width:0;padding:10px 11px;border:1px solid var(--line);border-radius:var(--r-sm);background:rgba(20,17,13,.28)}.diagnosis-evidence dt{margin-bottom:5px;color:var(--text-lo);font-size:10px;letter-spacing:.07em;text-transform:uppercase}.diagnosis-evidence dd{overflow:hidden;margin:0;color:var(--text-machine);font-size:12px;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}.next-action{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:13px 14px;border-left:3px solid var(--accent);background:rgba(20,17,13,.35)}.next-action>div{display:grid;gap:3px}.next-action span{color:var(--accent-hi);font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase}.next-action b{font-size:14px}.next-action p{margin:0;color:var(--text-mid);font-size:12px;line-height:1.45}.next-action small{flex:none;color:var(--text-lo);font:11px var(--mono)}
.capture{margin:24px 0}.capture-head{display:flex;align-items:start;justify-content:space-between;gap:12px;margin-bottom:9px}.capture-head h2{margin:0 0 4px}.capture-head span{color:var(--text-lo);font:11px var(--mono)}.capture-filter{margin-bottom:8px;font-size:12px}.capture-more{margin:8px 0 0;color:var(--text-lo);font-size:11px}.capture-empty{display:grid;gap:5px;margin:24px 0;padding:15px;border:1px dashed var(--line-hi);border-radius:var(--r-sm);color:var(--text-mid);font-size:12px}.capture-empty b{color:var(--text-hi)}.actions-title{margin:27px 0 5px!important}.actions{margin-top:13px}.action-row,.action-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:end;padding:13px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--bg-2)}.action-row>div,.action-form>div{display:grid;gap:4px}.action-row b,.action-form b{font-size:13px}.action-row span,.action-form span{color:var(--text-lo);font-size:11px;line-height:1.45}.action-form textarea,.action-form input{grid-column:1}.action-form button{grid-column:2;grid-row:1 / span 2}.action-form.compact input{margin:0}.notice,.session-message{padding:10px 12px;border:1px solid var(--accent-line);border-radius:var(--r-sm);color:var(--accent-hi);background:var(--accent-dim);font-size:12px;line-height:1.45}.timeline-surface{margin-top:20px}.timeline-heading{display:flex;align-items:start;justify-content:space-between;gap:16px}.timeline-heading p{margin:-8px 0 12px;color:var(--text-lo);font-size:12px}.timeline-heading>span{color:var(--text-lo);font:11px var(--mono)}.worker-note{margin:-10px 0 15px;padding:10px 12px;border-left:2px solid var(--accent-line);background:var(--bg-1)}.online .status-dot{margin-right:4px}.offline .status-dot{background:#c09280;box-shadow:0 0 0 3px rgba(192,146,128,.13)}
.unavailable-actions{margin-top:22px;padding-top:17px;border-top:1px solid var(--line)}.unavailable-actions summary{color:var(--text-mid);font-size:13px;font-weight:600}.unavailable-actions>p{margin:9px 0 0;color:var(--text-lo);font-size:12px;line-height:1.45}.unavailable-actions .actions{opacity:.72}.unavailable-actions button:disabled{display:none}
.approval-summary{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.approval-summary span{padding:5px 7px;border:1px solid var(--line);border-radius:5px;color:var(--text-lo);font:10px var(--mono)}.approval-summary b{color:var(--text-machine);font-weight:500}.login-card .session-message{margin:-6px 0 4px}
@media(max-width:760px){.account{margin-left:auto}.account-button{font-size:0}.account-button .status-dot{width:8px;height:8px}.task-banner,.task-banner>div{align-items:start;flex-direction:column;gap:7px}.task-banner>span{white-space:normal}.action-row,.action-form{grid-template-columns:1fr}.action-row button,.action-form button{grid-column:auto;grid-row:auto;justify-self:start}.action-form textarea,.action-form input{grid-column:auto}.capture-head{align-items:stretch;flex-direction:column}.section-title{align-items:start;gap:12px;flex-direction:column}.section-title .machine{white-space:nowrap}.readout{display:none}}
@media(max-width:760px){.account{margin-left:auto}.account-button{font-size:0}.account-button .status-dot{width:8px;height:8px}.task-banner,.task-banner>div,.diagnosis-heading,.next-action{align-items:start;flex-direction:column;gap:7px}.task-banner>span{white-space:normal}.diagnosis-evidence{grid-template-columns:repeat(2,minmax(0,1fr))}.action-row,.action-form{grid-template-columns:1fr}.action-row button,.action-form button{grid-column:auto;grid-row:auto;justify-self:start}.action-form textarea,.action-form input{grid-column:auto}.capture-head{align-items:stretch;flex-direction:column}.section-title{align-items:start;gap:12px;flex-direction:column}.section-title .machine{white-space:nowrap}.readout{display:none}}