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:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user