// 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 } // Keep collection fields as JSON arrays for browser clients, including // older task records that genuinely have no retained events. d := TaskDetail{Task: t, Events: []domain.Event{}, 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 } else if t.State == domain.StateBlocked { // A blocked task has no active lease by definition, but must retain its // last observed pane evidence instead of rendering an unexplained void. d.Session = &Session{PaneID: t.LastPaneID, HarnessID: t.LastHarness, AgentStatus: t.PaneState, Blocker: t.Blocker} } 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 { // JSON null is not an empty collection to browser clients. In particular, // the shell renders the number of active sessions before any page-level // loading state, so a nil Sessions slice made an otherwise healthy empty // worker pool crash the entire SPA on `sessions.length`. tasks := s.Store.Tasks() if tasks == nil { tasks = []domain.Task{} } out := Overview{Tasks: tasks, Workers: []federation.Worker{}, Sessions: []Session{}, 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 } if action == "block" { // A browser-created block is an explicit operator decision. Preserve // that fact even if its prose happens to contain a system keyword. body["block_reason"] = string(domain.BlockReasonOperator) } 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) }