Files
orchestra/internal/provider/provider.go
T
kami ce6f02f9e6 checkpoint: multi-repo Gitea ingestion, per-project repos, rotation anchor_sha fix
Pre-existing uncommitted work found at session start: rotation now emits
anchor_sha on TaskReleased (previously silently dropped by store.Append
validation), multi-repo Gitea provider support, per-project git worktree
roots, and associated test coverage. Committing as a checkpoint before
starting remediation work tracked in AUDIT.md.
2026-07-27 18:15:02 +04:00

429 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)
if err := sink.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: domain.NewID(), Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
return count, fmt.Errorf("line %d: %w", line, err)
}
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)
}
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "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
}
}
return sink.Append(g.event(h.Issue, g.sourceName(), project))
}
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 {
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)
}