09e572f11e
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>
439 lines
12 KiB
Go
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)
|
|
}
|