From 373782cf1fe7cb1c29331c425218b1377b2b7845 Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 17 Jun 2026 03:02:49 +0000 Subject: [PATCH 1/3] feat(sessions): backend archive/unarchive with list filtering U1: exclude archived sessions from ListSessionsForUser; add ListArchivedSessionsForUser; ListSessions serves the archived view via ?archived=true. U2: add POST /sessions/{id}/archive and /unarchive. Archive flips status to archived first (so the reconciler stops managing it), then tears down the DevPod container in the background by reusing the workspace delete action; unarchive flips status back to active. Both are session-membership write-gated and broadcast session_update. --- server/internal/db/queries/sessions.sql | 13 +- server/internal/db/sessions.sql.go | 45 +++- server/internal/handler/archive_test.go | 264 ++++++++++++++++++++++++ server/internal/handler/sessions.go | 126 ++++++++++- server/internal/server/server.go | 2 + 5 files changed, 447 insertions(+), 3 deletions(-) create mode 100644 server/internal/handler/archive_test.go diff --git a/server/internal/db/queries/sessions.sql b/server/internal/db/queries/sessions.sql index dcf811a..ef56d16 100644 --- a/server/internal/db/queries/sessions.sql +++ b/server/internal/db/queries/sessions.sql @@ -4,10 +4,21 @@ -- session_members. session_members is the write/participation gate, not the -- read gate. The session -> project -> team -> team_members chain has one row -- per (session, user) so no DISTINCT is needed. +-- Archived sessions are excluded here; they surface only through +-- ListArchivedSessionsForUser (the Archived view). SELECT s.* FROM sessions s JOIN projects p ON p.id = s.project_id JOIN team_members tm ON tm.team_id = p.team_id -WHERE tm.user_id = $1 +WHERE tm.user_id = $1 AND s.status != 'archived' +ORDER BY s.last_activity_at DESC; + +-- name: ListArchivedSessionsForUser :many +-- Same team-scoped visibility as ListSessionsForUser, restricted to archived +-- sessions. Backs the on-demand Archived view (GET /sessions?archived=true). +SELECT s.* FROM sessions s +JOIN projects p ON p.id = s.project_id +JOIN team_members tm ON tm.team_id = p.team_id +WHERE tm.user_id = $1 AND s.status = 'archived' ORDER BY s.last_activity_at DESC; -- name: GetSession :one diff --git a/server/internal/db/sessions.sql.go b/server/internal/db/sessions.sql.go index 15ff2f0..b73e319 100644 --- a/server/internal/db/sessions.sql.go +++ b/server/internal/db/sessions.sql.go @@ -129,6 +129,47 @@ func (q *Queries) IsSessionTeamMember(ctx context.Context, arg IsSessionTeamMemb return is_member, err } +const listArchivedSessionsForUser = `-- name: ListArchivedSessionsForUser :many +SELECT s.id, s.name, s.project_id, s.status, s.workspace_status, s.plan_content, s.created_at, s.last_activity_at, s.repo_url, s.description FROM sessions s +JOIN projects p ON p.id = s.project_id +JOIN team_members tm ON tm.team_id = p.team_id +WHERE tm.user_id = $1 AND s.status = 'archived' +ORDER BY s.last_activity_at DESC +` + +// Same team-scoped visibility as ListSessionsForUser, restricted to archived +// sessions. Backs the on-demand Archived view (GET /sessions?archived=true). +func (q *Queries) ListArchivedSessionsForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) { + rows, err := q.db.Query(ctx, listArchivedSessionsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Session{} + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.Name, + &i.ProjectID, + &i.Status, + &i.WorkspaceStatus, + &i.PlanContent, + &i.CreatedAt, + &i.LastActivityAt, + &i.RepoUrl, + &i.Description, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listNonArchivedSessions = `-- name: ListNonArchivedSessions :many SELECT id, name, project_id, status, workspace_status, plan_content, created_at, last_activity_at, repo_url, description FROM sessions WHERE status != 'archived' @@ -204,7 +245,7 @@ const listSessionsForUser = `-- name: ListSessionsForUser :many SELECT s.id, s.name, s.project_id, s.status, s.workspace_status, s.plan_content, s.created_at, s.last_activity_at, s.repo_url, s.description FROM sessions s JOIN projects p ON p.id = s.project_id JOIN team_members tm ON tm.team_id = p.team_id -WHERE tm.user_id = $1 +WHERE tm.user_id = $1 AND s.status != 'archived' ORDER BY s.last_activity_at DESC ` @@ -213,6 +254,8 @@ ORDER BY s.last_activity_at DESC // session_members. session_members is the write/participation gate, not the // read gate. The session -> project -> team -> team_members chain has one row // per (session, user) so no DISTINCT is needed. +// Archived sessions are excluded here; they surface only through +// ListArchivedSessionsForUser (the Archived view). func (q *Queries) ListSessionsForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) { rows, err := q.db.Query(ctx, listSessionsForUser, userID) if err != nil { diff --git a/server/internal/handler/archive_test.go b/server/internal/handler/archive_test.go new file mode 100644 index 0000000..6308eaa --- /dev/null +++ b/server/internal/handler/archive_test.go @@ -0,0 +1,264 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/forgeutah/deuce/server/internal/auth" + db "github.com/forgeutah/deuce/server/internal/db" + "github.com/forgeutah/deuce/server/internal/ws" +) + +// archiveFixture spins up an isolated DB schema and seeds a team, a project, +// a session, a session member (alice) and a team-only member (bob, who can +// read the team's sessions but is NOT a session member, so the write gate +// must reject her archive attempts). The handler is built with workspaces=nil +// so archive runs status-only (no container teardown) — the teardown path +// reuses the already-exercised workspace delete action and is verified +// manually / by the existing workspace tests. +type archiveFixture struct { + pool *pgxpool.Pool + queries *db.Queries + router chi.Router + memberID uuid.UUID // session member + teamOnly uuid.UUID // team member, not session member + sessionID uuid.UUID +} + +func newArchiveFixture(t *testing.T) *archiveFixture { + t.Helper() + dburl := os.Getenv("TEST_DATABASE_URL") + if dburl == "" { + t.Skip("TEST_DATABASE_URL not set; skipping integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dburl) + if err != nil { + t.Fatalf("open pool: %v", err) + } + t.Cleanup(pool.Close) + if err := pool.Ping(ctx); err != nil { + t.Fatalf("ping pool: %v", err) + } + if _, err := pool.Exec(ctx, "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"); err != nil { + t.Fatalf("reset schema: %v", err) + } + if err := db.RunMigrations(ctx, pool); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + + q := db.New(pool) + + var teamID, memberID, teamOnly, projectID, sessionID uuid.UUID + if err := pool.QueryRow(ctx, `INSERT INTO teams (name, slug) VALUES ('Test Team', 'test-team') RETURNING id`).Scan(&teamID); err != nil { + t.Fatalf("seed team: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice') RETURNING id`).Scan(&memberID); err != nil { + t.Fatalf("seed member: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ('bob@example.com', 'Bob') RETURNING id`).Scan(&teamOnly); err != nil { + t.Fatalf("seed team-only user: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO projects (name, team_id) VALUES ('Test Project', $1) RETURNING id`, teamID).Scan(&projectID); err != nil { + t.Fatalf("seed project: %v", err) + } + if err := pool.QueryRow(ctx, `INSERT INTO sessions (name, project_id) VALUES ('alice-workspace', $1) RETURNING id`, projectID).Scan(&sessionID); err != nil { + t.Fatalf("seed session: %v", err) + } + if _, err := pool.Exec(ctx, `INSERT INTO session_members (session_id, user_id) VALUES ($1, $2)`, sessionID, memberID); err != nil { + t.Fatalf("seed session_member: %v", err) + } + // Both users are on the team (team membership = read gate). + if _, err := pool.Exec(ctx, `INSERT INTO team_members (team_id, user_id) VALUES ($1, $2), ($1, $3)`, teamID, memberID, teamOnly); err != nil { + t.Fatalf("seed team_members: %v", err) + } + + h := New(q, pool, ws.NewHub(), "", nil, nil, nil, "", "") + + r := chi.NewRouter() + r.Use(auth.Middleware("")) + r.Get("/api/sessions", h.ListSessions) + r.Post("/api/sessions/{sessionID}/archive", h.ArchiveSession) + r.Post("/api/sessions/{sessionID}/unarchive", h.UnarchiveSession) + + return &archiveFixture{ + pool: pool, + queries: q, + router: r, + memberID: memberID, + teamOnly: teamOnly, + sessionID: sessionID, + } +} + +func (f *archiveFixture) do(t *testing.T, method, path, asUserID string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + if asUserID != "" { + req.Header.Set("X-User-ID", asUserID) + } + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +// statusOf reads the current status column for the fixture's session. +func (f *archiveFixture) statusOf(t *testing.T) string { + t.Helper() + s, err := f.queries.GetSession(context.Background(), f.sessionID) + if err != nil { + t.Fatalf("get session: %v", err) + } + return s.Status +} + +// listIDs returns the session IDs the member sees from GET /api/sessions +// (optionally the archived view). +func (f *archiveFixture) listIDs(t *testing.T, archived bool) map[string]bool { + t.Helper() + path := "/api/sessions" + if archived { + path += "?archived=true" + } + rec := f.do(t, http.MethodGet, path, f.memberID.String()) + if rec.Code != http.StatusOK { + t.Fatalf("list: want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var sessions []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &sessions); err != nil { + t.Fatalf("decode list: %v", err) + } + ids := make(map[string]bool, len(sessions)) + for _, s := range sessions { + ids[s.ID] = true + } + return ids +} + +// TestArchiveSession_FlipsStatusAndFiltersList covers R1/R3/R4: archiving a +// session flips status to archived, removes it from the default sidebar list, +// and surfaces it only through the archived view. +func TestArchiveSession_FlipsStatusAndFiltersList(t *testing.T) { + f := newArchiveFixture(t) + + // Precondition: visible in the default list, status active. + if !f.listIDs(t, false)[f.sessionID.String()] { + t.Fatalf("precondition: session should be in default list") + } + if got := f.statusOf(t); got != "active" { + t.Fatalf("precondition: status want active, got %q", got) + } + + rec := f.do(t, http.MethodPost, "/api/sessions/"+f.sessionID.String()+"/archive", f.memberID.String()) + if rec.Code != http.StatusOK { + t.Fatalf("archive: want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if got := f.statusOf(t); got != "archived" { + t.Errorf("status after archive: want archived, got %q", got) + } + if f.listIDs(t, false)[f.sessionID.String()] { + t.Errorf("archived session must not appear in default list") + } + if !f.listIDs(t, true)[f.sessionID.String()] { + t.Errorf("archived session must appear in archived list") + } +} + +// TestArchiveSession_StatusOnlyWhenWorkspaceUnavailable covers the +// DevPod-unavailable path: with no workspace manager, archive still succeeds +// (status-only) and does not touch workspace_status. +func TestArchiveSession_StatusOnlyWhenWorkspaceUnavailable(t *testing.T) { + f := newArchiveFixture(t) + + before, err := f.queries.GetSession(context.Background(), f.sessionID) + if err != nil { + t.Fatalf("get session: %v", err) + } + + rec := f.do(t, http.MethodPost, "/api/sessions/"+f.sessionID.String()+"/archive", f.memberID.String()) + if rec.Code != http.StatusOK { + t.Fatalf("archive: want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + + after, err := f.queries.GetSession(context.Background(), f.sessionID) + if err != nil { + t.Fatalf("get session: %v", err) + } + if after.WorkspaceStatus != before.WorkspaceStatus { + t.Errorf("workspace_status should be untouched without devpod: was %q, now %q", + before.WorkspaceStatus, after.WorkspaceStatus) + } +} + +// TestArchiveSession_NonMemberForbidden covers R6: a team member who is NOT a +// session member is rejected by the write gate, and the status is unchanged. +func TestArchiveSession_NonMemberForbidden(t *testing.T) { + f := newArchiveFixture(t) + + rec := f.do(t, http.MethodPost, "/api/sessions/"+f.sessionID.String()+"/archive", f.teamOnly.String()) + if rec.Code != http.StatusForbidden { + t.Fatalf("archive by non-member: want 403, got %d body=%s", rec.Code, rec.Body.String()) + } + if got := f.statusOf(t); got != "active" { + t.Errorf("status must be unchanged after rejected archive: got %q", got) + } +} + +// TestUnarchiveSession_RestoresStatus covers R5: unarchiving flips status back +// to active and returns the session to the default list. +func TestUnarchiveSession_RestoresStatus(t *testing.T) { + f := newArchiveFixture(t) + + if rec := f.do(t, http.MethodPost, "/api/sessions/"+f.sessionID.String()+"/archive", f.memberID.String()); rec.Code != http.StatusOK { + t.Fatalf("archive: want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + + rec := f.do(t, http.MethodPost, "/api/sessions/"+f.sessionID.String()+"/unarchive", f.memberID.String()) + if rec.Code != http.StatusOK { + t.Fatalf("unarchive: want 200, got %d body=%s", rec.Code, rec.Body.String()) + } + if got := f.statusOf(t); got != "active" { + t.Errorf("status after unarchive: want active, got %q", got) + } + if !f.listIDs(t, false)[f.sessionID.String()] { + t.Errorf("restored session must reappear in default list") + } + if f.listIDs(t, true)[f.sessionID.String()] { + t.Errorf("restored session must not appear in archived list") + } +} + +// TestArchiveSession_InvalidUUID covers the path-param parse branch. +func TestArchiveSession_InvalidUUID(t *testing.T) { + f := newArchiveFixture(t) + + rec := f.do(t, http.MethodPost, "/api/sessions/not-a-uuid/archive", f.memberID.String()) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid uuid: want 400, got %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Error.Code != "INVALID_SESSION_ID" { + t.Errorf("error code: want INVALID_SESSION_ID, got %q", body.Error.Code) + } +} diff --git a/server/internal/handler/sessions.go b/server/internal/handler/sessions.go index acd9be6..f956e2c 100644 --- a/server/internal/handler/sessions.go +++ b/server/internal/handler/sessions.go @@ -85,7 +85,17 @@ func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) { return } - sessions, err := h.queries.ListSessionsForUser(r.Context(), userID) + // ?archived=true returns only archived sessions (the Archived view); the + // default returns only non-archived sessions (the normal sidebar). + archived := r.URL.Query().Get("archived") + listArchived := archived == "true" || archived == "1" + + var sessions []db.Session + if listArchived { + sessions, err = h.queries.ListArchivedSessionsForUser(r.Context(), userID) + } else { + sessions, err = h.queries.ListSessionsForUser(r.Context(), userID) + } if err != nil { writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to list sessions") return @@ -346,6 +356,120 @@ func (h *Handler) UpdateSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, sr) } +// ArchiveSession POST /api/sessions/{sessionID}/archive retires a session: +// it flips status to "archived" (preserving all history) and tears down the +// session's DevPod container in the background to reclaim resources. The +// session disappears from the normal sidebar (ListSessionsForUser excludes +// archived) and surfaces only through the Archived view. +func (h *Handler) ArchiveSession(w http.ResponseWriter, r *http.Request) { + sessionID, err := uuid.Parse(chi.URLParam(r, "sessionID")) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_SESSION_ID", "invalid session ID") + return + } + + userID, err := uuid.Parse(getUserID(r)) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_USER", "invalid user ID") + return + } + + // Write gate: archiving retires the session and destroys its container, so + // it requires SESSION membership. Runs before any existence lookup. + if !h.requireSessionMember(w, r, sessionID, userID) { + return + } + + // Flip status to archived FIRST, before teardown. The reconciler keys off + // ListNonArchivedSessions, so flipping first removes the session from its + // view and prevents a race to restart the container mid-teardown. + session, err := h.queries.UpdateSessionStatus(r.Context(), db.UpdateSessionStatusParams{ + ID: sessionID, + Status: "archived", + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to archive session") + return + } + + // Tear down the container in the background when devpod is available and a + // container may still exist. Reuses the shared delete action path, which + // writes the terminal workspace_status ("missing"/"failed") and broadcasts + // it. Archive still succeeds (status-only) when devpod is unavailable. + if h.workspaces != nil && h.workspaces.Available() && + session.WorkspaceStatus != "missing" && !isTransitionalStatus(session.WorkspaceStatus) { + if _, derr := h.queries.UpdateSessionWorkspaceStatus(r.Context(), db.UpdateSessionWorkspaceStatusParams{ + ID: sessionID, + WorkspaceStatus: "deleting", + }); derr == nil { + session.WorkspaceStatus = "deleting" + } + h.workspaceActions.Add(1) + go func() { + defer h.workspaceActions.Done() + h.runWorkspaceAction(sessionID, session.Name, session.RepoUrl, actionDelete) + }() + } + + sr, err := h.buildSessionResponse(r, session, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to build session") + return + } + + // Broadcast the archived state; clients refetch the (now archived-filtered) + // list and drop the session from the sidebar. + if msg, mErr := ws.NewServerMessage(ws.TypeSessionUpdate, sessionID.String(), sr); mErr == nil { + h.hub.BroadcastToSession(sessionID.String(), msg, nil) + } + + writeJSON(w, http.StatusOK, sr) +} + +// UnarchiveSession POST /api/sessions/{sessionID}/unarchive restores an +// archived session by flipping status back to "active". The container was torn +// down at archive time, so workspace_status remains "missing"; the session +// reappears in the sidebar with the normal start-workspace affordance. +func (h *Handler) UnarchiveSession(w http.ResponseWriter, r *http.Request) { + sessionID, err := uuid.Parse(chi.URLParam(r, "sessionID")) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_SESSION_ID", "invalid session ID") + return + } + + userID, err := uuid.Parse(getUserID(r)) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_USER", "invalid user ID") + return + } + + // Write gate: same membership requirement as archive. + if !h.requireSessionMember(w, r, sessionID, userID) { + return + } + + session, err := h.queries.UpdateSessionStatus(r.Context(), db.UpdateSessionStatusParams{ + ID: sessionID, + Status: "active", + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to restore session") + return + } + + sr, err := h.buildSessionResponse(r, session, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to build session") + return + } + + if msg, mErr := ws.NewServerMessage(ws.TypeSessionUpdate, sessionID.String(), sr); mErr == nil { + h.hub.BroadcastToSession(sessionID.String(), msg, nil) + } + + writeJSON(w, http.StatusOK, sr) +} + // vscodeURIResponse is the JSON body returned by GetSessionVSCodeURI. type vscodeURIResponse struct { URI string `json:"uri"` diff --git a/server/internal/server/server.go b/server/internal/server/server.go index 1eb2f16..d12bdea 100644 --- a/server/internal/server/server.go +++ b/server/internal/server/server.go @@ -207,6 +207,8 @@ func (s *Server) Router() http.Handler { r.Route("/{sessionID}", func(r chi.Router) { r.Get("/", h.GetSession) r.Patch("/", h.UpdateSession) + r.Post("/archive", h.ArchiveSession) + r.Post("/unarchive", h.UnarchiveSession) r.Post("/join", h.JoinSession) r.Post("/members", h.AddSessionMember) r.Delete("/members/{userID}", h.RemoveSessionMember) From f722becf489ced48f87d7e52b4a85e53bd8d2ceb Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Wed, 17 Jun 2026 03:04:32 +0000 Subject: [PATCH 2/3] feat(sessions): frontend archive API + store actions U3: add listArchivedSessions, archiveSession, unarchiveSession API wrappers. U4: add archivedSessions slice plus loadArchivedSessions / archiveSession / restoreSession store actions. archiveSession optimistically drops the session from the sidebar and clears activeSessionId when the archived session was active; restoreSession drops it from the archived view. Kept archivedSessions separate from sessions so the session_update list refetch can't clobber it. --- src/lib/api.ts | 10 +++ src/stores/session-store.test.ts | 118 +++++++++++++++++++++++++++++++ src/stores/session-store.ts | 42 +++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/stores/session-store.test.ts diff --git a/src/lib/api.ts b/src/lib/api.ts index 8d75bb1..df2d2cb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -147,6 +147,16 @@ export const api = { listSessions: () => request("/sessions"), + // Archived sessions are excluded from listSessions; fetch them on demand + // for the Archived view. + listArchivedSessions: () => request("/sessions?archived=true"), + + archiveSession: (id: string) => + request(`/sessions/${id}/archive`, { method: "POST" }), + + unarchiveSession: (id: string) => + request(`/sessions/${id}/unarchive`, { method: "POST" }), + getSession: (id: string) => request(`/sessions/${id}`), createSession: (body: CreateSessionBody) => diff --git a/src/stores/session-store.test.ts b/src/stores/session-store.test.ts new file mode 100644 index 0000000..202441f --- /dev/null +++ b/src/stores/session-store.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Session } from "@/types"; + +// Mock the API layer so the store's archive actions don't hit the network. +// Each test configures the resolved values it needs. +vi.mock("@/lib/api", () => ({ + api: { + archiveSession: vi.fn(async () => undefined), + unarchiveSession: vi.fn(async () => undefined), + listArchivedSessions: vi.fn(async () => []), + }, +})); + +import { useSessionStore } from "./session-store"; +import { api } from "@/lib/api"; + +function session(overrides: Partial): Session { + return { + id: "s1", + name: "alpha", + description: "", + projectId: "p1", + status: "active", + members: [], + unreadCount: 0, + createdAt: "2026-06-17T00:00:00Z", + lastActivityAt: "2026-06-17T00:00:00Z", + workspaceStatus: "ready", + planContent: "", + ...overrides, + }; +} + +const initial = useSessionStore.getState(); + +beforeEach(() => { + vi.clearAllMocks(); + // Reset the slices these tests touch. + useSessionStore.setState({ + sessions: [], + archivedSessions: [], + activeSessionId: null, + }); + (api.listArchivedSessions as ReturnType).mockResolvedValue([]); +}); + +describe("session-store archive actions", () => { + it("archiveSession removes the session from the sidebar list", async () => { + useSessionStore.setState({ + sessions: [session({ id: "s1" }), session({ id: "s2", name: "beta" })], + }); + + await initial.archiveSession("s1"); + + const ids = useSessionStore.getState().sessions.map((s) => s.id); + expect(ids).toEqual(["s2"]); + expect(api.archiveSession).toHaveBeenCalledWith("s1"); + }); + + it("archiveSession clears activeSessionId when the active session is archived", async () => { + useSessionStore.setState({ + sessions: [session({ id: "s1" })], + activeSessionId: "s1", + }); + + await initial.archiveSession("s1"); + + expect(useSessionStore.getState().activeSessionId).toBeNull(); + }); + + it("archiveSession leaves activeSessionId intact when a different session is archived", async () => { + useSessionStore.setState({ + sessions: [session({ id: "s1" }), session({ id: "s2" })], + activeSessionId: "s2", + }); + + await initial.archiveSession("s1"); + + expect(useSessionStore.getState().activeSessionId).toBe("s2"); + }); + + it("archiveSession on an unknown session leaves the list unchanged (idempotent)", async () => { + useSessionStore.setState({ sessions: [session({ id: "s1" })] }); + + await initial.archiveSession("does-not-exist"); + + expect(useSessionStore.getState().sessions.map((s) => s.id)).toEqual([ + "s1", + ]); + }); + + it("restoreSession removes the session from the archived list", async () => { + useSessionStore.setState({ + archivedSessions: [ + session({ id: "s1", status: "archived" }), + session({ id: "s2", status: "archived" }), + ], + }); + + await initial.restoreSession("s1"); + + const ids = useSessionStore.getState().archivedSessions.map((s) => s.id); + expect(ids).toEqual(["s2"]); + expect(api.unarchiveSession).toHaveBeenCalledWith("s1"); + }); + + it("loadArchivedSessions populates archivedSessions from the API", async () => { + (api.listArchivedSessions as ReturnType).mockResolvedValue([ + session({ id: "a1", status: "archived" }), + ]); + + await initial.loadArchivedSessions(); + + expect(useSessionStore.getState().archivedSessions.map((s) => s.id)).toEqual( + ["a1"], + ); + }); +}); diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index ee3385c..7964ec0 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -27,6 +27,10 @@ interface SessionState { teams: Team[]; projects: Project[]; sessions: Session[]; + // Archived sessions, loaded on demand for the Archived view. Kept separate + // from `sessions` so the session_update list refetch (which replaces + // `sessions` with the non-archived list) never clobbers the archived view. + archivedSessions: Session[]; messages: Record; activities: Record; fileTrees: Record; @@ -82,6 +86,13 @@ interface SessionState { sessionId: string, status: Session["workspaceStatus"], ) => void; + // Archive: hide a session from the sidebar (and tear down its container, + // server-side) while preserving its history. loadArchivedSessions fetches + // the Archived view; archiveSession/restoreSession move a session between + // the live and archived lists optimistically. + loadArchivedSessions: () => Promise; + archiveSession: (sessionId: string) => Promise; + restoreSession: (sessionId: string) => Promise; // Data setters setCurrentUser: (user: User | null) => void; @@ -103,6 +114,7 @@ export const useSessionStore = create((set, get) => ({ teams: [], projects: [], sessions: [], + archivedSessions: [], messages: {}, activities: {}, fileTrees: {}, @@ -352,6 +364,36 @@ export const useSessionStore = create((set, get) => ({ ), })), + loadArchivedSessions: async () => { + const archived = await api.listArchivedSessions(); + set({ archivedSessions: archived }); + }, + + archiveSession: async (sessionId) => { + await api.archiveSession(sessionId); + // Optimistically drop it from the sidebar. The server also broadcasts a + // session_update that refetches the (now archived-filtered) list; doing + // it here too keeps the UI snappy and consistent if that refetch lags. + set((state) => ({ + sessions: state.sessions.filter((s) => s.id !== sessionId), + // If the archived session was active, clear the pointer so the UI + // doesn't strand on a now-hidden view (mirrors setSessions). + activeSessionId: + state.activeSessionId === sessionId ? null : state.activeSessionId, + })); + }, + + restoreSession: async (sessionId) => { + await api.unarchiveSession(sessionId); + // Drop it from the archived view; the session_update refetch (or the next + // listSessions) repopulates the live sidebar list. + set((state) => ({ + archivedSessions: state.archivedSessions.filter( + (s) => s.id !== sessionId, + ), + })); + }, + setCurrentUser: (currentUser) => set({ currentUser }), setTeams: (teams) => set({ teams }), setProjects: (projects) => set({ projects }), From d3626d06d3503a8858aff24b9ec27b375763e2af Mon Sep 17 00:00:00 2001 From: Clint Berry Date: Tue, 28 Jul 2026 16:32:32 +0000 Subject: [PATCH 3/3] feat(sessions): archive/unarchive UI dialogs + plan docs Complete the archive-sessions frontend: ArchiveSessionDialog and ArchivedSessionsDialog, wired into SessionSidebar with the store actions added in earlier commits. Also add planning docs and gitignore the air hot-reload build artifact (server/tmp/). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + ...26-06-16-001-feat-team-skill-repos-plan.md | 261 ++++++++++++++++++ ...eat-devcontainer-fast-init-and-gui-plan.md | 231 ++++++++++++++++ ...26-06-16-004-feat-archive-sessions-plan.md | 234 ++++++++++++++++ src/components/layout/SessionSidebar.tsx | 47 +++- .../session/ArchiveSessionDialog.tsx | 95 +++++++ .../session/ArchivedSessionsDialog.tsx | 153 ++++++++++ src/stores/session-store.ts | 14 + 8 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-06-16-001-feat-team-skill-repos-plan.md create mode 100644 docs/plans/2026-06-16-003-feat-devcontainer-fast-init-and-gui-plan.md create mode 100644 docs/plans/2026-06-16-004-feat-archive-sessions-plan.md create mode 100644 src/components/session/ArchiveSessionDialog.tsx create mode 100644 src/components/session/ArchivedSessionsDialog.tsx diff --git a/.gitignore b/.gitignore index f8d8e97..0d1a795 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist .claude/worktrees/ .DS_Store server/bin/ +server/tmp/ # server/internal/web/dist is the embed target. The directory must exist so # `go:embed all:dist` resolves, but its contents are build output. Track only diff --git a/docs/plans/2026-06-16-001-feat-team-skill-repos-plan.md b/docs/plans/2026-06-16-001-feat-team-skill-repos-plan.md new file mode 100644 index 0000000..58b78d6 --- /dev/null +++ b/docs/plans/2026-06-16-001-feat-team-skill-repos-plan.md @@ -0,0 +1,261 @@ +--- +title: "feat: Team-global skill repos for the Pi agent" +type: feat +date: 2026-06-16 +status: active +origin: docs/ideation/2026-06-08-single-deuce-agent-ideation.md +related: + - docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md + - docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md + - docs/solutions/architecture-patterns/embedded-ssh-proxy-for-vscode-remote.md +--- + +# feat: Team-global skill repos for the Pi agent + +## Summary + +Let team members register **git skill-repo URLs** through the Deuce app. Deuce stores them per team and, when provisioning each session's DevPod container, writes them into the container's Pi settings (`~/.pi/agent/settings.json` `packages` array) so Pi auto-installs the skills on launch. This gives the single Pi agent ("Deuce") a team-curated, version-controlled skill library without baking skills into the Deuce release or copying skill files around. + +Pi implements the [Agent Skills standard](https://agentskills.io/specification) and loads skills in `--mode rpc` — **verified** against `pi` 0.74.2 (see origin solution doc). Pi's vendored docs (`packages.md`) further state it **auto-installs *missing* git/npm packages listed in `settings.json` on startup**; Deuce leans on that to manage a *list of URLs*, not skill file contents. **Not yet verified** (and load-bearing for R5): that *removing* a URL from the array stops the skill loading on next launch, rather than requiring an explicit `pi remove`. A blocking pre-implementation spike (below) settles this before U3/U4 are built; the design carries an imperative-`pi install`/`pi remove` fallback if the declarative-removal assumption fails. + +This plan does **not** collapse the five agent roles into one Deuce agent (tracked separately — `docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md`), and does not build the `@mention`→skill invocation UX. Project-level skills (a repo's own `.pi/skills/`) need no work — Pi auto-discovers them. + +--- + +## Problem Frame + +The single Pi agent runs as a generic coding agent in each session's DevPod container. There is no way for a team to give it a curated, evolving set of capabilities. Skills are the Agent-Skills-standard unit Pi already understands, and Pi can install them from git repos declared in its settings — but nothing in Deuce manages that list or gets it into the container. We need a team-owned registry plus a provisioning hook. + +**Security reality that frames the whole design:** a registered repo runs **arbitrary code inside every team member's container, automatically, on session start** — without the member invoking it. This is a lateral, persistent code-exec capability beyond "I can run code in my own session." Per explicit decision, v1 uses **flat-trust team-membership** for who can register (consistent with Deuce's uniform flat-trust model), with auditing (`added_by`), strict URL validation, an env allowlist for git, and an operator kill-switch as the mitigations. A stricter admin gate is deferred (no role model exists today). + +--- + +## Pre-Implementation Spike (blocking — gates U3/U4) + +The declarative-`settings.json` design (KTD2) rests on a Pi behavior that the origin solution doc did **not** test: that the `packages` array is the authoritative load list, so dropping an entry stops that skill loading. `packages.md` documents auto-install of *missing* packages and a separate explicit `pi remove` — it does not promise removal-by-omission. Before building U3/U4, run a ~10-minute spike against the **pinned** Pi version: + +1. Write `~/.pi/agent/settings.json` with `{"packages":["git:"]}`, launch `pi --mode rpc`, confirm via `get_commands` the skill auto-installs and loads (no explicit `pi install`). +2. Rewrite `settings.json` *without* that entry, relaunch, and check `get_commands`: is the skill gone? + +**Outcome routing:** +- If removal-by-omission works → proceed with KTD2 as written. +- If Pi still loads already-cloned packages (or requires `pi remove`) → U4 must add an explicit reconciliation step: diff prior vs current array and run `pi remove`/clear `~/.pi/agent/git/...` for dropped entries. KTD2's "declarative ownership expresses removal" rationale is replaced by explicit reconciliation. + +Capture the result as a `/ce-compound` solution doc and have the plan cite *that*, not the skills-discovery doc. + +--- + +## Requirements + +- **R1.** A team has a set of registered skill repos; any team member can list, add, and remove them (flat-trust, team-membership gated). No enable/disable in v1 — remove and re-add instead (deferred; see Scope Boundaries). +- **R2.** Each repo is a git URL (`https://`, `git:`, or `ssh://` form Pi accepts) with an `added_by` attribution. No ref/pinning in v1 (deferred). +- **R3.** When a session's container is provisioned (create / start / rebuild), Deuce writes the team's registered repos into the container's Pi `settings.json` `packages` array, alongside the existing `npm:pi-subagents` package, as the single declarative source of truth. +- **R4.** Pi installs and loads exactly the listed packages on launch; git clones run non-interactively (no credential prompt hang). +- **R5.** Removing a repo stops it loading on the next session start/rebuild — mechanism contingent on the spike above. **No live revocation in v1:** a repo deleted after it's running keeps executing in active sessions until each is restarted (see Risks). +- **R6.** An operator kill-switch (`DEUCE_SKILL_REPOS_ENABLED`, default off) gates both the API and provisioning so the capability is opt-in per deployment. When off, provisioning writes the **baseline** package set (no skill repos) rather than skipping, so a freshly provisioned container can't inherit stale repos. +- **R7.** Every new resource-scoped route is explicitly auth-gated before any existence lookup (no 404 enumeration oracle), with positive + negative authz tests — including a cross-team test (a `repoID` from team B reached via team A's path must not be read or mutated). +- **R8.** The management UI surfaces an explicit code-execution warning before a member can add a repo (it is the only per-user guardrail under flat-trust) — asserted by a test, not left as soft prose. + +--- + +## Key Technical Decisions + +- **KTD1 — Manage a list of URLs, not skill files.** Deuce never copies skill content. It writes repo URLs into Pi's `settings.json` `packages`; Pi clones + installs. Source of truth = the git repos. (origin solution doc.) +- **KTD2 — Declarative `settings.json` write, not per-repo `pi install`.** Deuce owns the full `packages` array (`npm:pi-subagents` + all registered skill-repo URLs) and writes it whole. Rationale: a CRUD-managed list must propagate *removals*. **Contingent on the spike:** this assumes Pi treats the array as the authoritative load list (dropping an entry stops it loading). If the spike shows Pi only adds missing packages, U4 adds explicit `pi remove`/clone-dir reconciliation (see spike outcome routing) — the declarative *write* stays, but removal becomes explicit. Per-repo `pi install` alone only *adds* and can't express removal, and a hand-write of only skill URLs would clobber the `pi-subagents` entry. The existing standalone `InstallPiPackage(PiSubagentsPackage)` call is folded into this declarative write. (Alternative considered below.) +- **KTD3 — Transport reuses the proven base64-over-`devpod ssh` channel.** New `WritePiSettings` manager method mirrors `InstallPiExtension` (`mkdir -p "$HOME/.pi/agent" && printf %s '' | base64 -d > "$HOME/.pi/agent/settings.json"`). JSON built in Go. Do **not** use the host bind-mount FS shortcut — `~/.pi/...` is outside the `/workspaces/` mount (devpod-docker-workspace-bind-mount learning). +- **KTD4 — Flat-trust team-membership gate (accepted *despite* escalation, not because it's equivalent).** Registration gates on `IsTeamMember` (the only team-scoped primitive; no role model exists). Be honest about what this grants: unlike "a member can run code in their own session" (self-targeted, explicit, ephemeral), registering a repo is **lateral, automatic, silent, and persistent** — it runs in every *other* member's container on their next session start without their consent. Flat-trust is accepted for v1 because (a) no role primitive exists, (b) the kill-switch is default-off, and (c) `added_by` gives an audit trail — *not* because it equals self-session code-exec. If a lighter gate is wanted without a role model, the team-creator is identifiable today. `added_by` recorded for audit. Stricter gating deferred. (broadening-resource-visibility learning: gate every route, before existence lookup.) +- **KTD5 — Git env is an allowlist, injected at launch.** `piLaunchSpec` adds a known, named set (`GIT_TERMINAL_PROMPT=0`, `GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=5'`) so Pi's startup auto-install clones non-interactively. Allowlist, not passthrough — git command-exec vars are injection vectors (embedded-ssh-proxy learning). +- **KTD6 — Hard delete, team-scoped table.** Mirror `user_ssh_keys` (no soft-delete exists in this codebase). `team_skill_repos(team_id FK CASCADE, url, added_by bare-UUID, created_at)`, unique on `(team_id, url)`. +- **KTD7 — Strict URL validation at the boundary.** The URL reaches a `git clone` (and `npm install`) inside the container; validate at registration with an **exact-scheme allowlist via `url.Parse`** (not `strings.HasPrefix`): permit only `https`/`http`/`ssh`/`git` (and `git:`-prefixed shorthand). **Explicitly reject `ext::`, `file://`, `--upload-pack`/option-looking inputs, userinfo `@`-host redirects, whitespace, and control/shell metacharacters.** base64 transport protects the *settings-file write*, not the downstream clone — that's why boundary validation is the real defense. + +--- + +## High-Level Technical Design + +Register-and-propagate flow (browser → API → DB → provisioning → container → Pi): + +```mermaid +sequenceDiagram + participant U as Team member (browser) + participant API as Deuce API (handler) + participant DB as Postgres + participant Prov as provisionAgentTools + participant WM as workspace.Manager + participant C as DevPod container + participant Pi as pi --mode rpc + + U->>API: POST /api/teams/{teamID}/skill-repos {url} + API->>API: IsTeamMember(teamID, user)? (gate before lookup) + API->>API: validate URL scheme/format + API->>DB: INSERT team_skill_repos (added_by=user) + API-->>U: 201 SkillRepo + + Note over Prov,Pi: later — session create/start/rebuild + Prov->>DB: GetSessionTeamID(session) → ListSkillReposByTeam(team) + Prov->>Prov: packages = [npm:pi-subagents, ...repo urls] + Prov->>WM: WritePiSettings(workspace, packages) + WM->>C: base64 settings.json → ~/.pi/agent/settings.json + Note over C,Pi: Pi launched with GIT_* allowlist env + Pi->>Pi: startup: auto-install missing packages (git clone) + Pi->>Pi: load skills → /skill:name available +``` + +--- + +## Implementation Units + +### U1. Data layer: `team_skill_repos` table + sqlc queries + +**Goal:** Persist team-scoped skill repos. +**Requirements:** R1, R2, R6 (enabled flag). +**Dependencies:** none. +**Files:** +- `server/internal/db/migrations/014_team_skill_repos.sql` (create) +- `server/internal/db/queries/skill_repos.sql` (create) +- `server/internal/db/*.go` (regenerated by `make generate` — do not hand-edit) +**Approach:** Mirror `008_user_ssh_keys.sql` / `queries/user_ssh_keys.sql`. Columns: `id UUID PK DEFAULT gen_random_uuid()`, `team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE`, `url TEXT NOT NULL CHECK (length(url) <= 2048)`, `added_by UUID` (bare nullable, mirroring `tasks.requested_by` — no FK so a user delete doesn't block the row), `created_at TIMESTAMPTZ NOT NULL DEFAULT now()`. `CREATE UNIQUE INDEX idx_team_skill_repos_team_url ON team_skill_repos(team_id, url)`. Queries: `ListSkillReposByTeam :many`, `GetSkillRepo :one` (by id AND team_id), `CreateSkillRepo :one` (`INSERT ... RETURNING *`), `DeleteSkillRepo :exec` (by id AND team_id). Also add `GetSessionTeamID :one` (`SELECT p.team_id FROM sessions s JOIN projects p ON p.id = s.project_id WHERE s.id = $1`) — U4 needs it to resolve a session's team; `team_id` is **not** on `sessions`, only reachable via `projects.team_id`. Run `make generate` then `make migrate`. +**Patterns to follow:** `server/internal/db/migrations/008_user_ssh_keys.sql`, `server/internal/db/queries/user_ssh_keys.sql`, goose `-- +goose Up/Down` directives. +**Test scenarios:** `Test expectation: none -- schema + generated queries; behavior is exercised through U2/U4 handler and provisioning tests. Verify migrate up/down runs cleanly and `make generate` produces compiling code.` +**Verification:** `make migrate` then `make migrate-down` succeed; generated `db` package compiles; unique constraint rejects duplicate `(team_id, url)`. + +### U2. REST CRUD handlers + routes (team-membership gated, URL-validated, kill-switch) + +**Goal:** Expose list/add/toggle/delete of skill repos, safely gated. +**Requirements:** R1, R2, R6, R7. +**Dependencies:** U1. +**Files:** +- `server/internal/handler/skill_repos.go` (create) +- `server/internal/handler/skill_repos_test.go` (create) +- `server/internal/server/server.go` (modify — register routes) +- `server/internal/config/config.go` (modify — add `SkillReposEnabled bool \`env:"DEUCE_SKILL_REPOS_ENABLED" envDefault:"false"\``) +- `server/internal/handler/handler.go` (modify — `Handler` has **no** `cfg` field today; add a `skillReposEnabled bool` field and a parameter to `handler.New(...)`, mirroring how `githubToken` is threaded; update test fixtures that call `New()`) +- `src/lib/api.ts`, `src/types/index.ts` (modify — see U6; types referenced here) +**Approach:** Mirror `handler/ssh_keys.go` (list-add-delete shape) and `handler/teams.go` (team-scoped + `IsTeamMember`). Routes under `r.Route("/api", ...)`: `GET /teams/{teamID}/skill-repos`, `POST /teams/{teamID}/skill-repos`, `DELETE /teams/{teamID}/skill-repos/{repoID}` (no PATCH — add/delete only). Each handler: parse `getUserID(r)` + `teamID`; **call `IsTeamMember` and return 403 before any DB existence lookup** (R7, gate-before-lookup); on success run the query. Project `db.TeamSkillRepo` into a camelCase wire struct (`id`, `url`, `addedBy`, `createdAt`) — never return the raw row. Map pgx `23505` → 409. **URL validation (KTD7):** accept only `https://`, `http://`, `ssh://`, `git://` URLs or `git:`-prefixed shorthand; reject anything containing shell metacharacters / whitespace / control chars; cap length. When `SkillReposEnabled` is false, all routes return 404 (`writeError(404, "NOT_FOUND", ...)`) so the feature is invisible when off (R6). +**Patterns to follow:** `handler/ssh_keys.go` (`uuid.Parse(getUserID(r))`, body decode, `writeJSON(w, 201, ...)`), `handler/teams.go` (`IsTeamMember`), `writeError`/`writeJSON` in `handler/handler.go`, route nesting in `server/internal/server/server.go`. +**Test scenarios:** +- Happy: member POSTs valid `https://` repo → 201, row persisted with `added_by` = caller; GET lists it; DELETE removes it (200/204). +- Edge: duplicate `(team_id, url)` → 409. Empty/over-length URL → 400. +- Error/authz (R7): non-member POST/GET/DELETE → **403 before existence check** (use a real repo ID belonging to another team and assert 403, not 404). Malformed `teamID`/`repoID` → 400. +- URL validation: `git:github.com/o/r` and `https://h/r` accepted; `ext::sh -c ...`, `file:///etc`, `https://legit@evil/r`, `--upload-pack=x`, `; rm -rf /`, backticks, `$(...)`, spaces → 400. +- Cross-team (R7): member of team A sends DELETE with a `repoID` that exists in team B → 404, and a follow-up GET via team B confirms the row is **unchanged**. +- Kill-switch: with `DEUCE_SKILL_REPOS_ENABLED=false`, every route → 404. +**Verification:** `go test ./internal/handler/ -run SkillRepo` passes; non-member and cross-team requests are rejected before any row is revealed. + +### U3. `WritePiSettings` provisioning transport + +**Goal:** Write a declarative `~/.pi/agent/settings.json` into a container. +**Requirements:** R3. +**Dependencies:** none (pure transport; consumed by U4). +**Files:** +- `server/internal/workspace/manager.go` (modify — add `WritePiSettings`) +- `server/internal/workspace/manager_test.go` (modify/create — JSON-shape + command-construction test) +**Approach:** Add `func (m *Manager) WritePiSettings(ctx, workspaceID string, packages []string, logFn LogFunc) error`. Build the JSON in Go from a struct `{ "packages": [...] }` (use `json.Marshal`; only emit keys Deuce owns so it merges cleanly conceptually but is written whole). Base64-encode and write via `ExecInWorkspace` exactly like `InstallPiExtension`: `mkdir -p "$HOME/.pi/agent" && printf %s '' | base64 -d > "$HOME/.pi/agent/settings.json"`. Non-fatal at call site (log + slog + return err). Idempotent (safe on every create/start). +**Patterns to follow:** `InstallPiExtension` in `server/internal/workspace/manager.go` (base64-over-`devpod ssh`, `CombinedOutput`, `logFn` one-shot success/failure line). +**Test scenarios:** +- Happy: given `["npm:pi-subagents", "git:github.com/o/r"]`, the marshaled JSON has a `packages` array in that order; the constructed command base64-decodes back to that JSON and targets `~/.pi/agent/settings.json`. +- Edge: empty `packages` → writes `{"packages":[]}` (valid, loads nothing extra). Nil slice → same (use `emit_empty_slices`-style guard so it's `[]` not `null`). +- Error: `ExecInWorkspace` failure → returns error, logs a warning, does not panic. +**Verification:** Unit test asserts JSON shape and decoded command string without needing a live container (mirror how manager tests fake the runner). + +### U4. Wire skill-repo packages into `provisionAgentTools` + +**Goal:** On provisioning, assemble and write the team's enabled packages. +**Requirements:** R3, R5, R6. +**Dependencies:** U1, U3. +**Files:** +- `server/internal/handler/workspace.go` (modify — `provisionAgentTools` signature + body) +- `server/internal/handler/sessions.go` (modify — `startWorkspace` call site, the second caller of `provisionAgentTools`) +- `server/internal/handler/workspace_test.go` (modify/create) +**Approach:** **Thread `sessionID uuid.UUID` into `provisionAgentTools`** — do *not* resolve team from `workspaceID`, which is the non-unique session *name*, not a UUID. Both callers already have `sessionID` in scope: `sessions.go startWorkspace` (session create) and `workspace.go runWorkspaceAction` (start/rebuild). Inside, resolve the team via `GetSessionTeamID(sessionID)` (U1). When `SkillReposEnabled`: call `ListSkillReposByTeam(teamID)`, build `packages := ["npm:pi-subagents", ]`, call `h.workspaces.WritePiSettings(ctx, workspaceID, packages, logFn)`. **Retire the standalone `InstallPiPackage(PiSubagentsPackage)` call** — pi-subagents now lives in the declarative array (KTD2); keep `InstallPiExtension(ask-user)` as-is (extension file, different path — no clobber). **When disabled, still write the baseline `["npm:pi-subagents"]`** (not skip) so a container can't inherit stale skill repos from a settings file written while the feature was on (R6). All calls stay idempotent + non-fatal. +**Approach note (removal semantics, R5 — contingent on spike):** if the spike confirms load-by-list, writing the array whole each provision means a removed/disabled repo simply isn't written and isn't loaded next start. If not, add the `pi remove`/clone-dir reconciliation here. Either way, changes apply on next session start, **not** to live Pi processes (no live revocation — see Risks). +**Patterns to follow:** existing `provisionAgentTools` body in `server/internal/handler/workspace.go`; `h.queries` usage in sibling handlers. +**Test scenarios:** +- Happy: team with two repos → `WritePiSettings` receives `["npm:pi-subagents", url1, url2]`. +- Edge: team with zero repos → `["npm:pi-subagents"]` only. +- Removal (R5): a deleted repo is absent from the next assembled package list. +- Kill-switch (R6): `SkillReposEnabled=false` → `WritePiSettings` receives the baseline `["npm:pi-subagents"]` (no stale skill repos), never the team's repos. +- Integration: provisioning a freshly-created workspace results in a `WritePiSettings` invocation with the resolved team's packages (fake workspace manager records the call). +**Verification:** `go test ./internal/handler/ -run Provision` passes; pi-subagents always present; disabled/removed repos never appear. + +### U5. Inject non-interactive git env into the Pi launch command + +**Goal:** Pi's startup auto-install clones git repos without hanging on credentials. +**Requirements:** R4. +**Dependencies:** none (independent of U1–U4; required for R4 end-to-end). +**Files:** +- `server/internal/agent/pirun/devpod_launcher.go` (modify — `piLaunchSpec`) +- `server/internal/agent/pirun/devpod_launcher_test.go` (modify/create) +**Approach:** In `piLaunchSpec`, append to `extraEnv` a fixed allowlist: `GIT_TERMINAL_PROMPT=0` and `GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=5`. These flow into the container as `devpod ssh --set-env KEY=VALUE` alongside the existing `DEUCE_SYSTEM_PROMPT`. Allowlist only — do not forward arbitrary env (embedded-ssh-proxy learning). Private-repo credentials are **deferred** (v1 = public repos; see Scope Boundaries). +**Patterns to follow:** existing `extraEnv`/`--append-system-prompt` handling in `piLaunchSpec` (`server/internal/agent/pirun/devpod_launcher.go`); `ExecInWorkspace` `--set-env` in `manager.go`. +**Test scenarios:** +- Happy: `piLaunchSpec` output `extraEnv` contains `GIT_TERMINAL_PROMPT=0` and the `GIT_SSH_COMMAND` entry, regardless of whether a system prompt is set. +- Edge: with and without `systemPrompt`, the git env entries are always present and the `--append-system-prompt` arg is unaffected. +**Verification:** `go test ./internal/agent/pirun/ -run LaunchSpec` passes; git env present in every launch. + +### U6. Frontend: API client, types, and a skill-repos management dialog + +**Goal:** Let a team member manage repos from the UI. +**Requirements:** R1, R2, R8. +**Dependencies:** U2. +**Files:** +- `src/types/index.ts` (modify — `SkillRepo` type) +- `src/lib/api.ts` (modify — `listSkillRepos`/`createSkillRepo`/`deleteSkillRepo`) +- `src/components/settings/SkillReposDialog.tsx` (create) +- wherever settings dialogs are launched (modify — add entry point next to SSH keys / agent settings) +**Approach:** Mirror `src/components/settings/SSHKeysDialog.tsx` (full list-add-delete CRUD) for layout and the `api.ts` request pattern. Add a URL input with the same scheme validation hint as the server (client-side convenience only; server validation is authoritative) and a delete control per row (no enable/disable in v1 — mirrors `user_ssh_keys` exactly). Surface a short security note in the dialog ("Skills run with full access inside every session's container — only add repos you trust"). Hide the entry point when the feature is disabled (the API returns 404 → treat as unavailable). +**Patterns to follow:** `src/components/settings/SSHKeysDialog.tsx`, the ssh-keys block in `src/lib/api.ts`, `ApiError` handling. +**Test scenarios:** One required (R8): assert the code-execution warning text renders in the add flow (so the only per-user guardrail can't silently regress). Otherwise UI CRUD over typed API wrappers needs no unit test; if a URL/ref-format helper is extracted, unit-test it (valid git forms accept; `ext::`/`file://`/shell-metachar/empty reject) alongside the existing pure-logic Vitest suites. +**Verification:** Dialog lists, adds (with client validation), toggles, and deletes repos against a running backend; rejects obviously malformed URLs before submit; reflects 409 on duplicate. + +--- + +## Scope Boundaries + +### In scope +- Team-global git skill-repo registry (list / add / delete), team-membership gated, operator kill-switch. +- Declarative `settings.json` provisioning into each container; non-interactive git env at launch. +- Management UI mirroring the SSH-keys dialog. + +### Deferred to Follow-Up Work +- **Enable/disable toggle + `ref` pinning.** Dropped from v1 (add/delete only, mirroring `user_ssh_keys`). Enable/disable is low-value until live propagation exists; `ref` pinning adds injection-validation surface. Return together once live propagation lands. +- **Private-repo credentials** (deploy key / token via `GIT_SSH_COMMAND` or a `DEUCE_SKILL_REPO_GIT_TOKEN` env threaded to the launcher). v1 is public repos. +- **Live propagation** to running Pi processes (v1 applies on next session start/rebuild). +- **Stricter authz** (team-admin role or a per-repo review/approval step) — needs a role model that does not exist yet. +- **Repo health/visibility** (surfacing clone/install failures from the container back into the UI; today they're non-fatal log lines). +- **`/ce-compound` capture** of the "team-registered code runs in every container" security pattern after this lands. + +### Outside this plan +- Collapsing the five agent roles into one Deuce agent (`docs/plans/2026-06-09-001-refactor-single-deuce-agent-plan.md`). +- `@mention`→`/skill:name` invocation UX, provenance badges, skill enumeration via `get_commands`. +- Project-level skills (Pi auto-discovers a repo's `.pi/skills/` — no work needed). + +--- + +## Risks & Mitigations + +- **Arbitrary code execution in every member's container (accepted under flat-trust).** A malicious or compromised repo = code-exec as the container user, on every teammate's session start. Mitigations: strict URL validation (KTD7), git env allowlist not passthrough (KTD5), `added_by` audit trail, operator kill-switch default-off (R6), and a clear UI warning. Residual risk is explicitly accepted for v1 per the trust-model decision; revisit with an admin gate (deferred). (broadening-resource-visibility + embedded-ssh-proxy learnings.) +- **URL → shell injection** via the `pi install`/settings path. Mitigation: validate scheme/format and reject shell-unsafe characters at registration; base64 transport avoids quoting hazards for the settings write. +- **No live revocation (R5).** Deleting a detected-malicious repo does **not** stop it in already-running sessions — it keeps executing until each container restarts. The kill-switch is operator/deploy-level and also doesn't touch live processes, so v1 has no fast revocation primitive. Mitigation: documented "restart affected team sessions" runbook; live propagation deferred. Decide explicitly whether shipping code-exec with no live revocation is acceptable for v1. +- **`npm install` postinstall scripts are a code-exec vector independent of skill content.** A registered repo with no dangerous skill files can still carry `"postinstall": "curl evil|sh"` in `package.json`, which runs at clone/install inside every member's container. Same flat-trust acceptance as the skill code itself; the git env allowlist (KTD5) does **not** mitigate it. Investigate a `--ignore-scripts` equivalent before v1; document if unavailable. +- **Settings clobbering.** Deuce owns the whole `packages` array and always includes `npm:pi-subagents`; folding the standalone install in removes the second writer (KTD2). Caveat: a user repo shipping its own `.pi/settings.json` with `packages` may override at project scope — document it. +- **Latency on first launch after each container create/rebuild** from clone + `npm install` (runs at Pi launch, not provision; cached in `~/.pi/agent/git` between launches within a living container). Accepted for v1; eager pre-warm at provision is a possible later optimization. (`node`/`npm` already ensured by `InstallPi`.) +- **Pre-existing gate-before-lookup in `runWorkspaceAction`.** The provisioning path U4 hooks into already calls `GetSession` before `requireSessionMember` (`workspace.go`), inverting the gate order the auth learning prescribes. Not introduced by this plan, but U4 adds skill-repo provisioning to that path — verify/fix the gate order (and add the non-member→403 test) as part of this work or file it explicitly. +- **Provisioning failures are non-fatal** — a bad repo shouldn't break the workspace; failures log and the session still runs without that skill. + +--- + +## Alternatives Considered + +- **Per-repo `pi install git:` at provision (reuse `InstallPiPackage`)** instead of a declarative `settings.json` write. Simpler and pre-warms, but cannot express *removal* — a disabled/deleted repo lingers in the container's settings (pi install only adds), breaking R5. Rejected in favor of KTD2's declarative ownership. Pre-warming via eager install remains available as a later optimization layered on top. +- **Host bind-mount / shared volume** for skills. Rejected earlier in design: `devpod up` exposes no mount-injection flag, devcontainer.json is user-controlled, and docker-outside-of-docker resolves bind sources on the host (a Deuce-container dir isn't valid). The settings/URL approach sidesteps all of it. (See thread + devpod-docker-workspace-bind-mount learning.) + +--- + +## Sources & Research + +- Origin ideation: `docs/ideation/2026-06-08-single-deuce-agent-ideation.md`. +- Skill *discovery* + `--mode rpc` loading + imperative `pi install`: `docs/solutions/architecture-patterns/pi-loads-agent-skills-standard-in-rpc-mode.md` (verified `pi` 0.74.2). **This doc does *not* cover declarative `settings.json` `packages` auto-install or removal** — that comes from Pi's vendored `packages.md` (auto-install of *missing* packages) and is otherwise unverified (see the blocking spike). +- **Pi version is unpinned:** `piInstallScript` runs `curl pi.dev/install.sh | sh` (only Node is pinned), so each container gets whatever Pi ships at provision time. The `packages` contract could drift. Either pin Pi to a known-good version in `piInstallScript` and cite it, or capture `pi --version` to logs at provision so drift is observable. +- Auth pattern: `docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md` (gate every route, before existence lookup; positive+negative tests). +- Boundary/env posture: `docs/solutions/architecture-patterns/embedded-ssh-proxy-for-vscode-remote.md` (env allowlist, validate values crossing into the container). +- Patterns to mirror: `server/internal/db/migrations/008_user_ssh_keys.sql`, `server/internal/db/queries/user_ssh_keys.sql`, `server/internal/handler/ssh_keys.go`, `server/internal/handler/teams.go` (`IsTeamMember`), `server/internal/workspace/manager.go` (`InstallPiExtension`, `InstallPiPackage`, `ExecInWorkspace`), `server/internal/agent/pirun/devpod_launcher.go` (`piLaunchSpec`), `src/components/settings/SSHKeysDialog.tsx`. diff --git a/docs/plans/2026-06-16-003-feat-devcontainer-fast-init-and-gui-plan.md b/docs/plans/2026-06-16-003-feat-devcontainer-fast-init-and-gui-plan.md new file mode 100644 index 0000000..cc783d7 --- /dev/null +++ b/docs/plans/2026-06-16-003-feat-devcontainer-fast-init-and-gui-plan.md @@ -0,0 +1,231 @@ +--- +title: "feat: Fast devcontainer init + in-container GUI (Docker-native alternate)" +status: active +date: 2026-06-16 +type: feat +origin: docs/brainstorms/2026-06-16-microvm-workspace-migration-requirements.md +--- + +# feat: Fast devcontainer init + in-container GUI (Docker-native alternate) + +## Summary + +Keep the existing DevPod/Docker devcontainer runtime and attack the two surface wants directly: make session start fast by building each repo's devcontainer image **once** (a per-repo prebuilt image with Pi and tooling baked in), optionally serving sessions from a warm container pool and caching `~/.vscode-server`; and add a software-rendered, agent-callable desktop (Xvfb + XFCE + Chromium + KasmVNC) baked into the same image, surfaced as a new in-app tab over the existing `docker exec` transport. No provider rewrite, no SSH/exec rewrite, no kernel-isolation change. + +This is the **alternate** to the Kata microVM migration ([2026-06-16-002](docs/plans/2026-06-16-002-feat-microvm-workspace-migration-plan.md)). It is far smaller and lower-risk, but it deliberately does **not** deliver the per-session kernel boundary that plan exists to provide — see Scope Boundaries. + +--- + +## Problem Frame + +Two pains motivate the work (see origin: docs/brainstorms/2026-06-16-microvm-workspace-migration-requirements.md): + +- **Slow session start.** Today `Create` runs `devpod up ` (`server/internal/workspace/manager.go`), which clones the repo, **builds the devcontainer image from scratch**, starts the container, and then Deuce installs Pi over `devpod ssh` (base64-pushed `InstallPi`/`InstallPiExtension`). On top of that, VS Code Remote re-downloads `~/.vscode-server` (~120MB) on every container recreate (noted in `CLAUDE.md`). The from-scratch build and per-recreate downloads dominate cold-start. +- **No GUI.** There is no way for a human or the agent to *see* UI changes — `STRATEGY.md`'s "Coding & Preview" track wants live UI preview as a first-class, agent-callable surface. + +The microVM plan addresses these plus a third driver (isolation). This plan intentionally scopes to **only** the two above, trading away the isolation upgrade for a much cheaper, lower-risk change that reuses nearly all existing plumbing. + +--- + +## Key Technical Decisions + +- **Per-repo prebuilt image, not from-scratch builds.** Build each repo's devcontainer image once (on first connect and whenever the devcontainer definition changes), tag it, and start sessions from that prebuilt image instead of rebuilding per session. DevPod supports this natively (`devpod build` → prebuild image, consumed by `devpod up`), so it's low-risk. This is the single biggest cold-start win and the Docker analogue of the microVM plan's "template," **minus the approval gate** — there's no new trust boundary here (containers already are the boundary), so the prebuilt image is a pure cache, rebuilt on config change, not an approved artifact. + +- **Bake Pi + tooling + desktop into the prebuilt image.** Move Pi install out of the post-create `devpod ssh` path (`InstallPi`/`InstallPiPackage`/`InstallPiExtension`/`symlinkPi` in `manager.go`, called via `provisionAgentTools` in `handler/workspace.go`) and into the image build. This removes the base64-over-ssh install round-trips from the session-open path. The Pi *launcher* (`pirun/devpod_launcher.go`) is unchanged — it still launches `pi --mode rpc` over `devpod ssh --command`. + +- **Cache `~/.vscode-server` in a per-user named volume.** Mount a persistent named volume at `~/.vscode-server` so the ~120MB VS Code Remote payload survives container recreates instead of re-downloading each time. This is the v2 follow-up `CLAUDE.md` already names. + +- **No warm container pool (out of scope for this first step).** Prebuilt-image + baked-tools + vscode-server cache are expected to capture enough of the cold-start win on their own. A pool is explicitly deferred — revisit only if start latency is still a problem after this lands. + +- **The GUI is a pure image addition + a WS bridge — no transport rewrite.** Xvfb + XFCE/openbox + Chromium + KasmVNC bake into the image; the desktop reaches the browser through the **existing** `docker exec` TCP-forward path (`buildTCPForwardCmd` in `server/internal/sshproxy/docker.go`, already used for VS Code `direct-tcpip`). Because the transport stays `docker exec`, none of the SSH-proxy / Pi-launcher / reconciler rework the microVM plan needs applies here. + +- **The desktop is one Xvfb display, two consumers.** XFCE/Chromium run on `Xvfb :1`; KasmVNC serves `:1` to the browser for humans, and the agent drives the same `:1` via `scrot`/`xdotool` exposed as Pi tools — satisfying `STRATEGY.md`'s agent-native-parity constraint. + +- **Do not bake per-anything secrets into the prebuilt image.** The prebuilt image is shared by every session for that repo, so anything baked in (credentials, tokens, SSH keys) is shared across sessions. Keep the existing posture: the `ANTHROPIC` key is injected via env at Pi launch and never persisted; the prebuilt image carries tooling only. (This is the one entropy concern that carries over from the microVM plan, in a much milder form because containers don't run their own sshd — the SSH proxy terminates `docker exec` on the Deuce host.) + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + subgraph Host["Deuce host (Docker)"] + deuce["deuce Go binary
(SSH proxy via docker exec)"] + img["per-repo prebuilt image
(Pi + desktop baked in)"] + vol["~/.vscode-server
named volume cache"] + subgraph C["session devcontainer"] + pi["pi --mode rpc"] + desk["Xvfb :1 + XFCE + Chromium
KasmVNC + xdotool/scrot"] + end + end + imgdef["devcontainer definition"] -->|build once / on change| img + img -->|devpod up uses prebuild| C + vol -.mount.- C + deuce -->|docker exec / direct-tcpip| desk + deuce -->|devpod ssh --command| pi +``` + +```mermaid +flowchart TB + s["CreateSession (repoURL)"] --> q{prebuilt image
for repo + current def?} + q -->|yes| fast["start container from prebuild
(no build, tools baked)"] + q -->|no/stale| build["build prebuilt image
(slow, once) then start"] + fast --> ready["pi up + desktop up
workspace_status = ready"] + build --> ready +``` + +--- + +## Requirements Traceability + +| Origin requirement | Addressed here? | +|---|---| +| R4 fast start from prepared state | Yes — U1/U3 (warm pool dropped) | +| R12–R14 software-rendered desktop, no GPU | Yes — U4 | +| R15 desktop is agent-callable | Yes — U4 | +| R5–R9 per-repo template build/approve lifecycle | Partial — prebuilt-image rebuild on config change (U1), **no approval gate** (no trust boundary) | +| R1–R3 per-session kernel isolation | **No — out of scope** (see Scope Boundaries; this is the deliberate tradeoff vs the microVM plan) | +| R11 per-session entropy/secret regen | N/A — containers don't run their own sshd; no baked secrets (KTD) | +| R16/R17 exec-transport rework | N/A — `docker exec` / `devpod ssh` transport unchanged | + +--- + +## Implementation Units + +### Phase 1 — Fast init + +### U1. Per-repo prebuilt image with Pi + tooling baked in + +- **Goal:** Build each repo's devcontainer image once (and on devcontainer-definition change), tag it, and start sessions from it; move Pi/tool install from post-create ssh into the image build. +- **Requirements:** R4; partial R5–R9 (rebuild-on-change, no approval gate). +- **Dependencies:** none. +- **Files:** + - `server/internal/workspace/manager.go` (add a prebuild step — `devpod build` or equivalent — and have `Create` consume the prebuilt image tag; delete/skip `InstallPi*`/`piInstallScript`/`symlinkPi` once baked) + - `server/internal/handler/workspace.go` (`provisionAgentTools` ~line 22 becomes a no-op / removed; add a rebuild trigger on devcontainer-definition change) + - `deploy/workspace-image/` (the baked layer added on top of the repo's devcontainer: Pi, the `ask_user` extension, tools) + - `server/internal/config/config.go` (prebuild image tag/registry settings) +- **Approach:** Use DevPod's prebuild flow: produce a prebuild image per repo, keyed by the repo's devcontainer definition hash, and have `devpod up` consume it (`--prebuild-repository` or a local tag). Bake Pi + the `ask_user` extension into the image instead of pushing them over `devpod ssh`. A repo's prebuilt image is rebuilt when its devcontainer definition changes (hash mismatch) or on a manual "rebuild environment" action; ordinary code pushes reuse the cached image. No approval gate — the image is a cache, not a trust artifact. +- **Patterns to follow:** the existing `Create`/`InstallPi` flow in `manager.go`; the "Adding a New API Endpoint" convention in `CLAUDE.md` for the rebuild trigger. +- **Test scenarios:** + - Happy path: first session for a repo builds the prebuilt image (slow); a second session starts from the cached image with no rebuild and no over-ssh Pi install. + - Staleness: changing the devcontainer definition invalidates the cached image and triggers a rebuild; an ordinary code push does not. + - Baked Pi: `pi --mode rpc` launches from the image with no `InstallPi` step; the `ask_user` extension is present. + - Verification that `provisionAgentTools` is no longer on the session-open path. +- **Verification:** second-and-later sessions for a repo skip the image build and the Pi install entirely; cold start drops to container start + boot. + +### U3. Cache `~/.vscode-server` in a per-user named volume + +- **Goal:** Stop the ~120MB VS Code Remote re-download on every container recreate. +- **Requirements:** R4. +- **Dependencies:** none (independent of U1/U2). +- **Files:** + - `server/internal/workspace/manager.go` (mount a per-user/per-repo named volume at `~/.vscode-server` when creating the container) + - `server/internal/config/config.go` (volume naming/root config) +- **Approach:** Allocate a Docker named volume scoped per user (or per user+repo) and mount it at the container's `~/.vscode-server`. The VS Code server payload then persists across recreates. Confirm permissions/UID match the container's `remoteUser` so the mounted volume is writable. +- **Patterns to follow:** the existing bind-mount handling documented in `docs/solutions/architecture-patterns/devpod-docker-workspace-bind-mount-2026-05-13.md`. +- **Test scenarios:** + - Happy path: first VS Code connect populates the volume; recreating the container reuses it with no re-download. + - Permissions: the mounted volume is writable by the container `remoteUser` (no permission-denied on server install). + - Isolation: one user's volume is not mounted into another user's container. +- **Verification:** "Open in VS Code" on a recreated container does not re-download `~/.vscode-server`. + +### Phase 2 — In-container GUI + +### U4. Software-rendered, agent-callable desktop in the image + +- **Goal:** A no-GPU desktop with a browser inside the container, viewable in a new session tab and drivable by the agent. +- **Requirements:** R12, R13, R14, R15. +- **Dependencies:** U1 (baked into the prebuilt image). +- **Files:** + - `deploy/workspace-image/` (add `Xvfb`, XFCE/openbox, Chromium, KasmVNC, `xdotool`, `scrot`/`ffmpeg` to the baked layer; start them via the container's init so a session has the desktop immediately — R13) + - `server/internal/handler/desktop.go` (new — WS endpoint bridging the browser to the in-container KasmVNC port) + - `server/internal/server/server.go` (register the desktop WS route with the same session-member/live gate as `/ws/terminal/{sessionID}`) + - `server/internal/agent/pirun/extension/` or the baked layer (agent desktop tools: screenshot via `scrot`/`ffmpeg` of `:1`, input via `xdotool`) + - `src/types/index.ts` (`TabType` add `"desktop"`), `src/components/layout/CenterPanel.tsx` (tabs array + render branch, `requiresLiveWorkspace: true`), `src/components/desktop/DesktopView.tsx` (new — KasmVNC/noVNC client), `src/lib/api.ts` +- **Approach:** XFCE/openbox + Chromium on `Xvfb :1`; KasmVNC serves `:1` (CPU-only, single unified server). The desktop WS handler reaches the in-container KasmVNC port using the existing `docker exec` TCP-forward (`buildTCPForwardCmd` in `sshproxy/docker.go`) — no new transport. The agent drives the same `:1` via `scrot`/`xdotool` exposed as Pi tools (alongside `ask_user`), so human and agent share one display (R15). The new tab follows the existing `files`/`terminal` pattern with `requiresLiveWorkspace` gating. The desktop WS route inherits the same session-member/live authorization gate as the terminal WS. Configure KasmVNC with a per-session credential (or bind it so it's only reachable through the proxy path) so a process inside the container can't read the display unauthenticated. +- **Patterns to follow:** `server/internal/handler/terminal.go` + `server.go` for the WS bridge and its gate; `buildTCPForwardCmd` in `sshproxy/docker.go` for the in-container port forward; the `files`/`terminal` tab pattern in `CenterPanel.tsx`; the `ask_user` extension for agent-tool shape. +- **Test scenarios:** + - `Covers AE4.` A human opens the desktop tab and sees the live UI; the agent screenshots the same display and injects a click via `xdotool`, both against `:1`. + - No-GPU: the desktop renders via `Xvfb` software path with no GPU device. + - Availability: a session from the prebuilt image exposes the desktop immediately, no per-session desktop setup. + - Authorization: the desktop WS enforces the session-member/live gate before the bridge opens; a non-member is rejected. + - KasmVNC auth: a process inside the container cannot reach the desktop stream without the Deuce-issued credential. + - Gating: the desktop tab shows `RecoveryCard` when the workspace is not live (`requiresLiveWorkspace`). +- **Verification:** humans and `@deuce` can both see and drive the in-container desktop over the existing `docker exec` transport; no GPU required; the authorization gate holds. + +--- + +## System-Wide Impact + +- **No isolation change.** The container remains the security boundary; this plan does not add a kernel boundary. If untrusted agent-generated code is the real concern, this plan does not address it — the microVM plan does. +- **Authorization surface:** one new route (the desktop WS) takes a session ID. Per `docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md`, gate it explicitly with the session-member/live tier, the same as the terminal WS. +- **Reduced surface:** the post-create Pi base64-over-ssh install path is removed (baked into the image); the per-recreate `~/.vscode-server` download is eliminated. +- **Storage:** prebuilt images and per-user `~/.vscode-server` volumes consume disk; add a GC/retention policy for stale prebuilt images and orphaned volumes. + +--- + +## Scope Boundaries + +### Deferred for later + +- Warm container pool — dropped from this first step by decision; revisit only if prebuilt-image + vscode-server cache don't make start fast enough. +- Container checkpoint/restore (CRIU `docker checkpoint`) for sub-second resume — experimental and finicky; not needed if the above suffices. +- Lazy image pulling (eStargz/SOCI) — only if prebuilt-image pull time becomes the bottleneck. + +### Outside this plan's identity + +- **Per-session kernel isolation.** This is the defining difference from the microVM plan ([2026-06-16-002](docs/plans/2026-06-16-002-feat-microvm-workspace-migration-plan.md)). If isolation of untrusted code is a hard requirement, that plan is the answer, not this one. This plan optimizes the existing trust model; it does not change it. +- **GPU acceleration / virtio-gpu** — the desktop is software-rendered by design. +- **An approval gate on the prebuilt image** — omitted deliberately; there's no new trust boundary, so the image is a cache, not an approved artifact. + +--- + +## Dependencies / Assumptions + +- The existing DevPod/Docker stack and `docker exec` transport stay in place; no Linux/KVM requirement (this runs anywhere Docker does, including macOS/OrbStack — a notable advantage over the microVM plan for local dev). +- DevPod's prebuild flow (`devpod build` + prebuild-repository consumption) works for the target provider; verify against the configured `DEVPOD_PROVIDER`. +- `ask_user` requires a capable model — haiku won't call the tool; `DEUCE_PI_MODEL` must be capable for interactive prompts (existing constraint). +- KasmVNC runs CPU-only and is reachable from the Deuce host via the in-container port forward. + +--- + +## Comparison to the microVM plan + +| Dimension | This plan (Docker-native) | microVM plan (2026-06-16-002) | +|---|---|---| +| Isolation | Container boundary (unchanged) | Per-session kernel boundary | +| Fast start | Prebuilt image + vscode-server cache | Warm VM pool from approved digest | +| GUI | Same desktop, over `docker exec` | Same desktop, over vsock/sshd | +| Transport rework | None (`docker exec`/`devpod ssh` kept) | Full SSH-proxy + Pi-launcher + reconciler rewrite | +| Host requirement | Any Docker host (macOS OK) | Linux + KVM only | +| Rough size | ~3–5 units, low risk | 8 units, high risk, security-critical | +| Leaves on the table | No isolation upgrade | — | + +--- + +## Outstanding Questions + +### Resolve before planning + +- Is per-session **kernel isolation** actually required? If yes, this plan is insufficient on its own and the microVM plan is the real answer — this becomes a stopgap. If no (container boundary is acceptable), this plan fully addresses the stated wants. + +### Deferred to implementation + +- DevPod prebuild mechanics against the configured provider (local tag vs prebuild-repository registry). +- `~/.vscode-server` volume scoping (per-user vs per-user+repo) and UID/permission handling. +- Desktop WS transport detail: reuse `buildTCPForwardCmd` vs a dedicated forward; KasmVNC credential injection mechanism. +- Retention/GC policy for stale prebuilt images and orphaned vscode-server volumes. + +--- + +## Sources / Research + +- Origin: `docs/brainstorms/2026-06-16-microvm-workspace-migration-requirements.md`. +- Companion: `docs/plans/2026-06-16-002-feat-microvm-workspace-migration-plan.md` (the isolation-bearing alternative this plan trades against). +- `server/internal/workspace/manager.go` — `Create` (`devpod up`), `InstallPi*` post-create install moved into the image. +- `server/internal/handler/workspace.go` — `provisionAgentTools` (~line 22) removed from the session-open path. +- `server/internal/sshproxy/docker.go` — `buildTCPForwardCmd` reused for the in-container desktop port. +- `server/internal/handler/terminal.go` + `server/internal/server/server.go` — WS bridge + session-member gate pattern for the new desktop route. +- `src/components/layout/CenterPanel.tsx`, `src/types/index.ts` — session-surface tabs and the new desktop tab attach point. +- `CLAUDE.md` — the `~/.vscode-server` ~120MB per-recreate download (the per-user-volume-cache v2 follow-up) and devcontainer compatibility requirements. +- `docs/solutions/architecture-patterns/devpod-docker-workspace-bind-mount-2026-05-13.md` — bind-mount/volume handling. diff --git a/docs/plans/2026-06-16-004-feat-archive-sessions-plan.md b/docs/plans/2026-06-16-004-feat-archive-sessions-plan.md new file mode 100644 index 0000000..960a375 --- /dev/null +++ b/docs/plans/2026-06-16-004-feat-archive-sessions-plan.md @@ -0,0 +1,234 @@ +--- +title: "feat: Archive sessions (preserve history, hide from sidebar, tear down container)" +status: active +date: 2026-06-16 +type: feat +--- + +# feat: Archive sessions (preserve history, hide from sidebar, tear down container) + +## Summary + +Give users a first-class way to **retire a session** without losing its history. Archiving flips a session's `status` to `archived` (preserving all messages, activities, and plan content in the DB), tears down its DevPod workspace/container to free CPU and disk, removes it from the normal sidebar, and surfaces it instead in a separate **Archived** view that can be restored later. + +This is the user-facing answer to "I can't delete workspaces" — today there is no session-delete or session-retire action at all (only a *workspace* delete that destroys the container but strands the session row in the DB). Archive intentionally replaces hard-delete: nothing is permanently destroyed except the live container, which is reproducible from the repo on restore. + +The good news from research: most of the substrate already exists. The `sessions.status` column already accepts `'archived'`, the frontend `SessionStatus` type already includes it, the `PATCH /api/sessions/{id}` write-gate + `session_update` broadcast already work, and the reconciler already excludes archived sessions via `ListNonArchivedSessions`. The remaining work is (1) wiring container teardown into the archive transition, (2) filtering archived sessions out of the default list and exposing them on demand, and (3) the UI to archive, view-archived, and restore. + +--- + +## Problem Frame + +A session in Deuce is a Slack-like channel backed by an isolated DevPod workspace. Sessions accumulate indefinitely: there is no supported way to retire one. The only adjacent action, `POST /api/sessions/{id}/workspace/delete`, destroys the DevPod container but leaves the session row with `workspace_status = 'missing'`, so the channel keeps cluttering the sidebar forever and its container resources may linger until then. + +Users want to: +- Stop an old session from cluttering the sidebar. +- Free the CPU/disk its container holds. +- Keep the conversation/plan history reachable for reference. +- Bring a session back if needed. + +"Delete" is the wrong primitive — it implies data loss. "Archive" captures the real intent: hide + reclaim resources, but preserve history and allow restore. + +--- + +## Requirements + +- R1. A session member can archive a session, flipping `status` to `archived`. +- R2. Archiving tears down the session's DevPod workspace (`devpod delete`), freeing container resources. The DB session row and all child rows (messages, activities, plan) are preserved. +- R3. Archived sessions do not appear in the normal sidebar lists (My Sessions / team groups). +- R4. Archived sessions remain reachable through a separate Archived view/filter, where their full history can be opened and read. +- R5. A session member can restore (un-archive) a session, flipping `status` back to `active`. The session reappears in the normal sidebar; its container is gone and is recreated through the existing workspace-start path on demand. +- R6. Archive and restore are gated on **session membership** (write gate), consistent with the documented two-gate authorization model — not merely team-read visibility. +- R7. Archive/restore propagate to connected clients so the sidebar updates without a manual refresh. + +--- + +## Key Technical Decisions + +- KTD1. Reuse `status = 'archived'`; no DB migration. The column, its `'archived'` value, the frontend `SessionStatus` union, and the existing `UpdateSessionStatus` query all already exist. Adding a migration would be redundant. (Confirmed in `server/internal/db/migrations/001_initial_schema.sql` and `src/types/index.ts`.) +- KTD2. Dedicated archive/unarchive endpoints rather than overloading `PATCH /sessions/{id}`. Archive is a compound, side-effecting operation (status flip + container teardown + broadcast), not a plain field write. A dedicated `POST /sessions/{id}/archive` + `POST /sessions/{id}/unarchive` mirrors the existing `/workspace/{start,stop,rebuild,delete}` action pattern, keeps the generic `PATCH` free of surprising side effects, and is independently testable. The existing `UpdateSession` PATCH path is left as-is. +- KTD3. Flip status to `archived` **before** tearing down the container. The reconciler keys off `ListNonArchivedSessions`; flipping first removes the session from its view so it cannot race to restart the container mid-teardown. This ordering mirrors the safe sequence already used for workspace lifecycle. +- KTD4. Filter archived at the query layer, fetch them on demand. `ListSessionsForUser` (the sidebar query) gains `AND s.status != 'archived'`; a new `ListArchivedSessionsForUser` returns archived-only. The `ListSessions` handler selects between them via an `?archived=true` query param. This keeps the default sidebar payload lean and only loads archived rows when the user opens the Archived view. The reconciler's separate `ListNonArchivedSessions` is unaffected. +- KTD5. Restore does not recreate the container. Un-archiving only flips status back to `active`; `workspace_status` remains `'missing'` from teardown, so the session reappears with the existing "start workspace" affordance. This avoids surprise resource consumption on restore and reuses the established start/rebuild path. +- KTD6. Reuse the existing `session_update` broadcast for propagation (R7). The client already refetches the (now archived-filtered) list on `session_update`, and `setSessions` already clears `activeSessionId` when the active session disappears — so archiving the active session degrades gracefully with no new event type. + +--- + +## High-Level Technical Design + +Archive lifecycle and the resulting list routing: + +```mermaid +flowchart TB + A[User clicks Archive on a session] --> B{Session member?} + B -->|no| Z[403 write-gate rejection] + B -->|yes| C[UpdateSessionStatus → 'archived'] + C --> D[workspace_status → 'deleting' + broadcast] + D --> E[background: workspaces.Delete → devpod delete] + E -->|ok| F[workspace_status → 'missing' + broadcast] + E -->|err| G[workspace_status → 'failed' + broadcast] + F --> H[reconciler ignores it via ListNonArchivedSessions] + + subgraph List routing + L1[GET /sessions] --> L2[ListSessionsForUser: status != archived → sidebar] + L3[GET /sessions?archived=true] --> L4[ListArchivedSessionsForUser: status = archived → Archived view] + end + + R[User clicks Restore] --> S[UpdateSessionStatus → 'active'] + S --> T[broadcast; session returns to sidebar with workspace 'missing'] +``` + +--- + +## Implementation Units + +### U1. List filtering: exclude archived from the sidebar, expose archived on demand + +**Goal:** The default session list stops returning archived sessions; a new query and query-param path returns archived-only. + +**Requirements:** R3, R4 + +**Dependencies:** none + +**Files:** +- `server/internal/db/queries/sessions.sql` — add `AND s.status != 'archived'` to `ListSessionsForUser`; add a new `ListArchivedSessionsForUser` (same team-scoped join, `AND s.status = 'archived'`, `ORDER BY s.last_activity_at DESC`). +- `server/internal/db/sessions.sql.go` — regenerated via `make generate` (do not hand-edit). +- `server/internal/handler/sessions.go` — `ListSessions` reads `r.URL.Query().Get("archived")`; when truthy, calls `ListArchivedSessionsForUser`, else the existing query. +- `server/internal/handler/sessions_test.go` (or existing handler test file) — coverage below. + +**Approach:** Mirror the existing `ListSessionsForUser` query exactly (the team-scoped JOIN chain through `projects`/`team_members`), changing only the status predicate. The handler change is a single branch on the query param; both branches reuse `buildSessionResponse`. Note the reconciler's `ListNonArchivedSessions` is a separate query and must not be touched. + +**Patterns to follow:** The team-read visibility join already documented in `ListSessionsForUser`; the existing handler shape in `ListSessions` (`server/internal/handler/sessions.go`). + +**Test scenarios:** +- Covers R3. `GET /sessions` (no param) for a user with a mix of active and archived sessions returns only the non-archived ones. +- Covers R4. `GET /sessions?archived=true` returns only the archived sessions, ordered by `last_activity_at` desc. +- A user with zero archived sessions gets an empty array (not null/error) from `?archived=true`. +- Team-scope is preserved: archived sessions belonging to a team the user is **not** a member of are not returned by either path. + +**Verification:** Both endpoints return the correct partition of a seeded mixed-status dataset; the reconciler still sees non-archived sessions unchanged. + +### U2. Archive/unarchive endpoints with container teardown + +**Goal:** Add `POST /sessions/{id}/archive` (status flip + container teardown + broadcast) and `POST /sessions/{id}/unarchive` (status flip + broadcast), both write-gated. + +**Requirements:** R1, R2, R5, R6, R7 + +**Dependencies:** none (can land alongside U1) + +**Files:** +- `server/internal/server/server.go` — register `r.Post("/archive", h.ArchiveSession)` and `r.Post("/unarchive", h.UnarchiveSession)` inside the existing `/{sessionID}` route group. +- `server/internal/handler/sessions.go` — `ArchiveSession` / `UnarchiveSession` handlers. +- `server/internal/handler/sessions_test.go` — coverage below. + +**Approach:** +- `ArchiveSession`: parse `sessionID` + `userID`; enforce `requireSessionMember` (write gate, R6); `UpdateSessionStatus(... 'archived')` **first** (KTD3); set `workspace_status` to `'deleting'` and broadcast; then tear down the container in the background reusing the existing workspace teardown path (`h.workspaces.Delete` + the `'missing'`/`'failed'` transitions used by `DeleteWorkspace` in `server/internal/handler/workspace.go`), broadcasting the terminal `workspace_status`. Guard on `h.workspaces != nil && h.workspaces.Available()` so archive still succeeds (status-only) when DevPod is unavailable. +- `UnarchiveSession`: same parse + write gate; `UpdateSessionStatus(... 'active')`; build response and broadcast `session_update`. No container work (KTD5). +- Both build the response via `buildSessionResponse` and broadcast `TypeSessionUpdate`, mirroring `UpdateSession`. + +**Patterns to follow:** `UpdateSession` (write gate + `UpdateSessionStatus` + `session_update` broadcast) and `handleWorkspaceAction`'s `actionDelete` branch (`server/internal/handler/workspace.go`) for the teardown + status-transition sequence. + +**Test scenarios:** +- Covers R1/R6. A session member POSTing `/archive` gets 200 and the session row status becomes `archived`. +- Covers R6. A non-member (team-read only) POSTing `/archive` is rejected by the write gate; status is unchanged. +- Covers R2. Archiving invokes the workspace teardown path (assert via a stub/fake workspace manager that `Delete` was called with the session's workspace id) and lands `workspace_status` at `'missing'` on success. +- Teardown failure path: when the workspace manager returns an error, status stays `archived` and `workspace_status` becomes `'failed'` (archive is not rolled back). +- DevPod-unavailable path: with no/unavailable workspace manager, `/archive` still flips status to `archived` and returns 200 without erroring. +- Covers R5. `/unarchive` on an archived session flips status back to `active` and does **not** call the workspace manager. +- Covers R7. Both actions broadcast a `session_update` for the session. +- Invalid `sessionID` returns 400; unknown session returns 404. + +**Verification:** Archiving a real session removes it from `GET /sessions`, makes it appear in `GET /sessions?archived=true`, and the container is gone (`devpod list` no longer shows it); unarchive returns it to the default list. + +### U3. Frontend API client + types + +**Goal:** Add typed API wrappers for listing archived sessions and for archive/unarchive. + +**Requirements:** R1, R4, R5 + +**Dependencies:** U1, U2 (contract) + +**Files:** +- `src/lib/api.ts` — add `listArchivedSessions: () => request("/sessions?archived=true")`, `archiveSession: (id) => request(\`/sessions/${id}/archive\`, { method: "POST" })`, `unarchiveSession: (id) => request(\`/sessions/${id}/unarchive\`, { method: "POST" })`. +- `src/types/index.ts` — no change expected (`SessionStatus` already includes `'archived'`); confirm during implementation. + +**Approach:** Mirror existing wrappers (`listSessions`, `updateSession`, `deleteWorkspace`) exactly — same `request` helper, same path style. + +**Patterns to follow:** Existing `api` object members in `src/lib/api.ts`. + +**Test scenarios:** Test expectation: none — thin typed wrappers over the shared `request` helper, exercised through U4's store tests and manual verification. No independent behavior to assert. + +**Verification:** `npx tsc -b --noEmit` passes; the new wrappers compile against the existing `Session` type. + +### U4. Frontend store: archived state + archive/restore actions + +**Goal:** Hold archived sessions separately and provide actions to load them, archive a session (optimistically removing it from the sidebar list), and restore one. + +**Requirements:** R3, R4, R5, R7 + +**Dependencies:** U3 + +**Files:** +- `src/stores/session-store.ts` — add `archivedSessions: Session[]` state; add actions `loadArchivedSessions()`, `archiveSession(sessionId)`, `restoreSession(sessionId)`. `archiveSession` calls `api.archiveSession`, removes the session from `sessions` (and clears `activeSessionId` if it pointed there). `restoreSession` calls `api.unarchiveSession`, removes from `archivedSessions`, and lets the subsequent `session_update`/list refetch repopulate `sessions`. +- `src/stores/session-store.test.ts` (or the existing reducer/visibility test file) — coverage below. + +**Approach:** Follow the existing `setSessions`/`updateWorkspaceStatus` reducer patterns. Keep `archivedSessions` independent of `sessions` so the `session_update` refetch path (which replaces `sessions` with the non-archived list) never clobbers the archived view. The existing `setSessions` already clears a dangling `activeSessionId`; `archiveSession` should do the same when removing the active session optimistically. + +**Patterns to follow:** Existing Zustand action style in `src/stores/session-store.ts`; the pure-logic vitest suites referenced in `npm test`. + +**Test scenarios:** +- Covers R3. `archiveSession(id)` removes the session from `sessions`. +- `archiveSession(id)` on the active session also clears `activeSessionId`. +- Covers R5. `restoreSession(id)` removes the session from `archivedSessions`. +- Covers R4. `loadArchivedSessions()` populates `archivedSessions` from the API result (mock the api module). +- Archiving a session not present in `sessions` is a no-op on `sessions` (idempotent / race-safe), consistent with the store's existing dedupe discipline. + +**Verification:** `npm test` passes; store transitions match the scenarios above. + +### U5. Frontend UI: archive action, Archived view, restore + +**Goal:** Let a member archive a session from the sidebar (with a confirm warning that the container is destroyed), browse archived sessions in a separate Archived view/toggle, open their history, and restore them. + +**Requirements:** R1, R3, R4, R5 + +**Dependencies:** U4 + +**Files:** +- `src/components/layout/SessionSidebar.tsx` — add a hover action (member-only) on `SessionCard` to archive; add an Archived entry/toggle at the bottom of the sidebar that calls `loadArchivedSessions()` and renders archived sessions (reusing `SessionCard`, whose `opacity-40` archived styling already exists) with a Restore action; archived cards open normally so history (messages/activities/plan) is readable. + +**Approach:** Add an Archive icon button alongside the existing inline edit-description button in `SessionCard` (same hover-reveal treatment, gated on membership / not `viewOnly`). Archiving prompts a confirm dialog noting the workspace container will be deleted (history preserved). The Archived view can be a collapsible group rendered only when it has loaded entries, or a filter toggle on the sidebar header — either way it is populated lazily via `loadArchivedSessions()` and is visually separate from the active sidebar groups (satisfies the chosen "separate view / filter toggle" direction). Restore is a per-card action in that view. + +**Patterns to follow:** The existing hover-action icon button pattern in `SessionCard` (`src/components/layout/SessionSidebar.tsx` lines ~157-166) and the `SessionGroup` collapsible group component (lines ~223-264). + +**Test scenarios:** +- Covers R1. Clicking Archive on a member session triggers the confirm flow and, on confirm, calls `archiveSession` — the card leaves the active groups. +- Covers R4. Opening the Archived view loads and lists archived sessions; selecting one opens its message/activity/plan history. +- Covers R5. Clicking Restore in the Archived view calls `restoreSession` and the session returns to the normal groups. +- The Archive action is not offered (or is disabled) for `viewOnly` (non-member) sessions, consistent with the write gate. + +**Verification:** Manual run (`npm run dev` + backend): archive a session → it disappears from the sidebar and the container is torn down; open Archived → its history is intact and readable; restore → it returns to the sidebar showing a stopped/`missing` workspace that can be started again. + +--- + +## Scope Boundaries + +In scope: +- Archive / view-archived / restore for sessions, with container teardown on archive. +- Backend filtering + endpoints, frontend API/store/UI wiring. + +Out of scope (true non-goals): +- Hard delete of a session and its history. Archive deliberately preserves all DB rows; permanent deletion is a separate, higher-risk concern. +- Auto-archiving by inactivity policy / retention rules. +- Bulk archive/restore. + +### Deferred to Follow-Up Work +- Per-user named-volume caching of `~/.vscode-server` so restore + container rebuild is cheaper (already tracked as a general v2 follow-up). +- Real-time propagation of archive to non-subscribed teammates: the `session_update` broadcast uses `BroadcastToSession`, so a teammate who has the sidebar open but is not subscribed to that session won't see it vanish until their next list refetch. This matches current behavior for other session mutations; broadening it (e.g., a team-scoped broadcast) is out of scope here. + +--- + +## Risks & Dependencies + +- Reconciler behavior on restore. After restore, `status` is `active` again while `workspace_status` is `'missing'`. Confirm the reconciler treats `'missing'` as "leave alone until the user starts it" rather than auto-recreating the container (`server/internal/reconcile/reconciler.go`). If it would auto-recreate, restore must set a status the reconciler ignores, or the recreate must be intentionally allowed. Verify during U2/U5. +- Teardown/flip atomicity. Status is flipped before teardown (KTD3); if teardown fails, the session stays archived with `workspace_status = 'failed'`. This is the intended end-state (archive succeeded, container cleanup needs a retry), but the Archived view should not imply the container is still consuming resources — surface `failed` honestly. +- Authorization regression surface. Archive/unarchive are new session-mutation routes; they must use `requireSessionMember` exactly like `UpdateSession`. The documented learning `docs/solutions/architecture-patterns/broadening-resource-visibility-requires-per-route-authorization-audit.md` is directly relevant — the write gate must run before any existence-dependent behavior. diff --git a/src/components/layout/SessionSidebar.tsx b/src/components/layout/SessionSidebar.tsx index ef7bad6..1c9c61d 100644 --- a/src/components/layout/SessionSidebar.tsx +++ b/src/components/layout/SessionSidebar.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { + Archive, Hash, Key, Pencil, @@ -25,6 +26,8 @@ import { api } from "@/lib/api"; import { useSessionStore } from "@/stores/session-store"; import { isSessionMember } from "@/lib/membership"; import { CreateSessionDialog } from "@/components/session/CreateSessionDialog"; +import { ArchiveSessionDialog } from "@/components/session/ArchiveSessionDialog"; +import { ArchivedSessionsDialog } from "@/components/session/ArchivedSessionsDialog"; import { AgentSettingsDialog } from "@/components/settings/AgentSettingsDialog"; import { SSHKeysDialog } from "@/components/settings/SSHKeysDialog"; import { TeamManagementDialog } from "@/components/teams/TeamManagementDialog"; @@ -48,6 +51,7 @@ function SessionCard({ ); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(session.description); + const [archiveOpen, setArchiveOpen] = useState(false); const inputRef = useRef(null); useEffect(() => { @@ -195,6 +199,28 @@ function SessionCard({ )}
+ {!viewOnly && ( + + )} + {archiveOpen && ( + + )} {viewOnly && ( - s.name.toLowerCase().includes(searchQuery.toLowerCase()), + // Archived sessions are excluded from listSessions server-side; this guard + // also keeps an archived session that was merged in for read-only viewing + // (viewArchivedSession) out of the live sidebar groups. + const filteredSessions = sessions.filter( + (s) => + s.status !== "archived" && + s.name.toLowerCase().includes(searchQuery.toLowerCase()), ); // A session references a project; the project carries the teamId used to @@ -446,6 +478,13 @@ export function SessionSidebar() { {/* Footer Nav */}
+
+ diff --git a/src/components/session/ArchiveSessionDialog.tsx b/src/components/session/ArchiveSessionDialog.tsx new file mode 100644 index 0000000..b6b4236 --- /dev/null +++ b/src/components/session/ArchiveSessionDialog.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { Archive, Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ApiError } from "@/lib/api"; +import { useSessionStore } from "@/stores/session-store"; + +interface Props { + sessionId: string; + sessionName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// Confirm dialog for archiving a session. Archiving hides the session from the +// sidebar and tears down its devpod container to reclaim resources; all chat +// history is preserved and the session can be restored from the Archived view. +export function ArchiveSessionDialog({ + sessionId, + sessionName, + open, + onOpenChange, +}: Props) { + const archiveSession = useSessionStore((s) => s.archiveSession); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function handleArchive() { + if (pending) return; + setError(null); + setPending(true); + try { + await archiveSession(sessionId); + onOpenChange(false); + } catch (err) { + const message = + err instanceof ApiError ? err.message : "Archive failed. Try again."; + setError(message); + } finally { + setPending(false); + } + } + + return ( + + + + Archive #{sessionName}? + + The session leaves your sidebar and its devpod container is torn + down to free resources. All chat history and the plan are kept — you + can reopen or restore it any time from the Archived view. + + + + {error && ( +

+ {error} +

+ )} + + + + + +
+
+ ); +} diff --git a/src/components/session/ArchivedSessionsDialog.tsx b/src/components/session/ArchivedSessionsDialog.tsx new file mode 100644 index 0000000..83510b3 --- /dev/null +++ b/src/components/session/ArchivedSessionsDialog.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import { ArchiveRestore, Hash, Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { ApiError } from "@/lib/api"; +import { useSessionStore } from "@/stores/session-store"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// The Archived view: a separate surface (kept out of the main sidebar) listing +// archived sessions so their history stays reachable. Each row can be opened +// for read-only viewing or restored back to the active sidebar. +export function ArchivedSessionsDialog({ open, onOpenChange }: Props) { + const archivedSessions = useSessionStore((s) => s.archivedSessions); + const loadArchivedSessions = useSessionStore((s) => s.loadArchivedSessions); + const restoreSession = useSessionStore((s) => s.restoreSession); + const viewArchivedSession = useSessionStore((s) => s.viewArchivedSession); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [restoringId, setRestoringId] = useState(null); + + // Refetch the archived list each time the dialog opens so it reflects + // anything archived/restored since it was last viewed. + useEffect(() => { + if (!open) return; + let cancelled = false; + // Synchronous reset of the fetch-status flags when the dialog opens — the + // accepted "synchronize with an external system on open" effect shape. + // eslint-disable-next-line react-hooks/set-state-in-effect + setError(null); + setLoading(true); + loadArchivedSessions() + .catch((err) => { + if (cancelled) return; + setError( + err instanceof ApiError + ? err.message + : "Failed to load archived sessions.", + ); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, loadArchivedSessions]); + + function handleOpen(sessionId: string) { + const session = archivedSessions.find((s) => s.id === sessionId); + if (!session) return; + viewArchivedSession(session); + onOpenChange(false); + } + + async function handleRestore(sessionId: string) { + if (restoringId) return; + setError(null); + setRestoringId(sessionId); + try { + await restoreSession(sessionId); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "Restore failed. Try again.", + ); + } finally { + setRestoringId(null); + } + } + + return ( + + + + Archived sessions + + History is preserved for archived sessions. Open one to read it, or + restore it to bring it back to your sidebar. + + + + {error && ( +

+ {error} +

+ )} + + {loading ? ( +
+ +
+ ) : archivedSessions.length === 0 ? ( +

+ No archived sessions. +

+ ) : ( + +
+ {archivedSessions.map((session) => ( +
+ + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index 7964ec0..2017e5c 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -93,6 +93,11 @@ interface SessionState { loadArchivedSessions: () => Promise; archiveSession: (sessionId: string) => Promise; restoreSession: (sessionId: string) => Promise; + // Open an archived session for read-only history viewing: merge it into + // `sessions` (so the center/chat panels, which resolve via sessions.find, + // can render it) and make it active. The sidebar groups filter archived + // status out, so this does not resurface it in the live lists. + viewArchivedSession: (session: Session) => void; // Data setters setCurrentUser: (user: User | null) => void; @@ -394,6 +399,15 @@ export const useSessionStore = create((set, get) => ({ })); }, + viewArchivedSession: (session) => { + set((state) => ({ + sessions: state.sessions.some((s) => s.id === session.id) + ? state.sessions + : [session, ...state.sessions], + })); + get().setActiveSession(session.id); + }, + setCurrentUser: (currentUser) => set({ currentUser }), setTeams: (teams) => set({ teams }), setProjects: (projects) => set({ projects }),