diff --git a/backend/internal/cli/dto_drift_e2e_test.go b/backend/internal/cli/dto_drift_e2e_test.go index cafff4a..676c4db 100644 --- a/backend/internal/cli/dto_drift_e2e_test.go +++ b/backend/internal/cli/dto_drift_e2e_test.go @@ -78,6 +78,14 @@ func (f *fakeSessionService) Kill(context.Context, domain.SessionID) (bool, erro return false, nil } +func (f *fakeSessionService) Archive(context.Context, domain.SessionID) error { + return nil +} + +func (f *fakeSessionService) Unarchive(context.Context, domain.SessionID) error { + return nil +} + func (f *fakeSessionService) RollbackSpawn(context.Context, domain.SessionID) (sessionsvc.RollbackOutcome, error) { return sessionsvc.RollbackOutcome{}, nil } diff --git a/backend/internal/domain/session.go b/backend/internal/domain/session.go index be93d30..d10d30f 100644 --- a/backend/internal/domain/session.go +++ b/backend/internal/domain/session.go @@ -48,11 +48,17 @@ type SessionRecord struct { // activity state. Zero means no hook has ever reported, which deriveStatus // surfaces as StatusNoSignal after a grace period. Internal fact, not part // of the API read model. - FirstSignalAt time.Time `json:"-"` - IsTerminated bool `json:"isTerminated"` - Metadata SessionMetadata `json:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + FirstSignalAt time.Time `json:"-"` + IsTerminated bool `json:"isTerminated"` + // ArchivedAt soft-hides a terminated session from default UI lists without + // destroying the row (mirrors ProjectRecord.ArchivedAt). Zero means not + // archived. It records user intent via the session service — lifecycle + // never sets it, and restore clears it so a running session can never be + // hidden. Internal fact; the read model exposes the derived IsArchived. + ArchivedAt time.Time `json:"-"` + Metadata SessionMetadata `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // Session is the read-model returned across the API boundary: a SessionRecord @@ -60,6 +66,7 @@ type SessionRecord struct { type Session struct { SessionRecord Status SessionStatus `json:"status"` + IsArchived bool `json:"isArchived,omitempty"` TerminalHandleID string `json:"terminalHandleId,omitempty"` // Branch is the session's worktree branch, surfaced from Metadata (which // stays internal) so the UI's git rail can label the workspace. diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index 1a5f588..b811099 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -537,6 +537,16 @@ paths: type: - "null" - boolean + - description: When true, return only archived sessions; when false, exclude + archived sessions. Omitted, both are returned. + in: query + name: archived + schema: + description: When true, return only archived sessions; when false, exclude + archived sessions. Omitted, both are returned. + type: + - "null" + - boolean responses: "200": content: @@ -729,6 +739,45 @@ paths: summary: Report an agent activity-state signal for a session tags: - sessions + /api/v1/sessions/{sessionId}/archive: + post: + operationId: archiveSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ArchiveSessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "409": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Conflict + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Archive a terminated session (soft-hide it from default lists) + tags: + - sessions /api/v1/sessions/{sessionId}/git: get: operationId: getSessionGitStatus @@ -1186,6 +1235,39 @@ paths: summary: Send a message to a running session's agent tags: - sessions + /api/v1/sessions/{sessionId}/unarchive: + post: + operationId: unarchiveSession + parameters: + - description: Session identifier, e.g. project-1. + in: path + name: sessionId + required: true + schema: + description: Session identifier, e.g. project-1. + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/UnarchiveSessionResponse' + description: OK + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/APIError' + description: Internal Server Error + summary: Return an archived session to default lists + tags: + - sessions /api/v1/sessions/cleanup: post: operationId: cleanupSessions @@ -1266,6 +1348,16 @@ components: permissions: type: string type: object + ArchiveSessionResponse: + properties: + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + type: object ClaimPRRequest: properties: allowTakeover: @@ -1760,6 +1852,8 @@ components: type: string id: type: string + isArchived: + type: boolean isTerminated: type: boolean issueId: @@ -1935,6 +2029,16 @@ components: required: - projectId type: object + UnarchiveSessionResponse: + properties: + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + type: object WorkspaceRepo: properties: name: diff --git a/backend/internal/httpd/apispec/specgen/build.go b/backend/internal/httpd/apispec/specgen/build.go index 3b76599..ad4f739 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -143,6 +143,8 @@ var schemaNames = map[string]string{ "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", "ControllersCleanupSkippedSession": "CleanupSkippedSession", "ControllersKillSessionResponse": "KillSessionResponse", + "ControllersArchiveSessionResponse": "ArchiveSessionResponse", + "ControllersUnarchiveSessionResponse": "UnarchiveSessionResponse", "ControllersRollbackSessionResponse": "RollbackSessionResponse", "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", @@ -481,6 +483,27 @@ func sessionOperations() []operation { {http.StatusInternalServerError, envelope.APIError{}}, }, }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/archive", id: "archiveSession", tag: "sessions", + summary: "Archive a terminated session (soft-hide it from default lists)", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.ArchiveSessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusConflict, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, + { + method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/unarchive", id: "unarchiveSession", tag: "sessions", + summary: "Return an archived session to default lists", + pathParams: []any{controllers.SessionIDParam{}}, + resps: []respUnit{ + {http.StatusOK, controllers.UnarchiveSessionResponse{}}, + {http.StatusNotFound, envelope.APIError{}}, + {http.StatusInternalServerError, envelope.APIError{}}, + }, + }, { method: http.MethodPost, path: "/api/v1/sessions/{sessionId}/rollback", id: "rollbackSession", tag: "sessions", summary: "Undo a partially-completed spawn (delete seed row, or kill if spawn output exists)", diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index adab3bf..30523b1 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -104,6 +104,7 @@ type ListSessionsQuery struct { Active *bool `query:"active,omitempty" description:"When true, return non-terminated sessions; when false, return terminated sessions."` OrchestratorOnly *bool `query:"orchestratorOnly,omitempty" description:"When true, return only orchestrator sessions."` Fresh *bool `query:"fresh,omitempty" description:"When true, return only fresh non-terminated sessions."` + Archived *bool `query:"archived,omitempty" description:"When true, return only archived sessions; when false, exclude archived sessions. Omitted, both are returned."` } // CleanupSessionsQuery is the query string accepted by POST /api/v1/sessions/cleanup. @@ -157,6 +158,18 @@ type KillSessionResponse struct { Freed bool `json:"freed,omitempty"` } +// ArchiveSessionResponse is the body of POST /api/v1/sessions/{sessionId}/archive. +type ArchiveSessionResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` +} + +// UnarchiveSessionResponse is the body of POST /api/v1/sessions/{sessionId}/unarchive. +type UnarchiveSessionResponse struct { + OK bool `json:"ok"` + SessionID domain.SessionID `json:"sessionId"` +} + // RollbackSessionResponse is the body of POST /api/v1/sessions/{sessionId}/rollback. // Exactly one of Deleted/Killed is true on a successful rollback; both are // false when the session was already absent or already terminated (benign). diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index 866384d..99f4584 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -30,6 +30,8 @@ type SessionService interface { Get(ctx context.Context, id domain.SessionID) (domain.Session, error) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) Kill(ctx context.Context, id domain.SessionID) (bool, error) + Archive(ctx context.Context, id domain.SessionID) error + Unarchive(ctx context.Context, id domain.SessionID) error RollbackSpawn(ctx context.Context, id domain.SessionID) (sessionsvc.RollbackOutcome, error) Cleanup(ctx context.Context, project domain.ProjectID) (sessionsvc.CleanupOutcome, error) Rename(ctx context.Context, id domain.SessionID, displayName string) error @@ -69,6 +71,8 @@ func (c *SessionsController) Register(r chi.Router) { r.Patch("/sessions/{sessionId}", c.rename) r.Post("/sessions/{sessionId}/restore", c.restore) r.Post("/sessions/{sessionId}/kill", c.kill) + r.Post("/sessions/{sessionId}/archive", c.archive) + r.Post("/sessions/{sessionId}/unarchive", c.unarchive) r.Post("/sessions/{sessionId}/rollback", c.rollback) r.Post("/sessions/{sessionId}/send", c.send) r.Post("/sessions/{sessionId}/activity", c.activity) @@ -228,6 +232,33 @@ func (c *SessionsController) kill(w http.ResponseWriter, r *http.Request) { envelope.WriteJSON(w, http.StatusOK, KillSessionResponse{OK: true, SessionID: sessionID(r), Freed: freed}) } +// archive soft-hides a terminated session from default lists. Conflicts (409 +// SESSION_NOT_TERMINATED) when the session is still running: kill first, so an +// active agent can never be hidden. +func (c *SessionsController) archive(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/archive") + return + } + if err := c.Svc.Archive(r.Context(), sessionID(r)); err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, ArchiveSessionResponse{OK: true, SessionID: sessionID(r)}) +} + +func (c *SessionsController) unarchive(w http.ResponseWriter, r *http.Request) { + if c.Svc == nil { + apispec.NotImplemented(w, r, "POST", "/api/v1/sessions/{sessionId}/unarchive") + return + } + if err := c.Svc.Unarchive(r.Context(), sessionID(r)); err != nil { + envelope.WriteError(w, r, err) + return + } + envelope.WriteJSON(w, http.StatusOK, UnarchiveSessionResponse{OK: true, SessionID: sessionID(r)}) +} + // rollback undoes a partially-completed spawn: if the session row is still in // seed state (no workspace, no runtime handle yet), the row is deleted // outright. If anything observable has landed it falls back to Kill so the @@ -487,6 +518,13 @@ func parseSessionListFilter(r *http.Request) (sessionsvc.ListFilter, error) { } filter.Fresh = fresh } + if raw := q.Get("archived"); raw != "" { + archived, err := strconv.ParseBool(raw) + if err != nil { + return sessionsvc.ListFilter{}, errors.New("archived must be a boolean") + } + filter.Archived = &archived + } return filter, nil } diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 7614150..9066a16 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -20,6 +20,7 @@ import ( type fakeSessionService struct { sessions map[domain.SessionID]domain.Session + listFilters []sessionsvc.ListFilter sent string cleanupProjects []domain.ProjectID cleanupResult []domain.SessionID @@ -42,6 +43,7 @@ func newFakeSessionService() *fakeSessionService { } func (f *fakeSessionService) List(_ context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error) { + f.listFilters = append(f.listFilters, filter) var out []domain.Session for _, s := range f.sessions { if filter.ProjectID != "" && s.ProjectID != filter.ProjectID { @@ -88,11 +90,35 @@ func (f *fakeSessionService) Get(_ context.Context, id domain.SessionID) (domain func (f *fakeSessionService) Restore(_ context.Context, id domain.SessionID) (domain.Session, error) { s := f.sessions[id] s.IsTerminated = false + s.IsArchived = false s.Status = domain.StatusIdle f.sessions[id] = s return s, nil } +func (f *fakeSessionService) Archive(_ context.Context, id domain.SessionID) error { + s, ok := f.sessions[id] + if !ok { + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + if !s.IsTerminated { + return apierr.Conflict("SESSION_NOT_TERMINATED", "Kill the worker before archiving it", nil) + } + s.IsArchived = true + f.sessions[id] = s + return nil +} + +func (f *fakeSessionService) Unarchive(_ context.Context, id domain.SessionID) error { + s, ok := f.sessions[id] + if !ok { + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + s.IsArchived = false + f.sessions[id] = s + return nil +} + func (f *fakeSessionService) Kill(_ context.Context, id domain.SessionID) (bool, error) { s := f.sessions[id] s.IsTerminated = true @@ -365,6 +391,56 @@ func TestSessionsAPI_SendValidation(t *testing.T) { assertErrorCode(t, body, status, http.StatusBadRequest, "MESSAGE_REQUIRED") } +func TestSessionsAPI_ArchiveLifecycle(t *testing.T) { + svc := newFakeSessionService() + srv := newSessionTestServer(t, svc) + + // Archiving a running session conflicts — kill first. + body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/archive", "") + assertErrorCode(t, body, status, http.StatusConflict, "SESSION_NOT_TERMINATED") + + if body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/kill", ""); status != http.StatusOK { + t.Fatalf("kill = %d, want 200; body=%s", status, body) + } + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/archive", "") + if status != http.StatusOK { + t.Fatalf("archive = %d, want 200; body=%s", status, body) + } + var archived struct { + OK bool `json:"ok"` + SessionID string `json:"sessionId"` + } + mustJSON(t, body, &archived) + if !archived.OK || archived.SessionID != "ao-1" || !svc.sessions["ao-1"].IsArchived { + t.Fatalf("archive response = %#v, session = %+v", archived, svc.sessions["ao-1"]) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/unarchive", "") + if status != http.StatusOK || svc.sessions["ao-1"].IsArchived { + t.Fatalf("unarchive = %d body=%s session=%+v", status, body, svc.sessions["ao-1"]) + } + + body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions/missing-1/archive", "") + assertErrorCode(t, body, status, http.StatusNotFound, "SESSION_NOT_FOUND") +} + +func TestSessionsAPI_ListArchivedFilter(t *testing.T) { + svc := newFakeSessionService() + srv := newSessionTestServer(t, svc) + + body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?archived=true", "") + if status != http.StatusOK { + t.Fatalf("GET sessions = %d, want 200; body=%s", status, body) + } + last := svc.listFilters[len(svc.listFilters)-1] + if last.Archived == nil || !*last.Archived { + t.Fatalf("archived filter not parsed: %#v", last) + } + + body, status, _ = doRequest(t, srv, "GET", "/api/v1/sessions?archived=banana", "") + assertErrorCode(t, body, status, http.StatusBadRequest, "INVALID_QUERY") +} + func TestSessionsAPI_CleanupWithProjectFilter(t *testing.T) { svc := newFakeSessionService() svc.cleanupResult = []domain.SessionID{"ao-1"} diff --git a/backend/internal/service/session/service.go b/backend/internal/service/session/service.go index 5ca427d..fcb0be8 100644 --- a/backend/internal/service/session/service.go +++ b/backend/internal/service/session/service.go @@ -19,6 +19,8 @@ type Store interface { ListSessions(ctx context.Context, project domain.ProjectID) ([]domain.SessionRecord, error) ListAllSessions(ctx context.Context) ([]domain.SessionRecord, error) RenameSession(ctx context.Context, id domain.SessionID, displayName string, updatedAt time.Time) (bool, error) + ArchiveSession(ctx context.Context, id domain.SessionID, at time.Time) (bool, error) + UnarchiveSession(ctx context.Context, id domain.SessionID, at time.Time) (bool, error) GetDisplayPRFactsForSession(ctx context.Context, id domain.SessionID) (domain.PRFacts, bool, error) ListPRsBySession(ctx context.Context, sessionID domain.SessionID) ([]domain.PullRequest, error) ListPRComments(ctx context.Context, prURL string) ([]domain.PullRequestComment, error) @@ -31,6 +33,9 @@ type ListFilter struct { Active *bool OrchestratorOnly bool Fresh bool + // Archived selects only archived (true) or only non-archived (false) + // sessions; nil includes both. + Archived *bool } // commander is the command-side surface Service delegates to: the @@ -181,14 +186,61 @@ func (s *Service) SpawnOrchestrator(ctx context.Context, projectID domain.Projec } // Restore relaunches a terminated session and returns the API-facing read model. +// A restore also unarchives: archived is a "hidden from default lists" fact +// that must never apply to a running session. func (s *Service) Restore(ctx context.Context, id domain.SessionID) (domain.Session, error) { rec, err := s.manager.Restore(ctx, id) if err != nil { return domain.Session{}, toAPIError(err) } + if !rec.ArchivedAt.IsZero() { + if _, err := s.store.UnarchiveSession(ctx, id, s.now().UTC()); err != nil { + return domain.Session{}, fmt.Errorf("unarchive restored %s: %w", id, err) + } + rec.ArchivedAt = time.Time{} + } return s.toSession(ctx, rec) } +// Archive soft-hides a terminated session from default UI lists. Archiving a +// session that is still running is rejected: the worker must be killed first +// so an active agent can never be hidden. Re-archiving is a no-op. +func (s *Service) Archive(ctx context.Context, id domain.SessionID) error { + rec, ok, err := s.store.GetSession(ctx, id) + if err != nil { + return fmt.Errorf("archive %s: %w", id, err) + } + if !ok { + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + if !rec.IsTerminated { + return apierr.Conflict("SESSION_NOT_TERMINATED", "Kill the worker before archiving it", nil) + } + if !rec.ArchivedAt.IsZero() { + return nil + } + if _, err := s.store.ArchiveSession(ctx, id, s.now().UTC()); err != nil { + return fmt.Errorf("archive %s: %w", id, err) + } + return nil +} + +// Unarchive returns an archived session to default UI lists. Unarchiving a +// session that is not archived is a no-op. +func (s *Service) Unarchive(ctx context.Context, id domain.SessionID) error { + _, ok, err := s.store.GetSession(ctx, id) + if err != nil { + return fmt.Errorf("unarchive %s: %w", id, err) + } + if !ok { + return apierr.NotFound("SESSION_NOT_FOUND", "Unknown session") + } + if _, err := s.store.UnarchiveSession(ctx, id, s.now().UTC()); err != nil { + return fmt.Errorf("unarchive %s: %w", id, err) + } + return nil +} + // Kill delegates terminal intent and teardown to the internal manager. func (s *Service) Kill(ctx context.Context, id domain.SessionID) (bool, error) { freed, err := s.manager.Kill(ctx, id) @@ -290,6 +342,9 @@ func matchesSessionFilter(rec domain.SessionRecord, filter ListFilter) bool { if filter.Fresh && rec.IsTerminated { return false } + if filter.Archived != nil && *filter.Archived == rec.ArchivedAt.IsZero() { + return false + } return true } @@ -339,9 +394,9 @@ func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (doma return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) } if !ok { - return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil, s.now(), s.harnessSignals(rec.Harness)), TerminalHandleID: rec.Metadata.RuntimeHandleID, Branch: rec.Metadata.Branch}, nil + return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil, s.now(), s.harnessSignals(rec.Harness)), IsArchived: !rec.ArchivedAt.IsZero(), TerminalHandleID: rec.Metadata.RuntimeHandleID, Branch: rec.Metadata.Branch}, nil } - return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr, s.now(), s.harnessSignals(rec.Harness)), TerminalHandleID: rec.Metadata.RuntimeHandleID, Branch: rec.Metadata.Branch}, nil + return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr, s.now(), s.harnessSignals(rec.Harness)), IsArchived: !rec.ArchivedAt.IsZero(), TerminalHandleID: rec.Metadata.RuntimeHandleID, Branch: rec.Metadata.Branch}, nil } // now tolerates a zero-value Service (tests construct the struct literally diff --git a/backend/internal/service/session/service_test.go b/backend/internal/service/session/service_test.go index 9af3cee..866c5f1 100644 --- a/backend/internal/service/session/service_test.go +++ b/backend/internal/service/session/service_test.go @@ -65,6 +65,28 @@ func (f *fakeStore) RenameSession(_ context.Context, id domain.SessionID, displa return true, nil } +func (f *fakeStore) ArchiveSession(_ context.Context, id domain.SessionID, at time.Time) (bool, error) { + r, ok := f.sessions[id] + if !ok || !r.IsTerminated || !r.ArchivedAt.IsZero() { + return false, nil + } + r.ArchivedAt = at + r.UpdatedAt = at + f.sessions[id] = r + return true, nil +} + +func (f *fakeStore) UnarchiveSession(_ context.Context, id domain.SessionID, at time.Time) (bool, error) { + r, ok := f.sessions[id] + if !ok || r.ArchivedAt.IsZero() { + return false, nil + } + r.ArchivedAt = time.Time{} + r.UpdatedAt = at + f.sessions[id] = r + return true, nil +} + func (f *fakeStore) GetDisplayPRFactsForSession(_ context.Context, id domain.SessionID) (domain.PRFacts, bool, error) { pr, ok := f.pr[id] return pr, ok, nil @@ -124,6 +146,117 @@ func TestSessionRenameMissingSessionReturnsNotFound(t *testing.T) { } } +func TestArchiveRequiresTerminatedSession(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer"} + + err := (&Service{store: st}).Archive(context.Background(), "mer-1") + var e *apierr.Error + if !errors.As(err, &e) || e.Kind != apierr.KindConflict || e.Code != "SESSION_NOT_TERMINATED" { + t.Fatalf("err = %v, want apierr Conflict SESSION_NOT_TERMINATED", err) + } + if !st.sessions["mer-1"].ArchivedAt.IsZero() { + t.Fatalf("running session must not be archived: %+v", st.sessions["mer-1"]) + } +} + +func TestArchiveMissingSessionReturnsNotFound(t *testing.T) { + err := (&Service{store: newFakeStore()}).Archive(context.Background(), "mer-404") + var e *apierr.Error + if !errors.As(err, &e) || e.Kind != apierr.KindNotFound || e.Code != "SESSION_NOT_FOUND" { + t.Fatalf("err = %v, want apierr NotFound SESSION_NOT_FOUND", err) + } +} + +func TestArchiveHidesSessionFromFilteredListAndUnarchiveRestoresIt(t *testing.T) { + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true} + st.sessions["mer-2"] = domain.SessionRecord{ID: "mer-2", ProjectID: "mer"} + svc := &Service{store: st} + + if err := svc.Archive(context.Background(), "mer-1"); err != nil { + t.Fatalf("Archive: %v", err) + } + if st.sessions["mer-1"].ArchivedAt.IsZero() { + t.Fatalf("archived_at not set: %+v", st.sessions["mer-1"]) + } + // Re-archiving is a no-op, not an error. + if err := svc.Archive(context.Background(), "mer-1"); err != nil { + t.Fatalf("repeat Archive: %v", err) + } + + archived := true + notArchived := false + cases := []struct { + filter ListFilter + want map[domain.SessionID]bool + }{ + {ListFilter{ProjectID: "mer"}, map[domain.SessionID]bool{"mer-1": true, "mer-2": true}}, + {ListFilter{ProjectID: "mer", Archived: ¬Archived}, map[domain.SessionID]bool{"mer-2": true}}, + {ListFilter{ProjectID: "mer", Archived: &archived}, map[domain.SessionID]bool{"mer-1": true}}, + } + for _, tc := range cases { + list, err := svc.List(context.Background(), tc.filter) + if err != nil { + t.Fatalf("List(%+v): %v", tc.filter, err) + } + got := map[domain.SessionID]bool{} + for _, s := range list { + got[s.ID] = true + if s.ID == "mer-1" && !s.IsArchived { + t.Fatalf("mer-1 read model must surface isArchived: %+v", s) + } + } + if len(got) != len(tc.want) { + t.Fatalf("List(%+v) = %v, want %v", tc.filter, got, tc.want) + } + for id := range tc.want { + if !got[id] { + t.Fatalf("List(%+v) = %v, want %v", tc.filter, got, tc.want) + } + } + } + + if err := svc.Unarchive(context.Background(), "mer-1"); err != nil { + t.Fatalf("Unarchive: %v", err) + } + if !st.sessions["mer-1"].ArchivedAt.IsZero() { + t.Fatalf("archived_at not cleared: %+v", st.sessions["mer-1"]) + } +} + +// archivedRestoreCommander returns a canned record from Restore so the test +// can hand the service an archived-but-just-restored session. +type archivedRestoreCommander struct { + fakeCommander + rec domain.SessionRecord +} + +func (c *archivedRestoreCommander) Restore(context.Context, domain.SessionID) (domain.SessionRecord, error) { + return c.rec, nil +} + +func TestRestoreClearsArchived(t *testing.T) { + archivedAt := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + st := newFakeStore() + st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true, ArchivedAt: archivedAt} + svc := &Service{ + manager: &archivedRestoreCommander{rec: domain.SessionRecord{ID: "mer-1", ProjectID: "mer", ArchivedAt: archivedAt}}, + store: st, + } + + sess, err := svc.Restore(context.Background(), "mer-1") + if err != nil { + t.Fatalf("Restore: %v", err) + } + if sess.IsArchived { + t.Fatalf("restored session must not read as archived: %+v", sess) + } + if !st.sessions["mer-1"].ArchivedAt.IsZero() { + t.Fatalf("restore must clear archived_at: %+v", st.sessions["mer-1"]) + } +} + // fakeCommander records Kill/Spawn calls so a test can assert the // clean-orchestrator ordering without wiring a real session engine. type fakeCommander struct { diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index f8d614b..8be396d 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -130,6 +130,7 @@ type Session struct { UpdatedAt time.Time DisplayName string FirstSignalAt sql.NullTime + ArchivedAt sql.NullTime } type SessionWorktree struct { diff --git a/backend/internal/storage/sqlite/gen/sessions.sql.go b/backend/internal/storage/sqlite/gen/sessions.sql.go index 920e17f..6898b38 100644 --- a/backend/internal/storage/sqlite/gen/sessions.sql.go +++ b/backend/internal/storage/sqlite/gen/sessions.sql.go @@ -13,10 +13,33 @@ import ( "github.com/aoagents/agent-orchestrator/backend/internal/domain" ) +const archiveSession = `-- name: ArchiveSession :execrows +UPDATE sessions SET archived_at = ?, updated_at = ? +WHERE id = ? AND is_terminated = 1 AND archived_at IS NULL +` + +type ArchiveSessionParams struct { + ArchivedAt sql.NullTime + UpdatedAt time.Time + ID domain.SessionID +} + +// ArchiveSession soft-hides a terminated session. The is_terminated guard is +// in the statement so a concurrent restore can't race the service's +// pre-check; the archived_at IS NULL guard makes a repeat archive a no-op +// (0 rows) instead of moving the timestamp. +func (q *Queries) ArchiveSession(ctx context.Context, arg ArchiveSessionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, archiveSession, arg.ArchivedAt, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getSession = `-- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, archived_at FROM sessions WHERE id = ? ` @@ -42,6 +65,7 @@ func (q *Queries) GetSession(ctx context.Context, id domain.SessionID) (Session, &i.UpdatedAt, &i.DisplayName, &i.FirstSignalAt, + &i.ArchivedAt, ) return i, err } @@ -103,7 +127,7 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) er const listAllSessions = `-- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, archived_at FROM sessions ORDER BY project_id, num ` @@ -135,6 +159,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { &i.UpdatedAt, &i.DisplayName, &i.FirstSignalAt, + &i.ArchivedAt, ); err != nil { return nil, err } @@ -152,7 +177,7 @@ func (q *Queries) ListAllSessions(ctx context.Context) ([]Session, error) { const listSessionsByProject = `-- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, archived_at FROM sessions WHERE project_id = ? ORDER BY num ` @@ -184,6 +209,7 @@ func (q *Queries) ListSessionsByProject(ctx context.Context, projectID domain.Pr &i.UpdatedAt, &i.DisplayName, &i.FirstSignalAt, + &i.ArchivedAt, ); err != nil { return nil, err } @@ -251,6 +277,24 @@ func (q *Queries) SessionIsSeed(ctx context.Context, id domain.SessionID) (bool, return is_seed, err } +const unarchiveSession = `-- name: UnarchiveSession :execrows +UPDATE sessions SET archived_at = NULL, updated_at = ? +WHERE id = ? AND archived_at IS NOT NULL +` + +type UnarchiveSessionParams struct { + UpdatedAt time.Time + ID domain.SessionID +} + +func (q *Queries) UnarchiveSession(ctx context.Context, arg UnarchiveSessionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, unarchiveSession, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const updateSession = `-- name: UpdateSession :exec UPDATE sessions SET issue_id = ?, kind = ?, harness = ?, display_name = ?, diff --git a/backend/internal/storage/sqlite/migrations/0011_add_session_archived_at.sql b/backend/internal/storage/sqlite/migrations/0011_add_session_archived_at.sql new file mode 100644 index 0000000..6cb45be --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0011_add_session_archived_at.sql @@ -0,0 +1,51 @@ +-- +goose Up +-- archived_at soft-hides a terminated session from default UI lists without +-- destroying the row (mirrors projects.archived_at). NULL means not archived. +-- Archiving is user intent recorded by the session service; it is only valid +-- on terminated sessions, and a restore clears it so a running session can +-- never be hidden. +-- +goose StatementBegin +ALTER TABLE sessions ADD COLUMN archived_at TIMESTAMP; +-- +goose StatementEnd + +-- Recreate the sessions update CDC trigger so archive/unarchive flips fan out +-- a session_updated event to connected dashboards. IS NOT is the NULL-safe +-- comparison: NULL -> timestamp and timestamp -> NULL both count as a change. +-- +goose StatementBegin +DROP TRIGGER IF EXISTS sessions_cdc_update; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated + OR (OLD.first_signal_at IS NULL AND NEW.first_signal_at IS NOT NULL) + OR OLD.archived_at IS NOT NEW.archived_at +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, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TRIGGER IF EXISTS sessions_cdc_update; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER sessions_cdc_update +AFTER UPDATE ON sessions +WHEN OLD.activity_state <> NEW.activity_state + OR OLD.is_terminated <> NEW.is_terminated + OR (OLD.first_signal_at IS NULL AND NEW.first_signal_at IS NOT NULL) +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, 'activity', NEW.activity_state, 'isTerminated', json(CASE WHEN NEW.is_terminated THEN 'true' ELSE 'false' END)), + NEW.updated_at); +END; +-- +goose StatementEnd +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN archived_at; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/queries/sessions.sql b/backend/internal/storage/sqlite/queries/sessions.sql index 5c66c07..c840c07 100644 --- a/backend/internal/storage/sqlite/queries/sessions.sql +++ b/backend/internal/storage/sqlite/queries/sessions.sql @@ -20,25 +20,37 @@ WHERE id = ?; -- name: GetSession :one SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, archived_at FROM sessions WHERE id = ?; -- name: ListSessionsByProject :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, archived_at FROM sessions WHERE project_id = ? ORDER BY num; -- name: ListAllSessions :many SELECT id, project_id, num, issue_id, kind, harness, activity_state, activity_last_at, is_terminated, branch, workspace_path, - runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at + runtime_handle_id, agent_session_id, prompt, created_at, updated_at, display_name, first_signal_at, archived_at FROM sessions ORDER BY project_id, num; -- name: RenameSession :execrows UPDATE sessions SET display_name = ?, updated_at = ? WHERE id = ?; +-- name: ArchiveSession :execrows +-- ArchiveSession soft-hides a terminated session. The is_terminated guard is +-- in the statement so a concurrent restore can't race the service's +-- pre-check; the archived_at IS NULL guard makes a repeat archive a no-op +-- (0 rows) instead of moving the timestamp. +UPDATE sessions SET archived_at = ?, updated_at = ? +WHERE id = ? AND is_terminated = 1 AND archived_at IS NULL; + +-- name: UnarchiveSession :execrows +UPDATE sessions SET archived_at = NULL, updated_at = ? +WHERE id = ? AND archived_at IS NOT NULL; + -- name: SessionIsSeed :one -- SessionIsSeed reports whether the session id matches a row still in seed -- state (see DeleteSeedSession for the conditions). Callers probe with this diff --git a/backend/internal/storage/sqlite/store/session_store.go b/backend/internal/storage/sqlite/store/session_store.go index 84f17a5..9a21f9b 100644 --- a/backend/internal/storage/sqlite/store/session_store.go +++ b/backend/internal/storage/sqlite/store/session_store.go @@ -56,6 +56,35 @@ func (s *Store) RenameSession(ctx context.Context, id domain.SessionID, displayN return rows > 0, nil } +// ArchiveSession soft-hides a terminated session. Returns ok=false when no +// row matched: the session is absent, not terminated, or already archived +// (the caller distinguishes those by reading the record first). +func (s *Store) ArchiveSession(ctx context.Context, id domain.SessionID, at time.Time) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + rows, err := s.qw.ArchiveSession(ctx, gen.ArchiveSessionParams{ + ArchivedAt: timeToNullTime(at), + UpdatedAt: at, + ID: id, + }) + if err != nil { + return false, fmt.Errorf("archive session %s: %w", id, err) + } + return rows > 0, nil +} + +// UnarchiveSession clears a session's archived_at. Returns ok=false when the +// session was absent or not archived (both benign no-ops for callers). +func (s *Store) UnarchiveSession(ctx context.Context, id domain.SessionID, at time.Time) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + rows, err := s.qw.UnarchiveSession(ctx, gen.UnarchiveSessionParams{UpdatedAt: at, ID: id}) + if err != nil { + return false, fmt.Errorf("unarchive session %s: %w", id, err) + } + return rows > 0, nil +} + // DeleteSession removes a session row, but only if it is still in seed state // (no workspace, no runtime handle, no agent session id, no prompt, and not // already terminated). Rows that have observable spawn output are immutable @@ -182,6 +211,7 @@ func rowToRecord(row gen.Session) domain.SessionRecord { }, FirstSignalAt: nullTimeToTime(row.FirstSignalAt), IsTerminated: row.IsTerminated, + ArchivedAt: nullTimeToTime(row.ArchivedAt), Metadata: domain.SessionMetadata{ Branch: row.Branch, WorkspacePath: row.WorkspacePath, diff --git a/backend/internal/storage/sqlite/store/store_test.go b/backend/internal/storage/sqlite/store/store_test.go index 9571ef5..a64e3c9 100644 --- a/backend/internal/storage/sqlite/store/store_test.go +++ b/backend/internal/storage/sqlite/store/store_test.go @@ -302,6 +302,73 @@ func TestSessionFirstSignalRoundTrip(t *testing.T) { } } +func TestSessionArchiveRoundTrip(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedProject(t, s, "mer") + r, _ := s.CreateSession(ctx, sampleRecord("mer")) + at := time.Now().UTC().Truncate(time.Second) + + // A running session never archives: the is_terminated guard is in the SQL. + if ok, err := s.ArchiveSession(ctx, r.ID, at); err != nil || ok { + t.Fatalf("archive of running session: ok=%v err=%v", ok, err) + } + + r.IsTerminated = true + if err := s.UpdateSession(ctx, r); err != nil { + t.Fatal(err) + } + if ok, err := s.ArchiveSession(ctx, r.ID, at); err != nil || !ok { + t.Fatalf("archive: ok=%v err=%v", ok, err) + } + got, _, _ := s.GetSession(ctx, r.ID) + if !got.ArchivedAt.Equal(at) { + t.Fatalf("archived_at = %v, want %v", got.ArchivedAt, at) + } + // Re-archiving is a no-op so the timestamp never moves. + if ok, _ := s.ArchiveSession(ctx, r.ID, at.Add(time.Hour)); ok { + t.Fatalf("repeat archive must not match a row") + } + + // A full lifecycle update must not clobber the archive fact. + got.Activity.State = domain.ActivityIdle + if err := s.UpdateSession(ctx, got); err != nil { + t.Fatal(err) + } + kept, _, _ := s.GetSession(ctx, r.ID) + if !kept.ArchivedAt.Equal(at) { + t.Fatalf("UpdateSession clobbered archived_at: %v", kept.ArchivedAt) + } + + // Archive flips fan out CDC events (session_created + archive + unarchive; + // the activity update above also logs one). + if ok, err := s.UnarchiveSession(ctx, r.ID, at.Add(time.Minute)); err != nil || !ok { + t.Fatalf("unarchive: ok=%v err=%v", ok, err) + } + cleared, _, _ := s.GetSession(ctx, r.ID) + if !cleared.ArchivedAt.IsZero() { + t.Fatalf("archived_at not cleared: %v", cleared.ArchivedAt) + } + if ok, _ := s.UnarchiveSession(ctx, r.ID, at); ok { + t.Fatalf("repeat unarchive must not match a row") + } + + evs, err := s.EventsAfter(ctx, 0, 100) + if err != nil { + t.Fatal(err) + } + var updates int + for _, e := range evs { + if string(e.Type) == "session_updated" { + updates++ + } + } + // terminate + archive + activity flip + unarchive = 4 update events. + if updates != 4 { + t.Fatalf("session_updated events = %d, want 4 (archive flips must reach the change log)", updates) + } +} + func TestPRCRUD(t *testing.T) { s := newTestStore(t) ctx := context.Background() diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8e521b3..31c4134 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -247,6 +247,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/sessions/{sessionId}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Archive a terminated session (soft-hide it from default lists) */ + post: operations["archiveSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/sessions/{sessionId}/git": { parameters: { query?: never; @@ -417,6 +434,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/sessions/{sessionId}/unarchive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Return an archived session to default lists */ + post: operations["unarchiveSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/sessions/cleanup": { parameters: { query?: never; @@ -458,6 +492,10 @@ export interface components { model?: string; permissions?: string; }; + ArchiveSessionResponse: { + ok: boolean; + sessionId: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -649,6 +687,7 @@ export interface components { displayName?: string; harness?: string; id: string; + isArchived?: boolean; isTerminated: boolean; issueId?: string; kind: string; @@ -710,6 +749,10 @@ export interface components { projectId: string; prompt?: string; }; + UnarchiveSessionResponse: { + ok: boolean; + sessionId: string; + }; WorkspaceRepo: { name: string; relativePath: string; @@ -1388,6 +1431,8 @@ export interface operations { orchestratorOnly?: null | boolean; /** @description When true, return only fresh non-terminated sessions. */ fresh?: null | boolean; + /** @description When true, return only archived sessions; when false, exclude archived sessions. Omitted, both are returned. */ + archived?: null | boolean; }; header?: never; path?: never; @@ -1642,6 +1687,56 @@ export interface operations { }; }; }; + archiveSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArchiveSessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; getSessionGitStatus: { parameters: { query?: never; @@ -2226,6 +2321,47 @@ export interface operations { }; }; }; + unarchiveSession: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Session identifier, e.g. project-1. */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnarchiveSessionResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; + }; + }; cleanupSessions: { parameters: { query?: { diff --git a/frontend/src/renderer/components/SessionView.tsx b/frontend/src/renderer/components/SessionView.tsx index f3c0a54..613d4a6 100644 --- a/frontend/src/renderer/components/SessionView.tsx +++ b/frontend/src/renderer/components/SessionView.tsx @@ -49,7 +49,11 @@ export function SessionView({ sessionId, projectId }: SessionViewProps) { const { daemonStatus } = useShell(); const inspectorRef = useRef(null); - const session = workspaces.flatMap((workspace) => workspace.sessions).find((s) => s.id === sessionId); + // Archived sessions stay reachable: their sidebar disclosure rows navigate + // here like any other worker. + const session = workspaces + .flatMap((workspace) => [...workspace.sessions, ...(workspace.archivedSessions ?? [])]) + .find((s) => s.id === sessionId); const isOrchestrator = session ? isOrchestratorSession(session) : false; const workspace = (session && workspaces.find((w) => w.id === session.workspaceId)) ?? diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx new file mode 100644 index 0000000..b474619 --- /dev/null +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, test, vi } from "vitest"; +import { Sidebar } from "./Sidebar"; +import { SidebarProvider } from "./ui/sidebar"; +import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace"; + +// Selection comes from the router in production; the archive interactions +// under test only need stable params and a recordable navigate. +const { navigateMock } = vi.hoisted(() => ({ navigateMock: vi.fn() })); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigateMock, + useParams: () => ({}), + useRouterState: ({ select }: { select: (state: { location: { pathname: string } }) => string }) => + select({ location: { pathname: "/" } }), +})); +vi.mock("../hooks/useEventsConnection", () => ({ useEventsConnection: () => "connected" })); + +function worker(overrides: Partial = {}): WorkspaceSession { + return { + id: "sess-1", + workspaceId: "proj-1", + workspaceName: "my-app", + title: "old-task", + provider: "claude-code", + kind: "worker", + branch: "session/sess-1", + status: "terminated", + updatedAt: "2026-06-11T00:00:00Z", + ...overrides, + }; +} + +function renderSidebar(workspaces: WorkspaceSummary[]) { + const onArchiveSession = vi.fn(async () => {}); + const onUnarchiveSession = vi.fn(async () => {}); + render( + + {})} + onNewWorker={vi.fn()} + onUnarchiveSession={onUnarchiveSession} + workspaces={workspaces} + /> + , + ); + return { onArchiveSession, onUnarchiveSession }; +} + +test("archives a terminated worker from its row's context menu", async () => { + const user = userEvent.setup(); + const { onArchiveSession } = renderSidebar([ + { id: "proj-1", name: "my-app", path: "/p", sessions: [worker()], archivedSessions: [] }, + ]); + + fireEvent.contextMenu(screen.getByRole("button", { name: "Open old-task" })); + await user.click(await screen.findByRole("menuitem", { name: "Archive worker" })); + + expect(onArchiveSession).toHaveBeenCalledWith("sess-1"); +}); + +test("running workers get no context menu", () => { + renderSidebar([{ id: "proj-1", name: "my-app", path: "/p", sessions: [worker({ status: "working" })] }]); + + fireEvent.contextMenu(screen.getByRole("button", { name: "Open old-task" })); + + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); +}); + +test("archived workers sit behind the Archived disclosure and offer Unarchive", async () => { + const user = userEvent.setup(); + const { onUnarchiveSession } = renderSidebar([ + { + id: "proj-1", + name: "my-app", + path: "/p", + sessions: [], + archivedSessions: [worker({ archived: true })], + }, + ]); + + // Hidden until the disclosure expands; the project worker count ignores them. + expect(screen.queryByRole("button", { name: "Open old-task" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Archived workers in my-app" })); + + fireEvent.contextMenu(screen.getByRole("button", { name: "Open old-task" })); + await user.click(await screen.findByRole("menuitem", { name: "Unarchive worker" })); + + expect(onUnarchiveSession).toHaveBeenCalledWith("sess-1"); +}); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 80137a0..5e539a6 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -1,10 +1,22 @@ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router"; -import { ChevronRight, GitPullRequest, Moon, Plus, Search, Settings, Sun, Waypoints } from "lucide-react"; +import { + Archive, + ArchiveRestore, + ChevronRight, + GitPullRequest, + Moon, + Plus, + Search, + Settings, + Sun, + Waypoints, +} from "lucide-react"; import { useState } from "react"; import { attentionZone, type WorkspaceSession, type WorkspaceSummary, workerSessions } from "../types/workspace"; import { aoBridge } from "../lib/bridge"; import { useEventsConnection } from "../hooks/useEventsConnection"; import { useResizable } from "../hooks/useResizable"; +import { ContextMenu, useContextMenu, type ContextMenuItem } from "./ui/context-menu"; import { DropdownMenu, DropdownMenuContent, @@ -49,6 +61,8 @@ type SidebarProps = { workspaces: WorkspaceSummary[]; onCreateProject: (input: { path: string }) => Promise; onNewWorker: (projectId: string) => void; + onArchiveSession: (sessionId: string) => Promise; + onUnarchiveSession: (sessionId: string) => Promise; }; // Selection state comes from the URL: which project/session is active is the @@ -91,12 +105,52 @@ function SessionDot({ session }: { session: WorkspaceSession }) { // _shell owns open state (synced to the ui-store) and `collapsible="icon"` // replaces the old hand-rolled CollapsedRail — the same tree restyles itself // via group-data-[collapsible=icon] into the 48px letter rail. -export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject, onNewWorker }: SidebarProps) { +export function Sidebar({ + daemonStatus, + workspaceError, + workspaces, + onCreateProject, + onNewWorker, + onArchiveSession, + onUnarchiveSession, +}: SidebarProps) { const selection = useSelection(); const eventsConnection = useEventsConnection(); const { state } = useSidebar(); const theme = useUiStore((s) => s.theme); const toggleTheme = useUiStore((s) => s.toggleTheme); + const { menu, openMenu, closeMenu } = useContextMenu(); + + // Right-click actions on worker rows. Running workers get no menu (kill from + // the row is follow-up work); archive is terminated-only, mirroring the + // daemon's SESSION_NOT_TERMINATED guard. + const sessionMenuItems = (session: WorkspaceSession): ContextMenuItem[] => { + if (session.archived) { + return [ + { + id: "unarchive", + label: "Unarchive worker", + icon: