fix: add from __future__ import annotations to workspace_manager.py
Fixes NameError: ToolInstance not defined at runtime because type annotations are evaluated at class definition time. Deferring annotation evaluation with __future__ annotations keeps TYPE_CHECKING imports from causing runtime crashes. Also includes ruff formatting cleanup on workspace-related files.
This commit is contained in:
@@ -1684,6 +1684,7 @@ async def start_instance(
|
|||||||
repo_path = ""
|
repo_path = ""
|
||||||
if instance.workspace_id:
|
if instance.workspace_id:
|
||||||
from src.models.workspace import Workspace as WorkspaceModel
|
from src.models.workspace import Workspace as WorkspaceModel
|
||||||
|
|
||||||
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
workspace = await session.get(WorkspaceModel, instance.workspace_id)
|
||||||
if workspace:
|
if workspace:
|
||||||
repo_path = workspace.path
|
repo_path = workspace.path
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ async def create_workspace(
|
|||||||
"branch": workspace.branch,
|
"branch": workspace.branch,
|
||||||
"path": workspace.path,
|
"path": workspace.path,
|
||||||
"status": workspace.status,
|
"status": workspace.status,
|
||||||
"created_at": workspace.created_at.isoformat() if workspace.created_at else None,
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -216,9 +218,7 @@ async def delete_workspace(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
detail={
|
detail={
|
||||||
"message": "Workspace has running tool instances",
|
"message": "Workspace has running tool instances",
|
||||||
"instances": [
|
"instances": [{"id": str(i.id), "name": i.name} for i in exc.instances],
|
||||||
{"id": str(i.id), "name": i.name} for i in exc.instances
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Workspace lifecycle management service."""
|
"""Workspace lifecycle management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -170,6 +172,4 @@ class WorkspaceManager:
|
|||||||
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||||
For now, this is a placeholder.
|
For now, this is a placeholder.
|
||||||
"""
|
"""
|
||||||
logger.warning(
|
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||||
"Placeholder: stopping and deleting instance %s", instance.id
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
|
|||||||
class TestListWorkspaces:
|
class TestListWorkspaces:
|
||||||
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
||||||
|
|
||||||
def test_list_empty(self, authenticated_client: TestClient, test_repo: GitRepository):
|
def test_list_empty(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
"""Returns empty list when no workspaces exist."""
|
"""Returns empty list when no workspaces exist."""
|
||||||
response = authenticated_client.get(
|
response = authenticated_client.get(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
||||||
@@ -69,7 +71,10 @@ class TestListWorkspaces:
|
|||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
def test_list_with_workspaces(
|
def test_list_with_workspaces(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns workspaces with instance counts."""
|
"""Returns workspaces with instance counts."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -99,7 +104,9 @@ class TestListWorkspaces:
|
|||||||
class TestCreateWorkspace:
|
class TestCreateWorkspace:
|
||||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
||||||
|
|
||||||
def test_create_success(self, authenticated_client: TestClient, test_repo: GitRepository):
|
def test_create_success(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
"""Creates a workspace and clones the repo."""
|
"""Creates a workspace and clones the repo."""
|
||||||
mock_ws = Workspace(
|
mock_ws = Workspace(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -110,7 +117,9 @@ class TestCreateWorkspace:
|
|||||||
path="/data/working-copies/test/feature-branch",
|
path="/data/working-copies/test/feature-branch",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(WorkspaceManager, "create", return_value=mock_ws) as mock_create:
|
with patch.object(
|
||||||
|
WorkspaceManager, "create", return_value=mock_ws
|
||||||
|
) as mock_create:
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
json={"name": "feature-branch", "branch": "feature"},
|
json={"name": "feature-branch", "branch": "feature"},
|
||||||
@@ -121,7 +130,9 @@ class TestCreateWorkspace:
|
|||||||
assert data["branch"] == "feature"
|
assert data["branch"] == "feature"
|
||||||
mock_create.assert_called_once()
|
mock_create.assert_called_once()
|
||||||
|
|
||||||
def test_create_missing_name(self, authenticated_client: TestClient, test_repo: GitRepository):
|
def test_create_missing_name(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
"""Returns 400 when name is missing."""
|
"""Returns 400 when name is missing."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
@@ -131,7 +142,10 @@ class TestCreateWorkspace:
|
|||||||
assert "name" in response.json()["detail"]
|
assert "name" in response.json()["detail"]
|
||||||
|
|
||||||
def test_create_duplicate_name(
|
def test_create_duplicate_name(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns 409 when workspace name already exists."""
|
"""Returns 409 when workspace name already exists."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -148,7 +162,9 @@ class TestCreateWorkspace:
|
|||||||
|
|
||||||
asyncio.run(_commit())
|
asyncio.run(_commit())
|
||||||
|
|
||||||
with patch.object(WorkspaceManager, "create", side_effect=Exception("duplicate")):
|
with patch.object(
|
||||||
|
WorkspaceManager, "create", side_effect=Exception("duplicate")
|
||||||
|
):
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
json={"name": "dev", "branch": "main"},
|
json={"name": "dev", "branch": "main"},
|
||||||
@@ -160,7 +176,10 @@ class TestDeleteWorkspace:
|
|||||||
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
||||||
|
|
||||||
def test_delete_without_instances(
|
def test_delete_without_instances(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Deletes workspace when no instances exist."""
|
"""Deletes workspace when no instances exist."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -185,9 +204,14 @@ class TestDeleteWorkspace:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "deleted"
|
assert response.json()["status"] == "deleted"
|
||||||
|
|
||||||
@pytest.mark.skip(reason="Async fixture interaction with sync tests — endpoint logic verified manually")
|
@pytest.mark.skip(
|
||||||
|
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
|
||||||
|
)
|
||||||
def test_delete_with_instances_no_force(
|
def test_delete_with_instances_no_force(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns 409 when workspace has instances and force=False."""
|
"""Returns 409 when workspace has instances and force=False."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -239,7 +263,10 @@ class TestDeleteWorkspace:
|
|||||||
assert len(detail["instances"]) == 1
|
assert len(detail["instances"]) == 1
|
||||||
|
|
||||||
def test_delete_with_instances_force(
|
def test_delete_with_instances_force(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Deletes workspace when force=True even with instances."""
|
"""Deletes workspace when force=True even with instances."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -268,7 +295,10 @@ class TestSyncWorkspace:
|
|||||||
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
||||||
|
|
||||||
def test_sync_success(
|
def test_sync_success(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Sync succeeds and updates last_sync_at."""
|
"""Sync succeeds and updates last_sync_at."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
@@ -298,7 +328,10 @@ class TestSyncWorkspace:
|
|||||||
assert data["pulled"] is True
|
assert data["pulled"] is True
|
||||||
|
|
||||||
def test_sync_branch_deleted(
|
def test_sync_branch_deleted(
|
||||||
self, authenticated_client: TestClient, db_session: AsyncSession, test_repo: GitRepository
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
):
|
):
|
||||||
"""Returns 409 when branch was deleted from remote."""
|
"""Returns 409 when branch was deleted from remote."""
|
||||||
ws = Workspace(
|
ws = Workspace(
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ class TestGitServiceClone:
|
|||||||
with patch(
|
with patch(
|
||||||
"asyncio.create_subprocess_exec", return_value=mock_proc
|
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||||
) as mock_exec:
|
) as mock_exec:
|
||||||
await GitService.clone("https://github.com/test/repo.git", "main", "/tmp/ws")
|
await GitService.clone(
|
||||||
|
"https://github.com/test/repo.git", "main", "/tmp/ws"
|
||||||
|
)
|
||||||
|
|
||||||
mock_exec.assert_called_once_with(
|
mock_exec.assert_called_once_with(
|
||||||
"git",
|
"git",
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ export interface Session {
|
|||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string
|
repoId: string,
|
||||||
): Promise<ToolInstance[]> {
|
): Promise<ToolInstance[]> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
);
|
);
|
||||||
return response.data.instances;
|
return response.data.instances;
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export async function createInstance(
|
|||||||
newBranch?: string,
|
newBranch?: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
sshKeyIds?: string[],
|
sshKeyIds?: string[],
|
||||||
workspaceId?: string
|
workspaceId?: string,
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -68,7 +68,7 @@ export async function createInstance(
|
|||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
config_profile_id: configProfileId,
|
config_profile_id: configProfileId,
|
||||||
ssh_key_ids: sshKeyIds || [],
|
ssh_key_ids: sshKeyIds || [],
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -79,12 +79,12 @@ export async function startInstance(
|
|||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
sshKeyIds?: string[],
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,7 +92,14 @@ export async function startInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
return startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
retries - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -101,10 +108,10 @@ export async function startInstance(
|
|||||||
export async function stopInstance(
|
export async function stopInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -115,12 +122,12 @@ export async function restartInstance(
|
|||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
sshKeyIds?: string[],
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -128,7 +135,14 @@ export async function restartInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
return restartInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
retries - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -138,11 +152,11 @@ export async function deleteInstance(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
force?: boolean
|
force?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
{ params: { force } }
|
{ params: { force } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +180,10 @@ export interface InstanceHealth {
|
|||||||
export async function checkInstanceHealth(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<InstanceHealth> {
|
): Promise<InstanceHealth> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -177,10 +191,10 @@ export async function checkInstanceHealth(
|
|||||||
export async function recreateInstanceTunnel(
|
export async function recreateInstanceTunnel(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
/** Workspace API client. */
|
/** Workspace API client. */
|
||||||
|
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
import type { Workspace, CreateWorkspaceRequest, SyncResult } from "../types/workspace";
|
import type {
|
||||||
|
Workspace,
|
||||||
|
CreateWorkspaceRequest,
|
||||||
|
SyncResult,
|
||||||
|
} from "../types/workspace";
|
||||||
|
|
||||||
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
||||||
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
||||||
return workspaceId ? `${base}/${workspaceId}` : base;
|
return workspaceId ? `${base}/${workspaceId}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkspaces(projectId: string, repoId: string): Promise<Workspace[]> {
|
export async function listWorkspaces(
|
||||||
const response = await apiClient.get<Workspace[]>(workspaceUrl(projectId, repoId));
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
): Promise<Workspace[]> {
|
||||||
|
const response = await apiClient.get<Workspace[]>(
|
||||||
|
workspaceUrl(projectId, repoId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +27,10 @@ export async function createWorkspace(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
data: CreateWorkspaceRequest,
|
data: CreateWorkspaceRequest,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const response = await apiClient.post<Workspace>(workspaceUrl(projectId, repoId), data);
|
const response = await apiClient.post<Workspace>(
|
||||||
|
workspaceUrl(projectId, repoId),
|
||||||
|
data,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +39,9 @@ export async function getWorkspace(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const response = await apiClient.get<Workspace>(workspaceUrl(projectId, repoId, workspaceId));
|
const response = await apiClient.get<Workspace>(
|
||||||
|
workspaceUrl(projectId, repoId, workspaceId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +51,10 @@ export async function updateWorkspace(
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
data: Partial<CreateWorkspaceRequest>,
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const response = await apiClient.patch<Workspace>(workspaceUrl(projectId, repoId, workspaceId), data);
|
const response = await apiClient.patch<Workspace>(
|
||||||
|
workspaceUrl(projectId, repoId, workspaceId),
|
||||||
|
data,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export interface StartToolModalProps {
|
|||||||
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartToolModal({ workspace, onClose, onStart }: StartToolModalProps) {
|
export function StartToolModal({
|
||||||
|
workspace,
|
||||||
|
onClose,
|
||||||
|
onStart,
|
||||||
|
}: StartToolModalProps) {
|
||||||
const [toolTypeId, setToolTypeId] = useState("");
|
const [toolTypeId, setToolTypeId] = useState("");
|
||||||
const [configProfileId, setConfigProfileId] = useState("");
|
const [configProfileId, setConfigProfileId] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -73,10 +77,19 @@ export function StartToolModal({ workspace, onClose, onStart }: StartToolModalPr
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="form-error">{error}</p>}
|
{error && <p className="form-error">{error}</p>}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
{submitting ? "Starting..." : "Start Tool"}
|
{submitting ? "Starting..." : "Start Tool"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export function WorkspaceCard({
|
|||||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||||
<div className="workspace-header">
|
<div className="workspace-header">
|
||||||
<h4>{workspace.name}</h4>
|
<h4>{workspace.name}</h4>
|
||||||
<span className={`status-badge ${statusClass}`}>{workspace.status}</span>
|
<span className={`status-badge ${statusClass}`}>
|
||||||
|
{workspace.status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-meta">
|
<div className="workspace-meta">
|
||||||
<p className="workspace-project">
|
<p className="workspace-project">
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export function WorkspaceCreateForm({
|
|||||||
try {
|
try {
|
||||||
await onSubmit({ name: name.trim(), branch: branch.trim() });
|
await onSubmit({ name: name.trim(), branch: branch.trim() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to create workspace");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to create workspace",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,12 @@ export function WorkspaceCreateForm({
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="form-error">{error}</p>}
|
{error && <p className="form-error">{error}</p>}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={submitting}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
|||||||
@@ -107,9 +107,16 @@ export function useWorkspaceActions(): UseWorkspaceActionsResult {
|
|||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as ApiError;
|
const error = err as ApiError;
|
||||||
if (error.response?.status === 409 && error.response.data?.detail?.branch_deleted) {
|
if (
|
||||||
const message = error.response.data.detail.message || "Branch was deleted from remote";
|
error.response?.status === 409 &&
|
||||||
const confirmed = window.confirm(`${message}\n\nDelete this workspace?`);
|
error.response.data?.detail?.branch_deleted
|
||||||
|
) {
|
||||||
|
const message =
|
||||||
|
error.response.data.detail.message ||
|
||||||
|
"Branch was deleted from remote";
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`${message}\n\nDelete this workspace?`,
|
||||||
|
);
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
await deleteWorkspace(projectId, repoId, workspace.id, true);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export interface UseWorkspacesResult {
|
|||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesResult {
|
export function useWorkspaces(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
): UseWorkspacesResult {
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -23,7 +26,9 @@ export function useWorkspaces(projectId: string, repoId: string): UseWorkspacesR
|
|||||||
const data = await listWorkspaces(projectId, repoId);
|
const data = await listWorkspaces(projectId, repoId);
|
||||||
setWorkspaces(data);
|
setWorkspaces(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load workspaces");
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load workspaces",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function WorkspacesPage() {
|
|||||||
const projectId = "default-project";
|
const projectId = "default-project";
|
||||||
const repoId = "default-repo";
|
const repoId = "default-repo";
|
||||||
|
|
||||||
const { workspaces, loading, error, refresh } = useWorkspaces(projectId, repoId);
|
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 }) => {
|
||||||
@@ -35,7 +38,10 @@ export function WorkspacesPage() {
|
|||||||
await actions.sync(projectId, repoId, workspace, refresh);
|
await actions.sync(projectId, repoId, workspace, refresh);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartTool = async (toolTypeId: string, configProfileId?: string) => {
|
const handleStartTool = async (
|
||||||
|
toolTypeId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
) => {
|
||||||
if (!startWorkspace) return;
|
if (!startWorkspace) return;
|
||||||
try {
|
try {
|
||||||
const instance = await createInstance(
|
const instance = await createInstance(
|
||||||
@@ -48,7 +54,7 @@ export function WorkspacesPage() {
|
|||||||
undefined,
|
undefined,
|
||||||
configProfileId,
|
configProfileId,
|
||||||
[],
|
[],
|
||||||
startWorkspace.id
|
startWorkspace.id,
|
||||||
);
|
);
|
||||||
await startInstance(projectId, repoId, instance.id, configProfileId);
|
await startInstance(projectId, repoId, instance.id, configProfileId);
|
||||||
setStartWorkspace(null);
|
setStartWorkspace(null);
|
||||||
@@ -63,10 +69,17 @@ export function WorkspacesPage() {
|
|||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1>Workspaces</h1>
|
<h1>Workspaces</h1>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="btn btn-secondary" onClick={refresh} disabled={loading}>
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="refresh" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
>
|
||||||
<Icon name="add" size="sm" /> New Workspace
|
<Icon name="add" size="sm" /> New Workspace
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +101,10 @@ 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 className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
>
|
||||||
<Icon name="add" size="sm" /> Create your first workspace
|
<Icon name="add" size="sm" /> Create your first workspace
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-5
@@ -22,7 +22,10 @@ export const AppRouter = () => {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginRedirectPage />} />
|
<Route path="/login" element={<LoginRedirectPage />} />
|
||||||
<Route path="/ssh-keys" element={<Navigate to="/settings/ssh-keys" replace />} />
|
<Route
|
||||||
|
path="/ssh-keys"
|
||||||
|
element={<Navigate to="/settings/ssh-keys" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
@@ -34,9 +37,18 @@ export const AppRouter = () => {
|
|||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
||||||
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
|
<Route
|
||||||
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
|
path="projects/:projectId/repositories"
|
||||||
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
|
element={<GitRepositoriesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="projects/:projectId/repositories/:repoId/history"
|
||||||
|
element={<GitHistoryPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="projects/:projectId/settings/*"
|
||||||
|
element={<ProjectSettingsPage />}
|
||||||
|
/>
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
<Route path="config-profiles" element={<ConfigProfilesPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />}>
|
<Route path="settings" element={<SettingsPage />}>
|
||||||
@@ -48,7 +60,10 @@ export const AppRouter = () => {
|
|||||||
<Route path="sessions" element={<SessionsPage />} />
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route path="instances/:instanceId/terminal" element={<TerminalPage />} />
|
<Route
|
||||||
|
path="instances/:instanceId/terminal"
|
||||||
|
element={<TerminalPage />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/404" element={<NotFoundPage />} />
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user