Files
orchestra/internal/provider/provider.go
T
kami a5d361b59f Read acceptance criteria from one recognized issue section
Gitea ingest set title, description and capability but never acceptance, so
every Gitea-sourced task rendered "Acceptance: Not stated." however carefully
its body was written. F2 restored the body; it did not wire the field.

The convention is deliberately tiny. A markdown heading spelled "acceptance"
or "acceptance criteria" opens the section, bullet and checklist items until
the next heading become the criteria in order, checkbox markers are stripped,
empty items are dropped, and the whole section leaves the description so a
criterion is never also read as instruction prose. Nothing else is recognized:
inferring acceptance from arbitrary prose would eventually invent a
requirement, and a fabricated criterion outranks every human decision beneath
it in the authority order.

An absent section yields no acceptance, which is not an ingestion failure. The
task renders "Not stated." and the frame phase resolves it through the
decision-request path, where a human answers instead of the parser guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:35:37 +04:00

495 lines
14 KiB
Go

package provider
import (
"bufio"
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"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
}
// acceptanceHeading opens the one recognized acceptance section. The
// convention is deliberately tiny: two spellings, markdown heading only. An
// ingestion that infers acceptance from arbitrary prose eventually invents
// requirements, and a fabricated acceptance criterion outranks every human
// decision below it in the authority order.
var acceptanceHeading = regexp.MustCompile(`(?i)^#{1,6}[ \t]*acceptance([ \t]+criteria)?[ \t]*:?[ \t]*$`)
// acceptanceItem matches a bullet or checklist item and captures its text.
var acceptanceItem = regexp.MustCompile(`^[ \t]*[-*+][ \t]+(?:\[[ xX]\][ \t]*)?(.*)$`)
var markdownHeading = regexp.MustCompile(`^#{1,6}[ \t]+`)
// splitAcceptance separates an issue body into the description and the
// acceptance criteria the issue stated for itself. Everything from the
// recognized heading to the next heading leaves the description, so a criterion
// is never also read as instruction prose. An empty result is not an ingestion
// failure: a task with no stated acceptance renders "Not stated." and the frame
// phase is where that gets resolved, through the decision-request path.
func splitAcceptance(body string) (string, []string) {
lines := strings.Split(body, "\n")
start := -1
for i, l := range lines {
if acceptanceHeading.MatchString(strings.TrimRight(l, " \t\r")) {
start = i
break
}
}
if start < 0 {
return body, nil
}
var acceptance []string
end := len(lines)
for i := start + 1; i < len(lines); i++ {
line := strings.TrimRight(lines[i], " \t\r")
if markdownHeading.MatchString(line) {
end = i
break
}
m := acceptanceItem.FindStringSubmatch(line)
if m == nil {
continue
}
if item := strings.TrimSpace(m[1]); item != "" {
acceptance = append(acceptance, item)
}
}
kept := append(append([]string{}, lines[:start]...), lines[end:]...)
return strings.Trim(strings.Join(kept, "\n"), "\n"), acceptance
}
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.
description, acceptance := splitAcceptance(issue.Body)
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": description, "capability": caps}
if len(acceptance) > 0 {
p["acceptance"] = acceptance
}
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)
}