Add web UI and worker capture/approval command channel

Introduces the browser-facing surface and the worker-side protocol that
backs it:

- internal/ui: joined read model plus per-task lifecycle and approval
  controls, kept separate from the raw endpoints workers and harnesses
  depend on.
- internal/webui + web/: Vite/React app, build output embedded via
  go:embed and served as an SPA fallback.
- federation: per-(worker, task) captures with a monotonic revision that
  advances only when pane text actually changes, and a command queue
  restricted to grant_approval / deny_approval, each bound to the capture
  revision the operator acted on.
- orchestra-worker: publishes captures and executes commands only after
  re-reading the pane and confirming the revision still matches. Sends
  keystrokes only for a visible y/n prompt or OpenCode's fully labelled
  selector, and refuses to deny through that selector rather than guess
  at unobservable navigation.

This is the ownership boundary AUDIT.md's B14 and B17 call for: approval
becomes an explicit, revision-bound operation executed by the worker that
owns the pane, instead of a side effect of prompting over a
coordinator-driven remote socket.

Also ignores the web build inputs and outputs. node_modules ships vendored
Go packages, so go build and go test walk into it if it is merely
untracked; both node_modules and .node_modules are excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01535A3Y8RtkAi8wYuWhtkEd
This commit is contained in:
kami
2026-07-28 23:14:16 +04:00
parent bb43944572
commit b57894b183
34 changed files with 4841 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import {afterEach,describe,expect,it,vi} from 'vitest'
import {api} from './client'
describe('UI API client',()=>{
afterEach(()=>vi.unstubAllGlobals())
it('submits every supplied task field to the UI creation endpoint',async()=>{
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({task_id:'t'}),{status:200}))
vi.stubGlobal('fetch',fetch)
await api.create({source:'web',external_id:'x',project:'p',parent:'parent',inherent_priority:2})
expect(fetch).toHaveBeenCalledWith('/v1/ui/tasks',expect.objectContaining({method:'POST'}))
expect((fetch.mock.calls[0][1].body as string)).toContain('"parent":"parent"')
})
it('uploads a completion report before a task completion action',async()=>{
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({ref:'a'.repeat(64)}),{status:201}))
vi.stubGlobal('fetch',fetch)
await expect(api.upload('report')).resolves.toBe('a'.repeat(64))
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
})
})
+5
View File
@@ -0,0 +1,5 @@
import type { Detail, Overview } from './types'
async function request<T>(path:string, init?:RequestInit):Promise<T>{const r=await fetch(path,{headers:{'Content-Type':'application/json',...init?.headers},...init});if(!r.ok)throw new Error(await r.text());return r.json() as Promise<T>}
async function text(path:string){const r=await fetch(path);if(!r.ok)throw new Error(await r.text());return r.text()}
async function upload(body:string){const r=await fetch('/v1/artifacts',{method:'POST',headers:{'Content-Type':'text/markdown'},body});if(!r.ok)throw new Error(await r.text());return (await r.json() as {ref:string}).ref}
export const api={overview:()=>request<Overview>('/v1/ui/overview'),detail:(id:string)=>request<Detail>('/v1/ui/tasks/'+id),artifact:(ref:string)=>text('/v1/ui/artifacts/'+ref),upload,create:(body:unknown)=>request('/v1/ui/tasks',{method:'POST',body:JSON.stringify(body)}),action:(id:string,action:string,body={})=>request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`,{method:'POST',body:JSON.stringify(body)})}
+8
View File
@@ -0,0 +1,8 @@
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 }
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 }
export interface Action { id:string; enabled:boolean; reason?:string; needs?:string[] }
export interface Detail { task:Task; events:Array<{id:string;type:string;at:string;payload:unknown}>; session?:Session; handoff_ref?:string; report_ref?:string; actions:Action[] }
export interface Overview { tasks:Task[]; workers:Array<{id:string;capacity:number;last_seen:string;online:boolean}>; sessions:Session[]; updated_at:string }
+23
View File
@@ -0,0 +1,23 @@
import React,{useEffect,useRef,useState} from 'react'
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,PendingApproval,Task,TaskState} from './api/types'
import './style.css'
const client=new QueryClient(),states:TaskState[]=['queued','leased','blocked','completed','failed']
const label:Record<TaskState,string>={queued:'Queued',leased:'In session',blocked:'Blocked',completed:'Complete',failed:'Failed'}
const actionLabel:Record<string,string>={handoff:'Request handoff',complete:'Complete task',block:'Mark blocked'}
function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=useQueryClient(),input=useRef<HTMLInputElement>(null),[term,setTerm]=useState('');useEffect(()=>{input.current?.focus()},[]);const choose=(id:string)=>{if(id==='board')nav('/');if(id==='workers')nav('/workers');if(id==='new'){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(id==='refresh')qc.invalidateQueries();close()};const commands=[['board','Go to dispatch board','G then B'],['new','Create a new task','N'],['workers','View worker pool','G then W'],['refresh','Refresh live data','R']] as const;const matches=commands.filter(c=>c[1].toLowerCase().includes(term.toLowerCase()));return <div className="palette-backdrop" onMouseDown={close}><section className="palette" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e=>e.stopPropagation()}><input ref={input} value={term} onChange={e=>setTerm(e.target.value)} onKeyDown={e=>{if(e.key==='Escape')close();if(e.key==='Enter'&&matches[0])choose(matches[0][0])}} placeholder="Find a command…" aria-label="Find a command"/><div className="palette-list">{matches.map(([id,name,key])=><button key={id} className="palette-command" onClick={()=>choose(id)}><span>{name}</span><kbd>{key}</kbd></button>)}{!matches.length&&<p className="palette-empty">No matching command.</p>}</div><p className="palette-foot"><kbd></kbd> run <kbd>esc</kbd> close</p></section></div>}
function Shell({children}:{children:React.ReactNode}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!(e.target instanceof HTMLInputElement)&&!(e.target instanceof HTMLTextAreaElement)){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav]);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></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
function Board({tasks}:{tasks: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=><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></Link>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>}
function Overview(){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!,active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(d.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>Blocked or failed</span><b>{attention}</b></div><div><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><h2>Task flow</h2><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><Board tasks={d.tasks}/>{createOpen&&<Create close={()=>setCreateOpen(false)}/>}</main>}
function Create({close}:{close:()=>void}){const qc=useQueryClient(),nav=useNavigate(),first=useRef<HTMLInputElement>(null),[title,setTitle]=useState(''),[description,setDescription]=useState(''),[project,setProject]=useState('default'),[capability,setCapability]=useState(''),[advanced,setAdvanced]=useState(''),[formError,setFormError]=useState('');useEffect(()=>{first.current?.focus()},[]);const m=useMutation({mutationFn:()=>{let extra:Record<string,unknown>={};if(advanced.trim()){try{extra=JSON.parse(advanced)}catch{return Promise.reject(new Error('Additional fields must be valid JSON.'))}}return api.create({...extra,source:'web',external_id:crypto.randomUUID(),project,title,description,capability:capability.split(',').map(x=>x.trim()).filter(Boolean)})},onSuccess:(e:any)=>{qc.invalidateQueries({queryKey:['overview']});nav('/tasks/'+e.task_id)}});const submit=(e:React.FormEvent)=>{e.preventDefault();setFormError('');if(title.trim().length<3){setFormError('Give the task a title of at least three characters.');return}if(description.trim().length<10){setFormError('Add enough immutable instructions for an agent to act safely.');return}m.mutate()};return <div className="modal-backdrop" onMouseDown={close}><section className="create modal" role="dialog" aria-modal="true" aria-labelledby="new-task-title" onMouseDown={e=>e.stopPropagation()}><div className="modal-heading"><div><p className="eyebrow">Dispatch new work</p><h2 id="new-task-title">New task</h2></div><button className="icon-button" onClick={close} aria-label="Close task creation">×</button></div><p>Give the harness a clear objective and immutable operating instructions.</p><form onSubmit={submit} noValidate><label>Task title<input ref={first} required value={title} onChange={e=>setTitle(e.target.value)} placeholder="e.g. Add the health endpoint" autoComplete="off"/></label><label>Project<input required value={project} onChange={e=>setProject(e.target.value)} placeholder="Project name" autoComplete="off"/></label><label>Capabilities <small>Optional, comma-separated</small><input value={capability} onChange={e=>setCapability(e.target.value)} placeholder="go, docker" autoComplete="off"/></label><label>Immutable instructions<textarea required value={description} onChange={e=>setDescription(e.target.value)} placeholder="Describe the outcome, constraints, and evidence required."/></label><details><summary>Additional accepted task fields</summary><textarea aria-label="Additional task fields JSON" placeholder={'{"parent":"…","inherent_priority":1,"due":"2026-07-30T12:00:00Z"}'} value={advanced} onChange={e=>setAdvanced(e.target.value)}/></details><div className="modal-actions"><button type="button" className="quiet-button" onClick={close}>Cancel</button><button disabled={m.isPending}>{m.isPending?'Creating task…':'Create task'}</button></div>{(formError||m.error)&&<span className="error" role="alert">{formError||String(m.error)}</span>}</form></section></div>}
function Approval({approval,onAction,pending}:{approval:PendingApproval;onAction:(a:string)=>void;pending:boolean}){const ref=useRef<HTMLDivElement>(null),grant=approval.kind!=='unknown',deny=approval.kind==='shell';useEffect(()=>{const node=ref.current;if(!node)return;node.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();const trap=(e:KeyboardEvent)=>{if(e.key==='Escape')return; if(e.key!=='Tab')return;const focus=[...node.querySelectorAll<HTMLElement>('button:not(:disabled)')],i=focus.indexOf(document.activeElement as HTMLElement);if(e.shiftKey&&i<=0){e.preventDefault();focus.at(-1)?.focus()}else if(!e.shiftKey&&i===focus.length-1){e.preventDefault();focus[0].focus()}};node.addEventListener('keydown',trap);return()=>node.removeEventListener('keydown',trap)},[]);return <div className="approval-backdrop"><section ref={ref} className="approval" role="dialog" aria-modal="true" aria-labelledby="approval-title"><p className="eyebrow">Harness gate</p><h2 id="approval-title">Permission required</h2><p>{approval.summary}</p><pre>{approval.command||approval.diff||'The harness prompt could not be parsed safely.'}</pre><small>Pane {approval.pane_id} · capture revision {approval.capture_revision} · detected {new Date(approval.detected_at).toLocaleString()}</small>{approval.kind==='opencode_once'&&<p>Approving sends Enter to OpenCodes explicitly displayed <b>Allow once</b> selection. Reject is unavailable because the selected position cannot be verified from the capture.</p>}{!grant&&<p role="alert">This prompt cannot be safely executed because its exact confirmation is unknown.</p>}<div><button disabled={!grant||pending} onClick={()=>onAction('grant_approval')}>{approval.kind==='opencode_once'?'Allow once':'Approve'}</button><button disabled={!deny||pending} title={deny?'':'This selector does not expose a verifiable reject position.'} onClick={()=>onAction('deny_approval')}>Reject</button></div></section></div>}
function Lifecycle({action,mutate,pending}:{action:Action;mutate:(action:string,body?:object)=>void;pending:boolean}){const [value,setValue]=useState('');if(action.id==='handoff')return <button disabled={!action.enabled||pending} title={action.reason} onClick={()=>mutate('handoff')}>Request handoff</button>;if(action.id==='complete')return <form onSubmit={async e=>{e.preventDefault();try{const ref=await api.upload(value);mutate('complete',{report_ref:ref,receipt:{source:'web',completed_at:new Date().toISOString()}})}catch(err){alert(String(err))}}}><label className="sr-only" htmlFor="completion-report">Completion report</label><textarea id="completion-report" required placeholder="Completion report, stored as evidence" value={value} onChange={e=>setValue(e.target.value)}/><button disabled={!action.enabled||pending}>Complete task</button></form>;const field=action.id==='block'?'blocker':'reason';return <form onSubmit={e=>{e.preventDefault();mutate(action.id,{[field]:value})}}><label className="sr-only" htmlFor={'action-'+action.id}>{field}</label><input id={'action-'+action.id} required placeholder={field==='blocker'?'What is blocking progress?':'Reason'} value={value} onChange={e=>setValue(e.target.value)}/><button disabled={!action.enabled||pending} title={action.reason||action.needs?.join(',')}>{actionLabel[action.id]||action.id}</button></form>}
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient();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:()=>qc.invalidateQueries({queryKey:['task',taskID]})});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!;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>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} 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><div className={'task-state state-'+d.task.state}>{label[d.task.state]}</div><h2>Live capture</h2><pre>{d.session?.capture?.text||d.session?.blocker||'No live capture is available for this task.'}</pre><h2 style={{marginTop:20}}>Actions</h2><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>}</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">{d.session?.lease_until?new Date(d.session.lease_until).toLocaleString():'—'}</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" style={{marginTop:20}}><h2>Timeline</h2><ol className="timeline">{d.events.map(e=><li key={e.id}><span>{e.type}</span><time>{new Date(e.at).toLocaleString()}</time></li>)}</ol></div></main>}
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 workers=q.data!.workers;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?<table><thead><tr><th>Worker</th><th>State</th><th>Capacity</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'}>{w.online?'online':'offline'}</td><td>{w.capacity}</td><td>{new Date(w.last_seen).toLocaleString()}</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>}
function Artifact(){const {ref=''}=useParams();const q=useQuery({queryKey:['artifact',ref],queryFn:()=>api.artifact(ref)});return <main className="page"><Link className="back" to="/"> Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Evidence artifact</p><h1>Recorded output.</h1></div><p className="machine">{ref}</p></div>{q.isLoading?<p className="loading">Retrieving artifact</p>:q.error?<p className="error" role="alert">{String(q.error)}</p>:<pre>{q.data}</pre>}</main>}
function App(){return <Shell><Routes><Route path="/" element={<Overview/>}/><Route path="/tasks/:taskID" element={<TaskDetail/>}/><Route path="/workers" element={<Workers/>}/><Route path="/artifacts/:ref" element={<Artifact/>}/></Routes></Shell>};createRoot(document.getElementById('root')!).render(<React.StrictMode><QueryClientProvider client={client}><BrowserRouter><App/></BrowserRouter></QueryClientProvider></React.StrictMode>)
+10
View File
File diff suppressed because one or more lines are too long