diff --git a/backend/internal/cdc/broadcast.go b/backend/internal/cdc/broadcast.go index a7458e38d..b914f766e 100644 --- a/backend/internal/cdc/broadcast.go +++ b/backend/internal/cdc/broadcast.go @@ -1,25 +1,29 @@ package cdc -import "sync" +import ( + "log/slog" + "sync" +) -// Broadcaster is the in-process fan-out the consumer feeds. Subscribers (the +// Broadcaster is the in-process fan-out the poller feeds. Subscribers (the // WS/SSE transport, wired in the frontend task) register a callback; every -// consumed Event is delivered to all current subscribers. It is the single -// seam between the CDC pipeline and live delivery, so the transport can be -// built and swapped without touching the pipeline. +// polled Event is delivered to all current subscribers. It is the single seam +// between the CDC poller and live delivery, so the transport can be built and +// swapped without touching the poller. type Broadcaster struct { mu sync.RWMutex nextID int subs map[int]func(Event) + logger *slog.Logger } // NewBroadcaster returns an empty Broadcaster ready for subscriptions. func NewBroadcaster() *Broadcaster { - return &Broadcaster{subs: map[int]func(Event){}} + return &Broadcaster{subs: map[int]func(Event){}, logger: slog.Default()} } // Subscribe registers fn and returns an unsubscribe function. fn is called -// synchronously from the consumer loop, so it must not block; a transport that +// synchronously from the poller loop, so it must not block; a transport that // needs buffering should push onto its own channel inside fn. func (b *Broadcaster) Subscribe(fn func(Event)) (unsubscribe func()) { b.mu.Lock() @@ -34,11 +38,29 @@ func (b *Broadcaster) Subscribe(fn func(Event)) (unsubscribe func()) { } } -// Publish delivers e to every current subscriber. +// SubscriberCount reports the number of current subscribers. +func (b *Broadcaster) SubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subs) +} + +// Publish delivers e to every current subscriber. A panicking subscriber is +// recovered and logged so one bad callback can't kill the poller goroutine or +// starve the other subscribers. func (b *Broadcaster) Publish(e Event) { b.mu.RLock() defer b.mu.RUnlock() for _, fn := range b.subs { - fn(e) + b.deliver(fn, e) } } + +func (b *Broadcaster) deliver(fn func(Event), e Event) { + defer func() { + if r := recover(); r != nil { + b.logger.Error("cdc broadcaster: subscriber panicked", "seq", e.Seq, "panic", r) + } + }() + fn(e) +} diff --git a/backend/internal/cdc/cdc_integration_test.go b/backend/internal/cdc/cdc_integration_test.go deleted file mode 100644 index 9390afe01..000000000 --- a/backend/internal/cdc/cdc_integration_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package cdc_test - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/cdc" - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" -) - -// outboxAdapter bridges sqlite.Store's outbox methods to cdc.OutboxStore. This -// is the same glue the composition root (main.go) installs. -type outboxAdapter struct{ s *sqlite.Store } - -func (a outboxAdapter) ListUnsent(ctx context.Context, limit int) ([]cdc.PendingEvent, error) { - evs, err := a.s.ListUnsent(ctx, limit) - if err != nil { - return nil, err - } - out := make([]cdc.PendingEvent, len(evs)) - for i, e := range evs { - out[i] = cdc.PendingEvent{ - OutboxID: e.OutboxID, - Event: cdc.Event{ - Seq: e.Seq, - SessionID: e.SessionID, - EventType: e.EventType, - Revision: e.Revision, - Payload: e.Payload, - CreatedAt: e.CreatedAt, - }, - } - } - return out, nil -} - -func (a outboxAdapter) MarkSent(ctx context.Context, id int64, at time.Time) error { - return a.s.MarkSent(ctx, id, at) -} -func (a outboxAdapter) MarkFailed(ctx context.Context, id int64, msg string) error { - return a.s.MarkFailed(ctx, id, msg) -} - -func newStore(t *testing.T) *sqlite.Store { - t.Helper() - db, err := sqlite.Open(t.TempDir()) - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { db.Close() }) - return sqlite.NewStore(db) -} - -func rec(id string) domain.SessionRecord { - now := time.Now().UTC() - return domain.SessionRecord{ - ID: domain.SessionID(id), ProjectID: "p", Kind: domain.KindWorker, CreatedAt: now, UpdatedAt: now, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, - PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, - }, - } -} - -func TestEndToEndPublishConsume(t *testing.T) { - ctx := context.Background() - store := newStore(t) - dir := t.TempDir() - log, err := cdc.OpenLog(dir, 0) - if err != nil { - t.Fatal(err) - } - defer log.Close() - - // Three canonical writes => three outbox rows, seq 1..3. - r := rec("s1") - if err := store.Upsert(ctx, r, ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - r.Lifecycle.Revision = 1 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - r.Lifecycle.Revision = 2 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) - if err := pub.Drain(ctx); err != nil { - t.Fatalf("drain: %v", err) - } - - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - - con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - // Drive one poll synchronously instead of waiting on the goroutine. - if err := con.Poll(ctx); err != nil { - t.Fatalf("poll: %v", err) - } - - if len(got) != 3 { - t.Fatalf("delivered %d events, want 3", len(got)) - } - for i, e := range got { - if e.Seq != int64(i+1) { - t.Fatalf("event %d has seq %d, want %d", i, e.Seq, i+1) - } - } - if got[0].EventType != string(ports.EventSessionCreated) { - t.Fatalf("first event type = %q", got[0].EventType) - } - - // Idempotency: a second poll with no new bytes delivers nothing more. - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - if len(got) != 3 { - t.Fatalf("re-poll delivered extra events: %d", len(got)) - } - - // Offset persisted at seq 3. - off, _ := store.GetOffset(ctx, "fe") - if off != 3 { - t.Fatalf("offset = %d, want 3", off) - } - - // Janitor: consumer ACKed 3, so sent rows with seq < 3 are reclaimed. - jan := cdc.NewJanitor(store, cdc.JanitorConfig{}) - deleted, err := jan.Sweep(ctx) - if err != nil { - t.Fatal(err) - } - if deleted != 2 { - t.Fatalf("janitor deleted %d, want 2 (seq 1,2 < watermark 3)", deleted) - } -} - -func TestConsumerRestartSkipsDelivered(t *testing.T) { - ctx := context.Background() - store := newStore(t) - dir := t.TempDir() - log, _ := cdc.OpenLog(dir, 0) - defer log.Close() - - if err := store.Upsert(ctx, rec("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - - // Pre-seed the durable offset as if a prior consumer already delivered seq 1. - if err := store.SetOffset(ctx, "fe", 1, time.Now().UTC()); err != nil { - t.Fatal(err) - } - - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - if len(got) != 0 { - t.Fatalf("restart re-delivered already-acked events: %d", len(got)) - } -} - -// fakeSnapshot stands in for the sessions-table snapshot source on resync. -type fakeSnapshot struct { - events []cdc.Event - maxSeq int64 -} - -func (f fakeSnapshot) Snapshot(context.Context) ([]cdc.Event, int64, error) { - return f.events, f.maxSeq, nil -} - -func TestRotationTriggersResync(t *testing.T) { - ctx := context.Background() - store := newStore(t) - dir := t.TempDir() - // Tiny cap so a couple of writes force a rotation. - log, err := cdc.OpenLog(dir, 80) - if err != nil { - t.Fatal(err) - } - defer log.Close() - - var got []cdc.Event - bc := cdc.NewBroadcaster() - bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) - - snap := fakeSnapshot{events: []cdc.Event{{Seq: 5, SessionID: "s1", EventType: "session_updated"}}, maxSeq: 5} - con := cdc.NewConsumer("fe", dir+"/"+cdc.LogFileName, store, bc, cdc.ConsumerConfig{Snapshot: snap}) - if _, err := con.Start(ctx); err != nil { - t.Fatal(err) - } - - pub := cdc.NewPublisher(outboxAdapter{store}, log, cdc.PublisherConfig{}) - - // First write + drain + poll: consumer reads it and advances its cursor. - if err := store.Upsert(ctx, rec("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - cursorBefore := len(got) - - // Force rotation by writing past the cap, then poll: the file shrank, so the - // consumer must resync from the snapshot source. - r := rec("s1") - r.Lifecycle.Revision = 1 - if err := store.Upsert(ctx, r, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - if err := pub.Drain(ctx); err != nil { - t.Fatal(err) - } - if err := con.Poll(ctx); err != nil { - t.Fatal(err) - } - - if len(got) <= cursorBefore { - t.Fatal("expected resync to deliver the snapshot event") - } - // The snapshot event (seq 5) must be among the delivered events. - var sawSnapshot bool - for _, e := range got { - if e.Seq == 5 { - sawSnapshot = true - } - } - if !sawSnapshot { - t.Fatalf("resync did not deliver snapshot event; got %+v", got) - } -} diff --git a/backend/internal/cdc/cdc_test.go b/backend/internal/cdc/cdc_test.go new file mode 100644 index 000000000..d72370f4a --- /dev/null +++ b/backend/internal/cdc/cdc_test.go @@ -0,0 +1,192 @@ +package cdc_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/cdc" + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite" +) + +// storeSource adapts sqlite.Store to cdc.Source — the same glue the daemon wires. +type storeSource struct{ s *sqlite.Store } + +func (a storeSource) EventsAfter(ctx context.Context, after int64, limit int) ([]cdc.Event, error) { + rows, err := a.s.ReadChangeLogAfter(ctx, after, limit) + if err != nil { + return nil, err + } + out := make([]cdc.Event, len(rows)) + for i, r := range rows { + out[i] = cdc.Event{ + Seq: r.Seq, + ProjectID: r.ProjectID, + SessionID: r.SessionID, + Type: cdc.EventType(r.EventType), + Payload: json.RawMessage(r.Payload), + CreatedAt: r.CreatedAt, + } + } + return out, nil +} + +func (a storeSource) LatestSeq(ctx context.Context) (int64, error) { return a.s.MaxChangeLogSeq(ctx) } + +func newStore(t *testing.T) *sqlite.Store { + t.Helper() + s, err := sqlite.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func seedSession(t *testing.T, s *sqlite.Store) domain.SessionRecord { + t.Helper() + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + if err := s.UpsertProject(ctx, sqlite.ProjectRow{ID: "mer", Path: "/m", RegisteredAt: now}); err != nil { + t.Fatal(err) + } + r, err := s.CreateSession(ctx, domain.SessionRecord{ + ProjectID: "mer", Kind: domain.KindWorker, + Lifecycle: domain.CanonicalSessionLifecycle{ + Session: domain.SessionSubstate{State: domain.SessionWorking}, + Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, + }, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return r +} + +// TestE2E_StoreWriteToBroadcast drives the whole path: a store write fires a DB +// trigger that appends to change_log; the poller reads it and broadcasts. +func TestE2E_StoreWriteToBroadcast(t *testing.T) { + ctx := context.Background() + s := newStore(t) + r := seedSession(t, s) // -> session_created (seq 1) + + r.Lifecycle.Session.State = domain.SessionIdle + if err := s.UpdateSession(ctx, r); err != nil { // -> session_updated (seq 2) + t.Fatal(err) + } + if err := s.UpsertPR(ctx, sqlite.PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: r.UpdatedAt}); err != nil { // -> pr_created (seq 3) + t.Fatal(err) + } + + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { got = append(got, e) }) + p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // StartSeq 0: read from the top + if err := p.Poll(ctx); err != nil { + t.Fatal(err) + } + + if len(got) != 3 { + t.Fatalf("delivered %d events, want 3", len(got)) + } + for i, e := range got { + if e.Seq != int64(i+1) { + t.Fatalf("event %d seq=%d, want %d", i, e.Seq, i+1) + } + if e.ProjectID != "mer" { + t.Fatalf("event %d project=%q, want mer", i, e.ProjectID) + } + } + if got[0].Type != cdc.EventSessionCreated || got[1].Type != cdc.EventSessionUpdated || got[2].Type != cdc.EventPRCreated { + t.Fatalf("types = %s, %s, %s", got[0].Type, got[1].Type, got[2].Type) + } + // the trigger-built JSON payload survives as a usable RawMessage. + var payload map[string]any + if err := json.Unmarshal(got[0].Payload, &payload); err != nil { + t.Fatalf("payload not JSON: %v", err) + } + if payload["id"] != string(r.ID) || payload["state"] != "working" { + t.Fatalf("payload = %v", payload) + } + + // idempotent: a second poll with no new rows delivers nothing more. + if err := p.Poll(ctx); err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("re-poll delivered extra events: %d", len(got)) + } +} + +// TestE2E_ConcurrentPollerLiveDelivery runs the poller as a goroutine (the daemon +// model) and asserts every store change is delivered exactly once, in order. +func TestE2E_ConcurrentPollerLiveDelivery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := newStore(t) + r := seedSession(t, s) // seq 1 + + var mu sync.Mutex + var got []cdc.Event + bc := cdc.NewBroadcaster() + bc.Subscribe(func(e cdc.Event) { mu.Lock(); got = append(got, e); mu.Unlock() }) + + p := cdc.NewPoller(storeSource{s}, bc, cdc.PollerConfig{}) // from the top + done := p.Start(ctx) + + const n = 6 + for i := 0; i < n; i++ { + r.Lifecycle.IsAlive = i%2 == 0 // toggles is_alive -> sessions_cdc_update fires + if err := s.UpdateSession(ctx, r); err != nil { + t.Fatal(err) + } + } + want := 1 + n // session_created + n updates + + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + c := len(got) + mu.Unlock() + if c >= want { + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out: delivered %d/%d", c, want) + } + time.Sleep(20 * time.Millisecond) + } + cancel() + <-done + + mu.Lock() + defer mu.Unlock() + if len(got) != want { + t.Fatalf("delivered %d events, want %d", len(got), want) + } + for i, e := range got { + if e.Seq != int64(i+1) { + t.Fatalf("event %d has seq %d, want %d (out-of-order/duplicate)", i, e.Seq, i+1) + } + } +} + +// TestBroadcasterRecoversPanickingSubscriber: one panicking subscriber must not +// kill delivery to the others (or crash the poller goroutine). +func TestBroadcasterRecoversPanickingSubscriber(t *testing.T) { + bc := cdc.NewBroadcaster() + good := 0 + bc.Subscribe(func(cdc.Event) { panic("boom") }) + bc.Subscribe(func(cdc.Event) { good++ }) + + bc.Publish(cdc.Event{Seq: 1}) // must not panic + bc.Publish(cdc.Event{Seq: 2}) + + if good != 2 { + t.Fatalf("good subscriber got %d, want 2 (panic was not isolated)", good) + } +} diff --git a/backend/internal/cdc/consumer.go b/backend/internal/cdc/consumer.go deleted file mode 100644 index 00edb0f10..000000000 --- a/backend/internal/cdc/consumer.go +++ /dev/null @@ -1,221 +0,0 @@ -package cdc - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "time" -) - -// DefaultPollInterval is how often the consumer checks the log for new bytes. -// Polling (rather than fs-notify) keeps the consumer dependency-free; at this -// cadence live updates stay well under a human-perceptible delay. -const DefaultPollInterval = 100 * time.Millisecond - -// OffsetStore persists the consumer's durable seq cursor (at-least-once). -type OffsetStore interface { - GetOffset(ctx context.Context, consumer string) (int64, error) - SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error -} - -// SnapshotSource rebuilds current state from the source of truth (the sessions -// table) after a rotation gap, where log lines for unconsumed-but-already-sent -// events were truncated away. It returns one Event per live session plus the -// MAX(change_log seq) the snapshot corresponds to, so the consumer can resume. -type SnapshotSource interface { - Snapshot(ctx context.Context) (events []Event, maxSeq int64, err error) -} - -// Consumer tails the JSONL log, deduplicates by seq, and fans each new event -// out through the Broadcaster, persisting its durable offset as it goes. -type Consumer struct { - name string - path string - offsets OffsetStore - bcast *Broadcaster - snapshot SnapshotSource - interval time.Duration - clock func() time.Time - logger *slog.Logger - - cursor int64 // byte offset into the log - lastSeq int64 // highest seq delivered - prevInfo os.FileInfo // identity of the file last polled (rotation detection) -} - -// ConsumerConfig holds optional knobs and the snapshot source. -type ConsumerConfig struct { - Snapshot SnapshotSource - Interval time.Duration - Clock func() time.Time - Logger *slog.Logger -} - -// NewConsumer constructs a Consumer named name (the consumer_offsets key) over -// the log at path, fanning out through bcast and persisting offsets via offsets. -func NewConsumer(name, path string, offsets OffsetStore, bcast *Broadcaster, cfg ConsumerConfig) *Consumer { - c := &Consumer{ - name: name, - path: path, - offsets: offsets, - bcast: bcast, - snapshot: cfg.Snapshot, - interval: cfg.Interval, - clock: cfg.Clock, - logger: cfg.Logger, - } - if c.interval <= 0 { - c.interval = DefaultPollInterval - } - if c.clock == nil { - c.clock = time.Now - } - if c.logger == nil { - c.logger = slog.Default() - } - return c -} - -// Start loads the durable offset and runs the poll loop until ctx is cancelled; -// the returned channel closes when the loop has exited. -func (c *Consumer) Start(ctx context.Context) (<-chan struct{}, error) { - seq, err := c.offsets.GetOffset(ctx, c.name) - if err != nil { - return nil, fmt.Errorf("load consumer offset: %w", err) - } - c.lastSeq = seq - - done := make(chan struct{}) - go func() { - defer close(done) - t := time.NewTicker(c.interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := c.Poll(ctx); err != nil { - c.logger.Error("cdc consumer: poll failed", "err", err) - } - } - } - }() - return done, nil -} - -// Poll reads any new bytes since the last cursor and delivers complete lines. It -// detects rotation (the file shrank below the cursor) and resyncs from the DB -// snapshot before resuming. -func (c *Consumer) Poll(ctx context.Context) error { - f, err := os.Open(c.path) - if err != nil { - if os.IsNotExist(err) { - return nil // publisher has not created the log yet - } - return fmt.Errorf("open cdc log: %w", err) - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return fmt.Errorf("stat cdc log: %w", err) - } - size := info.Size() - - rotated := (c.prevInfo != nil && !os.SameFile(c.prevInfo, info)) || size < c.cursor - c.prevInfo = info - if rotated { - // The previous file's bytes are void. Resync from the DB snapshot (if - // wired), then resume reading the fresh file from the top. - if err := c.resync(ctx); err != nil { - return err - } - c.cursor = 0 - } - if size == c.cursor { - return nil - } - - if _, err := f.Seek(c.cursor, io.SeekStart); err != nil { - return fmt.Errorf("seek cdc log: %w", err) - } - data, err := io.ReadAll(f) - if err != nil { - return fmt.Errorf("read cdc log: %w", err) - } - - consumed, maxSeq := c.processLines(data) - c.cursor += int64(consumed) - - if maxSeq > c.lastSeq { - c.lastSeq = maxSeq - if err := c.offsets.SetOffset(ctx, c.name, c.lastSeq, c.clock().UTC()); err != nil { - return fmt.Errorf("persist consumer offset: %w", err) - } - } - return nil -} - -// processLines delivers each complete (newline-terminated) line, skipping reset -// markers and any event whose seq was already delivered. It returns the number -// of bytes consumed (only complete lines) and the highest seq seen. -func (c *Consumer) processLines(data []byte) (consumed int, maxSeq int64) { - maxSeq = c.lastSeq - for { - nl := bytes.IndexByte(data[consumed:], '\n') - if nl < 0 { - return consumed, maxSeq // partial trailing line: leave for next poll - } - line := data[consumed : consumed+nl] - consumed += nl + 1 - - if isResetMarker(line) { - continue - } - var e Event - if err := json.Unmarshal(line, &e); err != nil { - c.logger.Error("cdc consumer: bad line skipped", "err", err) - continue - } - if e.Seq <= c.lastSeq { - continue // idempotent: already delivered - } - c.bcast.Publish(e) - if e.Seq > maxSeq { - maxSeq = e.Seq - } - } -} - -func (c *Consumer) resync(ctx context.Context) error { - if c.snapshot == nil { - return nil - } - events, maxSeq, err := c.snapshot.Snapshot(ctx) - if err != nil { - return fmt.Errorf("cdc consumer resync: %w", err) - } - for _, e := range events { - c.bcast.Publish(e) - } - if maxSeq > c.lastSeq { - c.lastSeq = maxSeq - if err := c.offsets.SetOffset(ctx, c.name, c.lastSeq, c.clock().UTC()); err != nil { - return fmt.Errorf("persist offset after resync: %w", err) - } - } - return nil -} - -func isResetMarker(line []byte) bool { - var m resetMarker - if err := json.Unmarshal(line, &m); err != nil { - return false - } - return m.Type == "reset" -} diff --git a/backend/internal/cdc/event.go b/backend/internal/cdc/event.go index b0eddf982..04f52648f 100644 --- a/backend/internal/cdc/event.go +++ b/backend/internal/cdc/event.go @@ -1,32 +1,40 @@ -// Package cdc is the change-data-capture pipeline that turns the storage layer's -// transactional outbox into a durable, ordered event stream for the frontend. +// Package cdc is the change-data-capture delivery layer. Change events are +// captured durably by SQLite triggers into the change_log table (see the storage +// migrations); this package POLLS that log and fans new events out, in order, to +// in-process subscribers (the WS/SSE transport, wired in the frontend task). // -// The flow: the publisher drains the SQLite outbox (sent=0, seq order) and -// appends each change as one JSON line to a rotating log file. The consumer -// tails that file from a durable byte cursor, deduplicates by seq, and fans each -// change out through the Broadcaster to in-process subscribers (the WS/SSE -// transport, wired later). The janitor reclaims outbox rows every consumer has -// acknowledged. Delivery is at-least-once; seq is the idempotency key. +// There is no durable outbox/JSONL/janitor machinery: the change_log table IS +// the durable, ordered source of truth, and clients catch up by reading it from +// their own offset (SSE Last-Event-ID). The poller + broadcaster here are only +// the LIVE push on top of that. package cdc -import "time" +import ( + "encoding/json" + "time" +) -// Event is one change-data-capture record. It is the JSONL line shape and the -// value handed to Broadcaster subscribers. Seq is the monotonic ordering and -// idempotency key (the change_log seq). +// EventType mirrors the event_type values the DB triggers write. +type EventType string + +const ( + EventSessionCreated EventType = "session_created" + EventSessionUpdated EventType = "session_updated" + EventPRCreated EventType = "pr_created" + EventPRUpdated EventType = "pr_updated" + EventPRCheckRecorded EventType = "pr_check_recorded" +) + +// Event is one CDC change read from change_log. Seq is the monotonic ordering + +// idempotency key (consumers dedup by it). SessionID is empty for project-level +// events. Payload is the trigger-built JSON, kept raw so a typed transport can +// narrow it by Type (the discriminated-union decode lives at the transport edge, +// not here). type Event struct { - Seq int64 `json:"seq"` - SessionID string `json:"sessionId"` - EventType string `json:"eventType"` - Revision int64 `json:"revision"` - Payload string `json:"payload"` - CreatedAt time.Time `json:"createdAt"` -} - -// resetMarker is written as the first line of a freshly rotated log file. A -// consumer that reads it knows the byte offsets of the previous file are void -// and must snapshot-resync, then resume from the current MAX(seq). -type resetMarker struct { - Type string `json:"type"` // always "reset" - RotatedAt time.Time `json:"rotatedAt"` + Seq int64 `json:"seq"` + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId,omitempty"` + Type EventType `json:"type"` + Payload json.RawMessage `json:"payload"` + CreatedAt time.Time `json:"createdAt"` } diff --git a/backend/internal/cdc/janitor.go b/backend/internal/cdc/janitor.go deleted file mode 100644 index 3968b2cf4..000000000 --- a/backend/internal/cdc/janitor.go +++ /dev/null @@ -1,84 +0,0 @@ -package cdc - -import ( - "context" - "log/slog" - "time" -) - -// DefaultJanitorInterval is the outbox-vacuum cadence. -const DefaultJanitorInterval = 60 * time.Second - -// Vacuum is the janitor's view of storage: the safe deletion watermark and the -// delete itself. -type Vacuum interface { - MinConsumerOffset(ctx context.Context) (int64, error) - DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) -} - -// Janitor reclaims delivered outbox rows every consumer has acknowledged. -// -// Watermark: MIN(consumer_offsets.last_seq). Rows with seq < watermark are sent -// AND past every consumer's cursor, so they are safe to drop. When the watermark -// is 0 (a consumer exists but has acknowledged nothing, or none is registered -// yet) the janitor deletes nothing — it never races ahead of a consumer that -// has not yet read an event. change_log is never touched: it is the durable -// history and the snapshot-resync floor. -type Janitor struct { - store Vacuum - interval time.Duration - logger *slog.Logger -} - -// JanitorConfig holds optional knobs; zero values fall back to defaults. -type JanitorConfig struct { - Interval time.Duration - Logger *slog.Logger -} - -// NewJanitor constructs a Janitor over store. -func NewJanitor(store Vacuum, cfg JanitorConfig) *Janitor { - j := &Janitor{store: store, interval: cfg.Interval, logger: cfg.Logger} - if j.interval <= 0 { - j.interval = DefaultJanitorInterval - } - if j.logger == nil { - j.logger = slog.Default() - } - return j -} - -// Start runs the vacuum loop until ctx is cancelled; the returned channel closes -// when the loop has exited. -func (j *Janitor) Start(ctx context.Context) <-chan struct{} { - done := make(chan struct{}) - go func() { - defer close(done) - t := time.NewTicker(j.interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if _, err := j.Sweep(ctx); err != nil { - j.logger.Error("cdc janitor: sweep failed", "err", err) - } - } - } - }() - return done -} - -// Sweep deletes delivered outbox rows below the safe watermark and returns the -// number removed. -func (j *Janitor) Sweep(ctx context.Context) (int64, error) { - watermark, err := j.store.MinConsumerOffset(ctx) - if err != nil { - return 0, err - } - if watermark <= 0 { - return 0, nil - } - return j.store.DeleteSentOutboxBelow(ctx, watermark) -} diff --git a/backend/internal/cdc/jsonl.go b/backend/internal/cdc/jsonl.go deleted file mode 100644 index 74dc0695c..000000000 --- a/backend/internal/cdc/jsonl.go +++ /dev/null @@ -1,109 +0,0 @@ -package cdc - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" -) - -// LogFileName is the active CDC log under the data dir. -const LogFileName = "session-events.jsonl" - -// DefaultMaxBytes is the size at which the log rotates (1 MiB). -const DefaultMaxBytes int64 = 1 << 20 - -// Log is the append-only JSONL sink the publisher writes to. When it grows past -// maxBytes it rotates by truncating in place and writing a reset marker as the -// new first line — the consumer treats a shrunken file as "resync from the DB -// snapshot", so the log itself is not the durable source of truth (SQLite is). -type Log struct { - mu sync.Mutex - path string - maxBytes int64 - f *os.File - size int64 -} - -// OpenLog opens (creating if absent) the JSONL log in dir. maxBytes <= 0 uses -// DefaultMaxBytes. -func OpenLog(dir string, maxBytes int64) (*Log, error) { - if maxBytes <= 0 { - maxBytes = DefaultMaxBytes - } - path := filepath.Join(dir, LogFileName) - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return nil, fmt.Errorf("open cdc log: %w", err) - } - info, err := f.Stat() - if err != nil { - f.Close() - return nil, fmt.Errorf("stat cdc log: %w", err) - } - return &Log{path: path, maxBytes: maxBytes, f: f, size: info.Size()}, nil -} - -// Append writes one event as a JSON line, flushing to disk. It rotates first if -// the file is already at/over the size cap, so a single oversized burst still -// lands in a fresh segment. -func (l *Log) Append(e Event) error { - l.mu.Lock() - defer l.mu.Unlock() - - if l.size >= l.maxBytes { - if err := l.rotateLocked(); err != nil { - return err - } - } - return l.writeLocked(e) -} - -func (l *Log) writeLocked(v any) error { - line, err := json.Marshal(v) - if err != nil { - return fmt.Errorf("marshal cdc line: %w", err) - } - line = append(line, '\n') - n, err := l.f.Write(line) - l.size += int64(n) - if err != nil { - return fmt.Errorf("write cdc line: %w", err) - } - if err := l.f.Sync(); err != nil { - return fmt.Errorf("sync cdc log: %w", err) - } - return nil -} - -// rotateLocked renames the active file aside and starts a fresh one whose first -// line is a reset marker. Renaming (not truncating in place) gives the file a -// new identity, so a polling consumer reliably detects rotation via -// os.SameFile even if the fresh file grows past its old byte cursor between -// polls. The consumer then resyncs from the DB snapshot. -func (l *Log) rotateLocked() error { - if err := l.f.Close(); err != nil { - return fmt.Errorf("close cdc log for rotate: %w", err) - } - archive := l.path + ".1" - _ = os.Remove(archive) // best-effort: history lives in SQLite, not the log - if err := os.Rename(l.path, archive); err != nil { - return fmt.Errorf("rotate cdc log: %w", err) - } - f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return fmt.Errorf("reopen cdc log after rotate: %w", err) - } - l.f = f - l.size = 0 - return l.writeLocked(resetMarker{Type: "reset", RotatedAt: time.Now().UTC()}) -} - -// Close closes the underlying file. -func (l *Log) Close() error { - l.mu.Lock() - defer l.mu.Unlock() - return l.f.Close() -} diff --git a/backend/internal/cdc/poller.go b/backend/internal/cdc/poller.go new file mode 100644 index 000000000..c824def37 --- /dev/null +++ b/backend/internal/cdc/poller.go @@ -0,0 +1,123 @@ +package cdc + +import ( + "context" + "fmt" + "log/slog" + "time" +) + +// DefaultPollInterval is how often the poller checks change_log for new rows. +// Polling (rather than fs-notify or a DB hook) keeps it dependency-free; at this +// cadence live updates stay well under a human-perceptible delay. +const DefaultPollInterval = 100 * time.Millisecond + +// DefaultBatch bounds how many events one poll drains. +const DefaultBatch = 512 + +// Source is the poller's view of the durable log: read events after a seq, and +// the current head seq. The storage layer implements it (the change_log table). +type Source interface { + EventsAfter(ctx context.Context, after int64, limit int) ([]Event, error) + LatestSeq(ctx context.Context) (int64, error) +} + +// Poller tails change_log and fans each new event out through the Broadcaster, +// in seq order. It holds only an in-memory cursor (lastSeq): it is the LIVE push +// path, while durable catch-up is the client's job (read change_log from its own +// offset). A restart re-seeks to head, so the poller never re-broadcasts history +// to a freshly-started broadcaster. +type Poller struct { + src Source + bcast *Broadcaster + interval time.Duration + batch int + logger *slog.Logger + lastSeq int64 +} + +// PollerConfig holds optional knobs; zero values fall back to defaults. StartSeq +// is the cursor to begin from; production wiring leaves it 0 and calls +// SeekToHead, tests set it to read from the beginning. +type PollerConfig struct { + Interval time.Duration + Batch int + Logger *slog.Logger + StartSeq int64 +} + +// NewPoller constructs a Poller over src, fanning out through bcast. +func NewPoller(src Source, bcast *Broadcaster, cfg PollerConfig) *Poller { + p := &Poller{ + src: src, + bcast: bcast, + interval: cfg.Interval, + batch: cfg.Batch, + logger: cfg.Logger, + lastSeq: cfg.StartSeq, + } + if p.interval <= 0 { + p.interval = DefaultPollInterval + } + if p.batch <= 0 { + p.batch = DefaultBatch + } + if p.logger == nil { + p.logger = slog.Default() + } + return p +} + +// SeekToHead moves the cursor to the current head, so the poller only broadcasts +// events created from now on (clients catch up on older events via the store). +func (p *Poller) SeekToHead(ctx context.Context) error { + seq, err := p.src.LatestSeq(ctx) + if err != nil { + return fmt.Errorf("cdc poller seek: %w", err) + } + p.lastSeq = seq + return nil +} + +// Start runs the poll loop until ctx is cancelled; the returned channel closes +// when the loop has exited. +func (p *Poller) Start(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + t := time.NewTicker(p.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := p.Poll(ctx); err != nil { + p.logger.Error("cdc poller: poll failed", "err", err) + } + } + } + }() + return done +} + +// Poll drains one batch of new events and broadcasts them in seq order, +// advancing the cursor. Exported so tests (and a daemon) can drive a cycle +// synchronously. +func (p *Poller) Poll(ctx context.Context) error { + evs, err := p.src.EventsAfter(ctx, p.lastSeq, p.batch) + if err != nil { + return fmt.Errorf("cdc poller: read after %d: %w", p.lastSeq, err) + } + for _, e := range evs { + if e.Seq <= p.lastSeq { + continue // idempotent guard + } + p.bcast.Publish(e) + p.lastSeq = e.Seq + } + return nil +} + +// LastSeq returns the poller's current cursor (the highest seq broadcast). +func (p *Poller) LastSeq() int64 { return p.lastSeq } diff --git a/backend/internal/cdc/publisher.go b/backend/internal/cdc/publisher.go deleted file mode 100644 index 3283a236e..000000000 --- a/backend/internal/cdc/publisher.go +++ /dev/null @@ -1,115 +0,0 @@ -package cdc - -import ( - "context" - "log/slog" - "time" -) - -// DefaultPublishInterval is the outbox drain cadence. -const DefaultPublishInterval = 50 * time.Millisecond - -// DefaultBatchSize bounds how many outbox rows one drain pass handles. -const DefaultBatchSize = 256 - -// PendingEvent is an undelivered outbox row paired with its CDC event payload. -type PendingEvent struct { - OutboxID int64 - Event -} - -// OutboxStore is the publisher's view of the storage layer: read undelivered -// rows in seq order, then mark each delivered or failed. -type OutboxStore interface { - ListUnsent(ctx context.Context, limit int) ([]PendingEvent, error) - MarkSent(ctx context.Context, outboxID int64, at time.Time) error - MarkFailed(ctx context.Context, outboxID int64, errMsg string) error -} - -// Publisher drains the outbox into the JSONL log on a fixed cadence. -type Publisher struct { - src OutboxStore - log *Log - interval time.Duration - batch int - clock func() time.Time - logger *slog.Logger -} - -// PublisherConfig holds optional knobs; zero values fall back to defaults. -type PublisherConfig struct { - Interval time.Duration - Batch int - Clock func() time.Time - Logger *slog.Logger -} - -// NewPublisher constructs a Publisher over src and log. -func NewPublisher(src OutboxStore, log *Log, cfg PublisherConfig) *Publisher { - p := &Publisher{ - src: src, - log: log, - interval: cfg.Interval, - batch: cfg.Batch, - clock: cfg.Clock, - logger: cfg.Logger, - } - if p.interval <= 0 { - p.interval = DefaultPublishInterval - } - if p.batch <= 0 { - p.batch = DefaultBatchSize - } - if p.clock == nil { - p.clock = time.Now - } - if p.logger == nil { - p.logger = slog.Default() - } - return p -} - -// Start runs the drain loop until ctx is cancelled; the returned channel closes -// when the loop has exited. -func (p *Publisher) Start(ctx context.Context) <-chan struct{} { - done := make(chan struct{}) - go func() { - defer close(done) - t := time.NewTicker(p.interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - if err := p.Drain(ctx); err != nil { - p.logger.Error("cdc publisher: drain failed", "err", err) - } - } - } - }() - return done -} - -// Drain runs one pass: append each undelivered row to the log in seq order, -// marking it sent. A write failure stops the pass (the row is marked failed and -// retried next tick) so ordering is never violated by skipping ahead. -func (p *Publisher) Drain(ctx context.Context) error { - pending, err := p.src.ListUnsent(ctx, p.batch) - if err != nil { - return err - } - for _, pe := range pending { - if err := p.log.Append(pe.Event); err != nil { - p.logger.Error("cdc publisher: append failed", "outboxId", pe.OutboxID, "seq", pe.Seq, "err", err) - if merr := p.src.MarkFailed(ctx, pe.OutboxID, err.Error()); merr != nil { - p.logger.Error("cdc publisher: mark failed errored", "outboxId", pe.OutboxID, "err", merr) - } - return nil - } - if err := p.src.MarkSent(ctx, pe.OutboxID, p.clock().UTC()); err != nil { - return err - } - } - return nil -} diff --git a/backend/internal/domain/decide/decide.go b/backend/internal/domain/decide/decide.go index c46df18d5..be195aef4 100644 --- a/backend/internal/domain/decide/decide.go +++ b/backend/internal/domain/decide/decide.go @@ -1,7 +1,11 @@ // Package decide is the pure DECIDE core: total, deterministic, zero I/O. It -// collapses observed facts (plus the prior detecting/activity memory) into one -// LifecycleDecision. Every function here must remain side-effect free so the -// whole status truth-table can be tested in isolation. +// collapses observed liveness facts (plus the prior detecting memory) into one +// LifecycleDecision. Every function here is side-effect free so the whole +// liveness truth-table can be tested in isolation. +// +// PR-driven behaviour is NOT here: PR display status is derived by +// domain.DeriveStatus from the pr table, and PR-driven nudges are the reaction +// engine's job. decide is only about liveness + the anti-flap quarantine. package decide import ( @@ -30,158 +34,57 @@ const ( // terminal this decider may reach without quarantine); // - a *failed* probe (timeout/error) is never read as death — it routes to // detecting, as does any disagreement between the two probes; -// - only runtime-dead + process-dead + no-recent-activity reaches killed. +// - only runtime-down + process-dead + no-recent-activity reaches terminal. func ResolveProbeDecision(in ProbeInput) LifecycleDecision { if in.KillRequested { + reason := in.KillReason + if reason == "" { + reason = domain.TermManuallyKilled + } return LifecycleDecision{ - Status: domain.StatusKilled, - Evidence: "manual kill requested", - SessionState: domain.SessionTerminated, - SessionReason: domain.ReasonManuallyKilled, + Evidence: "manual kill requested", + SessionState: domain.SessionTerminated, + TerminationReason: reason, + IsAlive: false, } } - if in.RuntimeFailed || in.ProcessFailed || in.Runtime == domain.RuntimeProbeFailed { - ev := fmt.Sprintf("probe_failed runtime=%s runtimeFailed=%t process=%s processFailed=%t", - in.Runtime, in.RuntimeFailed, in.Process, in.ProcessFailed) - return detecting(in, domain.ReasonProbeFailure, ev) + if in.RuntimeFailed || in.ProcessFailed { + ev := fmt.Sprintf("probe_failed runtimeFailed=%t process=%s processFailed=%t", in.RuntimeFailed, in.Process, in.ProcessFailed) + return detecting(in, ev) } - switch in.Runtime { - case domain.RuntimeAlive: + if in.RuntimeAlive { if in.Process == ProcessDead { // Runtime up but the agent process is gone: probes disagree. ev := fmt.Sprintf("disagree runtime=alive process=%s recentActivity=%t", in.Process, in.RecentActivity) - return detecting(in, domain.ReasonAgentProcessExited, ev) + return detecting(in, ev) } return LifecycleDecision{ - Status: domain.StatusWorking, - Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process), - SessionState: domain.SessionWorking, - SessionReason: domain.ReasonTaskInProgress, - } - - case domain.RuntimeExited, domain.RuntimeMissing: - // Runtime is gone. Death is only concluded when the process is *also* - // confirmed dead AND nothing has been heard from the agent recently; - // any other shape is ambiguous and quarantines. - if in.Process == ProcessAlive || in.RecentActivity { - ev := fmt.Sprintf("disagree runtime=%s process=%s recentActivity=%t", in.Runtime, in.Process, in.RecentActivity) - return detecting(in, domain.ReasonRuntimeLost, ev) - } - if in.Process == ProcessDead { - return LifecycleDecision{ - Status: domain.StatusKilled, - Evidence: fmt.Sprintf("dead runtime=%s process=dead recentActivity=false", in.Runtime), - SessionState: domain.SessionTerminated, - SessionReason: domain.ReasonRuntimeLost, - } - } - // Process indeterminate: cannot confirm death, so quarantine. - ev := fmt.Sprintf("runtime_lost runtime=%s process=%s recentActivity=false", in.Runtime, in.Process) - return detecting(in, domain.ReasonRuntimeLost, ev) - - default: - // unknown (not yet probed): ambiguous, never conclude death. - ev := fmt.Sprintf("runtime_unknown runtime=%s process=%s recentActivity=%t", in.Runtime, in.Process, in.RecentActivity) - return detecting(in, domain.ReasonRuntimeLost, ev) - } -} - -// ResolveOpenPRDecision walks the PR pipeline ladder. CI failure dominates -// everything. Draft PRs then surface as draft and do not enter the review or -// merge states. Open PRs continue through requested changes, approval/merge -// states, pending review, stalled (idle-beyond-threshold), then plain open. -func ResolveOpenPRDecision(in OpenPRInput) LifecycleDecision { - // evidence is a stable, timestamp-free summary " # " - // for logs/traceability; it folds in the PR identity inputs (Number/URL). - evidence := func(cond string) string { - s := cond - if in.Number > 0 { - s += fmt.Sprintf(" #%d", in.Number) - } - if in.URL != "" { - s += " " + in.URL - } - return s - } - prState := domain.PROpen - if in.Draft { - prState = domain.PRDraft - } - base := func(status domain.SessionStatus, cond string, prReason domain.PRReason, ss domain.SessionState, sr domain.SessionReason) LifecycleDecision { - return LifecycleDecision{ - Status: status, - Evidence: evidence(cond), - SessionState: ss, - SessionReason: sr, - PRState: prState, - PRReason: prReason, + Evidence: fmt.Sprintf("alive runtime=alive process=%s", in.Process), + SessionState: domain.SessionWorking, + IsAlive: true, } } - switch { - case in.CIFailing: - return base(domain.StatusCIFailed, "ci_failing", domain.PRReasonCIFailing, domain.SessionWorking, domain.ReasonFixingCI) - case in.Draft: - return base(domain.StatusDraft, "draft", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) - case in.ChangesRequested: - return base(domain.StatusChangesRequested, "changes_requested", domain.PRReasonChangesRequested, domain.SessionWorking, domain.ReasonResolvingReviewComments) - case in.BotComments: - return base(domain.StatusChangesRequested, "bot_comments", domain.PRReasonBotComments, domain.SessionWorking, domain.ReasonResolvingReviewComments) - case in.MergeConflicts: - return base(domain.StatusPROpen, "merge_conflicts", domain.PRReasonMergeConflicts, domain.SessionWorking, domain.ReasonPRCreated) - case in.Mergeable: - // Mergeability is the authoritative merge gate, so it already folds in - // "approved if review is required". Checking it before Approved means a - // PR on a no-required-review repo (mergeable, not formally approved) is - // still surfaced as ready-to-merge instead of falling through to PR_OPEN. - return base(domain.StatusMergeable, "merge_ready", domain.PRReasonMergeReady, domain.SessionIdle, domain.ReasonAwaitingExternalReview) - case in.Approved: - return base(domain.StatusApproved, "approved", domain.PRReasonApproved, domain.SessionIdle, domain.ReasonAwaitingExternalReview) - case in.ReviewPending: - return base(domain.StatusReviewPending, "review_pending", domain.PRReasonReviewPending, domain.SessionIdle, domain.ReasonAwaitingExternalReview) - case in.IdleBeyond: - // A PR open but quiet past the stuck threshold needs a human nudge. - return base(domain.StatusStuck, "idle_beyond", domain.PRReasonInProgress, domain.SessionStuck, domain.ReasonAwaitingUserInput) - default: - return base(domain.StatusPROpen, "pr_open", domain.PRReasonInProgress, domain.SessionWorking, domain.ReasonPRCreated) + // Runtime is gone. Death is only concluded when the process is *also* + // confirmed dead AND nothing has been heard from the agent recently; any + // other shape is ambiguous and quarantines. + if in.Process == ProcessAlive || in.RecentActivity { + ev := fmt.Sprintf("disagree runtime=down process=%s recentActivity=%t", in.Process, in.RecentActivity) + return detecting(in, ev) } -} - -// ResolveTerminalPRStateDecision handles merged/closed PRs. A merge parks the -// session idle awaiting a human's post-merge decision; a close drops to idle. -// none/open are not terminal — callers should route those to the open-PR or -// probe deciders — but the function stays total for safety. -func ResolveTerminalPRStateDecision(pr domain.PRState) LifecycleDecision { - switch pr { - case domain.PRMerged: + if in.Process == ProcessDead { return LifecycleDecision{ - Status: domain.StatusMerged, - Evidence: "pr merged", - SessionState: domain.SessionIdle, - SessionReason: domain.ReasonMergedWaitingDecision, - PRState: domain.PRMerged, - PRReason: domain.PRReasonMerged, - } - case domain.PRClosed: - return LifecycleDecision{ - Status: domain.StatusIdle, - Evidence: "pr closed unmerged", - SessionState: domain.SessionIdle, - SessionReason: domain.ReasonAwaitingUserInput, - PRState: domain.PRClosed, - PRReason: domain.PRReasonClosedUnmerged, - } - default: - return LifecycleDecision{ - Status: domain.StatusWorking, - Evidence: fmt.Sprintf("non-terminal pr state=%s", pr), - SessionState: domain.SessionWorking, - SessionReason: domain.ReasonTaskInProgress, - PRState: pr, + Evidence: "dead runtime=down process=dead recentActivity=false", + SessionState: domain.SessionTerminated, + TerminationReason: domain.TermRuntimeLost, + IsAlive: false, } } + // Process indeterminate: cannot confirm death, so quarantine. + ev := fmt.Sprintf("runtime_lost runtime=down process=%s recentActivity=false", in.Process) + return detecting(in, ev) } // CreateDetectingDecision advances or escalates the anti-flap quarantine. @@ -189,9 +92,10 @@ func ResolveTerminalPRStateDecision(pr domain.PRState) LifecycleDecision { // The attempt counter climbs only while the (timestamp-stripped) evidence hash // is unchanged and resets the moment the evidence moves; StartedAt is preserved // across the whole detecting episode so the duration cap is a real wall-clock -// safety net even when the evidence keeps flapping. Escalation to stuck fires -// at DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration -// elapsed since first entering detecting. +// safety net even when the evidence keeps flapping. Escalation to stuck fires at +// DetectingMaxAttempts consecutive unchanged ticks OR DetectingMaxDuration +// elapsed since first entering detecting. Detecting/stuck leave IsAlive true: +// the probe was ambiguous, so the session is not confirmed dead. func CreateDetectingDecision(in DetectingInput) LifecycleDecision { hash := HashEvidence(in.Evidence) @@ -207,19 +111,17 @@ func CreateDetectingDecision(in DetectingInput) LifecycleDecision { escalate := attempts >= DetectingMaxAttempts || !in.Now.Before(startedAt.Add(DetectingMaxDuration)) if escalate { return LifecycleDecision{ - Status: domain.StatusStuck, - Evidence: in.Evidence, - SessionState: domain.SessionStuck, - SessionReason: in.ProposedReason, + Evidence: in.Evidence, + SessionState: domain.SessionStuck, + IsAlive: true, } } return LifecycleDecision{ - Status: domain.StatusDetecting, - Evidence: in.Evidence, - Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash}, - SessionState: domain.SessionDetecting, - SessionReason: in.ProposedReason, + Evidence: in.Evidence, + Detecting: &domain.DetectingState{Attempts: attempts, StartedAt: startedAt, EvidenceHash: hash}, + SessionState: domain.SessionDetecting, + IsAlive: true, } } @@ -237,38 +139,20 @@ func HashEvidence(evidence string) string { } // timestampPatterns is the list of regexes HashEvidence applies (in order) to -// delete the time-varying parts of an evidence string before hashing, so the -// same ambiguous signal restamped with a new clock value hashes equal and the -// detecting counter keeps climbing instead of resetting every tick. -// -// Order matters: the full datetime form is removed first so its embedded -// HH:MM:SS isn't half-eaten by the bare time-of-day pattern that follows. -// -// 1. full ISO-8601 / RFC3339 datetime — date, a T or space separator, -// HH:MM:SS, optional fractional seconds, optional Z or ±HH:MM offset. -// e.g. "2026-05-26T12:00:00Z", "2026-05-26 12:00:00.218+05:30" -// 2. a bare time-of-day, e.g. "12:00:00" or "12:00:00.218" -// 3. a bare unix epoch — any 10-13 digit run (seconds or millis), e.g. -// "1716724800". This is broad enough to also clobber a same-width numeric -// ID if one ever appears in evidence; evidence is decider-authored, so keep -// IDs out of evidence strings to preserve hash fidelity. +// delete the time-varying parts of an evidence string before hashing. var timestampPatterns = []*regexp.Regexp{ regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`), regexp.MustCompile(`\d{2}:\d{2}:\d{2}(?:\.\d+)?`), regexp.MustCompile(`\b\d{10,13}\b`), } -// detecting adapts a probe verdict into the shared anti-flap path. It packages -// the proposed reason + evidence (plus the prior counter from the same probe -// input) into a DetectingInput and defers to CreateDetectingDecision, so every +// detecting packages a probe verdict into the shared anti-flap path, so every // probe-driven ambiguity is counted and escalated by the identical quarantine // logic instead of each probe branch re-implementing the counter. -func detecting(in ProbeInput, reason domain.SessionReason, evidence string) LifecycleDecision { +func detecting(in ProbeInput, evidence string) LifecycleDecision { return CreateDetectingDecision(DetectingInput{ - Evidence: evidence, - ProposedState: domain.SessionDetecting, - ProposedReason: reason, - Prior: in.Prior, - Now: in.Now, + Evidence: evidence, + Prior: in.Prior, + Now: in.Now, }) } diff --git a/backend/internal/domain/decide/decide_test.go b/backend/internal/domain/decide/decide_test.go index 1a8159592..bc25af55e 100644 --- a/backend/internal/domain/decide/decide_test.go +++ b/backend/internal/domain/decide/decide_test.go @@ -7,570 +7,158 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -var t0 = time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) +var t0 = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) func TestResolveProbeDecision(t *testing.T) { - tests := []struct { - name string - in ProbeInput - wantStatus domain.SessionStatus - wantState domain.SessionState - wantReason domain.SessionReason - wantDetect bool // expect non-nil Detecting memory - wantTermNil bool // expect terminal (Detecting must be nil) - }{ - { - name: "kill requested short-circuits to terminal killed", - in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonManuallyKilled, - wantTermNil: true, - }, - { - name: "kill requested wins even over a dead+dead probe", - in: ProbeInput{KillRequested: true, Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonManuallyKilled, - wantTermNil: true, - }, - { - name: "runtime probe failed routes to detecting, never death", - in: ProbeInput{Runtime: domain.RuntimeMissing, RuntimeFailed: true, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantDetect: true, - }, - { - name: "process probe failed routes to detecting", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, ProcessFailed: true, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantDetect: true, - }, - { - name: "runtime state probe_failed routes to detecting", - in: ProbeInput{Runtime: domain.RuntimeProbeFailed, Process: ProcessIndeterminate, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonProbeFailure, - wantDetect: true, - }, - { - name: "runtime alive + process alive is working", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, - }, - { - name: "runtime alive + process indeterminate leans alive", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessIndeterminate, Now: t0}, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, - }, - { - name: "runtime alive + process dead disagree -> detecting (agent_process_exited)", - in: ProbeInput{Runtime: domain.RuntimeAlive, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonAgentProcessExited, - wantDetect: true, - }, - { - name: "runtime dead + process alive disagree -> detecting (runtime_lost)", - in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessAlive, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - { - name: "runtime dead + recent activity disagree -> detecting (runtime_lost)", - in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, RecentActivity: true, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - { - name: "runtime dead + process indeterminate cannot confirm -> detecting", - in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - { - name: "runtime exited + process dead + no activity -> killed terminal", - in: ProbeInput{Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonRuntimeLost, - wantTermNil: true, - }, - { - name: "runtime missing + process dead + no activity -> killed terminal", - in: ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusKilled, - wantState: domain.SessionTerminated, - wantReason: domain.ReasonRuntimeLost, - wantTermNil: true, - }, - { - name: "runtime unknown is ambiguous -> detecting (runtime_lost)", - in: ProbeInput{Runtime: domain.RuntimeUnknown, Process: ProcessDead, Now: t0}, - wantStatus: domain.StatusDetecting, - wantState: domain.SessionDetecting, - wantReason: domain.ReasonRuntimeLost, - wantDetect: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveProbeDecision(tt.in) - if got.Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) - } - if got.SessionState != tt.wantState { - t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) - } - if got.SessionReason != tt.wantReason { - t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason) - } - if tt.wantDetect && got.Detecting == nil { - t.Errorf("expected non-nil Detecting memory, got nil") - } - if tt.wantTermNil && got.Detecting != nil { - t.Errorf("terminal decision must carry nil Detecting, got %+v", got.Detecting) - } - }) - } -} - -func TestResolveOpenPRDecision(t *testing.T) { - tests := []struct { - name string - in OpenPRInput - wantStatus domain.SessionStatus - wantPR domain.PRReason - wantPRState domain.PRState - wantState domain.SessionState - }{ - { - name: "ci failing dominates everything", - in: OpenPRInput{CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusCIFailed, - wantPR: domain.PRReasonCIFailing, - wantState: domain.SessionWorking, - }, - { - name: "draft with failing CI maps to ci_failed", - in: OpenPRInput{Draft: true, CIFailing: true, ChangesRequested: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusCIFailed, - wantPR: domain.PRReasonCIFailing, - wantPRState: domain.PRDraft, - wantState: domain.SessionWorking, - }, - { - name: "draft ignores review and merge states", - in: OpenPRInput{Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, - wantStatus: domain.StatusDraft, - wantPR: domain.PRReasonInProgress, - wantPRState: domain.PRDraft, - wantState: domain.SessionWorking, - }, - { - name: "changes requested before approval states", - in: OpenPRInput{ChangesRequested: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusChangesRequested, - wantPR: domain.PRReasonChangesRequested, - wantState: domain.SessionWorking, - }, - { - name: "bot comments get distinct PR reason", - in: OpenPRInput{BotComments: true, Approved: true, Mergeable: true}, - wantStatus: domain.StatusChangesRequested, - wantPR: domain.PRReasonBotComments, - wantState: domain.SessionWorking, - }, - { - name: "merge conflicts get distinct PR reason", - in: OpenPRInput{MergeConflicts: true, Approved: true}, - wantStatus: domain.StatusPROpen, - wantPR: domain.PRReasonMergeConflicts, - wantState: domain.SessionWorking, - }, - { - name: "approved + mergeable -> mergeable", - in: OpenPRInput{Approved: true, Mergeable: true}, - wantStatus: domain.StatusMergeable, - wantPR: domain.PRReasonMergeReady, - wantState: domain.SessionIdle, - }, - { - name: "mergeable without formal approval (no required review) -> mergeable", - in: OpenPRInput{Mergeable: true}, - wantStatus: domain.StatusMergeable, - wantPR: domain.PRReasonMergeReady, - wantState: domain.SessionIdle, - }, - { - name: "approved but not mergeable -> approved", - in: OpenPRInput{Approved: true}, - wantStatus: domain.StatusApproved, - wantPR: domain.PRReasonApproved, - wantState: domain.SessionIdle, - }, - { - name: "review pending", - in: OpenPRInput{ReviewPending: true}, - wantStatus: domain.StatusReviewPending, - wantPR: domain.PRReasonReviewPending, - wantState: domain.SessionIdle, - }, - { - name: "idle beyond threshold -> stuck", - in: OpenPRInput{IdleBeyond: true}, - wantStatus: domain.StatusStuck, - wantPR: domain.PRReasonInProgress, - wantState: domain.SessionStuck, - }, - { - name: "review pending wins over idle-beyond", - in: OpenPRInput{ReviewPending: true, IdleBeyond: true}, - wantStatus: domain.StatusReviewPending, - wantPR: domain.PRReasonReviewPending, - wantState: domain.SessionIdle, - }, - { - name: "nothing set -> plain open", - in: OpenPRInput{}, - wantStatus: domain.StatusPROpen, - wantPR: domain.PRReasonInProgress, - wantState: domain.SessionWorking, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ResolveOpenPRDecision(tt.in) - if got.Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) - } - if got.PRReason != tt.wantPR { - t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) - } - wantPRState := tt.wantPRState - if wantPRState == "" { - wantPRState = domain.PROpen - } - if got.PRState != wantPRState { - t.Errorf("PRState = %q, want %q", got.PRState, wantPRState) - } - if got.SessionState != tt.wantState { - t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) - } - }) - } -} - -func TestResolveOpenPRDecisionEvidence(t *testing.T) { - tests := []struct { - name string - in OpenPRInput - want string - }{ - { - name: "condition with PR number and URL", - in: OpenPRInput{CIFailing: true, Number: 123, URL: "https://example.com/pr/123"}, - want: "ci_failing #123 https://example.com/pr/123", - }, - { - name: "condition with number only", - in: OpenPRInput{Approved: true, Mergeable: true, Number: 7}, - want: "merge_ready #7", - }, - { - name: "no identity falls back to the bare condition", - in: OpenPRInput{}, - want: "pr_open", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ResolveOpenPRDecision(tt.in).Evidence; got != tt.want { - t.Errorf("Evidence = %q, want %q", got, tt.want) - } - }) - } -} - -func TestDecidersDeriveConsistently(t *testing.T) { - // Every decision a decider produces must be self-consistent: the display - // Status it reports must equal what DeriveLegacyStatus produces from the - // canonical (session, pr) sub-states it emits. This locks the deciders and - // the display-derivation against drifting apart. - // - // The ResolveTerminalPRStateDecision none/open default is intentionally - // excluded — it is a documented no-op for misuse, not a real verdict. - var decisions []LifecycleDecision - - for _, in := range []OpenPRInput{ - {Draft: true, CIFailing: true}, - {Draft: true, ChangesRequested: true, Approved: true, Mergeable: true, ReviewPending: true, IdleBeyond: true}, - {CIFailing: true}, - {ChangesRequested: true}, - {BotComments: true}, - {MergeConflicts: true}, - {Approved: true, Mergeable: true}, - {Mergeable: true}, - {Approved: true}, - {ReviewPending: true}, - {IdleBeyond: true}, - {}, - } { - decisions = append(decisions, ResolveOpenPRDecision(in)) - } - - decisions = append(decisions, - ResolveTerminalPRStateDecision(domain.PRMerged), - ResolveTerminalPRStateDecision(domain.PRClosed), - ) - - for _, in := range []ProbeInput{ - {KillRequested: true, Now: t0}, - {Runtime: domain.RuntimeAlive, Process: ProcessAlive, Now: t0}, - {Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0}, - {Runtime: domain.RuntimeExited, Process: ProcessDead, Now: t0}, - } { - decisions = append(decisions, ResolveProbeDecision(in)) - } - - for _, d := range decisions { - l := domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: d.SessionState, Reason: d.SessionReason}, - PR: domain.PRSubstate{State: d.PRState, Reason: d.PRReason}, - } - if got := domain.DeriveLegacyStatus(l); got != d.Status { - t.Errorf("decision %+v: Status=%q but DeriveLegacyStatus=%q", d, d.Status, got) - } - } -} - -func TestResolveTerminalPRStateDecision(t *testing.T) { tests := []struct { name string - pr domain.PRState - wantStatus domain.SessionStatus + in ProbeInput wantState domain.SessionState - wantReason domain.SessionReason - wantPR domain.PRReason + wantReason domain.TerminationReason + wantAlive bool + wantDetect bool // expect a detecting verdict (first attempt -> SessionDetecting) }{ { - name: "merged parks idle awaiting decision", - pr: domain.PRMerged, - wantStatus: domain.StatusMerged, - wantState: domain.SessionIdle, - wantReason: domain.ReasonMergedWaitingDecision, - wantPR: domain.PRReasonMerged, + name: "kill requested -> terminated with reason", + in: ProbeInput{KillRequested: true, KillReason: domain.TermManuallyKilled, Now: t0}, + wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false, }, { - name: "closed drops to idle", - pr: domain.PRClosed, - wantStatus: domain.StatusIdle, - wantState: domain.SessionIdle, - wantReason: domain.ReasonAwaitingUserInput, - wantPR: domain.PRReasonClosedUnmerged, + name: "kill requested without reason defaults to manually_killed", + in: ProbeInput{KillRequested: true, Now: t0}, + wantState: domain.SessionTerminated, wantReason: domain.TermManuallyKilled, wantAlive: false, }, { - name: "non-terminal none is a working no-op", - pr: domain.PRNone, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, + name: "runtime probe failed -> detecting (not death)", + in: ProbeInput{RuntimeFailed: true, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "non-terminal open is a working no-op", - pr: domain.PROpen, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, + name: "process probe failed -> detecting", + in: ProbeInput{RuntimeAlive: true, ProcessFailed: true, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, { - name: "non-terminal draft is a working no-op", - pr: domain.PRDraft, - wantStatus: domain.StatusWorking, - wantState: domain.SessionWorking, - wantReason: domain.ReasonTaskInProgress, + name: "runtime alive + process alive -> working", + in: ProbeInput{RuntimeAlive: true, Process: ProcessAlive, Now: t0}, + wantState: domain.SessionWorking, wantAlive: true, + }, + { + name: "runtime alive + process indeterminate -> working", + in: ProbeInput{RuntimeAlive: true, Process: ProcessIndeterminate, Now: t0}, + wantState: domain.SessionWorking, wantAlive: true, + }, + { + name: "runtime alive + process dead -> detecting (disagree)", + in: ProbeInput{RuntimeAlive: true, Process: ProcessDead, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, + }, + { + name: "runtime down + process dead + no activity -> terminated runtime_lost", + in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: false, Now: t0}, + wantState: domain.SessionTerminated, wantReason: domain.TermRuntimeLost, wantAlive: false, + }, + { + name: "runtime down + process alive -> detecting (disagree)", + in: ProbeInput{RuntimeAlive: false, Process: ProcessAlive, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, + }, + { + name: "runtime down + process dead + recent activity -> detecting", + in: ProbeInput{RuntimeAlive: false, Process: ProcessDead, RecentActivity: true, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, + }, + { + name: "runtime down + process indeterminate -> detecting", + in: ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0}, + wantState: domain.SessionDetecting, wantAlive: true, wantDetect: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ResolveTerminalPRStateDecision(tt.pr) - if got.Status != tt.wantStatus { - t.Errorf("Status = %q, want %q", got.Status, tt.wantStatus) + d := ResolveProbeDecision(tt.in) + if d.SessionState != tt.wantState { + t.Errorf("state = %q, want %q", d.SessionState, tt.wantState) } - if got.SessionState != tt.wantState { - t.Errorf("SessionState = %q, want %q", got.SessionState, tt.wantState) + if d.TerminationReason != tt.wantReason { + t.Errorf("reason = %q, want %q", d.TerminationReason, tt.wantReason) } - if got.SessionReason != tt.wantReason { - t.Errorf("SessionReason = %q, want %q", got.SessionReason, tt.wantReason) + if d.IsAlive != tt.wantAlive { + t.Errorf("isAlive = %v, want %v", d.IsAlive, tt.wantAlive) } - if tt.wantPR != "" && got.PRReason != tt.wantPR { - t.Errorf("PRReason = %q, want %q", got.PRReason, tt.wantPR) + if tt.wantDetect && d.Detecting == nil { + t.Errorf("expected detecting memory, got nil") } }) } } func TestCreateDetectingDecision(t *testing.T) { - const ev = "runtime_lost runtime=missing process=indeterminate" - hash := HashEvidence(ev) - - t.Run("first entry records attempt 1 and stays detecting", func(t *testing.T) { - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Now: t0}) - if got.Status != domain.StatusDetecting || got.SessionState != domain.SessionDetecting { - t.Fatalf("want detecting, got Status=%q State=%q", got.Status, got.SessionState) - } - if got.Detecting == nil || got.Detecting.Attempts != 1 { - t.Fatalf("want attempts=1, got %+v", got.Detecting) - } - if !got.Detecting.StartedAt.Equal(t0) { - t.Errorf("StartedAt = %v, want %v", got.Detecting.StartedAt, t0) - } - if got.Detecting.EvidenceHash != hash { - t.Errorf("EvidenceHash = %q, want %q", got.Detecting.EvidenceHash, hash) - } - if got.SessionReason != domain.ReasonRuntimeLost { - t.Errorf("SessionReason = %q, want %q", got.SessionReason, domain.ReasonRuntimeLost) + t.Run("first entry sets attempts 1", func(t *testing.T) { + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Now: t0}) + if d.SessionState != domain.SessionDetecting || d.Detecting == nil || d.Detecting.Attempts != 1 { + t.Fatalf("got %+v", d) } }) - - t.Run("unchanged evidence climbs the counter", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) - if got.Detecting == nil || got.Detecting.Attempts != 2 { - t.Fatalf("want attempts=2, got %+v", got.Detecting) - } - if !got.Detecting.StartedAt.Equal(t0) { - t.Errorf("StartedAt must be preserved, got %v", got.Detecting.StartedAt) + t.Run("same evidence climbs the counter", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)}) + if d.Detecting == nil || d.Detecting.Attempts != 2 { + t.Fatalf("attempts = %+v, want 2", d.Detecting) } }) - - t.Run("escalates to stuck on the third unchanged tick", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) - if got.Status != domain.StatusStuck || got.SessionState != domain.SessionStuck { - t.Fatalf("want stuck, got Status=%q State=%q", got.Status, got.SessionState) - } - if got.Detecting != nil { - t.Errorf("stuck decision must drop detecting memory, got %+v", got.Detecting) - } - if got.SessionReason != domain.ReasonRuntimeLost { - t.Errorf("escalation should carry the why, got %q", got.SessionReason) + t.Run("changed evidence resets the counter", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 2, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "process dead", Prior: prior, Now: t0.Add(time.Second)}) + if d.Detecting == nil || d.Detecting.Attempts != 1 { + t.Fatalf("attempts = %+v, want 1 (evidence changed)", d.Detecting) } }) - - t.Run("changing evidence resets the counter but preserves StartedAt", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: "different evidence", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(time.Minute)}) - if got.Status != domain.StatusDetecting { - t.Fatalf("changed evidence should stay detecting, got %q", got.Status) - } - if got.Detecting == nil || got.Detecting.Attempts != 1 { - t.Fatalf("counter should reset to 1, got %+v", got.Detecting) - } - if !got.Detecting.StartedAt.Equal(t0) { - t.Errorf("StartedAt must survive an evidence change, got %v", got.Detecting.StartedAt) + t.Run("escalates to stuck at the attempt cap", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: DetectingMaxAttempts - 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(time.Second)}) + if d.SessionState != domain.SessionStuck { + t.Fatalf("state = %q, want stuck", d.SessionState) } }) - - t.Run("duration cap escalates even below the attempt count", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: ev, ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration)}) - if got.Status != domain.StatusStuck { - t.Fatalf("want stuck from duration cap, got %q", got.Status) - } - }) - - t.Run("duration cap fires even when evidence keeps flapping", func(t *testing.T) { - prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: hash} - got := CreateDetectingDecision(DetectingInput{Evidence: "ever-changing", ProposedReason: domain.ReasonRuntimeLost, Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Minute)}) - if got.Status != domain.StatusStuck { - t.Fatalf("duration cap must override a reset counter, got %q", got.Status) + t.Run("escalates to stuck past the duration cap", func(t *testing.T) { + prior := &domain.DetectingState{Attempts: 1, StartedAt: t0, EvidenceHash: HashEvidence("runtime down")} + d := CreateDetectingDecision(DetectingInput{Evidence: "runtime down", Prior: prior, Now: t0.Add(DetectingMaxDuration + time.Second)}) + if d.SessionState != domain.SessionStuck { + t.Fatalf("state = %q, want stuck (duration cap)", d.SessionState) } }) } func TestProbeDetectingEscalationFlow(t *testing.T) { - // An unchanging ambiguous probe should escalate to stuck after exactly - // DetectingMaxAttempts ticks. - in := ProbeInput{Runtime: domain.RuntimeMissing, Process: ProcessIndeterminate, Now: t0} - d := ResolveProbeDecision(in) + in := ProbeInput{RuntimeAlive: false, Process: ProcessIndeterminate, Now: t0} + var prior *domain.DetectingState for i := 1; i < DetectingMaxAttempts; i++ { - if d.Status != domain.StatusDetecting { - t.Fatalf("tick %d: expected detecting, got %q", i, d.Status) - } - in.Prior = d.Detecting + in.Prior = prior in.Now = t0.Add(time.Duration(i) * time.Second) - d = ResolveProbeDecision(in) + d := ResolveProbeDecision(in) + if d.SessionState != domain.SessionDetecting { + t.Fatalf("attempt %d: state = %q, want detecting", i, d.SessionState) + } + prior = d.Detecting } - if d.Status != domain.StatusStuck { - t.Fatalf("expected escalation to stuck after %d ticks, got %q", DetectingMaxAttempts, d.Status) + in.Prior = prior + in.Now = t0.Add(time.Hour) + if d := ResolveProbeDecision(in); d.SessionState != domain.SessionStuck { + t.Fatalf("final attempt: state = %q, want stuck", d.SessionState) } } func TestHashEvidence(t *testing.T) { - t.Run("identical strings hash identically", func(t *testing.T) { - if HashEvidence("same input") != HashEvidence("same input") { - t.Error("identical evidence must hash equal") - } - }) - - t.Run("different evidence hashes differently", func(t *testing.T) { - if HashEvidence("runtime_lost") == HashEvidence("agent_process_exited") { - t.Error("distinct evidence must hash differently") - } - }) - - t.Run("only the timestamp differs -> equal hash", func(t *testing.T) { - a := "probe failed at 2026-05-26T12:00:00Z runtime=missing" - b := "probe failed at 2026-05-26T12:05:43.218Z runtime=missing" - if HashEvidence(a) != HashEvidence(b) { - t.Errorf("restamped evidence should hash equal:\n a=%q\n b=%q", a, b) - } - }) - - t.Run("bare time-of-day stripped", func(t *testing.T) { - if HashEvidence("idle since 12:00:00") != HashEvidence("idle since 13:30:59") { - t.Error("time-of-day differences should be stripped") - } - }) - - t.Run("unix epoch stripped", func(t *testing.T) { - if HashEvidence("last seen 1716724800") != HashEvidence("last seen 1716728400") { - t.Error("epoch differences should be stripped") - } - }) - - t.Run("a real content change still changes the hash", func(t *testing.T) { - a := "probe at 2026-05-26T12:00:00Z runtime=missing" - b := "probe at 2026-05-26T12:00:00Z runtime=alive" - if HashEvidence(a) == HashEvidence(b) { - t.Error("non-timestamp content change must change the hash") - } - }) - - t.Run("whitespace differences are normalised", func(t *testing.T) { - if HashEvidence("runtime=missing process=dead") != HashEvidence("runtime=missing process=dead") { - t.Error("collapsed whitespace should hash equal") - } - }) + // timestamp-only differences hash equal; a real change differs. + a := HashEvidence("runtime down at 2026-05-31T12:00:00Z") + b := HashEvidence("runtime down at 2026-05-31T13:30:45Z") + if a != b { + t.Errorf("restamped evidence should hash equal") + } + c := HashEvidence("process dead at 2026-05-31T12:00:00Z") + if a == c { + t.Errorf("different evidence should hash differently") + } } diff --git a/backend/internal/domain/decide/types.go b/backend/internal/domain/decide/types.go index 1666fae74..832fab6fe 100644 --- a/backend/internal/domain/decide/types.go +++ b/backend/internal/domain/decide/types.go @@ -6,39 +6,34 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) -// LifecycleDecision is the output of every decider: the derived display status -// plus the canonical sub-state values to persist, the human-readable evidence, -// and the (possibly updated) detecting memory. +// LifecycleDecision is the output of a decider: the canonical session sub-state +// to persist (state, the liveness bool, and — only for a terminal state — the +// termination reason), the human-readable evidence, and the (possibly updated) +// detecting memory. The display status is NOT here — it is derived on read by +// domain.DeriveStatus from the persisted lifecycle plus the pr table. // -// Zero-value sub-state fields mean "this decider does not address that -// sub-state — leave it unchanged", NOT "set it to the empty value". SessionState -// is always populated, but the probe/detecting/kill paths legitimately leave -// PRState/PRReason empty: a liveness verdict knows nothing about the PR. When -// the LCM folds a decision into the next full canonical row it must therefore -// leave empty PRState/PRReason unchanged rather than writing them through — -// writing PRNone on a routine probe tick would clobber a live PR. Detecting is -// nil-by-default; the LCM explicitly clears stale detecting memory when a probe -// verdict leaves detecting. +// PR facts are likewise not here: a liveness verdict knows nothing about the PR, +// and PR-driven display/reactions are handled off the pr table, not the session +// state machine. type LifecycleDecision struct { - Status domain.SessionStatus - Evidence string - Detecting *domain.DetectingState - SessionState domain.SessionState - SessionReason domain.SessionReason - PRState domain.PRState - PRReason domain.PRReason + Evidence string + Detecting *domain.DetectingState + SessionState domain.SessionState + TerminationReason domain.TerminationReason // set only when SessionState is terminated + IsAlive bool } -// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout -// or error) is distinct from a "dead" verdict and must route to detecting, -// never to a death conclusion. KillRequested short-circuits to terminal. +// ProbeInput reconciles runtime + process liveness. A *failed* probe (timeout or +// error) is distinct from a "dead" verdict and must route to detecting, never to +// a death conclusion. KillRequested short-circuits to terminal with KillReason. type ProbeInput struct { - Runtime domain.RuntimeState - RuntimeFailed bool + RuntimeAlive bool // the runtime probe reports the backing runtime is up + RuntimeFailed bool // the runtime probe itself failed (timeout/error) — not "dead" Process ProcessLiveness ProcessFailed bool RecentActivity bool KillRequested bool + KillReason domain.TerminationReason // the terminal reason when KillRequested Prior *domain.DetectingState Now time.Time } @@ -52,28 +47,11 @@ const ( ProcessIndeterminate ProcessLiveness = "indeterminate" ) -// OpenPRInput drives the PR pipeline ladder for an open or draft PR. -type OpenPRInput struct { - Draft bool - CIFailing bool - ChangesRequested bool - BotComments bool - MergeConflicts bool - Approved bool - Mergeable bool - ReviewPending bool - IdleBeyond bool // idle past the stuck threshold - Number int - URL string -} - -// DetectingInput feeds the quarantine counter. Evidence is hashed with +// DetectingInput feeds the anti-flap quarantine counter. Evidence is hashed with // timestamps stripped, so "same ambiguous signal" keeps the counter climbing // while any real change resets it. type DetectingInput struct { - Evidence string - ProposedState domain.SessionState - ProposedReason domain.SessionReason - Prior *domain.DetectingState - Now time.Time + Evidence string + Prior *domain.DetectingState + Now time.Time } diff --git a/backend/internal/domain/lifecycle.go b/backend/internal/domain/lifecycle.go index fca87b6b8..b56367616 100644 --- a/backend/internal/domain/lifecycle.go +++ b/backend/internal/domain/lifecycle.go @@ -11,30 +11,35 @@ import "time" // Greenfield: we start at 1 and carry no migration/synthesis code. const LifecycleVersion = 1 -// CanonicalSessionLifecycle is the ONLY thing persisted for a session's state. -// The display status is derived from it on read (see DeriveLegacyStatus) and is -// never stored — this prevents canonical truth and display from drifting. +// CanonicalSessionLifecycle is the ONLY lifecycle state persisted for a session. +// The display status is derived from it (plus the session's PR facts, which live +// in the separate pr table) on read — see DeriveStatus — and is never stored, so +// canonical truth and display cannot drift. // -// Three orthogonal (state, reason) sub-states describe the session, its PR, and -// its runtime. Activity and Detecting are decider *inputs* that must survive -// between observations (they are read back by the pure decide core), so they -// live in the persisted record too. +// PR facts are deliberately NOT here: a session can own several PRs over its +// life, and PR state is owned by the pr table. The runtime axis is collapsed to +// a single IsAlive boolean. Activity and Detecting are decider *inputs* that +// must survive between observations, so they live in the persisted record. type CanonicalSessionLifecycle struct { // Version is the Go-only schema-shape constant for this record. It is not // persisted and is not part of the CDC payload. Version int - // Revision is the per-write monotonic counter. The storage layer's Upsert - // bumps it when the full row is persisted; the LCM does not. - Revision int `json:"revision"` - Session SessionSubstate `json:"session"` - PR PRSubstate `json:"pr"` - Runtime RuntimeSubstate `json:"runtime"` - // Activity is the last-known agent activity. It arrives on a different - // cadence (ApplyActivitySignal) than runtime probes (the reaper), so the - // probe decider reads it from here to answer "was there recent activity?". + Session SessionSubstate `json:"session"` Activity ActivitySubstate `json:"activity"` + // TerminationReason is set only when Session.State is terminated; '' otherwise. + TerminationReason TerminationReason `json:"terminationReason,omitempty"` + + // IsAlive is the single liveness fact: is the runtime/process backing this + // session still up? It replaces the old runtime (state, reason) axis — the + // nuance the probe decider needs (failed-probe != dead, anti-flap) lives in + // the decide core's inputs, not in a persisted enum. + IsAlive bool `json:"isAlive"` + + // Harness is the agent harness the session runs (claude-code, codex, ...). + Harness AgentHarness `json:"harness,omitempty"` + // Detecting is the anti-flap quarantine memory. It is non-nil only while // the session is in the detecting state; it carries the attempt counter, // the first-entry time, and a hash of the (timestamp-stripped) evidence so @@ -42,6 +47,18 @@ type CanonicalSessionLifecycle struct { Detecting *DetectingState `json:"detecting,omitempty"` } +// ---- agent harness ---- + +// AgentHarness identifies which agent CLI/runtime a session drives. +type AgentHarness string + +const ( + HarnessClaudeCode AgentHarness = "claude-code" + HarnessCodex AgentHarness = "codex" + HarnessAider AgentHarness = "aider" + HarnessOpenCode AgentHarness = "opencode" +) + // ---- session sub-state ---- type SessionState string @@ -57,98 +74,75 @@ const ( SessionTerminated SessionState = "terminated" ) -type SessionReason string +// TerminationReason is the typed "why" for a terminated session — the only +// state that carries a reason. Empty for every non-terminal state. It decides +// the terminal display status (killed / cleanup / errored). The PR-pipeline +// "why" (fixing CI, awaiting review, …) is NOT here; it is derived on read from +// the pr table, not persisted on the session. +type TerminationReason string const ( - ReasonSpawnRequested SessionReason = "spawn_requested" - ReasonAgentAcknowledged SessionReason = "agent_acknowledged" - ReasonTaskInProgress SessionReason = "task_in_progress" - ReasonPRCreated SessionReason = "pr_created" - ReasonFixingCI SessionReason = "fixing_ci" - ReasonResolvingReviewComments SessionReason = "resolving_review_comments" - ReasonAwaitingUserInput SessionReason = "awaiting_user_input" - ReasonAwaitingExternalReview SessionReason = "awaiting_external_review" - ReasonResearchComplete SessionReason = "research_complete" - ReasonMergedWaitingDecision SessionReason = "merged_waiting_decision" - ReasonManuallyKilled SessionReason = "manually_killed" - ReasonPRMerged SessionReason = "pr_merged" - ReasonAutoCleanup SessionReason = "auto_cleanup" - ReasonRuntimeLost SessionReason = "runtime_lost" - ReasonAgentProcessExited SessionReason = "agent_process_exited" - ReasonProbeFailure SessionReason = "probe_failure" - ReasonErrorInProcess SessionReason = "error_in_process" + TermNone TerminationReason = "" + TermManuallyKilled TerminationReason = "manually_killed" + TermRuntimeLost TerminationReason = "runtime_lost" + TermAgentProcessExited TerminationReason = "agent_process_exited" + TermProbeFailure TerminationReason = "probe_failure" + TermErrorInProcess TerminationReason = "error_in_process" + TermAutoCleanup TerminationReason = "auto_cleanup" + TermPRMerged TerminationReason = "pr_merged" ) type SessionSubstate struct { - State SessionState `json:"state"` - Reason SessionReason `json:"reason"` + State SessionState `json:"state"` } -// ---- PR sub-state ---- +// ---- PR facts (NOT persisted on the session; sourced from the pr table) ---- -type PRState string - -const ( - PRNone PRState = "none" - PRDraft PRState = "draft" - PROpen PRState = "open" - PRMerged PRState = "merged" - PRClosed PRState = "closed" -) - -type PRReason string - -const ( - PRReasonNotCreated PRReason = "not_created" - PRReasonInProgress PRReason = "in_progress" - PRReasonCIFailing PRReason = "ci_failing" - PRReasonReviewPending PRReason = "review_pending" - PRReasonChangesRequested PRReason = "changes_requested" - PRReasonBotComments PRReason = "bot_comments" - PRReasonMergeConflicts PRReason = "merge_conflicts" - PRReasonApproved PRReason = "approved" - PRReasonMergeReady PRReason = "merge_ready" - PRReasonMerged PRReason = "merged" - PRReasonClosedUnmerged PRReason = "closed_unmerged" - PRReasonClearedOnRestore PRReason = "cleared_on_restore" -) - -type PRSubstate struct { - State PRState `json:"state"` - Reason PRReason `json:"reason"` - Number int `json:"number,omitempty"` - URL string `json:"url,omitempty"` +// PRFacts is the per-session PR snapshot the status/reaction derivation reads +// from the pr table. It is the decider input that replaces the old persisted PR +// axis. The zero value (Exists=false) means "no PR", which derivation treats as +// "session has no PR". +type PRFacts struct { + URL string + Number int + Exists bool + Draft bool + Merged bool + Closed bool + CI CIState + Review ReviewDecision + Mergeability Mergeability + BotComments bool + IdleBeyond bool // idle past the stuck threshold } -// ---- runtime sub-state ---- - -type RuntimeState string +type CIState string const ( - RuntimeUnknown RuntimeState = "unknown" - RuntimeAlive RuntimeState = "alive" - RuntimeExited RuntimeState = "exited" - RuntimeMissing RuntimeState = "missing" - RuntimeProbeFailed RuntimeState = "probe_failed" + CIUnknown CIState = "unknown" + CIPending CIState = "pending" + CIPassing CIState = "passing" + CIFailing CIState = "failing" ) -type RuntimeReason string +type ReviewDecision string const ( - RuntimeReasonSpawnIncomplete RuntimeReason = "spawn_incomplete" - RuntimeReasonProcessRunning RuntimeReason = "process_running" - RuntimeReasonProcessMissing RuntimeReason = "process_missing" - RuntimeReasonTmuxMissing RuntimeReason = "tmux_missing" - RuntimeReasonManualKillRequested RuntimeReason = "manual_kill_requested" - RuntimeReasonPRMergedCleanup RuntimeReason = "pr_merged_cleanup" - RuntimeReasonAutoCleanup RuntimeReason = "auto_cleanup" - RuntimeReasonProbeError RuntimeReason = "probe_error" + ReviewNone ReviewDecision = "none" + ReviewApproved ReviewDecision = "approved" + ReviewChangesRequest ReviewDecision = "changes_requested" + ReviewRequired ReviewDecision = "review_required" ) -type RuntimeSubstate struct { - State RuntimeState `json:"state"` - Reason RuntimeReason `json:"reason"` -} +type Mergeability string + +const ( + MergeUnknown Mergeability = "unknown" + MergeMergeable Mergeability = "mergeable" + MergeConflicting Mergeability = "conflicting" + MergeBlocked Mergeability = "blocked" + MergeUnstable Mergeability = "unstable" +) // ---- activity sub-state (decider input) ---- diff --git a/backend/internal/domain/status.go b/backend/internal/domain/status.go index 1cc4404d3..0ff5c0fd8 100644 --- a/backend/internal/domain/status.go +++ b/backend/internal/domain/status.go @@ -1,7 +1,8 @@ package domain // SessionStatus is the single-word DISPLAY status the dashboard renders. It is -// derived from the canonical lifecycle on read and never persisted. +// derived from the canonical lifecycle (plus the session's PR facts) on read and +// never persisted. type SessionStatus string const ( @@ -26,27 +27,27 @@ const ( StatusTerminated SessionStatus = "terminated" ) -// DeriveLegacyStatus is the ONLY producer of the display status. It must stay a -// pure, total function of the canonical record. +// DeriveStatus is the ONLY producer of the display status. It is a pure, total +// function of the canonical record plus the session's PR facts (read from the pr +// table by the caller, since PR state is no longer persisted on the session). // // Order matters: // 1. Terminal / hard session states (done, terminated, needs_input, stuck, // detecting, not_started) map directly — these OUTRANK PR facts. -// 2. Otherwise a merged PR wins. -// 3. Otherwise a draft PR maps to draft, except CI failure still dominates. -// 4. Otherwise an open PR maps by its reason. -// 5. Otherwise fall through to the SOFT session state (idle/working). +// 2. Otherwise, if the session has a PR: a merged PR wins, else the PR pipeline +// ladder (CI failure dominates, then draft/review/merge states). +// 3. Otherwise fall through to the SOFT session state (idle/working). // // So "PR facts dominate session facts" applies only to the soft states: an idle // or working session with an open, CI-failing PR displays as ci_failed — but a -// session that is stuck or needs_input shows that regardless of PR state, since -// it needs a human either way. -func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { +// session that is stuck or needs_input shows that regardless, since it needs a +// human either way. +func DeriveStatus(l CanonicalSessionLifecycle, pr PRFacts) SessionStatus { switch l.Session.State { case SessionDone: return StatusDone case SessionTerminated: - return terminatedStatus(l.Session.Reason) + return terminatedStatus(l.TerminationReason) case SessionNeedsInput: return StatusNeedsInput case SessionStuck: @@ -57,16 +58,13 @@ func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { return StatusSpawning } - if l.PR.State == PRMerged { - return StatusMerged - } - - if l.PR.State == PRDraft { - return draftPRStatus(l.PR.Reason) - } - - if l.PR.State == PROpen { - return openPRStatus(l.PR.Reason) + if pr.Exists { + if pr.Merged { + return StatusMerged + } + if !pr.Closed { + return prPipelineStatus(pr) + } } if l.Session.State == SessionIdle { @@ -75,37 +73,35 @@ func DeriveLegacyStatus(l CanonicalSessionLifecycle) SessionStatus { return StatusWorking } -func terminatedStatus(r SessionReason) SessionStatus { +func terminatedStatus(r TerminationReason) SessionStatus { switch r { - case ReasonManuallyKilled, ReasonRuntimeLost, ReasonAgentProcessExited: + case TermManuallyKilled, TermRuntimeLost, TermAgentProcessExited: return StatusKilled - case ReasonAutoCleanup, ReasonPRMerged: + case TermAutoCleanup, TermPRMerged: return StatusCleanup - case ReasonErrorInProcess, ReasonProbeFailure: + case TermErrorInProcess, TermProbeFailure: return StatusErrored default: return StatusTerminated } } -func draftPRStatus(r PRReason) SessionStatus { - if r == PRReasonCIFailing { +// prPipelineStatus maps an open/draft PR's facts to a display status, preserving +// the old ladder: CI failure dominates everything, then draft, then the review / +// merge states. +func prPipelineStatus(pr PRFacts) SessionStatus { + switch { + case pr.CI == CIFailing: return StatusCIFailed - } - return StatusDraft -} - -func openPRStatus(r PRReason) SessionStatus { - switch r { - case PRReasonCIFailing: - return StatusCIFailed - case PRReasonChangesRequested, PRReasonBotComments: + case pr.Draft: + return StatusDraft + case pr.Review == ReviewChangesRequest || pr.BotComments: return StatusChangesRequested - case PRReasonApproved: - return StatusApproved - case PRReasonMergeReady: + case pr.Mergeability == MergeMergeable: return StatusMergeable - case PRReasonReviewPending: + case pr.Review == ReviewApproved: + return StatusApproved + case pr.Review == ReviewRequired: return StatusReviewPending default: return StatusPROpen diff --git a/backend/internal/domain/status_test.go b/backend/internal/domain/status_test.go index 098549984..ae63271cf 100644 --- a/backend/internal/domain/status_test.go +++ b/backend/internal/domain/status_test.go @@ -2,117 +2,58 @@ package domain import "testing" -func TestDeriveLegacyStatus(t *testing.T) { +func TestDeriveStatus(t *testing.T) { + // sess builds a non-terminal lifecycle (no reason). + sess := func(s SessionState) CanonicalSessionLifecycle { + return CanonicalSessionLifecycle{Session: SessionSubstate{State: s}} + } + // term builds a terminated lifecycle carrying a TerminationReason. + term := func(r TerminationReason) CanonicalSessionLifecycle { + return CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated}, TerminationReason: r} + } + openPR := func(mut func(*PRFacts)) PRFacts { + f := PRFacts{Exists: true, CI: CIUnknown, Review: ReviewNone, Mergeability: MergeUnknown} + if mut != nil { + mut(&f) + } + return f + } + tests := []struct { name string in CanonicalSessionLifecycle + pr PRFacts want SessionStatus }{ - { - name: "not_started maps to spawning", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionNotStarted, Reason: ReasonSpawnRequested}}, - want: StatusSpawning, - }, - { - name: "terminated+manually_killed maps to killed", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonManuallyKilled}}, - want: StatusKilled, - }, - { - name: "terminated+auto_cleanup maps to cleanup", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonAutoCleanup}}, - want: StatusCleanup, - }, - { - name: "terminated+error maps to errored", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionTerminated, Reason: ReasonErrorInProcess}}, - want: StatusErrored, - }, - { - name: "hard state needs_input maps directly", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionNeedsInput}}, - want: StatusNeedsInput, - }, - { - name: "merged PR dominates an idle session", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionIdle}, - PR: PRSubstate{State: PRMerged}, - }, - want: StatusMerged, - }, - { - name: "open PR with failing CI dominates idle session", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionIdle}, - PR: PRSubstate{State: PROpen, Reason: PRReasonCIFailing}, - }, - want: StatusCIFailed, - }, - { - name: "draft PR with failing CI maps to ci_failed", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PRDraft, Reason: PRReasonCIFailing}, - }, - want: StatusCIFailed, - }, - { - name: "draft PR ignores review and merge reasons", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PRDraft, Reason: PRReasonMergeReady}, - }, - want: StatusDraft, - }, - { - name: "open PR bot comments display as changes_requested", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonBotComments}, - }, - want: StatusChangesRequested, - }, - { - name: "open PR merge conflicts display as plain open", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonMergeConflicts}, - }, - want: StatusPROpen, - }, - { - name: "open PR approved", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonApproved}, - }, - want: StatusApproved, - }, - { - name: "open PR merge_ready maps to mergeable", - in: CanonicalSessionLifecycle{ - Session: SessionSubstate{State: SessionWorking}, - PR: PRSubstate{State: PROpen, Reason: PRReasonMergeReady}, - }, - want: StatusMergeable, - }, - { - name: "no PR falls through to idle", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionIdle}}, - want: StatusIdle, - }, - { - name: "no PR falls through to working", - in: CanonicalSessionLifecycle{Session: SessionSubstate{State: SessionWorking}}, - want: StatusWorking, - }, + {"not_started maps to spawning", sess(SessionNotStarted), PRFacts{}, StatusSpawning}, + {"terminated+manually_killed -> killed", term(TermManuallyKilled), PRFacts{}, StatusKilled}, + {"terminated+runtime_lost -> killed", term(TermRuntimeLost), PRFacts{}, StatusKilled}, + {"terminated+auto_cleanup -> cleanup", term(TermAutoCleanup), PRFacts{}, StatusCleanup}, + {"terminated+pr_merged -> cleanup", term(TermPRMerged), PRFacts{}, StatusCleanup}, + {"terminated+error -> errored", term(TermErrorInProcess), PRFacts{}, StatusErrored}, + {"needs_input maps directly", sess(SessionNeedsInput), PRFacts{}, StatusNeedsInput}, + {"stuck dominates any PR", sess(SessionStuck), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusStuck}, + + {"no PR + idle -> idle", sess(SessionIdle), PRFacts{}, StatusIdle}, + {"no PR + working -> working", sess(SessionWorking), PRFacts{}, StatusWorking}, + + {"merged PR dominates idle session", sess(SessionIdle), PRFacts{Exists: true, Merged: true}, StatusMerged}, + {"open PR failing CI -> ci_failed", sess(SessionIdle), openPR(func(f *PRFacts) { f.CI = CIFailing }), StatusCIFailed}, + {"draft PR failing CI -> ci_failed (CI dominates)", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.CI = CIFailing }), StatusCIFailed}, + {"draft PR ignores review state -> draft", sess(SessionWorking), openPR(func(f *PRFacts) { f.Draft = true; f.Review = ReviewApproved }), StatusDraft}, + {"open PR changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewChangesRequest }), StatusChangesRequested}, + {"open PR bot comments -> changes_requested", sess(SessionWorking), openPR(func(f *PRFacts) { f.BotComments = true }), StatusChangesRequested}, + {"open PR mergeable", sess(SessionWorking), openPR(func(f *PRFacts) { f.Mergeability = MergeMergeable }), StatusMergeable}, + {"open PR approved", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewApproved }), StatusApproved}, + {"open PR review required -> review_pending", sess(SessionWorking), openPR(func(f *PRFacts) { f.Review = ReviewRequired }), StatusReviewPending}, + {"open PR no signal -> pr_open", sess(SessionWorking), openPR(nil), StatusPROpen}, + {"closed PR falls through to soft state", sess(SessionIdle), PRFacts{Exists: true, Closed: true}, StatusIdle}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := DeriveLegacyStatus(tt.in); got != tt.want { - t.Errorf("DeriveLegacyStatus() = %q, want %q", got, tt.want) + if got := DeriveStatus(tt.in, tt.pr); got != tt.want { + t.Errorf("DeriveStatus() = %q, want %q", got, tt.want) } }) } diff --git a/backend/internal/storage/sqlite/cdc_store.go b/backend/internal/storage/sqlite/cdc_store.go deleted file mode 100644 index 8f92eda73..000000000 --- a/backend/internal/storage/sqlite/cdc_store.go +++ /dev/null @@ -1,112 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// OutboxEvent is a single undelivered change, joined from outbox + change_log. -// It is the unit the CDC publisher drains to JSONL. -type OutboxEvent struct { - OutboxID int64 - Seq int64 - SessionID string - EventType string - Revision int64 - Payload string - CreatedAt time.Time -} - -// ListUnsent returns up to limit undelivered events in seq order. -func (s *Store) ListUnsent(ctx context.Context, limit int) ([]OutboxEvent, error) { - rows, err := s.q.ListUnsentOutbox(ctx, int64(limit)) - if err != nil { - return nil, fmt.Errorf("list unsent outbox: %w", err) - } - out := make([]OutboxEvent, 0, len(rows)) - for _, r := range rows { - out = append(out, OutboxEvent{ - OutboxID: r.ID, - Seq: r.ChangeLogSeq, - SessionID: r.SessionID, - EventType: r.EventType, - Revision: r.Revision, - Payload: r.Payload, - CreatedAt: r.CreatedAt, - }) - } - return out, nil -} - -// MarkSent flags an outbox row delivered. -func (s *Store) MarkSent(ctx context.Context, outboxID int64, at time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.MarkOutboxSent(ctx, gen.MarkOutboxSentParams{ - SentAt: sql.NullTime{Time: at, Valid: true}, - ID: outboxID, - }) -} - -// MarkFailed bumps the attempt count and records the last error for an outbox row. -func (s *Store) MarkFailed(ctx context.Context, outboxID int64, errMsg string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.MarkOutboxFailed(ctx, gen.MarkOutboxFailedParams{LastError: errMsg, ID: outboxID}) -} - -// GetOffset returns a consumer's last acknowledged seq (0 if it has none). -func (s *Store) GetOffset(ctx context.Context, consumer string) (int64, error) { - seq, err := s.q.GetConsumerOffset(ctx, consumer) - if errors.Is(err, sql.ErrNoRows) { - return 0, nil - } - if err != nil { - return 0, fmt.Errorf("get consumer offset %s: %w", consumer, err) - } - return seq, nil -} - -// SetOffset durably records a consumer's acknowledged seq. -func (s *Store) SetOffset(ctx context.Context, consumer string, seq int64, at time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.UpsertConsumerOffset(ctx, gen.UpsertConsumerOffsetParams{ - Consumer: consumer, - LastSeq: seq, - UpdatedAt: at, - }) -} - -// MaxChangeLogSeq returns the highest change_log seq (0 if empty). Used by the -// consumer to resume after a snapshot resync. -func (s *Store) MaxChangeLogSeq(ctx context.Context) (int64, error) { - v, err := s.q.MaxChangeLogSeq(ctx) - if err != nil { - return 0, fmt.Errorf("max change_log seq: %w", err) - } - return v, nil -} - -// MinConsumerOffset returns the lowest acknowledged seq across all consumers -// (0 if none). The janitor uses it as the safe outbox-deletion watermark. -func (s *Store) MinConsumerOffset(ctx context.Context) (int64, error) { - v, err := s.q.MinConsumerOffset(ctx) - if err != nil { - return 0, fmt.Errorf("min consumer offset: %w", err) - } - return v, nil -} - -// DeleteSentOutboxBelow removes delivered outbox rows whose seq is below the -// watermark, returning the number removed. -func (s *Store) DeleteSentOutboxBelow(ctx context.Context, seq int64) (int64, error) { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeleteSentOutboxBelow(ctx, seq) -} diff --git a/backend/internal/storage/sqlite/changelog_store.go b/backend/internal/storage/sqlite/changelog_store.go new file mode 100644 index 000000000..927d79686 --- /dev/null +++ b/backend/internal/storage/sqlite/changelog_store.go @@ -0,0 +1,89 @@ +package sqlite + +import ( + "context" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// ChangeLogRow is one durable CDC event. These rows are written by the DB +// triggers (migration 0001), never by application code; the store only reads +// them, for the CDC poller. +type ChangeLogRow struct { + Seq int64 + ProjectID string + SessionID string // empty when the event is project-level (NULL in the DB) + EventType string + Payload string + CreatedAt time.Time +} + +// ReadChangeLogAfter returns up to limit events with seq > after, in seq order +// — the CDC poller's read. The frontend's offset is `after`. +func (s *Store) ReadChangeLogAfter(ctx context.Context, after int64, limit int) ([]ChangeLogRow, error) { + rows, err := s.qr.ReadChangeLogAfter(ctx, gen.ReadChangeLogAfterParams{Seq: after, Limit: int64(limit)}) + if err != nil { + return nil, fmt.Errorf("read change_log after %d: %w", after, err) + } + out := make([]ChangeLogRow, 0, len(rows)) + for _, r := range rows { + out = append(out, changeLogRowFromGen(r)) + } + return out, nil +} + +// ReadChangeLogAfterForProject is the project-scoped variant — a client +// subscribed to one project reads only its events. +func (s *Store) ReadChangeLogAfterForProject(ctx context.Context, project string, after int64, limit int) ([]ChangeLogRow, error) { + rows, err := s.qr.ReadChangeLogAfterForProject(ctx, gen.ReadChangeLogAfterForProjectParams{ + ProjectID: project, Seq: after, Limit: int64(limit), + }) + if err != nil { + return nil, fmt.Errorf("read change_log for %s after %d: %w", project, after, err) + } + out := make([]ChangeLogRow, 0, len(rows)) + for _, r := range rows { + out = append(out, changeLogRowFromGen(r)) + } + return out, nil +} + +// MaxChangeLogSeq returns the highest seq (0 if empty) — a fresh consumer's +// starting offset. +func (s *Store) MaxChangeLogSeq(ctx context.Context) (int64, error) { + v, err := s.qr.MaxChangeLogSeq(ctx) + if err != nil { + return 0, fmt.Errorf("max change_log seq: %w", err) + } + return asInt64(v), nil +} + +func changeLogRowFromGen(r gen.ChangeLog) ChangeLogRow { + row := ChangeLogRow{ + Seq: r.Seq, + ProjectID: r.ProjectID, + EventType: r.EventType, + Payload: r.Payload, + CreatedAt: r.CreatedAt, + } + if r.SessionID.Valid { + row.SessionID = r.SessionID.String + } + return row +} + +// asInt64 coerces sqlc's interface{} result for COALESCE(MAX(...)) — sqlc's +// SQLite type inference can't narrow the aggregate, so the generated signature +// is interface{}. modernc returns int64 for an integer aggregate. +func asInt64(v interface{}) int64 { + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + default: + return 0 + } +} diff --git a/backend/internal/storage/sqlite/db.go b/backend/internal/storage/sqlite/db.go index 0a2555e4e..63f6b7dd5 100644 --- a/backend/internal/storage/sqlite/db.go +++ b/backend/internal/storage/sqlite/db.go @@ -1,7 +1,6 @@ -// Package sqlite is the durable persistence adapter behind ports.LifecycleStore. -// It owns the SQLite schema (goose migrations), the revision-CAS upsert, and the -// transactional outbox (one txn writes the session row, a change_log entry, and -// the outbox row that the CDC publisher later drains to JSONL). +// Package sqlite is the durable persistence adapter: the 6-table schema (goose +// migrations), typed CRUD over sqlc-generated queries, and the read side of the +// trigger-driven CDC (it reads change_log; the DB triggers write it). package sqlite import ( @@ -20,40 +19,52 @@ var migrationsFS embed.FS // pragmas are applied on every connection open. WAL + NORMAL lets readers run // concurrently with the writer; busy_timeout absorbs brief writer contention; -// foreign_keys enforces the cascades. +// foreign_keys enforces the cascades and the CDC triggers' lookups. const pragmas = "?_pragma=journal_mode(WAL)" + "&_pragma=busy_timeout(5000)" + "&_pragma=foreign_keys(ON)" + "&_pragma=synchronous(NORMAL)" -// maxConnections caps the pool. WAL allows many concurrent readers, so reads -// (List/Get/GetPR/...) scale across the pool instead of queuing behind a single -// connection. Writes do NOT rely on the pool for serialization — the Store funnels -// every write through its writeMu (see store.go), which keeps WAL's single-writer -// rule and the revision-CAS read-then-write atomic regardless of pool size. -const maxConnections = 8 +// maxReaders caps the reader pool. WAL allows many concurrent readers. +const maxReaders = 8 -// Open opens (creating if absent) the SQLite database under dataDir, applies the -// connection pragmas, and runs all goose migrations up. The returned *sql.DB is -// sized for the many-reader / serialized-single-writer workload the LCM and -// readers impose. -func Open(dataDir string) (*sql.DB, error) { +// Open opens (creating if absent) the SQLite database under dataDir and returns +// a Store. It uses TWO pools against the same file: +// +// - a single WRITER connection (writeDB, MaxOpenConns=1): every write goes +// here, so a write and the CDC triggers' subqueries it fires always see the +// prior writes on the same connection (read-your-writes). This is required +// because the pr/pr_checks triggers SELECT from sessions/pr to fill in the +// event's project_id; a pooled writer could land that read on a connection +// that hasn't caught up to the commit and read NULL. +// - a READER pool (readDB, MaxOpenConns=maxReaders): all reads scale across +// it; WAL readers see the latest committed snapshot. +func Open(dataDir string) (*Store, error) { if err := os.MkdirAll(dataDir, 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } dsn := "file:" + filepath.Join(dataDir, "ao.db") + pragmas - db, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, fmt.Errorf("open sqlite: %w", err) - } - db.SetMaxOpenConns(maxConnections) - db.SetMaxIdleConns(maxConnections) // keep reader conns warm; avoid open/close churn - if err := migrate(db); err != nil { - db.Close() + writeDB, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite writer: %w", err) + } + writeDB.SetMaxOpenConns(1) + writeDB.SetMaxIdleConns(1) + if err := migrate(writeDB); err != nil { + writeDB.Close() return nil, err } - return db, nil + + readDB, err := sql.Open("sqlite", dsn) + if err != nil { + writeDB.Close() + return nil, fmt.Errorf("open sqlite reader: %w", err) + } + readDB.SetMaxOpenConns(maxReaders) + readDB.SetMaxIdleConns(maxReaders) + + return NewStore(writeDB, readDB), nil } func migrate(db *sql.DB) error { diff --git a/backend/internal/storage/sqlite/gen/cdc.sql.go b/backend/internal/storage/sqlite/gen/cdc.sql.go deleted file mode 100644 index c2eedc8c7..000000000 --- a/backend/internal/storage/sqlite/gen/cdc.sql.go +++ /dev/null @@ -1,199 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: cdc.sql - -package gen - -import ( - "context" - "database/sql" - "time" -) - -const deleteSentOutboxBelow = `-- name: DeleteSentOutboxBelow :execrows -DELETE FROM outbox WHERE sent = 1 AND change_log_seq < ? -` - -func (q *Queries) DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) { - result, err := q.db.ExecContext(ctx, deleteSentOutboxBelow, changeLogSeq) - if err != nil { - return 0, err - } - return result.RowsAffected() -} - -const getConsumerOffset = `-- name: GetConsumerOffset :one -SELECT last_seq FROM consumer_offsets WHERE consumer = ? -` - -func (q *Queries) GetConsumerOffset(ctx context.Context, consumer string) (int64, error) { - row := q.db.QueryRowContext(ctx, getConsumerOffset, consumer) - var last_seq int64 - err := row.Scan(&last_seq) - return last_seq, err -} - -const insertChangeLog = `-- name: InsertChangeLog :one -INSERT INTO change_log (session_id, event_type, revision, payload, created_at) -VALUES (?, ?, ?, ?, ?) -RETURNING seq -` - -type InsertChangeLogParams struct { - SessionID string - EventType string - Revision int64 - Payload string - CreatedAt time.Time -} - -// Appends a canonical-write record and returns its monotonic seq so the same -// transaction can thread it into the outbox row. -func (q *Queries) InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) { - row := q.db.QueryRowContext(ctx, insertChangeLog, - arg.SessionID, - arg.EventType, - arg.Revision, - arg.Payload, - arg.CreatedAt, - ) - var seq int64 - err := row.Scan(&seq) - return seq, err -} - -const insertOutbox = `-- name: InsertOutbox :exec -INSERT INTO outbox (change_log_seq, created_at) -VALUES (?, ?) -` - -type InsertOutboxParams struct { - ChangeLogSeq int64 - CreatedAt time.Time -} - -func (q *Queries) InsertOutbox(ctx context.Context, arg InsertOutboxParams) error { - _, err := q.db.ExecContext(ctx, insertOutbox, arg.ChangeLogSeq, arg.CreatedAt) - return err -} - -const listUnsentOutbox = `-- name: ListUnsentOutbox :many -SELECT o.id, o.change_log_seq, o.attempts, - c.session_id, c.event_type, c.revision, c.payload, c.created_at -FROM outbox o -JOIN change_log c ON c.seq = o.change_log_seq -WHERE o.sent = 0 -ORDER BY o.change_log_seq -LIMIT ? -` - -type ListUnsentOutboxRow struct { - ID int64 - ChangeLogSeq int64 - Attempts int64 - SessionID string - EventType string - Revision int64 - Payload string - CreatedAt time.Time -} - -func (q *Queries) ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) { - rows, err := q.db.QueryContext(ctx, listUnsentOutbox, limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListUnsentOutboxRow{} - for rows.Next() { - var i ListUnsentOutboxRow - if err := rows.Scan( - &i.ID, - &i.ChangeLogSeq, - &i.Attempts, - &i.SessionID, - &i.EventType, - &i.Revision, - &i.Payload, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const markOutboxFailed = `-- name: MarkOutboxFailed :exec -UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE id = ? -` - -type MarkOutboxFailedParams struct { - LastError string - ID int64 -} - -func (q *Queries) MarkOutboxFailed(ctx context.Context, arg MarkOutboxFailedParams) error { - _, err := q.db.ExecContext(ctx, markOutboxFailed, arg.LastError, arg.ID) - return err -} - -const markOutboxSent = `-- name: MarkOutboxSent :exec -UPDATE outbox SET sent = 1, sent_at = ? WHERE id = ? -` - -type MarkOutboxSentParams struct { - SentAt sql.NullTime - ID int64 -} - -func (q *Queries) MarkOutboxSent(ctx context.Context, arg MarkOutboxSentParams) error { - _, err := q.db.ExecContext(ctx, markOutboxSent, arg.SentAt, arg.ID) - return err -} - -const maxChangeLogSeq = `-- name: MaxChangeLogSeq :one -SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) FROM change_log -` - -func (q *Queries) MaxChangeLogSeq(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, maxChangeLogSeq) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const minConsumerOffset = `-- name: MinConsumerOffset :one -SELECT CAST(COALESCE(MIN(last_seq), 0) AS INTEGER) FROM consumer_offsets -` - -func (q *Queries) MinConsumerOffset(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, minConsumerOffset) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const upsertConsumerOffset = `-- name: UpsertConsumerOffset :exec -INSERT INTO consumer_offsets (consumer, last_seq, updated_at) -VALUES (?, ?, ?) -ON CONFLICT (consumer) DO UPDATE SET last_seq = excluded.last_seq, updated_at = excluded.updated_at -` - -type UpsertConsumerOffsetParams struct { - Consumer string - LastSeq int64 - UpdatedAt time.Time -} - -func (q *Queries) UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error { - _, err := q.db.ExecContext(ctx, upsertConsumerOffset, arg.Consumer, arg.LastSeq, arg.UpdatedAt) - return err -} diff --git a/backend/internal/storage/sqlite/gen/changelog.sql.go b/backend/internal/storage/sqlite/gen/changelog.sql.go new file mode 100644 index 000000000..6568fdcc1 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/changelog.sql.go @@ -0,0 +1,102 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: changelog.sql + +package gen + +import ( + "context" +) + +const maxChangeLogSeq = `-- name: MaxChangeLogSeq :one +SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log +` + +func (q *Queries) MaxChangeLogSeq(ctx context.Context) (interface{}, error) { + row := q.db.QueryRowContext(ctx, maxChangeLogSeq) + var seq interface{} + err := row.Scan(&seq) + return seq, err +} + +const readChangeLogAfter = `-- name: ReadChangeLogAfter :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE seq > ? ORDER BY seq LIMIT ? +` + +type ReadChangeLogAfterParams struct { + Seq int64 + Limit int64 +} + +func (q *Queries) ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfterParams) ([]ChangeLog, error) { + rows, err := q.db.QueryContext(ctx, readChangeLogAfter, arg.Seq, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChangeLog{} + for rows.Next() { + var i ChangeLog + if err := rows.Scan( + &i.Seq, + &i.ProjectID, + &i.SessionID, + &i.EventType, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const readChangeLogAfterForProject = `-- name: ReadChangeLogAfterForProject :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE project_id = ? AND seq > ? ORDER BY seq LIMIT ? +` + +type ReadChangeLogAfterForProjectParams struct { + ProjectID string + Seq int64 + Limit int64 +} + +func (q *Queries) ReadChangeLogAfterForProject(ctx context.Context, arg ReadChangeLogAfterForProjectParams) ([]ChangeLog, error) { + rows, err := q.db.QueryContext(ctx, readChangeLogAfterForProject, arg.ProjectID, arg.Seq, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChangeLog{} + for rows.Next() { + var i ChangeLog + if err := rows.Scan( + &i.Seq, + &i.ProjectID, + &i.SessionID, + &i.EventType, + &i.Payload, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/storage/sqlite/gen/metadata.sql.go b/backend/internal/storage/sqlite/gen/metadata.sql.go deleted file mode 100644 index 2c0396f73..000000000 --- a/backend/internal/storage/sqlite/gen/metadata.sql.go +++ /dev/null @@ -1,82 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: metadata.sql - -package gen - -import ( - "context" - "time" -) - -const getSessionMetadata = `-- name: GetSessionMetadata :one -SELECT branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt -FROM session_metadata -WHERE session_id = ? -` - -type GetSessionMetadataRow struct { - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string -} - -func (q *Queries) GetSessionMetadata(ctx context.Context, sessionID string) (GetSessionMetadataRow, error) { - row := q.db.QueryRowContext(ctx, getSessionMetadata, sessionID) - var i GetSessionMetadataRow - err := row.Scan( - &i.Branch, - &i.WorkspacePath, - &i.RuntimeHandleID, - &i.RuntimeName, - &i.AgentSessionID, - &i.Prompt, - ) - return i, err -} - -const upsertSessionMetadata = `-- name: UpsertSessionMetadata :exec -INSERT INTO session_metadata ( - session_id, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET - branch = CASE WHEN excluded.branch <> '' THEN excluded.branch ELSE session_metadata.branch END, - workspace_path = CASE WHEN excluded.workspace_path <> '' THEN excluded.workspace_path ELSE session_metadata.workspace_path END, - runtime_handle_id = CASE WHEN excluded.runtime_handle_id <> '' THEN excluded.runtime_handle_id ELSE session_metadata.runtime_handle_id END, - runtime_name = CASE WHEN excluded.runtime_name <> '' THEN excluded.runtime_name ELSE session_metadata.runtime_name END, - agent_session_id = CASE WHEN excluded.agent_session_id <> '' THEN excluded.agent_session_id ELSE session_metadata.agent_session_id END, - prompt = CASE WHEN excluded.prompt <> '' THEN excluded.prompt ELSE session_metadata.prompt END, - updated_at = excluded.updated_at -` - -type UpsertSessionMetadataParams struct { - SessionID string - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - UpdatedAt time.Time -} - -// Merge semantics: an empty incoming column is "leave unchanged", so a partial -// patch (e.g. spawn writing only the runtime handle) never clobbers a value set -// earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. -func (q *Queries) UpsertSessionMetadata(ctx context.Context, arg UpsertSessionMetadataParams) error { - _, err := q.db.ExecContext(ctx, upsertSessionMetadata, - arg.SessionID, - arg.Branch, - arg.WorkspacePath, - arg.RuntimeHandleID, - arg.RuntimeName, - arg.AgentSessionID, - arg.Prompt, - arg.UpdatedAt, - ) - return err -} diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 339062bf0..0c5b5c913 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -11,50 +11,36 @@ import ( type ChangeLog struct { Seq int64 - SessionID string + ProjectID string + SessionID sql.NullString EventType string - Revision int64 Payload string CreatedAt time.Time } -type ConsumerOffset struct { - Consumer string - LastSeq int64 - UpdatedAt time.Time -} - -type Outbox struct { - ID int64 - ChangeLogSeq int64 - Sent int64 - SentAt sql.NullTime - Attempts int64 - LastError string - CreatedAt time.Time -} - type Pr struct { + Url string SessionID string + Number int64 + PrState string ReviewDecision string - Mergeability string CiState string - CiPassed int64 - CiFailed int64 - CiPending int64 - CiLogTail string - LastFetchedAt time.Time + Mergeability string + UpdatedAt time.Time } type PrCheck struct { - SessionID string - Name string - Status string - Url string + PrUrl string + Name string + CommitHash string + Status string + Url string + LogTail string + CreatedAt time.Time } type PrComment struct { - SessionID string + PrUrl string CommentID string Author string File string @@ -67,58 +53,34 @@ type PrComment struct { type Project struct { ID string Path string - RepoOwner string - RepoName string - RepoPlatform string RepoOriginUrl string - DefaultBranch string DisplayName string - SessionPrefix string - Source string RegisteredAt time.Time ArchivedAt sql.NullTime } -type ReactionTracker struct { - SessionID string - ReactionKey string - Attempts int64 - Escalated int64 - FirstAttemptAt sql.NullTime - ProjectID string -} - type Session struct { ID string ProjectID string + Num int64 IssueID string Kind string - CreatedAt time.Time - UpdatedAt time.Time - Revision int64 + Harness string SessionState string - SessionReason string - PrState string - PrReason string - PrNumber int64 - PrUrl string - RuntimeState string - RuntimeReason string + TerminationReason string + IsAlive int64 ActivityState string ActivityLastAt time.Time ActivitySource string DetectingAttempts sql.NullInt64 DetectingStartedAt sql.NullTime DetectingEvidenceHash sql.NullString -} - -type SessionMetadatum struct { - SessionID string - Branch string - WorkspacePath string - RuntimeHandleID string - RuntimeName string - AgentSessionID string - Prompt string - UpdatedAt time.Time + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/backend/internal/storage/sqlite/gen/pr.sql.go b/backend/internal/storage/sqlite/gen/pr.sql.go index 95cbd20ae..f9fa36202 100644 --- a/backend/internal/storage/sqlite/gen/pr.sql.go +++ b/backend/internal/storage/sqlite/gen/pr.sql.go @@ -11,173 +11,56 @@ import ( ) const deletePR = `-- name: DeletePR :exec -DELETE FROM pr WHERE session_id = ? +DELETE FROM pr WHERE url = ? ` -func (q *Queries) DeletePR(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePR, sessionID) - return err -} - -const deletePRChecks = `-- name: DeletePRChecks :exec -DELETE FROM pr_check WHERE session_id = ? -` - -func (q *Queries) DeletePRChecks(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePRChecks, sessionID) - return err -} - -const deletePRComments = `-- name: DeletePRComments :exec -DELETE FROM pr_comment WHERE session_id = ? -` - -func (q *Queries) DeletePRComments(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deletePRComments, sessionID) +func (q *Queries) DeletePR(ctx context.Context, url string) error { + _, err := q.db.ExecContext(ctx, deletePR, url) return err } const getPR = `-- name: GetPR :one -SELECT session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -FROM pr -WHERE session_id = ? +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at FROM pr WHERE url = ? ` -func (q *Queries) GetPR(ctx context.Context, sessionID string) (Pr, error) { - row := q.db.QueryRowContext(ctx, getPR, sessionID) +func (q *Queries) GetPR(ctx context.Context, url string) (Pr, error) { + row := q.db.QueryRowContext(ctx, getPR, url) var i Pr err := row.Scan( + &i.Url, &i.SessionID, + &i.Number, + &i.PrState, &i.ReviewDecision, - &i.Mergeability, &i.CiState, - &i.CiPassed, - &i.CiFailed, - &i.CiPending, - &i.CiLogTail, - &i.LastFetchedAt, + &i.Mergeability, + &i.UpdatedAt, ) return i, err } -const insertPRCheck = `-- name: InsertPRCheck :exec -INSERT INTO pr_check (session_id, name, status, url) VALUES (?, ?, ?, ?) +const listPRsBySession = `-- name: ListPRsBySession :many +SELECT url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at FROM pr WHERE session_id = ? ORDER BY updated_at DESC ` -type InsertPRCheckParams struct { - SessionID string - Name string - Status string - Url string -} - -func (q *Queries) InsertPRCheck(ctx context.Context, arg InsertPRCheckParams) error { - _, err := q.db.ExecContext(ctx, insertPRCheck, - arg.SessionID, - arg.Name, - arg.Status, - arg.Url, - ) - return err -} - -const insertPRComment = `-- name: InsertPRComment :exec -INSERT INTO pr_comment (session_id, comment_id, author, file, line, body, resolved, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) -` - -type InsertPRCommentParams struct { - SessionID string - CommentID string - Author string - File string - Line int64 - Body string - Resolved int64 - CreatedAt time.Time -} - -func (q *Queries) InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error { - _, err := q.db.ExecContext(ctx, insertPRComment, - arg.SessionID, - arg.CommentID, - arg.Author, - arg.File, - arg.Line, - arg.Body, - arg.Resolved, - arg.CreatedAt, - ) - return err -} - -const listPRChecks = `-- name: ListPRChecks :many -SELECT name, status, url FROM pr_check WHERE session_id = ? ORDER BY name -` - -type ListPRChecksRow struct { - Name string - Status string - Url string -} - -func (q *Queries) ListPRChecks(ctx context.Context, sessionID string) ([]ListPRChecksRow, error) { - rows, err := q.db.QueryContext(ctx, listPRChecks, sessionID) +func (q *Queries) ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) { + rows, err := q.db.QueryContext(ctx, listPRsBySession, sessionID) if err != nil { return nil, err } defer rows.Close() - items := []ListPRChecksRow{} + items := []Pr{} for rows.Next() { - var i ListPRChecksRow - if err := rows.Scan(&i.Name, &i.Status, &i.Url); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listPRComments = `-- name: ListPRComments :many -SELECT comment_id, author, file, line, body, resolved, created_at -FROM pr_comment -WHERE session_id = ? -ORDER BY created_at, comment_id -` - -type ListPRCommentsRow struct { - CommentID string - Author string - File string - Line int64 - Body string - Resolved int64 - CreatedAt time.Time -} - -func (q *Queries) ListPRComments(ctx context.Context, sessionID string) ([]ListPRCommentsRow, error) { - rows, err := q.db.QueryContext(ctx, listPRComments, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListPRCommentsRow{} - for rows.Next() { - var i ListPRCommentsRow + var i Pr if err := rows.Scan( - &i.CommentID, - &i.Author, - &i.File, - &i.Line, - &i.Body, - &i.Resolved, - &i.CreatedAt, + &i.Url, + &i.SessionID, + &i.Number, + &i.PrState, + &i.ReviewDecision, + &i.CiState, + &i.Mergeability, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -193,43 +76,39 @@ func (q *Queries) ListPRComments(ctx context.Context, sessionID string) ([]ListP } const upsertPR = `-- name: UpsertPR :exec -INSERT INTO pr ( - session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET +INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (url) DO UPDATE SET + session_id = excluded.session_id, + number = excluded.number, + pr_state = excluded.pr_state, review_decision = excluded.review_decision, - mergeability = excluded.mergeability, - ci_state = excluded.ci_state, - ci_passed = excluded.ci_passed, - ci_failed = excluded.ci_failed, - ci_pending = excluded.ci_pending, - ci_log_tail = excluded.ci_log_tail, - last_fetched_at = excluded.last_fetched_at + ci_state = excluded.ci_state, + mergeability = excluded.mergeability, + updated_at = excluded.updated_at ` type UpsertPRParams struct { + Url string SessionID string + Number int64 + PrState string ReviewDecision string - Mergeability string CiState string - CiPassed int64 - CiFailed int64 - CiPending int64 - CiLogTail string - LastFetchedAt time.Time + Mergeability string + UpdatedAt time.Time } func (q *Queries) UpsertPR(ctx context.Context, arg UpsertPRParams) error { _, err := q.db.ExecContext(ctx, upsertPR, + arg.Url, arg.SessionID, + arg.Number, + arg.PrState, arg.ReviewDecision, - arg.Mergeability, arg.CiState, - arg.CiPassed, - arg.CiFailed, - arg.CiPending, - arg.CiLogTail, - arg.LastFetchedAt, + arg.Mergeability, + arg.UpdatedAt, ) return err } diff --git a/backend/internal/storage/sqlite/gen/pr_checks.sql.go b/backend/internal/storage/sqlite/gen/pr_checks.sql.go new file mode 100644 index 000000000..58668ab12 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr_checks.sql.go @@ -0,0 +1,119 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr_checks.sql + +package gen + +import ( + "context" + "time" +) + +const listChecksByPR = `-- name: ListChecksByPR :many +SELECT pr_url, name, commit_hash, status, url, log_tail, created_at FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at +` + +func (q *Queries) ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) { + rows, err := q.db.QueryContext(ctx, listChecksByPR, prUrl) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PrCheck{} + for rows.Next() { + var i PrCheck + if err := rows.Scan( + &i.PrUrl, + &i.Name, + &i.CommitHash, + &i.Status, + &i.Url, + &i.LogTail, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecentChecks = `-- name: ListRecentChecks :many +SELECT status, commit_hash, created_at FROM pr_checks +WHERE pr_url = ? AND name = ? +ORDER BY created_at DESC LIMIT ? +` + +type ListRecentChecksParams struct { + PrUrl string + Name string + Limit int64 +} + +type ListRecentChecksRow struct { + Status string + CommitHash string + CreatedAt time.Time +} + +func (q *Queries) ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) { + rows, err := q.db.QueryContext(ctx, listRecentChecks, arg.PrUrl, arg.Name, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRecentChecksRow{} + for rows.Next() { + var i ListRecentChecksRow + if err := rows.Scan(&i.Status, &i.CommitHash, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertPRCheck = `-- name: UpsertPRCheck :exec +INSERT INTO pr_checks (pr_url, name, commit_hash, status, url, log_tail, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, name, commit_hash) DO UPDATE SET + status = excluded.status, + url = excluded.url, + log_tail = excluded.log_tail +` + +type UpsertPRCheckParams struct { + PrUrl string + Name string + CommitHash string + Status string + Url string + LogTail string + CreatedAt time.Time +} + +func (q *Queries) UpsertPRCheck(ctx context.Context, arg UpsertPRCheckParams) error { + _, err := q.db.ExecContext(ctx, upsertPRCheck, + arg.PrUrl, + arg.Name, + arg.CommitHash, + arg.Status, + arg.Url, + arg.LogTail, + arg.CreatedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/pr_comment.sql.go b/backend/internal/storage/sqlite/gen/pr_comment.sql.go new file mode 100644 index 000000000..a2f09f344 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr_comment.sql.go @@ -0,0 +1,89 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr_comment.sql + +package gen + +import ( + "context" + "time" +) + +const deletePRComments = `-- name: DeletePRComments :exec +DELETE FROM pr_comment WHERE pr_url = ? +` + +func (q *Queries) DeletePRComments(ctx context.Context, prUrl string) error { + _, err := q.db.ExecContext(ctx, deletePRComments, prUrl) + return err +} + +const listPRComments = `-- name: ListPRComments :many +SELECT pr_url, comment_id, author, file, line, body, resolved, created_at FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id +` + +func (q *Queries) ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) { + rows, err := q.db.QueryContext(ctx, listPRComments, prUrl) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PrComment{} + for rows.Next() { + var i PrComment + if err := rows.Scan( + &i.PrUrl, + &i.CommentID, + &i.Author, + &i.File, + &i.Line, + &i.Body, + &i.Resolved, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertPRComment = `-- name: UpsertPRComment :exec +INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, comment_id) DO UPDATE SET + author = excluded.author, file = excluded.file, line = excluded.line, + body = excluded.body, resolved = excluded.resolved +` + +type UpsertPRCommentParams struct { + PrUrl string + CommentID string + Author string + File string + Line int64 + Body string + Resolved int64 + CreatedAt time.Time +} + +func (q *Queries) UpsertPRComment(ctx context.Context, arg UpsertPRCommentParams) error { + _, err := q.db.ExecContext(ctx, upsertPRComment, + arg.PrUrl, + arg.CommentID, + arg.Author, + arg.File, + arg.Line, + arg.Body, + arg.Resolved, + arg.CreatedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go index 33959b765..a7c953cd3 100644 --- a/backend/internal/storage/sqlite/gen/projects.sql.go +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -25,19 +25,9 @@ func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) return err } -const deleteProject = `-- name: DeleteProject :exec -DELETE FROM projects WHERE id = ? -` - -func (q *Queries) DeleteProject(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteProject, id) - return err -} - const getProject = `-- name: GetProject :one -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE id = ? +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE id = ? ` func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { @@ -46,14 +36,8 @@ func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { err := row.Scan( &i.ID, &i.Path, - &i.RepoOwner, - &i.RepoName, - &i.RepoPlatform, &i.RepoOriginUrl, - &i.DefaultBranch, &i.DisplayName, - &i.SessionPrefix, - &i.Source, &i.RegisteredAt, &i.ArchivedAt, ) @@ -61,10 +45,8 @@ func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { } const listProjects = `-- name: ListProjects :many -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE archived_at IS NULL -ORDER BY id +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE archived_at IS NULL ORDER BY id ` func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { @@ -79,14 +61,8 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { if err := rows.Scan( &i.ID, &i.Path, - &i.RepoOwner, - &i.RepoName, - &i.RepoPlatform, &i.RepoOriginUrl, - &i.DefaultBranch, &i.DisplayName, - &i.SessionPrefix, - &i.Source, &i.RegisteredAt, &i.ArchivedAt, ); err != nil { @@ -104,33 +80,20 @@ func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { } const upsertProject = `-- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, - repo_owner = excluded.repo_owner, - repo_name = excluded.repo_name, - repo_platform = excluded.repo_platform, repo_origin_url = excluded.repo_origin_url, - default_branch = excluded.default_branch, display_name = excluded.display_name, - session_prefix = excluded.session_prefix, - source = excluded.source, - registered_at = excluded.registered_at, archived_at = excluded.archived_at ` type UpsertProjectParams struct { ID string Path string - RepoOwner string - RepoName string - RepoPlatform string RepoOriginUrl string - DefaultBranch string DisplayName string - SessionPrefix string - Source string RegisteredAt time.Time ArchivedAt sql.NullTime } @@ -139,14 +102,8 @@ func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) er _, err := q.db.ExecContext(ctx, upsertProject, arg.ID, arg.Path, - arg.RepoOwner, - arg.RepoName, - arg.RepoPlatform, arg.RepoOriginUrl, - arg.DefaultBranch, arg.DisplayName, - arg.SessionPrefix, - arg.Source, arg.RegisteredAt, arg.ArchivedAt, ) diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 83aa0c7ed..365113b1e 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -10,50 +10,29 @@ import ( type Querier interface { ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error - DeletePR(ctx context.Context, sessionID string) error - DeletePRChecks(ctx context.Context, sessionID string) error - DeletePRComments(ctx context.Context, sessionID string) error - DeleteProject(ctx context.Context, id string) error - DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error - DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) - DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error - GetConsumerOffset(ctx context.Context, consumer string) (int64, error) - GetPR(ctx context.Context, sessionID string) (Pr, error) + DeletePR(ctx context.Context, url string) error + DeletePRComments(ctx context.Context, prUrl string) error + DeleteSession(ctx context.Context, id string) error + GetPR(ctx context.Context, url string) (Pr, error) GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) - GetSessionMetadata(ctx context.Context, sessionID string) (GetSessionMetadataRow, error) - GetSessionRevision(ctx context.Context, id string) (int64, error) - // Appends a canonical-write record and returns its monotonic seq so the same - // transaction can thread it into the outbox row. - InsertChangeLog(ctx context.Context, arg InsertChangeLogParams) (int64, error) - InsertOutbox(ctx context.Context, arg InsertOutboxParams) error - InsertPRCheck(ctx context.Context, arg InsertPRCheckParams) error - InsertPRComment(ctx context.Context, arg InsertPRCommentParams) error - // CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; - // the row is persisted at revision 1. - InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) + InsertSession(ctx context.Context, arg InsertSessionParams) error ListAllSessions(ctx context.Context) ([]Session, error) - ListPRChecks(ctx context.Context, sessionID string) ([]ListPRChecksRow, error) - ListPRComments(ctx context.Context, sessionID string) ([]ListPRCommentsRow, error) + ListChecksByPR(ctx context.Context, prUrl string) ([]PrCheck, error) + ListPRComments(ctx context.Context, prUrl string) ([]PrComment, error) + ListPRsBySession(ctx context.Context, sessionID string) ([]Pr, error) ListProjects(ctx context.Context) ([]Project, error) - ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) + ListRecentChecks(ctx context.Context, arg ListRecentChecksParams) ([]ListRecentChecksRow, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) - ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) - MarkOutboxFailed(ctx context.Context, arg MarkOutboxFailedParams) error - MarkOutboxSent(ctx context.Context, arg MarkOutboxSentParams) error - MaxChangeLogSeq(ctx context.Context) (int64, error) - MinConsumerOffset(ctx context.Context) (int64, error) - // CAS update: succeeds only when the stored revision equals the caller's loaded - // revision (@expected_revision). 0 rows affected => revision mismatch. - UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) - UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error + MaxChangeLogSeq(ctx context.Context) (interface{}, error) + NextSessionNum(ctx context.Context, projectID string) (int64, error) + ReadChangeLogAfter(ctx context.Context, arg ReadChangeLogAfterParams) ([]ChangeLog, error) + ReadChangeLogAfterForProject(ctx context.Context, arg ReadChangeLogAfterForProjectParams) ([]ChangeLog, error) + UpdateSession(ctx context.Context, arg UpdateSessionParams) error UpsertPR(ctx context.Context, arg UpsertPRParams) error + UpsertPRCheck(ctx context.Context, arg UpsertPRCheckParams) error + UpsertPRComment(ctx context.Context, arg UpsertPRCommentParams) error UpsertProject(ctx context.Context, arg UpsertProjectParams) error - UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error - // Merge semantics: an empty incoming column is "leave unchanged", so a partial - // patch (e.g. spawn writing only the runtime handle) never clobbers a value set - // earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. - UpsertSessionMetadata(ctx context.Context, arg UpsertSessionMetadataParams) error } var _ Querier = (*Queries)(nil) diff --git a/backend/internal/storage/sqlite/gen/reactions.sql.go b/backend/internal/storage/sqlite/gen/reactions.sql.go deleted file mode 100644 index dc7b01c2a..000000000 --- a/backend/internal/storage/sqlite/gen/reactions.sql.go +++ /dev/null @@ -1,100 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: reactions.sql - -package gen - -import ( - "context" - "database/sql" -) - -const deleteReactionTracker = `-- name: DeleteReactionTracker :exec -DELETE FROM reaction_trackers WHERE session_id = ? AND reaction_key = ? -` - -type DeleteReactionTrackerParams struct { - SessionID string - ReactionKey string -} - -func (q *Queries) DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error { - _, err := q.db.ExecContext(ctx, deleteReactionTracker, arg.SessionID, arg.ReactionKey) - return err -} - -const deleteSessionReactionTrackers = `-- name: DeleteSessionReactionTrackers :exec -DELETE FROM reaction_trackers WHERE session_id = ? -` - -func (q *Queries) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { - _, err := q.db.ExecContext(ctx, deleteSessionReactionTrackers, sessionID) - return err -} - -const listReactionTrackers = `-- name: ListReactionTrackers :many -SELECT session_id, reaction_key, attempts, escalated, first_attempt_at, project_id -FROM reaction_trackers -` - -func (q *Queries) ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) { - rows, err := q.db.QueryContext(ctx, listReactionTrackers) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ReactionTracker{} - for rows.Next() { - var i ReactionTracker - if err := rows.Scan( - &i.SessionID, - &i.ReactionKey, - &i.Attempts, - &i.Escalated, - &i.FirstAttemptAt, - &i.ProjectID, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertReactionTracker = `-- name: UpsertReactionTracker :exec -INSERT INTO reaction_trackers (session_id, reaction_key, attempts, escalated, first_attempt_at, project_id) -VALUES (?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id, reaction_key) DO UPDATE SET - attempts = excluded.attempts, - escalated = excluded.escalated, - first_attempt_at = excluded.first_attempt_at, - project_id = excluded.project_id -` - -type UpsertReactionTrackerParams struct { - SessionID string - ReactionKey string - Attempts int64 - Escalated int64 - FirstAttemptAt sql.NullTime - ProjectID string -} - -func (q *Queries) UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error { - _, err := q.db.ExecContext(ctx, upsertReactionTracker, - arg.SessionID, - arg.ReactionKey, - arg.Attempts, - arg.Escalated, - arg.FirstAttemptAt, - arg.ProjectID, - ) - return err -} diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index 00d97ad66..5365a22c4 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -11,8 +11,17 @@ import ( "time" ) +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = ? +` + +func (q *Queries) DeleteSession(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteSession, id) + return err +} + const getSession = `-- name: GetSession :one -SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions WHERE id = ? +SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE id = ? ` func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { @@ -21,117 +30,99 @@ func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { err := row.Scan( &i.ID, &i.ProjectID, + &i.Num, &i.IssueID, &i.Kind, - &i.CreatedAt, - &i.UpdatedAt, - &i.Revision, + &i.Harness, &i.SessionState, - &i.SessionReason, - &i.PrState, - &i.PrReason, - &i.PrNumber, - &i.PrUrl, - &i.RuntimeState, - &i.RuntimeReason, + &i.TerminationReason, + &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, &i.DetectingAttempts, &i.DetectingStartedAt, &i.DetectingEvidenceHash, + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + &i.CreatedAt, + &i.UpdatedAt, ) return i, err } -const getSessionRevision = `-- name: GetSessionRevision :one -SELECT revision FROM sessions WHERE id = ? -` - -func (q *Queries) GetSessionRevision(ctx context.Context, id string) (int64, error) { - row := q.db.QueryRowContext(ctx, getSessionRevision, id) - var revision int64 - err := row.Scan(&revision) - return revision, err -} - -const insertSession = `-- name: InsertSession :execrows +const insertSession = `-- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, issue_id, kind, created_at, updated_at, - revision, - session_state, session_reason, - pr_state, pr_reason, pr_number, pr_url, - runtime_state, runtime_reason, + id, project_id, num, issue_id, kind, harness, + session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, - detecting_attempts, detecting_started_at, detecting_evidence_hash -) VALUES ( - ?, ?, ?, ?, ?, ?, - 1, - ?, ?, - ?, ?, ?, ?, - ?, ?, - ?, ?, ?, - ?, ?, ? -) -ON CONFLICT (id) DO NOTHING + detecting_attempts, detecting_started_at, detecting_evidence_hash, + branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, + created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` type InsertSessionParams struct { ID string ProjectID string + Num int64 IssueID string Kind string - CreatedAt time.Time - UpdatedAt time.Time + Harness string SessionState string - SessionReason string - PrState string - PrReason string - PrNumber int64 - PrUrl string - RuntimeState string - RuntimeReason string + TerminationReason string + IsAlive int64 ActivityState string ActivityLastAt time.Time ActivitySource string DetectingAttempts sql.NullInt64 DetectingStartedAt sql.NullTime DetectingEvidenceHash sql.NullString + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + CreatedAt time.Time + UpdatedAt time.Time } -// CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; -// the row is persisted at revision 1. -func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) { - result, err := q.db.ExecContext(ctx, insertSession, +func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) error { + _, err := q.db.ExecContext(ctx, insertSession, arg.ID, arg.ProjectID, + arg.Num, arg.IssueID, arg.Kind, - arg.CreatedAt, - arg.UpdatedAt, + arg.Harness, arg.SessionState, - arg.SessionReason, - arg.PrState, - arg.PrReason, - arg.PrNumber, - arg.PrUrl, - arg.RuntimeState, - arg.RuntimeReason, + arg.TerminationReason, + arg.IsAlive, arg.ActivityState, arg.ActivityLastAt, arg.ActivitySource, arg.DetectingAttempts, arg.DetectingStartedAt, arg.DetectingEvidenceHash, + arg.Branch, + arg.WorkspacePath, + arg.RuntimeHandleID, + arg.RuntimeName, + arg.AgentSessionID, + arg.Prompt, + arg.CreatedAt, + arg.UpdatedAt, ) - if err != nil { - return 0, err - } - return result.RowsAffected() + return err } const listAllSessions = `-- name: ListAllSessions :many -SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions +SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions ORDER BY project_id, num ` func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { @@ -146,25 +137,27 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { if err := rows.Scan( &i.ID, &i.ProjectID, + &i.Num, &i.IssueID, &i.Kind, - &i.CreatedAt, - &i.UpdatedAt, - &i.Revision, + &i.Harness, &i.SessionState, - &i.SessionReason, - &i.PrState, - &i.PrReason, - &i.PrNumber, - &i.PrUrl, - &i.RuntimeState, - &i.RuntimeReason, + &i.TerminationReason, + &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, &i.DetectingAttempts, &i.DetectingStartedAt, &i.DetectingEvidenceHash, + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -180,7 +173,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { } const listSessionsByProject = `-- name: ListSessionsByProject :many -SELECT id, project_id, issue_id, kind, created_at, updated_at, revision, session_state, session_reason, pr_state, pr_reason, pr_number, pr_url, runtime_state, runtime_reason, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash FROM sessions WHERE project_id = ? +SELECT id, project_id, num, issue_id, kind, harness, session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, detecting_attempts, detecting_started_at, detecting_evidence_hash, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, created_at, updated_at FROM sessions WHERE project_id = ? ORDER BY num ` func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) { @@ -195,25 +188,27 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ( if err := rows.Scan( &i.ID, &i.ProjectID, + &i.Num, &i.IssueID, &i.Kind, - &i.CreatedAt, - &i.UpdatedAt, - &i.Revision, + &i.Harness, &i.SessionState, - &i.SessionReason, - &i.PrState, - &i.PrReason, - &i.PrNumber, - &i.PrUrl, - &i.RuntimeState, - &i.RuntimeReason, + &i.TerminationReason, + &i.IsAlive, &i.ActivityState, &i.ActivityLastAt, &i.ActivitySource, &i.DetectingAttempts, &i.DetectingStartedAt, &i.DetectingEvidenceHash, + &i.Branch, + &i.WorkspacePath, + &i.RuntimeHandleID, + &i.RuntimeName, + &i.AgentSessionID, + &i.Prompt, + &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -228,80 +223,73 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID string) ( return items, nil } -const updateSessionCAS = `-- name: UpdateSessionCAS :execrows -UPDATE sessions SET - project_id = ?, - issue_id = ?, - kind = ?, - updated_at = ?, - revision = revision + 1, - session_state = ?, - session_reason = ?, - pr_state = ?, - pr_reason = ?, - pr_number = ?, - pr_url = ?, - runtime_state = ?, - runtime_reason = ?, - activity_state = ?, - activity_last_at = ?, - activity_source = ?, - detecting_attempts = ?, - detecting_started_at = ?, - detecting_evidence_hash = ? -WHERE id = ? AND revision = ? +const nextSessionNum = `-- name: NextSessionNum :one +SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ? ` -type UpdateSessionCASParams struct { - ProjectID string +func (q *Queries) NextSessionNum(ctx context.Context, projectID string) (int64, error) { + row := q.db.QueryRowContext(ctx, nextSessionNum, projectID) + var next int64 + err := row.Scan(&next) + return next, err +} + +const updateSession = `-- name: UpdateSession :exec +UPDATE sessions SET + issue_id = ?, kind = ?, harness = ?, + session_state = ?, termination_reason = ?, is_alive = ?, + activity_state = ?, activity_last_at = ?, activity_source = ?, + detecting_attempts = ?, detecting_started_at = ?, detecting_evidence_hash = ?, + branch = ?, workspace_path = ?, runtime_handle_id = ?, runtime_name = ?, agent_session_id = ?, prompt = ?, + updated_at = ? +WHERE id = ? +` + +type UpdateSessionParams struct { IssueID string Kind string - UpdatedAt time.Time + Harness string SessionState string - SessionReason string - PrState string - PrReason string - PrNumber int64 - PrUrl string - RuntimeState string - RuntimeReason string + TerminationReason string + IsAlive int64 ActivityState string ActivityLastAt time.Time ActivitySource string DetectingAttempts sql.NullInt64 DetectingStartedAt sql.NullTime DetectingEvidenceHash sql.NullString + Branch string + WorkspacePath string + RuntimeHandleID string + RuntimeName string + AgentSessionID string + Prompt string + UpdatedAt time.Time ID string - Revision int64 } -// CAS update: succeeds only when the stored revision equals the caller's loaded -// revision (@expected_revision). 0 rows affected => revision mismatch. -func (q *Queries) UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) { - result, err := q.db.ExecContext(ctx, updateSessionCAS, - arg.ProjectID, +func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) error { + _, err := q.db.ExecContext(ctx, updateSession, arg.IssueID, arg.Kind, - arg.UpdatedAt, + arg.Harness, arg.SessionState, - arg.SessionReason, - arg.PrState, - arg.PrReason, - arg.PrNumber, - arg.PrUrl, - arg.RuntimeState, - arg.RuntimeReason, + arg.TerminationReason, + arg.IsAlive, arg.ActivityState, arg.ActivityLastAt, arg.ActivitySource, arg.DetectingAttempts, arg.DetectingStartedAt, arg.DetectingEvidenceHash, + arg.Branch, + arg.WorkspacePath, + arg.RuntimeHandleID, + arg.RuntimeName, + arg.AgentSessionID, + arg.Prompt, + arg.UpdatedAt, arg.ID, - arg.Revision, ) - if err != nil { - return 0, err - } - return result.RowsAffected() + return err } diff --git a/backend/internal/storage/sqlite/mapping.go b/backend/internal/storage/sqlite/mapping.go index 39ae21275..792854cf2 100644 --- a/backend/internal/storage/sqlite/mapping.go +++ b/backend/internal/storage/sqlite/mapping.go @@ -7,104 +7,100 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// recordToInsert maps a domain record to the generated insert params. The -// revision column is fixed to 1 by the query itself (insert path), so it is not -// carried here. -func recordToInsert(rec domain.SessionRecord) gen.InsertSessionParams { - lc := rec.Lifecycle - da, ds, dh := detectingToNull(lc.Detecting) - return gen.InsertSessionParams{ - ID: string(rec.ID), - ProjectID: string(rec.ProjectID), - IssueID: string(rec.IssueID), - Kind: string(rec.Kind), - CreatedAt: rec.CreatedAt, - UpdatedAt: rec.UpdatedAt, - SessionState: string(lc.Session.State), - SessionReason: string(lc.Session.Reason), - PrState: string(lc.PR.State), - PrReason: string(lc.PR.Reason), - PrNumber: int64(lc.PR.Number), - PrUrl: lc.PR.URL, - RuntimeState: string(lc.Runtime.State), - RuntimeReason: string(lc.Runtime.Reason), - ActivityState: string(lc.Activity.State), - ActivityLastAt: lc.Activity.LastActivityAt, - ActivitySource: string(lc.Activity.Source), - DetectingAttempts: da, - DetectingStartedAt: ds, - DetectingEvidenceHash: dh, +func boolToInt(b bool) int64 { + if b { + return 1 } + return 0 } -// recordToUpdate maps a domain record to the CAS update params. expectedRevision -// is the caller's loaded revision, used in the WHERE clause for the CAS check. -func recordToUpdate(rec domain.SessionRecord, expectedRevision int64) gen.UpdateSessionCASParams { - lc := rec.Lifecycle - da, ds, dh := detectingToNull(lc.Detecting) - return gen.UpdateSessionCASParams{ - ProjectID: string(rec.ProjectID), - IssueID: string(rec.IssueID), - Kind: string(rec.Kind), - UpdatedAt: rec.UpdatedAt, - SessionState: string(lc.Session.State), - SessionReason: string(lc.Session.Reason), - PrState: string(lc.PR.State), - PrReason: string(lc.PR.Reason), - PrNumber: int64(lc.PR.Number), - PrUrl: lc.PR.URL, - RuntimeState: string(lc.Runtime.State), - RuntimeReason: string(lc.Runtime.Reason), - ActivityState: string(lc.Activity.State), - ActivityLastAt: lc.Activity.LastActivityAt, - ActivitySource: string(lc.Activity.Source), - DetectingAttempts: da, - DetectingStartedAt: ds, - DetectingEvidenceHash: dh, - ID: string(rec.ID), - Revision: expectedRevision, - } -} - -// rowToRecord maps a stored session row back to a domain record. Metadata is -// deliberately left nil: it is a side-channel (session_metadata) read only by -// GetMetadata, never reconstructed here — mirroring the in-memory fakeStore. +// rowToRecord maps a stored session row to a domain record. The folded-in +// operational columns become Metadata; the canonical lifecycle is reassembled +// from the typed columns. Display status is never reconstructed here. func rowToRecord(row gen.Session) domain.SessionRecord { return domain.SessionRecord{ ID: domain.SessionID(row.ID), ProjectID: domain.ProjectID(row.ProjectID), IssueID: domain.IssueID(row.IssueID), Kind: domain.SessionKind(row.Kind), - Lifecycle: rowToLifecycle(row), + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Harness: domain.AgentHarness(row.Harness), + IsAlive: row.IsAlive != 0, + Session: domain.SessionSubstate{State: domain.SessionState(row.SessionState)}, + TerminationReason: domain.TerminationReason(row.TerminationReason), + Activity: domain.ActivitySubstate{ + State: domain.ActivityState(row.ActivityState), + LastActivityAt: row.ActivityLastAt, + Source: domain.ActivitySource(row.ActivitySource), + }, + Detecting: nullToDetecting(row), + }, + Metadata: domain.SessionMetadata{ + Branch: row.Branch, + WorkspacePath: row.WorkspacePath, + RuntimeHandleID: row.RuntimeHandleID, + RuntimeName: row.RuntimeName, + AgentSessionID: row.AgentSessionID, + Prompt: row.Prompt, + }, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } -func rowToLifecycle(row gen.Session) domain.CanonicalSessionLifecycle { - return domain.CanonicalSessionLifecycle{ - Version: domain.LifecycleVersion, - Revision: int(row.Revision), - Session: domain.SessionSubstate{ - State: domain.SessionState(row.SessionState), - Reason: domain.SessionReason(row.SessionReason), - }, - PR: domain.PRSubstate{ - State: domain.PRState(row.PrState), - Reason: domain.PRReason(row.PrReason), - Number: int(row.PrNumber), - URL: row.PrUrl, - }, - Runtime: domain.RuntimeSubstate{ - State: domain.RuntimeState(row.RuntimeState), - Reason: domain.RuntimeReason(row.RuntimeReason), - }, - Activity: domain.ActivitySubstate{ - State: domain.ActivityState(row.ActivityState), - LastActivityAt: row.ActivityLastAt, - Source: domain.ActivitySource(row.ActivitySource), - }, - Detecting: nullToDetecting(row), +func recordToInsert(rec domain.SessionRecord, num int64) gen.InsertSessionParams { + da, ds, dh := detectingToNull(rec.Lifecycle.Detecting) + return gen.InsertSessionParams{ + ID: string(rec.ID), + ProjectID: string(rec.ProjectID), + Num: num, + IssueID: string(rec.IssueID), + Kind: string(rec.Kind), + Harness: string(rec.Lifecycle.Harness), + SessionState: string(rec.Lifecycle.Session.State), + TerminationReason: string(rec.Lifecycle.TerminationReason), + IsAlive: boolToInt(rec.Lifecycle.IsAlive), + ActivityState: string(rec.Lifecycle.Activity.State), + ActivityLastAt: rec.Lifecycle.Activity.LastActivityAt, + ActivitySource: string(rec.Lifecycle.Activity.Source), + DetectingAttempts: da, + DetectingStartedAt: ds, + DetectingEvidenceHash: dh, + Branch: rec.Metadata.Branch, + WorkspacePath: rec.Metadata.WorkspacePath, + RuntimeHandleID: rec.Metadata.RuntimeHandleID, + RuntimeName: rec.Metadata.RuntimeName, + AgentSessionID: rec.Metadata.AgentSessionID, + Prompt: rec.Metadata.Prompt, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + } +} + +func recordToUpdate(rec domain.SessionRecord) gen.UpdateSessionParams { + da, ds, dh := detectingToNull(rec.Lifecycle.Detecting) + return gen.UpdateSessionParams{ + IssueID: string(rec.IssueID), + Kind: string(rec.Kind), + Harness: string(rec.Lifecycle.Harness), + SessionState: string(rec.Lifecycle.Session.State), + TerminationReason: string(rec.Lifecycle.TerminationReason), + IsAlive: boolToInt(rec.Lifecycle.IsAlive), + ActivityState: string(rec.Lifecycle.Activity.State), + ActivityLastAt: rec.Lifecycle.Activity.LastActivityAt, + ActivitySource: string(rec.Lifecycle.Activity.Source), + DetectingAttempts: da, + DetectingStartedAt: ds, + DetectingEvidenceHash: dh, + Branch: rec.Metadata.Branch, + WorkspacePath: rec.Metadata.WorkspacePath, + RuntimeHandleID: rec.Metadata.RuntimeHandleID, + RuntimeName: rec.Metadata.RuntimeName, + AgentSessionID: rec.Metadata.AgentSessionID, + Prompt: rec.Metadata.Prompt, + UpdatedAt: rec.UpdatedAt, + ID: string(rec.ID), } } diff --git a/backend/internal/storage/sqlite/migrations/0001_init.sql b/backend/internal/storage/sqlite/migrations/0001_init.sql index 382241256..6534816d7 100644 --- a/backend/internal/storage/sqlite/migrations/0001_init.sql +++ b/backend/internal/storage/sqlite/migrations/0001_init.sql @@ -1,116 +1,213 @@ -- +goose Up -- +goose StatementBegin --- sessions holds identity + the canonical lifecycle as typed columns. The --- display status is NEVER stored (it is derived on read). Metadata is NOT here — --- it lives in session_metadata, written by a side-channel that bypasses CDC. +-- projects is the durable registry of repos AO manages (the SQLite twin of the +-- YAML config). id is a short human/LLM-friendly slug (mer, ao) with a numeric +-- suffix on collision (ao, ao1, ao2). Soft-delete via archived_at keeps the row +-- so a session's project_id always resolves. +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + repo_origin_url TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + archived_at TIMESTAMP +); + +-- sessions is the canonical record. id is "{project_id}-{num}" (e.g. mer-1) — a +-- single string key, so every inbound FK is single-column. num is the per-project +-- counter (computed at insert under the write mutex). Operational metadata is +-- folded in (no separate table). is_alive replaces the old runtime axis; there is +-- no revision column — the per-session write mutex serializes and change_log.seq +-- orders. The display status is derived on read (from this + the pr row), never +-- stored. CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - issue_id TEXT NOT NULL DEFAULT '', - kind TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NOT NULL, + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id), + num INTEGER NOT NULL, + issue_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'worker', + harness TEXT NOT NULL DEFAULT '' + CHECK (harness IN ('', 'claude-code', 'codex', 'aider', 'opencode')), - -- canonical lifecycle: revision is the optimistic-concurrency (CAS) counter, - -- bumped only by the storage layer's Upsert. - revision INTEGER NOT NULL, + session_state TEXT NOT NULL + CHECK (session_state IN ('not_started', 'working', 'idle', 'needs_input', 'stuck', 'detecting', 'done', 'terminated')), + -- only terminal sessions carry a reason; '' otherwise. + termination_reason TEXT NOT NULL DEFAULT '' + CHECK (termination_reason IN ('', 'manually_killed', 'runtime_lost', 'agent_process_exited', 'probe_failure', 'error_in_process', 'auto_cleanup', 'pr_merged')), + is_alive INTEGER NOT NULL DEFAULT 0, - session_state TEXT NOT NULL, - session_reason TEXT NOT NULL, - - pr_state TEXT NOT NULL, - pr_reason TEXT NOT NULL, - pr_number INTEGER NOT NULL DEFAULT 0, - pr_url TEXT NOT NULL DEFAULT '', - - runtime_state TEXT NOT NULL, - runtime_reason TEXT NOT NULL, - - activity_state TEXT NOT NULL, - activity_last_at TIMESTAMP NOT NULL, - activity_source TEXT NOT NULL, + activity_state TEXT NOT NULL DEFAULT 'idle', + activity_last_at TIMESTAMP NOT NULL, + activity_source TEXT NOT NULL DEFAULT 'none', -- detecting quarantine memory; NULL when the session is not in detecting. - detecting_attempts INTEGER, - detecting_started_at TIMESTAMP, - detecting_evidence_hash TEXT -); + detecting_attempts INTEGER, + detecting_started_at TIMESTAMP, + detecting_evidence_hash TEXT, + -- folded-in operational handles (was the session_metadata table) + branch TEXT NOT NULL DEFAULT '', + workspace_path TEXT NOT NULL DEFAULT '', + runtime_handle_id TEXT NOT NULL DEFAULT '', + runtime_name TEXT NOT NULL DEFAULT '', + agent_session_id TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + UNIQUE (project_id, num) +); CREATE INDEX idx_sessions_project ON sessions (project_id); --- session_metadata is the 1:1 typed side-channel for a session's operational --- handles and seed inputs — the fields the Session Manager and reaper need but --- that are NOT part of the canonical lifecycle. One row per session, named --- columns (not a free-form key/value bag), so the set of metadata a session can --- carry is fixed by the schema. Written by PatchMetadata; never bumps revision --- and never emits a CDC event. -CREATE TABLE session_metadata ( - session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, - branch TEXT NOT NULL DEFAULT '', - workspace_path TEXT NOT NULL DEFAULT '', - runtime_handle_id TEXT NOT NULL DEFAULT '', - runtime_name TEXT NOT NULL DEFAULT '', - agent_session_id TEXT NOT NULL DEFAULT '', - prompt TEXT NOT NULL DEFAULT '', - updated_at TIMESTAMP NOT NULL +-- pr holds PR facts keyed by the normalized PR URL. One session can own many PRs +-- (session_id FK), but a PR belongs to one session (enforced at runtime). ci_state +-- is the rolled-up status; the per-check history lives in pr_checks. +CREATE TABLE pr ( + url TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE CASCADE, + number INTEGER NOT NULL DEFAULT 0, + pr_state TEXT NOT NULL DEFAULT 'open' + CHECK (pr_state IN ('draft', 'open', 'merged', 'closed')), + review_decision TEXT NOT NULL DEFAULT 'none' + CHECK (review_decision IN ('none', 'approved', 'changes_requested', 'review_required')), + ci_state TEXT NOT NULL DEFAULT 'unknown' + CHECK (ci_state IN ('unknown', 'pending', 'passing', 'failing')), + mergeability TEXT NOT NULL DEFAULT 'unknown' + CHECK (mergeability IN ('unknown', 'mergeable', 'conflicting', 'blocked', 'unstable')), + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX idx_pr_session ON pr (session_id); + +-- pr_checks is CI run history: one row per (PR, check, commit). The CI-fix-loop +-- brake is a LIMIT 3 query over it ("last 3 runs of this check all failed?") — no +-- counter is stored. Re-polling the same commit upserts the same row. +CREATE TABLE pr_checks ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + name TEXT NOT NULL, + commit_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown' + CHECK (status IN ('unknown', 'queued', 'in_progress', 'passed', 'failed', 'skipped', 'cancelled')), + url TEXT NOT NULL DEFAULT '', + log_tail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, name, commit_hash) +); +CREATE INDEX idx_pr_checks_lookup ON pr_checks (pr_url, name, created_at); + +-- pr_comment holds review comments, persisted so a session page does not wait on +-- GitHub. Cascades from pr. +CREATE TABLE pr_comment ( + pr_url TEXT NOT NULL REFERENCES pr (url) ON DELETE CASCADE, + comment_id TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + file TEXT NOT NULL DEFAULT '', + line INTEGER NOT NULL DEFAULT 0, + body TEXT NOT NULL DEFAULT '', + resolved INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (pr_url, comment_id) ); --- change_log is the durable, ordered record of every canonical write. seq is the --- monotonic CDC ordering/idempotency key. +-- change_log is the durable, append-only CDC event log. seq is the monotonic +-- ordering + idempotency key. Rows are written by TRIGGERS on the user-visible +-- tables (DB-native capture, atomic with the change) — never by application +-- emit-code. project_id is required, session_id is nullable (project-level events +-- have no session). The log is immutable (no published flag); consumers track +-- their own offset (SSE Last-Event-ID). CREATE TABLE change_log ( seq INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects (id), + session_id TEXT REFERENCES sessions (id), event_type TEXT NOT NULL, - revision INTEGER NOT NULL, payload TEXT NOT NULL, - created_at TIMESTAMP NOT NULL + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) ); +CREATE INDEX idx_change_log_project ON change_log (project_id, seq); --- outbox is the transactional-outbox: one unsent row per canonical write, drained --- by the publisher into JSONL. change_log_seq links it to its change_log row. -CREATE TABLE outbox ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - change_log_seq INTEGER NOT NULL REFERENCES change_log (seq), - sent INTEGER NOT NULL DEFAULT 0, - sent_at TIMESTAMP, - attempts INTEGER NOT NULL DEFAULT 0, - last_error TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL -); +-- +goose StatementEnd -CREATE INDEX idx_outbox_unsent ON outbox (change_log_seq) WHERE sent = 0; +-- CDC capture triggers. Each is its own goose statement (the trigger body holds +-- semicolons). They write change_log atomically with the originating change, so +-- the application never emits events — it just writes sessions/pr/pr_checks. --- consumer_offsets is the durable per-consumer cursor (at-least-once delivery). -CREATE TABLE consumer_offsets ( - consumer TEXT PRIMARY KEY, - last_seq INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL -); +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_insert +AFTER INSERT ON sessions +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_created', + json_object('id', NEW.id, 'state', NEW.session_state, 'terminationReason', NEW.termination_reason, + 'isAlive', NEW.is_alive, 'activity', NEW.activity_state), + NEW.updated_at); +END; +-- +goose StatementEnd --- reaction_trackers is the durable escalation budget (persisted so a restart does --- not re-fire human pages). Off the canonical CDC path. Mirrors the LCM's --- in-memory reactionTracker: attempts (numeric budget), escalated (silences --- further auto-dispatch), first_attempt_at (duration-escalation anchor), --- project_id (captured at first attempt for the escalation event). -CREATE TABLE reaction_trackers ( - session_id TEXT NOT NULL, - reaction_key TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - escalated INTEGER NOT NULL DEFAULT 0, - first_attempt_at TIMESTAMP, - project_id TEXT NOT NULL DEFAULT '', - PRIMARY KEY (session_id, reaction_key) -); +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.session_state <> NEW.session_state + OR OLD.termination_reason <> NEW.termination_reason + OR OLD.is_alive <> NEW.is_alive + OR OLD.activity_state <> NEW.activity_state +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES (NEW.project_id, NEW.id, 'session_updated', + json_object('id', NEW.id, 'state', NEW.session_state, 'terminationReason', NEW.termination_reason, + 'isAlive', NEW.is_alive, 'activity', NEW.activity_state), + NEW.updated_at); +END; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_insert +AFTER INSERT ON pr +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_created', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_cdc_update +AFTER UPDATE ON pr +WHEN OLD.pr_state <> NEW.pr_state + OR OLD.ci_state <> NEW.ci_state + OR OLD.review_decision <> NEW.review_decision + OR OLD.mergeability <> NEW.mergeability +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ((SELECT project_id FROM sessions WHERE id = NEW.session_id), NEW.session_id, 'pr_updated', + json_object('url', NEW.url, 'session', NEW.session_id, 'state', NEW.pr_state, + 'ci', NEW.ci_state, 'review', NEW.review_decision, 'mergeability', NEW.mergeability), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TRIGGER pr_checks_cdc_insert +AFTER INSERT ON pr_checks +BEGIN + INSERT INTO change_log (project_id, session_id, event_type, payload, created_at) + VALUES ( + (SELECT s.project_id FROM pr p JOIN sessions s ON s.id = p.session_id WHERE p.url = NEW.pr_url), + (SELECT session_id FROM pr WHERE url = NEW.pr_url), + 'pr_check_recorded', + json_object('pr', NEW.pr_url, 'name', NEW.name, 'commit', NEW.commit_hash, 'status', NEW.status), + NEW.created_at); +END; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -DROP TABLE reaction_trackers; -DROP TABLE consumer_offsets; -DROP TABLE outbox; DROP TABLE change_log; -DROP TABLE session_metadata; +DROP TABLE pr_comment; +DROP TABLE pr_checks; +DROP TABLE pr; DROP TABLE sessions; +DROP TABLE projects; -- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql deleted file mode 100644 index da987ed5c..000000000 --- a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql +++ /dev/null @@ -1,85 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- projects is the durable registry of repos AO manages, the SQLite twin of the --- old YAML config (global config.yaml + per-repo agent-orchestrator.yaml). id is --- the {basename}_{sha256(path:originUrl)[:10]} key the session layer references --- via sessions.project_id. The relationship is app-enforced, NOT a hard FK: --- SQLite cannot ALTER ADD a FK without a table rebuild, and an existing-session --- backfill may land sessions before their project row. -CREATE TABLE projects ( - id TEXT PRIMARY KEY, - path TEXT NOT NULL, - repo_owner TEXT NOT NULL DEFAULT '', - repo_name TEXT NOT NULL DEFAULT '', - repo_platform TEXT NOT NULL DEFAULT '', - repo_origin_url TEXT NOT NULL DEFAULT '', - default_branch TEXT NOT NULL DEFAULT '', - display_name TEXT NOT NULL DEFAULT '', - session_prefix TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - registered_at TIMESTAMP NOT NULL, - - -- soft delete: NULL = active. Archiving keeps the row so a session's - -- project_id always resolves (there is no FK to enforce it), avoiding - -- dangling references; active-only reads filter archived_at IS NULL. - archived_at TIMESTAMP -); - --- pr is the SCM observer's per-session cache of the rich PR facts that do NOT --- live in the canonical lifecycle (which keeps only pr_state/reason/number/url). --- 1:1 with a session (a PR is tied to a session by its branch), written by the --- SCM observer OFF the canonical CDC path (no revision bump, no change_log/outbox --- event), and cascades away with its session. Scalar facts are typed columns — --- review_decision/mergeability/ci_state are CHECK-constrained enums and the CI --- counts are integers, not opaque strings; the list facts (individual checks and --- review comments) are normalized into pr_check / pr_comment. -CREATE TABLE pr ( - session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, - review_decision TEXT NOT NULL DEFAULT 'none' - CHECK (review_decision IN ('none', 'approved', 'changes_requested', 'review_required')), - mergeability TEXT NOT NULL DEFAULT 'unknown' - CHECK (mergeability IN ('unknown', 'mergeable', 'conflicting', 'blocked', 'unstable')), - ci_state TEXT NOT NULL DEFAULT 'unknown' - CHECK (ci_state IN ('unknown', 'pending', 'passing', 'failing')), - ci_passed INTEGER NOT NULL DEFAULT 0, - ci_failed INTEGER NOT NULL DEFAULT 0, - ci_pending INTEGER NOT NULL DEFAULT 0, - ci_log_tail TEXT NOT NULL DEFAULT '', - last_fetched_at TIMESTAMP NOT NULL -); - --- pr_check is one CI check belonging to a pr (the normalized form of the old --- ci_summary string). It cascades from pr, so it cannot outlive its PR facts. -CREATE TABLE pr_check ( - session_id TEXT NOT NULL REFERENCES pr (session_id) ON DELETE CASCADE, - name TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'unknown' - CHECK (status IN ('unknown', 'queued', 'in_progress', 'passed', 'failed', 'skipped', 'cancelled')), - url TEXT NOT NULL DEFAULT '', - PRIMARY KEY (session_id, name) -); - --- pr_comment is one unresolved review comment belonging to a pr (the normalized --- form of the old pending_comments JSON-in-a-string). Cascades from pr. -CREATE TABLE pr_comment ( - session_id TEXT NOT NULL REFERENCES pr (session_id) ON DELETE CASCADE, - comment_id TEXT NOT NULL, - author TEXT NOT NULL DEFAULT '', - file TEXT NOT NULL DEFAULT '', - line INTEGER NOT NULL DEFAULT 0, - body TEXT NOT NULL DEFAULT '', - resolved INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL, - PRIMARY KEY (session_id, comment_id) -); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE pr_comment; -DROP TABLE pr_check; -DROP TABLE pr; -DROP TABLE projects; --- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/pr_projects_test.go b/backend/internal/storage/sqlite/pr_projects_test.go deleted file mode 100644 index 58227b1f3..000000000 --- a/backend/internal/storage/sqlite/pr_projects_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package sqlite - -import ( - "context" - "reflect" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/ports" -) - -func TestProjectUpsertGetListDelete(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - if _, ok, err := s.GetProject(ctx, "p1"); err != nil || ok { - t.Fatalf("get missing: ok=%v err=%v", ok, err) - } - - p := ProjectRow{ - ID: "p1", Path: "/repo", RepoOwner: "acme", RepoName: "widget", - RepoPlatform: "github", RepoOriginURL: "git@github.com:acme/widget.git", - DefaultBranch: "main", DisplayName: "Widget", SessionPrefix: "wid", - Source: "local", RegisteredAt: now, - } - if err := s.UpsertProject(ctx, p); err != nil { - t.Fatalf("upsert: %v", err) - } - - got, ok, err := s.GetProject(ctx, "p1") - if err != nil || !ok { - t.Fatalf("get: ok=%v err=%v", ok, err) - } - if got != p { - t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, p) - } - - // Upsert again with a changed field updates in place (no duplicate). - p.DisplayName = "Widget 2" - if err := s.UpsertProject(ctx, p); err != nil { - t.Fatalf("re-upsert: %v", err) - } - list, err := s.ListProjects(ctx) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 1 || list[0].DisplayName != "Widget 2" { - t.Fatalf("list after re-upsert = %+v", list) - } - - if err := s.DeleteProject(ctx, "p1"); err != nil { - t.Fatalf("delete: %v", err) - } - if _, ok, _ := s.GetProject(ctx, "p1"); ok { - t.Fatal("project should be gone after delete") - } -} - -func TestArchiveProjectHidesFromListButGetResolves(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - if err := s.UpsertProject(ctx, ProjectRow{ID: "p1", Path: "/repo", RegisteredAt: now}); err != nil { - t.Fatalf("upsert: %v", err) - } - if err := s.ArchiveProject(ctx, "p1", now); err != nil { - t.Fatalf("archive: %v", err) - } - - // Active-only list hides it. - list, err := s.ListProjects(ctx) - if err != nil { - t.Fatalf("list: %v", err) - } - if len(list) != 0 { - t.Fatalf("archived project should not appear in ListProjects, got %+v", list) - } - - // Get still resolves it (a session's project_id must not dangle) and reports - // the archived marker. - got, ok, err := s.GetProject(ctx, "p1") - if err != nil || !ok { - t.Fatalf("get archived: ok=%v err=%v", ok, err) - } - if got.ArchivedAt.IsZero() { - t.Fatal("archived project should carry a non-zero ArchivedAt") - } -} - -func TestPRUpsertGetDelete(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - // pr FKs sessions(id); seed the session first. - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("seed session: %v", err) - } - - if _, ok, err := s.GetPR(ctx, "s1"); err != nil || ok { - t.Fatalf("get missing: ok=%v err=%v", ok, err) - } - - pr := PRRow{ - SessionID: "s1", ReviewDecision: "changes_requested", Mergeability: "blocked", - CIState: "failing", CIPassed: 3, CIFailed: 1, CIPending: 0, CILogTail: "FAIL TestX", - LastFetchedAt: now, - } - if err := s.UpsertPR(ctx, pr); err != nil { - t.Fatalf("upsert: %v", err) - } - - got, ok, err := s.GetPR(ctx, "s1") - if err != nil || !ok { - t.Fatalf("get: ok=%v err=%v", ok, err) - } - if got != pr { - t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, pr) - } - - if err := s.DeletePR(ctx, "s1"); err != nil { - t.Fatalf("delete: %v", err) - } - if _, ok, _ := s.GetPR(ctx, "s1"); ok { - t.Fatal("pr should be gone after delete") - } -} - -func TestPRRejectsBadEnum(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("seed session: %v", err) - } - // review_decision is a CHECK-constrained enum; an off-list value must fail. - err := s.UpsertPR(ctx, PRRow{ - SessionID: "s1", ReviewDecision: "definitely_not_a_decision", - Mergeability: "unknown", CIState: "unknown", LastFetchedAt: time.Now().UTC(), - }) - if err == nil { - t.Fatal("expected CHECK constraint to reject an invalid review_decision") - } -} - -func TestPRChecksAndCommentsReplaceAndList(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - now := time.Now().UTC().Truncate(time.Second) - - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatalf("seed session: %v", err) - } - // pr_check / pr_comment FK pr(session_id); the pr row must exist first. - if err := s.UpsertPR(ctx, PRRow{ - SessionID: "s1", ReviewDecision: "review_required", Mergeability: "unknown", - CIState: "pending", LastFetchedAt: now, - }); err != nil { - t.Fatalf("upsert pr: %v", err) - } - - checks := []PRCheck{ - {Name: "build", Status: "passed", URL: "https://ci/build"}, - {Name: "test", Status: "failed", URL: "https://ci/test"}, - } - if err := s.ReplacePRChecks(ctx, "s1", checks); err != nil { - t.Fatalf("replace checks: %v", err) - } - gotChecks, err := s.ListPRChecks(ctx, "s1") - if err != nil { - t.Fatalf("list checks: %v", err) - } - if !reflect.DeepEqual(gotChecks, checks) { - t.Fatalf("checks = %+v, want %+v", gotChecks, checks) - } - // Replace is a set-replace, not a merge: a shorter set removes the rest. - if err := s.ReplacePRChecks(ctx, "s1", []PRCheck{{Name: "build", Status: "passed"}}); err != nil { - t.Fatalf("replace checks 2: %v", err) - } - if gotChecks, _ = s.ListPRChecks(ctx, "s1"); len(gotChecks) != 1 { - t.Fatalf("after replace, checks = %+v, want 1", gotChecks) - } - - comments := []PRComment{ - {CommentID: "c1", Author: "alice", File: "a.go", Line: 10, Body: "nit", Resolved: false, CreatedAt: now}, - {CommentID: "c2", Author: "bob", File: "b.go", Line: 20, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, - } - if err := s.ReplacePRComments(ctx, "s1", comments); err != nil { - t.Fatalf("replace comments: %v", err) - } - gotComments, err := s.ListPRComments(ctx, "s1") - if err != nil { - t.Fatalf("list comments: %v", err) - } - if !reflect.DeepEqual(gotComments, comments) { - t.Fatalf("comments = %+v, want %+v", gotComments, comments) - } - - // Deleting the pr cascades its checks and comments. - if err := s.DeletePR(ctx, "s1"); err != nil { - t.Fatalf("delete pr: %v", err) - } - if c, _ := s.ListPRChecks(ctx, "s1"); len(c) != 0 { - t.Fatalf("checks not cascaded: %+v", c) - } - if c, _ := s.ListPRComments(ctx, "s1"); len(c) != 0 { - t.Fatalf("comments not cascaded: %+v", c) - } -} diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go index 1eca08f8f..4170da4d9 100644 --- a/backend/internal/storage/sqlite/pr_store.go +++ b/backend/internal/storage/sqlite/pr_store.go @@ -10,32 +10,163 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// PRRow is the SCM observer's cache of the scalar PR facts that do not live in -// the canonical lifecycle (which keeps only pr_state/reason/number/url). It is -// 1:1 with a session and written OFF the canonical CDC path: upserting it never -// bumps revision and never emits a change_log/outbox event. The list facts -// (checks, comments) are separate rows — see PRCheck / PRComment. +// PRRow is the scalar PR facts row (the pr table), keyed by normalized URL. One +// session can own many PRs; a PR belongs to one session (session_id FK). type PRRow struct { + URL string SessionID string + Number int64 + State string // draft | open | merged | closed ReviewDecision string // none | approved | changes_requested | review_required - Mergeability string // unknown | mergeable | conflicting | blocked | unstable CIState string // unknown | pending | passing | failing - CIPassed int64 - CIFailed int64 - CIPending int64 - CILogTail string - LastFetchedAt time.Time + Mergeability string // unknown | mergeable | conflicting | blocked | unstable + UpdatedAt time.Time } -// PRCheck is one CI check belonging to a session's PR. -type PRCheck struct { - Name string - Status string // unknown | queued | in_progress | passed | failed | skipped | cancelled - URL string +// UpsertPR inserts or replaces the scalar PR facts for a PR URL. Empty enum +// fields default to their "nothing known yet" value so a partial row is valid +// against the CHECK constraints (matches the domain zero values none/unknown). +func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { + if r.State == "" { + r.State = "open" + } + if r.ReviewDecision == "" { + r.ReviewDecision = "none" + } + if r.CIState == "" { + r.CIState = "unknown" + } + if r.Mergeability == "" { + r.Mergeability = "unknown" + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpsertPR(ctx, gen.UpsertPRParams{ + Url: r.URL, + SessionID: r.SessionID, + Number: r.Number, + PrState: r.State, + ReviewDecision: r.ReviewDecision, + CiState: r.CIState, + Mergeability: r.Mergeability, + UpdatedAt: r.UpdatedAt, + }) } -// PRComment is one review comment belonging to a session's PR. -type PRComment struct { +// GetPR returns the PR facts for a URL, or ok=false if absent. +func (s *Store) GetPR(ctx context.Context, url string) (PRRow, bool, error) { + p, err := s.qr.GetPR(ctx, url) + if errors.Is(err, sql.ErrNoRows) { + return PRRow{}, false, nil + } + if err != nil { + return PRRow{}, false, fmt.Errorf("get pr %s: %w", url, err) + } + return prRowFromGen(p), true, nil +} + +// ListPRsBySession returns every PR owned by a session, newest first. +func (s *Store) ListPRsBySession(ctx context.Context, sessionID string) ([]PRRow, error) { + rows, err := s.qr.ListPRsBySession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("list prs for %s: %w", sessionID, err) + } + out := make([]PRRow, 0, len(rows)) + for _, p := range rows { + out = append(out, prRowFromGen(p)) + } + return out, nil +} + +// DeletePR removes a PR (cascades to its checks + comments). +func (s *Store) DeletePR(ctx context.Context, url string) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.DeletePR(ctx, url) +} + +func prRowFromGen(p gen.Pr) PRRow { + return PRRow{ + URL: p.Url, + SessionID: p.SessionID, + Number: p.Number, + State: p.PrState, + ReviewDecision: p.ReviewDecision, + CIState: p.CiState, + Mergeability: p.Mergeability, + UpdatedAt: p.UpdatedAt, + } +} + +// ---- pr_checks: CI run history ---- + +// PRCheckRow is one CI check run for a PR (one row per check name per commit). +type PRCheckRow struct { + PRURL string + Name string + CommitHash string + Status string // unknown | queued | in_progress | passed | failed | skipped | cancelled + URL string + LogTail string + CreatedAt time.Time +} + +// RecordCheck upserts a CI check run. Re-polling the same (pr, name, commit) +// updates the same row; a new commit creates a new row (a fresh agent attempt). +func (s *Store) RecordCheck(ctx context.Context, r PRCheckRow) error { + if r.Status == "" { + r.Status = "unknown" + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpsertPRCheck(ctx, gen.UpsertPRCheckParams{ + PrUrl: r.PRURL, + Name: r.Name, + CommitHash: r.CommitHash, + Status: r.Status, + Url: r.URL, + LogTail: r.LogTail, + CreatedAt: r.CreatedAt, + }) +} + +// RecentCheckStatuses returns the statuses of the last `limit` runs of a check, +// most-recent first. The CI-fix-loop brake reads this: "last 3 all failed?". +func (s *Store) RecentCheckStatuses(ctx context.Context, prURL, name string, limit int) ([]string, error) { + rows, err := s.qr.ListRecentChecks(ctx, gen.ListRecentChecksParams{ + PrUrl: prURL, Name: name, Limit: int64(limit), + }) + if err != nil { + return nil, fmt.Errorf("recent checks %s/%s: %w", prURL, name, err) + } + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Status) + } + return out, nil +} + +// ListChecks returns every recorded check run for a PR. +func (s *Store) ListChecks(ctx context.Context, prURL string) ([]PRCheckRow, error) { + rows, err := s.qr.ListChecksByPR(ctx, prURL) + if err != nil { + return nil, fmt.Errorf("list checks %s: %w", prURL, err) + } + out := make([]PRCheckRow, 0, len(rows)) + for _, c := range rows { + out = append(out, PRCheckRow{ + PRURL: c.PrUrl, Name: c.Name, CommitHash: c.CommitHash, + Status: c.Status, URL: c.Url, LogTail: c.LogTail, CreatedAt: c.CreatedAt, + }) + } + return out, nil +} + +// ---- pr_comment ---- + +// PRCommentRow is one review comment on a PR. +type PRCommentRow struct { + PRURL string CommentID string Author string File string @@ -45,101 +176,18 @@ type PRComment struct { CreatedAt time.Time } -// UpsertPR inserts or replaces the scalar PR facts for one session. -func (s *Store) UpsertPR(ctx context.Context, r PRRow) error { +// ReplacePRComments atomically replaces the full comment set for a PR (each SCM +// fetch reports the current set, so a replace keeps it in sync). +func (s *Store) ReplacePRComments(ctx context.Context, prURL string, comments []PRCommentRow) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.UpsertPR(ctx, gen.UpsertPRParams{ - SessionID: r.SessionID, - ReviewDecision: r.ReviewDecision, - Mergeability: r.Mergeability, - CiState: r.CIState, - CiPassed: r.CIPassed, - CiFailed: r.CIFailed, - CiPending: r.CIPending, - CiLogTail: r.CILogTail, - LastFetchedAt: r.LastFetchedAt, - }) -} - -// GetPR returns the scalar PR facts for one session. ok is false when no row -// exists (the SCM observer has not fetched yet, or the session has no PR). -func (s *Store) GetPR(ctx context.Context, sessionID string) (PRRow, bool, error) { - p, err := s.q.GetPR(ctx, sessionID) - if errors.Is(err, sql.ErrNoRows) { - return PRRow{}, false, nil - } - if err != nil { - return PRRow{}, false, fmt.Errorf("get pr: %w", err) - } - return PRRow{ - SessionID: p.SessionID, - ReviewDecision: p.ReviewDecision, - Mergeability: p.Mergeability, - CIState: p.CiState, - CIPassed: p.CiPassed, - CIFailed: p.CiFailed, - CIPending: p.CiPending, - CILogTail: p.CiLogTail, - LastFetchedAt: p.LastFetchedAt, - }, true, nil -} - -// DeletePR drops the scalar PR facts for one session, cascading its checks and -// comments. Normally unnecessary (the chain cascades on session delete); exposed -// for explicit eviction. -func (s *Store) DeletePR(ctx context.Context, sessionID string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeletePR(ctx, sessionID) -} - -// ReplacePRChecks atomically replaces the full set of CI checks for a session's -// PR — each SCM fetch reports the current set, so a replace (not a merge) keeps -// the table in sync (a check that disappeared upstream is removed). The PR row -// must already exist (pr_check FKs pr). -func (s *Store) ReplacePRChecks(ctx context.Context, sessionID string, checks []PRCheck) error { - return s.inTx(ctx, "replace pr checks", func(qtx *gen.Queries) error { - if err := qtx.DeletePRChecks(ctx, sessionID); err != nil { - return err - } - for _, c := range checks { - if err := qtx.InsertPRCheck(ctx, gen.InsertPRCheckParams{ - SessionID: sessionID, - Name: c.Name, - Status: c.Status, - Url: c.URL, - }); err != nil { - return fmt.Errorf("check %q: %w", c.Name, err) - } - } - return nil - }) -} - -// ListPRChecks returns a session's CI checks, ordered by name. -func (s *Store) ListPRChecks(ctx context.Context, sessionID string) ([]PRCheck, error) { - rows, err := s.q.ListPRChecks(ctx, sessionID) - if err != nil { - return nil, fmt.Errorf("list pr checks: %w", err) - } - out := make([]PRCheck, 0, len(rows)) - for _, r := range rows { - out = append(out, PRCheck{Name: r.Name, Status: r.Status, URL: r.Url}) - } - return out, nil -} - -// ReplacePRComments atomically replaces the full set of review comments for a -// session's PR (same replace-not-merge rationale as ReplacePRChecks). -func (s *Store) ReplacePRComments(ctx context.Context, sessionID string, comments []PRComment) error { - return s.inTx(ctx, "replace pr comments", func(qtx *gen.Queries) error { - if err := qtx.DeletePRComments(ctx, sessionID); err != nil { + return s.inTx(ctx, "replace pr comments", func(q *gen.Queries) error { + if err := q.DeletePRComments(ctx, prURL); err != nil { return err } for _, c := range comments { - if err := qtx.InsertPRComment(ctx, gen.InsertPRCommentParams{ - SessionID: sessionID, + if err := q.UpsertPRComment(ctx, gen.UpsertPRCommentParams{ + PrUrl: prURL, CommentID: c.CommentID, Author: c.Author, File: c.File, @@ -155,47 +203,18 @@ func (s *Store) ReplacePRComments(ctx context.Context, sessionID string, comment }) } -// ListPRComments returns a session's review comments, ordered by creation time. -func (s *Store) ListPRComments(ctx context.Context, sessionID string) ([]PRComment, error) { - rows, err := s.q.ListPRComments(ctx, sessionID) +// ListPRComments returns a PR's review comments, oldest first. +func (s *Store) ListPRComments(ctx context.Context, prURL string) ([]PRCommentRow, error) { + rows, err := s.qr.ListPRComments(ctx, prURL) if err != nil { - return nil, fmt.Errorf("list pr comments: %w", err) + return nil, fmt.Errorf("list pr comments %s: %w", prURL, err) } - out := make([]PRComment, 0, len(rows)) - for _, r := range rows { - out = append(out, PRComment{ - CommentID: r.CommentID, - Author: r.Author, - File: r.File, - Line: r.Line, - Body: r.Body, - Resolved: r.Resolved != 0, - CreatedAt: r.CreatedAt, + out := make([]PRCommentRow, 0, len(rows)) + for _, c := range rows { + out = append(out, PRCommentRow{ + PRURL: c.PrUrl, CommentID: c.CommentID, Author: c.Author, File: c.File, + Line: c.Line, Body: c.Body, Resolved: c.Resolved != 0, CreatedAt: c.CreatedAt, }) } return out, nil } - -// inTx runs fn inside a single write transaction over the store's queries, -// rolling back on error. It holds writeMu for the duration, so callers must not -// already hold it. -func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin %s: %w", what, err) - } - defer tx.Rollback() - if err := fn(s.q.WithTx(tx)); err != nil { - return fmt.Errorf("%s: %w", what, err) - } - return tx.Commit() -} - -func boolToInt(b bool) int64 { - if b { - return 1 - } - return 0 -} diff --git a/backend/internal/storage/sqlite/project_store.go b/backend/internal/storage/sqlite/project_store.go index 4837cafcc..d81943c3c 100644 --- a/backend/internal/storage/sqlite/project_store.go +++ b/backend/internal/storage/sqlite/project_store.go @@ -10,74 +10,46 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// ProjectRow is one registered repo, the durable twin of the old YAML config -// entry. It is the unit the registration path upserts and cross-project readers -// list. Off the canonical CDC path: writing a project never emits a change_log -// or outbox event. +// ProjectRow is one registered repo (the projects table). id is a short slug +// (mer, ao). ArchivedAt zero means active. type ProjectRow struct { ID string Path string - RepoOwner string - RepoName string - RepoPlatform string RepoOriginURL string - DefaultBranch string DisplayName string - SessionPrefix string - Source string RegisteredAt time.Time - // ArchivedAt is the soft-delete marker; zero means active. GetProject returns - // it regardless of state (so a session can resolve its archived project); - // ListProjects returns only rows where it is zero. - ArchivedAt time.Time + ArchivedAt time.Time } -// UpsertProject inserts or updates one registered project. +// UpsertProject inserts or updates a registered project. func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.UpsertProject(ctx, gen.UpsertProjectParams{ + return s.qw.UpsertProject(ctx, gen.UpsertProjectParams{ ID: r.ID, Path: r.Path, - RepoOwner: r.RepoOwner, - RepoName: r.RepoName, - RepoPlatform: r.RepoPlatform, RepoOriginUrl: r.RepoOriginURL, - DefaultBranch: r.DefaultBranch, DisplayName: r.DisplayName, - SessionPrefix: r.SessionPrefix, - Source: r.Source, RegisteredAt: r.RegisteredAt, ArchivedAt: nullTime(r.ArchivedAt), }) } -// ArchiveProject soft-deletes one project, keeping the row so a session's -// project_id still resolves. Active-only reads (ListProjects) then hide it. -func (s *Store) ArchiveProject(ctx context.Context, id string, t time.Time) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.ArchiveProject(ctx, gen.ArchiveProjectParams{ - ArchivedAt: nullTime(t), - ID: id, - }) -} - -// GetProject returns one project by id. ok is false when no row exists. +// GetProject returns a project by id (active or archived), or ok=false. func (s *Store) GetProject(ctx context.Context, id string) (ProjectRow, bool, error) { - p, err := s.q.GetProject(ctx, id) + p, err := s.qr.GetProject(ctx, id) if errors.Is(err, sql.ErrNoRows) { return ProjectRow{}, false, nil } if err != nil { - return ProjectRow{}, false, fmt.Errorf("get project: %w", err) + return ProjectRow{}, false, fmt.Errorf("get project %s: %w", id, err) } return projectRowFromGen(p), true, nil } -// ListProjects returns every registered project, ordered by id. +// ListProjects returns active (non-archived) projects, ordered by id. func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { - rows, err := s.q.ListProjects(ctx) + rows, err := s.qr.ListProjects(ctx) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } @@ -88,31 +60,31 @@ func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { return out, nil } -// DeleteProject removes one project by id. -func (s *Store) DeleteProject(ctx context.Context, id string) error { +// ArchiveProject soft-deletes a project (the row stays so session.project_id +// still resolves). +func (s *Store) ArchiveProject(ctx context.Context, id string, at time.Time) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.DeleteProject(ctx, id) + return s.qw.ArchiveProject(ctx, gen.ArchiveProjectParams{ + ArchivedAt: nullTime(at), + ID: id, + }) } func projectRowFromGen(p gen.Project) ProjectRow { - return ProjectRow{ + r := ProjectRow{ ID: p.ID, Path: p.Path, - RepoOwner: p.RepoOwner, - RepoName: p.RepoName, - RepoPlatform: p.RepoPlatform, RepoOriginURL: p.RepoOriginUrl, - DefaultBranch: p.DefaultBranch, DisplayName: p.DisplayName, - SessionPrefix: p.SessionPrefix, - Source: p.Source, RegisteredAt: p.RegisteredAt, - ArchivedAt: p.ArchivedAt.Time, } + if p.ArchivedAt.Valid { + r.ArchivedAt = p.ArchivedAt.Time + } + return r } -// nullTime maps a zero time.Time to a NULL column, else a valid timestamp. func nullTime(t time.Time) sql.NullTime { if t.IsZero() { return sql.NullTime{} diff --git a/backend/internal/storage/sqlite/queries/cdc.sql b/backend/internal/storage/sqlite/queries/cdc.sql deleted file mode 100644 index b818194a5..000000000 --- a/backend/internal/storage/sqlite/queries/cdc.sql +++ /dev/null @@ -1,42 +0,0 @@ --- name: InsertChangeLog :one --- Appends a canonical-write record and returns its monotonic seq so the same --- transaction can thread it into the outbox row. -INSERT INTO change_log (session_id, event_type, revision, payload, created_at) -VALUES (?, ?, ?, ?, ?) -RETURNING seq; - --- name: InsertOutbox :exec -INSERT INTO outbox (change_log_seq, created_at) -VALUES (?, ?); - --- name: ListUnsentOutbox :many -SELECT o.id, o.change_log_seq, o.attempts, - c.session_id, c.event_type, c.revision, c.payload, c.created_at -FROM outbox o -JOIN change_log c ON c.seq = o.change_log_seq -WHERE o.sent = 0 -ORDER BY o.change_log_seq -LIMIT ?; - --- name: MarkOutboxSent :exec -UPDATE outbox SET sent = 1, sent_at = ? WHERE id = ?; - --- name: MarkOutboxFailed :exec -UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE id = ?; - --- name: GetConsumerOffset :one -SELECT last_seq FROM consumer_offsets WHERE consumer = ?; - --- name: UpsertConsumerOffset :exec -INSERT INTO consumer_offsets (consumer, last_seq, updated_at) -VALUES (?, ?, ?) -ON CONFLICT (consumer) DO UPDATE SET last_seq = excluded.last_seq, updated_at = excluded.updated_at; - --- name: MaxChangeLogSeq :one -SELECT CAST(COALESCE(MAX(seq), 0) AS INTEGER) FROM change_log; - --- name: MinConsumerOffset :one -SELECT CAST(COALESCE(MIN(last_seq), 0) AS INTEGER) FROM consumer_offsets; - --- name: DeleteSentOutboxBelow :execrows -DELETE FROM outbox WHERE sent = 1 AND change_log_seq < ?; diff --git a/backend/internal/storage/sqlite/queries/changelog.sql b/backend/internal/storage/sqlite/queries/changelog.sql new file mode 100644 index 000000000..0e11899c2 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/changelog.sql @@ -0,0 +1,10 @@ +-- name: ReadChangeLogAfter :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE seq > ? ORDER BY seq LIMIT ?; + +-- name: ReadChangeLogAfterForProject :many +SELECT seq, project_id, session_id, event_type, payload, created_at +FROM change_log WHERE project_id = ? AND seq > ? ORDER BY seq LIMIT ?; + +-- name: MaxChangeLogSeq :one +SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log; diff --git a/backend/internal/storage/sqlite/queries/metadata.sql b/backend/internal/storage/sqlite/queries/metadata.sql deleted file mode 100644 index 158552daa..000000000 --- a/backend/internal/storage/sqlite/queries/metadata.sql +++ /dev/null @@ -1,20 +0,0 @@ --- name: GetSessionMetadata :one -SELECT branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt -FROM session_metadata -WHERE session_id = ?; - --- name: UpsertSessionMetadata :exec --- Merge semantics: an empty incoming column is "leave unchanged", so a partial --- patch (e.g. spawn writing only the runtime handle) never clobbers a value set --- earlier (e.g. the branch set at creation). Mirrors the old per-key map merge. -INSERT INTO session_metadata ( - session_id, branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET - branch = CASE WHEN excluded.branch <> '' THEN excluded.branch ELSE session_metadata.branch END, - workspace_path = CASE WHEN excluded.workspace_path <> '' THEN excluded.workspace_path ELSE session_metadata.workspace_path END, - runtime_handle_id = CASE WHEN excluded.runtime_handle_id <> '' THEN excluded.runtime_handle_id ELSE session_metadata.runtime_handle_id END, - runtime_name = CASE WHEN excluded.runtime_name <> '' THEN excluded.runtime_name ELSE session_metadata.runtime_name END, - agent_session_id = CASE WHEN excluded.agent_session_id <> '' THEN excluded.agent_session_id ELSE session_metadata.agent_session_id END, - prompt = CASE WHEN excluded.prompt <> '' THEN excluded.prompt ELSE session_metadata.prompt END, - updated_at = excluded.updated_at; diff --git a/backend/internal/storage/sqlite/queries/pr.sql b/backend/internal/storage/sqlite/queries/pr.sql index 13c14a78d..e6b41cf1a 100644 --- a/backend/internal/storage/sqlite/queries/pr.sql +++ b/backend/internal/storage/sqlite/queries/pr.sql @@ -1,43 +1,20 @@ -- name: UpsertPR :exec -INSERT INTO pr ( - session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id) DO UPDATE SET +INSERT INTO pr (url, session_id, number, pr_state, review_decision, ci_state, mergeability, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (url) DO UPDATE SET + session_id = excluded.session_id, + number = excluded.number, + pr_state = excluded.pr_state, review_decision = excluded.review_decision, - mergeability = excluded.mergeability, - ci_state = excluded.ci_state, - ci_passed = excluded.ci_passed, - ci_failed = excluded.ci_failed, - ci_pending = excluded.ci_pending, - ci_log_tail = excluded.ci_log_tail, - last_fetched_at = excluded.last_fetched_at; + ci_state = excluded.ci_state, + mergeability = excluded.mergeability, + updated_at = excluded.updated_at; -- name: GetPR :one -SELECT session_id, review_decision, mergeability, ci_state, ci_passed, ci_failed, ci_pending, ci_log_tail, last_fetched_at -FROM pr -WHERE session_id = ?; +SELECT * FROM pr WHERE url = ?; + +-- name: ListPRsBySession :many +SELECT * FROM pr WHERE session_id = ? ORDER BY updated_at DESC; -- name: DeletePR :exec -DELETE FROM pr WHERE session_id = ?; - --- name: DeletePRChecks :exec -DELETE FROM pr_check WHERE session_id = ?; - --- name: InsertPRCheck :exec -INSERT INTO pr_check (session_id, name, status, url) VALUES (?, ?, ?, ?); - --- name: ListPRChecks :many -SELECT name, status, url FROM pr_check WHERE session_id = ? ORDER BY name; - --- name: DeletePRComments :exec -DELETE FROM pr_comment WHERE session_id = ?; - --- name: InsertPRComment :exec -INSERT INTO pr_comment (session_id, comment_id, author, file, line, body, resolved, created_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?); - --- name: ListPRComments :many -SELECT comment_id, author, file, line, body, resolved, created_at -FROM pr_comment -WHERE session_id = ? -ORDER BY created_at, comment_id; +DELETE FROM pr WHERE url = ?; diff --git a/backend/internal/storage/sqlite/queries/pr_checks.sql b/backend/internal/storage/sqlite/queries/pr_checks.sql new file mode 100644 index 000000000..2e3e3c154 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr_checks.sql @@ -0,0 +1,15 @@ +-- name: UpsertPRCheck :exec +INSERT INTO pr_checks (pr_url, name, commit_hash, status, url, log_tail, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, name, commit_hash) DO UPDATE SET + status = excluded.status, + url = excluded.url, + log_tail = excluded.log_tail; + +-- name: ListRecentChecks :many +SELECT status, commit_hash, created_at FROM pr_checks +WHERE pr_url = ? AND name = ? +ORDER BY created_at DESC LIMIT ?; + +-- name: ListChecksByPR :many +SELECT * FROM pr_checks WHERE pr_url = ? ORDER BY name, created_at; diff --git a/backend/internal/storage/sqlite/queries/pr_comment.sql b/backend/internal/storage/sqlite/queries/pr_comment.sql new file mode 100644 index 000000000..df4f99d01 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr_comment.sql @@ -0,0 +1,12 @@ +-- name: UpsertPRComment :exec +INSERT INTO pr_comment (pr_url, comment_id, author, file, line, body, resolved, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (pr_url, comment_id) DO UPDATE SET + author = excluded.author, file = excluded.file, line = excluded.line, + body = excluded.body, resolved = excluded.resolved; + +-- name: DeletePRComments :exec +DELETE FROM pr_comment WHERE pr_url = ?; + +-- name: ListPRComments :many +SELECT * FROM pr_comment WHERE pr_url = ? ORDER BY created_at, comment_id; diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql index 054b8f0e8..3dc28950b 100644 --- a/backend/internal/storage/sqlite/queries/projects.sql +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -1,32 +1,19 @@ -- name: UpsertProject :exec -INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO projects (id, path, repo_origin_url, display_name, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET path = excluded.path, - repo_owner = excluded.repo_owner, - repo_name = excluded.repo_name, - repo_platform = excluded.repo_platform, repo_origin_url = excluded.repo_origin_url, - default_branch = excluded.default_branch, display_name = excluded.display_name, - session_prefix = excluded.session_prefix, - source = excluded.source, - registered_at = excluded.registered_at, archived_at = excluded.archived_at; -- name: GetProject :one -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE id = ?; +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE id = ?; -- name: ListProjects :many -SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at -FROM projects -WHERE archived_at IS NULL -ORDER BY id; +SELECT id, path, repo_origin_url, display_name, registered_at, archived_at +FROM projects WHERE archived_at IS NULL ORDER BY id; -- name: ArchiveProject :exec UPDATE projects SET archived_at = ? WHERE id = ?; - --- name: DeleteProject :exec -DELETE FROM projects WHERE id = ?; diff --git a/backend/internal/storage/sqlite/queries/reactions.sql b/backend/internal/storage/sqlite/queries/reactions.sql deleted file mode 100644 index 0ccd99c3f..000000000 --- a/backend/internal/storage/sqlite/queries/reactions.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: ListReactionTrackers :many -SELECT session_id, reaction_key, attempts, escalated, first_attempt_at, project_id -FROM reaction_trackers; - --- name: UpsertReactionTracker :exec -INSERT INTO reaction_trackers (session_id, reaction_key, attempts, escalated, first_attempt_at, project_id) -VALUES (?, ?, ?, ?, ?, ?) -ON CONFLICT (session_id, reaction_key) DO UPDATE SET - attempts = excluded.attempts, - escalated = excluded.escalated, - first_attempt_at = excluded.first_attempt_at, - project_id = excluded.project_id; - --- name: DeleteReactionTracker :exec -DELETE FROM reaction_trackers WHERE session_id = ? AND reaction_key = ?; - --- name: DeleteSessionReactionTrackers :exec -DELETE FROM reaction_trackers WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index 48cdcacf1..9b294de3c 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -1,58 +1,34 @@ --- name: InsertSession :execrows --- CAS insert: only succeeds for a brand-new id. Incoming revision must be 0; --- the row is persisted at revision 1. +-- name: NextSessionNum :one +SELECT COALESCE(MAX(num), 0) + 1 AS next FROM sessions WHERE project_id = ?; + +-- name: InsertSession :exec INSERT INTO sessions ( - id, project_id, issue_id, kind, created_at, updated_at, - revision, - session_state, session_reason, - pr_state, pr_reason, pr_number, pr_url, - runtime_state, runtime_reason, + id, project_id, num, issue_id, kind, harness, + session_state, termination_reason, is_alive, activity_state, activity_last_at, activity_source, - detecting_attempts, detecting_started_at, detecting_evidence_hash -) VALUES ( - ?, ?, ?, ?, ?, ?, - 1, - ?, ?, - ?, ?, ?, ?, - ?, ?, - ?, ?, ?, - ?, ?, ? -) -ON CONFLICT (id) DO NOTHING; + detecting_attempts, detecting_started_at, detecting_evidence_hash, + branch, workspace_path, runtime_handle_id, runtime_name, agent_session_id, prompt, + created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); --- name: UpdateSessionCAS :execrows --- CAS update: succeeds only when the stored revision equals the caller's loaded --- revision (@expected_revision). 0 rows affected => revision mismatch. +-- name: UpdateSession :exec UPDATE sessions SET - project_id = ?, - issue_id = ?, - kind = ?, - updated_at = ?, - revision = revision + 1, - session_state = ?, - session_reason = ?, - pr_state = ?, - pr_reason = ?, - pr_number = ?, - pr_url = ?, - runtime_state = ?, - runtime_reason = ?, - activity_state = ?, - activity_last_at = ?, - activity_source = ?, - detecting_attempts = ?, - detecting_started_at = ?, - detecting_evidence_hash = ? -WHERE id = ? AND revision = ?; - --- name: GetSessionRevision :one -SELECT revision FROM sessions WHERE id = ?; + issue_id = ?, kind = ?, harness = ?, + session_state = ?, termination_reason = ?, is_alive = ?, + activity_state = ?, activity_last_at = ?, activity_source = ?, + detecting_attempts = ?, detecting_started_at = ?, detecting_evidence_hash = ?, + branch = ?, workspace_path = ?, runtime_handle_id = ?, runtime_name = ?, agent_session_id = ?, prompt = ?, + updated_at = ? +WHERE id = ?; -- name: GetSession :one SELECT * FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many -SELECT * FROM sessions WHERE project_id = ?; +SELECT * FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many -SELECT * FROM sessions; +SELECT * FROM sessions ORDER BY project_id, num; + +-- name: DeleteSession :exec +DELETE FROM sessions WHERE id = ?; diff --git a/backend/internal/storage/sqlite/reaction_store.go b/backend/internal/storage/sqlite/reaction_store.go deleted file mode 100644 index c703a21b2..000000000 --- a/backend/internal/storage/sqlite/reaction_store.go +++ /dev/null @@ -1,86 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// ReactionTrackerRow is one persisted escalation budget, the durable mirror of -// the LCM's in-memory reactionTracker. It is the unit the lifecycle Manager -// hydrates on startup and writes through on each mutation. -type ReactionTrackerRow struct { - SessionID string - ReactionKey string - Attempts int - Escalated bool - FirstAttemptAt time.Time - ProjectID string -} - -// ListReactionTrackers returns every persisted escalation budget so the Manager -// can rehydrate its in-memory trackers after a restart. -func (s *Store) ListReactionTrackers(ctx context.Context) ([]ReactionTrackerRow, error) { - rows, err := s.q.ListReactionTrackers(ctx) - if err != nil { - return nil, fmt.Errorf("list reaction trackers: %w", err) - } - out := make([]ReactionTrackerRow, 0, len(rows)) - for _, r := range rows { - var first time.Time - if r.FirstAttemptAt.Valid { - first = r.FirstAttemptAt.Time - } - out = append(out, ReactionTrackerRow{ - SessionID: r.SessionID, - ReactionKey: r.ReactionKey, - Attempts: int(r.Attempts), - Escalated: r.Escalated != 0, - FirstAttemptAt: first, - ProjectID: r.ProjectID, - }) - } - return out, nil -} - -// SaveReactionTracker durably persists one escalation budget (insert or update). -func (s *Store) SaveReactionTracker(ctx context.Context, r ReactionTrackerRow) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - escalated := int64(0) - if r.Escalated { - escalated = 1 - } - first := sql.NullTime{} - if !r.FirstAttemptAt.IsZero() { - first = sql.NullTime{Time: r.FirstAttemptAt, Valid: true} - } - return s.q.UpsertReactionTracker(ctx, gen.UpsertReactionTrackerParams{ - SessionID: r.SessionID, - ReactionKey: r.ReactionKey, - Attempts: int64(r.Attempts), - Escalated: escalated, - FirstAttemptAt: first, - ProjectID: r.ProjectID, - }) -} - -// DeleteReactionTracker drops one escalation budget. -func (s *Store) DeleteReactionTracker(ctx context.Context, sessionID, reactionKey string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeleteReactionTracker(ctx, gen.DeleteReactionTrackerParams{ - SessionID: sessionID, - ReactionKey: reactionKey, - }) -} - -// DeleteSessionReactionTrackers drops every escalation budget for a session. -func (s *Store) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.q.DeleteSessionReactionTrackers(ctx, sessionID) -} diff --git a/backend/internal/storage/sqlite/spike_test.go b/backend/internal/storage/sqlite/spike_test.go deleted file mode 100644 index 30b43fc7c..000000000 --- a/backend/internal/storage/sqlite/spike_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package sqlite - -import ( - "context" - "testing" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// TestSpikeOutboxTxn de-risks the whole adapter: it proves the sqlc-generated -// Querier composes inside one *sql.Tx and that the change_log seq returned -// mid-transaction threads into the outbox row — the transactional-outbox shape -// the publisher later drains. Step 0 of the implementation plan. -func TestSpikeOutboxTxn(t *testing.T) { - db, err := Open(t.TempDir()) - if err != nil { - t.Fatalf("open: %v", err) - } - defer db.Close() - - ctx := context.Background() - now := time.Now().UTC() - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("begin: %v", err) - } - defer tx.Rollback() - - q := gen.New(db).WithTx(tx) - - // 1. CAS insert of a brand-new session (revision 0 -> persisted 1). - rows, err := q.InsertSession(ctx, gen.InsertSessionParams{ - ID: "s1", - ProjectID: "p1", - Kind: "worker", - CreatedAt: now, - UpdatedAt: now, - SessionState: "working", - SessionReason: "spawn_requested", - PrState: "none", - PrReason: "not_created", - RuntimeState: "unknown", - RuntimeReason: "spawn_incomplete", - ActivityState: "active", - ActivityLastAt: now, - ActivitySource: "none", - }) - if err != nil { - t.Fatalf("insert session: %v", err) - } - if rows != 1 { - t.Fatalf("insert session affected %d rows, want 1", rows) - } - - // 2. Append the change_log entry and capture its seq mid-transaction. - seq, err := q.InsertChangeLog(ctx, gen.InsertChangeLogParams{ - SessionID: "s1", - EventType: "session_created", - Revision: 1, - Payload: `{"id":"s1"}`, - CreatedAt: now, - }) - if err != nil { - t.Fatalf("insert change_log: %v", err) - } - if seq != 1 { - t.Fatalf("change_log seq = %d, want 1", seq) - } - - // 3. Thread the seq into the outbox row — the key thing the spike validates. - if err := q.InsertOutbox(ctx, gen.InsertOutboxParams{ChangeLogSeq: seq, CreatedAt: now}); err != nil { - t.Fatalf("insert outbox: %v", err) - } - - if err := tx.Commit(); err != nil { - t.Fatalf("commit: %v", err) - } - - // Verify the outbox row is visible, unsent, and linked to change_log seq 1. - unsent, err := gen.New(db).ListUnsentOutbox(ctx, 10) - if err != nil { - t.Fatalf("list unsent: %v", err) - } - if len(unsent) != 1 { - t.Fatalf("unsent outbox = %d rows, want 1", len(unsent)) - } - if unsent[0].ChangeLogSeq != 1 || unsent[0].SessionID != "s1" || unsent[0].EventType != "session_created" { - t.Fatalf("unexpected outbox row: %+v", unsent[0]) - } -} diff --git a/backend/internal/storage/sqlite/store.go b/backend/internal/storage/sqlite/store.go index 2effeaee8..800c18240 100644 --- a/backend/internal/storage/sqlite/store.go +++ b/backend/internal/storage/sqlite/store.go @@ -6,48 +6,77 @@ import ( "errors" "fmt" "sync" - "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" ) -// Store is the SQLite-backed ports.LifecycleStore. Reads (Load/Get/List/...) run -// concurrently across the connection pool; every write is funnelled through -// writeMu so there is exactly one writer at a time. That single-writer guarantee -// is load-bearing: it keeps WAL's single-writer rule and makes the revision-CAS -// (read-then-write in Upsert) atomic without depending on the pool size. Hold -// writeMu only around writes — never around a read — and never call one -// write method from inside another (the mutex is not reentrant). +// Store is the SQLite-backed persistence layer. It routes writes to a single +// writer connection (qw) and reads to a reader pool (qr) — see Open. writeMu +// guards the read-modify-write write methods (e.g. CreateSession's +// next-num-then-insert) so concurrent writes can't interleave them. +// +// CDC is captured by DB triggers (migration 0001), NOT by this layer: the store +// never writes change_log, it only reads it for the CDC poller. type Store struct { - db *sql.DB - q *gen.Queries + writeDB *sql.DB + readDB *sql.DB + qw *gen.Queries // bound to the single writer connection + qr *gen.Queries // bound to the reader pool writeMu sync.Mutex } -var _ ports.LifecycleStore = (*Store)(nil) - -// NewStore wraps an opened *sql.DB (see Open) as a LifecycleStore. -func NewStore(db *sql.DB) *Store { - return &Store{db: db, q: gen.New(db)} +// NewStore wraps an opened writer + reader *sql.DB (see Open) as a Store. +func NewStore(writeDB, readDB *sql.DB) *Store { + return &Store{ + writeDB: writeDB, + readDB: readDB, + qw: gen.New(writeDB), + qr: gen.New(readDB), + } } -// Load returns the canonical lifecycle for a session, or ok=false if absent. -func (s *Store) Load(ctx context.Context, id domain.SessionID) (domain.CanonicalSessionLifecycle, bool, error) { - row, err := s.q.GetSession(ctx, string(id)) - if errors.Is(err, sql.ErrNoRows) { - return domain.CanonicalSessionLifecycle{}, false, nil +// Close closes both pools. +func (s *Store) Close() error { + err := s.writeDB.Close() + if e := s.readDB.Close(); e != nil && err == nil { + err = e } + return err +} + +// ---- sessions ---- + +// CreateSession assigns the per-project identity ("{project}-{num}") and inserts +// the record, returning it with ID populated. The next-num read and the insert +// run on the writer connection under writeMu, so two concurrent creates in the +// same project can't collide on num. +func (s *Store) CreateSession(ctx context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + + num, err := s.qw.NextSessionNum(ctx, string(rec.ProjectID)) if err != nil { - return domain.CanonicalSessionLifecycle{}, false, fmt.Errorf("load session %s: %w", id, err) + return domain.SessionRecord{}, fmt.Errorf("next session num for %s: %w", rec.ProjectID, err) } - return rowToLifecycle(row), true, nil + rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, num)) + if err := s.qw.InsertSession(ctx, recordToInsert(rec, num)); err != nil { + return domain.SessionRecord{}, fmt.Errorf("insert session %s: %w", rec.ID, err) + } + return rec, nil } -// Get returns the full record (no derived status) for a session. -func (s *Store) Get(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { - row, err := s.q.GetSession(ctx, string(id)) +// UpdateSession writes the full mutable state of an existing session. The +// id/project/num/created_at are immutable and not touched here. +func (s *Store) UpdateSession(ctx context.Context, rec domain.SessionRecord) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.qw.UpdateSession(ctx, recordToUpdate(rec)) +} + +// GetSession returns the full record for a session, or ok=false if absent. +func (s *Store) GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) { + row, err := s.qr.GetSession(ctx, string(id)) if errors.Is(err, sql.ErrNoRows) { return domain.SessionRecord{}, false, nil } @@ -57,71 +86,49 @@ func (s *Store) Get(ctx context.Context, id domain.SessionID) (domain.SessionRec return rowToRecord(row), true, nil } -// List returns every record for a project (no archive filter — mirrors the -// in-memory store contract; terminal filtering is the caller's job). -func (s *Store) List(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { - rows, err := s.q.ListSessionsByProject(ctx, string(project)) +// ListSessions returns every session in a project, ordered by num. +func (s *Store) ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) { + rows, err := s.qr.ListSessionsByProject(ctx, string(project)) if err != nil { return nil, fmt.Errorf("list sessions for %s: %w", project, err) } - out := make([]domain.SessionRecord, 0, len(rows)) - for _, row := range rows { - out = append(out, rowToRecord(row)) - } - return out, nil + return mapSessionRows(rows), nil } -// ListAll returns every persisted session across all projects. The CDC snapshot -// source uses it to rebuild current state after a log-rotation gap. -func (s *Store) ListAll(ctx context.Context) ([]domain.SessionRecord, error) { - rows, err := s.q.ListAllSessions(ctx) +// ListAllSessions returns every session across all projects. +func (s *Store) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) { + rows, err := s.qr.ListAllSessions(ctx) if err != nil { return nil, fmt.Errorf("list all sessions: %w", err) } - out := make([]domain.SessionRecord, 0, len(rows)) - for _, row := range rows { - out = append(out, rowToRecord(row)) - } - return out, nil + return mapSessionRows(rows), nil } -// GetMetadata returns the typed metadata for a session, or the zero value if the -// session has no metadata row yet. -func (s *Store) GetMetadata(ctx context.Context, id domain.SessionID) (domain.SessionMetadata, error) { - row, err := s.q.GetSessionMetadata(ctx, string(id)) - if errors.Is(err, sql.ErrNoRows) { - return domain.SessionMetadata{}, nil - } - if err != nil { - return domain.SessionMetadata{}, fmt.Errorf("get metadata %s: %w", id, err) - } - return domain.SessionMetadata{ - Branch: row.Branch, - WorkspacePath: row.WorkspacePath, - RuntimeHandleID: row.RuntimeHandleID, - RuntimeName: row.RuntimeName, - AgentSessionID: row.AgentSessionID, - Prompt: row.Prompt, - }, nil -} - -// PatchMetadata merges meta into the session's metadata. It is outside the -// canonical write path: no revision bump, no CDC event. Empty fields are left -// unchanged (see UpsertSessionMetadata), so a partial patch is non-destructive. -func (s *Store) PatchMetadata(ctx context.Context, id domain.SessionID, meta domain.SessionMetadata) error { - if meta.IsZero() { - return nil - } +// DeleteSession removes a session (cascades to its pr/checks/comments). +func (s *Store) DeleteSession(ctx context.Context, id domain.SessionID) error { s.writeMu.Lock() defer s.writeMu.Unlock() - return s.q.UpsertSessionMetadata(ctx, gen.UpsertSessionMetadataParams{ - SessionID: string(id), - Branch: meta.Branch, - WorkspacePath: meta.WorkspacePath, - RuntimeHandleID: meta.RuntimeHandleID, - RuntimeName: meta.RuntimeName, - AgentSessionID: meta.AgentSessionID, - Prompt: meta.Prompt, - UpdatedAt: time.Now().UTC(), - }) + return s.qw.DeleteSession(ctx, string(id)) +} + +func mapSessionRows(rows []gen.Session) []domain.SessionRecord { + out := make([]domain.SessionRecord, 0, len(rows)) + for _, r := range rows { + out = append(out, rowToRecord(r)) + } + return out +} + +// inTx runs fn inside a single write transaction on the writer connection, +// rolling back on error. The caller must already hold writeMu. +func (s *Store) inTx(ctx context.Context, what string, fn func(*gen.Queries) error) error { + tx, err := s.writeDB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin %s: %w", what, err) + } + defer tx.Rollback() + if err := fn(s.qw.WithTx(tx)); err != nil { + return fmt.Errorf("%s: %w", what, err) + } + return tx.Commit() } diff --git a/backend/internal/storage/sqlite/store_test.go b/backend/internal/storage/sqlite/store_test.go index a197f3af1..55165c41f 100644 --- a/backend/internal/storage/sqlite/store_test.go +++ b/backend/internal/storage/sqlite/store_test.go @@ -3,306 +3,314 @@ package sqlite import ( "context" "fmt" - "strings" "sync" "testing" "time" "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" ) func newTestStore(t *testing.T) *Store { t.Helper() - db, err := Open(t.TempDir()) + s, err := Open(t.TempDir()) if err != nil { t.Fatalf("open: %v", err) } - t.Cleanup(func() { db.Close() }) - return NewStore(db) + t.Cleanup(func() { _ = s.Close() }) + return s } -func sampleRecord(id string) domain.SessionRecord { +func seedProject(t *testing.T, s *Store, id string) { + t.Helper() + if err := s.UpsertProject(context.Background(), ProjectRow{ + ID: id, Path: "/tmp/" + id, RegisteredAt: time.Now().UTC().Truncate(time.Second), + }); err != nil { + t.Fatalf("seed project %s: %v", id, err) + } +} + +func sampleRecord(project string) domain.SessionRecord { now := time.Now().UTC().Truncate(time.Second) return domain.SessionRecord{ - ID: domain.SessionID(id), - ProjectID: "proj", - IssueID: "issue-1", + ProjectID: domain.ProjectID(project), Kind: domain.KindWorker, + Lifecycle: domain.CanonicalSessionLifecycle{ + Version: domain.LifecycleVersion, + Harness: domain.HarnessClaudeCode, + IsAlive: true, + Session: domain.SessionSubstate{State: domain.SessionWorking}, + Activity: domain.ActivitySubstate{ + State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative, + }, + }, + Metadata: domain.SessionMetadata{Branch: "feat/x", WorkspacePath: "/ws"}, CreatedAt: now, UpdatedAt: now, - Lifecycle: domain.CanonicalSessionLifecycle{ - Session: domain.SessionSubstate{State: domain.SessionWorking, Reason: domain.ReasonTaskInProgress}, - PR: domain.PRSubstate{State: domain.PRNone, Reason: domain.PRReasonNotCreated}, - Runtime: domain.RuntimeSubstate{State: domain.RuntimeAlive, Reason: domain.RuntimeReasonProcessRunning}, - Activity: domain.ActivitySubstate{State: domain.ActivityActive, LastActivityAt: now, Source: domain.SourceNative}, - }, } } -func TestUpsertInsertThenUpdateBumpsRevision(t *testing.T) { +func TestProjectCRUDAndArchive(t *testing.T) { s := newTestStore(t) ctx := context.Background() - rec := sampleRecord("s1") + seedProject(t, s, "mer") - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatalf("insert: %v", err) - } - lc, ok, err := s.Load(ctx, "s1") + got, ok, err := s.GetProject(ctx, "mer") if err != nil || !ok { - t.Fatalf("load after insert: ok=%v err=%v", ok, err) + t.Fatalf("get: ok=%v err=%v", ok, err) } - if lc.Revision != 1 { - t.Fatalf("revision after insert = %d, want 1", lc.Revision) + if got.ID != "mer" || got.Path != "/tmp/mer" { + t.Fatalf("project = %+v", got) } - - // Update must carry the loaded revision (1) and persist as 2. - rec.Lifecycle.Revision = 1 - rec.Lifecycle.Session.State = domain.SessionIdle - if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { - t.Fatalf("update: %v", err) + if list, _ := s.ListProjects(ctx); len(list) != 1 { + t.Fatalf("active list = %d, want 1", len(list)) } - lc, _, _ = s.Load(ctx, "s1") - if lc.Revision != 2 { - t.Fatalf("revision after update = %d, want 2", lc.Revision) + // archive hides from the active list but still resolves by id. + if err := s.ArchiveProject(ctx, "mer", time.Now().UTC()); err != nil { + t.Fatal(err) } - if lc.Session.State != domain.SessionIdle { - t.Fatalf("state after update = %q, want idle", lc.Session.State) + if list, _ := s.ListProjects(ctx); len(list) != 0 { + t.Fatalf("after archive, active list = %d, want 0", len(list)) + } + if _, ok, _ := s.GetProject(ctx, "mer"); !ok { + t.Fatal("archived project must still resolve by id") } } -func TestUpsertStaleRevisionMismatch(t *testing.T) { +func TestSessionCreateAssignsPerProjectID(t *testing.T) { s := newTestStore(t) ctx := context.Background() - rec := sampleRecord("s1") - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatalf("insert: %v", err) - } + seedProject(t, s, "mer") + seedProject(t, s, "ao") - // Stored revision is 1; submitting revision 0 (stale) must mismatch and - // write nothing new (no extra outbox/change_log rows). - rec.Lifecycle.Revision = 0 - err := s.Upsert(ctx, rec, ports.EventSessionStateChanged) - if err == nil || !strings.Contains(err.Error(), "revision mismatch") { - t.Fatalf("stale update err = %v, want revision mismatch", err) - } - assertOutboxCount(t, s, ctx, 1) -} - -func TestUpsertInsertNonZeroRevisionErrors(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - rec := sampleRecord("s1") - rec.Lifecycle.Revision = 5 - err := s.Upsert(ctx, rec, ports.EventSessionCreated) - if err == nil || !strings.Contains(err.Error(), "revision mismatch") { - t.Fatalf("insert with revision 5 err = %v, want revision mismatch", err) - } - // Nothing should be persisted. - if _, ok, _ := s.Get(ctx, "s1"); ok { - t.Fatal("session persisted despite revision-mismatch insert") - } - assertOutboxCount(t, s, ctx, 0) -} - -func TestUpsertOutboxAtomicityAndOrdering(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - rec := sampleRecord("s1") - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatalf("insert: %v", err) - } - rec.Lifecycle.Revision = 1 - if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { - t.Fatalf("update: %v", err) - } - - rows, err := NewStore(s.db).q.ListUnsentOutbox(ctx, 100) + r1, err := s.CreateSession(ctx, sampleRecord("mer")) if err != nil { - t.Fatalf("list outbox: %v", err) - } - if len(rows) != 2 { - t.Fatalf("outbox rows = %d, want 2", len(rows)) - } - // seq strictly monotonic, event types verbatim, revisions 1 then 2. - if rows[0].ChangeLogSeq != 1 || rows[1].ChangeLogSeq != 2 { - t.Fatalf("seq not monotonic: %d, %d", rows[0].ChangeLogSeq, rows[1].ChangeLogSeq) - } - if rows[0].EventType != string(ports.EventSessionCreated) || rows[1].EventType != string(ports.EventSessionStateChanged) { - t.Fatalf("event types = %q, %q", rows[0].EventType, rows[1].EventType) - } - if rows[0].Revision != 1 || rows[1].Revision != 2 { - t.Fatalf("revisions = %d, %d, want 1, 2", rows[0].Revision, rows[1].Revision) - } -} - -func TestGetListRoundTrip(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - a := sampleRecord("a") - b := sampleRecord("b") - b.ProjectID = "other" - if err := s.Upsert(ctx, a, ports.EventSessionCreated); err != nil { t.Fatal(err) } - if err := s.Upsert(ctx, b, ports.EventSessionCreated); err != nil { - t.Fatal(err) + r2, _ := s.CreateSession(ctx, sampleRecord("mer")) + r3, _ := s.CreateSession(ctx, sampleRecord("ao")) + if r1.ID != "mer-1" || r2.ID != "mer-2" || r3.ID != "ao-1" { + t.Fatalf("ids = %s, %s, %s; want mer-1, mer-2, ao-1", r1.ID, r2.ID, r3.ID) } - - got, ok, err := s.Get(ctx, "a") + got, ok, err := s.GetSession(ctx, "mer-1") if err != nil || !ok { - t.Fatalf("get a: ok=%v err=%v", ok, err) + t.Fatalf("get: ok=%v err=%v", ok, err) } - if got.ID != "a" || got.Lifecycle.Revision != 1 || got.IssueID != "issue-1" { - t.Fatalf("unexpected record: %+v", got) + if got.Lifecycle.Session.State != domain.SessionWorking || !got.Lifecycle.IsAlive || + got.Lifecycle.Harness != domain.HarnessClaudeCode || got.Metadata.Branch != "feat/x" { + t.Fatalf("round-trip mismatch: %+v", got) } - if !got.Metadata.IsZero() { - t.Fatalf("Get must not reconstruct metadata, got %v", got.Metadata) + if list, _ := s.ListSessions(ctx, "mer"); len(list) != 2 { + t.Fatalf("list mer = %d, want 2", len(list)) } + if all, _ := s.ListAllSessions(ctx); len(all) != 3 { + t.Fatalf("list all = %d, want 3", len(all)) + } +} - list, err := s.List(ctx, "proj") +func TestSessionUpdateAndDetecting(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + + r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionDetecting} + r.Lifecycle.IsAlive = false + r.Lifecycle.Detecting = &domain.DetectingState{Attempts: 2, StartedAt: r.CreatedAt, EvidenceHash: "abc"} + if err := s.UpdateSession(ctx, r); err != nil { + t.Fatal(err) + } + got, _, _ := s.GetSession(ctx, r.ID) + if got.Lifecycle.Session.State != domain.SessionDetecting || got.Lifecycle.IsAlive { + t.Fatalf("update not persisted: %+v", got.Lifecycle.Session) + } + if got.Lifecycle.Detecting == nil || got.Lifecycle.Detecting.Attempts != 2 || got.Lifecycle.Detecting.EvidenceHash != "abc" { + t.Fatalf("detecting not round-tripped: %+v", got.Lifecycle.Detecting) + } + // clearing detecting persists as nil. + got.Lifecycle.Detecting = nil + got.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionWorking} + _ = s.UpdateSession(ctx, got) + again, _, _ := s.GetSession(ctx, r.ID) + if again.Lifecycle.Detecting != nil { + t.Fatalf("detecting should clear to nil, got %+v", again.Lifecycle.Detecting) + } +} + +func TestPRCRUD(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) + + pr := PRRow{ + URL: "https://gh/pr/1", SessionID: string(r.ID), Number: 1, State: "open", + ReviewDecision: "review_required", CIState: "failing", Mergeability: "blocked", UpdatedAt: now, + } + if err := s.UpsertPR(ctx, pr); err != nil { + t.Fatal(err) + } + got, ok, err := s.GetPR(ctx, pr.URL) + if err != nil || !ok || got != pr { + t.Fatalf("get pr: ok=%v err=%v got=%+v", ok, err, got) + } + if list, _ := s.ListPRsBySession(ctx, string(r.ID)); len(list) != 1 { + t.Fatalf("list prs = %d, want 1", len(list)) + } + if err := s.DeletePR(ctx, pr.URL); err != nil { + t.Fatal(err) + } + if _, ok, _ := s.GetPR(ctx, pr.URL); ok { + t.Fatal("pr should be gone") + } +} + +func TestPRChecksLoopBrakeQuery(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) + _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: now}) + + // three consecutive failing runs of "build" (one per commit). + for i := 1; i <= 3; i++ { + if err := s.RecordCheck(ctx, PRCheckRow{ + PRURL: "pr1", Name: "build", CommitHash: fmt.Sprintf("c%d", i), + Status: "failed", CreatedAt: now.Add(time.Duration(i) * time.Second), + }); err != nil { + t.Fatal(err) + } + } + last3, err := s.RecentCheckStatuses(ctx, "pr1", "build", 3) if err != nil { t.Fatal(err) } - if len(list) != 1 || list[0].ID != "a" { - t.Fatalf("List(proj) = %+v, want only a", list) + if len(last3) != 3 || last3[0] != "failed" || last3[1] != "failed" || last3[2] != "failed" { + t.Fatalf("recent statuses = %v, want 3x failed (loop brake would trip)", last3) + } + // a pass on a newer commit breaks the streak. + _ = s.RecordCheck(ctx, PRCheckRow{PRURL: "pr1", Name: "build", CommitHash: "c4", Status: "passed", CreatedAt: now.Add(4 * time.Second)}) + last3, _ = s.RecentCheckStatuses(ctx, "pr1", "build", 3) + if last3[0] != "passed" { + t.Fatalf("most recent should be passed, got %v", last3) } } -func TestMetadataSideChannel(t *testing.T) { +func TestPRCommentsReplace(t *testing.T) { s := newTestStore(t) ctx := context.Background() - if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { - t.Fatal(err) - } + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + now := time.Now().UTC().Truncate(time.Second) + _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: now}) - if err := s.PatchMetadata(ctx, "s1", domain.SessionMetadata{Branch: "feat/x", Prompt: "do it"}); err != nil { - t.Fatalf("patch: %v", err) + _ = s.ReplacePRComments(ctx, "pr1", []PRCommentRow{ + {PRURL: "pr1", CommentID: "c1", Author: "a", File: "a.go", Line: 1, Body: "nit", CreatedAt: now}, + {PRURL: "pr1", CommentID: "c2", Author: "b", File: "b.go", Line: 2, Body: "bug", Resolved: true, CreatedAt: now.Add(time.Second)}, + }) + if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 2 { + t.Fatalf("comments = %d, want 2", len(list)) } - // A partial patch (only Branch) must not clobber the earlier Prompt. - if err := s.PatchMetadata(ctx, "s1", domain.SessionMetadata{Branch: "feat/y"}); err != nil { - t.Fatalf("patch overwrite: %v", err) + // replace with a smaller set drops the rest. + _ = s.ReplacePRComments(ctx, "pr1", []PRCommentRow{{PRURL: "pr1", CommentID: "c1", Body: "x", CreatedAt: now}}) + if list, _ := s.ListPRComments(ctx, "pr1"); len(list) != 1 { + t.Fatalf("after replace, comments = %d, want 1", len(list)) } +} - m, err := s.GetMetadata(ctx, "s1") +func TestCDCTriggersPopulateChangeLog(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + // a real state change logs; a metadata-only change does not (WHEN guard). + r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionIdle} + _ = s.UpdateSession(ctx, r) + r.Metadata.Prompt = "only metadata changed" + _ = s.UpdateSession(ctx, r) + // a PR insert logs too. + _ = s.UpsertPR(ctx, PRRow{URL: "pr1", SessionID: string(r.ID), State: "open", UpdatedAt: r.UpdatedAt}) + + evs, err := s.ReadChangeLogAfter(ctx, 0, 100) if err != nil { t.Fatal(err) } - if m.Branch != "feat/y" || m.Prompt != "do it" { - t.Fatalf("metadata = %+v", m) + var types []string + for _, e := range evs { + if e.ProjectID != "mer" { + t.Fatalf("event project = %s, want mer", e.ProjectID) + } + types = append(types, e.EventType) } - // Metadata writes must not bump revision (off the canonical path). - lc, _, _ := s.Load(ctx, "s1") - if lc.Revision != 1 { - t.Fatalf("revision = %d after metadata patch, want 1 (no bump)", lc.Revision) + want := []string{"session_created", "session_updated", "pr_created"} + if len(types) != 3 || types[0] != want[0] || types[1] != want[1] || types[2] != want[2] { + t.Fatalf("change_log event types = %v, want %v (metadata-only update suppressed)", types, want) + } + max, _ := s.MaxChangeLogSeq(ctx) + if max != int64(len(evs)) { + t.Fatalf("max seq = %d, want %d", max, len(evs)) } } -func TestDetectingRoundTrip(t *testing.T) { +func TestConcurrentSessionCreateAssignsUniqueNums(t *testing.T) { s := newTestStore(t) ctx := context.Background() - rec := sampleRecord("s1") - rec.Lifecycle.Session.State = domain.SessionDetecting - rec.Lifecycle.Detecting = &domain.DetectingState{ - Attempts: 2, - StartedAt: time.Now().UTC().Truncate(time.Second), - EvidenceHash: "abc123", - } - if err := s.Upsert(ctx, rec, ports.EventSessionCreated); err != nil { - t.Fatal(err) - } - lc, _, _ := s.Load(ctx, "s1") - if lc.Detecting == nil { - t.Fatal("Detecting lost on round-trip") - } - if lc.Detecting.Attempts != 2 || lc.Detecting.EvidenceHash != "abc123" { - t.Fatalf("detecting = %+v", lc.Detecting) - } - - // Clearing Detecting must null the columns back out. - rec.Lifecycle.Revision = 1 - rec.Lifecycle.Detecting = nil - if err := s.Upsert(ctx, rec, ports.EventSessionStateChanged); err != nil { - t.Fatal(err) - } - lc, _, _ = s.Load(ctx, "s1") - if lc.Detecting != nil { - t.Fatalf("Detecting not cleared: %+v", lc.Detecting) - } -} - -func TestLoadGetMissing(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - if _, ok, err := s.Load(ctx, "nope"); ok || err != nil { - t.Fatalf("Load missing: ok=%v err=%v", ok, err) - } - if _, ok, err := s.Get(ctx, "nope"); ok || err != nil { - t.Fatalf("Get missing: ok=%v err=%v", ok, err) - } - if m, err := s.GetMetadata(ctx, "nope"); err != nil || !m.IsZero() { - t.Fatalf("GetMetadata missing: m=%v err=%v", m, err) - } -} - -func assertOutboxCount(t *testing.T, s *Store, ctx context.Context, want int) { - t.Helper() - rows, err := s.q.ListUnsentOutbox(ctx, 1000) - if err != nil { - t.Fatalf("list outbox: %v", err) - } - if len(rows) != want { - t.Fatalf("outbox count = %d, want %d", len(rows), want) - } -} - -// TestConcurrentReadsAndWrites exercises the read-pool + write-mutex model: -// many writers (each its own session) run alongside many readers hammering -// ListAll. Reads must not be serialized behind writes, writes must not corrupt -// or error under the revision-CAS, and the final state must be exact. Run under -// -race this also guards the writeMu discipline. -func TestConcurrentReadsAndWrites(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - const n = 16 + seedProject(t, s, "mer") + const n = 20 var wg sync.WaitGroup - errc := make(chan error, n*2) - + ids := make([]string, n) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() - if err := s.Upsert(ctx, sampleRecord(fmt.Sprintf("s%02d", i)), ports.EventSessionCreated); err != nil { - errc <- err + r, err := s.CreateSession(ctx, sampleRecord("mer")) + if err != nil { + t.Errorf("create: %v", err) + return } + ids[i] = string(r.ID) }(i) } - for i := 0; i < n; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 25; j++ { - if _, err := s.ListAll(ctx); err != nil { - errc <- err - return - } - } - }() - } wg.Wait() - close(errc) - for err := range errc { - t.Fatalf("concurrent op error: %v", err) - } - got, err := s.ListAll(ctx) - if err != nil { - t.Fatal(err) + seen := map[string]bool{} + for _, id := range ids { + if id == "" || seen[id] { + t.Fatalf("duplicate or empty id: %q in %v", id, ids) + } + seen[id] = true } - if len(got) != n { - t.Fatalf("after %d concurrent inserts, ListAll returned %d", n, len(got)) + if all, _ := s.ListAllSessions(ctx); len(all) != n { + t.Fatalf("created %d sessions, want %d", len(all), n) + } +} + +func TestTerminationReasonRoundTripAndCheck(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + + // terminate with a valid reason -> round-trips. + r.Lifecycle.Session = domain.SessionSubstate{State: domain.SessionTerminated} + r.Lifecycle.TerminationReason = domain.TermManuallyKilled + if err := s.UpdateSession(ctx, r); err != nil { + t.Fatal(err) + } + got, _, _ := s.GetSession(ctx, r.ID) + if got.Lifecycle.TerminationReason != domain.TermManuallyKilled { + t.Fatalf("termination_reason = %q, want manually_killed", got.Lifecycle.TerminationReason) + } + if domain.DeriveStatus(got.Lifecycle, domain.PRFacts{}) != domain.StatusKilled { + t.Fatal("terminated+manually_killed should derive to killed") + } + + // an off-enum reason is rejected by the CHECK constraint. + r.Lifecycle.TerminationReason = domain.TerminationReason("definitely_not_a_reason") + if err := s.UpdateSession(ctx, r); err == nil { + t.Fatal("expected CHECK constraint to reject an invalid termination_reason") } } diff --git a/backend/internal/storage/sqlite/upsert.go b/backend/internal/storage/sqlite/upsert.go deleted file mode 100644 index f8ae40932..000000000 --- a/backend/internal/storage/sqlite/upsert.go +++ /dev/null @@ -1,115 +0,0 @@ -package sqlite - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/aoagents/agent-orchestrator/backend/internal/domain" - "github.com/aoagents/agent-orchestrator/backend/internal/ports" - "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" -) - -// Upsert performs the one atomic canonical write: it CAS-checks and persists the -// session row (bumping revision), appends a change_log entry, and enqueues an -// outbox row linked to that entry's seq — all in a single transaction. Only the -// LCM calls this. -// -// Revision CAS (mirrors the in-memory store contract exactly): -// - existing row: rec.Lifecycle.Revision must equal the stored revision, else -// a revision-mismatch error and nothing is written; on match it persists at -// stored+1. -// - insert: rec.Lifecycle.Revision must be 0, persisted as 1. -func (s *Store) Upsert(ctx context.Context, rec domain.SessionRecord, eventType ports.EventType) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin upsert: %w", err) - } - defer tx.Rollback() - qtx := s.q.WithTx(tx) - - newRevision, err := casPersist(ctx, qtx, rec) - if err != nil { - return err - } - - if err := appendOutbox(ctx, qtx, rec, newRevision, eventType); err != nil { - return err - } - - return tx.Commit() -} - -// casPersist applies the revision-CAS insert-or-update and returns the new -// stored revision. -func casPersist(ctx context.Context, q *gen.Queries, rec domain.SessionRecord) (int, error) { - stored, err := q.GetSessionRevision(ctx, string(rec.ID)) - switch { - case errors.Is(err, sql.ErrNoRows): - // Insert path: incoming revision must be 0; row persists at revision 1. - if rec.Lifecycle.Revision != 0 { - return 0, fmt.Errorf("revision mismatch for insert %s: have %d, want 0", rec.ID, rec.Lifecycle.Revision) - } - rows, err := q.InsertSession(ctx, recordToInsert(rec)) - if err != nil { - return 0, fmt.Errorf("insert session %s: %w", rec.ID, err) - } - if rows != 1 { - // Another writer raced us between the revision check and the insert. - // With single-writer this should not happen; treat as a CAS failure. - return 0, fmt.Errorf("revision mismatch for insert %s: row already exists", rec.ID) - } - return 1, nil - case err != nil: - return 0, fmt.Errorf("read revision %s: %w", rec.ID, err) - default: - // Update path: incoming revision must equal the stored revision. - if int64(rec.Lifecycle.Revision) != stored { - return 0, fmt.Errorf("revision mismatch for %s: have %d, want %d", rec.ID, rec.Lifecycle.Revision, stored) - } - rows, err := q.UpdateSessionCAS(ctx, recordToUpdate(rec, stored)) - if err != nil { - return 0, fmt.Errorf("update session %s: %w", rec.ID, err) - } - if rows != 1 { - return 0, fmt.Errorf("revision mismatch for %s: stale revision %d", rec.ID, rec.Lifecycle.Revision) - } - return int(stored) + 1, nil - } -} - -// appendOutbox writes the change_log entry and threads its seq into a fresh -// outbox row. The change_log payload is the persisted record at its new revision -// (metadata is excluded by SessionRecord's json:"-" tag — it is not on the -// canonical path). -func appendOutbox(ctx context.Context, q *gen.Queries, rec domain.SessionRecord, newRevision int, eventType ports.EventType) error { - now := time.Now().UTC() - payload := rec - payload.Lifecycle.Revision = newRevision - payload.Lifecycle.Version = domain.LifecycleVersion - blob, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal change_log payload %s: %w", rec.ID, err) - } - - seq, err := q.InsertChangeLog(ctx, gen.InsertChangeLogParams{ - SessionID: string(rec.ID), - EventType: string(eventType), - Revision: int64(newRevision), - Payload: string(blob), - CreatedAt: now, - }) - if err != nil { - return fmt.Errorf("insert change_log %s: %w", rec.ID, err) - } - - if err := q.InsertOutbox(ctx, gen.InsertOutboxParams{ChangeLogSeq: seq, CreatedAt: now}); err != nil { - return fmt.Errorf("insert outbox %s: %w", rec.ID, err) - } - return nil -}