Skip to content

Commit ebc69a4

Browse files
committed
refactor: move session status assembly to service (#62)
1 parent c8f6050 commit ebc69a4

13 files changed

Lines changed: 310 additions & 179 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Rewrite of the agent-orchestrator: a long-running Go backend daemon (`backend/`)
44
paired with a placeholder Electron + TypeScript frontend shell (`frontend/`).
55

66
See [`docs/`](docs/README.md) for architecture and status — start with the
7-
Lifecycle Manager + Session Manager lane in [`docs/architecture.md`](docs/architecture.md).
7+
Lifecycle Manager + Session Service lane in [`docs/architecture.md`](docs/architecture.md).
88

99
## Backend daemon
1010

backend/internal/domain/status.go

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,52 +19,3 @@ const (
1919
StatusIdle SessionStatus = "idle"
2020
StatusTerminated SessionStatus = "terminated"
2121
)
22-
23-
// DeriveStatus is the ONLY producer of display status. It is a pure function of
24-
// persisted session facts and PR facts: is_terminated, activity_state, and the PR
25-
// table are the durable facts that tell the UI what it needs to know.
26-
func DeriveStatus(rec SessionRecord, pr *PRFacts) SessionStatus {
27-
if rec.IsTerminated {
28-
if pr != nil && pr.Merged {
29-
return StatusMerged
30-
}
31-
return StatusTerminated
32-
}
33-
34-
if rec.Activity.State == ActivityWaitingInput {
35-
return StatusNeedsInput
36-
}
37-
38-
if pr != nil {
39-
if pr.Merged {
40-
return StatusMerged
41-
}
42-
if !pr.Closed {
43-
return prPipelineStatus(*pr)
44-
}
45-
}
46-
47-
if rec.Activity.State == ActivityActive {
48-
return StatusWorking
49-
}
50-
return StatusIdle
51-
}
52-
53-
func prPipelineStatus(pr PRFacts) SessionStatus {
54-
switch {
55-
case pr.CI == CIFailing:
56-
return StatusCIFailed
57-
case pr.Draft:
58-
return StatusDraft
59-
case pr.Review == ReviewChangesRequest || pr.ReviewComments:
60-
return StatusChangesRequested
61-
case pr.Mergeability == MergeMergeable:
62-
return StatusMergeable
63-
case pr.Review == ReviewApproved:
64-
return StatusApproved
65-
case pr.Review == ReviewRequired:
66-
return StatusReviewPending
67-
default:
68-
return StatusPROpen
69-
}
70-
}

backend/internal/domain/status_test.go

Lines changed: 0 additions & 38 deletions
This file was deleted.

backend/internal/integration/lifecycle_sqlite_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import (
1111
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
1212
prsvc "github.com/aoagents/agent-orchestrator/backend/internal/pr"
1313
"github.com/aoagents/agent-orchestrator/backend/internal/project"
14-
"github.com/aoagents/agent-orchestrator/backend/internal/session"
14+
"github.com/aoagents/agent-orchestrator/backend/internal/service"
15+
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
1516
"github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite"
1617
)
1718

@@ -54,7 +55,7 @@ func (c *captureMessenger) Send(_ context.Context, _ domain.SessionID, msg strin
5455

5556
type stack struct {
5657
store *sqlite.Store
57-
sm *session.Manager
58+
sm *service.Session
5859
lcm *lifecycle.Manager
5960
prm *prsvc.Manager
6061
rt *stubRuntime
@@ -78,7 +79,8 @@ func newStack(t *testing.T) *stack {
7879
prm := prsvc.New(prsvc.Deps{Writer: store, Lifecycle: lcm})
7980
rt := &stubRuntime{}
8081
ws := &stubWorkspace{}
81-
sm := session.New(session.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
82+
mgr := sessionmanager.New(sessionmanager.Deps{Runtime: rt, Agent: stubAgent{}, Workspace: ws, Store: store, Messenger: msg, Lifecycle: lcm})
83+
sm := service.NewSession(mgr, store)
8284
return &stack{store: store, sm: sm, lcm: lcm, prm: prm, rt: rt, ws: ws, msg: msg}
8385
}
8486

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
8+
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
9+
sessionmanager "github.com/aoagents/agent-orchestrator/backend/internal/session_manager"
10+
)
11+
12+
// SessionStore is the persistence surface needed to assemble controller-facing session read models.
13+
type SessionStore interface {
14+
sessionmanager.Store
15+
GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error)
16+
}
17+
18+
// Session is the controller-facing session service. It delegates command-side
19+
// session operations to the internal sessionmanager.Manager and owns read-model
20+
// assembly, including user-facing display status derivation.
21+
type Session struct {
22+
manager *sessionmanager.Manager
23+
store SessionStore
24+
}
25+
26+
// NewSession wires a controller-facing session service over an internal session Manager.
27+
func NewSession(manager *sessionmanager.Manager, store SessionStore) *Session {
28+
return &Session{manager: manager, store: store}
29+
}
30+
31+
// Spawn creates a session and returns the API-facing read model.
32+
func (s *Session) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Session, error) {
33+
rec, err := s.manager.Spawn(ctx, cfg)
34+
if err != nil {
35+
return domain.Session{}, err
36+
}
37+
return s.toSession(ctx, rec)
38+
}
39+
40+
// Restore relaunches a terminated session and returns the API-facing read model.
41+
func (s *Session) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) {
42+
rec, err := s.manager.Restore(ctx, id)
43+
if err != nil {
44+
return domain.Session{}, err
45+
}
46+
return s.toSession(ctx, rec)
47+
}
48+
49+
// Kill delegates terminal intent and teardown to the internal manager.
50+
func (s *Session) Kill(ctx context.Context, id domain.SessionID) (bool, error) {
51+
return s.manager.Kill(ctx, id)
52+
}
53+
54+
// Send delegates agent messaging to the internal manager.
55+
func (s *Session) Send(ctx context.Context, id domain.SessionID, message string) error {
56+
return s.manager.Send(ctx, id, message)
57+
}
58+
59+
// Cleanup delegates terminal workspace cleanup to the internal manager.
60+
func (s *Session) Cleanup(ctx context.Context, project domain.ProjectID) ([]domain.SessionID, error) {
61+
return s.manager.Cleanup(ctx, project)
62+
}
63+
64+
// List returns the project's sessions as enriched display models.
65+
func (s *Session) List(ctx context.Context, project domain.ProjectID) ([]domain.Session, error) {
66+
recs, err := s.store.ListSessions(ctx, project)
67+
if err != nil {
68+
return nil, fmt.Errorf("list %s: %w", project, err)
69+
}
70+
out := make([]domain.Session, 0, len(recs))
71+
for _, rec := range recs {
72+
sess, err := s.toSession(ctx, rec)
73+
if err != nil {
74+
return nil, err
75+
}
76+
out = append(out, sess)
77+
}
78+
return out, nil
79+
}
80+
81+
// Get returns one session as an enriched display model, or sessionmanager.ErrNotFound if it is absent.
82+
func (s *Session) Get(ctx context.Context, id domain.SessionID) (domain.Session, error) {
83+
rec, ok, err := s.store.GetSession(ctx, id)
84+
if err != nil {
85+
return domain.Session{}, fmt.Errorf("get %s: %w", id, err)
86+
}
87+
if !ok {
88+
return domain.Session{}, fmt.Errorf("get %s: %w", id, sessionmanager.ErrNotFound)
89+
}
90+
return s.toSession(ctx, rec)
91+
}
92+
93+
func (s *Session) toSession(ctx context.Context, rec domain.SessionRecord) (domain.Session, error) {
94+
pr, ok, err := s.store.GetDisplayPRFactsForSession(ctx, rec.ID)
95+
if err != nil {
96+
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)
97+
}
98+
if !ok {
99+
return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil)}, nil
100+
}
101+
return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr)}, nil
102+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package service
2+
3+
import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
4+
5+
func deriveStatus(rec domain.SessionRecord, pr *domain.PRFacts) domain.SessionStatus {
6+
if rec.IsTerminated {
7+
if pr != nil && pr.Merged {
8+
return domain.StatusMerged
9+
}
10+
return domain.StatusTerminated
11+
}
12+
13+
if rec.Activity.State == domain.ActivityWaitingInput {
14+
return domain.StatusNeedsInput
15+
}
16+
17+
if pr != nil {
18+
if pr.Merged {
19+
return domain.StatusMerged
20+
}
21+
if !pr.Closed {
22+
return prPipelineStatus(*pr)
23+
}
24+
}
25+
26+
if rec.Activity.State == domain.ActivityActive {
27+
return domain.StatusWorking
28+
}
29+
return domain.StatusIdle
30+
}
31+
32+
func prPipelineStatus(pr domain.PRFacts) domain.SessionStatus {
33+
switch {
34+
case pr.CI == domain.CIFailing:
35+
return domain.StatusCIFailed
36+
case pr.Draft:
37+
return domain.StatusDraft
38+
case pr.Review == domain.ReviewChangesRequest || pr.ReviewComments:
39+
return domain.StatusChangesRequested
40+
case pr.Mergeability == domain.MergeMergeable:
41+
return domain.StatusMergeable
42+
case pr.Review == domain.ReviewApproved:
43+
return domain.StatusApproved
44+
case pr.Review == domain.ReviewRequired:
45+
return domain.StatusReviewPending
46+
default:
47+
return domain.StatusPROpen
48+
}
49+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package service
2+
3+
import (
4+
"testing"
5+
6+
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
7+
)
8+
9+
func statusRec(activity domain.ActivityState, terminated bool) domain.SessionRecord {
10+
return domain.SessionRecord{Activity: domain.Activity{State: activity}, IsTerminated: terminated}
11+
}
12+
13+
func statusPR(facts domain.PRFacts) *domain.PRFacts { return &facts }
14+
15+
func TestServiceDerivesStatusFromSessionFactsAndPR(t *testing.T) {
16+
tests := []struct {
17+
name string
18+
rec domain.SessionRecord
19+
pr *domain.PRFacts
20+
want domain.SessionStatus
21+
}{
22+
{"terminated", statusRec(domain.ActivityExited, true), nil, domain.StatusTerminated},
23+
{"merged-pr", statusRec(domain.ActivityIdle, true), statusPR(domain.PRFacts{Merged: true}), domain.StatusMerged},
24+
{"needs-input", statusRec(domain.ActivityWaitingInput, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), domain.StatusNeedsInput},
25+
{"ci-failed", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{CI: domain.CIFailing}), domain.StatusCIFailed},
26+
{"draft", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Draft: true}), domain.StatusDraft},
27+
{"changes-requested", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewChangesRequest}), domain.StatusChangesRequested},
28+
{"mergeable", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Mergeability: domain.MergeMergeable}), domain.StatusMergeable},
29+
{"approved", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewApproved}), domain.StatusApproved},
30+
{"review-pending", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{Review: domain.ReviewRequired}), domain.StatusReviewPending},
31+
{"pr-open", statusRec(domain.ActivityIdle, false), statusPR(domain.PRFacts{}), domain.StatusPROpen},
32+
{"working", statusRec(domain.ActivityActive, false), nil, domain.StatusWorking},
33+
{"idle", statusRec(domain.ActivityIdle, false), nil, domain.StatusIdle},
34+
}
35+
for _, tt := range tests {
36+
t.Run(tt.name, func(t *testing.T) {
37+
if got := deriveStatus(tt.rec, tt.pr); got != tt.want {
38+
t.Fatalf("got %q want %q", got, tt.want)
39+
}
40+
})
41+
}
42+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package service
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
9+
)
10+
11+
type fakeSessionStore struct {
12+
sessions map[domain.SessionID]domain.SessionRecord
13+
pr map[domain.SessionID]domain.PRFacts
14+
num int
15+
}
16+
17+
func newFakeSessionStore() *fakeSessionStore {
18+
return &fakeSessionStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
19+
}
20+
21+
func (f *fakeSessionStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
22+
f.num++
23+
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
24+
f.sessions[rec.ID] = rec
25+
return rec, nil
26+
}
27+
28+
func (f *fakeSessionStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
29+
r, ok := f.sessions[id]
30+
return r, ok, nil
31+
}
32+
33+
func (f *fakeSessionStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
34+
var out []domain.SessionRecord
35+
for _, r := range f.sessions {
36+
if r.ProjectID == p {
37+
out = append(out, r)
38+
}
39+
}
40+
return out, nil
41+
}
42+
43+
func (f *fakeSessionStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) {
44+
pr, ok := f.pr[id]
45+
return pr, ok, nil
46+
}
47+
48+
func TestSessionListDerivesStatusFromPRFacts(t *testing.T) {
49+
st := newFakeSessionStore()
50+
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", Activity: domain.Activity{State: domain.ActivityActive}}
51+
st.pr["mer-1"] = domain.PRFacts{URL: "pr1", CI: domain.CIFailing}
52+
53+
list, err := (&Session{store: st}).List(context.Background(), "mer")
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
if len(list) != 1 || list[0].Status != domain.StatusCIFailed {
58+
t.Fatalf("got %+v", list)
59+
}
60+
}

0 commit comments

Comments
 (0)