fix: add top-level GET /workspaces endpoint and derive project/repo from workspace data

- Add all_workspaces_router with GET /workspaces/ (no project/repo required)
- Include project_id in workspace responses
- Frontend: useWorkspaces() calls listAllWorkspaces when no args
- Frontend: WorkspacesPage uses top-level list, derives project/repo from workspace for mutations
- Fixes 422 from invalid UUID path params
This commit is contained in:
2026-06-01 00:20:19 +02:00
parent b05de96569
commit 59b125d8e2
6 changed files with 93 additions and 35 deletions
+49
View File
@@ -17,6 +17,54 @@ from src.services.workspace_manager import WorkspaceHasInstancesError, Workspace
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces") router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
all_workspaces_router = APIRouter(prefix="/workspaces")
@all_workspaces_router.get("/")
async def list_all_workspaces(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list[dict]:
"""List all workspaces for the current user across all repos."""
instance_count = (
select(func.count(ToolInstance.id))
.where(ToolInstance.workspace_id == Workspace.id)
.correlate(Workspace)
.scalar_subquery()
)
result = await session.execute(
select(
Workspace,
GitRepository.name.label("repo_name"),
GitRepository.project_id,
instance_count.label("instance_count"),
)
.join(GitRepository, Workspace.repo_id == GitRepository.id)
.where(Workspace.user_id == user_id)
.order_by(Workspace.created_at.desc())
)
rows = result.all()
return [
{
"id": str(ws.id),
"name": ws.name,
"repo_id": str(ws.repo_id),
"repo_name": repo_name or "",
"project_id": str(project_id) if project_id else "",
"project_name": "", # Could join with Project if needed
"user_id": str(ws.user_id),
"branch": ws.branch,
"path": ws.path,
"status": ws.status,
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
"created_at": ws.created_at.isoformat() if ws.created_at else None,
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
"instance_count": count or 0,
}
for ws, repo_name, project_id, count in rows
]
@router.get("/") @router.get("/")
@@ -54,6 +102,7 @@ async def list_workspaces(
"name": ws.name, "name": ws.name,
"repo_id": str(ws.repo_id), "repo_id": str(ws.repo_id),
"repo_name": repo.name, "repo_name": repo.name,
"project_id": str(repo.project_id) if repo.project_id else "",
"project_name": repo.project.name if repo.project else "", "project_name": repo.project.name if repo.project else "",
"user_id": str(ws.user_id), "user_id": str(ws.user_id),
"branch": ws.branch, "branch": ws.branch,
+2 -1
View File
@@ -24,7 +24,7 @@ from src.api.tool_types import router as tool_types_router
from src.api.notifications import router as notifications_router from src.api.notifications import router as notifications_router
from src.api.user_config import router as user_config_router from src.api.user_config import router as user_config_router
from src.api.users import router as users_router from src.api.users import router as users_router
from src.api.workspaces import router as workspaces_router from src.api.workspaces import all_workspaces_router, router as workspaces_router
from src.config import Settings from src.config import Settings
from src.models.notification import Notification # noqa: F401 Alembic model discovery from src.models.notification import Notification # noqa: F401 Alembic model discovery
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
@@ -160,5 +160,6 @@ app.include_router(instance_proxy_router)
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(events_router) app.include_router(events_router)
app.include_router(notifications_router) app.include_router(notifications_router)
app.include_router(all_workspaces_router)
app.include_router(workspaces_router) app.include_router(workspaces_router)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
+5
View File
@@ -22,6 +22,11 @@ export async function listWorkspaces(
return response.data; return response.data;
} }
export async function listAllWorkspaces(): Promise<Workspace[]> {
const response = await apiClient.get<Workspace[]>("/workspaces/");
return response.data;
}
export async function createWorkspace( export async function createWorkspace(
projectId: string, projectId: string,
repoId: string, repoId: string,
+7 -4
View File
@@ -1,7 +1,7 @@
/** Hook for fetching workspaces. */ /** Hook for fetching workspaces. */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { listWorkspaces } from "../api/workspaces"; import { listAllWorkspaces, listWorkspaces } from "../api/workspaces";
import type { Workspace } from "../types/workspace"; import type { Workspace } from "../types/workspace";
export interface UseWorkspacesResult { export interface UseWorkspacesResult {
@@ -12,8 +12,8 @@ export interface UseWorkspacesResult {
} }
export function useWorkspaces( export function useWorkspaces(
projectId: string, projectId?: string,
repoId: string, repoId?: string,
): UseWorkspacesResult { ): UseWorkspacesResult {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]); const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -23,7 +23,10 @@ export function useWorkspaces(
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await listWorkspaces(projectId, repoId); const data =
projectId && repoId
? await listWorkspaces(projectId, repoId)
: await listAllWorkspaces();
setWorkspaces(data); setWorkspaces(data);
} catch (err) { } catch (err) {
setError( setError(
+29 -30
View File
@@ -13,29 +13,25 @@ import type { Workspace } from "../types/workspace";
export function WorkspacesPage() { export function WorkspacesPage() {
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null); const [startWorkspace, setStartWorkspace] = useState<Workspace | null>(null);
const [createTarget, setCreateTarget] = useState<{ projectId: string; repoId: string } | null>(null);
// TODO: Get projectId and repoId from URL params or context const { workspaces, loading, error, refresh } = useWorkspaces();
const projectId = "default-project";
const repoId = "default-repo";
const { workspaces, loading, error, refresh } = useWorkspaces(
projectId,
repoId,
);
const actions = useWorkspaceActions(); const actions = useWorkspaceActions();
const handleCreate = async (data: { name: string; branch: string }) => { const handleCreate = async (data: { name: string; branch: string }) => {
await actions.create(projectId, repoId, data); if (!createTarget) return;
await actions.create(createTarget.projectId, createTarget.repoId, data);
setShowCreate(false); setShowCreate(false);
setCreateTarget(null);
await refresh(); await refresh();
}; };
const handleDelete = async (workspace: Workspace) => { const handleDelete = async (workspace: Workspace) => {
await actions.delete(projectId, repoId, workspace, refresh); await actions.delete(workspace.project_id, workspace.repo_id, workspace, refresh);
}; };
const handleSync = async (workspace: Workspace) => { const handleSync = async (workspace: Workspace) => {
await actions.sync(projectId, repoId, workspace, refresh); await actions.sync(workspace.project_id, workspace.repo_id, workspace, refresh);
}; };
const handleStartTool = async ( const handleStartTool = async (
@@ -45,8 +41,8 @@ export function WorkspacesPage() {
if (!startWorkspace) return; if (!startWorkspace) return;
try { try {
const instance = await createInstance( const instance = await createInstance(
projectId, startWorkspace.project_id,
repoId, startWorkspace.repo_id,
toolTypeId, toolTypeId,
`${startWorkspace.name} - ${toolTypeId}`, `${startWorkspace.name} - ${toolTypeId}`,
undefined, undefined,
@@ -56,7 +52,7 @@ export function WorkspacesPage() {
[], [],
startWorkspace.id, startWorkspace.id,
); );
await startInstance(projectId, repoId, instance.id, configProfileId); await startInstance(startWorkspace.project_id, startWorkspace.repo_id, instance.id, configProfileId);
setStartWorkspace(null); setStartWorkspace(null);
await refresh(); await refresh();
} catch (err) { } catch (err) {
@@ -76,23 +72,31 @@ export function WorkspacesPage() {
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => setShowCreate(true)} onClick={() => {
> if (workspaces.length > 0) {
<Icon name="add" size="sm" /> New Workspace const first = workspaces[0];
</button> setCreateTarget({ projectId: first.project_id, repoId: first.repo_id });
setShowCreate(true);
} else {
alert("Navigate to a project to create your first workspace.");
}
}}
>
<Icon name="add" size="sm" /> New Workspace
</button>
</div> </div>
</header> </header>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{showCreate && ( {showCreate && createTarget && (
<WorkspaceCreateForm <WorkspaceCreateForm
projectId={projectId} projectId={createTarget.projectId}
repoId={repoId} repoId={createTarget.repoId}
onSubmit={handleCreate} onSubmit={handleCreate}
onCancel={() => setShowCreate(false)} onCancel={() => { setShowCreate(false); setCreateTarget(null); }}
/> />
)} )}
@@ -101,12 +105,7 @@ export function WorkspacesPage() {
) : workspaces.length === 0 ? ( ) : workspaces.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<p>No workspaces yet.</p> <p>No workspaces yet.</p>
<button <p>Navigate to a project to create your first workspace.</p>
className="btn btn-primary"
onClick={() => setShowCreate(true)}
>
<Icon name="add" size="sm" /> Create your first workspace
</button>
</div> </div>
) : ( ) : (
<div className="workspaces-grid"> <div className="workspaces-grid">
+1
View File
@@ -5,6 +5,7 @@ export interface Workspace {
name: string; name: string;
repo_id: string; repo_id: string;
repo_name: string; repo_name: string;
project_id: string;
project_name: string; project_name: string;
user_id: string; user_id: string;
branch: string; branch: string;