feat(storage,cdc): minimal 6-table schema + trigger-driven CDC (storage layer)

Reworks the storage + CDC layer to the simplified design agreed in review:

Schema (one clean migration, 0001): projects, sessions, pr, pr_checks,
pr_comment, change_log. sessions.id is a single string key "{project}-{num}"
(mer-1); operational metadata folded into sessions; is_alive replaces the
runtime axis; no revision (the per-session write mutex serializes, change_log.seq
orders). pr keyed by URL (1 session : many PRs). pr_checks is CI run history
(one row per check per commit) — the CI-fix-loop brake is a LIMIT 3 query, no
counter stored. change_log carries a required project_id FK + nullable session_id.

CDC is DB-native: AFTER INSERT/UPDATE triggers on sessions/pr/pr_checks append
to change_log atomically with the change (json_object payloads). The old durable
outbox/JSONL/janitor pipeline is gone; the cdc package is now a Poller that reads
change_log and fans events out through the in-memory Broadcaster (hardened with
recover()). Clients catch up via the log from their own offset (SSE Last-Event-ID).

Storage uses a single writer connection + a reader pool (read-your-writes for the
triggers' subqueries; concurrent reads). sqlc-generated typed queries.

Tests (-race): CRUD, per-project id assignment, the loop-brake query, concurrent
creates, triggers populating change_log; CDC end-to-end through the real store,
concurrent goroutine delivery, broadcaster panic-isolation.

NOTE: scoped to storage + CDC. The lifecycle-engine consumers (decide, lifecycle,
session, reaper, main wiring) still reference the old domain axes and need a
follow-up integration pass to compile against the new model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
prateek 2026-05-31 05:04:31 +05:30
parent ba47212802
commit e5c4fd6ffd
49 changed files with 2229 additions and 4198 deletions

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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"
}

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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 "<condition> #<num> <url>"
// 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,
})
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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) ----

View File

@ -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

View File

@ -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)
}
})
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
)

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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),
}
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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{}

View File

@ -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 < ?;

View File

@ -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;

View File

@ -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;

View File

@ -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 = ?;

View File

@ -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;

View File

@ -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;

View File

@ -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 = ?;

View File

@ -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 = ?;

View File

@ -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 = ?;

View File

@ -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)
}

View File

@ -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])
}
}

View File

@ -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()
}

View File

@ -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")
}
}

View File

@ -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
}