Skip to content
This repository was archived by the owner on Jun 5, 2025. It is now read-only.

Commit afc4232

Browse files
committed
Implement CRUD actions for dealing with archived workspaces
This implements listing, recovering and hard-deleting archived workspaces. Closes: #668 Signed-off-by: Juan Antonio Osorio <ozz@stacklok.com>
1 parent f11cbdf commit afc4232

File tree

5 files changed

+212
-3
lines changed

5 files changed

+212
-3
lines changed

src/codegate/api/v1.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async def list_workspaces() -> v1_models.ListWorkspacesResponse:
2727
"""List all workspaces."""
2828
wslist = await wscrud.get_workspaces()
2929

30-
resp = v1_models.ListWorkspacesResponse.from_db_workspaces(wslist)
30+
resp = v1_models.ListWorkspacesResponse.from_db_workspaces_active(wslist)
3131

3232
return resp
3333

@@ -136,6 +136,55 @@ async def delete_workspace(workspace_name: str):
136136
return Response(status_code=204)
137137

138138

139+
@v1.get("/workspaces/archive", tags=["Workspaces"], generate_unique_id_function=uniq_name)
140+
async def list_archived_workspaces() -> v1_models.ListWorkspacesResponse:
141+
"""List all archived workspaces."""
142+
wslist = await wscrud.get_archived_workspaces()
143+
144+
resp = v1_models.ListWorkspacesResponse.from_db_workspaces(wslist)
145+
146+
return resp
147+
148+
149+
@v1.post(
150+
"/workspaces/archive/{workspace_name}/recover",
151+
tags=["Workspaces"],
152+
generate_unique_id_function=uniq_name,
153+
status_code=204,
154+
)
155+
async def recover_workspace(workspace_name: str):
156+
"""Recover an archived workspace by name."""
157+
try:
158+
_ = await wscrud.recover_workspace(workspace_name)
159+
except crud.WorkspaceDoesNotExistError:
160+
raise HTTPException(status_code=404, detail="Workspace does not exist")
161+
except crud.WorkspaceCrudError as e:
162+
raise HTTPException(status_code=400, detail=str(e))
163+
except Exception:
164+
raise HTTPException(status_code=500, detail="Internal server error")
165+
166+
return Response(status_code=204)
167+
168+
169+
@v1.delete(
170+
"/workspaces/archive/{workspace_name}",
171+
tags=["Workspaces"],
172+
generate_unique_id_function=uniq_name,
173+
)
174+
async def hard_delete_workspace(workspace_name: str):
175+
"""Hard delete an archived workspace by name."""
176+
try:
177+
_ = await wscrud.hard_delete_workspace(workspace_name)
178+
except crud.WorkspaceDoesNotExistError:
179+
raise HTTPException(status_code=404, detail="Workspace does not exist")
180+
except crud.WorkspaceCrudError as e:
181+
raise HTTPException(status_code=400, detail=str(e))
182+
except Exception:
183+
raise HTTPException(status_code=500, detail="Internal server error")
184+
185+
return Response(status_code=204)
186+
187+
139188
@v1.get(
140189
"/workspaces/{workspace_name}/alerts",
141190
tags=["Workspaces"],

src/codegate/api/v1_models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ListWorkspacesResponse(pydantic.BaseModel):
2323
workspaces: list[Workspace]
2424

2525
@classmethod
26-
def from_db_workspaces(
26+
def from_db_workspaces_active(
2727
cls, db_workspaces: List[db_models.WorkspaceActive]
2828
) -> "ListWorkspacesResponse":
2929
return cls(
@@ -33,6 +33,12 @@ def from_db_workspaces(
3333
]
3434
)
3535

36+
@classmethod
37+
def from_db_workspaces(
38+
cls, db_workspaces: List[db_models.Workspace]
39+
) -> "ListWorkspacesResponse":
40+
return cls(workspaces=[Workspace(name=ws.name, is_active=False) for ws in db_workspaces])
41+
3642

3743
class ListActiveWorkspacesResponse(pydantic.BaseModel):
3844
workspaces: list[ActiveWorkspace]

src/codegate/db/connection.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,33 @@ async def soft_delete_workspace(self, workspace: Workspace) -> Optional[Workspac
318318
)
319319
return deleted_workspace
320320

321+
async def hard_delete_workspace(self, workspace: Workspace) -> Optional[Workspace]:
322+
sql = text(
323+
"""
324+
DELETE FROM workspaces
325+
WHERE id = :id
326+
RETURNING *
327+
"""
328+
)
329+
deleted_workspace = await self._execute_update_pydantic_model(
330+
workspace, sql, should_raise=True
331+
)
332+
return deleted_workspace
333+
334+
async def recover_workspace(self, workspace: Workspace) -> Optional[Workspace]:
335+
sql = text(
336+
"""
337+
UPDATE workspaces
338+
SET deleted_at = NULL
339+
WHERE id = :id
340+
RETURNING *
341+
"""
342+
)
343+
recovered_workspace = await self._execute_update_pydantic_model(
344+
workspace, sql, should_raise=True
345+
)
346+
return recovered_workspace
347+
321348

322349
class DbReader(DbCodeGate):
323350

@@ -431,6 +458,19 @@ async def get_workspaces(self) -> List[WorkspaceActive]:
431458
workspaces = await self._execute_select_pydantic_model(WorkspaceActive, sql)
432459
return workspaces
433460

461+
async def get_archived_workspaces(self) -> List[Workspace]:
462+
sql = text(
463+
"""
464+
SELECT
465+
id, name, system_prompt
466+
FROM workspaces
467+
WHERE deleted_at IS NOT NULL
468+
ORDER BY deleted_at DESC
469+
"""
470+
)
471+
workspaces = await self._execute_select_pydantic_model(Workspace, sql)
472+
return workspaces
473+
434474
async def get_workspace_by_name(self, name: str) -> Optional[Workspace]:
435475
sql = text(
436476
"""
@@ -446,6 +486,21 @@ async def get_workspace_by_name(self, name: str) -> Optional[Workspace]:
446486
)
447487
return workspaces[0] if workspaces else None
448488

489+
async def get_archived_workspace_by_name(self, name: str) -> Optional[Workspace]:
490+
sql = text(
491+
"""
492+
SELECT
493+
id, name, system_prompt
494+
FROM workspaces
495+
WHERE name = :name AND deleted_at IS NOT NULL
496+
"""
497+
)
498+
conditions = {"name": name}
499+
workspaces = await self._exec_select_conditions_to_pydantic(
500+
Workspace, sql, conditions, should_raise=True
501+
)
502+
return workspaces[0] if workspaces else None
503+
449504
async def get_sessions(self) -> List[Session]:
450505
sql = text(
451506
"""

src/codegate/pipeline/cli/commands.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ def subcommands(self) -> Dict[str, Callable[[List[str]], Awaitable[str]]]:
155155
"activate": self._activate_workspace,
156156
"remove": self._remove_workspace,
157157
"rename": self._rename_workspace,
158+
"list-archived": self._list_archived_workspaces,
159+
"restore": self._restore_workspace,
160+
"delete-archived": self._delete_archived_workspace,
158161
}
159162

160163
async def _list_workspaces(self, flags: Dict[str, str], args: List[str]) -> str:
@@ -267,6 +270,58 @@ async def _remove_workspace(self, flags: Dict[str, str], args: List[str]) -> str
267270
return "An error occurred while removing the workspace"
268271
return f"Workspace **{workspace_name}** has been removed"
269272

273+
async def _list_archived_workspaces(self, flags: Dict[str, str], args: List[str]) -> str:
274+
"""
275+
List all archived workspaces
276+
"""
277+
workspaces = await self.workspace_crud.get_archived_workspaces()
278+
respond_str = ""
279+
for workspace in workspaces:
280+
respond_str += f"- {workspace.name}\n"
281+
return respond_str
282+
283+
async def _restore_workspace(self, flags: Dict[str, str], args: List[str]) -> str:
284+
"""
285+
Restore an archived workspace
286+
"""
287+
if args is None or len(args) == 0:
288+
return "Please provide a name. Use `codegate workspace restore workspace_name`"
289+
290+
workspace_name = args[0]
291+
if not workspace_name:
292+
return "Please provide a name. Use `codegate workspace restore workspace_name`"
293+
294+
try:
295+
await self.workspace_crud.recover_workspace(workspace_name)
296+
except crud.WorkspaceDoesNotExistError:
297+
return f"Workspace **{workspace_name}** does not exist"
298+
except crud.WorkspaceCrudError as e:
299+
return str(e)
300+
except Exception:
301+
return "An error occurred while restoring the workspace"
302+
return f"Workspace **{workspace_name}** has been restored"
303+
304+
async def _delete_archived_workspace(self, flags: Dict[str, str], args: List[str]) -> str:
305+
"""
306+
Hard delete an archived workspace
307+
"""
308+
if args is None or len(args) == 0:
309+
return "Please provide a name. Use `codegate workspace delete-archived workspace_name`"
310+
311+
workspace_name = args[0]
312+
if not workspace_name:
313+
return "Please provide a name. Use `codegate workspace delete-archived workspace_name`"
314+
315+
try:
316+
await self.workspace_crud.hard_delete_workspace(workspace_name)
317+
except crud.WorkspaceDoesNotExistError:
318+
return f"Workspace **{workspace_name}** does not exist"
319+
except crud.WorkspaceCrudError as e:
320+
return str(e)
321+
except Exception:
322+
return "An error occurred while deleting the workspace"
323+
return f"Workspace **{workspace_name}** has been deleted"
324+
270325
@property
271326
def help(self) -> str:
272327
return (
@@ -289,6 +344,14 @@ def help(self) -> str:
289344
" - *args*:\n\n"
290345
" - `workspace_name`\n"
291346
" - `new_workspace_name`\n\n"
347+
"- `list-archived`: List all archived workspaces\n\n"
348+
" - *args*: None\n\n"
349+
"- `restore`: Restore an archived workspace\n\n"
350+
" - *args*:\n\n"
351+
" - `workspace_name`\n\n"
352+
"- `delete-archived`: Hard delete an archived workspace\n\n"
353+
" - *args*:\n\n"
354+
" - `workspace_name`\n\n"
292355
)
293356

294357

src/codegate/workspaces/crud.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class WorkspaceAlreadyActiveError(WorkspaceCrudError):
2020
DEFAULT_WORKSPACE_NAME = "default"
2121

2222
# These are reserved keywords that cannot be used for workspaces
23-
RESERVED_WORKSPACE_KEYWORDS = [DEFAULT_WORKSPACE_NAME, "active"]
23+
RESERVED_WORKSPACE_KEYWORDS = [DEFAULT_WORKSPACE_NAME, "active", "archived"]
2424

2525

2626
class WorkspaceCrud:
@@ -75,6 +75,12 @@ async def get_workspaces(self) -> List[WorkspaceActive]:
7575
"""
7676
return await self._db_reader.get_workspaces()
7777

78+
async def get_archived_workspaces(self) -> List[Workspace]:
79+
"""
80+
Get all archived workspaces
81+
"""
82+
return await self._db_reader.get_archived_workspaces()
83+
7884
async def get_active_workspace(self) -> Optional[ActiveWorkspace]:
7985
"""
8086
Get the active workspace
@@ -115,6 +121,18 @@ async def activate_workspace(self, workspace_name: str):
115121
await db_recorder.update_session(session)
116122
return
117123

124+
async def recover_workspace(self, workspace_name: str):
125+
"""
126+
Recover an archived workspace
127+
"""
128+
selected_workspace = await self._db_reader.get_archived_workspace_by_name(workspace_name)
129+
if not selected_workspace:
130+
raise WorkspaceDoesNotExistError(f"Workspace {workspace_name} does not exist.")
131+
132+
db_recorder = DbRecorder()
133+
await db_recorder.recover_workspace(selected_workspace)
134+
return
135+
118136
async def update_workspace_system_prompt(
119137
self, workspace_name: str, sys_prompt_lst: List[str]
120138
) -> Workspace:
@@ -157,6 +175,24 @@ async def soft_delete_workspace(self, workspace_name: str):
157175
raise WorkspaceCrudError(f"Error deleting workspace {workspace_name}")
158176
return
159177

178+
async def hard_delete_workspace(self, workspace_name: str):
179+
"""
180+
Hard delete a workspace
181+
"""
182+
if workspace_name == "":
183+
raise WorkspaceCrudError("Workspace name cannot be empty.")
184+
185+
selected_workspace = await self._db_reader.get_archived_workspace_by_name(workspace_name)
186+
if not selected_workspace:
187+
raise WorkspaceDoesNotExistError(f"Workspace {workspace_name} does not exist.")
188+
189+
db_recorder = DbRecorder()
190+
try:
191+
_ = await db_recorder.hard_delete_workspace(selected_workspace)
192+
except Exception:
193+
raise WorkspaceCrudError(f"Error deleting workspace {workspace_name}")
194+
return
195+
160196
async def get_workspace_by_name(self, workspace_name: str) -> Workspace:
161197
workspace = await self._db_reader.get_workspace_by_name(workspace_name)
162198
if not workspace:

0 commit comments

Comments
 (0)