efd0a5e3cd
The store fences TaskReleased, TaskBlocked and TaskCompleted on a leased task against the live harness_id and lease_epoch. The UI action handler sent neither, so all three returned 409 on exactly the tasks the UI listed them as enabled for. Found live: eight block attempts against a stuck run at a stable version, all 409 "task version conflict". The fence is there to reject a stale writer, not the operator. Carry the lease read at the top of the handler. The version CAS on the append still rejects a racing write. Also initialise body when the request carries none. The block path wrote block_reason into a nil map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
190 lines
7.1 KiB
Go
190 lines
7.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())
|
|
}
|
|
}
|