Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
17 changes: 12 additions & 5 deletions backend/internal/domain/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,25 @@ 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
// plus the derived display Status.
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.
Expand Down
104 changes: 104 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1760,6 +1852,8 @@ components:
type: string
id:
type: string
isArchived:
type: boolean
isTerminated:
type: boolean
issueId:
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/httpd/apispec/specgen/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)",
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/httpd/controllers/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
38 changes: 38 additions & 0 deletions backend/internal/httpd/controllers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading