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
+4
View File
@@ -0,0 +1,4 @@
.git
data
web/node_modules
web/dist
+9
View File
@@ -3,3 +3,12 @@
.orchestra-config/
clients/
/orchestra
# Web UI build inputs/outputs. node_modules in particular ships vendored Go
# packages (e.g. flatted/golang), so leaving it merely untracked is not
# enough — `go build ./...` and `go test ./...` walk into it.
/package-lock.json
node_modules/
.node_modules/
web/dist/
web/tsconfig.tsbuildinfo
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . ./
RUN go build -trimpath -ldflags='-s -w' -o /out/orchestra ./cmd/orchestra
FROM alpine:3.21
RUN adduser -D -u 10001 orchestra
WORKDIR /app
COPY --from=build /out/orchestra /app/orchestra
COPY deploy/docker-api-entrypoint.sh /app/docker-api-entrypoint.sh
RUN mkdir /data && chown orchestra:orchestra /data
ENV ORCHESTRA_DATA=/data ORCHESTRA_PORT=9145
VOLUME ["/data"]
EXPOSE 9145
ENTRYPOINT ["/app/docker-api-entrypoint.sh"]
+164
View File
@@ -0,0 +1,164 @@
# Orchestra Web UI Plan
## Goal
Build a proper TypeScript single-page application for local operation of
Orchestra. It must make live task/session state observable, make every
supported control actionable, and render harness approval requests as the
actual pending decision rather than as a vague `blocked` badge. Authentication
is deliberately out of scope for this first local-only version; the API
contract must still keep a clean boundary where auth can be added later.
## Product scope
### Monitoring
- Task board with queued, leased, blocked, completed, and failed states.
- Task detail with description, lifecycle timeline, current worker/harness,
lease expiry, handoff/report references, and Git/worktree facts when a
session is live.
- Worker view: online/offline state, capacity, last heartbeat, active task,
and harness identity.
- Session view: agent status, occupancy, turn-boundary health, recent pane
capture, extracted blocker, and update timestamp.
- Artifact view for handoffs and reports, with JSON formatting for canonical
handoffs and plain-text/Markdown rendering for reports.
### Controls
- Create a task with all currently accepted task fields.
- Request a handoff/rotation through the existing task-control path.
- Grant or deny a currently displayed harness permission.
- Explicitly release, block, or complete a task only where the backend can
validate the required evidence.
- Refresh and polling controls; no control is rendered merely as decoration.
## Architecture
### Frontend
Create `web/` as a Vite + React + TypeScript application.
- React Router routes: `/`, `/tasks/:taskID`, `/workers`, and `/artifacts/:ref`.
- TanStack Query for polling, cache invalidation after controls, retry/error
state, and optimistic UI only after the server returns success.
- A small component layer (CSS modules or Tailwind, chosen during setup) with
accessible dialogs, keyboard-focus handling, and responsive layouts.
- Build output is embedded into the Go binary with `go:embed` and served at
`/`; `/v1/*` remains API-only. The Go server serves `index.html` as the
SPA fallback and static assets with cache headers.
- A typed API client generated from hand-maintained TypeScript interfaces in
`web/src/api/types.ts`; those interfaces are exercised against Go JSON
response tests so frontend/backend field drift fails CI.
### Backend API additions
Keep existing endpoints working. Add a UI-oriented read model rather than
forcing the browser to join unrelated raw endpoints itself.
| Endpoint | Purpose |
|---|---|
| `GET /v1/ui/overview` | Tasks, workers, orchestration health, and active-session summaries in one pollable response. |
| `GET /v1/ui/tasks/{id}` | Full task detail, ordered lifecycle events, active session, blocker, artifact refs, and available actions. |
| `GET /v1/ui/tasks/{id}/capture?source=recent` | Current pane capture plus revision/timestamp; never silently returns stale text as live. |
| `GET /v1/artifacts/{ref}` | Existing artifact fetch, with content type supplied by the UI read model where known. |
| `POST /v1/ui/tasks` | Create task; validates the same fields as `POST /v1/tasks`. |
| `POST /v1/ui/tasks/{id}/actions/{action}` | Narrow action wrapper for handoff request, approval grant/deny, release, block, and completion; returns the resulting task/session state. |
The raw lifecycle endpoints stay available for harness/worker use. The UI
wrapper is responsible for describing whether an action is currently allowed,
why it is unavailable, and what evidence/body it needs.
## Correct approval design
The approval UI must reflect the real permission prompt, not the existing
event-only `ApprovalGranted`/`ApprovalDenied` records.
1. Add a structured `PendingApproval` model:
```ts
type PendingApproval = {
kind: "shell" | "edit" | "unknown";
summary: string;
command?: string;
diff?: string;
paneId: string;
captureRevision: number;
detectedAt: string;
};
```
2. Parse the current `pane.read` text worker-side. Preserve the exact shell
command or proposed edit/diff shown by the harness. If parsing is
uncertain, return `kind: "unknown"` with the raw relevant excerpt and
disable the grant button; do not guess.
3. Make approval a real execution command. `grant` sends the correct
`agent.send_keys`/`pane.send_keys` confirmation to the pane only if the
same `pane_id`, permission kind, and capture revision are still pending.
`deny` sends the explicit reject key sequence. Record the corresponding
`ApprovalGranted`/`ApprovalDenied` event only after the herdr action is
acknowledged.
4. The coordinator cannot directly control a workpc-owned Unix herdr socket.
Extend the federation worker protocol with a durable, worker-pulled
control-command stream (or command queue):
- coordinator queues `grant_approval` / `deny_approval` addressed to a
worker and task;
- worker validates its local current capture revision and sends the key;
- worker posts an acknowledged result or a stale/rejected result;
- the UI polls the final result and removes/enables controls accordingly.
This is required before rendering remote approval buttons as enabled.
A local coordinator-owned session may use the same command contract via a
local implementation, so UI behavior is identical across machines.
5. Render approvals in an accessible modal and task-detail panel: visible
command/diff, target pane, capture timestamp, keyboard focus trapped in
the dialog, `Approve` and `Reject` buttons, a stale-state warning, and no
implicit approval on refresh or Enter outside the focused button.
## UI layout
- **Overview:** summary counters, worker health strip, active/blocked session
cards, and the task board.
- **Task detail:** immutable task instruction, timeline, session telemetry,
live capture, handoff/report evidence, and the contextual action panel.
- **Session capture:** terminal-style, selectable text with an explicit
source label (`recent`, `scrollback`, etc.), refresh time, and truncation
indicator.
- **Approval dialog:** command or edit preview first, then consequence and
worker/pane metadata; approval controls only when `PendingApproval` is
current and actionable.
- **Workers:** capacity/heartbeat table, active lease/session link, and
visible offline/degraded reasons.
## Delivery phases
1. **Contracts and read model** — define Go UI DTOs, add overview/task-detail
endpoints, tests, and structured local session/capture/approval parsing.
2. **React shell** — scaffold Vite/React/TypeScript, embed production build,
implement overview, task detail, workers, polling, loading, and error
states.
3. **Evidence and controls** — artifact viewer, task creation, action
confirmation UX, and disabled-state explanations.
4. **Federated approvals** — worker command queue, stale-revision protection,
actual grant/deny execution, audit events, and UI result handling.
5. **Verification** — Go API tests, TypeScript unit/component tests, Playwright
flows, and a live workpc OpenCode E2E covering: task visible → permission
rendered with exact command → explicit approval → pane advances → action
result and lifecycle update visible.
## Acceptance criteria
- `npm run build`, `npm run lint`, TypeScript checks, Go build/vet/tests, and
UI browser tests pass in CI.
- Every rendered active session has a real source endpoint and a visible
degraded/error state if capture is unavailable.
- Every enabled control reaches a tested backend path; no placeholder buttons.
- A remote approval cannot be granted against a different or stale prompt.
- The approval panel displays the exact pending command or edit preview from
the current pane capture.
- The writer/reviewer workflow can be monitored from task creation through
completion without SSH or raw JSON-RPC inspection.
+79
View File
@@ -234,6 +234,78 @@ func (w *worker) releaseReady(ctx context.Context) {
}
}
// publishCaptures makes remote panes observable without allowing the
// coordinator to touch their unix herdr socket.
func (w *worker) publishCaptures(ctx context.Context) {
for taskID, session := range w.sessions {
text, err := (herdr.CLIAdapter{Client: w.herdr, Harness: w.harness}).PaneCapture(ctx, session, "recent")
if err != nil {
continue
}
if _, err := w.api.PublishCapture(ctx, federation.Capture{TaskID: taskID, PaneID: session.PaneID, Text: text}); err != nil {
log.Printf("publish capture %s: %v", taskID, err)
}
}
}
func approvalResponse(text, kind string) (string, bool) {
low := strings.ToLower(text)
// Never invent a keystroke. y/n prompts label both decisions directly.
if strings.Contains(low, "[y/n]") || strings.Contains(low, "(y/n)") {
if kind == "grant_approval" {
return "y\n", true
}
return "n\n", true
}
// OpenCode's explicit selector states "Allow once Allow always Reject"
// and "enter confirm". Enter is consequently a bounded one-time grant;
// rejection would require unobservable selector navigation, so refuse it.
if kind == "grant_approval" && strings.Contains(low, "allow once") && strings.Contains(low, "allow always") && strings.Contains(low, "reject") && strings.Contains(low, "enter confirm") {
return "\n", true
}
return "", false
}
func (w *worker) runCommands(ctx context.Context) {
commands, err := w.api.Commands(ctx)
if err != nil {
log.Printf("poll controls: %v", err)
return
}
for _, command := range commands {
session, ok := w.sessions[command.TaskID]
if !ok || session.PaneID != command.PaneID {
_ = w.api.ResolveCommand(ctx, command.ID, "stale", "session or pane changed")
continue
}
text, err := (herdr.CLIAdapter{Client: w.herdr, Harness: w.harness}).PaneCapture(ctx, session, "recent")
if err != nil {
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "capture unavailable: "+err.Error())
continue
}
capture, err := w.api.PublishCapture(ctx, federation.Capture{TaskID: command.TaskID, PaneID: session.PaneID, Text: text})
if err != nil {
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "cannot publish capture: "+err.Error())
continue
}
if capture.Revision != command.CaptureRevision {
_ = w.api.ResolveCommand(ctx, command.ID, "stale", "capture revision changed")
continue
}
input, ok := approvalResponse(text, command.Kind)
if !ok {
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "prompt does not expose an executable approval control")
continue
}
if err := w.herdr.Call(ctx, "pane.send_text", map[string]any{"pane_id": session.PaneID, "text": input}, nil); err != nil {
_ = w.api.ResolveCommand(ctx, command.ID, "rejected", "herdr did not acknowledge input: "+err.Error())
continue
}
if err := w.api.ResolveCommand(ctx, command.ID, "acknowledged", ""); err != nil {
log.Printf("ack command %s: %v", command.ID, err)
}
}
}
func (w *worker) once(ctx context.Context) error {
es, _, err := w.api.Events(ctx, w.cursor)
if err != nil {
@@ -297,6 +369,13 @@ func (w *worker) once(ctx context.Context) error {
}
}
}
// Unit/replay-only workers intentionally have no herdr connection. A
// production worker always does, and only then participates in the live
// capture/control protocol.
if w.herdr != nil {
w.publishCaptures(ctx)
w.runCommands(ctx)
}
w.releaseReady(ctx)
if err := w.save(); err != nil {
return err
+85
View File
@@ -15,6 +15,7 @@ import (
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestWorkerReregistersAfterCoordinatorForgetsIt(t *testing.T) {
@@ -172,3 +173,87 @@ func TestWorkerStartsRouterIssuedLeaseInLocalGitWorktree(t *testing.T) {
t.Fatalf("TASK.md: %v", err)
}
}
func TestWorkerApprovalCommandIsRevisionBoundAndAcknowledged(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
sent := make(chan string, 1)
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
var request herdr.Request
if json.NewDecoder(c).Decode(&request) != nil {
return
}
result := `{}`
switch request.Method {
case "pane.read":
result = `{"read":{"text":"Permission required\n$ git status\nProceed? [y/n]"}}`
case "pane.send_text":
var p struct {
Text string `json:"text"`
}
_ = json.Unmarshal(mustJSON(request.Params), &p)
sent <- p.Text
}
_ = json.NewEncoder(c).Encode(herdr.Response{ID: request.ID, Result: json.RawMessage(result)})
}()
}
}()
resolved := make(chan map[string]string, 1)
api := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/federation/commands":
_ = json.NewEncoder(rw).Encode([]federation.Command{{ID: "c1", TaskID: "task", Kind: "grant_approval", PaneID: "pane", CaptureRevision: 7}})
case r.Method == http.MethodPost && r.URL.Path == "/v1/federation/workers/h/captures":
_ = json.NewEncoder(rw).Encode(federation.Capture{TaskID: "task", PaneID: "pane", Revision: 7})
case r.Method == http.MethodPost && r.URL.Path == "/v1/federation/commands/c1":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
resolved <- body
rw.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
rw.WriteHeader(http.StatusNotFound)
}
}))
defer api.Close()
w := &worker{api: federation.Client{BaseURL: api.URL, WorkerID: "h", Token: "t"}, herdr: herdr.New(ln.Addr().String()), harness: "opencode", sessions: map[string]herdr.Session{"task": {PaneID: "pane"}}}
w.runCommands(context.Background())
select {
case got := <-sent:
if got != "y\n" {
t.Fatalf("approval input=%q", got)
}
case <-time.After(time.Second):
t.Fatal("worker did not send approval")
}
select {
case got := <-resolved:
if got["status"] != "acknowledged" {
t.Fatalf("resolution=%v", got)
}
case <-time.After(time.Second):
t.Fatal("worker did not resolve command")
}
}
func TestApprovalResponseOpenCodeAllowOnce(t *testing.T) {
text := "Permission required\nAllow once Allow always Reject\n⇆ select enter confirm"
if got, ok := approvalResponse(text, "grant_approval"); !ok || got != "\n" {
t.Fatalf("grant response = %q, %v", got, ok)
}
if got, ok := approvalResponse(text, "deny_approval"); ok || got != "" {
t.Fatalf("deny response = %q, %v; reject must not guess selector navigation", got, ok)
}
}
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
# Keep the existing coordinator's secrets/config in the host-owned env file;
# Docker never needs to read or copy it. The process runs as the same numeric
# service user and can therefore read the bind-mounted file.
if [ -r /etc/orchestra/orchestra.env ]; then
set -a
. /etc/orchestra/orchestra.env
set +a
fi
export ORCHESTRA_DATA=/data
export ORCHESTRA_PORT=9145
exec /app/orchestra
+28
View File
@@ -181,3 +181,31 @@ func (c Client) Complete(ctx context.Context, taskID, reportRef string) error {
}
return err
}
func (c Client) PublishCapture(ctx context.Context, capture Capture) (Capture, error) {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/captures", capture)
if err != nil {
return Capture{}, err
}
defer resp.Body.Close()
var out Capture
err = json.NewDecoder(resp.Body).Decode(&out)
return out, err
}
func (c Client) Commands(ctx context.Context) ([]Command, error) {
resp, err := c.request(ctx, http.MethodGet, "/v1/federation/commands", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out []Command
err = json.NewDecoder(resp.Body).Decode(&out)
return out, err
}
func (c Client) ResolveCommand(ctx context.Context, id, status, message string) error {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/commands/"+url.PathEscape(id), map[string]string{"status": status, "message": message})
if resp != nil {
resp.Body.Close()
}
return err
}
+119
View File
@@ -1,7 +1,9 @@
package federation
import (
"crypto/sha256"
"errors"
"fmt"
"sync"
"time"
)
@@ -18,6 +20,26 @@ type Worker struct {
Token string `json:"-"`
}
// Capture is published by a worker that owns the pane. The coordinator never
// reads a remote herdr socket; this is the worker-pulled counterpart.
type Capture struct {
TaskID string `json:"task_id"`
PaneID string `json:"pane_id"`
Text string `json:"text"`
Revision uint64 `json:"revision"`
At time.Time `json:"at"`
}
type Command struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Kind string `json:"kind"`
PaneID string `json:"pane_id"`
CaptureRevision uint64 `json:"capture_revision"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type Registry struct {
mu sync.Mutex
// AdmitToken, if set, is a pre-shared secret every registration must
@@ -29,6 +51,8 @@ type Registry struct {
TTL time.Duration
OnOffline func(Worker)
cursors map[string]uint64
captures map[string]Capture // worker/task
commands map[string][]Command
}
func (r *Registry) init() {
@@ -41,6 +65,101 @@ func (r *Registry) init() {
if r.cursors == nil {
r.cursors = map[string]uint64{}
}
if r.captures == nil {
r.captures = map[string]Capture{}
}
if r.commands == nil {
r.commands = map[string][]Command{}
}
}
func captureKey(worker, task string) string { return worker + "\x00" + task }
func (r *Registry) PutCapture(worker string, c Capture) (Capture, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
if _, ok := r.workers[worker]; !ok {
return Capture{}, ErrUnknownWorker
}
if c.TaskID == "" || c.PaneID == "" {
return Capture{}, errors.New("task_id and pane_id required")
}
k := captureKey(worker, c.TaskID)
old := r.captures[k]
if old.Text != c.Text || old.PaneID != c.PaneID {
c.Revision = old.Revision + 1
}
if c.Revision == 0 {
c.Revision = 1
}
c.At = time.Now().UTC()
r.captures[k] = c
return c, nil
}
func (r *Registry) Capture(worker, task string) (Capture, bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
c, ok := r.captures[captureKey(worker, task)]
return c, ok
}
func (r *Registry) Queue(worker string, c Command) (Command, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
if _, ok := r.workers[worker]; !ok {
return Command{}, ErrUnknownWorker
}
if c.TaskID == "" || c.PaneID == "" || c.CaptureRevision == 0 || (c.Kind != "grant_approval" && c.Kind != "deny_approval") {
return Command{}, errors.New("invalid control command")
}
c.ID = fmt.Sprintf("cmd-%x", sha256.Sum256([]byte(fmt.Sprintf("%s/%s/%s/%d/%d", worker, c.TaskID, c.Kind, c.CaptureRevision, time.Now().UnixNano()))))[:20]
c.CreatedAt = time.Now().UTC()
c.Status = "pending"
r.commands[worker] = append(r.commands[worker], c)
return c, nil
}
func (r *Registry) Commands(worker string) ([]Command, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
if _, ok := r.workers[worker]; !ok {
return nil, ErrUnknownWorker
}
var out []Command
for _, c := range r.commands[worker] {
if c.Status == "pending" {
out = append(out, c)
}
}
return out, nil
}
func (r *Registry) Command(worker, id string) (Command, bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
for _, c := range r.commands[worker] {
if c.ID == id {
return c, true
}
}
return Command{}, false
}
func (r *Registry) CompleteCommand(worker, id, status, message string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
for i := range r.commands[worker] {
if r.commands[worker][i].ID == id {
if r.commands[worker][i].Status != "pending" {
return errors.New("command already resolved")
}
r.commands[worker][i].Status = status
r.commands[worker][i].Error = message
return nil
}
}
return errors.New("command not found")
}
// Register admits a worker. admitToken must match r.AdmitToken whenever one
+30
View File
@@ -75,3 +75,33 @@ func TestOfflineHookRunsOnceOnTransition(t *testing.T) {
case <-time.After(10 * time.Millisecond):
}
}
func TestCaptureRevisionAndCommandQueue(t *testing.T) {
r := &Registry{}
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
t.Fatal(err)
}
c, err := r.PutCapture("w", Capture{TaskID: "task", PaneID: "pane", Text: "Permission required\n$ ls"})
if err != nil || c.Revision != 1 {
t.Fatalf("capture=%#v err=%v", c, err)
}
again, err := r.PutCapture("w", Capture{TaskID: "task", PaneID: "pane", Text: c.Text})
if err != nil || again.Revision != 1 {
t.Fatalf("same capture=%#v err=%v", again, err)
}
cmd, err := r.Queue("w", Command{TaskID: "task", Kind: "grant_approval", PaneID: "pane", CaptureRevision: 1})
if err != nil {
t.Fatal(err)
}
commands, err := r.Commands("w")
if err != nil || len(commands) != 1 || commands[0].ID != cmd.ID {
t.Fatalf("commands=%#v err=%v", commands, err)
}
if err := r.CompleteCommand("w", cmd.ID, "acknowledged", ""); err != nil {
t.Fatal(err)
}
commands, _ = r.Commands("w")
if len(commands) != 0 {
t.Fatalf("pending=%#v", commands)
}
}
+29
View File
@@ -66,6 +66,13 @@ type AgentBlocker interface {
type PaneCapture interface {
PaneCapture(context.Context, Session, string) (string, error)
}
// ApprovalResponder executes an explicitly displayed permission decision.
// Implementations must re-read the pane before sending input so callers can
// bind a decision to the exact capture they rendered.
type ApprovalResponder interface {
RespondApproval(context.Context, Session, bool, string) error
}
type CLIAdapter struct {
Client *Client
Harness string
@@ -595,6 +602,28 @@ func (a CLIAdapter) PaneCapture(ctx context.Context, s Session, source string) (
return r.Read.Text, nil
}
// RespondApproval only acts on harness prompts that visibly expose a y/n
// choice. This deliberately refuses unknown dialog layouts rather than
// guessing an Enter key could mean approval.
func (a CLIAdapter) RespondApproval(ctx context.Context, s Session, grant bool, expectedCapture string) error {
current, err := a.PaneCapture(ctx, s, "recent")
if err != nil {
return err
}
if current != expectedCapture {
return fmt.Errorf("approval prompt changed")
}
low := strings.ToLower(current)
if !strings.Contains(low, "[y/n]") && !strings.Contains(low, "(y/n)") {
return fmt.Errorf("approval prompt has no unambiguous y/n confirmation")
}
input := "n\n"
if grant {
input = "y\n"
}
return a.Client.Call(ctx, "pane.send_text", map[string]any{"pane_id": s.PaneID, "text": input}, nil)
}
func statusFromAgentResult(v any) string {
if m, ok := v.(map[string]any); ok {
for _, key := range []string{"status", "agent_status", "state"} {
+55
View File
@@ -995,6 +995,61 @@ func (c *Coordinator) Session(taskID string) (herdr.Session, bool) {
return s, ok
}
// RequestHandoff asks the live harness to prepare its agent-authored handoff.
// It deliberately does not release the pane: a later validated handoff is the
// only evidence that can make a rotation safe.
func (c *Coordinator) RequestHandoff(ctx context.Context, taskID string) error {
c.loadSessions()
c.mu.Lock()
s, ok := c.sessions[taskID]
c.mu.Unlock()
if !ok {
return fmt.Errorf("session not found for task %s", taskID)
}
if s.HandoffRequested {
return nil
}
a, err := c.adapterFor(taskID, s)
if err != nil {
return err
}
req, ok := a.(herdr.HandoffRequester)
if !ok {
return fmt.Errorf("harness does not support handoff requests")
}
if err := req.RequestHandoff(ctx, s); err != nil {
return err
}
s.HandoffRequested = true
c.mu.Lock()
c.sessions[taskID] = s
err = c.saveSessionsLocked()
c.mu.Unlock()
return err
}
// RespondApproval is the local implementation of the same guarded command
// contract used by federation workers. It rechecks the displayed capture at
// the owning herdr immediately before input is sent.
func (c *Coordinator) RespondApproval(ctx context.Context, taskID string, grant bool, expectedCapture string) error {
c.loadSessions()
c.mu.Lock()
s, ok := c.sessions[taskID]
c.mu.Unlock()
if !ok {
return fmt.Errorf("session not found for task %s", taskID)
}
a, err := c.adapterFor(taskID, s)
if err != nil {
return err
}
responder, ok := a.(herdr.ApprovalResponder)
if !ok {
return fmt.Errorf("harness does not support approval responses")
}
return responder.RespondApproval(ctx, s, grant, expectedCapture)
}
func (c *Coordinator) Capture(ctx context.Context, taskID, source string) (string, error) {
s, ok := c.Session(taskID)
if !ok {
+378
View File
@@ -0,0 +1,378 @@
// Package ui provides the browser-oriented Orchestra read model and controls.
package ui
import (
"context"
"encoding/json"
"fmt"
"net/http"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/federation"
"orchestra/internal/orchestrator"
"orchestra/internal/store"
"sort"
"strings"
"time"
)
type PendingApproval struct {
Kind string `json:"kind"`
Summary string `json:"summary"`
Command string `json:"command,omitempty"`
Diff string `json:"diff,omitempty"`
PaneID string `json:"pane_id"`
CaptureRevision uint64 `json:"capture_revision"`
DetectedAt time.Time `json:"detected_at"`
}
type Capture struct {
TaskID string `json:"task_id"`
Source string `json:"source"`
Text string `json:"text"`
Revision uint64 `json:"revision"`
At time.Time `json:"at"`
Truncated bool `json:"truncated"`
}
type Action struct {
ID string `json:"id"`
Enabled bool `json:"enabled"`
Reason string `json:"reason,omitempty"`
Needs []string `json:"needs,omitempty"`
}
type Session struct {
PaneID string `json:"pane_id,omitempty"`
HarnessID string `json:"harness_id,omitempty"`
AgentStatus string `json:"agent_status,omitempty"`
Blocker string `json:"blocker,omitempty"`
LeaseUntil *time.Time `json:"lease_until,omitempty"`
Capture *Capture `json:"capture,omitempty"`
Approval *PendingApproval `json:"pending_approval,omitempty"`
}
type TaskDetail struct {
Task domain.Task `json:"task"`
Events []domain.Event `json:"events"`
Session *Session `json:"session,omitempty"`
HandoffRef string `json:"handoff_ref,omitempty"`
ReportRef string `json:"report_ref,omitempty"`
Actions []Action `json:"actions"`
}
type Overview struct {
Tasks []domain.Task `json:"tasks"`
Workers []federation.Worker `json:"workers"`
Sessions []Session `json:"sessions"`
UpdatedAt time.Time `json:"updated_at"`
}
type Server struct {
Store *store.Store
Workers *federation.Registry
Coordinator *orchestrator.Coordinator
Route func(domain.Event) error
}
func (s Server) capture(ctx context.Context, t domain.Task) (*Capture, error) {
if t.State != domain.StateLeased || t.Lease == nil {
return nil, nil
}
if s.Coordinator != nil {
text, err := s.Coordinator.Capture(ctx, t.ID, "recent")
if err == nil {
return &Capture{TaskID: t.ID, Source: "recent", Text: text, Revision: uint64(time.Now().UnixNano()), At: time.Now().UTC()}, nil
}
}
if s.Workers != nil {
if c, ok := s.Workers.Capture(t.Lease.HarnessID, t.ID); ok {
return &Capture{TaskID: t.ID, Source: "recent", Text: c.Text, Revision: c.Revision, At: c.At}, nil
}
}
return nil, fmt.Errorf("capture unavailable from owning worker")
}
func ParsePendingApproval(text, pane string, revision uint64, at time.Time) *PendingApproval {
lines := strings.Split(text, "\n")
low := strings.ToLower(text)
if !strings.Contains(low, "permission required") && !strings.Contains(low, "approval required") && !strings.Contains(low, "allow this") {
return nil
}
p := &PendingApproval{Kind: "unknown", Summary: "Harness permission prompt needs review", PaneID: pane, CaptureRevision: revision, DetectedAt: at}
// OpenCode renders this exact three-choice selector and documents that
// Enter confirms the initially selected "Allow once" action. It is not a
// y/n prompt, so model it separately: grant is safe and bounded to once;
// reject remains unavailable because the selected position is not exposed
// in pane capture and Orchestra must not guess navigation keystrokes.
if strings.Contains(low, "allow once") && strings.Contains(low, "allow always") && strings.Contains(low, "reject") && strings.Contains(low, "enter confirm") {
p.Kind = "opencode_once"
p.Summary = "Allow this command once"
}
for _, l := range lines {
l = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "┃"))
if strings.HasPrefix(l, "$ ") {
if p.Kind == "unknown" {
p.Kind = "shell"
}
p.Command = strings.TrimSpace(strings.TrimPrefix(l, "$ "))
if p.Kind == "shell" {
p.Summary = "Run shell command"
}
return p
}
}
// Edits are intentionally only actionable when the harness gave an explicit diff.
if i := strings.Index(text, "diff --git "); i >= 0 {
p.Kind = "edit"
p.Diff = text[i:]
p.Summary = "Apply proposed edit"
}
return p
}
func (s Server) detail(ctx context.Context, id string) (TaskDetail, error) {
t, ok := s.Store.Task(id)
if !ok {
return TaskDetail{}, domain.ErrNotFound
}
d := TaskDetail{Task: t, Actions: actions(t)}
for _, e := range s.Store.Events(0) {
if e.TaskID != id {
continue
}
d.Events = append(d.Events, e)
var p struct {
HandoffRef string `json:"handoff_ref"`
ReportRef string `json:"report_ref"`
}
_ = json.Unmarshal(e.Payload, &p)
if p.HandoffRef != "" {
d.HandoffRef = p.HandoffRef
}
if p.ReportRef != "" {
d.ReportRef = p.ReportRef
}
}
sort.Slice(d.Events, func(i, j int) bool { return d.Events[i].Seq < d.Events[j].Seq })
if t.Lease != nil {
session := &Session{HarnessID: t.Lease.HarnessID, LeaseUntil: &t.Lease.Until}
if c, err := s.capture(ctx, t); err == nil && c != nil {
session.Capture = c
session.PaneID = capturePane(s, t.ID, c)
session.Approval = ParsePendingApproval(c.Text, session.PaneID, c.Revision, c.At)
} else if err != nil {
session.Blocker = "capture unavailable: " + err.Error()
}
d.Session = session
}
return d, nil
}
func capturePane(s Server, taskID string, c *Capture) string {
if s.Coordinator != nil {
if session, ok := s.Coordinator.Session(taskID); ok {
return session.PaneID
}
}
if s.Workers != nil {
for _, w := range s.Workers.Snapshot() {
if remote, ok := s.Workers.Capture(w.ID, taskID); ok && remote.Revision == c.Revision {
return remote.PaneID
}
}
}
return ""
}
func actions(t domain.Task) []Action {
active := t.State == domain.StateLeased
return []Action{{ID: "handoff", Enabled: active, Reason: "requires a live leased session"}, {ID: "release", Enabled: active, Needs: []string{"reason or handoff_ref"}}, {ID: "block", Enabled: active, Needs: []string{"blocker"}}, {ID: "complete", Enabled: active, Needs: []string{"report_ref", "receipt"}}}
}
func (s Server) Overview(ctx context.Context) Overview {
out := Overview{Tasks: s.Store.Tasks(), UpdatedAt: time.Now().UTC()}
if s.Workers != nil {
out.Workers = s.Workers.Snapshot()
}
for _, t := range out.Tasks {
if t.Lease != nil {
d, _ := s.detail(ctx, t.ID)
if d.Session != nil {
out.Sessions = append(out.Sessions, *d.Session)
}
}
}
return out
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func (s Server) Handler() http.Handler {
m := http.NewServeMux()
m.HandleFunc("/v1/ui/overview", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", 405)
return
}
writeJSON(w, s.Overview(r.Context()))
})
m.HandleFunc("/v1/ui/tasks", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var p map[string]any
if json.NewDecoder(r.Body).Decode(&p) != nil {
http.Error(w, "invalid json", 400)
return
}
b, _ := json.Marshal(p)
e := domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b, Surface: string(authz.Web)}
if err := s.Store.Append(e); err != nil {
http.Error(w, err.Error(), 400)
return
}
if s.Route != nil {
_ = s.Route(e)
}
writeJSON(w, e)
})
m.HandleFunc("/v1/ui/tasks/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/v1/ui/tasks/")
p := strings.Split(rest, "/")
if len(p) == 1 && r.Method == http.MethodGet {
d, err := s.detail(r.Context(), p[0])
if err != nil {
http.Error(w, "task not found", 404)
return
}
writeJSON(w, d)
return
}
if len(p) == 2 && p[1] == "capture" && r.Method == http.MethodGet {
t, ok := s.Store.Task(p[0])
if !ok {
http.Error(w, "task not found", 404)
return
}
c, err := s.capture(r.Context(), t)
if err != nil || c == nil {
http.Error(w, fmt.Sprintf("capture unavailable: %v", err), 503)
return
}
writeJSON(w, c)
return
}
if len(p) == 3 && p[1] == "actions" && r.Method == http.MethodPost {
s.action(w, r, p[0], p[2])
return
}
http.Error(w, "not found", 404)
})
m.HandleFunc("/v1/ui/artifacts/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", 405)
return
}
ref := strings.TrimPrefix(r.URL.Path, "/v1/ui/artifacts/")
if len(ref) != 64 {
http.Error(w, "artifact ref required", 400)
return
}
b, err := s.Store.Artifact(ref)
if err != nil {
http.Error(w, "artifact not found", 404)
return
}
trim := strings.TrimSpace(string(b))
if json.Valid(b) {
w.Header().Set("Content-Type", "application/json")
} else if strings.HasPrefix(trim, "#") || strings.Contains(trim, "\n#") {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
} else {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
_, _ = w.Write(b)
})
return m
}
func (s Server) action(w http.ResponseWriter, r *http.Request, id, action string) {
t, ok := s.Store.Task(id)
if !ok {
http.Error(w, "task not found", 404)
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
var typ string
switch action {
case "grant_approval", "deny_approval":
c, err := s.capture(r.Context(), t)
if err != nil || c == nil {
http.Error(w, "current capture unavailable", 503)
return
}
pane := capturePane(s, id, c)
approval := ParsePendingApproval(c.Text, pane, c.Revision, c.At)
if approval == nil || approval.Kind == "unknown" || pane == "" {
http.Error(w, "current prompt is not safely actionable", 409)
return
}
if s.Workers != nil {
if _, remote := s.Workers.Capture(t.Lease.HarnessID, t.ID); remote {
command, err := s.Workers.Queue(t.Lease.HarnessID, federation.Command{TaskID: id, Kind: action, PaneID: pane, CaptureRevision: c.Revision})
if err != nil {
http.Error(w, err.Error(), 409)
return
}
writeJSON(w, map[string]any{"command": command, "task": t})
return
}
}
if s.Coordinator == nil {
http.Error(w, "approval executor unavailable", 503)
return
}
if err := s.Coordinator.RespondApproval(r.Context(), id, action == "grant_approval", c.Text); err != nil {
http.Error(w, err.Error(), 409)
return
}
typ := "ApprovalGranted"
if action == "deny_approval" {
typ = "ApprovalDenied"
}
payload, _ := json.Marshal(map[string]any{"subject_ref": id, "pane_id": pane, "capture_revision": c.Revision})
e := domain.Event{ID: domain.NewID(), Type: typ, TaskID: id, Version: t.Version + 1, Payload: payload, Surface: string(authz.Web)}
if err := s.Store.Append(e); err != nil {
http.Error(w, err.Error(), 409)
return
}
d, _ := s.detail(r.Context(), id)
writeJSON(w, d)
return
case "handoff":
if s.Coordinator == nil {
http.Error(w, "live coordinator unavailable", 503)
return
}
if err := s.Coordinator.RequestHandoff(r.Context(), id); err != nil {
http.Error(w, err.Error(), 409)
return
}
d, _ := s.detail(r.Context(), id)
writeJSON(w, d)
return
case "release":
typ = "TaskReleased"
case "block":
typ = "TaskBlocked"
case "complete":
typ = "TaskCompleted"
default:
http.Error(w, "unknown action", 404)
return
}
b, _ := json.Marshal(body)
e := domain.Event{ID: domain.NewID(), Type: typ, TaskID: id, Version: t.Version + 1, Payload: b, Surface: string(authz.Web)}
if err := s.Store.Append(e); err != nil {
http.Error(w, err.Error(), 409)
return
}
if s.Route != nil {
_ = s.Route(e)
}
d, _ := s.detail(r.Context(), id)
writeJSON(w, d)
}
+45
View File
@@ -0,0 +1,45 @@
package ui
import (
"bytes"
"net/http"
"net/http/httptest"
"orchestra/internal/store"
"strings"
"testing"
"time"
)
func TestOverviewAndTaskDetailHTTP(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
h := Server{Store: s}.Handler()
req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"one","project":"demo","title":"UI task"}`))
r := httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK {
t.Fatalf("create status=%d body=%s", r.Code, r.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/v1/ui/overview", nil)
r = httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK || !strings.Contains(r.Body.String(), "UI task") {
t.Fatalf("overview status=%d body=%s", r.Code, r.Body.String())
}
}
func TestParsePendingApprovalPreservesExactShellCommand(t *testing.T) {
text := "Permission required\n$ git status --short && go test ./..."
p := ParsePendingApproval(text, "p1", 7, time.Unix(1, 0))
if p == nil || p.Kind != "shell" || p.Command != "git status --short && go test ./..." {
t.Fatalf("got %#v", p)
}
}
func TestParsePendingApprovalDoesNotInventActionablePrompt(t *testing.T) {
p := ParsePendingApproval("Approval required: choose an option", "p1", 7, time.Now())
if p == nil || p.Kind != "unknown" || !strings.Contains(p.Summary, "review") {
t.Fatalf("got %#v", p)
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
:root{font:16px system-ui;color:#e7edf3;background:#111827}body{max-width:1200px;margin:auto;padding:1.5rem}a{color:#8dd5ff}header{display:flex;gap:2rem;align-items:center}.board{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}.board section{background:#1f2937;border-radius:8px;padding:.7rem;min-height:12rem}.card{display:block;color:inherit;background:#374151;padding:.6rem;margin:.5rem 0;border-radius:5px;text-decoration:none}.card small{display:block;color:#b9c3d0}input,textarea,button{padding:.55rem;margin:.25rem}textarea{min-height:5rem}.create{display:grid;max-width:38rem;margin-top:2rem}pre{background:#030712;padding:1rem;overflow:auto;white-space:pre-wrap}.approval{border:2px solid #fbbf24;background:#422006;padding:1rem;border-radius:8px}table{border-collapse:collapse}td,th{padding:.5rem;border:1px solid #4b5563}@media(max-width:800px){.board{grid-template-columns:1fr 1fr}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
:root{font:16px system-ui;color:#e7edf3;background:#111827}body{max-width:1200px;margin:auto;padding:1.5rem}a{color:#8dd5ff}header{display:flex;gap:2rem;align-items:center}.board{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}.board section{background:#1f2937;border-radius:8px;padding:.7rem;min-height:12rem}.card{display:block;color:inherit;background:#374151;padding:.6rem;margin:.5rem 0;border-radius:5px;text-decoration:none}.card small{display:block;color:#b9c3d0}input,textarea,button{padding:.55rem;margin:.25rem}textarea{min-height:5rem}.create{display:grid;max-width:38rem;margin-top:2rem}pre{background:#030712;padding:1rem;overflow:auto;white-space:pre-wrap}.approval{border:2px solid #fbbf24;background:#422006;padding:1rem;border-radius:8px}table{border-collapse:collapse}td,th{padding:.5rem;border:1px solid #4b5563}@media(max-width:800px){.board{grid-template-columns:1fr 1fr}}.actions{display:grid;gap:.75rem;max-width:42rem}.actions form{display:flex;flex-wrap:wrap;align-items:center}.actions textarea{flex:1;min-width:16rem}.approval-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#000a;display:grid;place-items:center;padding:1rem;z-index:10}.approval{max-width:50rem;max-height:90vh;overflow:auto}details textarea{width:100%}
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
<script type="module" crossorigin src="/assets/index-hnZ7xNV9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hcvlnkHy.css">
<div id="root"></div>
+37
View File
@@ -0,0 +1,37 @@
// Package webui embeds the compiled browser application in the Orchestra binary.
package webui
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed assets/* assets/assets/*
var files embed.FS
// Handler serves immutable fingerprinted assets and falls back to the SPA for
// browser routes. API routes are deliberately not handled here.
func Handler() http.Handler {
root, _ := fs.Sub(files, "assets")
static := http.FileServer(http.FS(root))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if p != "" && p != "." {
if _, err := fs.Stat(root, p); err == nil {
if strings.Contains(p, "/assets/") || strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
static.ServeHTTP(w, r)
return
}
}
w.Header().Set("Cache-Control", "no-cache")
b, err := fs.ReadFile(root, "index.html")
if err != nil { http.Error(w, "web UI unavailable", http.StatusInternalServerError); return }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
})
}
+11
View File
@@ -0,0 +1,11 @@
FROM node:24-alpine AS build
WORKDIR /src
COPY package*.json ./
RUN npm ci --no-audit --no-fund
COPY . ./
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /src/dist/ /usr/share/nginx/html/
EXPOSE 8080
+2
View File
@@ -0,0 +1,2 @@
import js from '@eslint/js'
export default [js.configs.recommended, { files:['src/**/*.{ts,tsx}'], ignores:['dist/**'], rules:{'no-undef':'off'} }]
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /v1/ {
proxy_pass ${ORCHESTRA_API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /metrics { proxy_pass ${ORCHESTRA_API_UPSTREAM}; }
location /healthz { proxy_pass ${ORCHESTRA_API_UPSTREAM}; }
location /readyz { proxy_pass ${ORCHESTRA_API_UPSTREAM}; }
location / { try_files $uri $uri/ /index.html; }
}
+3450
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
{
"name": "orchestra-web",
"private": true,
"type": "module",
"version": "0.1.0",
"scripts": {"dev":"vite", "build":"tsc -b && vite build", "lint":"tsc -b", "test":"vitest run"},
"dependencies": {"@tanstack/react-query":"^5.66.0", "react":"^19.0.0", "react-dom":"^19.0.0", "react-router-dom":"^7.1.5"},
"devDependencies": {"@eslint/js":"^9.19.0", "@types/react":"^19.0.8", "@types/react-dom":"^19.0.3", "@vitejs/plugin-react":"^4.4.1", "eslint":"^9.19.0", "typescript":"^5.7.3", "vite":"^6.1.0", "vitest":"^3.0.5"}
}
+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
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"]}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins:[react()],
build:{outDir:'dist', emptyOutDir:true},
test:{exclude:['**/node_modules/**','**/.node_modules/**','**/dist/**']},
})