Files
orchestra/internal/ui/ui_test.go
T
kami 92f32d6fea Give the operator a way to resubmit, and baseline progress at launch
Two gaps F33 left behind.

F34: the renewal gate exempts a lease with no baseline, so a pane that opened
and never started got a full free renewal period. That is the exact case the
gate exists to catch, and it happened live at 01:33:37: the stuck run 5 lease
renewed to 22:03 on a pane that had not moved since 01:13. Baseline the
progress hash at launch, where the pane is already being read.

F35: nothing could re-poke a live pane. Orchestra can put text in an editor and
be wrong about whether it landed, and the only recovery was to destroy the
lease and wait out expiry, roughly an hour. The new "resubmit" action presses
Enter on text Orchestra itself submitted. It changes no lifecycle state so it
appends no event, and it is fenced like an approval: a live worker-owned pane
at the capture revision the operator was looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
2026-08-28 01:34:26 +04:00

238 lines
9.1 KiB
Go

package ui
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"orchestra/internal/domain"
"orchestra/internal/federation"
"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)
}
}
// TestFederatedApprovalIsQueuedAtTheWorkerRevisionAndAudited guards B19 and
// B20 together: the queued command must carry the worker's own capture
// counter (not a coordinator timestamp the worker can never match, which
// resolved every such approval "stale"), and queueing must leave an
// ApprovalRequested event behind — the grant/deny is appended later, when
// the worker reports the keystroke was acknowledged.
func TestFederatedApprovalIsQueuedAtTheWorkerRevisionAndAudited(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
workers := &federation.Registry{}
if err := workers.Register(federation.Worker{ID: "wpc-claude", Token: "tok"}, ""); err != nil {
t.Fatal(err)
}
h := Server{Store: s, Workers: workers}.Handler()
req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"fed","project":"demo","title":"Federated"}`))
r := httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK {
t.Fatalf("create status=%d body=%s", r.Code, r.Body.String())
}
id := s.Tasks()[0].ID
if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil {
t.Fatal(err)
}
capture, err := workers.PutCapture("wpc-claude", federation.Capture{TaskID: id, PaneID: "wA:p1", Text: "Permission required\n$ rm -rf build"})
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/grant_approval", bytes.NewBufferString(`{}`))
r = httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK {
t.Fatalf("grant status=%d body=%s", r.Code, r.Body.String())
}
pending, err := workers.Commands("wpc-claude")
if err != nil || len(pending) != 1 {
t.Fatalf("commands=%#v err=%v", pending, err)
}
if pending[0].CaptureRevision != capture.Revision || pending[0].PaneID != "wA:p1" {
t.Fatalf("queued at revision %d pane %q, worker published %d wA:p1", pending[0].CaptureRevision, pending[0].PaneID, capture.Revision)
}
var requested *domain.Event
for _, e := range s.Events(0) {
if e.Type == "ApprovalRequested" && e.TaskID == id {
x := e
requested = &x
}
if e.Type == "ApprovalGranted" {
t.Fatal("queueing a command must not claim an outcome the worker has not reported")
}
}
if requested == nil {
t.Fatal("federated approval left no audit trail")
}
var p struct {
SubjectRef string `json:"subject_ref"`
Decision string `json:"decision"`
}
if err := json.Unmarshal(requested.Payload, &p); err != nil {
t.Fatal(err)
}
if p.SubjectRef != pending[0].ID || p.Decision != "grant_approval" {
t.Fatalf("audit event does not identify the command: %s", requested.Payload)
}
}
// TestCoordinatorCaptureRevisionTracksContentNotTime guards B20: the local
// revision used to be UnixNano, so it changed on every read and told a
// caller nothing about whether the pane had changed.
func TestCoordinatorCaptureRevisionTracksContentNotTime(t *testing.T) {
a := captureRevision("claude", "Permission required\n$ ls")
if a != captureRevision("claude", "Permission required\n$ ls") {
t.Fatal("revision changed without the pane text changing")
}
if a == captureRevision("claude", "Permission required\n$ rm -rf /") {
t.Fatal("revision did not change when the pane text changed")
}
if a == 0 {
t.Fatal("revision 0 means \"no revision\" to federation.Queue")
}
}
// TestOperatorBlockReachesALeasedTask guards the fence the browser could never
// satisfy: the store rejects TaskBlocked on a leased task unless the payload
// carries the live harness_id and lease_epoch, and the UI action handler sent
// neither. Every block/release/complete the UI advertised on a leased task
// returned 409, which left an operator with no lifecycle action at all on the
// tasks that most needed one.
func TestOperatorBlockReachesALeasedTask(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":"blk","project":"demo","title":"Leased"}`))
r := httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK {
t.Fatalf("create status=%d body=%s", r.Code, r.Body.String())
}
id := s.Tasks()[0].ID
if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/block", bytes.NewBufferString(`{"blocker":"parked by the operator"}`))
r = httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK {
t.Fatalf("block status=%d body=%s", r.Code, r.Body.String())
}
task, ok := s.Task(id)
if !ok || task.State != domain.StateBlocked {
t.Fatalf("state=%q ok=%v, want blocked", task.State, ok)
}
if task.BlockReason != domain.BlockReasonOperator {
t.Fatalf("block_reason=%q, want %q", task.BlockReason, domain.BlockReasonOperator)
}
}
// A missing request body must not panic the action handler. Decode leaves the
// map nil, and the block path writes into it.
func TestActionWithoutABodyDoesNotPanic(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":"nobody","project":"demo","title":"No body"}`))
r := httptest.NewRecorder()
h.ServeHTTP(r, req)
id := s.Tasks()[0].ID
req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/block", nil)
r = httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusConflict || !strings.Contains(r.Body.String(), "blocker required") {
t.Fatalf("block status=%d body=%s, want 409 blocker required", r.Code, r.Body.String())
}
}
// TestResubmitQueuesAKeystrokeWithoutTouchingLifecycle covers the gap F33 left
// behind: a pane holding unsent input had no recovery short of destroying the
// lease and waiting out expiry. Resubmit presses Enter on text Orchestra
// already submitted, so it queues a worker command and appends no event.
func TestResubmitQueuesAKeystrokeWithoutTouchingLifecycle(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
workers := &federation.Registry{}
if err := workers.Register(federation.Worker{ID: "wpc-claude", Token: "tok"}, ""); err != nil {
t.Fatal(err)
}
h := Server{Store: s, Workers: workers}.Handler()
req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"stuck","project":"demo","title":"Stuck launch"}`))
r := httptest.NewRecorder()
h.ServeHTTP(r, req)
id := s.Tasks()[0].ID
if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil {
t.Fatal(err)
}
before := len(s.Events(0))
capture, err := workers.PutCapture("wpc-claude", federation.Capture{TaskID: id, PaneID: "wA:p1", Text: "> read .orchestra/launch.md now"})
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/resubmit", bytes.NewBufferString(`{}`))
r = httptest.NewRecorder()
h.ServeHTTP(r, req)
if r.Code != http.StatusOK {
t.Fatalf("resubmit status=%d body=%s", r.Code, r.Body.String())
}
pending, err := workers.Commands("wpc-claude")
if err != nil || len(pending) != 1 {
t.Fatalf("commands=%#v err=%v", pending, err)
}
if pending[0].Kind != "resubmit" || pending[0].PaneID != "wA:p1" || pending[0].CaptureRevision != capture.Revision {
t.Fatalf("queued %#v, want a resubmit bound to wA:p1 at revision %d", pending[0], capture.Revision)
}
if got := len(s.Events(0)); got != before {
t.Fatalf("events %d -> %d: a keystroke must not append a lifecycle event", before, got)
}
task, _ := s.Task(id)
if task.State != domain.StateLeased {
t.Fatalf("state=%q, want the lease untouched", task.State)
}
}