Skip to content

feat: add remote workspace operations API - #127

Merged
delete-cloud merged 1 commit into
mainfrom
feat/adr-0021-pr3-workspace-ops
May 10, 2026
Merged

delete-cloud merged 1 commit into
mainfrom
feat/adr-0021-pr3-workspace-ops

Conversation

@delete-cloud

@delete-cloud delete-cloud commented May 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add provider-facing workspace inventory, cleanup, archive export, and archive manifest APIs
  • expose admin HTTP workspace operations and session-scoped archive manifest/download endpoints
  • keep the existing session workspace archive endpoint as a compatibility alias

Validation

  • uv run pytest tests/ui/test_http_server.py tests/ui/test_http_server_workspace_transfer.py tests/ui/test_session_manager_public_api.py tests/ui/test_session_manager_runtime.py tests/coding_agent/environment/ -k "session or workspace or remote or cancel or manifest or admin" -q
  • uv run ruff check src/coding_agent/environment/workspace_provider.py src/coding_agent/environment/docker_workspace_provider.py src/coding_agent/environment/init.py src/coding_agent/ui/http_server.py src/coding_agent/ui/schemas.py src/coding_agent/ui/session_manager.py tests/ui/test_http_server_workspace_transfer.py
  • uv run basedpyright src/coding_agent/environment/workspace_provider.py src/coding_agent/environment/docker_workspace_provider.py src/coding_agent/environment/init.py src/coding_agent/ui/http_server.py src/coding_agent/ui/schemas.py src/coding_agent/ui/session_manager.py (0 errors; existing warnings)
  • git diff --check

Summary by CodeRabbit

Release Notes

  • New Features

    • Added workspace management capabilities including listing, retrieval, cleanup, and archive export operations
    • Introduced workspace archive manifest generation with detailed file count, size, and change tracking
    • Added admin-protected REST API endpoints for workspace administration and monitoring
  • Tests

    • Added comprehensive test coverage for workspace management endpoints and authentication

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 10, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR extends the cloud workspace platform with inventory, cleanup, and archive-export capabilities. It introduces workspace status and archive metadata dataclasses, adds five new protocol methods to WorkspaceProvider, implements them in the Docker workspace provider, exposes admin-protected REST endpoints for workspace management, refactors session archive export to use a generic callable pattern, and includes comprehensive test coverage.

Changes

Cloud Workspace Management Lifecycle

