feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)

* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189)

codex r1.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
xarmian
2026-10-08 03:59:26 -04:00
committed by GitHub
parent 28501155b1
commit 15c9f76366
29 changed files with 1072 additions and 90 deletions
+5 -4
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -390,13 +390,13 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.
**Tool catalog (v0.71)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
**Tool catalog (v0.72)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
| Tool | Actions |
|---|---|
| `pad_item` | `create`, `update`, `delete`, `get`, `list`, `move`, `restore`, `link`, `unlink`, `deps`, `star`, `unstar`, `starred`, `comment`, `list-comments`, `edit-comment`, `delete-comment`, `backlinks`, `bulk-update`, `note`, `decide`, `export`, `import`, `history`, `remind`, `ack-reminder`, `claim`, `release` |
| `pad_workspace` | `list`, `members`, `invite`, `storage`, `audit-log`, `create`, `claim`, `deleted`, `restore` |
| `pad_collection` | `list`, `create`, `update`, `delete` |
| `pad_collection` | `list`, `create`, `update`, `delete`, `list-archived`, `restore` |
| `pad_project` | `dashboard`, `next`, `ready`, `stale`, `standup`, `changelog`, `report`, `activity` |
| `pad_role` | `list`, `create`, `update`, `delete` |
| `pad_search` | `query` |
@@ -414,7 +414,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.
**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.71"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.72"`) and advertised in the initialize handshake and at
`pad://_meta/version`, so external agents can pin against it; the per-version
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
+59 -7
View File
@@ -389,7 +389,7 @@ issue-ID equivalent for collections themselves.`,
func collectionsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete <slug>",
Short: "Soft-delete a non-default collection (owner-only, irreversible from CLI)",
Short: "Archive a non-default collection and hide its items (owner-only; restore with 'pad collection restore')",
Long: `Soft-delete a collection by slug.
Constraints:
@@ -398,11 +398,11 @@ Constraints:
marked is_default=true). For seeded collections, use
'pad collection update' to adapt them (rename, reshape schema,
swap icon) instead.
- Items in the collection are NOT cascaded — they remain in the
database with the soft-deleted collection_id. The web UI hides
them; the API still surfaces them if queried directly.
- No 'undelete' subcommand or restore endpoint exists; recovery
is only possible from a database backup.`,
- Items in the collection are NOT deleted — they stay in the
database, hidden with the collection.
- Reversible: 'pad collection archived' lists archived collections
and 'pad collection restore <slug>' brings one back with its items
(TASK-2189).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
@@ -411,7 +411,59 @@ Constraints:
if err := client.DeleteCollection(ws, collSlug); err != nil {
return err
}
fmt.Printf("Deleted collection %s\n", collSlug)
fmt.Printf("Archived collection %s (restore with: pad collection restore %s)\n", collSlug, collSlug)
return nil
},
}
}
// --- archived / restore (TASK-2189) ---
func collectionsArchivedCmd() *cobra.Command {
return &cobra.Command{
Use: "archived",
Short: "List archived collections and their item counts (owner-only)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()
list, err := client.ListArchivedCollections(ws)
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(list)
}
if len(list) == 0 {
fmt.Println("No archived collections.")
return nil
}
for _, a := range list {
fmt.Printf("%-24s %-8s %5d items archived %s\n", a.Slug, a.Prefix, a.ItemCount, a.ArchivedAt)
}
return nil
},
}
}
func collectionsRestoreCmd() *cobra.Command {
return &cobra.Command{
Use: "restore <slug>",
Short: "Restore an archived collection, with its items (owner-only)",
Long: `Restore an archived collection by its slug (or id). Its items come back
with it: archiving never deleted them. See 'pad collection archived'.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
ws := getWorkspace()
coll, err := client.RestoreCollection(ws, args[0])
if err != nil {
return err
}
if formatFlag == "json" {
return cli.PrintJSON(coll)
}
fmt.Printf("Restored collection %s\n", coll.Slug)
return nil
},
}
+2
View File
@@ -169,6 +169,8 @@ func collectionCmd() *cobra.Command {
collectionsCreateCmd(),
collectionsUpdateCmd(),
collectionsDeleteCmd(),
collectionsArchivedCmd(),
collectionsRestoreCmd(),
)
return cmd
}
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -77,7 +77,7 @@
"idempotentHint": false,
"openWorldHint": false
},
"description": "Collection management — list, create, update, and delete collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Soft-delete a collection. Owner-only. No restore endpoint\n exists — recovery requires a database backup. Required:\n workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT cascaded — they remain\n in the database with the soft-deleted collection_id.\n The web UI hides them; raw API queries still surface\n them.\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
"description": "Collection management — list, create, update, archive (delete) and restore collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Archive a collection: it and its items leave every view. Owner-only.\n Reversible with restore. Required: workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT deleted — they stay in the\n database, hidden with the collection, and come back with it.\n list-archived — The workspace's archived collections, each with its slug,\n id, prefix, item_count and archived_at. Owner-only.\n Required: workspace.\n restore — Restore an archived collection, with its items. Owner-only.\n Required: workspace, slug (the archived collection's slug from\n list-archived, or its id).\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
"inputSchema": {
"properties": {
"action": {
@@ -86,6 +86,8 @@
"create",
"delete",
"list",
"list-archived",
"restore",
"update"
],
"type": "string"
@@ -138,7 +140,7 @@
"type": "object"
},
"slug": {
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update and action=delete; identifies which collection to mutate.",
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update, action=delete and action=restore; identifies which collection to mutate. For restore, the archived collection's slug (from list-archived) or its id.",
"type": "string"
},
"sort_order": {
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -77,7 +77,7 @@
"idempotentHint": false,
"openWorldHint": false
},
"description": "Collection management — list, create, update, and delete collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Soft-delete a collection. Owner-only. No restore endpoint\n exists — recovery requires a database backup. Required:\n workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT cascaded — they remain\n in the database with the soft-deleted collection_id.\n The web UI hides them; raw API queries still surface\n them.\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
"description": "Collection management — list, create, update, archive (delete) and restore collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Archive a collection: it and its items leave every view. Owner-only.\n Reversible with restore. Required: workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT deleted — they stay in the\n database, hidden with the collection, and come back with it.\n list-archived — The workspace's archived collections, each with its slug,\n id, prefix, item_count and archived_at. Owner-only.\n Required: workspace.\n restore — Restore an archived collection, with its items. Owner-only.\n Required: workspace, slug (the archived collection's slug from\n list-archived, or its id).\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
"inputSchema": {
"properties": {
"action": {
@@ -86,6 +86,8 @@
"create",
"delete",
"list",
"list-archived",
"restore",
"update"
],
"type": "string"
@@ -138,7 +140,7 @@
"type": "object"
},
"slug": {
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update and action=delete; identifies which collection to mutate.",
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update, action=delete and action=restore; identifies which collection to mutate. For restore, the archived collection's slug (from list-archived) or its id.",
"type": "string"
},
"sort_order": {
+17
View File
@@ -230,6 +230,23 @@ func (c *Client) DeleteCollection(wsSlug, collSlug string) error {
return c.delete("/workspaces/" + wsSlug + "/collections/" + collSlug)
}
// ListArchivedCollections returns the workspace's archived collections
// (TASK-2189).
func (c *Client) ListArchivedCollections(wsSlug string) ([]models.ArchivedCollection, error) {
var out []models.ArchivedCollection
err := c.get("/workspaces/"+wsSlug+"/archived-collections", &out)
return out, err
}
// RestoreCollection un-archives a collection by slug or id (TASK-2189).
func (c *Client) RestoreCollection(wsSlug, collRef string) (*models.Collection, error) {
var out models.Collection
if err := c.post("/workspaces/"+wsSlug+"/archived-collections/"+url.PathEscape(collRef)+"/restore", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// --- Items ---
// ListItems returns items across all collections in a workspace.
+19 -12
View File
@@ -1,8 +1,9 @@
package mcp
// padCollectionTool exposes collection management. Four actions:
// list (read-only), create (admin-mutating), update (admin-mutating),
// delete (admin-mutating).
// padCollectionTool exposes collection management. Six actions:
// list and list-archived (read-only), create, update, delete (archive) and
// restore (admin-mutating). restore and list-archived are TASK-2189: an
// agent's archive is recoverable by an agent.
//
// `update` (TASK-1510) and `delete` (TASK-1511) are the adaptation
// primitives for the `/pad onboard` playbook (PLAN-1496): the agent
@@ -30,7 +31,7 @@ var padCollectionTool = ToolDef{
{
Name: "slug",
Type: "string",
Description: "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update and action=delete; identifies which collection to mutate.",
Description: "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update, action=delete and action=restore; identifies which collection to mutate. For restore, the archived collection's slug (from list-archived) or its id.",
},
{
Name: "name",
@@ -91,10 +92,13 @@ var padCollectionTool = ToolDef{
"create": passThrough([]string{"collection", "create"}),
"update": passThrough([]string{"collection", "update"}),
"delete": passThrough([]string{"collection", "delete"}),
// TASK-2189: an agent's archive is recoverable by an agent.
"list-archived": passThrough([]string{"collection", "archived"}),
"restore": passThrough([]string{"collection", "restore"}),
},
}
const padCollectionToolDescription = `Collection management — list, create, update, and delete collection types.
const padCollectionToolDescription = `Collection management — list, create, update, archive (delete) and restore collection types.
Actions:
list — List collections in the workspace with their schemas + counts.
@@ -123,16 +127,19 @@ Actions:
This is the adaptation primitive for the onboarding playbook
(/pad onboard) — rewrite seeded collections to match the
project's actual vocabulary instead of template defaults.
delete — Soft-delete a collection. Owner-only. No restore endpoint
exists — recovery requires a database backup. Required:
workspace, slug.
delete — Archive a collection: it and its items leave every view. Owner-only.
Reversible with restore. Required: workspace, slug.
Constraints:
- Cannot delete a default (template-seeded) collection.
Adapt those via update instead (rename, reshape schema).
- Items in the collection are NOT cascaded — they remain
in the database with the soft-deleted collection_id.
The web UI hides them; raw API queries still surface
them.
- Items in the collection are NOT deleted — they stay in the
database, hidden with the collection, and come back with it.
list-archived — The workspace's archived collections, each with its slug,
id, prefix, item_count and archived_at. Owner-only.
Required: workspace.
restore — Restore an archived collection, with its items. Owner-only.
Required: workspace, slug (the archived collection's slug from
list-archived, or its id).
Schema for an individual collection is included in the list response — read it
from there rather than calling list again. v0.2 does not expose a dedicated
+12
View File
@@ -95,6 +95,9 @@ func TestReadOnlyCatalog_ActionsMatchCmdhelp(t *testing.T) {
{"pad_collection", "create"}: {"collection", "create"},
{"pad_collection", "update"}: {"collection", "update"},
{"pad_collection", "delete"}: {"collection", "delete"},
// TASK-2189: archive is reversible.
{"pad_collection", "list-archived"}: {"collection", "archived"},
{"pad_collection", "restore"}: {"collection", "restore"},
{"pad_project", "dashboard"}: {"project", "dashboard"},
{"pad_project", "next"}: {"project", "next"},
@@ -258,6 +261,9 @@ func TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath(t *testing.T) {
{"pad_collection", "create"}: {"collection", "create"},
{"pad_collection", "update"}: {"collection", "update"},
{"pad_collection", "delete"}: {"collection", "delete"},
// TASK-2189: archive is reversible.
{"pad_collection", "list-archived"}: {"collection", "archived"},
{"pad_collection", "restore"}: {"collection", "restore"},
{"pad_project", "dashboard"}: {"project", "dashboard"},
{"pad_project", "next"}: {"project", "next"},
@@ -528,6 +534,12 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document {
Args: mkArgs("slug"),
Flags: mkFlags("workspace"),
},
"collection archived": {Summary: "list archived cols", Flags: mkFlags("workspace")},
"collection restore": {
Summary: "restore col",
Args: mkArgs("slug"),
Flags: mkFlags("workspace"),
},
// Project
"project dashboard": {Summary: "dash", Flags: mkFlags("workspace")},
"project next": {Summary: "next", Flags: mkFlags("workspace")},
+49 -47
View File
@@ -309,53 +309,55 @@ var ChatGPTCatalog = []ChatGPTTool{
// ChatGPTExclusions are the /mcp operations deliberately not on the ChatGPT
// surface, each with its reason (TASK-3321 U1-T, lead-ruled).
var ChatGPTExclusions = map[ChatGPTSource]string{
{Tool: chatGPTSetWorkspaceSource}: "on this shared server it persists no session workspace, so it would only duplicate get_workspace_overview, the connect step",
{"pad_item", "bulk-update"}: "batch overwrite with a large blast radius and little chat value",
{"pad_item", "move"}: "drops fields the target collection does not declare; confusing in chat",
{"pad_item", "delete-comment"}: "destructive, rarely needed",
{"pad_item", "edit-comment"}: "overwrite; deferred to v2",
{"pad_item", "unlink"}: "deferred to v2",
{"pad_item", "claim"}: "agent lease semantics, not a chat concept",
{"pad_item", "release"}: "agent lease semantics, not a chat concept",
{"pad_item", "remind"}: "deferred to v2",
{"pad_item", "ack-reminder"}: "deferred to v2",
{"pad_item", "star"}: "personal UI state",
{"pad_item", "unstar"}: "personal UI state",
{"pad_item", "starred"}: "personal UI state",
{"pad_item", "note"}: "structured implementation-note writer for coding agents",
{"pad_item", "decide"}: "structured decision-log writer for coding agents",
{"pad_item", "import"}: "artifact files, not chat",
{"pad_item", "export"}: "artifact files, not chat",
{"pad_item", "backlinks"}: "deferred to v2",
{"pad_workspace", "create"}: "cloud signup creates the first workspace; a user with none is pointed at app.getpad.dev",
{"pad_workspace", "invite"}: "sends email to an arbitrary address; kept to the web app",
{"pad_workspace", "claim"}: "workspace lifecycle; web app",
{"pad_workspace", "restore"}: "workspace lifecycle; web app",
{"pad_workspace", "deleted"}: "workspace lifecycle; web app",
{"pad_workspace", "members"}: "returns member emails (fails data minimization)",
{"pad_workspace", "audit-log"}: "returns IP addresses and user agents (fails data minimization)",
{"pad_workspace", "storage"}: "administration",
{"pad_collection", "create"}: "workspace configuration; web app or onboarding",
{"pad_collection", "update"}: "workspace configuration; web app or onboarding",
{"pad_collection", "delete"}: "workspace configuration; web app or onboarding",
{"pad_role", "create"}: "workspace configuration; web app or onboarding",
{"pad_role", "update"}: "workspace configuration; web app or onboarding",
{"pad_role", "delete"}: "workspace configuration; web app or onboarding",
{"pad_role", "list"}: "agent-role configuration, not used in chat",
{"pad_project", "changelog"}: "deferred to v2 (long output)",
{"pad_project", "standup"}: "deferred to v2 (long output)",
{"pad_project", "report"}: "deferred to v2 (long output)",
{"pad_project", "stale"}: "overlaps ready_items and the dashboard's attention list; v2",
{"pad_library", "list"}: "workspace configuration",
{"pad_library", "get"}: "workspace configuration",
{"pad_library", "activate"}: "workspace configuration (creates items)",
{"pad_meta", "server-info"}: "operator diagnostics (fails data minimization)",
{"pad_meta", "tool-surface"}: "operator diagnostics (fails data minimization)",
{"pad_meta", "version"}: "operator diagnostics (fails data minimization)",
{"pad_playbook", "match"}: "needs the decision provider and sends text to it; v2",
{"pad_playbook", "run"}: "returns a body for an agent to execute; get_playbook serves read-and-follow",
{"pad_attachment", "list"}: "metadata only with no display story yet; v2",
{"pad_attachment", "show"}: "metadata only with no display story yet; v2",
{Tool: chatGPTSetWorkspaceSource}: "on this shared server it persists no session workspace, so it would only duplicate get_workspace_overview, the connect step",
{"pad_item", "bulk-update"}: "batch overwrite with a large blast radius and little chat value",
{"pad_item", "move"}: "drops fields the target collection does not declare; confusing in chat",
{"pad_item", "delete-comment"}: "destructive, rarely needed",
{"pad_item", "edit-comment"}: "overwrite; deferred to v2",
{"pad_item", "unlink"}: "deferred to v2",
{"pad_item", "claim"}: "agent lease semantics, not a chat concept",
{"pad_item", "release"}: "agent lease semantics, not a chat concept",
{"pad_item", "remind"}: "deferred to v2",
{"pad_item", "ack-reminder"}: "deferred to v2",
{"pad_item", "star"}: "personal UI state",
{"pad_item", "unstar"}: "personal UI state",
{"pad_item", "starred"}: "personal UI state",
{"pad_item", "note"}: "structured implementation-note writer for coding agents",
{"pad_item", "decide"}: "structured decision-log writer for coding agents",
{"pad_item", "import"}: "artifact files, not chat",
{"pad_item", "export"}: "artifact files, not chat",
{"pad_item", "backlinks"}: "deferred to v2",
{"pad_workspace", "create"}: "cloud signup creates the first workspace; a user with none is pointed at app.getpad.dev",
{"pad_workspace", "invite"}: "sends email to an arbitrary address; kept to the web app",
{"pad_workspace", "claim"}: "workspace lifecycle; web app",
{"pad_workspace", "restore"}: "workspace lifecycle; web app",
{"pad_workspace", "deleted"}: "workspace lifecycle; web app",
{"pad_workspace", "members"}: "returns member emails (fails data minimization)",
{"pad_workspace", "audit-log"}: "returns IP addresses and user agents (fails data minimization)",
{"pad_workspace", "storage"}: "administration",
{"pad_collection", "create"}: "workspace configuration; web app or onboarding",
{"pad_collection", "update"}: "workspace configuration; web app or onboarding",
{"pad_collection", "delete"}: "workspace configuration; web app or onboarding",
{"pad_collection", "list-archived"}: "workspace configuration; web app or onboarding",
{"pad_collection", "restore"}: "workspace configuration; web app or onboarding",
{"pad_role", "create"}: "workspace configuration; web app or onboarding",
{"pad_role", "update"}: "workspace configuration; web app or onboarding",
{"pad_role", "delete"}: "workspace configuration; web app or onboarding",
{"pad_role", "list"}: "agent-role configuration, not used in chat",
{"pad_project", "changelog"}: "deferred to v2 (long output)",
{"pad_project", "standup"}: "deferred to v2 (long output)",
{"pad_project", "report"}: "deferred to v2 (long output)",
{"pad_project", "stale"}: "overlaps ready_items and the dashboard's attention list; v2",
{"pad_library", "list"}: "workspace configuration",
{"pad_library", "get"}: "workspace configuration",
{"pad_library", "activate"}: "workspace configuration (creates items)",
{"pad_meta", "server-info"}: "operator diagnostics (fails data minimization)",
{"pad_meta", "tool-surface"}: "operator diagnostics (fails data minimization)",
{"pad_meta", "version"}: "operator diagnostics (fails data minimization)",
{"pad_playbook", "match"}: "needs the decision provider and sends text to it; v2",
{"pad_playbook", "run"}: "returns a body for an agent to execute; get_playbook serves read-and-follow",
{"pad_attachment", "list"}: "metadata only with no display story yet; v2",
{"pad_attachment", "show"}: "metadata only with no display story yet; v2",
}
// ChatGPTExperimentalCapabilities is the ChatGPT server's
+9
View File
@@ -355,6 +355,15 @@ func init() {
method: http.MethodDelete,
pathTemplate: "/api/v1/workspaces/{workspace}/collections/{slug}",
}.toRouteMapper(),
// TASK-2189: archive is now reversible, by an agent too.
"collection archived": routeSpec{
method: http.MethodGet,
pathTemplate: "/api/v1/workspaces/{workspace}/archived-collections",
}.toRouteMapper(),
"collection restore": routeSpec{
method: http.MethodPost,
pathTemplate: "/api/v1/workspaces/{workspace}/archived-collections/{slug}/restore",
}.toRouteMapper(),
"role list": routeSpec{
method: http.MethodGet,
pathTemplate: "/api/v1/workspaces/{workspace}/agent-roles",
+2 -2
View File
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
If the user is asking general code questions with no project-management thread, you don't need this server.
## Tool surface (v0.71)
## Tool surface (v0.72)
Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total.
@@ -36,7 +36,7 @@ If ANY element fails, the whole write is refused and nothing is stored — never
In `relation_targets`, a `multi_relation` key carries a JSON **ARRAY** of the same `{id, ref, title}` objects, in stored order and one per stored element — including an `id`-only entry for an element that names nothing, so a position in the list lines up with the same position in `fields`. A scalar key is unchanged: still a single object, byte-identical to v0.31. Decide which shape to expect from the field's declared type, not by probing.
- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.
- `pad_collection` — Collections: list / create / update / delete.
- `pad_collection` — Collections: list / create / update / delete / list-archived / restore. `delete` archives: the collection and its items leave every view, and `restore` brings them back.
- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).
- `pad_role` — Agent roles: list / create / update / delete.
- `pad_search` — Full-text search across items: query.
+2
View File
@@ -94,6 +94,8 @@ var readOnlyActions = map[string]map[string]bool{
},
"pad_collection": {
"list": true,
// TASK-2189: restore is a write; list-archived is read-only.
"list-archived": true,
},
"pad_project": {
"dashboard": true,
+10 -1
View File
@@ -1738,6 +1738,15 @@ const CmdhelpVersion = "0.1"
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
// as on stdio. Stdio is unchanged.
//
// 0.72 — TASK-2189 (lead ruling). ADDITIVE bump on the v0.8 / v0.13
// grounds (existing CLI verbs wired onto the catalog): `pad_collection`
// gains `list-archived` (read-only: the workspace's archived
// collections with slug, id, prefix, item_count and archived_at) and
// `restore` (owner-only: un-archive a collection, named by `slug` or
// id, with its items, which the archive never deleted). The `delete`
// description no longer says "No restore endpoint exists": an agent's
// archive is now recoverable by an agent. Nothing existing moved.
//
// 0.71 — TASK-3462 U3a (lead ruling). ADDITIVE bump on the v0.63 /
// v0.53 grounds: the bootstrap every bootstrap door returns
// (`pad_set_workspace`'s embed, `pad_meta.action=bootstrap`, the
@@ -1853,7 +1862,7 @@ const CmdhelpVersion = "0.1"
// this surface can receive it; the entry exists so a future action does
// not collapse it to permission_denied. When an action that can reach
// it is added, that addition is the contract change and owns the bump.
const ToolSurfaceVersion = "0.71"
const ToolSurfaceVersion = "0.72"
// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a
+13
View File
@@ -289,3 +289,16 @@ func (u *CollectionUpdate) UnmarshalJSON(data []byte) error {
return nil
}
// ArchivedCollection is one archived (soft-deleted) collection, as the
// restore surfaces list it (TASK-2189). ItemCount counts its live items: an
// archive hides them behind the collection's deleted_at and deletes none.
type ArchivedCollection struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Prefix string `json:"prefix"`
Icon string `json:"icon,omitempty"`
ItemCount int `json:"item_count"`
ArchivedAt string `json:"archived_at"`
}
+90
View File
@@ -714,3 +714,93 @@ func uniqueCollectionConflictMessage(err error) string {
return "This change conflicts with an existing unique value"
}
}
// handleListArchivedCollections answers GET /workspaces/{ws}/archived-collections
// (TASK-2189): the workspace's archived collections with their item counts.
// Owner-only, like the archive it lists.
func (s *Server) handleListArchivedCollections(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
list, err := s.store.ListArchivedCollections(workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
// The live list's visibility rule (a restricted owner sees only the
// collections granted to them), so this is not a door onto hidden ones.
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
if visibleIDs != nil {
kept := list[:0]
for _, a := range list {
if isCollectionVisible(a.ID, visibleIDs) {
kept = append(kept, a)
}
}
list = kept
}
writeJSON(w, http.StatusOK, list)
}
// handleRestoreCollection answers POST
// /workspaces/{ws}/archived-collections/{collRef}/restore (TASK-2189), by id or
// slug: it
// un-archives the collection, and its items come back with it, since the
// archive never deleted them. 404 for an id that is not an archived collection
// of this workspace. Owner-only, like the archive it reverses.
func (s *Server) handleRestoreCollection(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "owner") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
const notArchived = "No archived collection with that slug or id in this workspace"
id, err := s.store.ResolveArchivedCollection(workspaceID, chi.URLParam(r, "collRef"))
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", notArchived)
return
}
if err != nil {
writeInternalError(w, err)
return
}
// The archive's own visibility rule: a collection the caller could not
// archive (hidden from a restricted owner) is not theirs to restore, and
// answers the same 404 as one that does not exist.
visible, err := s.checkCollectionFullyVisible(r, workspaceID, id)
if err != nil {
writeInternalError(w, err)
return
}
if !visible {
writeError(w, http.StatusNotFound, "not_found", notArchived)
return
}
coll, err := s.store.RestoreCollection(workspaceID, id)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", notArchived)
return
}
if err != nil {
writeInternalError(w, err)
return
}
// The archive's own side effect, reversed: the collection's grants apply
// again and its items re-enter views, so connections re-check (TASK-3365).
// The event tells open clients to refetch collections; the item-sync
// epoch fingerprints the live collection set, so delta clients resync and
// receive the restored items (BUG-3428).
s.invalidateWorkspaceAccess(workspaceID)
s.publishCollectionEvent(events.CollectionUpdated, workspaceID, coll.ID, coll.Slug, "", true)
writeJSON(w, http.StatusOK, coll)
}
+7
View File
@@ -2081,6 +2081,13 @@ func (s *Server) setupRouter() {
})
// Collections (v2)
// Archived collections (TASK-2189): list and restore. A
// sibling of /collections rather than inside it, because
// /collections/{collSlug} resolves LIVE collections only
// and a collection could itself be slugged "archived".
r.Get("/archived-collections", s.handleListArchivedCollections)
r.Post("/archived-collections/{collRef}/restore", s.handleRestoreCollection)
r.Route("/collections", func(r chi.Router) {
r.Get("/", s.handleListCollections)
r.Post("/", s.handleCreateCollection)
@@ -0,0 +1,171 @@
package server
import (
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-2189: archiving a collection hides it and its items; nothing could
// bring them back. GET archived-collections lists what is archived and POST
// .../restore brings one back, items included (the archive never deleted them).
func archivedList(t *testing.T, srv *Server, ws string) []models.ArchivedCollection {
t.Helper()
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/archived-collections", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list archived: %d %s", rr.Code, rr.Body.String())
}
var out []models.ArchivedCollection
parseJSON(t, rr, &out)
return out
}
func seedCollectionWithItems(t *testing.T, srv *Server, ws, name string, n int) models.Collection {
t.Helper()
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections", map[string]any{"name": name})
if rr.Code != http.StatusCreated {
t.Fatalf("create collection: %d %s", rr.Code, rr.Body.String())
}
var coll models.Collection
parseJSON(t, rr, &coll)
for i := 0; i < n; i++ {
irr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug+"/items", map[string]any{"title": name + " item"})
if irr.Code != http.StatusCreated {
t.Fatalf("create item: %d %s", irr.Code, irr.Body.String())
}
}
return coll
}
func itemCount(t *testing.T, srv *Server, ws, collSlug string) (int, int) {
t.Helper()
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/collections/"+collSlug+"/items", nil)
if rr.Code != http.StatusOK {
return rr.Code, -1
}
var items []models.Item
parseJSON(t, rr, &items)
return rr.Code, len(items)
}
func TestRestoreCollection_TASK2189_RoundTripWithItems(t *testing.T) {
srv := testServer(t)
ws := createWSWithCollections(t, srv)
coll := seedCollectionWithItems(t, srv, ws, "Field Notes", 3)
if rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug, nil); rr.Code != http.StatusNoContent {
t.Fatalf("archive: %d %s", rr.Code, rr.Body.String())
}
if code, _ := itemCount(t, srv, ws, coll.Slug); code != http.StatusNotFound {
t.Fatalf("archived collection's items answered %d, want 404", code)
}
list := archivedList(t, srv, ws)
if len(list) != 1 || list[0].ID != coll.ID || list[0].Slug != coll.Slug || list[0].ItemCount != 3 || list[0].ArchivedAt == "" {
t.Fatalf("archived list = %+v, want the collection with 3 items", list)
}
// The sync epoch fingerprints the live collection set: a restore must move
// it, so delta clients resync and receive the restored items (BUG-3428).
epoch := func() string {
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/items-changes?since=0", nil)
var body struct {
AccessEpoch string `json:"access_epoch"`
}
parseJSON(t, rr, &body)
return body.AccessEpoch
}
before := epoch()
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+coll.Slug+"/restore", nil)
if rr.Code != http.StatusOK {
t.Fatalf("restore by slug: %d %s", rr.Code, rr.Body.String())
}
if code, n := itemCount(t, srv, ws, coll.Slug); code != http.StatusOK || n != 3 {
t.Fatalf("after restore: %d with %d items, want 200 with 3", code, n)
}
if len(archivedList(t, srv, ws)) != 0 {
t.Fatal("a restored collection is still listed as archived")
}
if after := epoch(); after == before {
t.Fatalf("the item-sync epoch did not move on restore (%q): delta clients would never see the items", after)
}
// By id, too; and a second restore of a live collection is a 404.
if rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug, nil); rr.Code != http.StatusNoContent {
t.Fatalf("re-archive: %d", rr.Code)
}
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+coll.ID+"/restore", nil); rr.Code != http.StatusOK {
t.Fatalf("restore by id: %d %s", rr.Code, rr.Body.String())
}
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+coll.Slug+"/restore", nil); rr.Code != http.StatusNotFound {
t.Fatalf("restoring a LIVE collection answered %d, want 404", rr.Code)
}
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/no-such-thing/restore", nil); rr.Code != http.StatusNotFound {
t.Fatalf("restoring an unknown ref answered %d, want 404", rr.Code)
}
}
// A newer collection with the archived one's name took `x-2`, so a restore
// brings the old one back beside it, both reachable.
func TestRestoreCollection_TASK2189_BesideANewerSameNamedCollection(t *testing.T) {
srv := testServer(t)
ws := createWSWithCollections(t, srv)
old := seedCollectionWithItems(t, srv, ws, "Notes", 1)
if rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+ws+"/collections/"+old.Slug, nil); rr.Code != http.StatusNoContent {
t.Fatalf("archive: %d", rr.Code)
}
newer := seedCollectionWithItems(t, srv, ws, "Notes", 2)
if newer.Slug == old.Slug {
t.Fatalf("the newer collection reused the archived slug %q", old.Slug)
}
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+old.Slug+"/restore", nil); rr.Code != http.StatusOK {
t.Fatalf("restore: %d %s", rr.Code, rr.Body.String())
}
if _, n := itemCount(t, srv, ws, old.Slug); n != 1 {
t.Fatalf("restored collection has %d items, want 1", n)
}
if _, n := itemCount(t, srv, ws, newer.Slug); n != 2 {
t.Fatalf("newer collection has %d items, want 2", n)
}
}
// A restricted owner can neither list nor restore an archived collection they
// could not see live; the visible one works.
func TestRestoreCollection_TASK2189_RestrictedOwnerVisibility(t *testing.T) {
f := newRestrictedOwnerVisibilityFixture(t)
for _, c := range []*models.Collection{f.hiddenColl, f.visibleColl} {
if err := f.srv.store.DeleteCollection(c.ID, ""); err != nil {
t.Fatalf("archive %s: %v", c.Slug, err)
}
}
rr := doRequestWithHeaders(f.srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/archived-collections", nil, f.bearerHeaders())
if rr.Code != http.StatusOK {
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
}
var list []models.ArchivedCollection
parseJSON(t, rr, &list)
if len(list) != 1 || list[0].ID != f.visibleColl.ID {
t.Fatalf("restricted owner's archived list = %+v, want only the visible collection", list)
}
hidden := doRequestWithHeaders(f.srv, "POST", "/api/v1/workspaces/"+f.ws.Slug+"/archived-collections/"+f.hiddenColl.ID+"/restore", nil, f.bearerHeaders())
if hidden.Code != http.StatusNotFound {
t.Fatalf("restoring a hidden collection answered %d, want 404", hidden.Code)
}
still, err := f.srv.store.ListArchivedCollections(f.ws.ID)
if err != nil {
t.Fatal(err)
}
hiddenStillArchived := false
for _, a := range still {
hiddenStillArchived = hiddenStillArchived || a.ID == f.hiddenColl.ID
}
if !hiddenStillArchived {
t.Fatal("the hidden collection was restored by a caller who cannot see it")
}
visible := doRequestWithHeaders(f.srv, "POST", "/api/v1/workspaces/"+f.ws.Slug+"/archived-collections/"+f.visibleColl.ID+"/restore", nil, f.bearerHeaders())
if visible.Code != http.StatusOK {
t.Fatalf("restoring the visible collection answered %d: %s", visible.Code, visible.Body.String())
}
}
+76
View File
@@ -0,0 +1,76 @@
package store
import (
"database/sql"
"fmt"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-2189: archiving a collection (DeleteCollection) is a soft delete of the
// collection row; its items are untouched and only hidden by that row's
// deleted_at, and nothing purges an archived collection. These two make that
// reversible: list what is archived, and restore one by clearing deleted_at.
//
// A restore cannot collide: slugs are allocated unique across archived rows
// too (uniqueSlugQ), so a newer collection with the same name took `x-2`; and
// prefixes are not unique even among live collections (item numbers are
// workspace-unique, so refs stay unambiguous).
// ListArchivedCollections returns the workspace's archived collections, most
// recently archived first, each with its count of live items.
func (s *Store) ListArchivedCollections(workspaceID string) ([]models.ArchivedCollection, error) {
rows, err := s.db.Query(s.q(`
SELECT c.id, c.name, c.slug, c.prefix, c.icon, c.deleted_at,
(SELECT COUNT(*) FROM items i WHERE i.collection_id = c.id AND i.deleted_at IS NULL)
FROM collections c
WHERE c.workspace_id = ? AND c.deleted_at IS NOT NULL
ORDER BY c.deleted_at DESC, c.id`), workspaceID)
if err != nil {
return nil, fmt.Errorf("list archived collections: %w", err)
}
defer rows.Close()
out := []models.ArchivedCollection{}
for rows.Next() {
var a models.ArchivedCollection
var icon sql.NullString
if err := rows.Scan(&a.ID, &a.Name, &a.Slug, &a.Prefix, &icon, &a.ArchivedAt, &a.ItemCount); err != nil {
return nil, fmt.Errorf("scan archived collection: %w", err)
}
a.Icon = icon.String
out = append(out, a)
}
return out, rows.Err()
}
// ResolveArchivedCollection returns the id of this workspace's ARCHIVED
// collection named by ref, its id or its slug (slugs are unique per workspace
// across archived rows too, so either names at most one). sql.ErrNoRows when
// ref names none (unknown, another workspace's, or live), so a caller cannot
// tell those apart.
func (s *Store) ResolveArchivedCollection(workspaceID, ref string) (string, error) {
var id string
err := s.db.QueryRow(s.q(`
SELECT id FROM collections
WHERE workspace_id = ? AND (id = ? OR slug = ?) AND deleted_at IS NOT NULL`), workspaceID, ref, ref).Scan(&id)
return id, err
}
// RestoreCollection un-archives this workspace's archived collection id.
// sql.ErrNoRows when it is not one (already restored by a racing call, or
// never archived).
func (s *Store) RestoreCollection(workspaceID, id string) (*models.Collection, error) {
ts := now()
res, err := s.db.Exec(s.q(`
UPDATE collections SET deleted_at = NULL, updated_at = ?
WHERE id = ? AND workspace_id = ? AND deleted_at IS NOT NULL`), ts, id, workspaceID)
if err != nil {
return nil, fmt.Errorf("restore collection: %w", err)
}
if n, err := res.RowsAffected(); err != nil {
return nil, fmt.Errorf("restore collection: %w", err)
} else if n == 0 {
return nil, sql.ErrNoRows
}
return s.GetCollection(id)
}
@@ -0,0 +1,94 @@
package store_test
import (
"database/sql"
"errors"
"sync"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
// TASK-2189: list and restore archived collections, on both dialects (the
// deleted_at scan and the item-count subquery), and a restore race.
func TestArchivedCollections_SQLite(t *testing.T) { archivedCollections(t, storetest.NewSQLite(t)) }
func TestArchivedCollections_Postgres(t *testing.T) { archivedCollections(t, storetest.NewPostgres(t)) }
func archivedCollections(t *testing.T, s *store.Store) {
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Archive 2189"})
if err != nil {
t.Fatal(err)
}
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Field Notes"})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if _, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{Title: "note"}); err != nil {
t.Fatal(err)
}
}
if err := s.DeleteCollection(coll.ID, ""); err != nil {
t.Fatal(err)
}
list, err := s.ListArchivedCollections(ws.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].ID != coll.ID || list[0].ItemCount != 2 || list[0].ArchivedAt == "" {
t.Fatalf("archived = %+v, want the collection with 2 items and a time", list)
}
// A live slug, an unknown ref and another workspace's ref name nothing.
other, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Other 2189"})
if err != nil {
t.Fatal(err)
}
for _, c := range []struct{ ws, ref string }{{ws.ID, "nope"}, {other.ID, coll.ID}, {other.ID, coll.Slug}} {
if _, err := s.ResolveArchivedCollection(c.ws, c.ref); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("ResolveArchivedCollection(%s, %s) = %v, want ErrNoRows", c.ws, c.ref, err)
}
}
// Two concurrent restores: exactly one wins, the other is told there is
// nothing archived to restore.
id, err := s.ResolveArchivedCollection(ws.ID, coll.Slug)
if err != nil || id != coll.ID {
t.Fatalf("resolve by slug = %q, %v", id, err)
}
var wg sync.WaitGroup
var mu sync.Mutex
wins, losses := 0, 0
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := s.RestoreCollection(ws.ID, id)
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
wins++
case errors.Is(err, sql.ErrNoRows):
losses++
default:
t.Errorf("restore: %v", err)
}
}()
}
wg.Wait()
if wins != 1 || losses != 1 {
t.Fatalf("concurrent restores: %d won, %d lost; want exactly one of each", wins, losses)
}
live, err := s.GetCollection(coll.ID)
if err != nil || live == nil || live.DeletedAt != nil {
t.Fatalf("after restore: %+v %v, want the live collection", live, err)
}
if list, _ := s.ListArchivedCollections(ws.ID); len(list) != 0 {
t.Fatalf("still listed as archived: %+v", list)
}
}
+11 -1
View File
@@ -100,7 +100,8 @@ import type {
TutorialsResponse,
UIDismissalKey,
UIDismissalsResponse,
ImportOutcome
ImportOutcome,
ArchivedCollection
} from '$lib/types';
import { reportWorkspaceWrite } from './workspaceWrites';
import { noteServerDate } from './serverClock';
@@ -1533,6 +1534,15 @@ export const api = {
list: (ws: string) =>
request<Collection[]>(`/workspaces/${ws}/collections`),
// TASK-2189: archive is reversible. Owner-only, like the archive.
archived: (ws: string) =>
request<ArchivedCollection[]>(`/workspaces/${ws}/archived-collections`),
// By id or slug; its items come back with it.
restore: (ws: string, ref: string) =>
request<Collection>(`/workspaces/${ws}/archived-collections/${encodeURIComponent(ref)}/restore`, {
method: 'POST'
}),
create: (ws: string, data: CollectionCreate) =>
request<Collection>(`/workspaces/${ws}/collections`, {
method: 'POST',
@@ -0,0 +1,141 @@
<script lang="ts">
// TASK-2189: the workspace's archived collections, each restorable with its
// items (an archive never deleted them). Owner-only, like the archive; the
// caller renders it only for owners and the server enforces it.
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import { toastStore } from '$lib/stores/toast.svelte';
import type { ArchivedCollection } from '$lib/types';
interface Props {
wsSlug: string;
/** Changes whenever the live collection list does, so an archive or a
* restore elsewhere refreshes this list. */
refreshKey: string;
onrestored?: () => void;
}
let { wsSlug, refreshKey, onrestored }: Props = $props();
let archived = $state<ArchivedCollection[]>([]);
let loaded = $state(false);
let restoring = $state<string | null>(null);
let latest = 0;
$effect(() => {
const ws = wsSlug;
void refreshKey;
const token = ++latest;
const isSameIdentity = authStore.identityFence();
if (!ws) return;
api.collections
.archived(ws)
.then((list) => {
if (token !== latest || !isSameIdentity()) return;
archived = list;
loaded = true;
})
.catch(() => {
if (token !== latest || !isSameIdentity()) return;
loaded = true;
});
return () => {
latest++;
};
});
async function restore(a: ArchivedCollection) {
const ws = wsSlug;
const isSameIdentity = authStore.identityFence();
restoring = a.id;
try {
await api.collections.restore(ws, a.id);
if (!isSameIdentity() || ws !== wsSlug) return;
archived = archived.filter((x) => x.id !== a.id);
toastStore.show(`Restored "${a.name}" with its ${a.item_count} ${a.item_count === 1 ? 'item' : 'items'}`, 'success');
onrestored?.();
} catch {
if (!isSameIdentity()) return;
toastStore.show(`Failed to restore "${a.name}"`, 'error');
} finally {
if (restoring === a.id) restoring = null;
}
}
</script>
{#if loaded && archived.length > 0}
<div class="archived" data-testid="archived-collections">
<h3 class="archived-title">Archived collections</h3>
<p class="archived-hint">Archived collections and their items are hidden, not deleted. Restore brings both back.</p>
<ul class="archived-list">
{#each archived as a (a.id)}
<li class="archived-row">
<span class="archived-icon" aria-hidden="true">{a.icon || '#'}</span>
<span class="archived-name">{a.name}</span>
<span class="archived-slug mono">/{a.slug}</span>
<span class="archived-count">{a.item_count} {a.item_count === 1 ? 'item' : 'items'}</span>
<span class="archived-date" title={new Date(a.archived_at).toLocaleString()}
>archived {new Date(a.archived_at).toLocaleDateString()}</span
>
<button
type="button"
class="btn btn-restore"
disabled={restoring === a.id}
onclick={() => restore(a)}
aria-label={`Restore ${a.name}`}
>
{restoring === a.id ? 'Restoring…' : 'Restore'}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<style>
.archived {
margin-top: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.archived-title {
margin: 0;
font-size: 0.95em;
}
.archived-hint {
margin: 0;
font-size: 0.8em;
color: var(--text-muted);
}
.archived-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.archived-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px dashed var(--border);
border-radius: var(--radius-sm, 6px);
color: var(--text-secondary);
}
.archived-name {
color: var(--text-primary);
font-weight: 500;
}
.archived-slug,
.archived-count,
.archived-date {
font-size: 0.8em;
color: var(--text-muted);
}
.btn-restore {
margin-left: auto;
}
</style>
@@ -76,12 +76,24 @@
let confirmArchive = $state(false);
let archiving = $state(false);
// TASK-2189: archiving a big collection asks for its name to be typed, as
// deleting a workspace asks for its slug. Below the threshold, the count
// in the confirm is the guard.
const TYPED_ARCHIVE_THRESHOLD = 25;
let archiveTyped = $state('');
const archiveItemCount = $derived(collection.item_count);
// An UNKNOWN count fails closed (codex r1): the confirm cannot say how
// much is going, so the typed guard stands in for the number.
const archiveNeedsTyping = $derived(archiveItemCount === undefined || archiveItemCount >= TYPED_ARCHIVE_THRESHOLD);
const archiveConfirmed = $derived(!archiveNeedsTyping || archiveTyped.trim() === collection.name);
async function handleArchive() {
if (!confirmArchive) {
confirmArchive = true;
archiveTyped = '';
return;
}
if (!archiveConfirmed) return;
archiving = true;
// Target the SEEDED collection identity, NOT the live props — the props
// may now identify a different collection while this form still shows
@@ -100,8 +112,27 @@
// delete -> list -> delete, so the second DELETE — destructive — would
// otherwise be issued on behalf of a user who is gone.
const isSameIdentity = authStore.identityFence();
// The Undo outlives this modal, so it carries what it restores and the
// identity it was offered to, checked again at CLICK time: the workspace
// delete's pattern (BUG-3006), since the toast store is global.
const epochAtArchive = authStore.identityEpoch;
const finishArchived = () => {
toastStore.show(`Archived "${editedCollectionName}"`, 'success');
toastStore.show(`Archived "${editedCollectionName}"`, 'success', 12000, undefined, {
label: 'Undo',
onAction: () => {
if (authStore.identityEpoch !== epochAtArchive) return;
api.collections
.restore(editedWsSlug, editedCollectionId)
.then(() => {
if (authStore.identityEpoch !== epochAtArchive) return;
toastStore.show(`Restored "${editedCollectionName}"`, 'success');
})
.catch(() => {
if (authStore.identityEpoch !== epochAtArchive) return;
toastStore.show(`Failed to restore "${editedCollectionName}"`, 'error');
});
}
});
onupdated(undefined, editedCollectionId, editedCollectionSlug, editedWsSlug);
onclose();
};
@@ -794,22 +825,39 @@
<header class="danger-zone-header">
<h3 id="danger-zone-heading" class="danger-zone-title">Danger zone</h3>
<p class="danger-zone-hint">
Archiving removes this collection and all its items from the workspace.
This can't be undone from the UI.
Archiving hides this collection and all its items from the workspace.
Nothing is deleted: restore it any time from Settings › Collections.
</p>
</header>
{#if confirmArchive}
<div class="danger-zone-confirm" role="alertdialog" aria-labelledby="archive-confirm-msg">
<p id="archive-confirm-msg" class="danger-zone-confirm-msg">
Archive <strong>"{collection.name}"</strong> and all its items?
{#if archiveItemCount !== undefined}
Archive <strong>"{collection.name}"</strong> and its
{archiveItemCount} {archiveItemCount === 1 ? 'item' : 'items'}?
{:else}
Archive <strong>"{collection.name}"</strong> and all its items?
{/if}
</p>
{#if archiveNeedsTyping}
<label class="danger-zone-typed">
Type <strong>{collection.name}</strong> to confirm
<input
type="text"
bind:value={archiveTyped}
autocomplete="off"
spellcheck="false"
aria-label={`Type ${collection.name} to confirm archiving`}
/>
</label>
{/if}
<div class="danger-zone-confirm-actions">
<button
class="btn-archive-confirm"
type="button"
onclick={handleArchive}
disabled={archiving}
disabled={archiving || !archiveConfirmed}
>
{archiving ? 'Archiving…' : 'Yes, archive'}
</button>
@@ -1249,6 +1297,21 @@
gap: var(--space-3);
}
.danger-zone-typed {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: 0.85em;
color: var(--text-secondary);
}
.danger-zone-typed input {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm, 6px);
background: var(--bg-primary);
color: var(--text-primary);
font: inherit;
}
.danger-zone-confirm-msg {
margin: 0;
font-size: 0.9em;
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync, mount, unmount, tick } from 'svelte';
import type { ArchivedCollection, Collection } from '$lib/types';
/**
* TASK-2189: archiving a collection showed no count, had a two-click guard
* whatever its size, and could not be undone. Now the confirm names the item
* count, a big collection needs its name typed, the success toast carries an
* Undo that restores it, and Settings lists archived collections to restore.
*/
const api = vi.hoisted(() => ({
del: vi.fn(),
restore: vi.fn(),
archived: vi.fn()
}));
const toastShow = vi.hoisted(() => vi.fn());
vi.mock('$lib/api/client', () => ({
api: {
collections: {
update: vi.fn(),
list: vi.fn().mockResolvedValue([]),
delete: (...a: unknown[]) => api.del(...a),
restore: (...a: unknown[]) => api.restore(...a),
archived: (...a: unknown[]) => api.archived(...a)
},
items: { listByCollection: vi.fn().mockResolvedValue([]) }
},
isConflictOrNotFound: () => false
}));
vi.mock('$lib/stores/toast.svelte', () => ({
toastStore: { show: toastShow, dismiss: vi.fn(), get toasts() { return []; } }
}));
const { default: EditCollectionModal } = await import('./EditCollectionModal.svelte');
const { default: ArchivedCollections } = await import('./ArchivedCollections.svelte');
let host: HTMLElement;
let app: Record<string, unknown> | null = null;
async function settle(): Promise<void> {
flushSync();
for (let i = 0; i < 8; i++) {
await Promise.resolve();
await tick();
}
flushSync();
}
function coll(itemCount: number): Collection {
return {
id: 'c1',
slug: 'field-notes',
name: 'Field Notes',
icon: '',
description: '',
prefix: 'FN',
schema: JSON.stringify({ fields: [] }),
settings: '{}',
is_default: false,
item_count: itemCount,
updated_at: '2026-10-08T00:00:00Z'
} as unknown as Collection;
}
function button(text: RegExp): HTMLButtonElement | undefined {
return [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) => text.test(b.textContent?.trim() ?? ''));
}
beforeEach(() => {
host = document.createElement('div');
document.body.appendChild(host);
api.del.mockReset().mockResolvedValue(undefined);
api.restore.mockReset().mockResolvedValue({ id: 'c1', slug: 'field-notes', name: 'Field Notes' });
api.archived.mockReset();
toastShow.mockReset();
});
afterEach(() => {
if (app) unmount(app as never);
app = null;
host.remove();
});
async function openConfirm(itemCount: number) {
app = mount(EditCollectionModal, {
target: host,
props: { open: true, collection: coll(itemCount), wsSlug: 'ws', onclose: () => {}, onupdated: () => {} }
}) as Record<string, unknown>;
await settle();
button(/^Archive collection$/)!.click();
await settle();
}
describe('archive confirm', () => {
it('names the item count, and a small collection needs no typing', async () => {
await openConfirm(3);
expect(document.body.textContent?.replace(/\s+/g, ' ')).toContain('Archive "Field Notes" and its 3 items?');
expect(document.body.textContent).not.toMatch(/can't be undone/);
expect(document.body.textContent).toContain('restore it any time');
expect(document.querySelector('.danger-zone-typed')).toBeNull();
expect(button(/^Yes, archive$/)!.disabled).toBe(false);
});
it('a collection of 25 or more items needs its name typed first', async () => {
await openConfirm(25);
const yes = button(/^Yes, archive$/)!;
expect(yes.disabled).toBe(true);
const input = document.querySelector<HTMLInputElement>('.danger-zone-typed input')!;
input.value = 'Field Note';
input.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
expect(yes.disabled).toBe(true);
input.value = 'Field Notes';
input.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
expect(yes.disabled).toBe(false);
yes.click();
await settle();
expect(api.del).toHaveBeenCalledTimes(1);
});
it('an UNKNOWN item count fails closed: the name must be typed', async () => {
await openConfirm(undefined as unknown as number);
expect(document.querySelector('.danger-zone-typed')).not.toBeNull();
expect(button(/^Yes, archive$/)!.disabled).toBe(true);
});
it('the success toast carries an Undo that restores the collection by id', async () => {
await openConfirm(2);
button(/^Yes, archive$/)!.click();
await settle();
const call = toastShow.mock.calls.find((c) => String(c[0]).startsWith('Archived'));
expect(call, 'an Archived toast').toBeTruthy();
const action = call![4] as { label: string; onAction: () => void };
expect(action.label).toBe('Undo');
action.onAction();
await settle();
expect(api.restore).toHaveBeenCalledWith('ws', 'c1');
expect(toastShow.mock.calls.some((c) => String(c[0]).startsWith('Restored'))).toBe(true);
});
});
describe('Settings › Collections › Archived collections', () => {
const archived: ArchivedCollection[] = [
{ id: 'c1', name: 'Field Notes', slug: 'field-notes', prefix: 'FN', item_count: 214, archived_at: '2026-10-08T00:00:00Z' }
];
it('lists archived collections with their counts and restores one', async () => {
api.archived.mockResolvedValue(archived);
const onrestored = vi.fn();
app = mount(ArchivedCollections, { target: host, props: { wsSlug: 'ws', refreshKey: 'a', onrestored } }) as Record<string, unknown>;
await settle();
expect(document.body.textContent).toContain('Field Notes');
expect(document.body.textContent).toContain('214 items');
button(/^Restore$/)!.click();
await settle();
expect(api.restore).toHaveBeenCalledWith('ws', 'c1');
expect(onrestored).toHaveBeenCalledOnce();
expect(document.querySelector('[data-testid="archived-collections"]')).toBeNull();
});
it('renders nothing when nothing is archived', async () => {
api.archived.mockResolvedValue([]);
app = mount(ArchivedCollections, { target: host, props: { wsSlug: 'ws', refreshKey: 'a' } }) as Record<string, unknown>;
await settle();
expect(document.querySelector('[data-testid="archived-collections"]')).toBeNull();
});
});
@@ -297,6 +297,14 @@ const KNOWN_CONTINUATIONS: Record<string, string> = {
'lib/components/items/CopyItemDialog.svelte::<callback of $effect>::tick.then': 'ui: focus',
'lib/components/items/CopyItemDialog.svelte::resetForOpen::loadDestCollections.then':
'fenced: flowGen and identityFence() captured in resetForOpen; selects the BUG-3200 preset only from the list loadDestCollections fenced',
// TASK-2189: the archive toast's Undo. The toast outlives the modal, so it
// carries the identity epoch captured at archive time, as the workspace
// delete's Undo does (BUG-3006), and both continuations re-check it before
// toasting.
'lib/components/collections/EditCollectionModal.svelte::onAction::api.collections.restore.then':
'fenced: authStore.identityEpoch compared with the epoch captured at archive time (TASK-2189)',
'lib/components/collections/EditCollectionModal.svelte::onAction::api.collections.restore.catch':
'fenced: authStore.identityEpoch compared with the epoch captured at archive time (TASK-2189)',
};
+15
View File
@@ -3167,3 +3167,18 @@ export interface ImportOutcome {
workspace_name?: string;
owner_username?: string;
}
/**
* An archived collection, from `GET /workspaces/{ws}/archived-collections`
* (TASK-2189). Archiving hides the collection and its items; nothing is
* deleted, and `api.collections.restore` brings both back.
*/
export interface ArchivedCollection {
id: string;
name: string;
slug: string;
prefix: string;
icon?: string;
item_count: number;
archived_at: string;
}
@@ -12,6 +12,7 @@
import { parseSchema } from '$lib/types';
import CreateCollectionModal from '$lib/components/collections/CreateCollectionModal.svelte';
import EditCollectionModal from '$lib/components/collections/EditCollectionModal.svelte';
import ArchivedCollections from '$lib/components/collections/ArchivedCollections.svelte';
import StorageTab from '$lib/components/settings/StorageTab.svelte';
import AppsTab from '$lib/components/settings/apps/AppsTab.svelte';
import Chip from '$lib/components/common/Chip.svelte';
@@ -1236,6 +1237,13 @@
onclose={() => (showCreateModal = false)}
/>
{/if}
{#if isOwner}
<ArchivedCollections
{wsSlug}
refreshKey={collections.map((c) => c.id).join(',')}
onrestored={() => void refreshCollections(wsSlug)}
/>
{/if}
{#if editingCollection && isOwner}
<EditCollectionModal
open={true}