297 lines
9.2 KiB
Go
297 lines
9.2 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
var ctx = context.Background()
|
|
|
|
// ---- fakes ----
|
|
|
|
type fakeStore struct {
|
|
sessions map[domain.SessionID]domain.SessionRecord
|
|
pr map[domain.SessionID]domain.PRFacts
|
|
num int
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
return &fakeStore{sessions: map[domain.SessionID]domain.SessionRecord{}, pr: map[domain.SessionID]domain.PRFacts{}}
|
|
}
|
|
|
|
func (f *fakeStore) CreateSession(_ context.Context, rec domain.SessionRecord) (domain.SessionRecord, error) {
|
|
f.num++
|
|
rec.ID = domain.SessionID(fmt.Sprintf("%s-%d", rec.ProjectID, f.num))
|
|
f.sessions[rec.ID] = rec
|
|
return rec, nil
|
|
}
|
|
func (f *fakeStore) UpdateSession(_ context.Context, rec domain.SessionRecord) error {
|
|
f.sessions[rec.ID] = rec
|
|
return nil
|
|
}
|
|
func (f *fakeStore) GetSession(_ context.Context, id domain.SessionID) (domain.SessionRecord, bool, error) {
|
|
r, ok := f.sessions[id]
|
|
return r, ok, nil
|
|
}
|
|
func (f *fakeStore) ListSessions(_ context.Context, p domain.ProjectID) ([]domain.SessionRecord, error) {
|
|
var out []domain.SessionRecord
|
|
for _, r := range f.sessions {
|
|
if r.ProjectID == p {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeStore) ListAllSessions(_ context.Context) ([]domain.SessionRecord, error) {
|
|
out := make([]domain.SessionRecord, 0, len(f.sessions))
|
|
for _, r := range f.sessions {
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|
|
func (f *fakeStore) PRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, error) {
|
|
return f.pr[id], nil
|
|
}
|
|
|
|
// fakeLCM is the minimal lifecycle the Session Manager drives: it persists the
|
|
// spawn/kill canonical writes into the store so Get reflects them.
|
|
type fakeLCM struct {
|
|
store *fakeStore
|
|
completed int
|
|
}
|
|
|
|
func (l *fakeLCM) OnSpawnCompleted(_ context.Context, id domain.SessionID, o ports.SpawnOutcome) error {
|
|
l.completed++
|
|
rec := l.store.sessions[id]
|
|
rec.Lifecycle.Session.State = domain.SessionNotStarted
|
|
rec.Lifecycle.IsAlive = true
|
|
rec.Lifecycle.TerminationReason = domain.TermNone
|
|
rec.Metadata = domain.SessionMetadata{
|
|
Branch: o.Branch, WorkspacePath: o.WorkspacePath,
|
|
RuntimeHandleID: o.RuntimeHandle.ID, RuntimeName: o.RuntimeHandle.RuntimeName,
|
|
AgentSessionID: o.AgentSessionID, Prompt: o.Prompt,
|
|
}
|
|
l.store.sessions[id] = rec
|
|
return nil
|
|
}
|
|
func (l *fakeLCM) OnKillRequested(_ context.Context, id domain.SessionID, reason domain.TerminationReason) error {
|
|
rec := l.store.sessions[id]
|
|
rec.Lifecycle.Session.State = domain.SessionTerminated
|
|
rec.Lifecycle.TerminationReason = reason
|
|
rec.Lifecycle.IsAlive = false
|
|
l.store.sessions[id] = rec
|
|
return nil
|
|
}
|
|
func (l *fakeLCM) ApplyRuntimeObservation(context.Context, domain.SessionID, ports.RuntimeFacts) error {
|
|
return nil
|
|
}
|
|
func (l *fakeLCM) ApplyActivitySignal(context.Context, domain.SessionID, ports.ActivitySignal) error {
|
|
return nil
|
|
}
|
|
func (l *fakeLCM) ApplyPRObservation(context.Context, domain.SessionID, ports.PRObservation) error {
|
|
return nil
|
|
}
|
|
func (l *fakeLCM) TickEscalations(context.Context, time.Time) error { return nil }
|
|
func (l *fakeLCM) RunningSessions(context.Context) ([]domain.SessionRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type fakeRuntime struct {
|
|
createErr error
|
|
created, destroyed int
|
|
}
|
|
|
|
func (r *fakeRuntime) Create(context.Context, ports.RuntimeConfig) (ports.RuntimeHandle, error) {
|
|
if r.createErr != nil {
|
|
return ports.RuntimeHandle{}, r.createErr
|
|
}
|
|
r.created++
|
|
return ports.RuntimeHandle{ID: "h1", RuntimeName: "tmux"}, nil
|
|
}
|
|
func (r *fakeRuntime) Destroy(context.Context, ports.RuntimeHandle) error { r.destroyed++; return nil }
|
|
func (r *fakeRuntime) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
type fakeAgent struct{}
|
|
|
|
func (fakeAgent) GetLaunchCommand(ports.AgentConfig) string { return "launch" }
|
|
func (fakeAgent) GetEnvironment(ports.AgentConfig) map[string]string {
|
|
return map[string]string{"X": "1"}
|
|
}
|
|
func (fakeAgent) GetRestoreCommand(id string) string { return "resume " + id }
|
|
|
|
type fakeWorkspace struct {
|
|
destroyErr error
|
|
destroyed int
|
|
}
|
|
|
|
func (w *fakeWorkspace) Create(_ context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
|
|
return ports.WorkspaceInfo{Path: "/ws/" + string(cfg.SessionID), Branch: cfg.Branch, SessionID: cfg.SessionID, ProjectID: cfg.ProjectID}, nil
|
|
}
|
|
func (w *fakeWorkspace) Destroy(context.Context, ports.WorkspaceInfo) error {
|
|
w.destroyed++
|
|
return w.destroyErr
|
|
}
|
|
func (w *fakeWorkspace) Restore(ctx context.Context, cfg ports.WorkspaceConfig) (ports.WorkspaceInfo, error) {
|
|
return w.Create(ctx, cfg)
|
|
}
|
|
|
|
type fakeMessenger struct{ msgs []string }
|
|
|
|
func (m *fakeMessenger) Send(_ context.Context, _ domain.SessionID, msg string) error {
|
|
m.msgs = append(m.msgs, msg)
|
|
return nil
|
|
}
|
|
|
|
func newManager() (*Manager, *fakeStore, *fakeRuntime, *fakeWorkspace) {
|
|
st := newFakeStore()
|
|
rt := &fakeRuntime{}
|
|
ws := &fakeWorkspace{}
|
|
m := New(Deps{
|
|
Runtime: rt, Agent: fakeAgent{}, Workspace: ws,
|
|
Store: st, Messenger: &fakeMessenger{}, Lifecycle: &fakeLCM{store: st},
|
|
})
|
|
return m, st, rt, ws
|
|
}
|
|
|
|
func seedTerminal(st *fakeStore, id domain.SessionID, meta domain.SessionMetadata) {
|
|
st.sessions[id] = domain.SessionRecord{
|
|
ID: id, ProjectID: "mer", Metadata: meta,
|
|
Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionTerminated}},
|
|
}
|
|
}
|
|
|
|
// ---- tests ----
|
|
|
|
func TestSpawn_AssignsIDAndGoesLive(t *testing.T) {
|
|
m, st, rt, _ := newManager()
|
|
|
|
s, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer", Kind: domain.KindWorker, Prompt: "do it"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.ID != "mer-1" {
|
|
t.Fatalf("store should assign mer-1, got %q", s.ID)
|
|
}
|
|
if s.Status != domain.StatusSpawning {
|
|
t.Fatalf("fresh session displays spawning, got %q", s.Status)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatalf("runtime not created")
|
|
}
|
|
if st.sessions["mer-1"].Metadata.RuntimeHandleID != "h1" {
|
|
t.Fatal("spawn handle not folded into the row")
|
|
}
|
|
}
|
|
|
|
func TestSpawn_RollsBackOnRuntimeFailure(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
m.runtime = &fakeRuntime{createErr: errors.New("boom")}
|
|
|
|
if _, err := m.Spawn(ctx, ports.SpawnConfig{ProjectID: "mer"}); err == nil {
|
|
t.Fatal("expected spawn to fail")
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("workspace should be rolled back")
|
|
}
|
|
if st.sessions["mer-1"].Lifecycle.Session.State != domain.SessionTerminated {
|
|
t.Fatal("orphaned spawn should be parked terminal")
|
|
}
|
|
}
|
|
|
|
func TestKill_TearsDownRuntimeAndWorkspace(t *testing.T) {
|
|
m, st, rt, ws := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
|
|
freed, err := m.Kill(ctx, "mer-1", domain.TermManuallyKilled)
|
|
if err != nil || !freed {
|
|
t.Fatalf("kill should free the workspace: freed=%v err=%v", freed, err)
|
|
}
|
|
if rt.destroyed != 1 || ws.destroyed != 1 {
|
|
t.Fatal("kill should destroy runtime and workspace")
|
|
}
|
|
}
|
|
|
|
func TestKill_RefusesIncompleteHandle(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = domain.SessionRecord{ // live, but no teardown handles
|
|
ID: "mer-1", ProjectID: "mer",
|
|
Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true},
|
|
}
|
|
|
|
if _, err := m.Kill(ctx, "mer-1", domain.TermManuallyKilled); !errors.Is(err, ErrIncompleteHandle) {
|
|
t.Fatalf("want ErrIncompleteHandle, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRestore_ReopensTerminal(t *testing.T) {
|
|
m, st, rt, _ := newManager()
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1", Branch: "b", AgentSessionID: "agent-x"})
|
|
|
|
s, err := m.Restore(ctx, "mer-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.Status != domain.StatusSpawning {
|
|
t.Fatalf("restored session displays spawning, got %q", s.Status)
|
|
}
|
|
if rt.created != 1 {
|
|
t.Fatal("restore should relaunch the runtime")
|
|
}
|
|
}
|
|
|
|
func TestRestore_RefusesLiveSession(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
|
|
if _, err := m.Restore(ctx, "mer-1"); !errors.Is(err, ErrNotRestorable) {
|
|
t.Fatalf("want ErrNotRestorable, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestList_DerivesStatusFromPRFacts(t *testing.T) {
|
|
m, st, _, _ := newManager()
|
|
st.sessions["mer-1"] = mkLive("mer-1")
|
|
st.pr["mer-1"] = domain.PRFacts{Exists: true, CI: domain.CIFailing}
|
|
|
|
list, err := m.List(ctx, "mer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list) != 1 || list[0].Status != domain.StatusCIFailed {
|
|
t.Fatalf("status should reflect PR facts, got %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestCleanup_ReclaimsTerminalWorkspaces(t *testing.T) {
|
|
m, st, _, ws := newManager()
|
|
seedTerminal(st, "mer-1", domain.SessionMetadata{WorkspacePath: "/ws/mer-1"})
|
|
st.sessions["mer-2"] = mkLive("mer-2") // live: must be skipped
|
|
|
|
cleaned, err := m.Cleanup(ctx, "mer")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(cleaned) != 1 || cleaned[0] != "mer-1" {
|
|
t.Fatalf("only the terminal session should be reclaimed, got %v", cleaned)
|
|
}
|
|
if ws.destroyed != 1 {
|
|
t.Fatal("the live session's workspace must not be destroyed")
|
|
}
|
|
}
|
|
|
|
func mkLive(id domain.SessionID) domain.SessionRecord {
|
|
return domain.SessionRecord{
|
|
ID: id, ProjectID: "mer",
|
|
Metadata: domain.SessionMetadata{WorkspacePath: "/ws/" + string(id), RuntimeHandleID: "h1", RuntimeName: "tmux"},
|
|
Lifecycle: domain.CanonicalSessionLifecycle{Session: domain.SessionSubstate{State: domain.SessionWorking}, IsAlive: true},
|
|
}
|
|
}
|