From 2ee7dd3facd23b405416cbdc4ad42014f0216ef1 Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 24 Jun 2026 23:43:37 -0400 Subject: [PATCH] feat(web): WebMCP write/mutating dispatch + bootstrap (TASK-1893) (#766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire every mutating catalog action in the browser WebMCP dispatcher to a real api client method, completing the full-surface scope (DR-5). Each new dispatch entry forces the route wsSlug and rejects an agent-supplied workspace arg (DR-4); no write action silently no-ops. Actions wired: - pad_item: create, update, delete, restore, move, link, unlink, star, unstar, comment, bulk-update, import (export/deps/list-comments/starred reads also added) - pad_collection: create, update, delete - pad_role: create, update, delete - pad_playbook: run (side-effect-free) - pad_library: activate (resolve-by-title, mirrors the CLI) - pad_meta: bootstrap (scope addition — GET /agent/bootstrap) Thin client.ts methods added: playbooks.run, agentBootstrap, library.activateByTitle. Consent (DR-2): mutating tools mix reads+writes so descriptors emit no readOnlyHint — the browser fires per-invocation consent before execute runs. This dispatcher is the post-consent execution layer, not the gate. pad_project next/standup/changelog left as honest-error stubs (need new backend endpoints, tracked in TASK-1894). Refs TASK-1893 / PLAN-1888 Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ --- web/src/lib/api/client.ts | 60 +++ web/src/lib/types/index.ts | 4 + web/src/lib/webmcp/dispatch.test.ts | 627 +++++++++++++++++++++++++--- web/src/lib/webmcp/dispatch.ts | 516 ++++++++++++++++++++--- 4 files changed, 1099 insertions(+), 108 deletions(-) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 3bc1be89..11f811bc 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -497,6 +497,24 @@ export const api = { /** Full playbook item by ref / slug / invocation_slug. */ get: (ws: string, ref: string) => request(`/workspaces/${ws}/playbooks/${ref}`), + + /** + * Bind args to a playbook's declared spec and return the body + + * bound args + any unsatisfied required args. Side-effect-free + * server-side (the server only parses; the agent executes) — + * mirrors the CLI's `pad playbook run` and the MCP + * `pad_playbook.action=run`. Pass either a pre-parsed `args` map + * or raw CLI tokens (`raw_args`); the server merges them. + */ + run: ( + ws: string, + ref: string, + body?: { args?: Record; raw_args?: string[] } + ) => + request(`/workspaces/${ws}/playbooks/${ref}/run`, { + method: 'POST', + body: JSON.stringify(body ?? {}) + }), }, // ── Workspaces ──────────────────────────────────────────────────────────── @@ -1126,6 +1144,20 @@ export const api = { request(`/workspaces/${ws}/dashboard`) }, + // ── Agent bootstrap ─────────────────────────────────────────────────────── + + /** + * One-round-trip agent context for a workspace — user + collections + + * always-on conventions + roles + playbook metadata + dashboard + + * `needs_onboarding`. Mirrors the CLI `pad bootstrap`, the MCP + * `pad_meta.action=bootstrap`, and the `pad://workspace/{ws}/bootstrap` + * resource. Read-only. Typed loosely (`unknown`) because the + * AgentBootstrap shape lives in the Go server package and is not + * mirrored as a TS interface. + */ + agentBootstrap: (ws: string) => + request(`/workspaces/${ws}/agent/bootstrap`), + // ── Workspace Graph (PLAN-1730 / TASK-1732) ─────────────────────────────── graph: { @@ -1283,6 +1315,34 @@ export const api = { fields: JSON.stringify(fields) }) }); + }, + + /** + * Activate a library convention or playbook by its exact title. + * Resolves the title against the global library client-side + * (conventions first, then playbooks — the same precedence the CLI + * `pad library activate` and the MCP `pad_library.action=activate` + * use) and creates the matching workspace item. There is no + * server-side activate-by-title endpoint; the resolution lives in + * the client, mirroring cmd/pad/main.go::libraryActivateCmd. + * + * Throws when no entry matches the title. + */ + activateByTitle: async (ws: string, title: string): Promise => { + const conv = await api.library.get(); + for (const cat of conv.categories ?? []) { + const match = (cat.conventions ?? []).find((c) => c.title === title); + if (match) return api.library.activate(ws, match); + } + const plib = await api.library.getPlaybooks(); + for (const cat of plib.categories ?? []) { + const match = (cat.playbooks ?? []).find((p) => p.title === title); + if (match) return api.library.activatePlaybook(ws, match); + } + throw new PadApiError({ + code: 'not_found', + message: `no library convention or playbook titled ${JSON.stringify(title)}` + }); } }, diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 83473da3..28117eeb 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -384,6 +384,7 @@ export interface Collection { export interface CollectionCreate { name: string; slug?: string; + prefix?: string; icon?: string; description?: string; schema?: string; @@ -397,6 +398,7 @@ export interface FieldMigration { export interface CollectionUpdate { name?: string; + prefix?: string; icon?: string; description?: string; schema?: string; @@ -629,6 +631,8 @@ export interface ItemCreate { tags?: string; pinned?: boolean; parent_id?: string; + assigned_user_id?: string; + agent_role_id?: string; created_by?: string; source?: string; } diff --git a/web/src/lib/webmcp/dispatch.test.ts b/web/src/lib/webmcp/dispatch.test.ts index 5bef0d3f..1e6af6f7 100644 --- a/web/src/lib/webmcp/dispatch.test.ts +++ b/web/src/lib/webmcp/dispatch.test.ts @@ -9,39 +9,99 @@ function mockApi() { search: vi.fn(async () => ({ results: [] })), items: { list: vi.fn(async () => []), - get: vi.fn(async () => ({ ref: 'TASK-1' })), + get: vi.fn(async () => ({ id: 'uuid-target', ref: 'TASK-1' })), backlinks: vi.fn(async () => []), + create: vi.fn(async () => ({ ref: 'TASK-9' })), + update: vi.fn(async () => ({ ref: 'TASK-1' })), + delete: vi.fn(async () => undefined), + restore: vi.fn(async () => ({ ref: 'TASK-1' })), + move: vi.fn(async () => ({ ref: 'TASK-1' })), + star: vi.fn(async () => undefined), + unstar: vi.fn(async () => undefined), + starred: vi.fn(async () => []), + bulk: vi.fn(async () => ({ op: 'move', updated: [], failed: [] })), + }, + links: { + list: vi.fn(async () => [ + { id: 'link-1', link_type: 'blocks', target_id: 'uuid-target' }, + ]), + create: vi.fn(async () => ({ id: 'link-2' })), + delete: vi.fn(async () => undefined), + }, + comments: { + list: vi.fn(async () => []), + create: vi.fn(async () => ({ id: 'c-1' })), }, dashboard: { get: vi.fn(async () => ({ ok: true })) }, - collections: { list: vi.fn(async () => []) }, - agentRoles: { list: vi.fn(async () => []) }, - playbooks: { list: vi.fn(async () => []), get: vi.fn(async () => ({})) }, - library: { get: vi.fn(async () => ({})) }, + collections: { + list: vi.fn(async () => []), + create: vi.fn(async () => ({ slug: 'risks' })), + update: vi.fn(async () => ({ slug: 'risks' })), + delete: vi.fn(async () => undefined), + }, + agentRoles: { + list: vi.fn(async () => []), + get: vi.fn(async () => ({ id: 'role-uuid', slug: 'reviewer' })), + create: vi.fn(async () => ({ slug: 'reviewer' })), + update: vi.fn(async () => ({ slug: 'reviewer' })), + delete: vi.fn(async () => undefined), + }, + members: { + list: vi.fn(async () => ({ + members: [ + { user_id: 'user-uuid', user_name: 'Dave', user_email: 'dave@example.com' }, + ], + invitations: [], + })), + }, + playbooks: { + list: vi.fn(async () => []), + get: vi.fn(async () => ({})), + run: vi.fn(async () => ({ body: 'do the thing' })), + }, + library: { + get: vi.fn(async () => ({})), + activateByTitle: vi.fn(async () => ({ ref: 'CONVE-3' })), + }, workspaces: { list: vi.fn(async () => []) }, + exportItemArtifact: vi.fn(async () => ({ filename: 'x.pad.md', text: 'ARTIFACT' })), + importArtifact: vi.fn(async () => ({ ref: 'PLAYB-7', slug: 'ship', warnings: [] })), + agentBootstrap: vi.fn(async () => ({ needs_onboarding: false })), }; } type Api = typeof ApiClient; -// All catalog read actions are read_only=true; writes false. The real -// lookup comes from the served read set; here we hardcode the relevant ones. +// Mirror the served read set: known reads → true, known writes → false. +// (The dispatcher no longer branches on this — the route table is the source +// of truth — but register.ts still supplies it, so we keep it realistic.) const READ = new Set([ 'pad_search:query', 'pad_item:list', 'pad_item:get', + 'pad_item:deps', + 'pad_item:list-comments', + 'pad_item:starred', 'pad_item:backlinks', + 'pad_item:export', 'pad_project:dashboard', 'pad_collection:list', 'pad_role:list', 'pad_playbook:list', 'pad_playbook:get', + 'pad_playbook:run', 'pad_library:list', + 'pad_library:get', 'pad_workspace:list', + 'pad_meta:bootstrap', ]); const isReadOnly = (tool: string, action: string): boolean | undefined => { - // Mirror the served read set: known reads → true, known writes → false. if (READ.has(`${tool}:${action}`)) return true; - if (['create', 'update', 'delete', 'import'].includes(action)) return false; + if ( + ['create', 'update', 'delete', 'import', 'comment', 'link', 'unlink', 'move', + 'restore', 'star', 'unstar', 'bulk-update', 'activate'].includes(action) + ) + return false; return undefined; }; @@ -51,89 +111,537 @@ function parse(result: { content: { text: string }[]; isError?: boolean }) { return { isError: result.isError === true, text: result.content[0]?.text ?? '' }; } +function run(api: ReturnType, tool: string, args: Record) { + return dispatch(api as unknown as Api, WS, isReadOnly, tool, args); +} + +// ── Reads (regression — unchanged from 3a) ─────────────────────────────────── + describe('dispatch — wsSlug injection (DR-4)', () => { it('passes the route wsSlug to a read handler, never an arg', async () => { const api = mockApi(); - await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', { - action: 'list', - status: 'open', - }); + await run(api, 'pad_item', { action: 'list', status: 'open' }); expect(api.items.list).toHaveBeenCalledWith(WS, expect.objectContaining({ status: 'open' })); }); it('injects wsSlug into search filters', async () => { const api = mockApi(); - await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_search', { - action: 'query', - query: 'hello', - }); + await run(api, 'pad_search', { action: 'query', query: 'hello' }); expect(api.search).toHaveBeenCalledWith('hello', expect.objectContaining({ workspace: WS })); }); it('forwards ref to items.get', async () => { const api = mockApi(); - await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', { - action: 'get', - ref: 'TASK-5', - }); + await run(api, 'pad_item', { action: 'get', ref: 'TASK-5' }); expect(api.items.get).toHaveBeenCalledWith(WS, 'TASK-5'); }); }); +// ── Supplied-workspace rejection applies to writes too (DR-4) ──────────────── + describe('dispatch — supplied-workspace rejection (DR-4)', () => { - it('rejects an agent-supplied workspace arg outright', async () => { + it('rejects an agent-supplied workspace arg outright on a read', async () => { const api = mockApi(); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', { - action: 'list', - workspace: 'other-workspace', - }); + const result = await run(api, 'pad_item', { action: 'list', workspace: 'other-workspace' }); const { isError, text } = parse(result); expect(isError).toBe(true); expect(text).toMatch(/workspace/i); - // And it never reached the handler. expect(api.items.list).not.toHaveBeenCalled(); }); + it('rejects an agent-supplied workspace arg on a WRITE (create), no client call', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'X', + workspace: 'other-workspace', + }); + expect(parse(result).isError).toBe(true); + expect(api.items.create).not.toHaveBeenCalled(); + }); + + it('rejects an agent-supplied workspace on delete even when it equals the route slug', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { action: 'delete', ref: 'TASK-1', workspace: WS }); + expect(parse(result).isError).toBe(true); + expect(api.items.delete).not.toHaveBeenCalled(); + }); + it('ignores an empty-string workspace arg (treated as not supplied)', async () => { const api = mockApi(); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', { - action: 'list', - workspace: '', - }); + const result = await run(api, 'pad_item', { action: 'list', workspace: '' }); expect(parse(result).isError).toBe(false); expect(api.items.list).toHaveBeenCalledWith(WS, expect.anything()); }); +}); - it('rejects even when the supplied workspace equals the route slug', async () => { +// ── pad_item writes ───────────────────────────────────────────────────────── + +describe('dispatch — pad_item writes', () => { + it('create rolls flat fields into the fields JSON + injects ws + source', async () => { const api = mockApi(); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', { - action: 'list', - workspace: WS, + await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'Fix bug', + status: 'open', + priority: 'high', + parent: 'PLAN-3', + content: 'body', + tags: ['v1', 'frontend'], + }); + expect(api.items.create).toHaveBeenCalledTimes(1); + const [ws, coll, data] = api.items.create.mock.calls[0] as unknown as [string, string, any]; + expect(ws).toBe(WS); + expect(coll).toBe('tasks'); + expect(data.content).toBe('body'); + expect(data.source).toBe('web'); + expect(JSON.parse(data.fields)).toEqual({ status: 'open', priority: 'high', parent: 'PLAN-3' }); + expect(JSON.parse(data.tags)).toEqual(['v1', 'frontend']); + }); + + it('create resolves role slug → agent_role_id and assign → assigned_user_id', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'X', + role: 'reviewer', + assign: 'dave@example.com', + }); + expect(api.agentRoles.get).toHaveBeenCalledWith(WS, 'reviewer'); + expect(api.members.list).toHaveBeenCalledWith(WS); + const data = (api.items.create.mock.calls[0] as unknown as [string, string, any])[2]; + expect(data.agent_role_id).toBe('role-uuid'); + expect(data.assigned_user_id).toBe('user-uuid'); + }); + + it('update errors (no item write) when an assignee can not be resolved', async () => { + const api = mockApi(); + api.members.list.mockResolvedValueOnce({ members: [], invitations: [] }); + const result = await run(api, 'pad_item', { + action: 'update', + ref: 'TASK-1', + assign: 'ghost@example.com', }); expect(parse(result).isError).toBe(true); - expect(api.items.list).not.toHaveBeenCalled(); + expect(api.items.update).not.toHaveBeenCalled(); }); -}); -describe('dispatch — write actions (TASK-3b)', () => { - it('returns a precise not-wired error for a write action, no silent no-op', async () => { + it('create errors without collection/title and never calls the client', async () => { const api = mockApi(); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', { - action: 'delete', + const result = await run(api, 'pad_item', { action: 'create', title: 'no collection' }); + expect(parse(result).isError).toBe(true); + expect(api.items.create).not.toHaveBeenCalled(); + }); + + it('update omits fields JSON when only title changes (no field blow-away)', async () => { + const api = mockApi(); + await run(api, 'pad_item', { action: 'update', ref: 'TASK-1', title: 'Renamed' }); + const [ws, ref, data] = api.items.update.mock.calls[0] as unknown as [string, string, any]; + expect(ws).toBe(WS); + expect(ref).toBe('TASK-1'); + expect(data.title).toBe('Renamed'); + expect(data.fields).toBeUndefined(); + }); + + it('update forwards status into fields + the audit comment + force', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'update', ref: 'TASK-1', + status: 'done', + comment: 'shipped', + force: true, }); - const { isError, text } = parse(result); - expect(isError).toBe(true); - expect(text).toMatch(/TASK-3b/); + const data = (api.items.update.mock.calls[0] as unknown as [string, string, any])[2]; + expect(JSON.parse(data.fields)).toEqual({ status: 'done' }); + expect(data.comment).toBe('shipped'); + expect(data.force).toBe(true); + }); + + it('create maps `field` key=value entries into the fields JSON', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'X', + field: ['due_date=2026-06-01', 'effort=l'], + }); + const data = (api.items.create.mock.calls[0] as unknown as [string, string, any])[2]; + expect(JSON.parse(data.fields)).toEqual({ due_date: '2026-06-01', effort: 'l' }); + }); + + it('create errors (no client call) on a malformed `field` entry — no silent drop', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'X', + field: ['not-a-pair'], + }); + expect(parse(result).isError).toBe(true); + expect(parse(result).text).toMatch(/field/i); + expect(api.items.create).not.toHaveBeenCalled(); + }); + + it('create errors on a non-string `field` entry — no silent drop', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'X', + field: [{ key: 'status', value: 'done' }], + }); + expect(parse(result).isError).toBe(true); + expect(api.items.create).not.toHaveBeenCalled(); + }); + + it('delete maps to items.delete with the route ws', async () => { + const api = mockApi(); + await run(api, 'pad_item', { action: 'delete', ref: 'TASK-1' }); + expect(api.items.delete).toHaveBeenCalledWith(WS, 'TASK-1'); + }); + + it('move passes target_collection + force', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'move', + ref: 'TASK-1', + target_collection: 'ideas', + force: true, + }); + expect(api.items.move).toHaveBeenCalledWith(WS, 'TASK-1', 'ideas', undefined, { force: true }); + }); + + it('move passes `field` overrides through to field_overrides', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'move', + ref: 'TASK-1', + target_collection: 'ideas', + field: ['category=infra'], + }); + expect(api.items.move).toHaveBeenCalledWith( + WS, + 'TASK-1', + 'ideas', + { category: 'infra' }, + expect.anything(), + ); + }); + + it('comment maps message → body and reply_to → parent_id', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'comment', + ref: 'TASK-1', + message: 'looks good', + reply_to: 'c-0', + }); + expect(api.comments.create).toHaveBeenCalledWith( + WS, + 'TASK-1', + expect.objectContaining({ body: 'looks good', parent_id: 'c-0' }), + ); + }); + + it('star/unstar map to the right client methods', async () => { + const api = mockApi(); + await run(api, 'pad_item', { action: 'star', ref: 'TASK-1' }); + await run(api, 'pad_item', { action: 'unstar', ref: 'TASK-1' }); + expect(api.items.star).toHaveBeenCalledWith(WS, 'TASK-1'); + expect(api.items.unstar).toHaveBeenCalledWith(WS, 'TASK-1'); + }); + + it('bulk-update (status) maps to the move verb with refs as ids', async () => { + const api = mockApi(); + await run(api, 'pad_item', { action: 'bulk-update', refs: ['TASK-1', 'TASK-2'], status: 'done' }); + expect(api.items.bulk).toHaveBeenCalledWith( + WS, + expect.objectContaining({ op: 'move', ids: ['TASK-1', 'TASK-2'], status: 'done' }), + ); + }); + + it('bulk-update (priority) maps to the set-priority verb', async () => { + const api = mockApi(); + await run(api, 'pad_item', { action: 'bulk-update', refs: ['TASK-1'], priority: 'high' }); + expect(api.items.bulk).toHaveBeenCalledWith( + WS, + expect.objectContaining({ op: 'set-priority', ids: ['TASK-1'], priority: 'high' }), + ); + }); + + it('bulk-update errors (no partial write) on a non-string ref element', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { + action: 'bulk-update', + refs: ['TASK-1', 123], + status: 'done', + }); + expect(parse(result).isError).toBe(true); + expect(api.items.bulk).not.toHaveBeenCalled(); + }); + + it('bulk-update errors (no client call) when refs missing', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { action: 'bulk-update', status: 'done' }); + expect(parse(result).isError).toBe(true); + expect(api.items.bulk).not.toHaveBeenCalled(); }); }); -describe('dispatch — read action result envelope', () => { - it('wraps the client result as JSON text content', async () => { +// ── pad_item link / unlink ─────────────────────────────────────────────────── + +describe('dispatch — pad_item link/unlink', () => { + it('link resolves the target ref to an id and stores the canonical type', async () => { const api = mockApi(); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_project', { - action: 'dashboard', + await run(api, 'pad_item', { + action: 'link', + ref: 'TASK-1', + target: 'TASK-2', + link_type: 'blocks', }); + expect(api.items.get).toHaveBeenCalledWith(WS, 'TASK-2'); + expect(api.links.create).toHaveBeenCalledWith(WS, 'TASK-1', { + target_id: 'uuid-target', + link_type: 'blocks', + }); + }); + + it('blocked-by inverts source/target before creating a blocks edge', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'link', + ref: 'TASK-1', + target: 'TASK-2', + link_type: 'blocked-by', + }); + // source becomes the target (TASK-2), target id resolved from ref (TASK-1). + expect(api.items.get).toHaveBeenCalledWith(WS, 'TASK-1'); + expect(api.links.create).toHaveBeenCalledWith(WS, 'TASK-2', { + target_id: 'uuid-target', + link_type: 'blocks', + }); + }); + + it('split-from stores as split_from', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'link', + ref: 'TASK-1', + target: 'PLAN-2', + link_type: 'split-from', + }); + expect(api.links.create).toHaveBeenCalledWith(WS, 'TASK-1', { + target_id: 'uuid-target', + link_type: 'split_from', + }); + }); + + it('link errors on an unknown link_type, no client call', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { + action: 'link', + ref: 'TASK-1', + target: 'TASK-2', + link_type: 'nonsense', + }); + expect(parse(result).isError).toBe(true); + expect(api.links.create).not.toHaveBeenCalled(); + }); + + it('unlink finds the matching edge and deletes by id', async () => { + const api = mockApi(); + await run(api, 'pad_item', { + action: 'unlink', + ref: 'TASK-1', + target: 'TASK-2', + link_type: 'blocks', + }); + expect(api.links.list).toHaveBeenCalledWith(WS, 'TASK-1'); + expect(api.links.delete).toHaveBeenCalledWith(WS, 'link-1'); + }); +}); + +// ── pad_item export / import (artifact passthrough) ────────────────────────── + +describe('dispatch — pad_item export/import', () => { + it('export returns the artifact text', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { action: 'export', ref: 'PLAYB-1' }); + expect(api.exportItemArtifact).toHaveBeenCalledWith(WS, 'PLAYB-1'); + expect(parse(result).isError).toBe(false); + }); + + it('import passes the full artifact text through to importArtifact', async () => { + const api = mockApi(); + const ARTIFACT = '---\ncollection: playbooks\n---\n# Ship\nbody'; + await run(api, 'pad_item', { action: 'import', artifact: ARTIFACT }); + expect(api.importArtifact).toHaveBeenCalledWith(WS, ARTIFACT); + }); + + it('import errors (no client call) when artifact is missing', async () => { + const api = mockApi(); + const result = await run(api, 'pad_item', { action: 'import' }); + expect(parse(result).isError).toBe(true); + expect(api.importArtifact).not.toHaveBeenCalled(); + }); +}); + +// ── pad_collection / pad_role writes ───────────────────────────────────────── + +describe('dispatch — collection + role writes', () => { + it('collection create maps name + slug + schema', async () => { + const api = mockApi(); + await run(api, 'pad_collection', { + action: 'create', + name: 'Risks', + slug: 'risks', + schema: '{}', + }); + expect(api.collections.create).toHaveBeenCalledWith( + WS, + expect.objectContaining({ name: 'Risks', slug: 'risks', schema: '{}' }), + ); + }); + + it('collection create rolls layout/default_view/board_group_by into settings + prefix', async () => { + const api = mockApi(); + await run(api, 'pad_collection', { + action: 'create', + name: 'Risks', + prefix: 'RISK', + layout: 'balanced', + default_view: 'board', + board_group_by: 'status', + }); + const data = (api.collections.create.mock.calls[0] as unknown as [string, any])[1]; + expect(data.prefix).toBe('RISK'); + expect(JSON.parse(data.settings)).toEqual({ + layout: 'balanced', + default_view: 'board', + board_group_by: 'status', + }); + }); + + it('collection create accepts an object-shaped schema', async () => { + const api = mockApi(); + await run(api, 'pad_collection', { + action: 'create', + name: 'Risks', + schema: { fields: [{ key: 'status', type: 'select' }] }, + }); + const data = (api.collections.create.mock.calls[0] as unknown as [string, any])[1]; + expect(JSON.parse(data.schema)).toEqual({ fields: [{ key: 'status', type: 'select' }] }); + }); + + it('collection create errors on the fields DSL (no browser parser), no client call', async () => { + const api = mockApi(); + const result = await run(api, 'pad_collection', { + action: 'create', + name: 'Risks', + fields: 'status:select:open,done', + }); + expect(parse(result).isError).toBe(true); + expect(parse(result).text).toMatch(/fields/i); + expect(api.collections.create).not.toHaveBeenCalled(); + }); + + it('collection update targets the slug + carries prefix/sort_order', async () => { + const api = mockApi(); + await run(api, 'pad_collection', { + action: 'update', + slug: 'risks', + name: 'Risk Register', + prefix: 'RISK', + sort_order: 3, + }); + expect(api.collections.update).toHaveBeenCalledWith( + WS, + 'risks', + expect.objectContaining({ name: 'Risk Register', prefix: 'RISK', sort_order: 3 }), + ); + }); + + it('collection delete targets the slug', async () => { + const api = mockApi(); + await run(api, 'pad_collection', { action: 'delete', slug: 'risks' }); + expect(api.collections.delete).toHaveBeenCalledWith(WS, 'risks'); + }); + + it('role create maps name + description', async () => { + const api = mockApi(); + await run(api, 'pad_role', { action: 'create', name: 'Reviewer', description: 'Reviews PRs' }); + expect(api.agentRoles.create).toHaveBeenCalledWith( + WS, + expect.objectContaining({ name: 'Reviewer', description: 'Reviews PRs' }), + ); + }); + + it('role update targets the slug and maps new_slug → body.slug + sort_order', async () => { + const api = mockApi(); + await run(api, 'pad_role', { + action: 'update', + slug: 'reviewer', + new_slug: 'pr-reviewer', + icon: '👀', + sort_order: 2, + }); + expect(api.agentRoles.update).toHaveBeenCalledWith( + WS, + 'reviewer', + expect.objectContaining({ slug: 'pr-reviewer', icon: '👀', sort_order: 2 }), + ); + }); +}); + +// ── pad_playbook.run + pad_library.activate + pad_meta.bootstrap ───────────── + +describe('dispatch — playbook/library/meta writes', () => { + it('playbook run forwards ref + args + raw_args', async () => { + const api = mockApi(); + await run(api, 'pad_playbook', { + action: 'run', + ref: 'ship', + args: { stop_after_each: true }, + raw_args: ['PLAN-1'], + }); + expect(api.playbooks.run).toHaveBeenCalledWith( + WS, + 'ship', + expect.objectContaining({ args: { stop_after_each: true }, raw_args: ['PLAN-1'] }), + ); + }); + + it('library activate resolves by title via the route ws', async () => { + const api = mockApi(); + await run(api, 'pad_library', { action: 'activate', title: 'Ship tasks' }); + expect(api.library.activateByTitle).toHaveBeenCalledWith(WS, 'Ship tasks'); + }); + + it('library activate errors (no client call) without a title', async () => { + const api = mockApi(); + const result = await run(api, 'pad_library', { action: 'activate' }); + expect(parse(result).isError).toBe(true); + expect(api.library.activateByTitle).not.toHaveBeenCalled(); + }); + + it('meta bootstrap maps to agentBootstrap with the route ws', async () => { + const api = mockApi(); + const result = await run(api, 'pad_meta', { action: 'bootstrap' }); + expect(api.agentBootstrap).toHaveBeenCalledWith(WS); + expect(parse(result).isError).toBe(false); + }); +}); + +// ── Envelope + error behaviour ─────────────────────────────────────────────── + +describe('dispatch — result envelope + errors', () => { + it('wraps a read result as JSON text content', async () => { + const api = mockApi(); + const result = await run(api, 'pad_project', { action: 'dashboard' }); const { isError, text } = parse(result); expect(isError).toBe(false); expect(JSON.parse(text)).toEqual({ ok: true }); @@ -141,33 +649,30 @@ describe('dispatch — read action result envelope', () => { it('errors clearly when action is missing', async () => { const api = mockApi(); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_item', {}); + const result = await run(api, 'pad_item', {}); expect(parse(result).isError).toBe(true); expect(parse(result).text).toMatch(/action/); }); - it('errors for a read action with no browser mapping (e.g. pad_project.standup)', async () => { + it('errors for a catalog action with no browser mapping (pad_project.standup)', async () => { const api = mockApi(); - // standup is read_only in the catalog but has no browser handler. - const isReadStandup = (t: string, a: string) => - a === 'standup' ? true : isReadOnly(t, a); - const result = await dispatch(api as unknown as Api, WS, isReadStandup, 'pad_project', { - action: 'standup', - }); + const result = await run(api, 'pad_project', { action: 'standup' }); const { isError, text } = parse(result); expect(isError).toBe(true); expect(text).toMatch(/not available/i); }); - it('surfaces a thrown client error as an error result', async () => { + it('surfaces a thrown client error as an error result (server ACL → tool error)', async () => { const api = mockApi(); - api.dashboard.get.mockRejectedValueOnce(new Error('boom')); - const result = await dispatch(api as unknown as Api, WS, isReadOnly, 'pad_project', { - action: 'dashboard', + api.items.create.mockRejectedValueOnce(new Error('forbidden')); + const result = await run(api, 'pad_item', { + action: 'create', + collection: 'tasks', + title: 'X', }); const { isError, text } = parse(result); expect(isError).toBe(true); - expect(text).toMatch(/boom/); + expect(text).toMatch(/forbidden/); }); it('errors when there is no active workspace', async () => { diff --git a/web/src/lib/webmcp/dispatch.ts b/web/src/lib/webmcp/dispatch.ts index 0368aed0..5f9ed651 100644 --- a/web/src/lib/webmcp/dispatch.ts +++ b/web/src/lib/webmcp/dispatch.ts @@ -1,25 +1,37 @@ // dispatch.ts — the `(toolName, action, args) → client.ts` router for the -// WebMCP surface (PLAN-1888 / TASK-1892, piece 4). Pure/injectable: takes the -// `api` client + the route `wsSlug` as arguments so it's unit-testable with a -// mocked client and no browser. +// WebMCP surface (PLAN-1888 / TASK-1892 read dispatch, TASK-1893 write +// dispatch). Pure/injectable: takes the `api` client + the route `wsSlug` as +// arguments so it's unit-testable with a mocked client and no browser. // -// Two correctness constraints, both unit-tested: +// Correctness constraints, all unit-tested: // - DR-4: the workspace is FROZEN to the route `wsSlug`. An agent-supplied // `workspace` arg is REJECTED (not silently overridden) so a stray / -// malicious workspace can't slip through. Every read handler receives the -// route wsSlug, never an arg-derived one. -// - This task wires READ actions only. WRITE actions are recognized and -// return a clear "not yet wired (TASK-3b)" error — never a silent no-op. +// malicious workspace can't slip through. Every handler receives the route +// wsSlug, never an arg-derived one. +// - No silent no-ops: every mutating catalog action either dispatches to a +// real client.ts method or returns a precise, actionable error. A write +// action with no browser mapping returns an honest "not available" error. +// +// Consent (DR-2): consent is NOT enforced here. The browser host fires +// per-invocation consent BEFORE `execute` runs, gated by the descriptor's +// `readOnlyHint`. Mutating tools (pad_item, pad_collection, pad_role, +// pad_library) carry NO readOnlyHint (they mix reads + writes — descriptors.ts +// `isAllReadOnly`), so the host prompts on every call. By the time a write +// handler below runs, the user has already consented. This dispatcher is the +// post-consent execution layer, not the gate. (A real Chrome-149 consent check +// can't run in CI — see the PR body's manual-verification note.) import type { api as ApiClient } from '$lib/api/client'; +import type { ItemCreate, ItemUpdate } from '$lib/types'; type Api = typeof ApiClient; /** Result envelope the WebMCP host expects from `execute`. */ export type DispatchResult = ModelContextToolResult; -/** A read-action handler: pure call into the api client. */ -type ReadHandler = ( +/** A handler: pure call into the api client. Reads and writes share the shape; + * the route wsSlug is injected, the agent's `workspace` arg never reaches it. */ +type Handler = ( api: Api, wsSlug: string, args: Record, @@ -42,31 +54,290 @@ function num(args: Record, key: string): number | undefined { return undefined; } +function bool(args: Record, key: string): boolean | undefined { + const v = args[key]; + if (typeof v === 'boolean') return v; + if (v === 'true') return true; + if (v === 'false') return false; + return undefined; +} + function requireRef(args: Record): string { const ref = str(args, 'ref') ?? str(args, 'slug'); if (!ref) throw new Error("missing required arg 'ref'"); return ref; } +function requireArg(args: Record, key: string): string { + const v = str(args, key); + if (!v) throw new Error(`missing required arg '${key}'`); + return v; +} + +/** + * Coerce an array param (the MCP transport may also deliver a single + * string). Returns undefined when absent/empty. Throws on a non-string element + * rather than filtering it out, so a malformed array (e.g. `refs: ["TASK-1", 123]`, + * `tags: [123]`) surfaces a precise tool error instead of a partial / empty + * write — no silent param drop. + */ +function strArray(args: Record, key: string): string[] | undefined { + const v = args[key]; + if (v === undefined || v === null) return undefined; + if (typeof v === 'string') return v.length > 0 ? [v] : undefined; + if (!Array.isArray(v)) { + throw new Error(`'${key}' must be an array of strings`); + } + const out: string[] = []; + for (const e of v) { + if (typeof e !== 'string') { + throw new Error(`'${key}' must contain only strings (got ${JSON.stringify(e)})`); + } + if (e.length > 0) out.push(e); + } + return out.length > 0 ? out : undefined; +} + +/** + * Parse the catalog's `field` param (array of "key=value" strings for + * schema-declared custom fields) into a key→value object. Throws on a + * malformed entry (missing `=` or empty key) rather than silently dropping it, + * so an agent that mis-shapes a field setter gets a precise tool error instead + * of a write that quietly omits the field. + */ +function parseFieldKVP(args: Record): Record { + const raw = args.field; + if (raw === undefined || raw === null) return {}; + // Accept the canonical array shape, or a single string (lenient, + // matching strArray elsewhere). Anything else is malformed — throw rather + // than coerce/drop, so a mis-shaped `field` never silently no-ops. + const entries: unknown[] = Array.isArray(raw) ? raw : [raw]; + const out: Record = {}; + for (const entry of entries) { + if (typeof entry !== 'string') { + throw new Error( + `malformed 'field' entry ${JSON.stringify(entry)} — expected a "key=value" string`, + ); + } + if (entry.length === 0) continue; + const idx = entry.indexOf('='); + if (idx <= 0) { + throw new Error( + `malformed 'field' entry ${JSON.stringify(entry)} — expected "key=value"`, + ); + } + out[entry.slice(0, idx)] = entry.slice(idx + 1); + } + return out; +} + +/** + * Roll the catalog's flat item params (status / priority / category / parent + * plus arbitrary `field` key=value entries) into the `fields` JSON string the + * create/update endpoints persist — the browser-side mirror of the CLI's + * field-building in cmd/pad/main.go::item create. The server resolves a + * `parent` ref inside the fields JSON itself (handlers_items.go ~:580), so no + * client-side parent lookup is needed. + * + * Returns the JSON string, or undefined when no flat fields were supplied (so + * an update carrying only e.g. `title` doesn't blow away the item's fields). + */ +function buildFieldsJSON(args: Record): string | undefined { + const fields: Record = {}; + for (const key of ['status', 'priority', 'category', 'parent']) { + const v = str(args, key); + if (v !== undefined) fields[key] = v; + } + Object.assign(fields, parseFieldKVP(args)); + if (Object.keys(fields).length === 0) return undefined; + return JSON.stringify(fields); +} + +/** Tags: the catalog passes a JSON array of strings; ItemCreate/ItemUpdate + * store the canonical JSON-encoded string form. */ +function buildTagsJSON(args: Record): string | undefined { + const tags = strArray(args, 'tags'); + if (!tags) return undefined; + return JSON.stringify(tags); +} + +/** + * Resolve the catalog's `role` (slug) and `assign` (user name/email) item + * params to the `agent_role_id` / `assigned_user_id` the create/update + * endpoints persist — the browser mirror of the CLI's slug→ID / name→userID + * resolution (cmd/pad/main.go::item create). Throws (surfaced as a tool error, + * never a silent drop) when the role/user can't be resolved. + */ +async function resolveAssignment( + api: Api, + ws: string, + args: Record, +): Promise<{ agent_role_id?: string; assigned_user_id?: string }> { + const out: { agent_role_id?: string; assigned_user_id?: string } = {}; + const role = str(args, 'role'); + if (role !== undefined) { + const resolved = await api.agentRoles.get(ws, role); + if (!resolved?.id) throw new Error(`role ${JSON.stringify(role)} not found`); + out.agent_role_id = resolved.id; + } + const assign = str(args, 'assign'); + if (assign !== undefined) { + const { members } = await api.members.list(ws); + const match = members.find( + (m) => + m.user_name?.toLowerCase() === assign.toLowerCase() || + m.user_email?.toLowerCase() === assign.toLowerCase(), + ); + if (!match) throw new Error(`user ${JSON.stringify(assign)} not found in workspace members`); + out.assigned_user_id = match.user_id; + } + return out; +} + +/** + * Resolve the collection `schema` param to the JSON string the create/update + * endpoints persist. The catalog's `schema` is a structured CollectionSchema + * object, but the MCP transport may deliver it either as a JSON-encoded string + * or as a nested object — accept both. The `fields` compact DSL has no + * browser-side parser (it lives only in the Go CLI), so rather than silently + * dropping it we throw a precise error pointing the caller at `schema`. + */ +function collectionSchema(args: Record): string | undefined { + if (str(args, 'fields') !== undefined) { + throw new Error( + "the 'fields' DSL is not available in the browser WebMCP surface — " + + "pass 'schema' (structured CollectionSchema JSON) instead", + ); + } + const schema = args.schema; + if (schema === undefined || schema === null || schema === '') return undefined; + if (typeof schema === 'string') return schema; + if (typeof schema === 'object') return JSON.stringify(schema); + throw new Error("'schema' must be a CollectionSchema object or JSON string"); +} + +/** + * Roll the collection settings params (layout / default_view / board_group_by) + * into the JSON `settings` string the endpoint persists — the browser mirror + * of the CLI's CollectionSettings build. Returns undefined when none were + * supplied so an update doesn't clobber existing settings. + */ +function collectionSettings(args: Record): string | undefined { + const settings: Record = {}; + const layout = str(args, 'layout'); + if (layout !== undefined) settings.layout = layout; + const defaultView = str(args, 'default_view'); + if (defaultView !== undefined) settings.default_view = defaultView; + const boardGroupBy = str(args, 'board_group_by'); + if (boardGroupBy !== undefined) settings.board_group_by = boardGroupBy; + if (Object.keys(settings).length === 0) return undefined; + return JSON.stringify(settings); +} + function ok(result: unknown): DispatchResult { - return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] }; } function errResult(message: string): DispatchResult { return { content: [{ type: 'text', text: message }], isError: true }; } -// ── read dispatch table ────────────────────────────────────────────────── +// ── link/unlink routing ────────────────────────────────────────────────── // -// Keyed by `${toolName}:${action}`. Only READ actions appear here (this -// task). Each handler receives the ROUTE wsSlug — never an arg-supplied one. -// Project next/standup/changelog and pad_meta/bootstrap have no clean REST -// read endpoint exposed to the browser today, so they're intentionally -// absent and fall through to the "not available in browser" branch rather -// than being faked. +// Mirrors internal/mcp/catalog_item.go::itemLinkRoutes. The catalog's uniform +// (ref, target, link_type) shape maps onto the web `links` API, which is a +// lower-level graph surface: POST /items/{source}/links takes a TARGET UUID + +// a canonical graph type, and DELETE /links/{id} removes by edge id. +// +// Two translations vs. the catalog: +// - link_type → canonical graph type. The catalog's `blocked-by` is the +// inverse of `blocks` (no separate graph type): source/target swap and the +// stored type becomes `blocks`. `split-from` stores as `split_from`. +// - ref/target → ids. The path source resolves a ref/slug server-side, but +// the target must be a UUID, so we resolve the target ref via items.get. +// For inverted types the roles swap before resolution. -const READ_HANDLERS: Record = { - // pad_search +interface LinkRoute { + /** Canonical graph link_type stored server-side. */ + graphType: string; + /** When true, swap (ref, target) so the stored edge points the right way. */ + inverted: boolean; +} + +const LINK_ROUTES: Record = { + blocks: { graphType: 'blocks', inverted: false }, + // "ref blocked-by target" == "target blocks ref": swap, store as blocks. + 'blocked-by': { graphType: 'blocks', inverted: true }, + supersedes: { graphType: 'supersedes', inverted: false }, + implements: { graphType: 'implements', inverted: false }, + 'split-from': { graphType: 'split_from', inverted: false }, +}; + +function resolveLinkRoute(args: Record): { + sourceRef: string; + targetRef: string; + route: LinkRoute; +} { + const ref = requireRef(args); + const target = requireArg(args, 'target'); + const linkType = str(args, 'link_type'); + const route = linkType ? LINK_ROUTES[linkType] : undefined; + if (!route) { + throw new Error( + `link_type is required (one of: ${Object.keys(LINK_ROUTES).sort().join(', ')})`, + ); + } + const [sourceRef, targetRef] = route.inverted ? [target, ref] : [ref, target]; + return { sourceRef, targetRef, route }; +} + +async function dispatchItemLink( + api: Api, + ws: string, + args: Record, +): Promise { + const { sourceRef, targetRef, route } = resolveLinkRoute(args); + // The target must be a UUID for the web links API; resolve the ref. + const targetItem = await api.items.get(ws, targetRef); + return api.links.create(ws, sourceRef, { + target_id: targetItem.id, + link_type: route.graphType, + }); +} + +async function dispatchItemUnlink( + api: Api, + ws: string, + args: Record, +): Promise { + const { sourceRef, targetRef, route } = resolveLinkRoute(args); + const targetItem = await api.items.get(ws, targetRef); + // The web links API deletes by edge id, so find the matching edge from the + // source item's link list. + const links = await api.links.list(ws, sourceRef); + const match = links.find( + (l) => l.link_type === route.graphType && l.target_id === targetItem.id, + ); + if (!match) { + throw new Error(`no ${route.graphType} link to ${targetRef} to remove`); + } + return api.links.delete(ws, match.id); +} + +// ── dispatch table ───────────────────────────────────────────────────────── +// +// Keyed by `${toolName}:${action}`. Holds BOTH reads and writes — the read/ +// write split is enforced by the served `read_only` flag + the per-invocation +// consent gate (DR-2), not by which table an action lives in. Each handler +// receives the ROUTE wsSlug — never an arg-supplied one (DR-4). +// +// pad_project next/standup/changelog and any other catalog read with no +// browser REST mapping are intentionally absent and fall through to the "not +// available in browser" branch rather than being faked (TASK-1894 wires +// next/standup/changelog once their backend endpoints exist). + +const HANDLERS: Record = { + // ── pad_search ── 'pad_search:query': (api, ws, args) => api.search(str(args, 'query') ?? str(args, 'q') ?? '', { workspace: ws, @@ -76,7 +347,7 @@ const READ_HANDLERS: Record = { limit: num(args, 'limit'), }), - // pad_item reads + // ── pad_item reads ── 'pad_item:list': (api, ws, args) => api.items.list(ws, { collection: str(args, 'collection'), @@ -87,32 +358,189 @@ const READ_HANDLERS: Record = { limit: num(args, 'limit'), }), 'pad_item:get': (api, ws, args) => api.items.get(ws, requireRef(args)), + 'pad_item:deps': (api, ws, args) => api.links.list(ws, requireRef(args)), + 'pad_item:list-comments': (api, ws, args) => + api.comments.list(ws, requireRef(args)), + 'pad_item:starred': (api, ws, args) => + api.items.starred(ws, { + include_terminal: bool(args, 'all') === true ? true : undefined, + }), 'pad_item:backlinks': (api, ws, args) => api.items.backlinks(ws, requireRef(args), { limit: num(args, 'limit'), offset: num(args, 'offset'), }), + // export is read-only (side-effect-free): returns the artifact text. + 'pad_item:export': (api, ws, args) => + api.exportItemArtifact(ws, requireRef(args)), - // pad_project reads — only dashboard has a browser REST endpoint today. + // ── pad_item writes ── + 'pad_item:create': async (api, ws, args) => { + const collection = requireArg(args, 'collection'); + const title = requireArg(args, 'title'); + const data: ItemCreate = { title, source: 'web' }; + const content = str(args, 'content'); + if (content !== undefined) data.content = content; + const fields = buildFieldsJSON(args); + if (fields !== undefined) data.fields = fields; + const tags = buildTagsJSON(args); + if (tags !== undefined) data.tags = tags; + // role / assign resolve to ids; throws (not a silent drop) on miss. + const { agent_role_id, assigned_user_id } = await resolveAssignment(api, ws, args); + if (agent_role_id !== undefined) data.agent_role_id = agent_role_id; + if (assigned_user_id !== undefined) data.assigned_user_id = assigned_user_id; + return api.items.create(ws, collection, data); + }, + 'pad_item:update': async (api, ws, args) => { + const ref = requireRef(args); + const data: ItemUpdate = { source: 'web' }; + const title = str(args, 'title'); + if (title !== undefined) data.title = title; + const content = str(args, 'content'); + if (content !== undefined) data.content = content; + const fields = buildFieldsJSON(args); + if (fields !== undefined) data.fields = fields; + const tags = buildTagsJSON(args); + if (tags !== undefined) data.tags = tags; + const comment = str(args, 'comment'); + if (comment !== undefined) data.comment = comment; + if (bool(args, 'force') === true) data.force = true; + const { agent_role_id, assigned_user_id } = await resolveAssignment(api, ws, args); + if (agent_role_id !== undefined) data.agent_role_id = agent_role_id; + if (assigned_user_id !== undefined) data.assigned_user_id = assigned_user_id; + return api.items.update(ws, ref, data); + }, + 'pad_item:delete': (api, ws, args) => api.items.delete(ws, requireRef(args)), + 'pad_item:restore': (api, ws, args) => api.items.restore(ws, requireRef(args)), + 'pad_item:move': (api, ws, args) => { + // The catalog's `field` param maps to the move endpoint's + // field_overrides (mirrors the CLI's `pad item move --field`). + const overrides = parseFieldKVP(args); + return api.items.move( + ws, + requireRef(args), + requireArg(args, 'target_collection'), + Object.keys(overrides).length > 0 ? overrides : undefined, + { force: bool(args, 'force') === true ? true : undefined }, + ); + }, + 'pad_item:link': (api, ws, args) => dispatchItemLink(api, ws, args), + 'pad_item:unlink': (api, ws, args) => dispatchItemUnlink(api, ws, args), + 'pad_item:star': (api, ws, args) => api.items.star(ws, requireRef(args)), + 'pad_item:unstar': (api, ws, args) => api.items.unstar(ws, requireRef(args)), + 'pad_item:comment': (api, ws, args) => + api.comments.create(ws, requireRef(args), { + body: requireArg(args, 'message'), + parent_id: str(args, 'reply_to'), + source: 'web', + }), + 'pad_item:bulk-update': (api, ws, args) => { + const ids = strArray(args, 'refs'); + if (!ids || ids.length === 0) { + throw new Error('refs is required (array of item references)'); + } + const status = str(args, 'status'); + const priority = str(args, 'priority'); + if (status === undefined && priority === undefined) { + throw new Error('bulk-update requires at least one of status / priority'); + } + // The web bulk endpoint applies ONE verb per call (handlers_items_bulk.go): + // status rides the `move` verb, priority the `set-priority` verb. Mirror + // that one-verb-per-request shape (the MCP/CLI bulk-update sets both in a + // single command, but the web endpoint is split — surface the limit + // honestly rather than silently dropping one). + if (status !== undefined && priority !== undefined) { + throw new Error( + 'bulk-update accepts status OR priority per call in the browser, not both', + ); + } + const force = bool(args, 'force') === true ? true : undefined; + if (status !== undefined) { + return api.items.bulk(ws, { op: 'move', ids, status, force }); + } + return api.items.bulk(ws, { op: 'set-priority', ids, priority: priority!, force }); + }, + // import ingests a portable artifact (YAML frontmatter + body). The + // catalog passes the full artifact text in `artifact` (NOT `content`). + 'pad_item:import': (api, ws, args) => + api.importArtifact(ws, requireArg(args, 'artifact')), + + // ── pad_project reads — only dashboard has a browser REST endpoint today. 'pad_project:dashboard': (api, ws) => api.dashboard.get(ws), - // pad_collection reads + // ── pad_collection ── 'pad_collection:list': (api, ws) => api.collections.list(ws), + 'pad_collection:create': (api, ws, args) => + api.collections.create(ws, { + name: requireArg(args, 'name'), + slug: str(args, 'slug'), + prefix: str(args, 'prefix'), + icon: str(args, 'icon'), + description: str(args, 'description'), + schema: collectionSchema(args), + settings: collectionSettings(args), + }), + 'pad_collection:update': (api, ws, args) => + api.collections.update(ws, requireArg(args, 'slug'), { + name: str(args, 'name'), + prefix: str(args, 'prefix'), + icon: str(args, 'icon'), + description: str(args, 'description'), + schema: collectionSchema(args), + settings: collectionSettings(args), + sort_order: num(args, 'sort_order'), + }), + 'pad_collection:delete': (api, ws, args) => + api.collections.delete(ws, requireArg(args, 'slug')), - // pad_role reads + // ── pad_role ── 'pad_role:list': (api, ws) => api.agentRoles.list(ws), + 'pad_role:create': (api, ws, args) => + api.agentRoles.create(ws, { + name: requireArg(args, 'name'), + slug: str(args, 'slug'), + description: str(args, 'description'), + icon: str(args, 'icon'), + tools: str(args, 'tools'), + }), + 'pad_role:update': (api, ws, args) => + api.agentRoles.update(ws, requireArg(args, 'slug'), { + name: str(args, 'name'), + // catalog `new_slug` is the rename target; the web update body's + // `slug` field IS the new slug (the URL path carries the current one). + slug: str(args, 'new_slug'), + description: str(args, 'description'), + icon: str(args, 'icon'), + tools: str(args, 'tools'), + sort_order: num(args, 'sort_order'), + }), + 'pad_role:delete': (api, ws, args) => + api.agentRoles.delete(ws, requireArg(args, 'slug')), - // pad_playbook reads + // ── pad_playbook ── 'pad_playbook:list': (api, ws) => api.playbooks.list(ws), - 'pad_playbook:get': (api, ws, args) => - api.playbooks.get(ws, requireRef(args)), + 'pad_playbook:get': (api, ws, args) => api.playbooks.get(ws, requireRef(args)), + // run is side-effect-free server-side (parses + binds, the agent executes), + // classified read_only in the catalog. + 'pad_playbook:run': (api, ws, args) => + api.playbooks.run(ws, requireRef(args), { + args: (args.args as Record | undefined) ?? undefined, + raw_args: strArray(args, 'raw_args'), + }), - // pad_library reads + // ── pad_library ── 'pad_library:list': (api) => api.library.get(), 'pad_library:get': (api) => api.library.get(), + 'pad_library:activate': (api, ws, args) => + api.library.activateByTitle(ws, requireArg(args, 'title')), - // pad_workspace reads + // ── pad_workspace reads ── 'pad_workspace:list': (api) => api.workspaces.list(), + + // ── pad_meta ── + // bootstrap → GET /workspaces/{ws}/agent/bootstrap (scope addition, + // TASK-1893 comment). Read-only one-shot workspace context. + 'pad_meta:bootstrap': (api, ws) => api.agentBootstrap(ws), }; // ── dispatcher ───────────────────────────────────────────────────────────── @@ -123,7 +551,10 @@ const READ_HANDLERS: Record = { * @param api the singleton api client (injected for testability) * @param wsSlug the ROUTE workspace slug — the only workspace authority * @param isReadOnlyAction `(toolName, action) → boolean` from the descriptor's - * action map (the Go read set, served over the wire) + * action map (the Go read set, served over the wire). + * Retained for parity / future per-read-write + * branching; the route table itself is the source of + * truth for what's wired. * @param toolName e.g. "pad_item" * @param args the raw args object from the agent (includes `action`) * @@ -133,11 +564,13 @@ const READ_HANDLERS: Record = { export async function dispatch( api: Api, wsSlug: string, - isReadOnlyAction: (toolName: string, action: string) => boolean | undefined, + _isReadOnlyAction: (toolName: string, action: string) => boolean | undefined, toolName: string, args: Record, ): Promise { - // DR-4: the workspace is route-bound. Reject any attempt to set it. + // DR-4: the workspace is route-bound. Reject any attempt to set it — even + // when it equals the route slug, so the contract is unambiguous and a + // future route mismatch can't slip through. if ('workspace' in args && args.workspace !== undefined && args.workspace !== '') { return errResult( "the 'workspace' argument is not accepted — WebMCP tools always " + @@ -154,23 +587,12 @@ export async function dispatch( return errResult(`missing required arg 'action' for ${toolName}`); } - const readOnly = isReadOnlyAction(toolName, action); - - // Write actions: recognized but not yet wired (this task is read-only). - // Never a silent no-op — return a precise, actionable error. - if (readOnly === false) { - return errResult( - `${toolName}.${action} is a write action — not yet wired in the ` + - 'browser WebMCP surface (follows in TASK-3b)', - ); - } - const key = `${toolName}:${action}`; - const handler = READ_HANDLERS[key]; + const handler = HANDLERS[key]; if (!handler) { - // A read action the catalog exposes but with no browser REST mapping - // (e.g. pad_project.standup, pad_meta.bootstrap). Honest error, not a - // fake result. + // A catalog action with no browser mapping (e.g. pad_project.standup, + // pending TASK-1894). Honest error, never a fake result or silent + // no-op. return errResult( `${toolName}.${action} is not available in the browser WebMCP surface`, );