b57894b183
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
212 lines
6.1 KiB
Go
212 lines
6.1 KiB
Go
package federation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"orchestra/internal/domain"
|
|
"strings"
|
|
)
|
|
|
|
// Client is the worker-side protocol client. It carries no task state: the
|
|
// homesrv event log remains authoritative and workers only persist their
|
|
// local execution session/checkouts.
|
|
type Client struct {
|
|
BaseURL string
|
|
WorkerID string
|
|
Token string
|
|
AdmitToken string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func (c Client) Register(ctx context.Context, w Worker) error {
|
|
b, err := json.Marshal(map[string]any{"id": w.ID, "address": w.Address, "capacity": w.Capacity, "token": c.Token})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/v1/federation/workers", bytes.NewReader(b))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.AdmitToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.AdmitToken)
|
|
}
|
|
h := c.HTTP
|
|
if h == nil {
|
|
h = http.DefaultClient
|
|
}
|
|
resp, err := h.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("federation register: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-Orchestra-Worker", c.WorkerID)
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
h := c.HTTP
|
|
if h == nil {
|
|
h = http.DefaultClient
|
|
}
|
|
resp, err := h.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("federation: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (c Client) Events(ctx context.Context, since uint64) ([]domain.Event, uint64, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/federation/events?since="+fmt.Sprint(since), nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Cursor uint64 `json:"cursor"`
|
|
Events []domain.Event `json:"events"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return out.Events, out.Cursor, nil
|
|
}
|
|
|
|
// Tasks hydrates the worker's cache when its local state predates the
|
|
// coordinator's event-retention window. The coordinator remains authoritative
|
|
// for the task projection.
|
|
func (c Client) Tasks(ctx context.Context) ([]domain.Task, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/tasks", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var tasks []domain.Task
|
|
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
|
|
return nil, err
|
|
}
|
|
return tasks, nil
|
|
}
|
|
func (c Client) Ack(ctx context.Context, cursor uint64) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/events/ack", map[string]uint64{"cursor": cursor})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Heartbeat(ctx context.Context) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", nil)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Artifact(ctx context.Context, ref string) ([]byte, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/artifacts/"+url.PathEscape(ref), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func (c Client) PutArtifact(ctx context.Context, b []byte) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/v1/artifacts", bytes.NewReader(b))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
h := c.HTTP
|
|
if h == nil {
|
|
h = http.DefaultClient
|
|
}
|
|
resp, err := h.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("artifact upload: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
var out struct {
|
|
Ref string `json:"ref"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Ref, nil
|
|
}
|
|
func (c Client) Release(ctx context.Context, taskID, ref, anchor string) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/handoff", map[string]string{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Complete(ctx context.Context, taskID, reportRef string) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/complete", map[string]string{"task_id": taskID, "handoff_ref": reportRef})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
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
|
|
}
|