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.
This commit is contained in:
@@ -73,7 +73,7 @@ func TestSubscribeEmitsCursorAndEvent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := json.Marshal(map[string]any{"source": "test", "external_id": "1", "project": "p"})
|
||||
if err := s.Append(domain.Event{ID: "e", TaskID: "t", Type: "TaskCreated", Version: 1, Payload: p}); err != nil {
|
||||
if err := s.Append(domain.Event{ID: "e", TaskID: "t", Type: "TaskCreated", Version: 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := (&Server{Store: s}).Subscribe
|
||||
|
||||
@@ -17,6 +17,13 @@ const (
|
||||
Web Surface = "web"
|
||||
MCP Surface = "mcp"
|
||||
Maven Surface = "maven"
|
||||
// System identifies the plane itself — the router, coordinator, provider
|
||||
// adapters, and lease-expiry reclaim. Per invariant 2 ("the plane emits
|
||||
// events, not the agent"), these are the only non-surface emitters and are
|
||||
// always full control. Every event must carry an explicit Surface; there
|
||||
// is no unauthenticated default, so an emitter that forgets to declare one
|
||||
// is rejected at the bus rather than silently treated as trusted.
|
||||
System Surface = "system"
|
||||
)
|
||||
|
||||
type Capability int
|
||||
@@ -33,7 +40,7 @@ func CapabilityFor(s Surface) Capability {
|
||||
switch s {
|
||||
case Telegram, Ntfy:
|
||||
return NotifyOnly
|
||||
case TUI, Web:
|
||||
case TUI, Web, System:
|
||||
return FullControl
|
||||
case MCP, Maven:
|
||||
return GatedWrite
|
||||
|
||||
@@ -17,7 +17,11 @@ var ErrConflict = errors.New("task version conflict")
|
||||
var ErrNotFound = errors.New("task not found")
|
||||
var ErrInvalid = errors.New("invalid event")
|
||||
|
||||
const CurrentEventSchema = 1
|
||||
// CurrentEventSchema is 2: schema 2 requires every event to declare its
|
||||
// authorizing Surface (see ValidateEvent), enforced at the store append
|
||||
// boundary. Schema 1 events already on disk replay unchanged — tolerant
|
||||
// reader, not upcast (spec open question #2).
|
||||
const CurrentEventSchema = 2
|
||||
|
||||
type TaskState string
|
||||
|
||||
@@ -63,6 +67,12 @@ type Event struct {
|
||||
Version int `json:"version"`
|
||||
At time.Time `json:"at"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
// Surface identifies the bus capability the emitter is authorized under
|
||||
// (see internal/authz). It is required on every event so authorization is
|
||||
// enforced once, at the store append boundary, regardless of whether the
|
||||
// emitter reached the store over HTTP, from the router, from a harness
|
||||
// adapter, or from a provider.
|
||||
Surface string `json:"surface"`
|
||||
}
|
||||
|
||||
func Hash(v []byte) string { h := sha256.Sum256(v); return hex.EncodeToString(h[:]) }
|
||||
@@ -82,6 +92,9 @@ func ValidateEvent(e Event) error {
|
||||
if e.SchemaVersion > CurrentEventSchema || e.Type == "" || e.TaskID == "" || len(e.Payload) == 0 || len(e.Payload) > 64*1024 {
|
||||
return ErrInvalid
|
||||
}
|
||||
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
|
||||
return fmt.Errorf("%w: surface required", ErrInvalid)
|
||||
}
|
||||
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskReleased": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "QuotaReported": true, "StandupAdvisory": true}
|
||||
if !allowed[e.Type] {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// eventTypesUnderTest is the full lifecycle vocabulary the spec (§9 item 5)
|
||||
// requires validators for; a validator that panics or accepts garbage for any
|
||||
// of these on adversarial input is a defect regardless of whether real
|
||||
// producers happen to send well-formed payloads.
|
||||
var eventTypesUnderTest = []string{
|
||||
"TaskCreated", "TaskLeased", "TaskReleased", "TaskCompleted", "TaskFailed",
|
||||
"TaskBlocked", "ApprovalRequested", "ApprovalGranted", "ApprovalDenied",
|
||||
"TaskAmended", "QuotaReported", "StandupAdvisory",
|
||||
}
|
||||
|
||||
// FuzzValidatePayload feeds arbitrary JSON object shapes at every known event
|
||||
// type's validator and requires it to either return a typed ErrInvalid or
|
||||
// accept — never panic. The seed corpus below exercises adjacent-to-valid and
|
||||
// wildly-malformed shapes (wrong types, huge strings, nested structures,
|
||||
// nulls, NaN-adjacent floats via JSON) for each type.
|
||||
func FuzzValidatePayload(f *testing.F) {
|
||||
seeds := []string{
|
||||
`{}`,
|
||||
`null`,
|
||||
`{"source":"jsonl","external_id":"1","project":"p"}`,
|
||||
`{"source":123,"external_id":null,"project":[]}`,
|
||||
`{"harness_id":"h1","ttl":60,"expected_version":1}`,
|
||||
`{"harness_id":"h1","until_ns":1e300,"expected_version":1.5}`,
|
||||
`{"handoff_ref":"` + fakeHash() + `","anchor_sha":"` + fakeSHA() + `"}`,
|
||||
`{"handoff_ref":123,"anchor_sha":true}`,
|
||||
`{"report_ref":"` + fakeHash() + `","receipt":{"harness_id":"h1","consumed":1}}`,
|
||||
`{"report_ref":"","receipt":{}}`,
|
||||
`{"reason":"x"}`,
|
||||
`{"reason":123}`,
|
||||
`{"blocker":"x","handoff_ref":"` + fakeHash() + `"}`,
|
||||
`{"blocker":""}`,
|
||||
`{"amendment":"x"}`,
|
||||
`{"subject_ref":"x","options":["a","b"]}`,
|
||||
`{"subject_ref":123,"options":null}`,
|
||||
`{"harness_id":"h1","consumed":90.5}`,
|
||||
`{"harness_id":"h1","consumed":-1}`,
|
||||
`{"harness_id":"h1","consumed":"a lot"}`,
|
||||
`{"items":["standup line"]}`,
|
||||
`{"items":null}`,
|
||||
`{"a":{"b":{"c":{"d":[1,2,3,{"e":"f"}]}}}}`,
|
||||
`{"x":` + hugeString() + `}`,
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
return // not a JSON object; ValidateEvent itself rejects non-objects before reaching ValidatePayload
|
||||
}
|
||||
for _, typ := range eventTypesUnderTest {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("ValidatePayload(%q, %s) panicked: %v", typ, raw, r)
|
||||
}
|
||||
}()
|
||||
err := ValidatePayload(typ, p)
|
||||
if err != nil && err != ErrInvalid {
|
||||
// must still be a typed validation error, wrapping ErrInvalid
|
||||
if !isInvalid(err) {
|
||||
t.Fatalf("ValidatePayload(%q, %s) returned non-typed error: %v", typ, raw, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzValidateEvent exercises the full envelope path (schema version, surface
|
||||
// requirement, type allow-list, payload size/parseability) with arbitrary
|
||||
// type names, surfaces, and payload bytes, proving no combination panics.
|
||||
func FuzzValidateEvent(f *testing.F) {
|
||||
f.Add("TaskCreated", "system", 2, []byte(`{"source":"jsonl","external_id":"1","project":"p"}`))
|
||||
f.Add("", "", 0, []byte(``))
|
||||
f.Add("Bogus", "system", 2, []byte(`{}`))
|
||||
f.Add("TaskCreated", "", 2, []byte(`{"source":"jsonl","external_id":"1","project":"p"}`))
|
||||
f.Add("TaskCreated", "system", 1, []byte(`not json`))
|
||||
f.Add("QuotaReported", "system", 2, []byte(`null`))
|
||||
f.Add("TaskCompleted", "system", 99, []byte(`{"report_ref":"x","receipt":{}}`))
|
||||
|
||||
f.Fuzz(func(t *testing.T, typ, surface string, schema int, payload []byte) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("ValidateEvent panicked: type=%q surface=%q schema=%d payload=%q: %v", typ, surface, schema, payload, r)
|
||||
}
|
||||
}()
|
||||
e := Event{
|
||||
SchemaVersion: schema,
|
||||
Type: typ,
|
||||
TaskID: "t1",
|
||||
Payload: payload,
|
||||
Surface: surface,
|
||||
}
|
||||
_ = ValidateEvent(e)
|
||||
})
|
||||
}
|
||||
|
||||
func isInvalid(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ErrInvalid {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fakeHash() string {
|
||||
b := make([]byte, 32)
|
||||
for i := range b {
|
||||
b[i] = byte(i)
|
||||
}
|
||||
s := ""
|
||||
for _, c := range b {
|
||||
s += string("0123456789abcdef"[c>>4]) + string("0123456789abcdef"[c&0xf])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func fakeSHA() string {
|
||||
s := ""
|
||||
for i := 0; i < 40; i++ {
|
||||
s += "a"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hugeString() string {
|
||||
b, _ := json.Marshal(make([]byte, 0))
|
||||
_ = b
|
||||
s := `"`
|
||||
for i := 0; i < 5000; i++ {
|
||||
s += "x"
|
||||
}
|
||||
return s + `"`
|
||||
}
|
||||
+120
-15
@@ -2,7 +2,9 @@ package herdr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -15,6 +17,10 @@ type Adapter interface {
|
||||
Occupancy(Session) (float64, error)
|
||||
}
|
||||
|
||||
type WorktreeCreator interface {
|
||||
CreateWorktree(context.Context, string, string, string) (string, error)
|
||||
}
|
||||
|
||||
// TurnBoundary is optional so older herdr deployments remain usable. A true
|
||||
// result means the current harness turn has ended and handoff is safe.
|
||||
type TurnBoundary interface {
|
||||
@@ -26,6 +32,18 @@ type RotationSignal interface {
|
||||
type PaneExit interface {
|
||||
PaneExited(context.Context, Session) (bool, error)
|
||||
}
|
||||
|
||||
// AgentStatus is a live, non-lifecycle status reported by herdr. Consumers
|
||||
// must not infer task completion or release from it.
|
||||
type AgentStatus interface {
|
||||
AgentStatus(context.Context, Session) (string, error)
|
||||
}
|
||||
type AgentBlocker interface {
|
||||
AgentBlocker(context.Context, Session) (string, error)
|
||||
}
|
||||
type PaneCapture interface {
|
||||
PaneCapture(context.Context, Session, string) (string, error)
|
||||
}
|
||||
type CLIAdapter struct {
|
||||
Client *Client
|
||||
Harness string
|
||||
@@ -33,15 +51,29 @@ type CLIAdapter struct {
|
||||
Usage func(string) (Usage, error)
|
||||
}
|
||||
|
||||
func (a CLIAdapter) CreateWorktree(ctx context.Context, repo, root, taskID string) (string, error) {
|
||||
path, err := a.Client.Worktree(ctx, repo, filepath.Join(root, taskID), "orchestra/"+taskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("adapter: herdr returned empty worktree path")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (a CLIAdapter) Lease(ctx context.Context, task, worktree string) (Session, error) {
|
||||
if a.Client == nil {
|
||||
return Session{}, fmt.Errorf("adapter: client required")
|
||||
}
|
||||
var s Session
|
||||
e := a.Client.Call(ctx, "pane.create", map[string]string{"harness": a.Harness, "task_id": task, "worktree": worktree}, &s)
|
||||
s.Harness = a.Harness
|
||||
s.Worktree = worktree
|
||||
return s, e
|
||||
s, err := a.Client.StartAgent(ctx, worktree, worktree, "orchestra/"+task, a.Harness, task)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
if err := a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf("Begin Orchestra task %s. Inspect the repository, understand the task context, and proceed with the requested work.", task), 0); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (a CLIAdapter) Bootstrap(ctx context.Context, s Session, ref string) error {
|
||||
return a.Client.Prompt(ctx, s.PaneID, fmt.Sprintf("Read handoff %s, validate the anchor and TASK.md, then continue.", ref), time.Minute)
|
||||
@@ -57,23 +89,96 @@ func (a CLIAdapter) Kill(ctx context.Context, s Session) error {
|
||||
return a.Client.Call(ctx, "pane.kill", s, nil)
|
||||
}
|
||||
func (a CLIAdapter) AtTurnBoundary(ctx context.Context, s Session) (bool, error) {
|
||||
var r struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.status", s, &r); err != nil {
|
||||
status, err := a.AgentStatus(ctx, s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !IsBusy(r.Status), nil
|
||||
return !IsBusy(status), nil
|
||||
}
|
||||
func (a CLIAdapter) PaneExited(ctx context.Context, s Session) (bool, error) {
|
||||
var r struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.status", s, &r); err != nil {
|
||||
status, err := a.AgentStatus(ctx, s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.EqualFold(r.Status, "exited") || strings.EqualFold(r.Status, "dead"), nil
|
||||
return strings.EqualFold(status, "exited") || strings.EqualFold(status, "dead"), nil
|
||||
}
|
||||
func (a CLIAdapter) AgentStatus(ctx context.Context, s Session) (string, error) {
|
||||
// Current herdr protocol exposes agent state through agent.get; older
|
||||
// Orchestra code used pane.status, which is not a valid protocol method.
|
||||
var r map[string]any
|
||||
if err := a.Client.Call(ctx, "agent.get", map[string]any{"target": s.PaneID}, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return statusFromAgentResult(r), nil
|
||||
}
|
||||
|
||||
func (a CLIAdapter) AgentBlocker(ctx context.Context, s Session) (string, error) {
|
||||
var r struct {
|
||||
Read struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"read"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": "recent"}, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := strings.TrimSpace(r.Read.Text)
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, raw := range lines {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "┃"))
|
||||
if !strings.EqualFold(line, "Permission required") && !strings.EqualFold(line, "Approval required") && !strings.HasPrefix(strings.ToLower(line), "waiting for") {
|
||||
continue
|
||||
}
|
||||
for _, next := range lines[i+1:] {
|
||||
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(next), "┃"))
|
||||
if strings.HasPrefix(command, "$ ") {
|
||||
return strings.ToLower(line) + ": shell command `" + strings.TrimSpace(strings.TrimPrefix(command, "$ ")) + "`", nil
|
||||
}
|
||||
}
|
||||
return strings.ToLower(line), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (a CLIAdapter) PaneCapture(ctx context.Context, s Session, source string) (string, error) {
|
||||
if source == "" {
|
||||
source = "recent"
|
||||
}
|
||||
var r struct {
|
||||
Read struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"read"`
|
||||
}
|
||||
if err := a.Client.Call(ctx, "pane.read", map[string]any{"pane_id": s.PaneID, "source": source}, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Read.Text, nil
|
||||
}
|
||||
|
||||
func statusFromAgentResult(v any) string {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
for _, key := range []string{"status", "agent_status", "state"} {
|
||||
if s, ok := m[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for _, child := range m {
|
||||
if s := statusFromAgentResult(child); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
if a, ok := v.([]any); ok {
|
||||
for _, child := range a {
|
||||
if s := statusFromAgentResult(child); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var _ = json.RawMessage{}
|
||||
|
||||
func (a CLIAdapter) RotationSignal(ctx context.Context, s Session) (string, error) {
|
||||
var r struct {
|
||||
Reason string `json:"reason"`
|
||||
|
||||
+113
-14
@@ -13,6 +13,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -22,13 +23,15 @@ var ErrProtocol = errors.New("herdr protocol error")
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
// Herdr's JSON-RPC decoder requires params to be present, including for
|
||||
// parameterless calls such as ping. Encode nil as an explicit JSON null.
|
||||
Params any `json:"params"`
|
||||
}
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
@@ -38,14 +41,39 @@ type Client struct {
|
||||
dial func() (net.Conn, error)
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
panes map[string]string
|
||||
}
|
||||
|
||||
func New(path string) *Client { return &Client{Path: path, Timeout: 10 * time.Second} }
|
||||
type WorktreeInfo struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type worktreeResponse struct {
|
||||
Path string `json:"path"`
|
||||
Worktree WorktreeInfo `json:"worktree"`
|
||||
RootPane struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
Agent string `json:"agent"`
|
||||
} `json:"root_pane"`
|
||||
Workspace struct {
|
||||
RootPane struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
} `json:"root_pane"`
|
||||
} `json:"workspace"`
|
||||
}
|
||||
|
||||
func New(path string) *Client {
|
||||
return &Client{Path: path, Timeout: 10 * time.Second, panes: map[string]string{}}
|
||||
}
|
||||
func (c *Client) conn() (net.Conn, error) {
|
||||
if c.dial != nil {
|
||||
return c.dial()
|
||||
}
|
||||
return net.DialTimeout("unix", c.Path, c.Timeout)
|
||||
network := "unix"
|
||||
if strings.Contains(c.Path, "://") || (strings.Contains(c.Path, ":") && !strings.HasPrefix(c.Path, "/")) {
|
||||
network = "tcp"
|
||||
}
|
||||
return net.DialTimeout(network, c.Path, c.Timeout)
|
||||
}
|
||||
func (c *Client) Call(ctx context.Context, method string, params any, out any) error {
|
||||
c.mu.Lock()
|
||||
@@ -62,6 +90,9 @@ func (c *Client) Call(ctx context.Context, method string, params any, out any) e
|
||||
} else if c.Timeout > 0 {
|
||||
_ = cn.SetDeadline(time.Now().Add(c.Timeout))
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if err = json.NewEncoder(cn).Encode(Request{ID: id, Method: method, Params: params}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,8 +110,8 @@ func (c *Client) Call(ctx context.Context, method string, params any, out any) e
|
||||
}
|
||||
|
||||
type PingResult struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Version string `json:"version"`
|
||||
Protocol json.RawMessage `json:"protocol"`
|
||||
Version json.RawMessage `json:"version"`
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
|
||||
@@ -93,8 +124,14 @@ func (c *Client) CheckProtocol(ctx context.Context, want string) error {
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if want != "" && p.Protocol != want {
|
||||
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, p.Protocol)
|
||||
if want != "" {
|
||||
var text string
|
||||
if err := json.Unmarshal(p.Protocol, &text); err != nil {
|
||||
text = string(p.Protocol)
|
||||
}
|
||||
if text != want {
|
||||
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, text)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -103,18 +140,80 @@ type Session struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
Worktree string `json:"worktree"`
|
||||
Harness string `json:"harness"`
|
||||
HerdrID string `json:"herdr_id,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
||||
p := map[string]any{"pane_id": pane, "prompt": text, "wait": map[string]any{"until": "turn_end", "timeout_ms": wait.Milliseconds()}}
|
||||
p := map[string]any{"target": pane, "text": text}
|
||||
if wait > 0 {
|
||||
p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()}
|
||||
}
|
||||
return c.Call(ctx, "agent.prompt", p, nil)
|
||||
}
|
||||
func (c *Client) Worktree(ctx context.Context, path, branch string) (string, error) {
|
||||
var r struct {
|
||||
Path string `json:"path"`
|
||||
func (c *Client) Worktree(ctx context.Context, cwd, path, branch string) (string, error) {
|
||||
var r worktreeResponse
|
||||
// Protocol 17 requires exactly one of path or branch. Use the explicit
|
||||
// path so the worker owns the checkout location; herdr manages the branch
|
||||
// associated with that worktree.
|
||||
p := map[string]any{"cwd": cwd, "path": path}
|
||||
e := c.Call(ctx, "worktree.create", p, &r)
|
||||
if e != nil && strings.Contains(strings.ToLower(e.Error()), "already exists") {
|
||||
e = c.Call(ctx, "worktree.open", map[string]any{"cwd": cwd, "path": path}, &r)
|
||||
}
|
||||
e := c.Call(ctx, "worktree.create", map[string]string{"path": path, "branch": branch}, &r)
|
||||
return r.Path, e
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
if r.RootPane.PaneID != "" {
|
||||
c.mu.Lock()
|
||||
c.panes[path] = r.RootPane.PaneID
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if r.Path != "" {
|
||||
return r.Path, nil
|
||||
}
|
||||
return r.Worktree.Path, nil
|
||||
}
|
||||
|
||||
func (c *Client) StartAgent(ctx context.Context, cwd, path, branch, harness, taskID string) (Session, error) {
|
||||
c.mu.Lock()
|
||||
paneID := c.panes[path]
|
||||
c.mu.Unlock()
|
||||
if paneID == "" {
|
||||
return Session{}, fmt.Errorf("herdr: no pane recorded for worktree %s", path)
|
||||
}
|
||||
var s Session
|
||||
if err := c.Call(ctx, "agent.start", map[string]any{
|
||||
"pane_id": paneID,
|
||||
"kind": harness,
|
||||
"name": harness,
|
||||
"args": []string{},
|
||||
}, &s); err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "already") {
|
||||
return Session{}, err
|
||||
}
|
||||
}
|
||||
s.PaneID = paneID
|
||||
s.Worktree = path
|
||||
s.Harness = harness
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HeadSHA returns the current commit of a worktree. The rotation path uses
|
||||
// this to populate TaskReleased.anchor_sha without trusting the adapter's
|
||||
// opaque handoff-ref return value.
|
||||
func HeadSHA(root string) (string, error) {
|
||||
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sha := string(out)
|
||||
if len(sha) > 0 && sha[len(sha)-1] == '\n' {
|
||||
sha = sha[:len(sha)-1]
|
||||
}
|
||||
if len(sha) != 40 {
|
||||
return "", fmt.Errorf("herdr: unexpected HEAD output %q", sha)
|
||||
}
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
// AnchorValid checks the split-then-close safety condition without trusting a
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/herdr"
|
||||
"orchestra/internal/orchestrator"
|
||||
@@ -128,7 +129,7 @@ func TestEndToEndIngestRouteLeaseRotateAndComplete(t *testing.T) {
|
||||
}
|
||||
got, _ := s.Task(task.ID)
|
||||
if got.State == domain.StateLeased && h.releases == 1 {
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: got.Version + 1, Payload: mustJSON(map[string]string{
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: got.Version + 1, Surface: string(authz.System), Payload: mustJSON(map[string]string{
|
||||
"handoff_ref": h.ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
})}); err != nil {
|
||||
@@ -153,7 +154,7 @@ func TestEndToEndIngestRouteLeaseRotateAndComplete(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: task.ID, Version: got.Version + 1, Payload: mustJSON(map[string]any{
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: task.ID, Version: got.Version + 1, Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"report_ref": ref,
|
||||
"receipt": map[string]any{"harness_id": "h1", "consumed": 1},
|
||||
})}); err != nil {
|
||||
@@ -181,7 +182,7 @@ func TestRestartReplayAndReconcileKillsOrphan(t *testing.T) {
|
||||
}
|
||||
// A fresh coordinator sees the durable session, then drops it once the lease is gone.
|
||||
ref, _ := s.PutArtifact([]byte("handoff"))
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: 3, Payload: mustJSON(map[string]string{
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: 3, Surface: string(authz.System), Payload: mustJSON(map[string]string{
|
||||
"handoff_ref": ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
})}); err != nil {
|
||||
@@ -207,10 +208,10 @@ func TestRestartReplayAndReconcileKillsOrphan(t *testing.T) {
|
||||
func TestProviderRetryReflectionQuotaAndVersionConflict(t *testing.T) {
|
||||
s, r, _ := setup(t)
|
||||
task := ingest(t, s, "retry")
|
||||
if err := s.Append(domain.Event{Type: "QuotaReported", TaskID: "quota", Version: 1, Payload: mustJSON(map[string]any{"harness_id": "h1", "consumed": 90.0})}); err != nil {
|
||||
if err := s.Append(domain.Event{Type: "QuotaReported", TaskID: "quota", Version: 1, Surface: string(authz.System), Payload: mustJSON(map[string]any{"harness_id": "h1", "consumed": 90.0})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := router.Router{Store: s, Registry: r, Reachability: alwaysReachable{}, Availability: router.QuotaAvailability{Store: s, Limits: map[string]float64{"h1": 100}, Now: time.Now}}
|
||||
rt := router.Router{Store: s, Registry: r, Reachability: alwaysReachable{}, Availability: router.QuotaAvailability{Store: s, Limits: map[string]router.QuotaWindowLimits{"h1": {Weekly: 100}}, Now: time.Now}}
|
||||
if got, _ := rt.AssignPending(); len(got) != 0 {
|
||||
t.Fatalf("quota assigned=%d", len(got))
|
||||
}
|
||||
@@ -225,7 +226,7 @@ func TestProviderRetryReflectionQuotaAndVersionConflict(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reflector := &fakeReflector{}
|
||||
if err := (provider.ReflectingSink{Sink: s, Tasks: s, Reflector: reflector}).Append(domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: task.ID, Version: 3, Payload: mustJSON(map[string]any{
|
||||
if err := (provider.ReflectingSink{Sink: s, Tasks: s, Reflector: reflector}).Append(domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: task.ID, Version: 3, Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"report_ref": ref,
|
||||
"receipt": map[string]any{"harness_id": "h1", "consumed": 1},
|
||||
})}); err != nil {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/federation"
|
||||
"orchestra/internal/herdr"
|
||||
"orchestra/internal/operations"
|
||||
"orchestra/internal/orchestrator"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/router"
|
||||
"orchestra/internal/store"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func gitInit(t *testing.T, dir, marker string) string {
|
||||
t.Helper()
|
||||
run := func(args ...string) {
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init")
|
||||
run("config", "user.email", "t@t")
|
||||
run("config", "user.name", "t")
|
||||
run("commit", "--allow-empty", "-m", "init: "+marker)
|
||||
head, err := herdr.HeadSHA(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
type fixedWorktree struct{ path string }
|
||||
|
||||
func (w fixedWorktree) Create(context.Context, domain.Task) (string, error) { return w.path, nil }
|
||||
|
||||
// machineAdapter is like harness but actually threads the worktree it was
|
||||
// leased against into the Session, the way a real herdr adapter must — the
|
||||
// shared harness fixture ignores it, which is fine for single-checkout
|
||||
// tests but would silently defeat this one's anchor_sha assertions.
|
||||
type machineAdapter struct{ *harness }
|
||||
|
||||
func (a machineAdapter) Lease(_ context.Context, _ string, worktree string) (herdr.Session, error) {
|
||||
return herdr.Session{Harness: "h1", PaneID: "pane-1", Worktree: worktree}, nil
|
||||
}
|
||||
|
||||
type singleAdapter struct{ a herdr.Adapter }
|
||||
|
||||
func (s singleAdapter) Adapter(string) (herdr.Adapter, error) { return s.a, nil }
|
||||
|
||||
// TestCrossMachineLeaseAnchorAndQuotaArePerHost exercises spec §2.1/§9 open
|
||||
// question 8 end to end using two independent local git checkouts standing
|
||||
// in for homesrv and workpc: each machine's Coordinator only ever validates
|
||||
// and stamps anchor_sha from its OWN local checkout (never the other
|
||||
// machine's), and quota consumption is accounted strictly per harness/host
|
||||
// so one machine exhausting its window cannot block the other from being
|
||||
// leased work. Federation registration/heartbeat plumbing (already covered
|
||||
// by internal/federation's own tests) is exercised alongside this to prove
|
||||
// the pieces fit together, not just in isolation.
|
||||
func TestCrossMachineLeaseAnchorAndQuotaArePerHost(t *testing.T) {
|
||||
homesrvRepo, workpcRepo := t.TempDir(), t.TempDir()
|
||||
homesrvHead := gitInit(t, homesrvRepo, "homesrv")
|
||||
workpcHead := gitInit(t, workpcRepo, "workpc")
|
||||
if homesrvHead == workpcHead {
|
||||
t.Fatal("test setup: expected distinct checkouts")
|
||||
}
|
||||
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The federation registry is homesrv's view of workpc as an intermittent
|
||||
// worker (spec §2.1): it must be registered and reachable before the
|
||||
// router would ever consider leasing to it.
|
||||
workers := &federation.Registry{}
|
||||
if err := workers.Register(federation.Worker{ID: "workpc", Address: "workpc.mesh", Token: "secret"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := workers.Heartbeat("workpc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
online := false
|
||||
for _, w := range workers.Snapshot() {
|
||||
if w.ID == "workpc" && w.Online {
|
||||
online = true
|
||||
}
|
||||
}
|
||||
if !online {
|
||||
t.Fatal("workpc worker should be online after heartbeat")
|
||||
}
|
||||
|
||||
// Two tasks, one leased to each machine's harness.
|
||||
mk := func(id string) domain.Task {
|
||||
b, _ := json.Marshal(map[string]any{"source": "jsonl", "external_id": id, "project": "p"})
|
||||
if err := s.Append(domain.Event{ID: id, Type: "TaskCreated", TaskID: id, Version: 1, Surface: string(authz.System), Payload: b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ := s.Task(id)
|
||||
return task
|
||||
}
|
||||
homesrvTask := mk("home-task")
|
||||
workpcTask := mk("workpc-task")
|
||||
|
||||
homesrvAdapter := &harness{occupancy: .95, boundary: true, ref: mustArtifact(t, s, "home-handoff")}
|
||||
workpcAdapter := &harness{occupancy: .95, boundary: true, ref: mustArtifact(t, s, "workpc-handoff")}
|
||||
|
||||
homesrvCoord := &orchestrator.Coordinator{Store: s, Worktrees: fixedWorktree{homesrvRepo}, Adapters: singleAdapter{machineAdapter{homesrvAdapter}}, StatePath: t.TempDir() + "/home-sessions.json"}
|
||||
workpcCoord := &orchestrator.Coordinator{Store: s, Worktrees: fixedWorktree{workpcRepo}, Adapters: singleAdapter{machineAdapter{workpcAdapter}}, StatePath: t.TempDir() + "/workpc-sessions.json"}
|
||||
|
||||
homesrvLease, err := s.Lease(homesrvTask.ID, "homesrv-h1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := homesrvCoord.Start(context.Background(), homesrvLease); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workpcLease, err := s.Lease(workpcTask.ID, "workpc-h1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := workpcCoord.Start(context.Background(), workpcLease); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctxHome, cancelHome := context.WithCancel(context.Background())
|
||||
defer cancelHome()
|
||||
ctxWork, cancelWork := context.WithCancel(context.Background())
|
||||
defer cancelWork()
|
||||
go homesrvCoord.Monitor(ctxHome, .8, time.Millisecond)
|
||||
go workpcCoord.Monitor(ctxWork, .8, time.Millisecond)
|
||||
|
||||
waitQueued := func(id string) domain.Task {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if t, ok := s.Task(id); ok && t.State == domain.StateQueued {
|
||||
return t
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("task %s never rotated back to queued", id)
|
||||
return domain.Task{}
|
||||
}
|
||||
waitQueued(homesrvTask.ID)
|
||||
waitQueued(workpcTask.ID)
|
||||
|
||||
// Each machine's coordinator must have stamped anchor_sha from its OWN
|
||||
// checkout — never the other machine's HEAD, and never each other's.
|
||||
anchorFor := func(taskID string) string {
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID != taskID || e.Type != "TaskReleased" {
|
||||
continue
|
||||
}
|
||||
var p map[string]any
|
||||
_ = json.Unmarshal(e.Payload, &p)
|
||||
return p["anchor_sha"].(string)
|
||||
}
|
||||
t.Fatalf("no TaskReleased found for %s", taskID)
|
||||
return ""
|
||||
}
|
||||
if got := anchorFor(homesrvTask.ID); got != homesrvHead {
|
||||
t.Fatalf("homesrv anchor_sha=%s want=%s (must validate against its own checkout)", got, homesrvHead)
|
||||
}
|
||||
if got := anchorFor(workpcTask.ID); got != workpcHead {
|
||||
t.Fatalf("workpc anchor_sha=%s want=%s (must validate against its own checkout, not homesrv's)", got, workpcHead)
|
||||
}
|
||||
|
||||
// Quota is accounted per host/harness: exhausting homesrv-h1 must not
|
||||
// affect workpc-h1's availability, and vice versa (spec §2.1, §7.2).
|
||||
quotaReport := func(harness string, consumed float64) {
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": harness, "consumed": consumed})
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "QuotaReported", TaskID: "quota", Version: 1, Surface: string(authz.System), Payload: p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quotaReport("homesrv-h1", 95)
|
||||
limits := map[string]router.QuotaWindowLimits{
|
||||
"homesrv-h1": {Weekly: 100},
|
||||
"workpc-h1": {Weekly: 100},
|
||||
}
|
||||
avail := router.QuotaAvailability{Store: s, Limits: limits}
|
||||
if avail.Available(registry.Herdr{ID: "homesrv-h1"}) {
|
||||
t.Fatal("homesrv-h1 should be quota-exhausted at 95/100")
|
||||
}
|
||||
if !avail.Available(registry.Herdr{ID: "workpc-h1"}) {
|
||||
t.Fatal("workpc-h1 exhaustion leaked from homesrv-h1's per-host accounting")
|
||||
}
|
||||
|
||||
// The windowed quota aggregation used by the brief (spec §7.4) must also
|
||||
// keep the two hosts separate.
|
||||
sums := operations.AggregateQuota(s.Events(0), time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
|
||||
if sums["homesrv-h1"] != 95 {
|
||||
t.Fatalf("homesrv-h1 aggregate=%v want 95", sums["homesrv-h1"])
|
||||
}
|
||||
if sums["workpc-h1"] != 0 {
|
||||
t.Fatalf("workpc-h1 aggregate=%v want 0 (must not inherit homesrv-h1's receipts)", sums["workpc-h1"])
|
||||
}
|
||||
}
|
||||
|
||||
func mustArtifact(t *testing.T, s *store.Store, content string) string {
|
||||
t.Helper()
|
||||
ref, err := s.PutArtifact([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package operations
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/store"
|
||||
"os/exec"
|
||||
@@ -99,7 +100,7 @@ func GenerateStandupAdvisory(s *store.Store, at time.Time) (domain.Event, error)
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
e := domain.Event{ID: domain.NewID(), TaskID: "system", Type: "StandupAdvisory", Payload: p, At: at}
|
||||
e := domain.Event{ID: domain.NewID(), TaskID: "system", Type: "StandupAdvisory", Payload: p, At: at, Surface: string(authz.System)}
|
||||
return e, s.Append(e)
|
||||
}
|
||||
|
||||
@@ -145,7 +146,7 @@ func ApplyAdvisory(s *store.Store, advisoryID string) ([]domain.Event, error) {
|
||||
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}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package operations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/store"
|
||||
"testing"
|
||||
@@ -22,7 +23,7 @@ func TestAggregateQuotaSumsRotationsAndWindows(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
enc := func(at time.Time, n float64) domain.Event {
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": "codex", "consumed": n})
|
||||
return domain.Event{Type: "QuotaReported", At: at, Payload: p}
|
||||
return domain.Event{Type: "QuotaReported", At: at, Payload: p, Surface: string(authz.System)}
|
||||
}
|
||||
es := []domain.Event{enc(now.Add(-2*time.Hour), 4), enc(now.Add(-time.Hour), 6), enc(now.Add(-48*time.Hour), 100)}
|
||||
got := AggregateQuota(es, now.Add(-3*time.Hour), now)
|
||||
@@ -37,11 +38,11 @@ func TestApplyAdvisoryRequiresApproval(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := json.Marshal(map[string]any{"source": "test", "external_id": "1", "project": "p", "title": "old"})
|
||||
if err := s.Append(domain.Event{ID: "task", TaskID: "task", Type: "TaskCreated", Version: 1, Payload: p}); err != nil {
|
||||
if err := s.Append(domain.Event{ID: "task", TaskID: "task", Type: "TaskCreated", Version: 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ap, _ := json.Marshal(map[string]any{"items": []StandupItem{{TaskID: "task", Title: "new"}}})
|
||||
adv := domain.Event{ID: "adv", TaskID: "system", Type: "StandupAdvisory", Payload: ap}
|
||||
adv := domain.Event{ID: "adv", TaskID: "system", Type: "StandupAdvisory", Payload: ap, Surface: string(authz.System)}
|
||||
if err := s.Append(adv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,7 +50,7 @@ func TestApplyAdvisoryRequiresApproval(t *testing.T) {
|
||||
t.Fatal("unapproved advisory applied")
|
||||
}
|
||||
grant, _ := json.Marshal(map[string]any{"subject_ref": "adv"})
|
||||
if err := s.Append(domain.Event{ID: "grant", TaskID: "system", Type: "ApprovalGranted", Payload: grant}); err != nil {
|
||||
if err := s.Append(domain.Event{ID: "grant", TaskID: "system", Type: "ApprovalGranted", Payload: grant, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ApplyAdvisory(s, "adv"); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/continuity"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/herdr"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -21,6 +23,12 @@ import (
|
||||
type Worktrees interface {
|
||||
Create(context.Context, domain.Task) (string, error)
|
||||
}
|
||||
type WorktreeSpec interface {
|
||||
Spec(domain.Task) (string, string, bool)
|
||||
}
|
||||
type WorktreeCleaner interface {
|
||||
Remove(context.Context, domain.Task, string) error
|
||||
}
|
||||
type Adapters interface {
|
||||
Adapter(string) (herdr.Adapter, error)
|
||||
}
|
||||
@@ -34,6 +42,10 @@ type GitWorktrees struct {
|
||||
TaskFileSHA string
|
||||
}
|
||||
|
||||
func (w GitWorktrees) Spec(domain.Task) (string, string, bool) {
|
||||
return w.Repo, w.Root, w.Repo != "" && w.Root != ""
|
||||
}
|
||||
|
||||
func (w GitWorktrees) Create(ctx context.Context, t domain.Task) (string, error) {
|
||||
if w.Root == "" || w.Repo == "" {
|
||||
return "", fmt.Errorf("worktree: root and repo required")
|
||||
@@ -63,6 +75,62 @@ func (w GitWorktrees) Create(ctx context.Context, t domain.Task) (string, error)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (w GitWorktrees) Remove(ctx context.Context, _ domain.Task, path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("worktree: path required")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", w.Repo, "worktree", "remove", "--force", path)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %w", string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectRepo is the minimal shape PerProjectGitWorktrees needs from a
|
||||
// project's registry entry — kept local (not importing internal/registry)
|
||||
// so orchestrator does not depend on registry's config-loading concerns.
|
||||
type ProjectRepo struct {
|
||||
Repo string
|
||||
WorktreeRoot string
|
||||
}
|
||||
|
||||
// PerProjectGitWorktrees resolves a task's repo/root by its project (spec
|
||||
// §2.2: each project is first-class and may have its own checkout), falling
|
||||
// back to Default for any project not present in Projects — this keeps
|
||||
// single-repo deployments working unchanged.
|
||||
type PerProjectGitWorktrees struct {
|
||||
Projects map[string]ProjectRepo
|
||||
Default GitWorktrees
|
||||
TaskFileSHA string
|
||||
}
|
||||
|
||||
func (w PerProjectGitWorktrees) Spec(t domain.Task) (string, string, bool) {
|
||||
g := w.Default
|
||||
if p, ok := w.Projects[t.Project]; ok && p.Repo != "" && p.WorktreeRoot != "" {
|
||||
g = GitWorktrees{Root: p.WorktreeRoot, Repo: p.Repo}
|
||||
}
|
||||
return g.Spec(t)
|
||||
}
|
||||
|
||||
func (w PerProjectGitWorktrees) Create(ctx context.Context, t domain.Task) (string, error) {
|
||||
g := w.Default
|
||||
if p, ok := w.Projects[t.Project]; ok && p.Repo != "" && p.WorktreeRoot != "" {
|
||||
g = GitWorktrees{Root: p.WorktreeRoot, Repo: p.Repo, TaskFileSHA: w.TaskFileSHA}
|
||||
}
|
||||
if g.TaskFileSHA == "" {
|
||||
g.TaskFileSHA = w.TaskFileSHA
|
||||
}
|
||||
return g.Create(ctx, t)
|
||||
}
|
||||
|
||||
func (w PerProjectGitWorktrees) Remove(ctx context.Context, t domain.Task, path string) error {
|
||||
g := w.Default
|
||||
if p, ok := w.Projects[t.Project]; ok && p.Repo != "" && p.WorktreeRoot != "" {
|
||||
g = GitWorktrees{Root: p.WorktreeRoot, Repo: p.Repo}
|
||||
}
|
||||
return g.Remove(ctx, t, path)
|
||||
}
|
||||
|
||||
type AdapterFactory struct{ Herdrs map[string]herdr.Adapter }
|
||||
|
||||
func (f AdapterFactory) Adapter(id string) (herdr.Adapter, error) {
|
||||
@@ -90,6 +158,21 @@ type MonitorHealth struct {
|
||||
LastRun time.Time `json:"last_run"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
Expired int `json:"expired"`
|
||||
// TurnBoundaryDegraded counts rotation ticks where Face B (spec §5.2,
|
||||
// §5.3 — "the Stop-hook/Face-B decides rotation, not the router") could
|
||||
// not be consulted, so occupancy-only thresholding is standing in. This
|
||||
// must stay observable rather than a silent fallback: an operator (or
|
||||
// the brief) can see when a deployment's rotation safety is degraded.
|
||||
TurnBoundaryDegraded int `json:"turn_boundary_degraded"`
|
||||
Sessions map[string]SessionHealth `json:"sessions,omitempty"`
|
||||
}
|
||||
|
||||
type SessionHealth struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
WaitingForApproval bool `json:"waiting_for_approval"`
|
||||
Blocker string `json:"blocker,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Coordinator) MonitorHealth() MonitorHealth {
|
||||
@@ -110,6 +193,58 @@ func (c *Coordinator) setMonitorHealth(err error, expired int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) recordTurnBoundaryDegraded() {
|
||||
c.healthMu.Lock()
|
||||
defer c.healthMu.Unlock()
|
||||
c.health.TurnBoundaryDegraded++
|
||||
}
|
||||
|
||||
func waitingForApproval(status string) bool {
|
||||
s := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(status, "-", "_"), " ", "_"))
|
||||
return s == "waiting_for_approval" || s == "awaiting_approval" || s == "approval_required"
|
||||
}
|
||||
|
||||
func (c *Coordinator) refreshSessionHealth(ctx context.Context) {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
sessions := make(map[string]herdr.Session, len(c.sessions))
|
||||
for id, s := range c.sessions {
|
||||
sessions[id] = s
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.healthMu.Lock()
|
||||
if c.health.Sessions == nil {
|
||||
c.health.Sessions = map[string]SessionHealth{}
|
||||
}
|
||||
c.healthMu.Unlock()
|
||||
for taskID, session := range sessions {
|
||||
adapterID := session.HerdrID
|
||||
if adapterID == "" {
|
||||
if task, ok := c.Store.Task(taskID); ok && task.Lease != nil {
|
||||
adapterID = task.Lease.HarnessID
|
||||
}
|
||||
}
|
||||
a, err := c.Adapters.Adapter(adapterID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, ok := a.(herdr.AgentStatus)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status, err := p.AgentStatus(ctx, session)
|
||||
h := SessionHealth{Status: status, WaitingForApproval: waitingForApproval(status), UpdatedAt: time.Now().UTC()}
|
||||
if err != nil {
|
||||
h.LastError = err.Error()
|
||||
} else if blocker, ok := a.(herdr.AgentBlocker); ok && strings.EqualFold(status, "blocked") {
|
||||
h.Blocker, _ = blocker.AgentBlocker(ctx, session)
|
||||
}
|
||||
c.healthMu.Lock()
|
||||
c.health.Sessions[taskID] = h
|
||||
c.healthMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) loadSessions() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -148,7 +283,6 @@ func (c *Coordinator) saveSessionsLocked() error {
|
||||
func (c *Coordinator) Reconcile(ctx context.Context) error {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for taskID, session := range c.sessions {
|
||||
t, ok := c.Store.Task(taskID)
|
||||
if ok && t.State == domain.StateLeased {
|
||||
@@ -159,7 +293,16 @@ func (c *Coordinator) Reconcile(ctx context.Context) error {
|
||||
}
|
||||
delete(c.sessions, taskID)
|
||||
}
|
||||
return c.saveSessionsLocked()
|
||||
err := c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Session health is derived state. Rebuild it immediately from the
|
||||
// durable session mappings so a restart does not hide an outstanding
|
||||
// approval until the first periodic monitor tick.
|
||||
c.refreshSessionHealth(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Monitor performs conservative hard-threshold rotation. The adapter owns
|
||||
@@ -183,6 +326,8 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D
|
||||
c.healthMu.Unlock()
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
c.refreshSessionHealth(ctx)
|
||||
c.cleanupCompleted(ctx)
|
||||
expired, err := c.expire(ctx)
|
||||
c.setMonitorHealth(err, len(expired))
|
||||
if err != nil {
|
||||
@@ -193,6 +338,34 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) cleanupCompleted(ctx context.Context) {
|
||||
cleaner, ok := c.Worktrees.(WorktreeCleaner)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
changed := false
|
||||
for taskID, session := range c.sessions {
|
||||
t, exists := c.Store.Task(taskID)
|
||||
if !exists || t.State != domain.StateCompleted {
|
||||
continue
|
||||
}
|
||||
if err := cleaner.Remove(ctx, t, session.Worktree); err != nil {
|
||||
c.healthMu.Lock()
|
||||
c.health.LastError = "worktree cleanup: " + err.Error()
|
||||
c.healthMu.Unlock()
|
||||
continue
|
||||
}
|
||||
delete(c.sessions, taskID)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = c.saveSessionsLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
|
||||
// pane.exited is the low-latency path; lease expiry below remains the
|
||||
// authoritative backstop when herdr misses an exit notification.
|
||||
@@ -204,7 +377,7 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
|
||||
if p, ok := a.(herdr.PaneExit); ok {
|
||||
if exited, ee := p.PaneExited(ctx, s); ee == nil && exited {
|
||||
b, _ := json.Marshal(map[string]string{"reason": "pane_exited", "harness_id": s.Harness})
|
||||
_ = c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: t.Version + 1, Payload: b})
|
||||
_ = c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,11 +430,25 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
if err != nil || (occupancy < hard && reason == "threshold") {
|
||||
continue
|
||||
}
|
||||
// Face B is treated as required, not best-effort (spec §5.2/§5.3):
|
||||
// an adapter that supports the turn-boundary probe but fails to
|
||||
// answer it blocks this tick's release rather than silently
|
||||
// proceeding as if mid-turn interruption were safe. Only an
|
||||
// adapter that genuinely does not implement TurnBoundary at all
|
||||
// falls back to occupancy-only thresholding, and that fallback is
|
||||
// recorded so it is observable (MonitorHealth.TurnBoundaryDegraded)
|
||||
// instead of invisible.
|
||||
if boundary, ok := a.(herdr.TurnBoundary); ok {
|
||||
atBoundary, boundaryErr := boundary.AtTurnBoundary(ctx, session)
|
||||
if boundaryErr == nil && !atBoundary {
|
||||
if boundaryErr != nil {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
continue
|
||||
}
|
||||
if !atBoundary {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
c.recordTurnBoundaryDegraded()
|
||||
}
|
||||
ref, err := a.Release(ctx, session)
|
||||
if err != nil {
|
||||
@@ -270,8 +457,16 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b}
|
||||
anchorSHA, err := herdr.HeadSHA(session.Worktree)
|
||||
if err != nil {
|
||||
// Cannot certify the anchor: do not release with an invalid
|
||||
// TaskReleased payload (it would fail validation and strand
|
||||
// the lease/session). Leave the lease intact for the next
|
||||
// tick or TTL expiry to reclaim.
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b, Surface: string(authz.System)}
|
||||
if c.Store.Append(e) == nil {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, taskID)
|
||||
@@ -300,14 +495,27 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil || p.HarnessID == "" {
|
||||
return fmt.Errorf("orchestrator: invalid lease")
|
||||
}
|
||||
w, err := c.Worktrees.Create(ctx, t)
|
||||
if err != nil {
|
||||
return c.block(t, "worktree: "+err.Error())
|
||||
}
|
||||
a, err := c.Adapters.Adapter(p.HarnessID)
|
||||
if err != nil {
|
||||
return c.block(t, "adapter: "+err.Error())
|
||||
}
|
||||
var w string
|
||||
if creator, ok := a.(herdr.WorktreeCreator); ok {
|
||||
planner, planned := c.Worktrees.(WorktreeSpec)
|
||||
if !planned {
|
||||
return c.block(t, "worktree: repository specification unavailable")
|
||||
}
|
||||
repo, root, valid := planner.Spec(t)
|
||||
if !valid {
|
||||
return c.block(t, "worktree: repository and root required")
|
||||
}
|
||||
w, err = creator.CreateWorktree(ctx, repo, root, t.ID)
|
||||
} else {
|
||||
w, err = c.Worktrees.Create(ctx, t)
|
||||
}
|
||||
if err != nil {
|
||||
return c.block(t, "worktree: "+err.Error())
|
||||
}
|
||||
s, err := a.Lease(ctx, t.ID, w)
|
||||
if err != nil {
|
||||
return c.block(t, "lease: "+err.Error())
|
||||
@@ -318,6 +526,7 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
|
||||
return c.block(t, "bootstrap: "+err.Error())
|
||||
}
|
||||
}
|
||||
s.HerdrID = p.HarnessID
|
||||
c.mu.Lock()
|
||||
if c.sessions == nil {
|
||||
c.sessions = map[string]herdr.Session{}
|
||||
@@ -325,12 +534,18 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
|
||||
c.sessions[t.ID] = s
|
||||
err = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
c.healthMu.Lock()
|
||||
if c.health.Sessions == nil {
|
||||
c.health.Sessions = map[string]SessionHealth{}
|
||||
}
|
||||
c.health.Sessions[t.ID] = SessionHealth{Status: "running", UpdatedAt: time.Now().UTC()}
|
||||
c.healthMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Coordinator) block(t domain.Task, reason string) error {
|
||||
b, _ := json.Marshal(map[string]string{"blocker": reason})
|
||||
return c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b})
|
||||
return c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
|
||||
}
|
||||
|
||||
func (c *Coordinator) Session(taskID string) (herdr.Session, bool) {
|
||||
@@ -340,3 +555,25 @@ func (c *Coordinator) Session(taskID string) (herdr.Session, bool) {
|
||||
s, ok := c.sessions[taskID]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (c *Coordinator) Capture(ctx context.Context, taskID, source string) (string, error) {
|
||||
s, ok := c.Session(taskID)
|
||||
if !ok {
|
||||
return "", domain.ErrNotFound
|
||||
}
|
||||
id := s.HerdrID
|
||||
if id == "" {
|
||||
if t, ok := c.Store.Task(taskID); ok && t.Lease != nil {
|
||||
id = t.Lease.HarnessID
|
||||
}
|
||||
}
|
||||
a, err := c.Adapters.Adapter(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p, ok := a.(herdr.PaneCapture)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("pane capture unsupported")
|
||||
}
|
||||
return p.PaneCapture(ctx, s, source)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package orchestrator_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/herdr"
|
||||
"orchestra/internal/orchestrator"
|
||||
"orchestra/internal/store"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeAdapter struct {
|
||||
occupancy float64
|
||||
boundary bool
|
||||
ref string
|
||||
releases int
|
||||
}
|
||||
|
||||
func (a *fakeAdapter) Lease(_ context.Context, _ string, worktree string) (herdr.Session, error) {
|
||||
return herdr.Session{Harness: "h1", PaneID: "pane-1", Worktree: worktree}, nil
|
||||
}
|
||||
func (a *fakeAdapter) Bootstrap(context.Context, herdr.Session, string) error { return nil }
|
||||
func (a *fakeAdapter) Release(context.Context, herdr.Session) (string, error) {
|
||||
a.releases++
|
||||
return a.ref, nil
|
||||
}
|
||||
func (a *fakeAdapter) Kill(context.Context, herdr.Session) error { return nil }
|
||||
func (a *fakeAdapter) Occupancy(herdr.Session) (float64, error) { return a.occupancy, nil }
|
||||
func (a *fakeAdapter) AtTurnBoundary(context.Context, herdr.Session) (bool, error) {
|
||||
return a.boundary, nil
|
||||
}
|
||||
|
||||
type worktrees struct{ path string }
|
||||
|
||||
func (w worktrees) Create(context.Context, domain.Task) (string, error) { return w.path, nil }
|
||||
|
||||
type adapters struct{ a herdr.Adapter }
|
||||
|
||||
func (a adapters) Adapter(string) (herdr.Adapter, error) { return a.a, nil }
|
||||
|
||||
func run(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationEmitsValidReleaseWithAnchorSHA guards the highest-priority spec
|
||||
// defect noted in progress.md: automated rotation must emit a TaskReleased
|
||||
// event that satisfies domain.ValidatePayload (handoff_ref + anchor_sha), not
|
||||
// a payload missing anchor_sha that silently fails to append.
|
||||
func TestRotationEmitsValidReleaseWithAnchorSHA(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
run(t, repo, "init")
|
||||
run(t, repo, "config", "user.email", "t@t")
|
||||
run(t, repo, "config", "user.name", "t")
|
||||
run(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
head, err := herdr.HeadSHA(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "t1", Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"source": "jsonl", "external_id": "1", "project": "p",
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := s.Tasks()[0]
|
||||
ref, err := s.PutArtifact([]byte("handoff"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &fakeAdapter{occupancy: .95, boundary: true, ref: ref}
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
|
||||
leaseEvt, err := s.Lease(task.ID, "h1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Start(context.Background(), leaseEvt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go c.Monitor(ctx, .8, time.Millisecond)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got, ok := s.Task(task.ID); ok && got.State == domain.StateQueued {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
got, ok := s.Task(task.ID)
|
||||
if !ok || got.State != domain.StateQueued {
|
||||
t.Fatalf("rotation did not complete: state=%v ok=%v", got.State, ok)
|
||||
}
|
||||
if a.releases == 0 {
|
||||
t.Fatalf("adapter Release was never invoked")
|
||||
}
|
||||
|
||||
// Walk raw events to confirm the coordinator itself wrote a valid
|
||||
// TaskReleased payload with anchor_sha == the worktree's real HEAD.
|
||||
found := false
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID != task.ID || e.Type != "TaskReleased" {
|
||||
continue
|
||||
}
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := domain.ValidatePayload("TaskReleased", p); err != nil {
|
||||
t.Fatalf("coordinator emitted invalid TaskReleased: %v (%v)", err, p)
|
||||
}
|
||||
if p["anchor_sha"] != head {
|
||||
t.Fatalf("anchor_sha=%v want=%s", p["anchor_sha"], head)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("coordinator never emitted a TaskReleased event")
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||
|
||||
// erroringBoundaryAdapter supports Face B but its probe always fails — this
|
||||
// must block release (never silently treat an unanswerable boundary check
|
||||
// as safe to interrupt), unlike an adapter that doesn't implement the
|
||||
// interface at all.
|
||||
type erroringBoundaryAdapter struct{ fakeAdapter }
|
||||
|
||||
func (a *erroringBoundaryAdapter) AtTurnBoundary(context.Context, herdr.Session) (bool, error) {
|
||||
return false, errors.New("pane.status unsupported")
|
||||
}
|
||||
|
||||
// noBoundaryAdapter never implements herdr.TurnBoundary at all, exercising
|
||||
// the genuine occupancy-only degraded fallback.
|
||||
type noBoundaryAdapter struct {
|
||||
occupancy float64
|
||||
ref string
|
||||
releases int
|
||||
}
|
||||
|
||||
func (a *noBoundaryAdapter) Lease(_ context.Context, _ string, worktree string) (herdr.Session, error) {
|
||||
return herdr.Session{Harness: "h1", PaneID: "pane-1", Worktree: worktree}, nil
|
||||
}
|
||||
func (a *noBoundaryAdapter) Bootstrap(context.Context, herdr.Session, string) error { return nil }
|
||||
func (a *noBoundaryAdapter) Release(context.Context, herdr.Session) (string, error) {
|
||||
a.releases++
|
||||
return a.ref, nil
|
||||
}
|
||||
func (a *noBoundaryAdapter) Kill(context.Context, herdr.Session) error { return nil }
|
||||
func (a *noBoundaryAdapter) Occupancy(herdr.Session) (float64, error) { return a.occupancy, nil }
|
||||
|
||||
func setupRotationTask(t *testing.T, repo string) (*store.Store, string, domain.Task, string) {
|
||||
t.Helper()
|
||||
run(t, repo, "init")
|
||||
run(t, repo, "config", "user.email", "t@t")
|
||||
run(t, repo, "config", "user.name", "t")
|
||||
run(t, repo, "commit", "--allow-empty", "-m", "init")
|
||||
head, err := herdr.HeadSHA(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "t1", Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"source": "jsonl", "external_id": "1", "project": "p",
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := s.Tasks()[0]
|
||||
ref, err := s.PutArtifact([]byte("handoff"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, head, task, ref
|
||||
}
|
||||
|
||||
// TestTurnBoundaryErrorBlocksRelease proves an adapter that implements Face B
|
||||
// but cannot currently answer it (a transient herdr error) never falls
|
||||
// through to an unconfirmed release — spec §5.2/§5.3 treats the boundary
|
||||
// check as required, not best-effort.
|
||||
func TestTurnBoundaryErrorBlocksRelease(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
s, _, task, ref := setupRotationTask(t, repo)
|
||||
a := &erroringBoundaryAdapter{fakeAdapter{occupancy: .95, boundary: true, ref: ref}}
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
|
||||
leaseEvt, err := s.Lease(task.ID, "h1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Start(context.Background(), leaseEvt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go c.Monitor(ctx, .8, time.Millisecond)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
got, _ := s.Task(task.ID)
|
||||
if got.State != domain.StateLeased {
|
||||
t.Fatalf("release proceeded despite an unanswerable turn-boundary check: state=%s", got.State)
|
||||
}
|
||||
if a.releases != 0 {
|
||||
t.Fatalf("adapter.Release was called despite the boundary error, releases=%d", a.releases)
|
||||
}
|
||||
if c.MonitorHealth().TurnBoundaryDegraded == 0 {
|
||||
t.Fatal("turn-boundary degradation was not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoTurnBoundarySupportDegradesVisibly proves an adapter that never
|
||||
// implements Face B still falls back to occupancy-only thresholding (so
|
||||
// existing deployments keep working) but the degradation is observable via
|
||||
// MonitorHealth, not silent.
|
||||
func TestNoTurnBoundarySupportDegradesVisibly(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
s, head, task, ref := setupRotationTask(t, repo)
|
||||
a := &noBoundaryAdapter{occupancy: .95, ref: ref}
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{path: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"}
|
||||
|
||||
leaseEvt, err := s.Lease(task.ID, "h1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Start(context.Background(), leaseEvt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go c.Monitor(ctx, .8, time.Millisecond)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if got, ok := s.Task(task.ID); ok && got.State == domain.StateQueued {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
got, ok := s.Task(task.ID)
|
||||
if !ok || got.State != domain.StateQueued {
|
||||
t.Fatalf("rotation did not complete without Face B support: state=%v ok=%v", got.State, ok)
|
||||
}
|
||||
_ = head
|
||||
if c.MonitorHealth().TurnBoundaryDegraded == 0 {
|
||||
t.Fatal("missing Face B support was not recorded as degraded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package orchestrator_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/orchestrator"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func initRepo(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
run := func(args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("init")
|
||||
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", "README")
|
||||
run("commit", "-m", "init")
|
||||
}
|
||||
|
||||
func TestPerProjectGitWorktreesResolvesByProject(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
repoA := filepath.Join(base, "repo-a")
|
||||
repoB := filepath.Join(base, "repo-b")
|
||||
initRepo(t, repoA)
|
||||
initRepo(t, repoB)
|
||||
|
||||
w := orchestrator.PerProjectGitWorktrees{
|
||||
Projects: map[string]orchestrator.ProjectRepo{
|
||||
"proj-a": {Repo: repoA, WorktreeRoot: filepath.Join(base, "wt-a")},
|
||||
},
|
||||
Default: orchestrator.GitWorktrees{Root: filepath.Join(base, "wt-default"), Repo: repoB},
|
||||
}
|
||||
|
||||
pathA, err := w.Create(context.Background(), domain.Task{ID: "t1", Project: "proj-a"})
|
||||
if err != nil {
|
||||
t.Fatalf("create for proj-a: %v", err)
|
||||
}
|
||||
if filepath.Dir(pathA) != filepath.Join(base, "wt-a") {
|
||||
t.Fatalf("expected proj-a worktree under wt-a, got %s", pathA)
|
||||
}
|
||||
|
||||
pathDefault, err := w.Create(context.Background(), domain.Task{ID: "t2", Project: "unconfigured-project"})
|
||||
if err != nil {
|
||||
t.Fatalf("create for unconfigured project: %v", err)
|
||||
}
|
||||
if filepath.Dir(pathDefault) != filepath.Join(base, "wt-default") {
|
||||
t.Fatalf("expected unconfigured project to use default worktree root, got %s", pathDefault)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"orchestra/internal/domain"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGiteaSourceNameDefaultsToRepo(t *testing.T) {
|
||||
if got := (Gitea{Repo: "orchestra"}).SourceName(); got != "gitea" {
|
||||
t.Fatalf("expected legacy unnamespaced source, got %q", got)
|
||||
}
|
||||
if got := (Gitea{Repo: "orchestra", Project: "correx"}).SourceName(); got != "gitea:correx" {
|
||||
t.Fatalf("expected namespaced source, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaIngestWebhookTagsProject(t *testing.T) {
|
||||
g := Gitea{Owner: "kami", Repo: "correx-repo", Project: "correx", WebhookSecret: "s3cret"}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"action": "opened",
|
||||
"issue": map[string]any{"number": 42, "title": "fix thing", "body": "", "state": "open"},
|
||||
})
|
||||
mac := hmac.New(sha256.New, []byte("s3cret"))
|
||||
mac.Write(body)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
sk := &sink{}
|
||||
if err := g.IngestWebhook(body, sig, sk); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if len(sk.events) != 1 {
|
||||
t.Fatalf("expected 1 event, got %d", len(sk.events))
|
||||
}
|
||||
var p struct {
|
||||
Project string `json:"project"`
|
||||
Source string `json:"source"`
|
||||
ExternalID string `json:"external_id"`
|
||||
}
|
||||
if err := json.Unmarshal(sk.events[0].Payload, &p); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if p.Project != "correx" || p.Source != "gitea:correx" || p.ExternalID != "42" {
|
||||
t.Fatalf("unexpected payload: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaIngestWebhookRejectsBadSignature(t *testing.T) {
|
||||
g := Gitea{Owner: "kami", Repo: "r", Project: "p", WebhookSecret: "s3cret"}
|
||||
if err := g.IngestWebhook([]byte(`{"action":"opened","issue":{"number":1}}`), "wrong", &sink{}); err == nil {
|
||||
t.Fatal("expected signature rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiGiteaReflectDispatchesByTaskSource(t *testing.T) {
|
||||
var hitCorrex, hitMaven bool
|
||||
correxSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitCorrex = true
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer correxSrv.Close()
|
||||
mavenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hitMaven = true
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer mavenSrv.Close()
|
||||
|
||||
m := MultiGitea{Sources: map[string]Gitea{
|
||||
"gitea:correx": {BaseURL: correxSrv.URL, Owner: "kami", Repo: "correx-repo", Project: "correx"},
|
||||
"gitea:maven": {BaseURL: mavenSrv.URL, Owner: "kami", Repo: "maven-repo", Project: "maven"},
|
||||
}}
|
||||
|
||||
if err := m.ReflectTask(domain.Task{Source: "gitea:correx", ExternalID: "7"}, domain.Event{Type: "TaskCompleted"}); err != nil {
|
||||
t.Fatalf("reflect to correx: %v", err)
|
||||
}
|
||||
if !hitCorrex || hitMaven {
|
||||
t.Fatalf("expected only correx server hit, got correx=%v maven=%v", hitCorrex, hitMaven)
|
||||
}
|
||||
|
||||
hitCorrex, hitMaven = false, false
|
||||
if err := m.ReflectTask(domain.Task{Source: "gitea:maven", ExternalID: "3"}, domain.Event{Type: "TaskFailed"}); err != nil {
|
||||
t.Fatalf("reflect to maven: %v", err)
|
||||
}
|
||||
if hitCorrex || !hitMaven {
|
||||
t.Fatalf("expected only maven server hit, got correx=%v maven=%v", hitCorrex, hitMaven)
|
||||
}
|
||||
|
||||
// A task from a non-Gitea source (or an unregistered Gitea project) must
|
||||
// be a silent no-op, not an error.
|
||||
if err := m.ReflectTask(domain.Task{Source: "jsonl"}, domain.Event{Type: "TaskCompleted"}); err != nil {
|
||||
t.Fatalf("unmatched source should no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGiteaConfigsValidatesAndRejectsDuplicates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gitea.json")
|
||||
good := `[
|
||||
{"project":"correx","base_url":"https://gitea.internal","owner":"kami","repo":"correx"},
|
||||
{"project":"maven","base_url":"https://gitea.internal","owner":"kami","repo":"maven"}
|
||||
]`
|
||||
if err := os.WriteFile(path, []byte(good), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgs, err := LoadGiteaConfigs(path)
|
||||
if err != nil || len(cfgs) != 2 {
|
||||
t.Fatalf("cfgs=%d err=%v", len(cfgs), err)
|
||||
}
|
||||
|
||||
dupPath := filepath.Join(dir, "dup.json")
|
||||
dup := `[
|
||||
{"project":"correx","base_url":"https://gitea.internal","owner":"kami","repo":"correx"},
|
||||
{"project":"correx","base_url":"https://gitea.internal","owner":"kami","repo":"other"}
|
||||
]`
|
||||
if err := os.WriteFile(dupPath, []byte(dup), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadGiteaConfigs(dupPath); err == nil {
|
||||
t.Fatal("expected duplicate project rejection")
|
||||
}
|
||||
|
||||
incompletePath := filepath.Join(dir, "incomplete.json")
|
||||
if err := os.WriteFile(incompletePath, []byte(`[{"project":"x"}]`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadGiteaConfigs(incompletePath); err == nil {
|
||||
t.Fatal("expected missing-field rejection")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
@@ -136,7 +137,7 @@ func (j JSONL) Ingest(r io.Reader, sink Sink) (int, error) {
|
||||
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 {
|
||||
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++
|
||||
@@ -197,7 +198,78 @@ func (w JSONLWatcher) Run(ctx context.Context, sink Sink) error {
|
||||
|
||||
type Gitea struct {
|
||||
BaseURL, Token, WebhookSecret, Owner, Repo string
|
||||
Client *http.Client
|
||||
// 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"`
|
||||
@@ -229,7 +301,7 @@ func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
|
||||
}
|
||||
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}
|
||||
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) {
|
||||
@@ -242,11 +314,14 @@ func (g Gitea) IngestWebhook(body []byte, signature string, sink Sink) error {
|
||||
if h.Action == "closed" || h.Action == "deleted" {
|
||||
return nil
|
||||
}
|
||||
project := g.Repo
|
||||
if h.Repository.FullName != "" {
|
||||
project = h.Repository.FullName
|
||||
project := g.Project
|
||||
if project == "" {
|
||||
project = g.Repo
|
||||
if h.Repository.FullName != "" {
|
||||
project = h.Repository.FullName
|
||||
}
|
||||
}
|
||||
return sink.Append(g.event(h.Issue, "gitea", project))
|
||||
return sink.Append(g.event(h.Issue, g.sourceName(), project))
|
||||
}
|
||||
func validSignature(body []byte, got, secret string) bool {
|
||||
if secret == "" || got == "" {
|
||||
@@ -299,8 +374,12 @@ func (g Gitea) Poll(ctx context.Context, sink Sink) (int, error) {
|
||||
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, "gitea", g.Repo)); err != nil {
|
||||
if err := sink.Append(g.event(i, g.sourceName(), project)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,13 @@ var (
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
MachineAffinity []string `json:"machine_affinity"`
|
||||
// Repo and WorktreeRoot let each project resolve its own git checkout
|
||||
// (spec §2.2 — a project is first-class; nothing about the model implies
|
||||
// a single shared repo across all projects). Both optional: a project
|
||||
// that omits them falls back to whatever global default the deployment
|
||||
// wires (single-repo deployments keep working unchanged).
|
||||
Repo string `json:"repo,omitempty"`
|
||||
WorktreeRoot string `json:"worktree_root,omitempty"`
|
||||
}
|
||||
type Machine struct {
|
||||
ID string `json:"id"`
|
||||
@@ -34,8 +41,17 @@ type Herdr struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
QuotaLimit float64 `json:"quota_limit,omitempty"`
|
||||
// QuotaLimit is deprecated in favor of QuotaLimit5h/QuotaLimitWeekly; if
|
||||
// set and QuotaLimit5h is not, it is treated as the weekly limit only
|
||||
// (its historical meaning), to avoid silently inventing a 5h cap for
|
||||
// existing configuration.
|
||||
QuotaLimit float64 `json:"quota_limit,omitempty"`
|
||||
QuotaLimit5h float64 `json:"quota_limit_5h,omitempty"`
|
||||
QuotaLimitWeekly float64 `json:"quota_limit_weekly,omitempty"`
|
||||
}
|
||||
|
||||
const defaultHerdrPort = "9245"
|
||||
|
||||
type Config struct {
|
||||
Projects []Project `json:"projects"`
|
||||
Machines []Machine `json:"machines"`
|
||||
@@ -53,12 +69,58 @@ func Load(path string) (Registry, error) {
|
||||
return Registry{}, err
|
||||
}
|
||||
var c Config
|
||||
if err = json.Unmarshal(b, &c); err != nil {
|
||||
if err = json.Unmarshal(stripJSONComments(b), &c); err != nil {
|
||||
return Registry{}, fmt.Errorf("registry config: %w", err)
|
||||
}
|
||||
return New(c)
|
||||
}
|
||||
|
||||
// stripJSONComments removes // line comments and /* */ block comments from
|
||||
// JSONC input, leaving valid JSON. Comment markers inside string literals
|
||||
// (respecting backslash escapes) are left untouched. This lets deployments
|
||||
// annotate config.json in place instead of keeping a separate undocumented
|
||||
// copy (see deploy/config.example.jsonc).
|
||||
func stripJSONComments(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b))
|
||||
inString, escaped, inLineComment, inBlockComment := false, false, false, false
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
switch {
|
||||
case inLineComment:
|
||||
if c == '\n' {
|
||||
inLineComment = false
|
||||
out = append(out, c)
|
||||
}
|
||||
case inBlockComment:
|
||||
if c == '*' && i+1 < len(b) && b[i+1] == '/' {
|
||||
inBlockComment = false
|
||||
i++
|
||||
}
|
||||
case inString:
|
||||
out = append(out, c)
|
||||
if escaped {
|
||||
escaped = false
|
||||
} else if c == '\\' {
|
||||
escaped = true
|
||||
} else if c == '"' {
|
||||
inString = false
|
||||
}
|
||||
case c == '"':
|
||||
inString = true
|
||||
out = append(out, c)
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '/':
|
||||
inLineComment = true
|
||||
i++
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
||||
inBlockComment = true
|
||||
i++
|
||||
default:
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func New(c Config) (Registry, error) {
|
||||
r := Registry{map[string]Project{}, map[string]Machine{}, map[string]Herdr{}}
|
||||
for _, p := range c.Projects {
|
||||
@@ -160,6 +222,9 @@ func (r Registry) Candidates(project string, check Reachability, timeout time.Du
|
||||
addr := h.Address
|
||||
if addr == "" {
|
||||
addr = r.machines[h.MachineID].Address
|
||||
if host, _, err := net.SplitHostPort(addr); err == nil {
|
||||
addr = net.JoinHostPort(host, defaultHerdrPort)
|
||||
}
|
||||
}
|
||||
if check == nil || check.Reachable(addr, timeout) {
|
||||
out = append(out, h)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/store"
|
||||
@@ -17,7 +18,7 @@ func TestAssignPendingUsesProjectAffinityAndCapability(t *testing.T) {
|
||||
}
|
||||
add := func(id, project string, caps []string) {
|
||||
b, _ := json.Marshal(map[string]any{"source": "test", "external_id": id, "project": project, "capability": caps})
|
||||
if err := s.Append(domain.Event{Type: "TaskCreated", TaskID: id, Version: 1, Payload: b}); err != nil {
|
||||
if err := s.Append(domain.Event{Type: "TaskCreated", TaskID: id, Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +46,7 @@ func TestAssignPendingDoesNotPreemptRunningWork(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"source": "test", "external_id": "queued", "project": "p", "capability": []string{}})
|
||||
if err := s.Append(domain.Event{Type: "TaskCreated", TaskID: "queued", Version: 1, Payload: b}); err != nil {
|
||||
if err := s.Append(domain.Event{Type: "TaskCreated", TaskID: "queued", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := registry.New(registry.Config{Projects: []registry.Project{{ID: "p", MachineAffinity: []string{"m"}}}, Machines: []registry.Machine{{ID: "m", Address: "m:1"}}, Herdrs: []registry.Herdr{{ID: "h", MachineID: "m", Concurrency: 1}}})
|
||||
|
||||
+51
-25
@@ -4,6 +4,7 @@ package router
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/store"
|
||||
@@ -17,46 +18,71 @@ type AlwaysAvailable struct{}
|
||||
|
||||
func (AlwaysAvailable) Available(registry.Herdr) bool { return true }
|
||||
|
||||
// QuotaAvailability applies the conservative 80% rule to summed native
|
||||
// receipts in the configured rolling window. Receipts are additive across
|
||||
// rotations; a cumulative report must not replace earlier rotations.
|
||||
// QuotaWindowLimits are the two independent caps the spec (§7.2) requires:
|
||||
// the subscription pool's 5-hour rolling window and its weekly window. They
|
||||
// are tracked and evaluated separately — a harness deep into its 5h window
|
||||
// but fine on the week, or vice versa, must still be excluded.
|
||||
type QuotaWindowLimits struct {
|
||||
FiveHour float64
|
||||
Weekly float64
|
||||
}
|
||||
|
||||
const (
|
||||
fiveHourWindow = 5 * time.Hour
|
||||
weeklyWindow = 7 * 24 * time.Hour
|
||||
// quotaConservativeFraction is the degrade-safe default from spec §7.2/§9
|
||||
// item 1: since no quota pool is authoritative, treat 80% reported as
|
||||
// full rather than trusting the exact number.
|
||||
quotaConservativeFraction = 0.8
|
||||
)
|
||||
|
||||
// QuotaAvailability applies the conservative 80% rule independently to the
|
||||
// 5-hour rolling window and the weekly window, per harness. Receipts are
|
||||
// additive across rotations; a cumulative session total must never replace
|
||||
// earlier rotations' receipts (spec §5.2.1) — summing native per-report
|
||||
// `consumed` deltas is what keeps this correct across rotation.
|
||||
type QuotaAvailability struct {
|
||||
Store *store.Store
|
||||
Limits map[string]float64
|
||||
Window time.Duration
|
||||
Limits map[string]QuotaWindowLimits
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (q QuotaAvailability) Available(h registry.Herdr) bool {
|
||||
if q.Store == nil {
|
||||
return false
|
||||
}
|
||||
limit, bounded := q.Limits[h.ID]
|
||||
if !bounded || limit <= 0 {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
if q.Now != nil {
|
||||
now = q.Now()
|
||||
}
|
||||
window := q.Window
|
||||
if window <= 0 {
|
||||
window = 7 * 24 * time.Hour
|
||||
}
|
||||
func (q QuotaAvailability) sumSince(harnessID string, since time.Time) float64 {
|
||||
var consumed float64
|
||||
for _, e := range q.Store.Events(0) {
|
||||
if e.Type != "QuotaReported" || e.At.Before(now.Add(-window)) {
|
||||
if e.Type != "QuotaReported" || e.At.Before(since) {
|
||||
continue
|
||||
}
|
||||
var p struct {
|
||||
HarnessID string `json:"harness_id"`
|
||||
Consumed float64 `json:"consumed"`
|
||||
}
|
||||
if json.Unmarshal(e.Payload, &p) == nil && p.HarnessID == h.ID && p.Consumed >= 0 {
|
||||
if json.Unmarshal(e.Payload, &p) == nil && p.HarnessID == harnessID && p.Consumed >= 0 {
|
||||
consumed += p.Consumed
|
||||
}
|
||||
}
|
||||
return consumed < limit*0.8
|
||||
return consumed
|
||||
}
|
||||
|
||||
func (q QuotaAvailability) Available(h registry.Herdr) bool {
|
||||
if q.Store == nil {
|
||||
return false
|
||||
}
|
||||
limits, bounded := q.Limits[h.ID]
|
||||
if !bounded || (limits.FiveHour <= 0 && limits.Weekly <= 0) {
|
||||
return true
|
||||
}
|
||||
now := time.Now()
|
||||
if q.Now != nil {
|
||||
now = q.Now()
|
||||
}
|
||||
if limits.FiveHour > 0 && q.sumSince(h.ID, now.Add(-fiveHourWindow)) >= limits.FiveHour*quotaConservativeFraction {
|
||||
return false
|
||||
}
|
||||
if limits.Weekly > 0 && q.sumSince(h.ID, now.Add(-weeklyWindow)) >= limits.Weekly*quotaConservativeFraction {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type RetryPolicy struct {
|
||||
@@ -185,6 +211,6 @@ func importance(t domain.Task, now time.Time) time.Time {
|
||||
}
|
||||
func (r *Router) fail(t domain.Task) (domain.Event, error) {
|
||||
b, _ := json.Marshal(map[string]any{"reason": "retry_limit", "attempts": r.attempts[t.ID]})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskFailed", TaskID: t.ID, Version: t.Version + 1, Payload: b}
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskFailed", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
|
||||
return e, r.Store.Append(e)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/store"
|
||||
@@ -28,7 +29,7 @@ func TestAssignsByAffinityCapabilityAndConcurrency(t *testing.T) {
|
||||
}
|
||||
makeTask := func(id string) {
|
||||
b, _ := json.Marshal(map[string]any{"source": "test", "external_id": id, "project": "p", "capability": []string{"go"}})
|
||||
if err := s.Append(domain.Event{ID: id, TaskID: id, Type: "TaskCreated", Version: 1, Payload: b}); err != nil {
|
||||
if err := s.Append(domain.Event{ID: id, TaskID: id, Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -43,3 +44,45 @@ func TestAssignsByAffinityCapabilityAndConcurrency(t *testing.T) {
|
||||
t.Fatal("no task leased")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaWindowsAreIndependent proves the 5-hour rolling window and the
|
||||
// weekly window (spec §7.2, §9 item 1) are each conservative-80%-full gates
|
||||
// on their own — a harness can be fine on one window and excluded by the
|
||||
// other, and receipts outside a window must not count toward it.
|
||||
func TestQuotaWindowsAreIndependent(t *testing.T) {
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
report := func(at time.Time, consumed float64) {
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": "h1", "consumed": consumed})
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "QuotaReported", TaskID: "quota", Version: 1, Surface: string(authz.System), Payload: p, At: at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 1: only weekly limit configured. A receipt older than 5h but
|
||||
// within the week still counts toward the weekly gate.
|
||||
report(now.Add(-6*time.Hour), 85)
|
||||
weeklyOnly := QuotaAvailability{Store: s, Limits: map[string]QuotaWindowLimits{"h1": {Weekly: 100}}, Now: func() time.Time { return now }}
|
||||
if weeklyOnly.Available(registry.Herdr{ID: "h1"}) {
|
||||
t.Fatal("weekly window should be exhausted at 85/100 (>=80%)")
|
||||
}
|
||||
|
||||
// Case 2: only a 5h limit configured. The same 6h-old receipt is outside
|
||||
// the 5h window and must not count.
|
||||
fiveHourOnly := QuotaAvailability{Store: s, Limits: map[string]QuotaWindowLimits{"h1": {FiveHour: 100}}, Now: func() time.Time { return now }}
|
||||
if !fiveHourOnly.Available(registry.Herdr{ID: "h1"}) {
|
||||
t.Fatal("receipt outside the 5h window incorrectly counted against it")
|
||||
}
|
||||
|
||||
// Case 3: a fresh receipt inside the 5h window trips the 5h gate even
|
||||
// though the weekly gate (fed by both receipts) also trips — both are
|
||||
// independently enforced, and either failing excludes the harness.
|
||||
report(now.Add(-time.Minute), 90)
|
||||
both := QuotaAvailability{Store: s, Limits: map[string]QuotaWindowLimits{"h1": {FiveHour: 100, Weekly: 500}}, Now: func() time.Time { return now }}
|
||||
if both.Available(registry.Herdr{ID: "h1"}) {
|
||||
t.Fatal("5h window should be exhausted at 90/100 (>=80%) regardless of weekly headroom")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -45,6 +46,10 @@ func Open(dir string) (*Store, error) {
|
||||
s.external[t.Source+"\x00"+t.ExternalID] = t.ID
|
||||
}
|
||||
snapshotSeq = snap.Seq
|
||||
// Continue event numbering after the snapshot. Without restoring this
|
||||
// cursor, the first append after a restart reused sequence 1 and made
|
||||
// the append-only log unreplayable.
|
||||
s.seq = snapshotSeq
|
||||
} else if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return nil, readErr
|
||||
}
|
||||
@@ -160,9 +165,6 @@ func (s *Store) apply(e domain.Event) error {
|
||||
func (s *Store) Append(e domain.Event) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := domain.ValidateEvent(e); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.At.IsZero() {
|
||||
e.At = time.Now().UTC()
|
||||
}
|
||||
@@ -172,6 +174,15 @@ func (s *Store) Append(e domain.Event) error {
|
||||
if e.SchemaVersion == 0 {
|
||||
e.SchemaVersion = domain.CurrentEventSchema
|
||||
}
|
||||
if err := domain.ValidateEvent(e); err != nil {
|
||||
return err
|
||||
}
|
||||
// Enforced once, at the append boundary, per spec §7.1/invariant 4 — every
|
||||
// producer (HTTP handler, router, coordinator, provider, federation relay)
|
||||
// must declare its Surface here; there is no separate in-process bypass.
|
||||
if err := authz.AuthorizeEvent(authz.Surface(e.Surface), e.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.Type == "TaskCreated" {
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
@@ -327,7 +338,7 @@ func (s *Store) Lease(id, harness string, ttl time.Duration) (domain.Event, erro
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": harness, "ttl": ttl.Seconds(), "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version})
|
||||
e := domain.Event{ID: id, Type: "TaskLeased", TaskID: id, Version: t.Version + 1, Payload: p}
|
||||
e := domain.Event{ID: id, Type: "TaskLeased", TaskID: id, Version: t.Version + 1, Payload: p, Surface: string(authz.System)}
|
||||
return e, s.Append(e)
|
||||
}
|
||||
|
||||
@@ -336,7 +347,7 @@ func (s *Store) ExpireLeases(now time.Time) ([]domain.Event, error) {
|
||||
for _, t := range s.Tasks() {
|
||||
if t.State == domain.StateLeased && t.Lease != nil && !t.Lease.Until.After(now) {
|
||||
p, _ := json.Marshal(map[string]any{"reason": "lease_expired", "harness_id": t.Lease.HarnessID})
|
||||
e := domain.Event{ID: t.ID, Type: "TaskReleased", TaskID: t.ID, Version: t.Version + 1, Payload: p}
|
||||
e := domain.Event{ID: t.ID, Type: "TaskReleased", TaskID: t.ID, Version: t.Version + 1, Payload: p, Surface: string(authz.System)}
|
||||
if err := s.Append(e); err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
func created(id string) domain.Event {
|
||||
b, _ := json.Marshal(map[string]any{"source": "jsonl", "external_id": "42", "project": "demo", "capability": []string{"mechanical"}})
|
||||
return domain.Event{ID: id, Type: "TaskCreated", TaskID: "task-1", Version: 1, Payload: b}
|
||||
return domain.Event{ID: id, Type: "TaskCreated", TaskID: "task-1", Version: 1, Payload: b, Surface: string(authz.System)}
|
||||
}
|
||||
|
||||
func TestAppendReplayAndDeduplicate(t *testing.T) {
|
||||
@@ -34,10 +35,10 @@ func TestAppendReplayAndDeduplicate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
completion, _ := json.Marshal(map[string]any{"report_ref": ref, "receipt": map[string]any{"harness_id": "h", "consumed": 1}})
|
||||
if err := s.Append(domain.Event{Type: "TaskCompleted", TaskID: "task-1", Version: 2, Payload: completion}); err != nil {
|
||||
if err := s.Append(domain.Event{Type: "TaskCompleted", TaskID: "task-1", Version: 2, Payload: completion, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{Type: "TaskReleased", TaskID: "task-1", Version: 2, Payload: json.RawMessage(`{"handoff_ref":"` + ref + `","anchor_sha":"0123456789012345678901234567890123456789"}`)}); err != domain.ErrConflict {
|
||||
if err := s.Append(domain.Event{Type: "TaskReleased", TaskID: "task-1", Version: 2, Surface: string(authz.System), Payload: json.RawMessage(`{"handoff_ref":"` + ref + `","anchor_sha":"0123456789012345678901234567890123456789"}`)}); err != domain.ErrConflict {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
s2, err := Open(dir)
|
||||
@@ -85,7 +86,7 @@ func TestLifecycleEventsRequireEvidence(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := domain.ValidateEvent(domain.Event{Type: tc.typ, TaskID: "task-1", Version: 1, Payload: json.RawMessage(tc.body)})
|
||||
err := domain.ValidateEvent(domain.Event{Type: tc.typ, TaskID: "task-1", Version: 1, Payload: json.RawMessage(tc.body), Surface: string(authz.System)})
|
||||
if err == nil {
|
||||
t.Fatal("expected lifecycle evidence validation error")
|
||||
}
|
||||
@@ -93,6 +94,38 @@ func TestLifecycleEventsRequireEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendEnforcesAuthorizationAtTheBus proves authorization is checked
|
||||
// once at the append boundary (spec §7.1/§1.4), not only in HTTP handlers:
|
||||
// a caller writing to the store directly with a notify-only surface, or with
|
||||
// no declared surface at all, is rejected exactly like an HTTP request would
|
||||
// be — there is no in-process bypass for the router, coordinator, or a
|
||||
// provider adapter that forgets to declare who it's acting as.
|
||||
func TestAppendEnforcesAuthorizationAtTheBus(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"source": "jsonl", "external_id": "1", "project": "demo"})
|
||||
|
||||
// A notify-only surface (e.g. Telegram) must never be able to create a
|
||||
// task by calling the store directly, even though it bypasses HTTP.
|
||||
if err := s.Append(domain.Event{ID: "e1", Type: "TaskCreated", TaskID: "task-1", Version: 1, Payload: b, Surface: string(authz.Telegram)}); err == nil {
|
||||
t.Fatal("notify-only surface created a task via direct store access")
|
||||
}
|
||||
|
||||
// An internal producer that forgets to declare a surface is rejected,
|
||||
// not silently trusted as the plane.
|
||||
if err := s.Append(domain.Event{ID: "e2", Type: "TaskCreated", TaskID: "task-1", Version: 1, Payload: b}); err == nil {
|
||||
t.Fatal("event with no declared surface was accepted")
|
||||
}
|
||||
|
||||
// The plane (router/coordinator/provider) authorizes as System and
|
||||
// succeeds.
|
||||
if err := s.Append(domain.Event{ID: "e3", Type: "TaskCreated", TaskID: "task-1", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatalf("system surface rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectedVersionIsCheckedForEveryWriter(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
@@ -102,7 +135,7 @@ func TestExpectedVersionIsCheckedForEveryWriter(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := json.RawMessage(`{"reason":"rotate","expected_version":0}`)
|
||||
err = s.Append(domain.Event{Type: "TaskReleased", TaskID: "task-1", Version: 2, Payload: p})
|
||||
err = s.Append(domain.Event{Type: "TaskReleased", TaskID: "task-1", Version: 2, Payload: p, Surface: string(authz.System)})
|
||||
if err != domain.ErrConflict {
|
||||
t.Fatalf("expected CAS conflict, got %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user