Files
orchestra/internal/ui/ui.go
T
kami 95454afa72 Close B19-B21 and S12-S13, and fix the flaky router test
All five defects filed while implementing B18, plus the router flake that
predated them. None of this has run on the deployed instance: the service
is stopped and /usr/local/bin/orchestra predates every change here.

B20 is the one that could silently defeat approvals. The capture revision
was UnixNano, so it changed on every read and said nothing about whether
the pane had changed; it is now an FNV-1a hash of the pane text, changing
iff the text does. The worse half was precedence: capture() preferred the
coordinator over a published worker capture, handing Queue a timestamp the
owning worker's staleness check could never match, so every federated
approval resolved "stale" and the keystroke never happened. Worker captures
now win — their existence means a registered worker owns that pane — and
capturePane follows the same precedence via Capture.Source rather than
guessing.

B19 was filed as "federated approvals emit no event", which overstated it:
the resolution half already existed, and correctly fires only on an
acknowledged worker report. The missing half was the request. Server.action
now appends ApprovalRequested at queue time, subject_ref set to the command
ID the later resolution carries. If that append fails the queued command is
resolved "rejected" — a keystroke that left no audit trail must not run.

B21 bounds the command list: resolved commands prune after 30 minutes on
both Queue and Commands, pending ones never at any age, since dropping one
would discard an operator decision. The persistence half stays open and is
recorded as such — captures and commands are still in-memory only.

S12 splits ORCHESTRA_NTFY_TOKEN, which was both the secret handed to the
ntfy server and a valid inbound credential for the ntfy surface; the latter
is now ORCHESTRA_NTFY_SURFACE_TOKEN. Breaking: a deployment relying on the
old dual use has no inbound gate until it sets the new variable. S13
deletes the dead auth() copy of the authorization policy.

The router flake was in the test, not in assignment. Store.Tasks() ranges a
map, and the assertion indexed two separate Tasks() calls, failing whenever
the orderings disagreed; instrumenting it showed a valid TaskLeased and a
genuinely leased task on every "failing" run. It now snapshots once and
asserts that exactly one task is leased, and passes at -count=60.

AUDIT.md records what is still not done: the deployed env and binary, the
live re-verification B13-B17 has always lacked, and two operational faults
found in the journal that block it — all six herdrs are refusing
connections, and ntfy delivery is failing 403 on every send.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01535A3Y8RtkAi8wYuWhtkEd
2026-07-29 01:33:59 +04:00

422 lines
14 KiB
Go

// Package ui provides the browser-oriented Orchestra read model and controls.
package ui
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"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
}
// B20: precedence is deliberate, not incidental. A published worker
// capture means a registered worker owns that pane, and its revision is
// the counter the worker's own staleness check compares against — so it
// must win. Preferring the coordinator here (as this once did) handed
// Queue a revision the worker could never match, and every such approval
// resolved "stale" and silently never happened.
if s.Workers != nil {
if c, ok := s.Workers.Capture(t.Lease.HarnessID, t.ID); ok {
return &Capture{TaskID: t.ID, Source: "worker", Text: c.Text, Revision: c.Revision, At: c.At}, 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: captureRevision(t.Lease.HarnessID, text), At: time.Now().UTC()}, 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, 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
}
// captureRevision identifies *what the operator saw*, not when they saw it.
// B20: this was UnixNano, so it changed on every read and said nothing about
// whether the pane had changed. A content hash changes if and only if the
// text does, which is the only property any consumer of a revision wants.
func captureRevision(harness, text string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(harness))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(text))
// 0 means "no revision" to federation.Queue; never collide with it.
if v := h.Sum64(); v != 0 {
return v
}
return 1
}
// capturePane resolves the pane the capture came from, following the same
// source precedence capture() used — asking the coordinator about a pane a
// worker owns (or vice versa) can return a pane the text never came from.
func capturePane(s Server, t domain.Task, c *Capture) string {
if c.Source == "worker" && s.Workers != nil && t.Lease != nil {
if remote, ok := s.Workers.Capture(t.Lease.HarnessID, t.ID); ok && remote.Revision == c.Revision {
return remote.PaneID
}
return ""
}
if s.Coordinator != nil {
if session, ok := s.Coordinator.Session(t.ID); ok {
return session.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, t, 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 c.Source == "worker" {
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
}
// B19: the federated path used to return here, leaving the most
// safety-critical operation in the system invisible in the event
// log exactly when it crossed a machine boundary. The honest
// event at this point is a *request* — the keystroke has only
// been queued. The matching ApprovalGranted/ApprovalDenied is
// appended when the worker reports the input was acknowledged
// (see /v1/federation/commands/ in cmd/orchestra).
payload, _ := json.Marshal(map[string]any{
"subject_ref": command.ID, "options": []string{"grant_approval", "deny_approval"},
"decision": action, "task_id": id, "worker": t.Lease.HarnessID,
"pane_id": pane, "capture_revision": c.Revision,
})
e := domain.Event{ID: domain.NewID(), Type: "ApprovalRequested", TaskID: id, Version: t.Version + 1, Payload: payload, Surface: string(authz.Web)}
if err := s.Store.Append(e); err != nil {
// No audit trail, no approval. Resolve the queued command so
// the worker never executes an unrecorded keystroke.
_ = s.Workers.CompleteCommand(t.Lease.HarnessID, command.ID, "rejected", "audit append failed: "+err.Error())
http.Error(w, "approval not recorded: "+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)
}