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
+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)
})
}