Layer / File(s) Summary
Data Models & Protocol Contracts
workspace_provider.py
Introduces WorkspaceStatus enum, WorkspaceInventoryEntry and WorkspaceArchiveManifest dataclasses, and extends WorkspaceProvider protocol with list_cloud_workspaces, get_cloud_workspace, cleanup_cloud_workspace, export_workspace_archive_by_id, and workspace_archive_manifest methods.
Docker Provider Implementation
docker_workspace_provider.py
Implements five protocol methods in DockerWorkspaceProvider with helpers for directory iteration, status computation (active vs. stale based on age threshold), base64 archive creation, SHA-256 computation, and manifest generation. Adjusts error message in quota enforcement.
Config-based Delegation
workspace_provider.py
Adds five *_from_config helper functions that resolve the configured provider and delegate list, get, cleanup, export, and manifest operations while preserving optional filtering by active_workspace_ids.
Generic Archive Export
session_manager.py
Generalizes SessionManager.export_workspace_archive signature to accept Callable[[CloudWorkspaceBinding], T] and return T, enabling flexible archive-generation callables.
REST Response Schemas
schemas.py
Adds WorkspaceSummarySchema, WorkspaceListResponse, WorkspaceCleanupResponse, WorkspaceGcResponse, and WorkspaceArchiveManifestResponse for structured HTTP responses.
HTTP API Endpoints & Refactoring
http_server.py
Introduces six admin-protected /workspaces routes (list, GC, get, cleanup, manifest, archive), refactors session archive export into manifest and archive endpoints with compatibility alias, includes _require_admin_context guard and response mapping helpers.
Package Re-exports
environment/__init__.py
Updates imports and __all__ to publicly expose new workspace types and helper functions from workspace_provider.
Tests
test_http_server_workspace_transfer.py
Adds helpers for auth configuration, validates admin-only access, workspace filtering, cleanup behavior with active sessions, archive manifest correctness, and endpoint alias compatibility.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Workspaces now list, clean, and archive with grace,
Admin endpoints guard this cloud-bound space,
Inventory entries track their aging state,
Manifests describe each archive's fate,
From Docker to REST, the plumbing's in place! 📦✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding a remote workspace operations API. It directly reflects the main functionality introduced across multiple modules.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/adr-0021-pr3-workspace-ops

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@coding-agent/src/coding_agent/environment/docker_workspace_provider.py`:
- Around line 800-836: Wrap filesystem stat()/access operations to tolerate
concurrent deletions: in _docker_workspace_entry, catch
FileNotFoundError/OSError around workspace_root.stat() (used for mtime checks
and updated_at) and return a sentinel (or raise a custom skip) so callers can
skip the entry; in _list_docker_workspaces, call _docker_workspace_entry inside
a try/except (catching FileNotFoundError/OSError) and continue (skip) when a
workspace disappears between iteration and stat. Ensure you only skip the
problematic workspace and do not let the exception bubble up.

In `@coding-agent/src/coding_agent/ui/http_server.py`:
- Around line 1368-1409: The handlers get workspace and cleanup_workspace
currently map KeyError to 404 using str(exc) and let ValueError bubble to 500;
change both to: catch ValueError from get_cloud_workspace_from_config and
cleanup_cloud_workspace_from_config and raise HTTPException(status_code=400,
detail=str(exc)) to indicate malformed client input, replace raising 404 with
HTTPException(status_code=404, detail=_key_error_detail(exc)) so KeyError uses
_key_error_detail instead of str(exc), and ensure these changes are applied in
the async try/except blocks in the get handler (where
get_cloud_workspace_from_config is called) and in cleanup_workspace (where
cleanup_cloud_workspace_from_config is called).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 966e311c-58aa-4357-8807-8549ec0ffc54

📥 Commits

Reviewing files that changed from the base of the PR and between c17ae80 and 333c6fa.

📒 Files selected for processing (7)
  • coding-agent/src/coding_agent/environment/__init__.py
  • coding-agent/src/coding_agent/environment/docker_workspace_provider.py
  • coding-agent/src/coding_agent/environment/workspace_provider.py
  • coding-agent/src/coding_agent/ui/http_server.py
  • coding-agent/src/coding_agent/ui/schemas.py
  • coding-agent/src/coding_agent/ui/session_manager.py
  • coding-agent/tests/ui/test_http_server_workspace_transfer.py

Comment on lines +800 to +836
def _list_docker_workspaces(
provider_config: _DockerWorkspaceProviderConfig,
active_workspace_ids: set[str],
) -> list[WorkspaceInventoryEntry]:
if not provider_config.workspace_root.exists():
return []
entries: list[WorkspaceInventoryEntry] = []
for path in sorted(
provider_config.workspace_root.iterdir(), key=lambda item: item.name
):
if not path.is_dir() or not _is_provider_workspace_id(path.name):
continue
entries.append(
_docker_workspace_entry(provider_config, path, active_workspace_ids)
)
return entries


def _docker_workspace_entry(
provider_config: _DockerWorkspaceProviderConfig,
workspace_root: Path,
active_workspace_ids: set[str],
) -> WorkspaceInventoryEntry:
workspace_id = workspace_root.name
status: WorkspaceStatus = "active"
if workspace_id not in active_workspace_ids:
max_age = provider_config.max_workspace_age_seconds
if (
max_age is not None
and workspace_root.stat().st_mtime < time.time() - max_age
):
status = "stale"
return WorkspaceInventoryEntry(
workspace_id=workspace_id,
status=status,
updated_at=datetime.fromtimestamp(workspace_root.stat().st_mtime, UTC),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle concurrent workspace deletion during inventory listing.

At Line 807 and Line 829/835, stat() can fail if another request cleans a workspace concurrently, causing the entire listing to error instead of skipping that workspace.

Proposed fix
 def _list_docker_workspaces(
     provider_config: _DockerWorkspaceProviderConfig,
     active_workspace_ids: set[str],
 ) -> list[WorkspaceInventoryEntry]:
@@
     for path in sorted(
         provider_config.workspace_root.iterdir(), key=lambda item: item.name
     ):
         if not path.is_dir() or not _is_provider_workspace_id(path.name):
             continue
-        entries.append(
-            _docker_workspace_entry(provider_config, path, active_workspace_ids)
-        )
+        try:
+            entries.append(
+                _docker_workspace_entry(provider_config, path, active_workspace_ids)
+            )
+        except FileNotFoundError:
+            continue
     return entries
@@
 def _docker_workspace_entry(
@@
 ) -> WorkspaceInventoryEntry:
     workspace_id = workspace_root.name
+    stat_result = workspace_root.stat()
     status: WorkspaceStatus = "active"
     if workspace_id not in active_workspace_ids:
         max_age = provider_config.max_workspace_age_seconds
         if (
             max_age is not None
-            and workspace_root.stat().st_mtime < time.time() - max_age
+            and stat_result.st_mtime < time.time() - max_age
         ):
             status = "stale"
     return WorkspaceInventoryEntry(
         workspace_id=workspace_id,
         status=status,
-        updated_at=datetime.fromtimestamp(workspace_root.stat().st_mtime, UTC),
+        updated_at=datetime.fromtimestamp(stat_result.st_mtime, UTC),
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@coding-agent/src/coding_agent/environment/docker_workspace_provider.py`
around lines 800 - 836, Wrap filesystem stat()/access operations to tolerate
concurrent deletions: in _docker_workspace_entry, catch
FileNotFoundError/OSError around workspace_root.stat() (used for mtime checks
and updated_at) and return a sentinel (or raise a custom skip) so callers can
skip the entry; in _list_docker_workspaces, call _docker_workspace_entry inside
a try/except (catching FileNotFoundError/OSError) and continue (skip) when a
workspace disappears between iteration and stat. Ensure you only skip the
problematic workspace and do not let the exception bubble up.

