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

Commit 3736620

Browse files
Implement CRUD actions for dealing with archived workspaces (#686)
* 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> * Add pragma to enable foreign key support in SQLite Co-Authored-By: Alejandro Ponce de Leon <aponcedeleonch@stacklok.com> Signed-off-by: Juan Antonio Osorio <ozz@stacklok.com> --------- Signed-off-by: Juan Antonio Osorio <ozz@stacklok.com> Co-authored-by: Alejandro Ponce de Leon <aponcedeleonch@stacklok.com>
1 parent fc3f468 commit 3736620

File tree

5 files changed

+228
-4
lines changed

5 files changed

+228
-4
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: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from alembic import command as alembic_command
99
from alembic.config import Config as AlembicConfig
1010
from pydantic import BaseModel
11-
from sqlalchemy import CursorResult, TextClause, text
11+
from sqlalchemy import CursorResult, TextClause, event, text
12+
from sqlalchemy.engine import Engine
1213
from sqlalchemy.exc import IntegrityError, OperationalError
1314
from sqlalchemy.ext.asyncio import create_async_engine
1415

@@ -35,6 +36,20 @@ class AlreadyExistsError(Exception):
3536
pass
3637

3738

39+
@event.listens_for(Engine, "connect")
40+
def set_sqlite_pragma(dbapi_connection, connection_record):
41+
"""
42+
Ensures that foreign keys are enabled for the SQLite database at every connection.
43+
SQLite does not enforce foreign keys by default, so we need to enable them manually.
44+
[SQLAlchemy docs](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#foreign-key-support)
45+
[SQLite docs](https://www.sqlite.org/foreignkeys.html)
46+
[SO](https://stackoverflow.com/questions/2614984/sqlite-sqlalchemy-how-to-enforce-foreign-keys)
47+
"""
48+
cursor = dbapi_connection.cursor()
49+
cursor.execute("PRAGMA foreign_keys=ON")
50+
cursor.close()
51+
52+
3853
class DbCodeGate:
3954
_instance = None
4055

@@ -318,6 +333,33 @@ async def soft_delete_workspace(self, workspace: Workspace) -> Optional[Workspac
318333
)
319334
return deleted_workspace
320335

336+
async def hard_delete_workspace(self, workspace: Workspace) -> Optional[Workspace]:
337+
sql = text(
338+
"""
339+
DELETE FROM workspaces
340+
WHERE id = :id
341+
RETURNING *
342+
"""
343+
)
344+
deleted_workspace = await self._execute_update_pydantic_model(
345+
workspace, sql, should_raise=True
346+
)
347+
return deleted_workspace
348+
349+
async def recover_workspace(self, workspace: Workspace) -> Optional[Workspace]:
350+
sql = text(
351+
"""
352+
UPDATE workspaces
353+
SET deleted_at = NULL
354+
WHERE id = :id
355+
RETURNING *
356+
"""
357+
)
358+
recovered_workspace = await self._execute_update_pydantic_model(
359+
workspace, sql, should_raise=True
360+
)
361+
return recovered_workspace
362+
321363

322364
class DbReader(DbCodeGate):
323365

@@ -431,6 +473,19 @@ async def get_workspaces(self) -> List[WorkspaceActive]:
431473
workspaces = await self._execute_select_pydantic_model(WorkspaceActive, sql)
432474
return workspaces
433475

476+
async def get_archived_workspaces(self) -> List[Workspace]:
477+
sql = text(
478+
"""
479+
SELECT
480+
id, name, system_prompt
481+
FROM workspaces
482+
WHERE deleted_at IS NOT NULL
483+
ORDER BY deleted_at DESC
484+
"""
485+
)
486+
workspaces = await self._execute_select_pydantic_model(Workspace, sql)
487+
return workspaces
488+
434489
async def get_workspace_by_name(self, name: str) -> Optional[Workspace]:
435490
sql = text(
436491
"""
@@ -446,6 +501,21 @@ async def get_workspace_by_name(self, name: str) -> Optional[Workspace]:
446501
)
447502
return workspaces[0] if workspaces else None
448503

504+
async def get_archived_workspace_by_name(self, name: str) -> Optional[Workspace]:
505+
sql = text(
506+
"""
507+
SELECT
508+
id, name, system_prompt
509+
FROM workspaces
510+
WHERE name = :name AND deleted_at IS NOT NULL
511+
"""
512+
)
513+
conditions = {"name": name}
514+
workspaces = await self._exec_select_conditions_to_pydantic(
515+
Workspace, sql, conditions, should_raise=True
516+
)
517+
return workspaces[0] if workspaces else None
518+
449519
async def get_sessions(self) -> List[Session]:
450520
sql = text(
451521
"""

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)