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:
parent
ba47212802
commit
e5c4fd6ffd
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ----
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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 < ?;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 = ?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 = ?;
|
||||
|
|
|
|||
|
|
@ -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 = ?;
|
||||
|
|
@ -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 = ?;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue