92f32d6fea
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
238 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|