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:
@@ -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