From 8cd8f1f3dac14cfdf36b6a73804194ef535a8987 Mon Sep 17 00:00:00 2001 From: Ashish Huddar Date: Thu, 11 Jun 2026 16:58:10 +0530 Subject: [PATCH 1/2] feat(sessions): archive worker sessions from the sidebar row Terminated workers can now be archived to declutter the sidebar without destroying the row. Right-clicking a terminated worker offers Archive next to Restore; archived workers move behind a collapsed "Archived (n)" disclosure at the end of the project's worker list, where Unarchive brings them back. Daemon side, sessions gain a nullable archived_at (migration 0011, mirroring the projects soft-delete pattern) with POST /sessions/{id}/archive and /unarchive routes and an `archived` list filter. Archiving requires a terminated session (409 SESSION_NOT_TERMINATED otherwise) and restore clears the flag, so a running agent can never be hidden. The sessions CDC update trigger is recreated so archive flips fan out session_updated events; the full UpdateSession write deliberately leaves archived_at untouched so lifecycle writes can't clobber user intent. Also removes a stray aria-hidden on the context-menu overlay that hid every menu item (kill/restore included) from the accessibility tree. Co-Authored-By: Claude Fable 5 --- backend/internal/cli/dto_drift_e2e_test.go | 8 + backend/internal/domain/session.go | 17 +- backend/internal/httpd/apispec/openapi.yaml | 104 +++++++++++ .../internal/httpd/apispec/specgen/build.go | 23 +++ backend/internal/httpd/controllers/dto.go | 13 ++ .../internal/httpd/controllers/sessions.go | 38 ++++ .../httpd/controllers/sessions_test.go | 76 ++++++++ backend/internal/service/session/service.go | 59 ++++++- .../internal/service/session/service_test.go | 133 ++++++++++++++ backend/internal/storage/sqlite/gen/models.go | 1 + .../storage/sqlite/gen/sessions.sql.go | 50 +++++- .../0011_add_session_archived_at.sql | 51 ++++++ .../storage/sqlite/queries/sessions.sql | 18 +- .../storage/sqlite/store/session_store.go | 30 ++++ .../storage/sqlite/store/store_test.go | 67 +++++++ frontend/src/api/schema.ts | 136 ++++++++++++++ frontend/src/renderer/App.test.tsx | 73 +++++++- frontend/src/renderer/App.tsx | 22 ++- frontend/src/renderer/components/Sidebar.tsx | 167 ++++++++++++++---- .../renderer/components/ui/context-menu.tsx | 1 - .../renderer/hooks/useWorkspaceQuery.test.tsx | 37 ++++ .../src/renderer/hooks/useWorkspaceQuery.ts | 5 +- frontend/src/renderer/types/workspace.ts | 8 + 23 files changed, 1083 insertions(+), 54 deletions(-) create mode 100644 backend/internal/storage/sqlite/migrations/0011_add_session_archived_at.sql 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 07a8a02..aab6548 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -448,6 +448,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: @@ -640,6 +650,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 @@ -1097,6 +1146,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 @@ -1177,6 +1259,16 @@ components: permissions: type: string type: object + ArchiveSessionResponse: + properties: + ok: + type: boolean + sessionId: + type: string + required: + - ok + - sessionId + type: object ClaimPRRequest: properties: allowTakeover: @@ -1604,6 +1696,8 @@ components: type: string id: type: string + isArchived: + type: boolean isTerminated: type: boolean issueId: @@ -1779,6 +1873,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 e401ced..0a490be 100644 --- a/backend/internal/httpd/apispec/specgen/build.go +++ b/backend/internal/httpd/apispec/specgen/build.go @@ -141,6 +141,8 @@ var schemaNames = map[string]string{ "ControllersCleanupSessionsResponse": "CleanupSessionsResponse", "ControllersCleanupSkippedSession": "CleanupSkippedSession", "ControllersKillSessionResponse": "KillSessionResponse", + "ControllersArchiveSessionResponse": "ArchiveSessionResponse", + "ControllersUnarchiveSessionResponse": "UnarchiveSessionResponse", "ControllersRollbackSessionResponse": "RollbackSessionResponse", "ControllersSendSessionMessageRequest": "SendSessionMessageRequest", "ControllersSendSessionMessageResponse": "SendSessionMessageResponse", @@ -435,6 +437,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 1f3d415..3f3d178 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 f19bd83..d76caa4 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 @@ -472,6 +503,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 03c74c2..ff6c472 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -19,6 +19,7 @@ import ( type fakeSessionService struct { sessions map[domain.SessionID]domain.Session + listFilters []sessionsvc.ListFilter sent string cleanupProjects []domain.ProjectID cleanupResult []domain.SessionID @@ -40,6 +41,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 { @@ -86,11 +88,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 @@ -361,6 +387,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 9065bca..d2fb386 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -196,6 +196,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; @@ -366,6 +383,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; @@ -407,6 +441,10 @@ export interface components { model?: string; permissions?: string; }; + ArchiveSessionResponse: { + ok: boolean; + sessionId: string; + }; ClaimPRRequest: { allowTakeover?: null | boolean; pr: string; @@ -572,6 +610,7 @@ export interface components { displayName?: string; harness?: string; id: string; + isArchived?: boolean; isTerminated: boolean; issueId?: string; kind: string; @@ -633,6 +672,10 @@ export interface components { projectId: string; prompt?: string; }; + UnarchiveSessionResponse: { + ok: boolean; + sessionId: string; + }; WorkspaceRepo: { name: string; relativePath: string; @@ -1190,6 +1233,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; @@ -1444,6 +1489,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; @@ -2028,6 +2123,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/App.test.tsx b/frontend/src/renderer/App.test.tsx index 62d1681..8264a7f 100644 --- a/frontend/src/renderer/App.test.tsx +++ b/frontend/src/renderer/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { beforeEach, expect, test, vi } from "vitest"; @@ -19,6 +19,7 @@ const { postMock, patchMock, mockData } = vi.hoisted(() => ({ harness?: string; status: string; isTerminated: boolean; + isArchived?: boolean; updatedAt: string; }[], }, @@ -213,6 +214,76 @@ test("renames the spawned worker when a name is given", async () => { expect(await screen.findByRole("button", { name: "fix-login" })).toBeInTheDocument(); }); +test("archives a terminated worker from its sidebar row menu", async () => { + const user = userEvent.setup(); + mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }]; + mockData.sessions = [ + { + id: "sess-1", + projectId: "proj-1", + displayName: "old-task", + status: "terminated", + isTerminated: true, + updatedAt: new Date().toISOString(), + }, + ]; + postMock.mockImplementationOnce(async () => { + // The daemon stamps archived_at; the post-action refetch sees it. + mockData.sessions[0].isArchived = true; + return { data: { ok: true, sessionId: "sess-1" } }; + }); + + renderApp(); + + fireEvent.contextMenu(await screen.findByRole("button", { name: "old-task" })); + await user.click(await screen.findByRole("menuitem", { name: "Archive worker" })); + + expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/archive", { + params: { path: { sessionId: "sess-1" } }, + }); + // The row leaves the default worker list and lands behind the collapsed + // Archived disclosure. + expect(await screen.findByRole("button", { name: "Archived workers in my-app" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "old-task" })).not.toBeInTheDocument(); +}); + +test("unarchives a worker from the Archived group", async () => { + const user = userEvent.setup(); + mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }]; + mockData.sessions = [ + { + id: "sess-1", + projectId: "proj-1", + displayName: "old-task", + status: "terminated", + isTerminated: true, + isArchived: true, + updatedAt: new Date().toISOString(), + }, + ]; + postMock.mockImplementationOnce(async () => { + mockData.sessions[0].isArchived = false; + return { data: { ok: true, sessionId: "sess-1" } }; + }); + + renderApp(); + + // Archived rows stay hidden until the disclosure is expanded. + const disclosure = await screen.findByRole("button", { name: "Archived workers in my-app" }); + expect(screen.queryByRole("button", { name: "old-task" })).not.toBeInTheDocument(); + await user.click(disclosure); + + fireEvent.contextMenu(await screen.findByRole("button", { name: "old-task" })); + await user.click(await screen.findByRole("menuitem", { name: "Unarchive worker" })); + + expect(postMock).toHaveBeenCalledWith("/api/v1/sessions/{sessionId}/unarchive", { + params: { path: { sessionId: "sess-1" } }, + }); + // Back in the default list; the empty disclosure disappears. + expect(await screen.findByRole("button", { name: "old-task" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Archived workers in my-app" })).not.toBeInTheDocument(); +}); + test("surfaces an error when spawning fails", async () => { const user = userEvent.setup(); mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }]; diff --git a/frontend/src/renderer/App.tsx b/frontend/src/renderer/App.tsx index f4a8213..a23bbe9 100644 --- a/frontend/src/renderer/App.tsx +++ b/frontend/src/renderer/App.tsx @@ -54,7 +54,9 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) { workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? workspaces[0] ?? null; const selectedSession = view === "session" - ? workspaces.flatMap((workspace) => workspace.sessions).find((session) => session.id === selectedSessionId) + ? workspaces + .flatMap((workspace) => [...workspace.sessions, ...(workspace.archivedSessions ?? [])]) + .find((session) => session.id === selectedSessionId) : undefined; const sessionWorkspace = selectedSession ? (workspaces.find((workspace) => workspace.id === selectedSession.workspaceId) ?? selectedWorkspace) @@ -196,6 +198,22 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) { await refetchWorkspaces(); }; + const archiveSession = async (sessionId: string) => { + const { error } = await apiClient.POST("/api/v1/sessions/{sessionId}/archive", { + params: { path: { sessionId } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not archive worker")); + await refetchWorkspaces(); + }; + + const unarchiveSession = async (sessionId: string) => { + const { error } = await apiClient.POST("/api/v1/sessions/{sessionId}/unarchive", { + params: { path: { sessionId } }, + }); + if (error) throw new Error(apiErrorMessage(error, "Could not unarchive worker")); + await refetchWorkspaces(); + }; + const cleanupProject = async (projectId: string) => { const { error } = await apiClient.POST("/api/v1/sessions/cleanup", { params: { query: { project: projectId } }, @@ -241,12 +259,14 @@ export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index 1b67e92..862ec03 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -1,4 +1,18 @@ -import { ChevronsUpDown, CircleStop, Eraser, Folder, Plus, RotateCcw, Search, Settings, Trash2, Waypoints } from "lucide-react"; +import { + Archive, + ArchiveRestore, + ChevronRight, + ChevronsUpDown, + CircleStop, + Eraser, + Folder, + Plus, + RotateCcw, + Search, + Settings, + Trash2, + Waypoints, +} from "lucide-react"; import { useState } from "react"; import { sessionIsActive, sessionNeedsAttention, type WorkspaceSession, type WorkspaceSummary } from "../types/workspace"; import { useUiStore } from "../stores/ui-store"; @@ -16,6 +30,8 @@ type SidebarProps = { onNewWorker: (projectId: string) => void; onKillSession: (sessionId: string) => Promise; onRestoreSession: (sessionId: string) => Promise; + onArchiveSession: (sessionId: string) => Promise; + onUnarchiveSession: (sessionId: string) => Promise; onCleanupProject: (projectId: string) => Promise; onRemoveProject: (projectId: string) => Promise; }; @@ -35,6 +51,8 @@ export function Sidebar({ onNewWorker, onKillSession, onRestoreSession, + onArchiveSession, + onUnarchiveSession, onCleanupProject, onRemoveProject, }: SidebarProps) { @@ -50,27 +68,46 @@ export function Sidebar({ const { agents, needYou } = fleetSummary(workspaces); const eventsConnection = useEventsConnection(); const { menu, openMenu, closeMenu } = useContextMenu(); + const [archivedOpen, setArchivedOpen] = useState>({}); - const sessionMenuItems = (session: WorkspaceSession): ContextMenuItem[] => - session.status === "terminated" - ? [ - { - id: "restore", - label: "Restore worker", - icon:
- {workspace.sessions.map((session) => { - const active = view === "session" && selectedSessionId === session.id; - return ( + {workspace.sessions.map((session) => ( + openMenu(event, sessionMenuItems(session))} + onSelect={() => selectSession(session.id, workspace.id)} + title={session.title} + /> + ))} + + {(workspace.archivedSessions ?? []).length > 0 && ( + <> - ); - })} + {archivedOpen[workspace.id] && + (workspace.archivedSessions ?? []).map((session) => ( + openMenu(event, sessionMenuItems(session))} + onSelect={() => selectSession(session.id, workspace.id)} + title={session.title} + /> + ))} + + )} )) )} @@ -218,6 +278,39 @@ export function Sidebar({ ); } +function SessionRow({ + active, + dimmed, + onContextMenu, + onSelect, + title, +}: { + active: boolean; + dimmed?: boolean; + onContextMenu: (event: React.MouseEvent) => void; + onSelect: () => void; + title: string; +}) { + return ( + + ); +} + function FooterRow({ icon, label, shortcut }: { icon: React.ReactNode; label: string; shortcut: string }) { return (