From 915a11825ac0f92e9ab31ec42e2880975a70419b Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 6 Jul 2026 16:27:30 +0000 Subject: [PATCH] feat(mcp): add pad_workspace restore + deleted actions (TASK-1973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the TASK-1972 CLI commands `pad workspace restore` / `pad workspace deleted` on the MCP surface as two new pad_workspace actions: - `deleted` (read-only) — lists the caller's soft-deleted workspaces still inside the 30-day restore window. - `restore` (mutating, non-destructive, owner-only) — un-soft-deletes a workspace by slug while it's still restorable. Both passThrough to the CLI subcommands and reuse the existing `slug` param (no new params). Non-interactive and non-destructive, so MCP-appropriate. - Mark `deleted` read-only in tool_surface.go readOnlyActions (restore stays a write, the safe default). - Wire both into the HTTP MCP route table (dispatch_http_routes.go) so cloud/remote MCP dispatches to the existing /workspaces/deleted + /workspaces/{slug}/restore endpoints instead of "not yet implemented over HTTP transport" — mirroring TASK-1521's create/claim wiring. - Bump ToolSurfaceVersion 0.7 -> 0.8 and document the addition in the CLAUDE.md MCP stability contract. - Extend catalog bijection/dispatch tests + cmdhelp fixture and add route-table + HTTP-mapping tests for the new actions. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST --- CLAUDE.md | 2 +- internal/mcp/catalog_readonly_test.go | 12 ++++ internal/mcp/catalog_workspace.go | 21 ++++-- .../mcp/catalog_workspace_lifecycle_test.go | 64 +++++++++++++++++++ internal/mcp/dispatch_http_routes.go | 15 +++++ internal/mcp/tool_surface.go | 5 +- internal/mcp/version.go | 15 ++++- 7 files changed, 126 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4fdf629e..c3d3a22c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,7 +225,7 @@ Surface: **Stability contract.** Two version constants live in `internal/mcp/version.go`, advertised in the handshake under `capabilities.experimental.padCmdhelp` and `capabilities.experimental.padToolSurface`: - `CmdhelpVersion` (currently `"0.1"`) — the cmdhelp CLI help-tree contract. Bump when CLI flag/arg schemas change incompatibly. -- `ToolSurfaceVersion` (currently `"0.7"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **v0.7** adds `export` + `import` actions to `pad_item`, mirroring the CLI `pad item export` / `pad item import` (covers playbooks AND conventions). `export` (read-only) takes `ref` and returns the portable artifact text — it forces the CLI's stdout sink (`-o -`) so the bytes come back as the result instead of a file. `import` (mutating, not destructive) takes a new `artifact` param (the full artifact text) and returns `{ref, slug, warnings}`; the ExecDispatcher can't pipe stdin, so it spills the artifact to a temp file and dispatches `item import `. v0.6 added the `pad_item.backlinks` action; v0.5 added `pad_library`. v0.3 (PLAN-1377 / TASK-1380) introduced `pad_meta.action: bootstrap`, `pad_set_workspace`'s embedded-bootstrap response, and the `pad://workspace/{ws}/bootstrap` resource. **v0.4 (PLAN-1410)** is a comprehensive bootstrap-payload trim — same tool catalog, slimmer JSON shape inside bootstrap responses: `BootstrapCollection` projection drops `id`/`workspace_id`/timestamps/`settings` and emits `schema` as a nested object; `BootstrapRole` projection drops UUIDs/timestamps/`tools`; convention `slug` dropped; top-level `recent_activity` (a duplicate of `dashboard.recent_activity`) removed; new `BootstrapDashboard` wrapper caps five sub-arrays (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`) at 5 entries each with parallel `*_overflow_count` fields; redundant schema labels omitted when `label == TitleCase(key)`. Cumulative size reduction: ~40% on a representative workspace, ~54% on the fixture (see PLAN-1410's Result section for per-section deltas). Compatibility: most changes are subtractive (dropped fields) or additive (overflow counts), but **one type change is breaking**: `collections[].schema` went from a JSON-encoded string to a nested JSON object — clients that JSON.parse()'d the string need to consume it directly as an object now. The dropped fields (UUIDs, timestamps, settings, duplicate `recent_activity`, convention `slug`) have canonical alternatives (slugs for addressing; `pad collection list` / `pad role list` for the full models when needed). +- `ToolSurfaceVersion` (currently `"0.8"`) — the MCP tool catalog contract. Bump when tool names, action enums, or parameter shapes change incompatibly. **v0.8** adds `restore` + `deleted` actions to `pad_workspace`, mirroring the CLI `pad workspace restore` / `pad workspace deleted` (TASK-1972): `deleted` (read-only) lists the caller's soft-deleted workspaces still inside the 30-day restore window; `restore` (mutating, not destructive, owner-only) un-soft-deletes a workspace by `slug` while it's still restorable. Both reuse the existing `slug` param — no new params; pure addition. **v0.7** adds `export` + `import` actions to `pad_item`, mirroring the CLI `pad item export` / `pad item import` (covers playbooks AND conventions). `export` (read-only) takes `ref` and returns the portable artifact text — it forces the CLI's stdout sink (`-o -`) so the bytes come back as the result instead of a file. `import` (mutating, not destructive) takes a new `artifact` param (the full artifact text) and returns `{ref, slug, warnings}`; the ExecDispatcher can't pipe stdin, so it spills the artifact to a temp file and dispatches `item import `. v0.6 added the `pad_item.backlinks` action; v0.5 added `pad_library`. v0.3 (PLAN-1377 / TASK-1380) introduced `pad_meta.action: bootstrap`, `pad_set_workspace`'s embedded-bootstrap response, and the `pad://workspace/{ws}/bootstrap` resource. **v0.4 (PLAN-1410)** is a comprehensive bootstrap-payload trim — same tool catalog, slimmer JSON shape inside bootstrap responses: `BootstrapCollection` projection drops `id`/`workspace_id`/timestamps/`settings` and emits `schema` as a nested object; `BootstrapRole` projection drops UUIDs/timestamps/`tools`; convention `slug` dropped; top-level `recent_activity` (a duplicate of `dashboard.recent_activity`) removed; new `BootstrapDashboard` wrapper caps five sub-arrays (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`) at 5 entries each with parallel `*_overflow_count` fields; redundant schema labels omitted when `label == TitleCase(key)`. Cumulative size reduction: ~40% on a representative workspace, ~54% on the fixture (see PLAN-1410's Result section for per-section deltas). Compatibility: most changes are subtractive (dropped fields) or additive (overflow counts), but **one type change is breaking**: `collections[].schema` went from a JSON-encoded string to a nested JSON object — clients that JSON.parse()'d the string need to consume it directly as an object now. The dropped fields (UUIDs, timestamps, settings, duplicate `recent_activity`, convention `slug`) have canonical alternatives (slugs for addressing; `pad collection list` / `pad role list` for the full models when needed). Both are also returned by `pad://_meta/version` and `pad_meta.action: version`. diff --git a/internal/mcp/catalog_readonly_test.go b/internal/mcp/catalog_readonly_test.go index 814baa36..9cb63096 100644 --- a/internal/mcp/catalog_readonly_test.go +++ b/internal/mcp/catalog_readonly_test.go @@ -85,6 +85,9 @@ func TestReadOnlyCatalog_ActionsMatchCmdhelp(t *testing.T) { // PLAN-1519 / TASK-1521 / IDEA-1517 §1 + §4: workspace lifecycle. {"pad_workspace", "create"}: {"workspace", "create"}, {"pad_workspace", "claim"}: {"workspace", "claim"}, + // TASK-1973: workspace soft-delete recovery. + {"pad_workspace", "deleted"}: {"workspace", "deleted"}, + {"pad_workspace", "restore"}: {"workspace", "restore"}, {"pad_collection", "list"}: {"collection", "list"}, {"pad_collection", "create"}: {"collection", "create"}, @@ -229,6 +232,9 @@ func TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath(t *testing.T) { // PLAN-1519 / TASK-1521 / IDEA-1517 §1 + §4: workspace lifecycle. {"pad_workspace", "create"}: {"workspace", "create"}, {"pad_workspace", "claim"}: {"workspace", "claim"}, + // TASK-1973: workspace soft-delete recovery. + {"pad_workspace", "deleted"}: {"workspace", "deleted"}, + {"pad_workspace", "restore"}: {"workspace", "restore"}, {"pad_collection", "list"}: {"collection", "list"}, {"pad_collection", "create"}: {"collection", "create"}, @@ -441,6 +447,12 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document { Args: mkArgs("code"), Flags: mkFlags("workspace"), }, + // TASK-1973: workspace soft-delete recovery. + "workspace deleted": {Summary: "list soft-deleted workspaces"}, + "workspace restore": { + Summary: "restore a soft-deleted workspace", + Args: mkArgs("slug"), + }, "workspace audit-log": { Summary: "audit log", Flags: map[string]cmdhelp.Flag{ diff --git a/internal/mcp/catalog_workspace.go b/internal/mcp/catalog_workspace.go index cdf6e639..6a5ba49c 100644 --- a/internal/mcp/catalog_workspace.go +++ b/internal/mcp/catalog_workspace.go @@ -7,7 +7,9 @@ import ( ) // padWorkspaceTool exposes workspace-level operations: discovery -// (list), membership (members, invite), storage usage, and audit log. +// (list), membership (members, invite), storage usage, audit log, +// lifecycle (create, claim), and soft-delete recovery (deleted, +// restore). // // All actions dispatch through the existing CLI/HTTP route table. // Workspace context: this tool operates ON workspaces, so the @@ -66,7 +68,7 @@ var padWorkspaceTool = ToolDef{ { Name: "slug", Type: "string", - Description: "Workspace slug (kebab-case, globally unique). Optional for action=create; derived from name when omitted.", + Description: "Workspace slug (kebab-case, globally unique). Optional for action=create (derived from name when omitted); required for action=restore (names the soft-deleted workspace to restore).", }, { Name: "template", @@ -89,6 +91,11 @@ var padWorkspaceTool = ToolDef{ // PLAN-1519 / TASK-1521 / IDEA-1517 §1 + §4. "create": passThrough([]string{"workspace", "create"}), "claim": passThrough([]string{"workspace", "claim"}), + // TASK-1973: workspace soft-delete recovery (mirrors the CLI + // `pad workspace deleted` / `pad workspace restore` from TASK-1972). + // Both non-interactive; restore is mutating but non-destructive. + "deleted": passThrough([]string{"workspace", "deleted"}), + "restore": passThrough([]string{"workspace", "restore"}), }, } @@ -154,7 +161,13 @@ Actions: invalid_code, 404 not_found (workspace doesn't exist or you're not a member), 412 connection_not_persisted (pre-Phase-C grant — re-authorize). + deleted — List the caller's soft-deleted workspaces still inside the + 30-day restore window, with days remaining before each is + permanently purged. No params. Read-only. + restore — Un-soft-delete a workspace by slug, provided it is still inside + the restore window. Owner-only. Mutating but non-destructive. + Required: slug. Use pad_workspace when an agent needs to discover, manage membership, audit -workspace activity, or bring a new/additional workspace into this connection. -For item-level operations use pad_item.` +workspace activity, recover a soft-deleted workspace, or bring a new/additional +workspace into this connection. For item-level operations use pad_item.` diff --git a/internal/mcp/catalog_workspace_lifecycle_test.go b/internal/mcp/catalog_workspace_lifecycle_test.go index 9c7a3263..53d9dfc8 100644 --- a/internal/mcp/catalog_workspace_lifecycle_test.go +++ b/internal/mcp/catalog_workspace_lifecycle_test.go @@ -195,3 +195,67 @@ func TestRouteTable_RegistersWorkspaceLifecycle(t *testing.T) { } } } + +// TASK-1973: workspace soft-delete recovery route wiring. The catalog +// advertises pad_workspace.deleted / .restore; without these route +// entries the HTTPHandlerDispatcher (cloud MCP) would return "not yet +// implemented over HTTP transport" for calls the tool surface promises. +func TestRouteTable_RegistersWorkspaceRecovery(t *testing.T) { + for _, key := range []string{"workspace deleted", "workspace restore"} { + if _, ok := routeTable[key]; !ok { + t.Errorf("routeTable missing entry for %q — cloud MCP dispatch would fail", key) + } + } +} + +func TestWorkspaceDeletedRoute_HTTPMappingShape(t *testing.T) { + mapper := routeTable["workspace deleted"] + if mapper == nil { + t.Fatal("routeTable['workspace deleted'] is nil") + } + method, path, body, err := mapper(map[string]any{}) + if err != nil { + t.Fatalf("mapper: %v", err) + } + if method != "GET" { + t.Errorf("method = %q, want GET", method) + } + if path != "/api/v1/workspaces/deleted" { + t.Errorf("path = %q, want /api/v1/workspaces/deleted", path) + } + if body != nil { + t.Errorf("body = %s, want nil", body) + } +} + +func TestWorkspaceRestoreRoute_HTTPMappingShape(t *testing.T) { + mapper := routeTable["workspace restore"] + if mapper == nil { + t.Fatal("routeTable['workspace restore'] is nil") + } + // The deleted workspace is identified by the `slug` positional/input — + // it fills the {slug} path placeholder, not the session `workspace`. + method, path, body, err := mapper(map[string]any{"slug": "my-old-ws"}) + if err != nil { + t.Fatalf("mapper: %v", err) + } + if method != "POST" { + t.Errorf("method = %q, want POST", method) + } + if path != "/api/v1/workspaces/my-old-ws/restore" { + t.Errorf("path = %q, want /api/v1/workspaces/my-old-ws/restore", path) + } + if body != nil { + t.Errorf("body = %s, want nil", body) + } +} + +func TestWorkspaceRestoreRoute_RequiresSlug(t *testing.T) { + mapper := routeTable["workspace restore"] + if mapper == nil { + t.Fatal("routeTable['workspace restore'] is nil") + } + if _, _, _, err := mapper(map[string]any{}); err == nil { + t.Error("missing slug should error (path placeholder unfilled)") + } +} diff --git a/internal/mcp/dispatch_http_routes.go b/internal/mcp/dispatch_http_routes.go index d9555143..e1f298f6 100644 --- a/internal/mcp/dispatch_http_routes.go +++ b/internal/mcp/dispatch_http_routes.go @@ -386,6 +386,21 @@ func init() { // /api/v1/oauth/claim with the same payload shape. "workspace create": mapWorkspaceCreate, "workspace claim": mapWorkspaceClaim, + // TASK-1973: workspace soft-delete recovery over MCP. `deleted` + // GETs the caller's soft-deleted workspaces still inside the + // restore window (no path params — the endpoint scopes to the + // requesting user). `restore` POSTs to the target workspace's + // {slug} (the deleted workspace, passed as the `slug` input — + // NOT the session `workspace` param) with an empty body. Mirrors + // the CLI client's /workspaces/deleted + /workspaces/{slug}/restore. + "workspace deleted": routeSpec{ + method: http.MethodGet, + pathTemplate: "/api/v1/workspaces/deleted", + }.toRouteMapper(), + "workspace restore": routeSpec{ + method: http.MethodPost, + pathTemplate: "/api/v1/workspaces/{slug}/restore", + }.toRouteMapper(), // --- TASK-968 follow-up: project intelligence + admin extras --- // `project next` returns the full dashboard JSON — same shape the diff --git a/internal/mcp/tool_surface.go b/internal/mcp/tool_surface.go index 23bd6662..c8496d8f 100644 --- a/internal/mcp/tool_surface.go +++ b/internal/mcp/tool_surface.go @@ -57,7 +57,8 @@ type toolSurfaceToolSummary struct { // - pad_item: get/list/deps/starred/list-comments/backlinks/export read; // create/update/delete/move/restore/link/unlink/star/unstar/ // comment/bulk-update/note/decide/import write. -// - pad_workspace: list/members/storage/audit-log read; invite/create/claim write. +// - pad_workspace: list/members/storage/audit-log/deleted read; +// invite/create/claim/restore write. // - pad_collection: list read; create/update/delete write. // - pad_project: all read (dashboard/next/standup/changelog/report). // - pad_role: list read; create/update/delete write. @@ -81,6 +82,8 @@ var readOnlyActions = map[string]map[string]bool{ "members": true, "storage": true, "audit-log": true, + // TASK-1973: restore is a write; deleted is read-only. + "deleted": true, }, "pad_collection": { "list": true, diff --git a/internal/mcp/version.go b/internal/mcp/version.go index 7af02d13..d0d2e20b 100644 --- a/internal/mcp/version.go +++ b/internal/mcp/version.go @@ -79,7 +79,7 @@ const CmdhelpVersion = "0.1" // pagination. Pure addition; existing pad_item actions unchanged. // Backwards-compatible for v0.5 consumers that don't enumerate // the new action. -// - "0.7" — current. Artifact export/import (Phase 5): adds two +// - "0.7" — historical. Artifact export/import (Phase 5): adds two // actions to `pad_item` mirroring the CLI `pad item export` / // `pad item import`. `export` (read-only) takes `ref` and returns // the portable artifact TEXT (YAML frontmatter + Markdown body) — @@ -94,6 +94,17 @@ const CmdhelpVersion = "0.1" // the `artifact` param to the vocabulary. Pure addition; existing // pad_item actions unchanged. Backwards-compatible for v0.6 // consumers that don't enumerate the new actions. +// - "0.8" — current. TASK-1973: workspace soft-delete recovery. +// Adds two actions to `pad_workspace` mirroring the CLI +// `pad workspace deleted` / `pad workspace restore` (TASK-1972): +// `deleted` (read-only) lists the caller's soft-deleted workspaces +// still inside the 30-day restore window; `restore` (mutating, not +// destructive) un-soft-deletes a workspace by `slug` while it's +// still restorable (owner-only). Both non-interactive. Reuses the +// existing `slug` param (now also required for action=restore); no +// new params. Pure addition; existing pad_workspace actions +// unchanged. Backwards-compatible for v0.7 consumers that don't +// enumerate the new actions. // - "0.4" — PLAN-1410: comprehensive bootstrap-payload // trim, cutting ~40% of bytes off the AgentBootstrap response. // Same tool catalog (still eight resource × action tools + @@ -136,7 +147,7 @@ const CmdhelpVersion = "0.1" // - result.capabilities.experimental.padToolSurface.version (handshake). // - pad://_meta/version resource (queryable JSON document). // - pad_meta.action: tool-surface (full catalog introspection). -const ToolSurfaceVersion = "0.7" +const ToolSurfaceVersion = "0.8" // MetaVersionURI is the canonical URI of the queryable version document. // Lives outside the pad://workspace/{ws}/... namespace because it's a