Comment on lines +1368 to +1409
try:
entry = await asyncio.to_thread(
get_cloud_workspace_from_config,
_load_cloud_workspace_config(),
workspace_id,
active_workspace_ids=await _active_cloud_workspace_ids(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _workspace_summary_response(entry)


@app.delete("/workspaces/{workspace_id}", response_model=WorkspaceCleanupResponse)
@limiter.limit(RateLimits.CLOSE_SESSION)
async def cleanup_workspace(
request: Request,
response: Response,
workspace_id: str,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> WorkspaceCleanupResponse:
del request
_require_admin_context(auth_context)
try:
entry = await asyncio.to_thread(
cleanup_cloud_workspace_from_config,
_load_cloud_workspace_config(),
workspace_id,
active_workspace_ids=await _active_cloud_workspace_ids(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Cloud workspace cleanup failed workspace_id=%s", workspace_id)
response.status_code = 500
return WorkspaceCleanupResponse(
workspace_id=workspace_id,
status="cleanup_failed",
error=str(exc),
)
return WorkspaceCleanupResponse(workspace_id=entry.workspace_id, status="cleaned")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Map invalid workspace_id input to 400 (not 500) on workspace get/cleanup.

At Line 1369 and Line 1391, malformed IDs can raise ValueError from provider validation; today that falls through to 500 paths. This should be a client error (400). Also prefer _key_error_detail(exc) over str(exc) for clean 404 details.

Proposed fix
 async def get_workspace(
@@
     try:
         entry = await asyncio.to_thread(
             get_cloud_workspace_from_config,
             _load_cloud_workspace_config(),
             workspace_id,
             active_workspace_ids=await _active_cloud_workspace_ids(),
         )
     except KeyError as exc:
-        raise HTTPException(status_code=404, detail=str(exc)) from exc
+        raise HTTPException(status_code=404, detail=_key_error_detail(exc)) from exc
+    except (ValueError, TypeError) as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
     return _workspace_summary_response(entry)
@@
 async def cleanup_workspace(
@@
     try:
         entry = await asyncio.to_thread(
             cleanup_cloud_workspace_from_config,
             _load_cloud_workspace_config(),
             workspace_id,
             active_workspace_ids=await _active_cloud_workspace_ids(),
         )
     except KeyError as exc:
-        raise HTTPException(status_code=404, detail=str(exc)) from exc
+        raise HTTPException(status_code=404, detail=_key_error_detail(exc)) from exc
+    except (ValueError, TypeError) as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
     except RuntimeError as exc:
         raise HTTPException(status_code=409, detail=str(exc)) from exc
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
entry = await asyncio.to_thread(
get_cloud_workspace_from_config,
_load_cloud_workspace_config(),
workspace_id,
active_workspace_ids=await _active_cloud_workspace_ids(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _workspace_summary_response(entry)
@app.delete("/workspaces/{workspace_id}", response_model=WorkspaceCleanupResponse)
@limiter.limit(RateLimits.CLOSE_SESSION)
async def cleanup_workspace(
request: Request,
response: Response,
workspace_id: str,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> WorkspaceCleanupResponse:
del request
_require_admin_context(auth_context)
try:
entry = await asyncio.to_thread(
cleanup_cloud_workspace_from_config,
_load_cloud_workspace_config(),
workspace_id,
active_workspace_ids=await _active_cloud_workspace_ids(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Cloud workspace cleanup failed workspace_id=%s", workspace_id)
response.status_code = 500
return WorkspaceCleanupResponse(
workspace_id=workspace_id,
status="cleanup_failed",
error=str(exc),
)
return WorkspaceCleanupResponse(workspace_id=entry.workspace_id, status="cleaned")
try:
entry = await asyncio.to_thread(
get_cloud_workspace_from_config,
_load_cloud_workspace_config(),
workspace_id,
active_workspace_ids=await _active_cloud_workspace_ids(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=_key_error_detail(exc)) from exc
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _workspace_summary_response(entry)
`@app.delete`("/workspaces/{workspace_id}", response_model=WorkspaceCleanupResponse)
`@limiter.limit`(RateLimits.CLOSE_SESSION)
async def cleanup_workspace(
request: Request,
response: Response,
workspace_id: str,
auth_context: AuthContext | None = Depends(auth_context_from_headers),
) -> WorkspaceCleanupResponse:
del request
_require_admin_context(auth_context)
try:
entry = await asyncio.to_thread(
cleanup_cloud_workspace_from_config,
_load_cloud_workspace_config(),
workspace_id,
active_workspace_ids=await _active_cloud_workspace_ids(),
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=_key_error_detail(exc)) from exc
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Cloud workspace cleanup failed workspace_id=%s", workspace_id)
response.status_code = 500
return WorkspaceCleanupResponse(
workspace_id=workspace_id,
status="cleanup_failed",
error=str(exc),
)
return WorkspaceCleanupResponse(workspace_id=entry.workspace_id, status="cleaned")
🧰 Tools
🪛 Ruff (0.15.12)

[warning] 1386-1386: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@coding-agent/src/coding_agent/ui/http_server.py` around lines 1368 - 1409,
The handlers get workspace and cleanup_workspace currently map KeyError to 404
using str(exc) and let ValueError bubble to 500; change both to: catch
ValueError from get_cloud_workspace_from_config and
cleanup_cloud_workspace_from_config and raise HTTPException(status_code=400,
detail=str(exc)) to indicate malformed client input, replace raising 404 with
HTTPException(status_code=404, detail=_key_error_detail(exc)) so KeyError uses
_key_error_detail instead of str(exc), and ensure these changes are applied in
the async try/except blocks in the get handler (where
get_cloud_workspace_from_config is called) and in cleanup_workspace (where
cleanup_cloud_workspace_from_config is called).

@delete-cloud
delete-cloud merged commit f4663e8 into main May 10, 2026
1 check passed
@delete-cloud
delete-cloud deleted the feat/adr-0021-pr3-workspace-ops branch May 10, 2026 11:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant