Add federation worker and canonical handoffs

This commit is contained in:
kami
2026-07-28 16:17:18 +04:00
parent 58793a5aa3
commit 2cecbc4015
22 changed files with 1429 additions and 108 deletions
+183
View File
@@ -0,0 +1,183 @@
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
}
+51
View File
@@ -0,0 +1,51 @@
package federation
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestClientRegistersPollsAndReadsArtifactAsWorker(t *testing.T) {
seen := map[string]bool{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/federation/workers" {
seen["register"] = r.Header.Get("Authorization") == "Bearer admit"
w.WriteHeader(http.StatusCreated)
return
}
if r.Header.Get("X-Orchestra-Worker") != "h1" || r.Header.Get("Authorization") != "Bearer worker" {
t.Errorf("worker auth missing")
}
switch r.URL.Path {
case "/v1/federation/events":
seen["events"] = true
_, _ = w.Write([]byte(`{"cursor":3,"events":[{"seq":3,"id":"e","type":"TaskCreated","task_id":"t","version":1,"payload":{"source":"s","external_id":"x","project":"p"},"surface":"system"}]}`))
case "/v1/artifacts/abc":
seen["artifact"] = true
_, _ = w.Write([]byte(`{"meta":{"id":"x"}}`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer s.Close()
c := Client{BaseURL: s.URL, WorkerID: "h1", Token: "worker", AdmitToken: "admit"}
if err := c.Register(context.Background(), Worker{ID: "h1"}); err != nil {
t.Fatal(err)
}
es, cur, err := c.Events(context.Background(), 0)
if err != nil || cur != 3 || len(es) != 1 || es[0].Type != "TaskCreated" {
t.Fatalf("events=%v cursor=%d err=%v", es, cur, err)
}
b, err := c.Artifact(context.Background(), "abc")
if err != nil || string(b) != "{\"meta\":{\"id\":\"x\"}}" {
t.Fatalf("artifact=%s err=%v", b, err)
}
for _, k := range []string{"register", "events", "artifact"} {
if !seen[k] {
t.Errorf("%s not seen", k)
}
}
}
+17
View File
@@ -42,6 +42,7 @@ func (r *Registry) init() {
r.cursors = map[string]uint64{}
}
}
// Register admits a worker. admitToken must match r.AdmitToken whenever one
// is configured. Re-registering an ID that's already claimed requires that
// worker's own current token, so a caller can't self-declare someone else's
@@ -118,6 +119,22 @@ func (r *Registry) Heartbeat(id string) error {
r.workers[id] = w
return nil
}
// Available refreshes TTL state and reports whether a registered worker owns
// this harness id. Router admission uses it so a reachable TCP bridge alone
// can never make an offline worker eligible for a lease.
func (r *Registry) Available(id string) bool {
r.mu.Lock()
defer r.mu.Unlock()
r.init()
w, ok := r.workers[id]
if !ok {
return false
}
w.Online = time.Since(w.LastSeen) <= r.TTL
r.workers[id] = w
return w.Online
}
func (r *Registry) Snapshot() []Worker {
r.mu.Lock()
defer r.mu.Unlock()