223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
// Package operations contains read-side projections and operational observability.
|
|
package operations
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/store"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Brief struct {
|
|
From time.Time `json:"from"`
|
|
To time.Time `json:"to"`
|
|
Completed int `json:"completed"`
|
|
Failed int `json:"failed"`
|
|
Blocked int `json:"blocked"`
|
|
NeedsAttention []domain.Event `json:"needs_attention"`
|
|
Quota map[string]float64 `json:"quota_consumed"`
|
|
Receipts []CompletionReceipt `json:"receipts"`
|
|
// Git is keyed by project ID (spec §7.4: "what pushed, what's on which
|
|
// branch, what workpc still needs to pull" is a per-project question,
|
|
// not a single global answer read off the event-log directory).
|
|
Git map[string]GitSync `json:"git_sync"`
|
|
}
|
|
type GitSync struct {
|
|
Branch string `json:"branch"`
|
|
Head string `json:"head"`
|
|
Status string `json:"status"`
|
|
// Ahead/Behind are counts vs the branch's upstream, when one is
|
|
// configured — "what pushed" and "what workpc still needs to pull"
|
|
// (§7.4). Zero when there is no upstream (e.g. detached HEAD).
|
|
Ahead int `json:"ahead,omitempty"`
|
|
Behind int `json:"behind,omitempty"`
|
|
}
|
|
|
|
// CompletionReceipt names the proof a completion carries (§7.4: "what
|
|
// pushed... and the receipts for every completion"), pulled out of
|
|
// TaskCompleted's payload rather than just counted.
|
|
type CompletionReceipt struct {
|
|
TaskID string `json:"task_id"`
|
|
ReportRef string `json:"report_ref"`
|
|
Receipt map[string]any `json:"receipt"`
|
|
}
|
|
|
|
type StandupItem struct {
|
|
TaskID string `json:"task_id"`
|
|
State domain.TaskState `json:"state"`
|
|
Title string `json:"title,omitempty"`
|
|
}
|
|
|
|
// QuotaReceipt is the native usage receipt produced by a harness. Receipts
|
|
// are additive: a task that crosses rotations contributes each rotation's
|
|
// receipt to every window containing it.
|
|
type QuotaReceipt struct {
|
|
HarnessID string `json:"harness_id"`
|
|
Consumed float64 `json:"consumed"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// QuotaWindows publishes the two routing windows from the same additive
|
|
// per-lease receipts used by the scheduler.
|
|
type QuotaWindows struct {
|
|
FiveHour map[string]float64 `json:"five_hour"`
|
|
Weekly map[string]float64 `json:"weekly"`
|
|
}
|
|
|
|
func ProjectQuotaWindows(events []domain.Event, now time.Time) QuotaWindows {
|
|
return QuotaWindows{FiveHour: AggregateQuota(events, now.Add(-5*time.Hour), now), Weekly: AggregateQuota(events, now.Add(-7*24*time.Hour), now)}
|
|
}
|
|
|
|
// AggregateQuota sums native receipts in the requested window. It does not
|
|
// de-duplicate rotations or use a cumulative session total.
|
|
func AggregateQuota(events []domain.Event, from, to time.Time) map[string]float64 {
|
|
out := map[string]float64{}
|
|
for _, e := range events {
|
|
if e.Type != "QuotaReported" || e.At.Before(from) || e.At.After(to) {
|
|
continue
|
|
}
|
|
var r QuotaReceipt
|
|
if json.Unmarshal(e.Payload, &r) == nil && r.HarnessID != "" && r.Consumed >= 0 {
|
|
out[r.HarnessID] += r.Consumed
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func StandupItems(tasks []domain.Task) []StandupItem {
|
|
out := make([]StandupItem, 0)
|
|
for _, t := range tasks {
|
|
if t.State == domain.StateQueued || t.State == domain.StateLeased || t.State == domain.StateNeedsAttention || t.State == domain.StateBlocked {
|
|
out = append(out, StandupItem{t.ID, t.State, t.Title})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BuildBrief folds only events in [from,to]. It is deliberately read-only.
|
|
func BuildBrief(events []domain.Event, from, to time.Time, git map[string]GitSync) Brief {
|
|
b := Brief{From: from, To: to, Quota: map[string]float64{}, Git: git}
|
|
for _, e := range events {
|
|
if e.At.Before(from) || e.At.After(to) {
|
|
continue
|
|
}
|
|
var p map[string]any
|
|
_ = json.Unmarshal(e.Payload, &p)
|
|
switch e.Type {
|
|
case "TaskCompleted":
|
|
b.Completed++
|
|
receipt, _ := p["receipt"].(map[string]any)
|
|
reportRef, _ := p["report_ref"].(string)
|
|
b.Receipts = append(b.Receipts, CompletionReceipt{TaskID: e.TaskID, ReportRef: reportRef, Receipt: receipt})
|
|
case "TaskFailed":
|
|
b.Failed++
|
|
b.NeedsAttention = append(b.NeedsAttention, e)
|
|
case "TaskBlocked":
|
|
b.Blocked++
|
|
b.NeedsAttention = append(b.NeedsAttention, e)
|
|
case "TaskNeedsAttention":
|
|
b.NeedsAttention = append(b.NeedsAttention, e)
|
|
case "ApprovalRequested":
|
|
b.NeedsAttention = append(b.NeedsAttention, e)
|
|
}
|
|
}
|
|
b.Quota = AggregateQuota(events, from, to)
|
|
return b
|
|
}
|
|
|
|
// GenerateStandupAdvisory creates a persisted, read-only recommendation.
|
|
// Applying it is intentionally a separate approval-gated operation.
|
|
func GenerateStandupAdvisory(s *store.Store, at time.Time) (domain.Event, error) {
|
|
items := StandupItems(s.Tasks())
|
|
p, err := json.Marshal(map[string]any{"items": items, "generated_at": at.UTC()})
|
|
if err != nil {
|
|
return domain.Event{}, err
|
|
}
|
|
e := domain.Event{ID: domain.NewID(), TaskID: "system", Type: "StandupAdvisory", Payload: p, At: at, Surface: string(authz.System)}
|
|
return e, s.Append(e)
|
|
}
|
|
|
|
// ApplyAdvisory applies only approved title recommendations. Unknown or
|
|
// malformed recommendations are ignored, while event conflicts are returned.
|
|
func ApplyAdvisory(s *store.Store, advisoryID string) ([]domain.Event, error) {
|
|
var advisory *domain.Event
|
|
approved := false
|
|
for _, e := range s.Events(0) {
|
|
if e.ID == advisoryID && e.Type == "StandupAdvisory" {
|
|
x := e
|
|
advisory = &x
|
|
}
|
|
if e.Type == "ApprovalGranted" {
|
|
var p struct {
|
|
SubjectRef string `json:"subject_ref"`
|
|
}
|
|
_ = json.Unmarshal(e.Payload, &p)
|
|
if p.SubjectRef == advisoryID {
|
|
approved = true
|
|
}
|
|
}
|
|
}
|
|
if advisory == nil {
|
|
return nil, fmt.Errorf("advisory %q not found", advisoryID)
|
|
}
|
|
if !approved {
|
|
return nil, fmt.Errorf("advisory %q is not approved", advisoryID)
|
|
}
|
|
var p struct {
|
|
Items []StandupItem `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(advisory.Payload, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
var out []domain.Event
|
|
for _, item := range p.Items {
|
|
if item.TaskID == "" || item.Title == "" {
|
|
continue
|
|
}
|
|
t, ok := s.Task(item.TaskID)
|
|
if !ok || t.Title == item.Title {
|
|
continue
|
|
}
|
|
b, _ := json.Marshal(map[string]any{"title": item.Title, "advisory_ref": advisoryID})
|
|
e := domain.Event{ID: domain.NewID(), TaskID: item.TaskID, Type: "TaskAmended", Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
|
|
if err := s.Append(e); err != nil {
|
|
return out, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GitState reports local checkout state for the brief. Git failures are visible,
|
|
// never silently interpreted as synchronized.
|
|
func GitState(dir string) GitSync {
|
|
g := GitSync{Status: "unavailable"}
|
|
run := func(args ...string) string {
|
|
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
g.Branch, g.Head = run("branch", "--show-current"), run("rev-parse", "HEAD")
|
|
if s := run("status", "--porcelain"); s != "" {
|
|
g.Status = "dirty"
|
|
} else if g.Head != "" {
|
|
g.Status = "clean"
|
|
}
|
|
if g.Head == "" {
|
|
g.Status = fmt.Sprintf("git unavailable (%s)", dir)
|
|
}
|
|
if counts := run("rev-list", "--left-right", "--count", "@{u}...HEAD"); counts != "" {
|
|
var behind, ahead int
|
|
if n, _ := fmt.Sscanf(counts, "%d\t%d", &behind, &ahead); n == 2 {
|
|
g.Behind, g.Ahead = behind, ahead
|
|
}
|
|
}
|
|
return g
|
|
}
|