package federation import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "orchestra/internal/domain" "orchestra/internal/review" "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, "supported_projects": w.SupportedProjects, "build": w.Build, "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, &StatusError{Code: resp.StatusCode, Status: resp.Status, Body: strings.TrimSpace(string(b))} } return resp, nil } // StatusError is a coordinator answer the caller can classify. A refusal is // the agent's mistake and has to reach the agent; a transport failure is not, // and must not be reported to it as one. The message keeps the previous // wording so callers that match on it still work. type StatusError struct { Code int Status string Body string } func (e *StatusError) Error() string { return fmt.Sprintf("federation: %s: %s", e.Status, e.Body) } 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 } // TurnDecision is the coordinator's answer at a worker's turn boundary: the // verdict the worker reported, plus the human decisions this session has not // been shown. Decisions are present only when the verdict is continue. type TurnDecision struct { Verdict string `json:"verdict"` Decisions []domain.HumanDecision `json:"decisions,omitempty"` } // Turn reports a verified turn boundary and collects any newer human // decisions. The worker evaluates rotation locally, because only it can see // the pane; authority stays with the coordinator. func (c Client) Turn(ctx context.Context, taskID, epoch, verdict string, delivered []string) (TurnDecision, error) { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/turn", map[string]any{ "task_id": taskID, "lease_epoch": epoch, "verdict": verdict, "delivered_decisions": delivered, }) if err != nil { return TurnDecision{}, err } defer resp.Body.Close() var out TurnDecision if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return TurnDecision{}, err } return out, nil } // AdvancePhase carries an agent's bounded phase-change request to the // coordinator, which decides. The accepted phase comes back so the worker // knows the session it owns has been superseded and must rotate. // // artifact is the sealed output of the phase being left, and is empty for a // phase that produces none. func (c Client) AdvancePhase(ctx context.Context, taskID, epoch, operationID string, from, to domain.WorkPhase, artifact []byte) (domain.WorkPhase, error) { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/phase", map[string]any{ "task_id": taskID, "lease_epoch": epoch, "operation_id": operationID, "from": string(from), "to": string(to), "artifact": artifact, }) if err != nil { return "", err } defer resp.Body.Close() var out struct { Phase domain.WorkPhase `json:"phase"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } return out.Phase, nil } // Intent fetches the reduced authority for one task: its contract plus the // human decisions still standing. A worker renders its launch instruction // from this, never from handoff prose. func (c Client) Intent(ctx context.Context, taskID string) (domain.EffectiveIntent, error) { resp, err := c.request(ctx, http.MethodGet, "/v1/tasks/"+url.PathEscape(taskID)+"/intent", nil) if err != nil { return domain.EffectiveIntent{}, err } defer resp.Body.Close() var intent domain.EffectiveIntent if err := json.NewDecoder(resp.Body).Decode(&intent); err != nil { return domain.EffectiveIntent{}, err } return intent, 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, health WorkerHealth) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", health) if resp != nil { resp.Body.Close() } return err } func (c Client) Renew(ctx context.Context, taskID, epoch string, expectedVersion, ttlSeconds int) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/renew", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "ttl_seconds": ttlSeconds}) if resp != nil { resp.Body.Close() } return err } func (c Client) Start(ctx context.Context, taskID, epoch string, expectedVersion int, evidence domain.SessionEvidence) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/start", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "session_evidence": evidence}) if resp != nil { resp.Body.Close() } return err } func (c Client) NackStart(ctx context.Context, taskID, epoch string, expectedVersion int, failureClass, detail string, evidence domain.SessionEvidence) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/nack", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "failure_class": failureClass, "last_error": detail, "session_evidence": evidence}) 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") req.Header.Set("X-Orchestra-Worker", c.WorkerID) req.Header.Set("Authorization", "Bearer "+c.Token) 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, transactionID, epoch string, expectedVersion int, evidence domain.SessionEvidence) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/handoff", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_epoch": epoch, "expected_version": expectedVersion, "session_evidence": evidence}) if resp != nil { resp.Body.Close() } return err } func (c Client) Pickup(ctx context.Context, taskID, ref, anchor, transactionID, epoch string, leaseVersion int, evidence domain.SessionEvidence) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/pickup", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_epoch": epoch, "lease_version": leaseVersion, "session_evidence": evidence}) if resp != nil { resp.Body.Close() } return err } func (c Client) Complete(ctx context.Context, taskID, reportRef, resultSHA, branch, remote, epoch string, expectedVersion int, receipt map[string]any, evidence domain.SessionEvidence) error { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/complete", map[string]any{"task_id": taskID, "handoff_ref": reportRef, "result_sha": resultSHA, "branch": branch, "remote": remote, "lease_epoch": epoch, "expected_version": expectedVersion, "receipt": receipt, "session_evidence": evidence}) if resp != nil { resp.Body.Close() } return err } // Submit outcomes. Sealing a review and submitting are one call because a // review that blocks has no submission to make, and splitting them would let a // lost response leave a sealed review with nothing acting on it. const ( // SubmitSubmitted: the pull request is open and TaskSubmitted is recorded. SubmitSubmitted = "submitted" // SubmitChangesRequested: the review blocked and the task is back in // implementation. SubmitChangesRequested = "changes_requested" // SubmitNoPublisher: the project has no forge, so the caller completes the // task directly instead. SubmitNoPublisher = "no_publisher" ) // Submit seals the review against resultSHA and submits that commit for human // review. The coordinator owns both events; the worker supplies evidence. func (c Client) Submit(ctx context.Context, taskID, epoch string, expectedVersion int, resultSHA, remote string, result review.Result, gate domain.GateResult) (string, error) { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/submit", map[string]any{ "task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "result_sha": resultSHA, "remote": remote, "review": result, "gate": gate, }) if err != nil { return "", err } defer resp.Body.Close() var out struct { Status string `json:"status"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } return out.Status, nil } // VerificationRun is one plan command the worker executed. type VerificationRun struct { Command []string `json:"command"` ExitCode int `json:"exit_code"` Output string `json:"output,omitempty"` } // PlanPhaseCommands asks the coordinator which commands a phase's verification // runs. The worker never takes a command from the agent's request: the plan // settled that at seal time, and the project's policy authorises it here. func (c Client) PlanPhaseCommands(ctx context.Context, taskID, phaseID, epoch string) ([][]string, error) { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-phase", map[string]any{ "task_id": taskID, "phase_id": phaseID, "lease_epoch": epoch, }) if err != nil { return nil, err } defer resp.Body.Close() var out struct { Commands [][]string `json:"commands"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } return out.Commands, nil } // RecordPlanPhase reports what the commands exited and returns the status // Orchestra established. The worker supplies evidence; it does not conclude. func (c Client) RecordPlanPhase(ctx context.Context, taskID, phaseID, atSHA, epoch string, runs []VerificationRun) (string, error) { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-phase-result", map[string]any{ "task_id": taskID, "phase_id": phaseID, "at_sha": atSHA, "lease_epoch": epoch, "runs": runs, }) if err != nil { return "", err } defer resp.Body.Close() var out struct { Status string `json:"status"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } return out.Status, nil } // ReportPlanMismatch carries the implementer's contradiction report. It // returns the phase Orchestra reopened, or an empty phase when the task // stopped for the human instead. func (c Client) ReportPlanMismatch(ctx context.Context, taskID, epoch string, m domain.PlanMismatch) (string, error) { resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-mismatch", map[string]any{ "task_id": taskID, "lease_epoch": epoch, "mismatch": m, }) if err != nil { return "", err } defer resp.Body.Close() var out struct { Phase string `json:"phase"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } return out.Phase, nil } 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 }