mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
feat(web): WebMCP write/mutating dispatch + bootstrap (TASK-1893) (#766)
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
This commit is contained in:
@@ -497,6 +497,24 @@ export const api = {
|
||||
/** Full playbook item by ref / slug / invocation_slug. */
|
||||
get: (ws: string, ref: string) =>
|
||||
request<Item>(`/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<string, unknown>; raw_args?: string[] }
|
||||
) =>
|
||||
request<unknown>(`/workspaces/${ws}/playbooks/${ref}/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body ?? {})
|
||||
}),
|
||||
},
|
||||
|
||||
// ── Workspaces ────────────────────────────────────────────────────────────
|
||||
@@ -1126,6 +1144,20 @@ export const api = {
|
||||
request<DashboardResponse>(`/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<unknown>(`/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<Item> => {
|
||||
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)}`
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof mockApi>, tool: string, args: Record<string, unknown>) {
|
||||
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 () => {
|
||||
|
||||
+469
-47
@@ -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<string, unknown>,
|
||||
@@ -42,31 +54,290 @@ function num(args: Record<string, unknown>, key: string): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bool(args: Record<string, unknown>, 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, unknown>): string {
|
||||
const ref = str(args, 'ref') ?? str(args, 'slug');
|
||||
if (!ref) throw new Error("missing required arg 'ref'");
|
||||
return ref;
|
||||
}
|
||||
|
||||
function requireArg(args: Record<string, unknown>, key: string): string {
|
||||
const v = str(args, key);
|
||||
if (!v) throw new Error(`missing required arg '${key}'`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an array<string> 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<string, unknown>, 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<string, unknown>): Record<string, string> {
|
||||
const raw = args.field;
|
||||
if (raw === undefined || raw === null) return {};
|
||||
// Accept the canonical array<string> 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<string, string> = {};
|
||||
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, unknown>): string | undefined {
|
||||
const fields: Record<string, unknown> = {};
|
||||
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, unknown>): 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<string, unknown>,
|
||||
): 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, unknown>): 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, unknown>): string | undefined {
|
||||
const settings: Record<string, unknown> = {};
|
||||
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<string, ReadHandler> = {
|
||||
// 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<string, LinkRoute> = {
|
||||
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<string, unknown>): {
|
||||
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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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<string, Handler> = {
|
||||
// ── 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<string, ReadHandler> = {
|
||||
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<string, ReadHandler> = {
|
||||
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<string, unknown> | 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<string, ReadHandler> = {
|
||||
* @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<string, ReadHandler> = {
|
||||
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<string, unknown>,
|
||||
): Promise<DispatchResult> {
|
||||
// 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`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user