Files
orchestra/internal/provider/provider.go
T
kami 09e572f11e Carry the Gitea issue body into the task
Found by reading .orchestra/launch.md on the first burn-in task. The rendered
context said `Acceptance: Not stated.` and carried the issue title as the whole
goal, because Gitea.event parsed the issue body and then dropped it from the
TaskCreated payload.

The body is the task's own statement of what it wants, which every rendered
context ranks above continuity and below only a human decision. The store
already reads `description` from TaskCreated; only the provider was silent.

Every Gitea-sourced task so far has therefore run on its title alone. Classify
as an authority bug, not a model-following failure: no agent could have known
what it was not told.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:53:36 +04:00

439 lines
12 KiB
Go

package provider
import (
"bufio"
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
)
type Sink interface{ Append(domain.Event) error }
type Provider interface {
Ingest(io.Reader, Sink) (int, error)
}
type Reflector interface{ Reflect(domain.Event) error }
type TaskReflector interface {
ReflectTask(domain.Task, domain.Event) error
}
type TaskLookup interface {
Task(string) (domain.Task, bool)
}
// ReflectingSink preserves the append-first rule while asynchronously
// reflecting terminal state to an external provider. Reflection failures are
// returned to the caller so the supervisor can retry and surface health.
type ReflectingSink struct {
Sink Sink
Tasks TaskLookup
Reflector TaskReflector
}
func (s ReflectingSink) Append(e domain.Event) error {
if err := s.Sink.Append(e); err != nil {
return err
}
if s.Reflector == nil || s.Tasks == nil {
return nil
}
if t, ok := s.Tasks.Task(e.TaskID); ok {
return s.Reflector.ReflectTask(t, e)
}
return nil
}
type Health struct {
Name string `json:"name"`
Running bool `json:"running"`
LastError string `json:"last_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// Supervise restarts a provider loop with bounded backoff and exposes its
// current health. The loop exits only when its context is cancelled.
type Supervisor struct {
Name string
Run func(context.Context) error
Backoff time.Duration
mu sync.RWMutex
health Health
}
func (s *Supervisor) Start(ctx context.Context) {
if s.Backoff <= 0 {
s.Backoff = time.Second
}
s.mu.Lock()
s.health = Health{Name: s.Name, Running: true, UpdatedAt: time.Now().UTC()}
s.mu.Unlock()
go func() {
for {
err := s.Run(ctx)
if ctx.Err() != nil {
s.mu.Lock()
s.health.Running = false
s.health.UpdatedAt = time.Now().UTC()
s.mu.Unlock()
return
}
s.mu.Lock()
s.health.LastError = errString(err)
s.health.UpdatedAt = time.Now().UTC()
s.mu.Unlock()
t := time.NewTimer(s.Backoff)
select {
case <-ctx.Done():
t.Stop()
return
case <-t.C:
}
}
}()
}
func errString(err error) string {
if err == nil {
return "provider stopped"
}
return err.Error()
}
func (s *Supervisor) Health() Health { s.mu.RLock(); defer s.mu.RUnlock(); return s.health }
type JSONL struct{ Source string }
func (j JSONL) Ingest(r io.Reader, sink Sink) (int, error) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 4*1024*1024)
count, line := 0, 0
for sc.Scan() {
line++
raw := bytes.TrimSpace(sc.Bytes())
if len(raw) == 0 {
continue
}
var p map[string]any
if err := json.Unmarshal(raw, &p); err != nil {
return count, fmt.Errorf("line %d: %w", line, err)
}
if j.Source != "" {
p["source"] = j.Source
}
if err := domain.ValidateCreated(p); err != nil {
return count, fmt.Errorf("line %d: %w", line, err)
}
b, _ := json.Marshal(p)
err := sink.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b, Surface: string(authz.System)})
if err != nil && !errors.Is(err, domain.ErrDuplicate) {
return count, fmt.Errorf("line %d: %w", line, err)
}
if err == nil {
count++
}
}
return count, sc.Err()
}
// JSONLWatcher ingests only newly appended lines and tolerates file rotation.
type JSONLWatcher struct {
Path string
Interval time.Duration
Provider JSONL
}
func (w JSONLWatcher) Run(ctx context.Context, sink Sink) error {
if w.Interval <= 0 {
w.Interval = time.Second
}
if w.Provider.Source == "" {
w.Provider.Source = "jsonl"
}
var offset int64
for {
f, err := os.Open(w.Path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(w.Interval):
continue
}
}
return err
}
if st, _ := f.Stat(); st.Size() < offset {
offset = 0
}
if _, err = f.Seek(offset, io.SeekStart); err != nil {
f.Close()
return err
}
n, err := w.Provider.Ingest(f, sink)
pos, _ := f.Seek(0, io.SeekCurrent)
offset = pos
f.Close()
if err != nil {
return err
}
_ = n
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(w.Interval):
}
}
}
type Gitea struct {
BaseURL, Token, WebhookSecret, Owner, Repo string
// Project, if set, is the orchestra project id ingested tasks are
// tagged with (registry.Project.ID) and the key this source is
// dispatched under in MultiGitea. Deployments with a single Gitea repo
// may leave it empty, in which case Repo is used as both — preserving
// the historical single-source behavior.
Project string
Client *http.Client
}
// sourceName is the provider "source" every ingested TaskCreated carries,
// and the (source,external_id) idempotency/reflection key. It is namespaced
// per project so issue numbers from two different Gitea repos never
// collide in the dedup key, and so MultiGitea can route a TaskCompleted's
// reflection back to the correct repo.
func (g Gitea) sourceName() string { return g.SourceName() }
// SourceName is the exported form of sourceName, for callers (e.g.
// cmd/orchestra) that build a MultiGitea{Sources: ...} map.
func (g Gitea) SourceName() string {
if g.Project == "" {
return "gitea"
}
return "gitea:" + g.Project
}
// GiteaSourceConfig describes one Gitea repo to ingest from/reflect to.
// Load a list of these from JSON (ORCHESTRA_GITEA_CONFIG) to run more than
// one Gitea-backed project side by side — each project may have its own
// repo, owner, and credentials.
type GiteaSourceConfig struct {
Project string `json:"project"`
BaseURL string `json:"base_url"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Token string `json:"token"`
WebhookSecret string `json:"webhook_secret"`
}
// LoadGiteaConfigs reads a JSON array of GiteaSourceConfig from path.
func LoadGiteaConfigs(path string) ([]GiteaSourceConfig, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var out []GiteaSourceConfig
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("gitea config: %w", err)
}
seen := map[string]bool{}
for _, c := range out {
if c.Project == "" || c.BaseURL == "" || c.Owner == "" || c.Repo == "" {
return nil, fmt.Errorf("gitea config: project, base_url, owner, and repo are required (got %+v)", c)
}
if seen[c.Project] {
return nil, fmt.Errorf("gitea config: duplicate project %q", c.Project)
}
seen[c.Project] = true
}
return out, nil
}
// MultiGitea dispatches TaskReflector reflection to whichever Gitea source
// ingested the task, keyed by Gitea.sourceName(). This lets several Gitea
// repos (one per project) share a single ReflectingSink.
type MultiGitea struct{ Sources map[string]Gitea }
func (m MultiGitea) ReflectTask(task domain.Task, e domain.Event) error {
g, ok := m.Sources[task.Source]
if !ok {
return nil
}
return g.ReflectTask(task, e)
}
type giteaIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Labels []struct {
Name string `json:"name"`
} `json:"labels"`
}
type giteaWebhook struct {
Action string `json:"action"`
Issue giteaIssue `json:"issue"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
func (g Gitea) client() *http.Client {
if g.Client != nil {
return g.Client
}
return http.DefaultClient
}
func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
caps := []string{}
for _, l := range issue.Labels {
caps = append(caps, l.Name)
}
// The body is the task's own statement of what it wants, which is rank-one
// authority in every rendered context. Dropping it here made every
// Gitea-sourced task run on its title alone, with "Acceptance: Not stated."
// Found on the first burn-in task, 2026-08-26.
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": issue.Body, "capability": caps}
b, _ := json.Marshal(p)
return domain.Event{ID: domain.NewID(), TaskID: domain.NewID(), Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)}
}
func (g Gitea) IngestWebhook(body []byte, signature string, sink Sink) error {
if !validSignature(body, signature, g.WebhookSecret) {
return errors.New("invalid webhook signature")
}
var h giteaWebhook
if err := json.Unmarshal(body, &h); err != nil {
return err
}
if h.Action == "closed" || h.Action == "deleted" {
return nil
}
project := g.Project
if project == "" {
project = g.Repo
if h.Repository.FullName != "" {
project = h.Repository.FullName
}
}
if err := sink.Append(g.event(h.Issue, g.sourceName(), project)); err != nil && !errors.Is(err, domain.ErrDuplicate) {
return err
}
return nil
}
func validSignature(body []byte, got, secret string) bool {
if secret == "" || got == "" {
return false
}
got = strings.TrimPrefix(got, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.ToLower(got)), []byte(want))
}
// WebhookHandler authenticates the request before decoding or appending it.
func (g Gitea) WebhookHandler(sink Sink) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if err = g.IngestWebhook(body, r.Header.Get("X-Gitea-Signature"), sink); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusAccepted)
})
}
func (g Gitea) Poll(ctx context.Context, sink Sink) (int, error) {
u := strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + g.Owner + "/" + g.Repo + "/issues?state=open"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
if g.Token != "" {
req.Header.Set("Authorization", "token "+g.Token)
}
resp, err := g.client().Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return 0, fmt.Errorf("gitea poll: %s", resp.Status)
}
var issues []giteaIssue
if err = json.NewDecoder(resp.Body).Decode(&issues); err != nil {
return 0, err
}
project := g.Project
if project == "" {
project = g.Repo
}
for _, i := range issues {
if err := sink.Append(g.event(i, g.sourceName(), project)); err != nil && !errors.Is(err, domain.ErrDuplicate) {
return 0, err
}
}
return len(issues), nil
}
func (g Gitea) Reflect(e domain.Event) error {
if e.Type != "TaskCompleted" && e.Type != "TaskBlocked" && e.Type != "TaskFailed" {
return nil
}
var p struct {
ExternalID string `json:"external_id"`
}
_ = json.Unmarshal(e.Payload, &p)
if p.ExternalID == "" {
return nil
}
u := strings.TrimRight(g.BaseURL, "/") + "/api/v1/repos/" + g.Owner + "/" + g.Repo + "/issues/" + p.ExternalID
body, _ := json.Marshal(map[string]any{"state": "closed"})
req, err := http.NewRequest(http.MethodPatch, u, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if g.Token != "" {
req.Header.Set("Authorization", "token "+g.Token)
}
resp, err := g.client().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("gitea reflect: %s", resp.Status)
}
return nil
}
// ReflectTask is the preferred reflection entry point because lifecycle events
// identify the internal task, while the external key lives on the task.
func (g Gitea) ReflectTask(task domain.Task, e domain.Event) error {
if e.Type != "TaskCompleted" && e.Type != "TaskBlocked" && e.Type != "TaskFailed" {
return nil
}
e.Payload, _ = json.Marshal(map[string]any{"external_id": task.ExternalID})
return g.Reflect(e)
}