From 098d6f45730c54964bd9840d6189ad9449e9175a Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 12:50:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20ActivepiecesRecipeBackend=20?= =?UTF-8?q?=E2=80=94=20recipes=20sync=20over=20HTTPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with flobyteAI PR #694 (Activepieces recipes endpoints). Same agentmark binary now supports two production-ready recipe deployments: - Local-only (default): file-based, single-user laptop - Activepieces-backed: hits the live ThinkFleet recipes API, team- shareable across users / sessions / on-prem deployments Mirrors ActivepiecesMemoryBackend exactly so the wiring story stays uniform: same Bearer sk-* auth, same project + chatbot scope model, same 404→null/false semantics on get/delete. Endpoint contract (one-to-one with flobyteAI PR #694): POST /v1/projects/:projectId/[chatbots/:chatbotId/]recipes ?on_conflict=fail|replace create / replace GET /v1/projects/:projectId/[chatbots/:chatbotId/]recipes ?target_app= list + filter GET /v1/projects/:projectId/[chatbots/:chatbotId/]recipes/:name DELETE /v1/projects/:projectId/[chatbots/:chatbotId/]recipes/:name GET /v1/projects/:projectId/[chatbots/:chatbotId/]recipes/describe Field-name translation on the wire: agentmark `target_app` ↔ Activepieces `targetApp` (snake/camel) Everything else is field-identical. Usage (post-merge of both PRs): import { createRecipesPlugin, ActivepiecesRecipeBackend } from '@thinkfleet/agentmark' const recipes = createRecipesPlugin({ backend: new ActivepiecesRecipeBackend({ baseUrl: process.env.AP_BASE_URL, apiKey: process.env.AP_API_KEY, // sk-... projectId: process.env.AP_PROJECT_ID, chatbotId: process.env.AP_CHATBOT_ID, // optional }), }) Same Claude Code / Cursor / Codex agent now reads and writes recipes to **real Activepieces storage** instead of a local file. Team members in the same project share recipes automatically. Tests (15 new, 506 total): - Construction validation (baseUrl, sk- prefix, projectId) - Auth shape + project/chatbot routing - save() POSTs to /recipes with on_conflict query param - save() translates target_app → targetApp on the wire - save() translates targetApp → target_app on the response - get/delete return null/false on 404 (not throw) - list with target_app builds the query string - non-2xx throws ActivepiecesRecipeError with status + body - describe reports kind=activepieces Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 7 +- src/plugins/recipes/activepieces-backend.ts | 223 ++++++++++++++++++++ src/plugins/recipes/index.ts | 2 + test/recipes/activepieces-backend.test.ts | 205 ++++++++++++++++++ 4 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 src/plugins/recipes/activepieces-backend.ts create mode 100644 test/recipes/activepieces-backend.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index ddf31bd..6d542bf 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -102,11 +102,13 @@ export type { } from '../plugins/system' // Recipes Pack — durable named playbooks the agent learns once + replays. -// Local-file backend only for now; Activepieces recipes endpoints don't -// exist yet so we don't ship a speculative HTTP backend. +// Two production-ready backends: LocalFile (default, single-user) and +// Activepieces (multi-user, hits the live recipes API). export { createRecipesPlugin, LocalFileRecipeBackend, + ActivepiecesRecipeBackend, + ActivepiecesRecipeError, RecipeStore, RECIPES_TOOLS, } from '../plugins/recipes' @@ -119,6 +121,7 @@ export type { RecipeBackend, RecipeBackendDescription, LocalFileRecipeBackendConfig, + ActivepiecesRecipeBackendConfig, } from '../plugins/recipes' // Memory Pack — hierarchical persistent memory for AI agents. Opt-in; diff --git a/src/plugins/recipes/activepieces-backend.ts b/src/plugins/recipes/activepieces-backend.ts new file mode 100644 index 0000000..92ac9aa --- /dev/null +++ b/src/plugins/recipes/activepieces-backend.ts @@ -0,0 +1,223 @@ +/** + * Activepieces-backed recipe backend. + * + * Wires the agentmark Recipes plugin to the production Activepieces + * `clawdbot_recipe` REST API. Same `RecipeBackend` interface as the + * local-file store — the plugin layer + tool handlers don't know or + * care which backend they're talking to. + * + * Endpoint contract (mirrored 1:1 from the Activepieces routes shipped + * in flobyteAI PR #694): + * + * POST /v1/projects/:projectId/[chatbots/:chatbotId/]recipes + * ?on_conflict=fail|replace create / replace + * GET /v1/projects/:projectId/[chatbots/:chatbotId/]recipes + * ?target_app= list + filter + * GET /v1/projects/:projectId/[chatbots/:chatbotId/]recipes/:name + * DELETE /v1/projects/:projectId/[chatbots/:chatbotId/]recipes/:name + * GET /v1/projects/:projectId/[chatbots/:chatbotId/]recipes/describe + * + * Auth: `Authorization: Bearer sk-` (Activepieces Service + * principal). The backend is scoped to one project; pass `chatbotId` + * for chatbot-scoped storage, omit for project-scoped. + * + * Field-name mapping (Activepieces uses `targetApp` camelCase + * server-side; the agentmark Recipe type uses `target_app` to match + * the tool-arg convention): + * - agentmark `target_app` ↔ Activepieces `targetApp` + * Everything else is identical. + */ +import type { RecipeBackend, RecipeBackendDescription } from './backend' +import type { Recipe } from './types' + +export interface ActivepiecesRecipeBackendConfig { + /** Base URL of the Activepieces API (no trailing slash). */ + baseUrl: string + /** API key (must start with `sk-`). */ + apiKey: string + /** Project id this backend operates against. Required. */ + projectId: string + /** Optional chatbot id for chatbot-scoped routes. */ + chatbotId?: string + /** Injectable fetch (for tests). Default: globalThis.fetch. */ + fetch?: typeof fetch + /** Per-request timeout in ms. Default: 15000. */ + timeoutMs?: number +} + +export class ActivepiecesRecipeError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: unknown, + ) { + super(message) + this.name = 'ActivepiecesRecipeError' + } +} + +/** Wire shape on the Activepieces side. Note the camelCase `targetApp`. */ +interface ApRecipe { + id: string + platformId: string + projectId: string + chatbotId: string | null + name: string + description: string | null + targetApp: string | null + parameters: Recipe['parameters'] + steps: Recipe['steps'] + version: number + created: string + updated: string +} + +export class ActivepiecesRecipeBackend implements RecipeBackend { + readonly baseUrl: string + readonly projectId: string + readonly chatbotId?: string + private readonly apiKey: string + private readonly fetcher: typeof fetch + private readonly timeoutMs: number + + constructor(config: ActivepiecesRecipeBackendConfig) { + if (!config.baseUrl) throw new Error('ActivepiecesRecipeBackend: baseUrl is required.') + if (!config.apiKey) throw new Error('ActivepiecesRecipeBackend: apiKey is required.') + if (!config.apiKey.startsWith('sk-')) { + throw new Error('ActivepiecesRecipeBackend: apiKey must start with "sk-".') + } + if (!config.projectId) throw new Error('ActivepiecesRecipeBackend: projectId is required.') + + this.baseUrl = config.baseUrl.replace(/\/+$/, '') + this.apiKey = config.apiKey + this.projectId = config.projectId + this.chatbotId = config.chatbotId + this.fetcher = config.fetch ?? globalThis.fetch + this.timeoutMs = config.timeoutMs ?? 15_000 + } + + async get(name: string): Promise { + try { + const r = await this.request('GET', `${this.recipeRoot()}/${encodeURIComponent(name)}`) + return apToAgentmark(r) + } catch (err) { + if (err instanceof ActivepiecesRecipeError && err.status === 404) return null + throw err + } + } + + async list(filter?: { target_app?: string }): Promise { + const qs = filter?.target_app + ? `?target_app=${encodeURIComponent(filter.target_app)}` + : '' + const results = await this.request('GET', `${this.recipeRoot()}${qs}`) + return results.map(apToAgentmark) + } + + async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise { + const onConflict = options.on_conflict ?? 'fail' + const body = { + name: recipe.name, + description: recipe.description, + targetApp: recipe.target_app, // snake → camel for the wire + parameters: recipe.parameters, + steps: recipe.steps, + } + const saved = await this.request( + 'POST', + `${this.recipeRoot()}?on_conflict=${onConflict}`, + body, + ) + return apToAgentmark(saved) + } + + async delete(name: string): Promise { + try { + const r = await this.request<{ deleted: boolean }>( + 'DELETE', + `${this.recipeRoot()}/${encodeURIComponent(name)}`, + ) + return r.deleted === true + } catch (err) { + if (err instanceof ActivepiecesRecipeError && err.status === 404) return false + throw err + } + } + + async clear(): Promise { + // No bulk-delete endpoint exists; iterate. + const recipes = await this.list() + for (const r of recipes) await this.delete(r.name) + } + + async describe(): Promise { + const remote = await this.request( + 'GET', + `${this.recipeRoot()}/describe`, + ).catch((): undefined => undefined) + return { + kind: 'activepieces', + base_url: this.baseUrl, + project_id: this.projectId, + chatbot_id: this.chatbotId, + ...(remote ?? {}), + } + } + + private recipeRoot(): string { + if (this.chatbotId) { + return `/v1/projects/${encodeURIComponent(this.projectId)}/chatbots/${encodeURIComponent(this.chatbotId)}/recipes` + } + return `/v1/projects/${encodeURIComponent(this.projectId)}/recipes` + } + + private async request(method: string, p: string, body?: unknown): Promise { + const headers: Record = { + authorization: `Bearer ${this.apiKey}`, + } + if (body !== undefined) headers['content-type'] = 'application/json' + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + + let response: Response + try { + response = await this.fetcher(`${this.baseUrl}${p}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + + const text = await response.text() + let parsed: unknown = undefined + if (text.length > 0) { + try { parsed = JSON.parse(text) } catch { parsed = text } + } + + if (!response.ok) { + throw new ActivepiecesRecipeError( + `Activepieces recipe ${method} ${p} failed: ${response.status} ${response.statusText}`, + response.status, + parsed, + ) + } + return parsed as T + } +} + +function apToAgentmark(r: ApRecipe): Recipe { + return { + name: r.name, + description: r.description ?? undefined, + target_app: r.targetApp ?? undefined, + parameters: r.parameters ?? [], + steps: r.steps, + version: r.version, + created_at: r.created, + updated_at: r.updated, + } +} diff --git a/src/plugins/recipes/index.ts b/src/plugins/recipes/index.ts index 5f7ca9d..669bd66 100644 --- a/src/plugins/recipes/index.ts +++ b/src/plugins/recipes/index.ts @@ -149,6 +149,8 @@ function recipeSummary(r: Recipe): Record { export { LocalFileRecipeBackend, RecipeStore } from './store' export type { LocalFileRecipeBackendConfig, RecipeStoreConfig } from './store' +export { ActivepiecesRecipeBackend, ActivepiecesRecipeError } from './activepieces-backend' +export type { ActivepiecesRecipeBackendConfig } from './activepieces-backend' export type { RecipeBackend, RecipeBackendDescription } from './backend' export { resolveRecipe, substitute, applyParameterSchema } from './substitute' export { RECIPES_TOOLS } from './tool-defs' diff --git a/test/recipes/activepieces-backend.test.ts b/test/recipes/activepieces-backend.test.ts new file mode 100644 index 0000000..f55dede --- /dev/null +++ b/test/recipes/activepieces-backend.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for ActivepiecesRecipeBackend. + * Mocks globalThis.fetch; no real network calls. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { ActivepiecesRecipeBackend, ActivepiecesRecipeError } from '../../src/plugins/recipes' +import type { Recipe } from '../../src/plugins/recipes' + +let originalFetch: typeof globalThis.fetch +let requests: Array<{ url: string; init?: RequestInit }> + +beforeEach(() => { + originalFetch = globalThis.fetch + requests = [] +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + requests.push({ url, init }) + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +/** Server-side wire shape (camelCase). */ +function apRecipe(overrides: Record = {}): Record { + return { + id: 'rec_default', + platformId: 'plat_1', + projectId: 'proj_1', + chatbotId: null, + name: 'fill-customer', + description: 'Fill the customer form', + targetApp: 'nowcerts', + parameters: [{ name: 'company', type: 'string', required: true }], + steps: [{ tool: 'agentmark_desktop_execute', args: { action_id: 'act_company' } }], + version: 1, + created: '2026-05-12T00:00:00Z', + updated: '2026-05-12T00:00:00Z', + ...overrides, + } +} + +const baseConfig = { + baseUrl: 'https://app.example.com', + apiKey: 'sk-test-1234', + projectId: 'proj_1', +} + +describe('ActivepiecesRecipeBackend — construction', () => { + it('requires baseUrl + sk-prefixed apiKey + projectId', () => { + expect(() => new ActivepiecesRecipeBackend({ ...baseConfig, baseUrl: '' })).toThrow(/baseUrl/) + expect(() => new ActivepiecesRecipeBackend({ ...baseConfig, apiKey: '' })).toThrow(/apiKey/) + expect(() => new ActivepiecesRecipeBackend({ ...baseConfig, apiKey: 'not-sk' })).toThrow(/sk-/) + expect(() => new ActivepiecesRecipeBackend({ ...baseConfig, projectId: '' })).toThrow(/projectId/) + }) + + it('strips trailing slashes from baseUrl', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesRecipeBackend({ ...baseConfig, baseUrl: 'https://x.com///' }) + await backend.list() + expect(requests[0].url.startsWith('https://x.com/v1/projects/')).toBe(true) + }) +}) + +describe('ActivepiecesRecipeBackend — auth + path routing', () => { + it('attaches Authorization Bearer header on every request', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesRecipeBackend(baseConfig) + await backend.list() + const headers = requests[0].init?.headers as Record + expect(headers.authorization).toBe('Bearer sk-test-1234') + }) + + it('targets project-scoped routes when chatbotId omitted', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesRecipeBackend(baseConfig) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/recipes') + expect(requests[0].url).not.toContain('/chatbots/') + }) + + it('targets chatbot-scoped routes when chatbotId supplied', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesRecipeBackend({ ...baseConfig, chatbotId: 'cb_42' }) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/chatbots/cb_42/recipes') + }) +}) + +describe('ActivepiecesRecipeBackend — save', () => { + it('POSTs to /recipes with on_conflict query + camelCase targetApp', async () => { + mockFetch(() => jsonResponse(apRecipe())) + const backend = new ActivepiecesRecipeBackend(baseConfig) + const recipe: Recipe = { + name: 'fill-customer', + description: 'desc', + target_app: 'nowcerts', + parameters: [], + steps: [{ tool: 'agentmark_desktop_execute', args: {} }], + version: 0, + created_at: '', + updated_at: '', + } + await backend.save(recipe, { on_conflict: 'replace' }) + + expect(requests[0].url).toBe('https://app.example.com/v1/projects/proj_1/recipes?on_conflict=replace') + expect(requests[0].init?.method).toBe('POST') + const body = JSON.parse(requests[0].init?.body as string) + // snake_case → camelCase on the wire + expect(body.targetApp).toBe('nowcerts') + expect(body.target_app).toBeUndefined() + }) + + it('defaults on_conflict to "fail"', async () => { + mockFetch(() => jsonResponse(apRecipe())) + const backend = new ActivepiecesRecipeBackend(baseConfig) + await backend.save({ + name: 'x', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '', + }) + expect(requests[0].url).toContain('on_conflict=fail') + }) + + it('maps the response back to the agentmark Recipe shape (camelCase → snake_case)', async () => { + mockFetch(() => jsonResponse(apRecipe({ targetApp: 'excel' }))) + const backend = new ActivepiecesRecipeBackend(baseConfig) + const r = await backend.save({ + name: 'x', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '', + }) + expect(r.target_app).toBe('excel') // came back as snake_case + }) +}) + +describe('ActivepiecesRecipeBackend — get + list + delete', () => { + it('get returns the mapped recipe', async () => { + mockFetch(() => jsonResponse(apRecipe())) + const backend = new ActivepiecesRecipeBackend(baseConfig) + const r = await backend.get('fill-customer') + expect(r?.name).toBe('fill-customer') + expect(r?.target_app).toBe('nowcerts') + expect(requests[0].url).toContain('/recipes/fill-customer') + }) + + it('get returns null on 404 instead of throwing', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new ActivepiecesRecipeBackend(baseConfig) + const r = await backend.get('missing') + expect(r).toBeNull() + }) + + it('list with target_app builds the query string', async () => { + mockFetch(() => jsonResponse([apRecipe()])) + const backend = new ActivepiecesRecipeBackend(baseConfig) + await backend.list({ target_app: 'excel' }) + expect(requests[0].url).toContain('target_app=excel') + }) + + it('delete returns true on success', async () => { + mockFetch(() => jsonResponse({ deleted: true })) + const backend = new ActivepiecesRecipeBackend(baseConfig) + const ok = await backend.delete('to-go') + expect(ok).toBe(true) + expect(requests[0].init?.method).toBe('DELETE') + }) + + it('delete returns false on 404 (not an error)', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new ActivepiecesRecipeBackend(baseConfig) + expect(await backend.delete('missing')).toBe(false) + }) +}) + +describe('ActivepiecesRecipeBackend — errors + describe', () => { + it('throws ActivepiecesRecipeError on non-2xx with status + body', async () => { + mockFetch(() => jsonResponse({ error: 'forbidden' }, 403)) + const backend = new ActivepiecesRecipeBackend(baseConfig) + try { + await backend.list() + throw new Error('should not reach') + } catch (err) { + expect(err).toBeInstanceOf(ActivepiecesRecipeError) + expect((err as ActivepiecesRecipeError).status).toBe(403) + } + }) + + it('describe reports kind=activepieces + project + chatbot', async () => { + mockFetch(() => new Response('', { status: 404 })) + const backend = new ActivepiecesRecipeBackend({ ...baseConfig, chatbotId: 'cb_x' }) + const desc = await backend.describe() + expect(desc.kind).toBe('activepieces') + expect(desc.project_id).toBe('proj_1') + expect(desc.chatbot_id).toBe('cb_x') + }) +})