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:
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
data
|
||||
web/node_modules
|
||||
web/dist
|
||||
@@ -3,3 +3,12 @@
|
||||
.orchestra-config/
|
||||
clients/
|
||||
/orchestra
|
||||
|
||||
# Web UI build inputs/outputs. node_modules in particular ships vendored Go
|
||||
# packages (e.g. flatted/golang), so leaving it merely untracked is not
|
||||
# enough — `go build ./...` and `go test ./...` walk into it.
|
||||
/package-lock.json
|
||||
node_modules/
|
||||
.node_modules/
|
||||
web/dist/
|
||||
web/tsconfig.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . ./
|
||||
RUN go build -trimpath -ldflags='-s -w' -o /out/orchestra ./cmd/orchestra
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN adduser -D -u 10001 orchestra
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/orchestra /app/orchestra
|
||||
COPY deploy/docker-api-entrypoint.sh /app/docker-api-entrypoint.sh
|
||||
RUN mkdir /data && chown orchestra:orchestra /data
|
||||
ENV ORCHESTRA_DATA=/data ORCHESTRA_PORT=9145
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 9145
|
||||
ENTRYPOINT ["/app/docker-api-entrypoint.sh"]
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# Orchestra Web UI Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Build a proper TypeScript single-page application for local operation of
|
||||
Orchestra. It must make live task/session state observable, make every
|
||||
supported control actionable, and render harness approval requests as the
|
||||
actual pending decision rather than as a vague `blocked` badge. Authentication
|
||||
is deliberately out of scope for this first local-only version; the API
|
||||
contract must still keep a clean boundary where auth can be added later.
|
||||
|
||||
## Product scope
|
||||
|
||||
### Monitoring
|
||||
|
||||
- Task board with queued, leased, blocked, completed, and failed states.
|
||||
- Task detail with description, lifecycle timeline, current worker/harness,
|
||||
lease expiry, handoff/report references, and Git/worktree facts when a
|
||||
session is live.
|
||||
- Worker view: online/offline state, capacity, last heartbeat, active task,
|
||||
and harness identity.
|
||||
- Session view: agent status, occupancy, turn-boundary health, recent pane
|
||||
capture, extracted blocker, and update timestamp.
|
||||
- Artifact view for handoffs and reports, with JSON formatting for canonical
|
||||
handoffs and plain-text/Markdown rendering for reports.
|
||||
|
||||
### Controls
|
||||
|
||||
- Create a task with all currently accepted task fields.
|
||||
- Request a handoff/rotation through the existing task-control path.
|
||||
- Grant or deny a currently displayed harness permission.
|
||||
- Explicitly release, block, or complete a task only where the backend can
|
||||
validate the required evidence.
|
||||
- Refresh and polling controls; no control is rendered merely as decoration.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend
|
||||
|
||||
Create `web/` as a Vite + React + TypeScript application.
|
||||
|
||||
- React Router routes: `/`, `/tasks/:taskID`, `/workers`, and `/artifacts/:ref`.
|
||||
- TanStack Query for polling, cache invalidation after controls, retry/error
|
||||
state, and optimistic UI only after the server returns success.
|
||||
- A small component layer (CSS modules or Tailwind, chosen during setup) with
|
||||
accessible dialogs, keyboard-focus handling, and responsive layouts.
|
||||
- Build output is embedded into the Go binary with `go:embed` and served at
|
||||
`/`; `/v1/*` remains API-only. The Go server serves `index.html` as the
|
||||
SPA fallback and static assets with cache headers.
|
||||
- A typed API client generated from hand-maintained TypeScript interfaces in
|
||||
`web/src/api/types.ts`; those interfaces are exercised against Go JSON
|
||||
response tests so frontend/backend field drift fails CI.
|
||||
|
||||
### Backend API additions
|
||||
|
||||
Keep existing endpoints working. Add a UI-oriented read model rather than
|
||||
forcing the browser to join unrelated raw endpoints itself.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /v1/ui/overview` | Tasks, workers, orchestration health, and active-session summaries in one pollable response. |
|
||||
| `GET /v1/ui/tasks/{id}` | Full task detail, ordered lifecycle events, active session, blocker, artifact refs, and available actions. |
|
||||
| `GET /v1/ui/tasks/{id}/capture?source=recent` | Current pane capture plus revision/timestamp; never silently returns stale text as live. |
|
||||
| `GET /v1/artifacts/{ref}` | Existing artifact fetch, with content type supplied by the UI read model where known. |
|
||||
| `POST /v1/ui/tasks` | Create task; validates the same fields as `POST /v1/tasks`. |
|
||||
| `POST /v1/ui/tasks/{id}/actions/{action}` | Narrow action wrapper for handoff request, approval grant/deny, release, block, and completion; returns the resulting task/session state. |
|
||||
|
||||
The raw lifecycle endpoints stay available for harness/worker use. The UI
|
||||
wrapper is responsible for describing whether an action is currently allowed,
|
||||
why it is unavailable, and what evidence/body it needs.
|
||||
|
||||
## Correct approval design
|
||||
|
||||
The approval UI must reflect the real permission prompt, not the existing
|
||||
event-only `ApprovalGranted`/`ApprovalDenied` records.
|
||||
|
||||
1. Add a structured `PendingApproval` model:
|
||||
|
||||
```ts
|
||||
type PendingApproval = {
|
||||
kind: "shell" | "edit" | "unknown";
|
||||
summary: string;
|
||||
command?: string;
|
||||
diff?: string;
|
||||
paneId: string;
|
||||
captureRevision: number;
|
||||
detectedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
2. Parse the current `pane.read` text worker-side. Preserve the exact shell
|
||||
command or proposed edit/diff shown by the harness. If parsing is
|
||||
uncertain, return `kind: "unknown"` with the raw relevant excerpt and
|
||||
disable the grant button; do not guess.
|
||||
|
||||
3. Make approval a real execution command. `grant` sends the correct
|
||||
`agent.send_keys`/`pane.send_keys` confirmation to the pane only if the
|
||||
same `pane_id`, permission kind, and capture revision are still pending.
|
||||
`deny` sends the explicit reject key sequence. Record the corresponding
|
||||
`ApprovalGranted`/`ApprovalDenied` event only after the herdr action is
|
||||
acknowledged.
|
||||
|
||||
4. The coordinator cannot directly control a workpc-owned Unix herdr socket.
|
||||
Extend the federation worker protocol with a durable, worker-pulled
|
||||
control-command stream (or command queue):
|
||||
|
||||
- coordinator queues `grant_approval` / `deny_approval` addressed to a
|
||||
worker and task;
|
||||
- worker validates its local current capture revision and sends the key;
|
||||
- worker posts an acknowledged result or a stale/rejected result;
|
||||
- the UI polls the final result and removes/enables controls accordingly.
|
||||
|
||||
This is required before rendering remote approval buttons as enabled.
|
||||
A local coordinator-owned session may use the same command contract via a
|
||||
local implementation, so UI behavior is identical across machines.
|
||||
|
||||
5. Render approvals in an accessible modal and task-detail panel: visible
|
||||
command/diff, target pane, capture timestamp, keyboard focus trapped in
|
||||
the dialog, `Approve` and `Reject` buttons, a stale-state warning, and no
|
||||
implicit approval on refresh or Enter outside the focused button.
|
||||
|
||||
## UI layout
|
||||
|
||||
- **Overview:** summary counters, worker health strip, active/blocked session
|
||||
cards, and the task board.
|
||||
- **Task detail:** immutable task instruction, timeline, session telemetry,
|
||||
live capture, handoff/report evidence, and the contextual action panel.
|
||||
- **Session capture:** terminal-style, selectable text with an explicit
|
||||
source label (`recent`, `scrollback`, etc.), refresh time, and truncation
|
||||
indicator.
|
||||
- **Approval dialog:** command or edit preview first, then consequence and
|
||||
worker/pane metadata; approval controls only when `PendingApproval` is
|
||||
current and actionable.
|
||||
- **Workers:** capacity/heartbeat table, active lease/session link, and
|
||||
visible offline/degraded reasons.
|
||||
|
||||
## Delivery phases
|
||||
|
||||
1. **Contracts and read model** — define Go UI DTOs, add overview/task-detail
|
||||
endpoints, tests, and structured local session/capture/approval parsing.
|
||||
2. **React shell** — scaffold Vite/React/TypeScript, embed production build,
|
||||
implement overview, task detail, workers, polling, loading, and error
|
||||
states.
|
||||
3. **Evidence and controls** — artifact viewer, task creation, action
|
||||
confirmation UX, and disabled-state explanations.
|
||||
4. **Federated approvals** — worker command queue, stale-revision protection,
|
||||
actual grant/deny execution, audit events, and UI result handling.
|
||||
5. **Verification** — Go API tests, TypeScript unit/component tests, Playwright
|
||||
flows, and a live workpc OpenCode E2E covering: task visible → permission
|
||||
rendered with exact command → explicit approval → pane advances → action
|
||||
result and lifecycle update visible.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `npm run build`, `npm run lint`, TypeScript checks, Go build/vet/tests, and
|
||||
UI browser tests pass in CI.
|
||||
- Every rendered active session has a real source endpoint and a visible
|
||||
degraded/error state if capture is unavailable.
|
||||
- Every enabled control reaches a tested backend path; no placeholder buttons.
|
||||
- A remote approval cannot be granted against a different or stale prompt.
|
||||
- The approval panel displays the exact pending command or edit preview from
|
||||
the current pane capture.
|
||||
- The writer/reviewer workflow can be monitored from task creation through
|
||||
completion without SSH or raw JSON-RPC inspection.
|
||||
@@ -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 }
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Keep the existing coordinator's secrets/config in the host-owned env file;
|
||||
# Docker never needs to read or copy it. The process runs as the same numeric
|
||||
# service user and can therefore read the bind-mounted file.
|
||||
if [ -r /etc/orchestra/orchestra.env ]; then
|
||||
set -a
|
||||
. /etc/orchestra/orchestra.env
|
||||
set +a
|
||||
fi
|
||||
export ORCHESTRA_DATA=/data
|
||||
export ORCHESTRA_PORT=9145
|
||||
exec /app/orchestra
|
||||
@@ -181,3 +181,31 @@ func (c Client) Complete(ctx context.Context, taskID, reportRef string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c Client) PublishCapture(ctx context.Context, capture Capture) (Capture, error) {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/captures", capture)
|
||||
if err != nil {
|
||||
return Capture{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out Capture
|
||||
err = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return out, err
|
||||
}
|
||||
func (c Client) Commands(ctx context.Context) ([]Command, error) {
|
||||
resp, err := c.request(ctx, http.MethodGet, "/v1/federation/commands", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out []Command
|
||||
err = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return out, err
|
||||
}
|
||||
func (c Client) ResolveCommand(ctx context.Context, id, status, message string) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/commands/"+url.PathEscape(id), map[string]string{"status": status, "message": message})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package federation
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -18,6 +20,26 @@ type Worker struct {
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
// Capture is published by a worker that owns the pane. The coordinator never
|
||||
// reads a remote herdr socket; this is the worker-pulled counterpart.
|
||||
type Capture struct {
|
||||
TaskID string `json:"task_id"`
|
||||
PaneID string `json:"pane_id"`
|
||||
Text string `json:"text"`
|
||||
Revision uint64 `json:"revision"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
type Command struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Kind string `json:"kind"`
|
||||
PaneID string `json:"pane_id"`
|
||||
CaptureRevision uint64 `json:"capture_revision"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
// AdmitToken, if set, is a pre-shared secret every registration must
|
||||
@@ -29,6 +51,8 @@ type Registry struct {
|
||||
TTL time.Duration
|
||||
OnOffline func(Worker)
|
||||
cursors map[string]uint64
|
||||
captures map[string]Capture // worker/task
|
||||
commands map[string][]Command
|
||||
}
|
||||
|
||||
func (r *Registry) init() {
|
||||
@@ -41,6 +65,101 @@ func (r *Registry) init() {
|
||||
if r.cursors == nil {
|
||||
r.cursors = map[string]uint64{}
|
||||
}
|
||||
if r.captures == nil {
|
||||
r.captures = map[string]Capture{}
|
||||
}
|
||||
if r.commands == nil {
|
||||
r.commands = map[string][]Command{}
|
||||
}
|
||||
}
|
||||
|
||||
func captureKey(worker, task string) string { return worker + "\x00" + task }
|
||||
func (r *Registry) PutCapture(worker string, c Capture) (Capture, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
if _, ok := r.workers[worker]; !ok {
|
||||
return Capture{}, ErrUnknownWorker
|
||||
}
|
||||
if c.TaskID == "" || c.PaneID == "" {
|
||||
return Capture{}, errors.New("task_id and pane_id required")
|
||||
}
|
||||
k := captureKey(worker, c.TaskID)
|
||||
old := r.captures[k]
|
||||
if old.Text != c.Text || old.PaneID != c.PaneID {
|
||||
c.Revision = old.Revision + 1
|
||||
}
|
||||
if c.Revision == 0 {
|
||||
c.Revision = 1
|
||||
}
|
||||
c.At = time.Now().UTC()
|
||||
r.captures[k] = c
|
||||
return c, nil
|
||||
}
|
||||
func (r *Registry) Capture(worker, task string) (Capture, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
c, ok := r.captures[captureKey(worker, task)]
|
||||
return c, ok
|
||||
}
|
||||
func (r *Registry) Queue(worker string, c Command) (Command, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
if _, ok := r.workers[worker]; !ok {
|
||||
return Command{}, ErrUnknownWorker
|
||||
}
|
||||
if c.TaskID == "" || c.PaneID == "" || c.CaptureRevision == 0 || (c.Kind != "grant_approval" && c.Kind != "deny_approval") {
|
||||
return Command{}, errors.New("invalid control command")
|
||||
}
|
||||
c.ID = fmt.Sprintf("cmd-%x", sha256.Sum256([]byte(fmt.Sprintf("%s/%s/%s/%d/%d", worker, c.TaskID, c.Kind, c.CaptureRevision, time.Now().UnixNano()))))[:20]
|
||||
c.CreatedAt = time.Now().UTC()
|
||||
c.Status = "pending"
|
||||
r.commands[worker] = append(r.commands[worker], c)
|
||||
return c, nil
|
||||
}
|
||||
func (r *Registry) Commands(worker string) ([]Command, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
if _, ok := r.workers[worker]; !ok {
|
||||
return nil, ErrUnknownWorker
|
||||
}
|
||||
var out []Command
|
||||
for _, c := range r.commands[worker] {
|
||||
if c.Status == "pending" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *Registry) Command(worker, id string) (Command, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
for _, c := range r.commands[worker] {
|
||||
if c.ID == id {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return Command{}, false
|
||||
}
|
||||
func (r *Registry) CompleteCommand(worker, id, status, message string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
for i := range r.commands[worker] {
|
||||
if r.commands[worker][i].ID == id {
|
||||
if r.commands[worker][i].Status != "pending" {
|
||||
return errors.New("command already resolved")
|
||||
}
|
||||
r.commands[worker][i].Status = status
|
||||
r.commands[worker][i].Error = message
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("command not found")
|
||||
}
|
||||
|
||||
// Register admits a worker. admitToken must match r.AdmitToken whenever one
|
||||
|
||||
@@ -75,3 +75,33 @@ func TestOfflineHookRunsOnceOnTransition(t *testing.T) {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRevisionAndCommandQueue(t *testing.T) {
|
||||
r := &Registry{}
|
||||
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := r.PutCapture("w", Capture{TaskID: "task", PaneID: "pane", Text: "Permission required\n$ ls"})
|
||||
if err != nil || c.Revision != 1 {
|
||||
t.Fatalf("capture=%#v err=%v", c, err)
|
||||
}
|
||||
again, err := r.PutCapture("w", Capture{TaskID: "task", PaneID: "pane", Text: c.Text})
|
||||
if err != nil || again.Revision != 1 {
|
||||
t.Fatalf("same capture=%#v err=%v", again, err)
|
||||
}
|
||||
cmd, err := r.Queue("w", Command{TaskID: "task", Kind: "grant_approval", PaneID: "pane", CaptureRevision: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands, err := r.Commands("w")
|
||||
if err != nil || len(commands) != 1 || commands[0].ID != cmd.ID {
|
||||
t.Fatalf("commands=%#v err=%v", commands, err)
|
||||
}
|
||||
if err := r.CompleteCommand("w", cmd.ID, "acknowledged", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
commands, _ = r.Commands("w")
|
||||
if len(commands) != 0 {
|
||||
t.Fatalf("pending=%#v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,13 @@ type AgentBlocker interface {
|
||||
type PaneCapture interface {
|
||||
PaneCapture(context.Context, Session, string) (string, error)
|
||||
}
|
||||
|
||||
// ApprovalResponder executes an explicitly displayed permission decision.
|
||||
// Implementations must re-read the pane before sending input so callers can
|
||||
// bind a decision to the exact capture they rendered.
|
||||
type ApprovalResponder interface {
|
||||
RespondApproval(context.Context, Session, bool, string) error
|
||||
}
|
||||
type CLIAdapter struct {
|
||||
Client *Client
|
||||
Harness string
|
||||
@@ -595,6 +602,28 @@ func (a CLIAdapter) PaneCapture(ctx context.Context, s Session, source string) (
|
||||
return r.Read.Text, nil
|
||||
}
|
||||
|
||||
// RespondApproval only acts on harness prompts that visibly expose a y/n
|
||||
// choice. This deliberately refuses unknown dialog layouts rather than
|
||||
// guessing an Enter key could mean approval.
|
||||
func (a CLIAdapter) RespondApproval(ctx context.Context, s Session, grant bool, expectedCapture string) error {
|
||||
current, err := a.PaneCapture(ctx, s, "recent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != expectedCapture {
|
||||
return fmt.Errorf("approval prompt changed")
|
||||
}
|
||||
low := strings.ToLower(current)
|
||||
if !strings.Contains(low, "[y/n]") && !strings.Contains(low, "(y/n)") {
|
||||
return fmt.Errorf("approval prompt has no unambiguous y/n confirmation")
|
||||
}
|
||||
input := "n\n"
|
||||
if grant {
|
||||
input = "y\n"
|
||||
}
|
||||
return a.Client.Call(ctx, "pane.send_text", map[string]any{"pane_id": s.PaneID, "text": input}, nil)
|
||||
}
|
||||
|
||||
func statusFromAgentResult(v any) string {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
for _, key := range []string{"status", "agent_status", "state"} {
|
||||
|
||||
@@ -995,6 +995,61 @@ func (c *Coordinator) Session(taskID string) (herdr.Session, bool) {
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// RequestHandoff asks the live harness to prepare its agent-authored handoff.
|
||||
// It deliberately does not release the pane: a later validated handoff is the
|
||||
// only evidence that can make a rotation safe.
|
||||
func (c *Coordinator) RequestHandoff(ctx context.Context, taskID string) error {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
s, ok := c.sessions[taskID]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("session not found for task %s", taskID)
|
||||
}
|
||||
if s.HandoffRequested {
|
||||
return nil
|
||||
}
|
||||
a, err := c.adapterFor(taskID, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, ok := a.(herdr.HandoffRequester)
|
||||
if !ok {
|
||||
return fmt.Errorf("harness does not support handoff requests")
|
||||
}
|
||||
if err := req.RequestHandoff(ctx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
s.HandoffRequested = true
|
||||
c.mu.Lock()
|
||||
c.sessions[taskID] = s
|
||||
err = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// RespondApproval is the local implementation of the same guarded command
|
||||
// contract used by federation workers. It rechecks the displayed capture at
|
||||
// the owning herdr immediately before input is sent.
|
||||
func (c *Coordinator) RespondApproval(ctx context.Context, taskID string, grant bool, expectedCapture string) error {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
s, ok := c.sessions[taskID]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("session not found for task %s", taskID)
|
||||
}
|
||||
a, err := c.adapterFor(taskID, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
responder, ok := a.(herdr.ApprovalResponder)
|
||||
if !ok {
|
||||
return fmt.Errorf("harness does not support approval responses")
|
||||
}
|
||||
return responder.RespondApproval(ctx, s, grant, expectedCapture)
|
||||
}
|
||||
|
||||
func (c *Coordinator) Capture(ctx context.Context, taskID, source string) (string, error) {
|
||||
s, ok := c.Session(taskID)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// Package ui provides the browser-oriented Orchestra read model and controls.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/federation"
|
||||
"orchestra/internal/orchestrator"
|
||||
"orchestra/internal/store"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PendingApproval struct {
|
||||
Kind string `json:"kind"`
|
||||
Summary string `json:"summary"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
PaneID string `json:"pane_id"`
|
||||
CaptureRevision uint64 `json:"capture_revision"`
|
||||
DetectedAt time.Time `json:"detected_at"`
|
||||
}
|
||||
type Capture struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Source string `json:"source"`
|
||||
Text string `json:"text"`
|
||||
Revision uint64 `json:"revision"`
|
||||
At time.Time `json:"at"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
}
|
||||
type Session struct {
|
||||
PaneID string `json:"pane_id,omitempty"`
|
||||
HarnessID string `json:"harness_id,omitempty"`
|
||||
AgentStatus string `json:"agent_status,omitempty"`
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
LeaseUntil *time.Time `json:"lease_until,omitempty"`
|
||||
Capture *Capture `json:"capture,omitempty"`
|
||||
Approval *PendingApproval `json:"pending_approval,omitempty"`
|
||||
}
|
||||
type TaskDetail struct {
|
||||
Task domain.Task `json:"task"`
|
||||
Events []domain.Event `json:"events"`
|
||||
Session *Session `json:"session,omitempty"`
|
||||
HandoffRef string `json:"handoff_ref,omitempty"`
|
||||
ReportRef string `json:"report_ref,omitempty"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
type Overview struct {
|
||||
Tasks []domain.Task `json:"tasks"`
|
||||
Workers []federation.Worker `json:"workers"`
|
||||
Sessions []Session `json:"sessions"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Store *store.Store
|
||||
Workers *federation.Registry
|
||||
Coordinator *orchestrator.Coordinator
|
||||
Route func(domain.Event) error
|
||||
}
|
||||
|
||||
func (s Server) capture(ctx context.Context, t domain.Task) (*Capture, error) {
|
||||
if t.State != domain.StateLeased || t.Lease == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if s.Coordinator != nil {
|
||||
text, err := s.Coordinator.Capture(ctx, t.ID, "recent")
|
||||
if err == nil {
|
||||
return &Capture{TaskID: t.ID, Source: "recent", Text: text, Revision: uint64(time.Now().UnixNano()), At: time.Now().UTC()}, nil
|
||||
}
|
||||
}
|
||||
if s.Workers != nil {
|
||||
if c, ok := s.Workers.Capture(t.Lease.HarnessID, t.ID); ok {
|
||||
return &Capture{TaskID: t.ID, Source: "recent", Text: c.Text, Revision: c.Revision, At: c.At}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("capture unavailable from owning worker")
|
||||
}
|
||||
func ParsePendingApproval(text, pane string, revision uint64, at time.Time) *PendingApproval {
|
||||
lines := strings.Split(text, "\n")
|
||||
low := strings.ToLower(text)
|
||||
if !strings.Contains(low, "permission required") && !strings.Contains(low, "approval required") && !strings.Contains(low, "allow this") {
|
||||
return nil
|
||||
}
|
||||
p := &PendingApproval{Kind: "unknown", Summary: "Harness permission prompt needs review", PaneID: pane, CaptureRevision: revision, DetectedAt: at}
|
||||
// OpenCode renders this exact three-choice selector and documents that
|
||||
// Enter confirms the initially selected "Allow once" action. It is not a
|
||||
// y/n prompt, so model it separately: grant is safe and bounded to once;
|
||||
// reject remains unavailable because the selected position is not exposed
|
||||
// in pane capture and Orchestra must not guess navigation keystrokes.
|
||||
if strings.Contains(low, "allow once") && strings.Contains(low, "allow always") && strings.Contains(low, "reject") && strings.Contains(low, "enter confirm") {
|
||||
p.Kind = "opencode_once"
|
||||
p.Summary = "Allow this command once"
|
||||
}
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "┃"))
|
||||
if strings.HasPrefix(l, "$ ") {
|
||||
if p.Kind == "unknown" {
|
||||
p.Kind = "shell"
|
||||
}
|
||||
p.Command = strings.TrimSpace(strings.TrimPrefix(l, "$ "))
|
||||
if p.Kind == "shell" {
|
||||
p.Summary = "Run shell command"
|
||||
}
|
||||
return p
|
||||
}
|
||||
}
|
||||
// Edits are intentionally only actionable when the harness gave an explicit diff.
|
||||
if i := strings.Index(text, "diff --git "); i >= 0 {
|
||||
p.Kind = "edit"
|
||||
p.Diff = text[i:]
|
||||
p.Summary = "Apply proposed edit"
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (s Server) detail(ctx context.Context, id string) (TaskDetail, error) {
|
||||
t, ok := s.Store.Task(id)
|
||||
if !ok {
|
||||
return TaskDetail{}, domain.ErrNotFound
|
||||
}
|
||||
d := TaskDetail{Task: t, Actions: actions(t)}
|
||||
for _, e := range s.Store.Events(0) {
|
||||
if e.TaskID != id {
|
||||
continue
|
||||
}
|
||||
d.Events = append(d.Events, e)
|
||||
var p struct {
|
||||
HandoffRef string `json:"handoff_ref"`
|
||||
ReportRef string `json:"report_ref"`
|
||||
}
|
||||
_ = json.Unmarshal(e.Payload, &p)
|
||||
if p.HandoffRef != "" {
|
||||
d.HandoffRef = p.HandoffRef
|
||||
}
|
||||
if p.ReportRef != "" {
|
||||
d.ReportRef = p.ReportRef
|
||||
}
|
||||
}
|
||||
sort.Slice(d.Events, func(i, j int) bool { return d.Events[i].Seq < d.Events[j].Seq })
|
||||
if t.Lease != nil {
|
||||
session := &Session{HarnessID: t.Lease.HarnessID, LeaseUntil: &t.Lease.Until}
|
||||
if c, err := s.capture(ctx, t); err == nil && c != nil {
|
||||
session.Capture = c
|
||||
session.PaneID = capturePane(s, t.ID, c)
|
||||
session.Approval = ParsePendingApproval(c.Text, session.PaneID, c.Revision, c.At)
|
||||
} else if err != nil {
|
||||
session.Blocker = "capture unavailable: " + err.Error()
|
||||
}
|
||||
d.Session = session
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func capturePane(s Server, taskID string, c *Capture) string {
|
||||
if s.Coordinator != nil {
|
||||
if session, ok := s.Coordinator.Session(taskID); ok {
|
||||
return session.PaneID
|
||||
}
|
||||
}
|
||||
if s.Workers != nil {
|
||||
for _, w := range s.Workers.Snapshot() {
|
||||
if remote, ok := s.Workers.Capture(w.ID, taskID); ok && remote.Revision == c.Revision {
|
||||
return remote.PaneID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func actions(t domain.Task) []Action {
|
||||
active := t.State == domain.StateLeased
|
||||
return []Action{{ID: "handoff", Enabled: active, Reason: "requires a live leased session"}, {ID: "release", Enabled: active, Needs: []string{"reason or handoff_ref"}}, {ID: "block", Enabled: active, Needs: []string{"blocker"}}, {ID: "complete", Enabled: active, Needs: []string{"report_ref", "receipt"}}}
|
||||
}
|
||||
func (s Server) Overview(ctx context.Context) Overview {
|
||||
out := Overview{Tasks: s.Store.Tasks(), UpdatedAt: time.Now().UTC()}
|
||||
if s.Workers != nil {
|
||||
out.Workers = s.Workers.Snapshot()
|
||||
}
|
||||
for _, t := range out.Tasks {
|
||||
if t.Lease != nil {
|
||||
d, _ := s.detail(ctx, t.ID)
|
||||
if d.Session != nil {
|
||||
out.Sessions = append(out.Sessions, *d.Session)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func (s Server) Handler() http.Handler {
|
||||
m := http.NewServeMux()
|
||||
m.HandleFunc("/v1/ui/overview", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
writeJSON(w, s.Overview(r.Context()))
|
||||
})
|
||||
m.HandleFunc("/v1/ui/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var p map[string]any
|
||||
if json.NewDecoder(r.Body).Decode(&p) != nil {
|
||||
http.Error(w, "invalid json", 400)
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(p)
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b, Surface: string(authz.Web)}
|
||||
if err := s.Store.Append(e); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if s.Route != nil {
|
||||
_ = s.Route(e)
|
||||
}
|
||||
writeJSON(w, e)
|
||||
})
|
||||
m.HandleFunc("/v1/ui/tasks/", func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/v1/ui/tasks/")
|
||||
p := strings.Split(rest, "/")
|
||||
if len(p) == 1 && r.Method == http.MethodGet {
|
||||
d, err := s.detail(r.Context(), p[0])
|
||||
if err != nil {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
writeJSON(w, d)
|
||||
return
|
||||
}
|
||||
if len(p) == 2 && p[1] == "capture" && r.Method == http.MethodGet {
|
||||
t, ok := s.Store.Task(p[0])
|
||||
if !ok {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
c, err := s.capture(r.Context(), t)
|
||||
if err != nil || c == nil {
|
||||
http.Error(w, fmt.Sprintf("capture unavailable: %v", err), 503)
|
||||
return
|
||||
}
|
||||
writeJSON(w, c)
|
||||
return
|
||||
}
|
||||
if len(p) == 3 && p[1] == "actions" && r.Method == http.MethodPost {
|
||||
s.action(w, r, p[0], p[2])
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found", 404)
|
||||
})
|
||||
m.HandleFunc("/v1/ui/artifacts/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
ref := strings.TrimPrefix(r.URL.Path, "/v1/ui/artifacts/")
|
||||
if len(ref) != 64 {
|
||||
http.Error(w, "artifact ref required", 400)
|
||||
return
|
||||
}
|
||||
b, err := s.Store.Artifact(ref)
|
||||
if err != nil {
|
||||
http.Error(w, "artifact not found", 404)
|
||||
return
|
||||
}
|
||||
trim := strings.TrimSpace(string(b))
|
||||
if json.Valid(b) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
} else if strings.HasPrefix(trim, "#") || strings.Contains(trim, "\n#") {
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
})
|
||||
return m
|
||||
}
|
||||
func (s Server) action(w http.ResponseWriter, r *http.Request, id, action string) {
|
||||
t, ok := s.Store.Task(id)
|
||||
if !ok {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
var typ string
|
||||
switch action {
|
||||
case "grant_approval", "deny_approval":
|
||||
c, err := s.capture(r.Context(), t)
|
||||
if err != nil || c == nil {
|
||||
http.Error(w, "current capture unavailable", 503)
|
||||
return
|
||||
}
|
||||
pane := capturePane(s, id, c)
|
||||
approval := ParsePendingApproval(c.Text, pane, c.Revision, c.At)
|
||||
if approval == nil || approval.Kind == "unknown" || pane == "" {
|
||||
http.Error(w, "current prompt is not safely actionable", 409)
|
||||
return
|
||||
}
|
||||
if s.Workers != nil {
|
||||
if _, remote := s.Workers.Capture(t.Lease.HarnessID, t.ID); remote {
|
||||
command, err := s.Workers.Queue(t.Lease.HarnessID, federation.Command{TaskID: id, Kind: action, PaneID: pane, CaptureRevision: c.Revision})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"command": command, "task": t})
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.Coordinator == nil {
|
||||
http.Error(w, "approval executor unavailable", 503)
|
||||
return
|
||||
}
|
||||
if err := s.Coordinator.RespondApproval(r.Context(), id, action == "grant_approval", c.Text); err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
typ := "ApprovalGranted"
|
||||
if action == "deny_approval" {
|
||||
typ = "ApprovalDenied"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"subject_ref": id, "pane_id": pane, "capture_revision": c.Revision})
|
||||
e := domain.Event{ID: domain.NewID(), Type: typ, TaskID: id, Version: t.Version + 1, Payload: payload, Surface: string(authz.Web)}
|
||||
if err := s.Store.Append(e); err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
d, _ := s.detail(r.Context(), id)
|
||||
writeJSON(w, d)
|
||||
return
|
||||
case "handoff":
|
||||
if s.Coordinator == nil {
|
||||
http.Error(w, "live coordinator unavailable", 503)
|
||||
return
|
||||
}
|
||||
if err := s.Coordinator.RequestHandoff(r.Context(), id); err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
d, _ := s.detail(r.Context(), id)
|
||||
writeJSON(w, d)
|
||||
return
|
||||
case "release":
|
||||
typ = "TaskReleased"
|
||||
case "block":
|
||||
typ = "TaskBlocked"
|
||||
case "complete":
|
||||
typ = "TaskCompleted"
|
||||
default:
|
||||
http.Error(w, "unknown action", 404)
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
e := domain.Event{ID: domain.NewID(), Type: typ, TaskID: id, Version: t.Version + 1, Payload: b, Surface: string(authz.Web)}
|
||||
if err := s.Store.Append(e); err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
if s.Route != nil {
|
||||
_ = s.Route(e)
|
||||
}
|
||||
d, _ := s.detail(r.Context(), id)
|
||||
writeJSON(w, d)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
:root{font:16px system-ui;color:#e7edf3;background:#111827}body{max-width:1200px;margin:auto;padding:1.5rem}a{color:#8dd5ff}header{display:flex;gap:2rem;align-items:center}.board{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}.board section{background:#1f2937;border-radius:8px;padding:.7rem;min-height:12rem}.card{display:block;color:inherit;background:#374151;padding:.6rem;margin:.5rem 0;border-radius:5px;text-decoration:none}.card small{display:block;color:#b9c3d0}input,textarea,button{padding:.55rem;margin:.25rem}textarea{min-height:5rem}.create{display:grid;max-width:38rem;margin-top:2rem}pre{background:#030712;padding:1rem;overflow:auto;white-space:pre-wrap}.approval{border:2px solid #fbbf24;background:#422006;padding:1rem;border-radius:8px}table{border-collapse:collapse}td,th{padding:.5rem;border:1px solid #4b5563}@media(max-width:800px){.board{grid-template-columns:1fr 1fr}}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
:root{font:16px system-ui;color:#e7edf3;background:#111827}body{max-width:1200px;margin:auto;padding:1.5rem}a{color:#8dd5ff}header{display:flex;gap:2rem;align-items:center}.board{display:grid;grid-template-columns:repeat(5,1fr);gap:1rem}.board section{background:#1f2937;border-radius:8px;padding:.7rem;min-height:12rem}.card{display:block;color:inherit;background:#374151;padding:.6rem;margin:.5rem 0;border-radius:5px;text-decoration:none}.card small{display:block;color:#b9c3d0}input,textarea,button{padding:.55rem;margin:.25rem}textarea{min-height:5rem}.create{display:grid;max-width:38rem;margin-top:2rem}pre{background:#030712;padding:1rem;overflow:auto;white-space:pre-wrap}.approval{border:2px solid #fbbf24;background:#422006;padding:1rem;border-radius:8px}table{border-collapse:collapse}td,th{padding:.5rem;border:1px solid #4b5563}@media(max-width:800px){.board{grid-template-columns:1fr 1fr}}.actions{display:grid;gap:.75rem;max-width:42rem}.actions form{display:flex;flex-wrap:wrap;align-items:center}.actions textarea{flex:1;min-width:16rem}.approval-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#000a;display:grid;place-items:center;padding:1rem;z-index:10}.approval{max-width:50rem;max-height:90vh;overflow:auto}details textarea{width:100%}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
<script type="module" crossorigin src="/assets/index-hnZ7xNV9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hcvlnkHy.css">
|
||||
<div id="root"></div>
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package webui embeds the compiled browser application in the Orchestra binary.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed assets/* assets/assets/*
|
||||
var files embed.FS
|
||||
|
||||
// Handler serves immutable fingerprinted assets and falls back to the SPA for
|
||||
// browser routes. API routes are deliberately not handled here.
|
||||
func Handler() http.Handler {
|
||||
root, _ := fs.Sub(files, "assets")
|
||||
static := http.FileServer(http.FS(root))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if p != "" && p != "." {
|
||||
if _, err := fs.Stat(root, p); err == nil {
|
||||
if strings.Contains(p, "/assets/") || strings.HasPrefix(p, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
static.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
b, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil { http.Error(w, "web UI unavailable", http.StatusInternalServerError); return }
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY package*.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /src/dist/ /usr/share/nginx/html/
|
||||
EXPOSE 8080
|
||||
@@ -0,0 +1,2 @@
|
||||
import js from '@eslint/js'
|
||||
export default [js.configs.recommended, { files:['src/**/*.{ts,tsx}'], ignores:['dist/**'], rules:{'no-undef':'off'} }]
|
||||
@@ -0,0 +1 @@
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /v1/ {
|
||||
proxy_pass ${ORCHESTRA_API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location /metrics { proxy_pass ${ORCHESTRA_API_UPSTREAM}; }
|
||||
location /healthz { proxy_pass ${ORCHESTRA_API_UPSTREAM}; }
|
||||
location /readyz { proxy_pass ${ORCHESTRA_API_UPSTREAM}; }
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
Generated
+3450
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "orchestra-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"scripts": {"dev":"vite", "build":"tsc -b && vite build", "lint":"tsc -b", "test":"vitest run"},
|
||||
"dependencies": {"@tanstack/react-query":"^5.66.0", "react":"^19.0.0", "react-dom":"^19.0.0", "react-router-dom":"^7.1.5"},
|
||||
"devDependencies": {"@eslint/js":"^9.19.0", "@types/react":"^19.0.8", "@types/react-dom":"^19.0.3", "@vitejs/plugin-react":"^4.4.1", "eslint":"^9.19.0", "typescript":"^5.7.3", "vite":"^6.1.0", "vitest":"^3.0.5"}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {afterEach,describe,expect,it,vi} from 'vitest'
|
||||
import {api} from './client'
|
||||
|
||||
describe('UI API client',()=>{
|
||||
afterEach(()=>vi.unstubAllGlobals())
|
||||
it('submits every supplied task field to the UI creation endpoint',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({task_id:'t'}),{status:200}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await api.create({source:'web',external_id:'x',project:'p',parent:'parent',inherent_priority:2})
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/ui/tasks',expect.objectContaining({method:'POST'}))
|
||||
expect((fetch.mock.calls[0][1].body as string)).toContain('"parent":"parent"')
|
||||
})
|
||||
it('uploads a completion report before a task completion action',async()=>{
|
||||
const fetch=vi.fn().mockResolvedValue(new Response(JSON.stringify({ref:'a'.repeat(64)}),{status:201}))
|
||||
vi.stubGlobal('fetch',fetch)
|
||||
await expect(api.upload('report')).resolves.toBe('a'.repeat(64))
|
||||
expect(fetch).toHaveBeenCalledWith('/v1/artifacts',expect.objectContaining({method:'POST',body:'report'}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Detail, Overview } from './types'
|
||||
async function request<T>(path:string, init?:RequestInit):Promise<T>{const r=await fetch(path,{headers:{'Content-Type':'application/json',...init?.headers},...init});if(!r.ok)throw new Error(await r.text());return r.json() as Promise<T>}
|
||||
async function text(path:string){const r=await fetch(path);if(!r.ok)throw new Error(await r.text());return r.text()}
|
||||
async function upload(body:string){const r=await fetch('/v1/artifacts',{method:'POST',headers:{'Content-Type':'text/markdown'},body});if(!r.ok)throw new Error(await r.text());return (await r.json() as {ref:string}).ref}
|
||||
export const api={overview:()=>request<Overview>('/v1/ui/overview'),detail:(id:string)=>request<Detail>('/v1/ui/tasks/'+id),artifact:(ref:string)=>text('/v1/ui/artifacts/'+ref),upload,create:(body:unknown)=>request('/v1/ui/tasks',{method:'POST',body:JSON.stringify(body)}),action:(id:string,action:string,body={})=>request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`,{method:'POST',body:JSON.stringify(body)})}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type TaskState='queued'|'leased'|'blocked'|'completed'|'failed'
|
||||
export interface Task { id:string; source:string; external_id:string; project:string; title?:string; description?:string; state:TaskState; version:number; lease?:{harness_id:string;until:string}; handoff_ref?:string }
|
||||
export interface PendingApproval { kind:'shell'|'opencode_once'|'edit'|'unknown'; summary:string; command?:string; diff?:string; pane_id:string; capture_revision:number; detected_at:string }
|
||||
export interface Capture { task_id:string; source:string; text:string; revision:number; at:string; truncated:boolean }
|
||||
export interface Session { pane_id?:string; harness_id?:string; agent_status?:string; blocker?:string; lease_until?:string; capture?:Capture; pending_approval?:PendingApproval }
|
||||
export interface Action { id:string; enabled:boolean; reason?:string; needs?:string[] }
|
||||
export interface Detail { task:Task; events:Array<{id:string;type:string;at:string;payload:unknown}>; session?:Session; handoff_ref?:string; report_ref?:string; actions:Action[] }
|
||||
export interface Overview { tasks:Task[]; workers:Array<{id:string;capacity:number;last_seen:string;online:boolean}>; sessions:Session[]; updated_at:string }
|
||||
@@ -0,0 +1,23 @@
|
||||
import React,{useEffect,useRef,useState} from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import {BrowserRouter,Link,NavLink,Route,Routes,useLocation,useNavigate,useParams} from 'react-router-dom'
|
||||
import {QueryClient,QueryClientProvider,useMutation,useQuery,useQueryClient} from '@tanstack/react-query'
|
||||
import {api} from './api/client'
|
||||
import type {Action,PendingApproval,Task,TaskState} from './api/types'
|
||||
import './style.css'
|
||||
|
||||
const client=new QueryClient(),states:TaskState[]=['queued','leased','blocked','completed','failed']
|
||||
const label:Record<TaskState,string>={queued:'Queued',leased:'In session',blocked:'Blocked',completed:'Complete',failed:'Failed'}
|
||||
const actionLabel:Record<string,string>={handoff:'Request handoff',complete:'Complete task',block:'Mark blocked'}
|
||||
|
||||
function CommandPalette({close}:{close:()=>void}){const nav=useNavigate(),qc=useQueryClient(),input=useRef<HTMLInputElement>(null),[term,setTerm]=useState('');useEffect(()=>{input.current?.focus()},[]);const choose=(id:string)=>{if(id==='board')nav('/');if(id==='workers')nav('/workers');if(id==='new'){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}if(id==='refresh')qc.invalidateQueries();close()};const commands=[['board','Go to dispatch board','G then B'],['new','Create a new task','N'],['workers','View worker pool','G then W'],['refresh','Refresh live data','R']] as const;const matches=commands.filter(c=>c[1].toLowerCase().includes(term.toLowerCase()));return <div className="palette-backdrop" onMouseDown={close}><section className="palette" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e=>e.stopPropagation()}><input ref={input} value={term} onChange={e=>setTerm(e.target.value)} onKeyDown={e=>{if(e.key==='Escape')close();if(e.key==='Enter'&&matches[0])choose(matches[0][0])}} placeholder="Find a command…" aria-label="Find a command"/><div className="palette-list">{matches.map(([id,name,key])=><button key={id} className="palette-command" onClick={()=>choose(id)}><span>{name}</span><kbd>{key}</kbd></button>)}{!matches.length&&<p className="palette-empty">No matching command.</p>}</div><p className="palette-foot"><kbd>↵</kbd> run <kbd>esc</kbd> close</p></section></div>}
|
||||
function Shell({children}:{children:React.ReactNode}){const where=useLocation(),nav=useNavigate(),[palette,setPalette]=useState(false);const overview=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});const sessions=overview.data?.sessions.length??0;useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();setPalette(true)}if(e.key==='n'&&!e.metaKey&&!e.ctrlKey&&!(e.target instanceof HTMLInputElement)&&!(e.target instanceof HTMLTextAreaElement)){nav('/');window.dispatchEvent(new Event('orchestra:new-task'))}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)},[nav]);return <div className="app-shell" data-app="orchestra"><aside className="rail"><Link className="mark" to="/" aria-label="Orchestra home"><i className="branch-mark"/><span>OR</span></Link><nav className="rail-nav" aria-label="Primary navigation"><NavLink end className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/"><span>Board</span></NavLink><NavLink className={({isActive})=>'rail-link '+(isActive?'active':'')} to="/workers"><span>Workers</span></NavLink></nav><span className="rail-footer">v0.1</span></aside><header className="topbar"><div className="topbar-title">Orchestra <small>{where.pathname==='/'?'dispatch board':where.pathname==='/workers'?'worker pool':'task record'}</small></div><button className="command" onClick={()=>setPalette(true)} aria-label="Open command palette"><span>Command</span><kbd>⌘ K</kbd></button><div className="readout"><span>SESSIONS <b>{sessions}</b></span><span>SYNC <b>5s</b></span></div></header>{children}{palette&&<CommandPalette close={()=>setPalette(false)}/>}</div>}
|
||||
function Board({tasks}:{tasks:Task[]}){return <div className="board">{states.map(state=>{const lane=tasks.filter(t=>t.state===state);return <section key={state}><div className="lane-head"><span>{label[state]}</span><span className="lane-count">{lane.length}</span></div>{lane.map(t=><Link className="card" to={'/tasks/'+t.id} key={t.id}><b>{t.title||t.id}</b><small><span>{t.project}</span><span className="machine">{t.id.slice(-5)}</span></small></Link>)}{!lane.length&&<p className="empty-lane">Nothing here. New work will appear in this branch.</p>}</section>})}</div>}
|
||||
function Overview(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000}),[createOpen,setCreateOpen]=useState(false);useEffect(()=>{const open=()=>setCreateOpen(true);window.addEventListener('orchestra:new-task',open);return()=>window.removeEventListener('orchestra:new-task',open)},[]);if(q.isLoading)return <main className="page loading"><p className="eyebrow">Dispatch board</p><p>Fetching queue state from the coordinator…</p></main>;if(q.error)return <main className="page error" role="alert">Queue unavailable: {String(q.error)}</main>;const d=q.data!,active=d.tasks.filter(t=>t.state==='leased').length,attention=d.tasks.filter(t=>t.state==='blocked'||t.state==='failed').length,approvals=new Set(d.sessions.filter(s=>s.pending_approval).map(s=>s.capture?.task_id));return <main className="page"><div className="page-heading"><div><p className="eyebrow">Agent dispatch</p><h1>Keep the work moving.</h1></div><div className="heading-actions"><p>Live task state across every connected harness. The board refreshes every five seconds.</p><button onClick={()=>setCreateOpen(true)}>New task <kbd>N</kbd></button></div></div><div className="status-strip" aria-label="Queue summary"><div><span>Tasks</span><b>{d.tasks.length}</b></div><div><span>In session</span><b>{active}</b></div><div className={attention?'attention':''}><span>Blocked or failed</span><b>{attention}</b></div><div><span>Awaiting approval</span><b>{approvals.size}</b></div></div><div className="section-title"><h2>Task flow</h2><span className="machine" aria-live="polite">SYNCED {new Date(d.updated_at).toLocaleTimeString()}</span></div><Board tasks={d.tasks}/>{createOpen&&<Create close={()=>setCreateOpen(false)}/>}</main>}
|
||||
function Create({close}:{close:()=>void}){const qc=useQueryClient(),nav=useNavigate(),first=useRef<HTMLInputElement>(null),[title,setTitle]=useState(''),[description,setDescription]=useState(''),[project,setProject]=useState('default'),[capability,setCapability]=useState(''),[advanced,setAdvanced]=useState(''),[formError,setFormError]=useState('');useEffect(()=>{first.current?.focus()},[]);const m=useMutation({mutationFn:()=>{let extra:Record<string,unknown>={};if(advanced.trim()){try{extra=JSON.parse(advanced)}catch{return Promise.reject(new Error('Additional fields must be valid JSON.'))}}return api.create({...extra,source:'web',external_id:crypto.randomUUID(),project,title,description,capability:capability.split(',').map(x=>x.trim()).filter(Boolean)})},onSuccess:(e:any)=>{qc.invalidateQueries({queryKey:['overview']});nav('/tasks/'+e.task_id)}});const submit=(e:React.FormEvent)=>{e.preventDefault();setFormError('');if(title.trim().length<3){setFormError('Give the task a title of at least three characters.');return}if(description.trim().length<10){setFormError('Add enough immutable instructions for an agent to act safely.');return}m.mutate()};return <div className="modal-backdrop" onMouseDown={close}><section className="create modal" role="dialog" aria-modal="true" aria-labelledby="new-task-title" onMouseDown={e=>e.stopPropagation()}><div className="modal-heading"><div><p className="eyebrow">Dispatch new work</p><h2 id="new-task-title">New task</h2></div><button className="icon-button" onClick={close} aria-label="Close task creation">×</button></div><p>Give the harness a clear objective and immutable operating instructions.</p><form onSubmit={submit} noValidate><label>Task title<input ref={first} required value={title} onChange={e=>setTitle(e.target.value)} placeholder="e.g. Add the health endpoint" autoComplete="off"/></label><label>Project<input required value={project} onChange={e=>setProject(e.target.value)} placeholder="Project name" autoComplete="off"/></label><label>Capabilities <small>Optional, comma-separated</small><input value={capability} onChange={e=>setCapability(e.target.value)} placeholder="go, docker" autoComplete="off"/></label><label>Immutable instructions<textarea required value={description} onChange={e=>setDescription(e.target.value)} placeholder="Describe the outcome, constraints, and evidence required."/></label><details><summary>Additional accepted task fields</summary><textarea aria-label="Additional task fields JSON" placeholder={'{"parent":"…","inherent_priority":1,"due":"2026-07-30T12:00:00Z"}'} value={advanced} onChange={e=>setAdvanced(e.target.value)}/></details><div className="modal-actions"><button type="button" className="quiet-button" onClick={close}>Cancel</button><button disabled={m.isPending}>{m.isPending?'Creating task…':'Create task'}</button></div>{(formError||m.error)&&<span className="error" role="alert">{formError||String(m.error)}</span>}</form></section></div>}
|
||||
function Approval({approval,onAction,pending}:{approval:PendingApproval;onAction:(a:string)=>void;pending:boolean}){const ref=useRef<HTMLDivElement>(null),grant=approval.kind!=='unknown',deny=approval.kind==='shell';useEffect(()=>{const node=ref.current;if(!node)return;node.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();const trap=(e:KeyboardEvent)=>{if(e.key==='Escape')return; if(e.key!=='Tab')return;const focus=[...node.querySelectorAll<HTMLElement>('button:not(:disabled)')],i=focus.indexOf(document.activeElement as HTMLElement);if(e.shiftKey&&i<=0){e.preventDefault();focus.at(-1)?.focus()}else if(!e.shiftKey&&i===focus.length-1){e.preventDefault();focus[0].focus()}};node.addEventListener('keydown',trap);return()=>node.removeEventListener('keydown',trap)},[]);return <div className="approval-backdrop"><section ref={ref} className="approval" role="dialog" aria-modal="true" aria-labelledby="approval-title"><p className="eyebrow">Harness gate</p><h2 id="approval-title">Permission required</h2><p>{approval.summary}</p><pre>{approval.command||approval.diff||'The harness prompt could not be parsed safely.'}</pre><small>Pane {approval.pane_id} · capture revision {approval.capture_revision} · detected {new Date(approval.detected_at).toLocaleString()}</small>{approval.kind==='opencode_once'&&<p>Approving sends Enter to OpenCode’s explicitly displayed <b>Allow once</b> selection. Reject is unavailable because the selected position cannot be verified from the capture.</p>}{!grant&&<p role="alert">This prompt cannot be safely executed because its exact confirmation is unknown.</p>}<div><button disabled={!grant||pending} onClick={()=>onAction('grant_approval')}>{approval.kind==='opencode_once'?'Allow once':'Approve'}</button><button disabled={!deny||pending} title={deny?'':'This selector does not expose a verifiable reject position.'} onClick={()=>onAction('deny_approval')}>Reject</button></div></section></div>}
|
||||
function Lifecycle({action,mutate,pending}:{action:Action;mutate:(action:string,body?:object)=>void;pending:boolean}){const [value,setValue]=useState('');if(action.id==='handoff')return <button disabled={!action.enabled||pending} title={action.reason} onClick={()=>mutate('handoff')}>Request handoff</button>;if(action.id==='complete')return <form onSubmit={async e=>{e.preventDefault();try{const ref=await api.upload(value);mutate('complete',{report_ref:ref,receipt:{source:'web',completed_at:new Date().toISOString()}})}catch(err){alert(String(err))}}}><label className="sr-only" htmlFor="completion-report">Completion report</label><textarea id="completion-report" required placeholder="Completion report, stored as evidence" value={value} onChange={e=>setValue(e.target.value)}/><button disabled={!action.enabled||pending}>Complete task</button></form>;const field=action.id==='block'?'blocker':'reason';return <form onSubmit={e=>{e.preventDefault();mutate(action.id,{[field]:value})}}><label className="sr-only" htmlFor={'action-'+action.id}>{field}</label><input id={'action-'+action.id} required placeholder={field==='blocker'?'What is blocking progress?':'Reason'} value={value} onChange={e=>setValue(e.target.value)}/><button disabled={!action.enabled||pending} title={action.reason||action.needs?.join(',')}>{actionLabel[action.id]||action.id}</button></form>}
|
||||
function TaskDetail(){const {taskID=''}=useParams(),qc=useQueryClient();const q=useQuery({queryKey:['task',taskID],queryFn:()=>api.detail(taskID),refetchInterval:3000});const m=useMutation({mutationFn:({action,body}:{action:string;body?:object})=>api.action(taskID,action,body),onSuccess:()=>qc.invalidateQueries({queryKey:['task',taskID]})});if(q.isLoading)return <main className="page loading">Fetching task record…</main>;if(q.error)return <main className="page error" role="alert">Task unavailable: {String(q.error)}</main>;const d=q.data!;return <main className="page"><Link className="back" to="/">← Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Task record</p><h1>{d.task.title||d.task.id}</h1></div><p className="machine">{d.task.id}</p></div>{d.session?.pending_approval&&<Approval approval={d.session.pending_approval} pending={m.isPending} onAction={a=>m.mutate({action:a})}/>}<div className="detail-grid"><div className="surface"><h2>Instructions</h2><p className="task-description">{d.task.description||'No immutable instructions were recorded.'}</p><div className={'task-state state-'+d.task.state}>{label[d.task.state]}</div><h2>Live capture</h2><pre>{d.session?.capture?.text||d.session?.blocker||'No live capture is available for this task.'}</pre><h2 style={{marginTop:20}}>Actions</h2><div className="actions">{d.actions.map(a=><Lifecycle key={a.id} action={a} pending={m.isPending} mutate={(action,body)=>m.mutate({action,body})}/>)}</div>{m.error&&<p className="error" role="alert">{String(m.error)}</p>}</div><aside className="surface"><h2>Session</h2><ul className="meta"><li><span>Project</span><span className="machine">{d.task.project}</span></li><li><span>State</span><span className="machine">{d.task.state}</span></li><li><span>Harness</span><span className="machine">{d.session?.harness_id||'—'}</span></li><li><span>Pane</span><span className="machine">{d.session?.pane_id||'—'}</span></li><li><span>Lease ends</span><span className="machine">{d.session?.lease_until?new Date(d.session.lease_until).toLocaleString():'—'}</span></li></ul>{d.handoff_ref&&<p><Link className="back" to={'/artifacts/'+d.handoff_ref}>View handoff →</Link></p>}{d.report_ref&&<p><Link className="back" to={'/artifacts/'+d.report_ref}>View report →</Link></p>}</aside></div><div className="surface" style={{marginTop:20}}><h2>Timeline</h2><ol className="timeline">{d.events.map(e=><li key={e.id}><span>{e.type}</span><time>{new Date(e.at).toLocaleString()}</time></li>)}</ol></div></main>}
|
||||
function Workers(){const q=useQuery({queryKey:['overview'],queryFn:api.overview,refetchInterval:5000});if(q.isLoading)return <main className="page loading">Fetching worker heartbeats…</main>;if(q.error)return <main className="page error" role="alert">Worker pool unavailable: {String(q.error)}</main>;const workers=q.data!.workers;return <main className="page"><Link className="back" to="/">← Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Worker pool</p><h1>Available capacity.</h1></div><p>{workers.filter(w=>w.online).length} of {workers.length} registered workers are reachable right now.</p></div>{workers.length?<table><thead><tr><th>Worker</th><th>State</th><th>Capacity</th><th>Last heartbeat</th></tr></thead><tbody>{workers.map(w=><tr key={w.id}><td>{w.id}</td><td className={w.online?'online':'offline'}>{w.online?'online':'offline'}</td><td>{w.capacity}</td><td>{new Date(w.last_seen).toLocaleString()}</td></tr>)}</tbody></table>:<section className="empty-panel"><i className="branch-mark"/><h2>No workers registered</h2><p>Connect a worker to begin leasing queued tasks.</p></section>}</main>}
|
||||
function Artifact(){const {ref=''}=useParams();const q=useQuery({queryKey:['artifact',ref],queryFn:()=>api.artifact(ref)});return <main className="page"><Link className="back" to="/">← Dispatch board</Link><div className="page-heading"><div><p className="eyebrow">Evidence artifact</p><h1>Recorded output.</h1></div><p className="machine">{ref}</p></div>{q.isLoading?<p className="loading">Retrieving artifact…</p>:q.error?<p className="error" role="alert">{String(q.error)}</p>:<pre>{q.data}</pre>}</main>}
|
||||
function App(){return <Shell><Routes><Route path="/" element={<Overview/>}/><Route path="/tasks/:taskID" element={<TaskDetail/>}/><Route path="/workers" element={<Workers/>}/><Route path="/artifacts/:ref" element={<Artifact/>}/></Routes></Shell>};createRoot(document.getElementById('root')!).render(<React.StrictMode><QueryClientProvider client={client}><BrowserRouter><App/></BrowserRouter></QueryClientProvider></React.StrictMode>)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"]}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins:[react()],
|
||||
build:{outDir:'dist', emptyOutDir:true},
|
||||
test:{exclude:['**/node_modules/**','**/.node_modules/**','**/dist/**']},
|
||||
})
|
||||
Reference in New Issue
Block a user