442 lines
15 KiB
Go
442 lines
15 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
|
|
}
|
|
// 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)
|
|
}
|