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/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}); 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 Client *http.Client } 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} } 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.Repo if h.Repository.FullName != "" { project = h.Repository.FullName } return sink.Append(g.event(h.Issue, "gitea", 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 } for _, i := range issues { if err := sink.Append(g.event(i, "gitea", g.Repo)); 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) }