diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 90df0cf870..2f47e85be6 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto' +import confluenceFixture from '../../fixtures/frontend/global/hub/confluence.json' +import servicenowFixture from '../../fixtures/frontend/global/hub/servicenow.json' +import outreachFixture from '../../fixtures/frontend/global/hub/outreach.json' import type { AppWithLastVersion, CompletedJob, @@ -250,9 +253,7 @@ export function listBenchmarkApps(workspace: string): ListableApp[] | null { } export function getBenchmarkAppByPath(workspace: string, path: string): AppWithLastVersion | null { - const app = benchmarkWorkspaceRunnables - .get(workspace) - ?.apps?.find((entry) => entry.path === path) + const app = benchmarkWorkspaceRunnables.get(workspace)?.apps?.find((entry) => entry.path === path) return app ? buildBenchmarkApp(app) : null } @@ -448,7 +449,10 @@ export function createBenchmarkCompletedJob(input: { labels: input.label ? [input.label] : undefined } - benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job }) + benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { + workspace: input.workspace, + job + }) return jobId } @@ -532,7 +536,13 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { */ const benchmarkDrafts = new Map< string, - { workspace: string; kind: UserDraftItemKind; path: string; value: unknown; createdAt: string } + { + workspace: string + kind: UserDraftItemKind + path: string + value: unknown + createdAt: string + } >() // Counter-based timestamps: deterministic run-to-run (same event order → same @@ -614,7 +624,9 @@ export function getBenchmarkDraftForUser(input: { }): GetDraftForUserResponse { const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path)) if (!entry) { - throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 }) + throw Object.assign(new Error(`no draft for "${input.path}"`), { + status: 404 + }) } return { value: entry.value, created_at: entry.createdAt } } @@ -635,7 +647,11 @@ export function getBenchmarkOwnDraft(input: { /** Whether a deployed benchmark item exists for a draft row's kind+path — * drives `draft_only`, which production computes against the deployed tables. */ -function benchmarkDeployedExists(workspace: string, kind: UserDraftItemKind, path: string): boolean { +function benchmarkDeployedExists( + workspace: string, + kind: UserDraftItemKind, + path: string +): boolean { if (kind === 'script') return Boolean(getBenchmarkScriptByPath(workspace, path)) if (kind === 'flow') return Boolean(getBenchmarkFlowByPath(workspace, path)) if (kind === 'app' || kind === 'raw_app') return Boolean(getBenchmarkAppByPath(workspace, path)) @@ -762,10 +778,11 @@ export function runBenchmarkDatatableSql(input: { * shape `pollJobResult` consumes. The job is created synchronously before * polling, so it is always present and completed. */ -export function getBenchmarkCompletedJobResultMaybe(input: { - workspace: string - id: string -}): { success: boolean; completed: boolean; result: unknown } { +export function getBenchmarkCompletedJobResultMaybe(input: { workspace: string; id: string }): { + success: boolean + completed: boolean + result: unknown +} { const job = getBenchmarkCompletedJob(input.workspace, input.id) if (!job) { throw new Error(`Job "${input.id}" not found in benchmark workspace`) @@ -896,7 +913,9 @@ export function previewBenchmarkSchedule(input: { }): Record { const schedule = input.requestBody?.schedule if (typeof schedule !== 'string' || schedule.trim().split(/\s+/).length !== 6) { - throw new Error(`schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}`) + throw new Error( + `schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}` + ) } return { @@ -928,7 +947,9 @@ export function createBenchmarkHttpTrigger(input: { typeof input.requestBody.route_path === 'string' && input.requestBody.route_path.startsWith('/') ) { - throw new Error(`HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"`) + throw new Error( + `HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"` + ) } return { path: input.requestBody.path, @@ -1024,7 +1045,39 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion { * hub tools throw and no case can exercise hub reuse. Serving fixtures rather * than the live hub also keeps assertions on script content stable as the real * hub republishes new versions. */ +const BENCHMARK_INTEGRATION_META_PATH = /^\/api\/integrations\/hub\/([^/]+)\/meta$/ + +/** One integration lifted verbatim from the content repo — its shipped scripts, its + * authored meta.json and its resource type. Hand-written fixtures make both routes to + * an integration's conventions look equally cheap; a real one is the only way to tell + * whether reading the metadata beats reading a script. */ +const REAL_HUB_INTEGRATIONS = [ + confluenceFixture, + servicenowFixture, + outreachFixture +] as unknown as Array<{ + app: string + display_name: string + description: string + docs_url: string + curated: boolean | null + meta: unknown + resource_type: { name: string; description: string; schema: unknown } + scripts: Array<{ + version_id: number + app: string + summary: string + description: string + terms: string + kind: string + language: string + content: string + schema: unknown + }> +}> + const BENCHMARK_HUB_SCRIPTS = [ + ...REAL_HUB_INTEGRATIONS.flatMap((integration) => integration.scripts), { version_id: 22235, app: 'holded', @@ -1112,6 +1165,87 @@ export async function main(discord_webhook: DiscordWebhook, message: string) { message: { type: 'string' } } } + }, + // Baremetrics carries no annotation script, on purpose: it stands for an + // integration whose conventions (an `apiKey` resource, bearer auth, the /v1 + // base) are only learnable by reading a script that does something else. + { + version_id: 8995, + app: 'baremetrics', + summary: 'List Sources', + description: 'List all the sources attached to a Baremetrics account.', + terms: 'baremetrics sources list revenue metrics', + language: 'bunnative', + content: `//native +type Baremetrics = { + apiKey: string; +}; +export async function main(resource: Baremetrics) { + const response = await fetch("https://api.baremetrics.com/v1/sources", { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: \`Bearer \${resource.apiKey}\`, + }, + }); + if (!response.ok) { + throw new Error(\`\${response.status} \${await response.text()}\`); + } + return await response.json(); +} +`, + schema: { + type: 'object', + required: ['resource'], + properties: { + resource: { type: 'object', format: 'resource-baremetrics' } + } + } + }, + { + version_id: 8996, + app: 'baremetrics', + summary: 'Create Customer', + description: 'Create a customer on a Baremetrics source.', + terms: 'baremetrics customer create source', + language: 'bunnative', + content: `//native +type Baremetrics = { + apiKey: string; +}; +export async function main( + resource: Baremetrics, + sourceId: string, + body: { oid: string; name?: string; email?: string }, +) { + const response = await fetch( + \`https://api.baremetrics.com/v1/\${sourceId}/customers\`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: \`Bearer \${resource.apiKey}\`, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + throw new Error(\`\${response.status} \${await response.text()}\`); + } + return await response.json(); +} +`, + schema: { + type: 'object', + required: ['resource', 'sourceId', 'body'], + properties: { + resource: { type: 'object', format: 'resource-baremetrics' }, + sourceId: { type: 'string' }, + body: { type: 'object' } + } + } } ] @@ -1121,20 +1255,26 @@ export async function main(discord_webhook: DiscordWebhook, message: string) { * integration, or overlapping on three meaningful words. A looser bar answers * "send a Slack message" with the Discord fixture, handing an unrelated case a * plausible-looking wrong integration. */ -function searchBenchmarkHubScripts(text: string) { +function searchBenchmarkHubScripts(text: string, app: string | null, kind: string | null) { const tokens = new Set( text .toLowerCase() .split(/[^a-z0-9]+/) .filter((token) => token.length > 2) ) - return BENCHMARK_HUB_SCRIPTS.map((script) => { - const words = new Set( - `${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/) - ) - const score = [...tokens].filter((token) => words.has(token)).length - return { script, score, namesApp: tokens.has(script.app) } - }) + // The real endpoint filters by app and kind before ranking, so honour both here — + // otherwise a narrowed search silently returns other integrations' scripts, and a + // trigger answers a search production would never have shown it to. + return BENCHMARK_HUB_SCRIPTS.filter( + (script) => (!app || script.app === app) && (!kind || (script.kind ?? 'script') === kind) + ) + .map((script) => { + const words = new Set( + `${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/) + ) + const score = [...tokens].filter((token) => words.has(token)).length + return { script, score, namesApp: tokens.has(script.app) } + }) .filter((entry) => entry.namesApp || entry.score >= 3) .sort((a, b) => b.score - a.score) .map(({ script }, index) => ({ @@ -1142,12 +1282,104 @@ function searchBenchmarkHubScripts(text: string) { id: script.version_id, version_id: script.version_id, summary: script.summary, + description: script.description ?? null, app: script.app, - kind: 'script', + kind: script.kind ?? 'script', score: 1 - index * 0.01 })) } +/** Listing an integration is unranked and description-bearing, matching the hub's + * top-scripts endpoint — that asymmetry with the semantic search is the whole + * reason the chat browses by app when no script matches the task. */ +function listBenchmarkHubScriptsByApp(app: string | null, kind: string | null) { + return BENCHMARK_HUB_SCRIPTS.filter( + (script) => (!app || script.app === app) && (!kind || (script.kind ?? 'script') === kind) + ).map((script) => ({ + id: script.version_id, + ask_id: script.version_id, + version_id: script.version_id, + summary: script.summary, + description: script.description ?? null, + app: script.app, + kind: script.kind ?? 'script', + views: 0, + votes: 0 + })) +} + +/** What `/integrations/hub//meta` serves. Carries the integration's conventions + * and none of the endpoints a case asks the model to write, so the tool shortens the + * path to that knowledge without supplying answers. */ +/** The integrations a case writes against, none of which the content repo documents — + * the same majority shape as the live hub, where 18 of ~216 carry a meta.json. Their + * conventions live in their shipped scripts, which is what these cases exercise. */ +const BENCHMARK_HUB_INTEGRATION_META: Record< + string, + { + display_name: string + description: string + docs_url: string + curated: boolean | null + meta: unknown + } +> = { + baremetrics: { + display_name: 'Baremetrics', + description: 'Subscription analytics for recurring-revenue businesses.', + docs_url: 'https://developers.baremetrics.com/reference', + curated: null, + meta: null + }, + holded: { + display_name: 'Holded', + description: 'Invoicing, accounting and CRM for small businesses.', + docs_url: 'https://developers.holded.com/reference', + curated: null, + meta: null + } +} + +/** Mirrors the hub's own derivation: hosts seen in the shipped scripts, whether they + * call the provider directly, and how many there are of each kind. */ +function benchmarkDerivedFacts(app: string) { + const scripts = BENCHMARK_HUB_SCRIPTS.filter((script) => script.app === app) + const hosts = new Map() + const languages: Record = {} + for (const script of scripts) { + languages[script.language] = (languages[script.language] ?? 0) + 1 + for (const match of script.content.matchAll(/https?:\/\/([a-zA-Z0-9._-]+)/g)) { + hosts.set(match[1], (hosts.get(match[1]) ?? 0) + 1) + } + } + return { + api_hosts: [...hosts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([host, count]) => ({ host, count })), + style: 'fetch', + languages, + script_counts: { + total: scripts.length, + by_kind: scripts.reduce>((acc, script) => { + const kind = script.kind ?? 'script' + acc[kind] = (acc[kind] ?? 0) + 1 + return acc + }, {}) + }, + top_scripts: scripts.map((script) => ({ + path: `hub/${script.version_id}/${script.app}/${script.summary.toLowerCase().replaceAll(/\s+/g, '_')}`, + ask_id: script.version_id, + version_id: script.version_id, + summary: script.summary, + description: script.description ?? null, + kind: script.kind ?? 'script', + language: script.language, + views: 0, + votes: 0 + })) + } +} + /** The hub keys a script by its version id; the app and slug segments that * follow are descriptive, so match on the id exactly as the real hub does. */ function getBenchmarkHubScript(path: string) { @@ -1190,6 +1422,9 @@ export function hasBenchmarkApiHandler(url: string): boolean { return ( path === '/api/workers/list' || path === '/api/embeddings/query_hub_scripts' || + path === '/api/scripts/hub/top' || + path === '/api/integrations/hub/list' || + BENCHMARK_INTEGRATION_META_PATH.test(path) || path.startsWith('/api/scripts/hub/get_full/') || BENCHMARK_AI_MODELS_PATH.test(path) ) @@ -1213,8 +1448,81 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) }) } if (path === '/api/embeddings/query_hub_scripts') { - const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? '' - return Response.json(searchBenchmarkHubScripts(text)) + const params = new URLSearchParams(url.split('?')[1] ?? '') + return Response.json( + searchBenchmarkHubScripts(params.get('text') ?? '', params.get('app'), params.get('kind')) + ) + } + if (path === '/api/scripts/hub/top') { + const params = new URLSearchParams(url.split('?')[1] ?? '') + return Response.json({ + asks: listBenchmarkHubScriptsByApp(params.get('app'), params.get('kind')) + }) + } + const integrationMeta = BENCHMARK_INTEGRATION_META_PATH.exec(path) + if (integrationMeta) { + const app = decodeURIComponent(integrationMeta[1]) + const real = REAL_HUB_INTEGRATIONS.find((integration) => integration.app === app) + if (real) { + return Response.json({ + app, + display_name: real.display_name, + description: real.description, + docs_url: real.docs_url, + curated: real.curated, + metadata_source: real.meta ? 'curated' : 'derived', + meta: real.meta, + meta_updated_at: null, + derived: benchmarkDerivedFacts(app), + resource_types: [{ id: 1, ...real.resource_type }] + }) + } + const entry = BENCHMARK_HUB_INTEGRATION_META[app] + if (!entry) { + return Response.json({ error: 'integration not found' }, { status: 404 }) + } + return Response.json({ + app, + display_name: entry.display_name, + description: entry.description, + docs_url: entry.docs_url, + curated: entry.curated, + metadata_source: entry.meta ? 'curated' : 'derived', + meta: entry.meta, + meta_updated_at: null, + derived: benchmarkDerivedFacts(app), + resource_types: [ + { + id: 1, + name: app, + description: `${entry.display_name} credentials`, + schema: { + type: 'object', + required: ['apiKey'], + properties: { + apiKey: { + type: 'string', + description: `${entry.display_name} API key` + } + } + } + } + ] + }) + } + if (path === '/api/integrations/hub/list') { + const apps = [...new Set(BENCHMARK_HUB_SCRIPTS.map((script) => script.app))].sort() + // Read off both fixtures the metadata endpoint serves from, or the list would + // call an integration undocumented and then hand back its authored notes. + const documented = new Set([ + ...REAL_HUB_INTEGRATIONS.filter((integration) => integration.meta).map( + (integration) => integration.app + ), + ...Object.entries(BENCHMARK_HUB_INTEGRATION_META) + .filter(([, entry]) => entry.meta) + .map(([app]) => app) + ]) + return Response.json(apps.map((name) => ({ name, documented: documented.has(name) }))) } if (path.startsWith('/api/scripts/hub/get_full/')) { const script = getBenchmarkHubScript(path) diff --git a/ai_evals/adapters/frontend/mockBackendApi.test.ts b/ai_evals/adapters/frontend/mockBackendApi.test.ts index 85bd1f3320..b3829e56da 100644 --- a/ai_evals/adapters/frontend/mockBackendApi.test.ts +++ b/ai_evals/adapters/frontend/mockBackendApi.test.ts @@ -16,3 +16,21 @@ describe('benchmark API fetch handlers', () => { expect(body[0]).toMatchObject({ worker: expect.any(String), worker_group: expect.any(String) }) }) }) + +// A benchmark whose list calls an integration undocumented while its metadata endpoint +// hands back authored notes teaches the model the flag means nothing. +describe('benchmark hub integration list', () => { + it('flags exactly the integrations whose metadata carries authored notes', async () => { + const listed = (await handleBenchmarkApiFetch('/api/integrations/hub/list').json()) as Array<{ + name: string + documented: boolean + }> + expect(listed.length).toBeGreaterThan(0) + + for (const { name, documented } of listed) { + const res = handleBenchmarkApiFetch(`/api/integrations/hub/${name}/meta`) + const authored = res.status === 200 && !!((await res.json()) as { meta?: unknown }).meta + expect(authored).toBe(documented) + } + }) +}) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 96aa3ab983..476218a066 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -2312,6 +2312,105 @@ - email skipJudge: true +# `List Pages` takes a cursor without ever following one, so where the next cursor +# comes from (`_links.next`) is stated in the authored metadata and in no script body. +# Paginating is therefore the one task where reading scripts is not an equal route. +- id: global-hub4-confluence-paginate-all-pages + prompt: |- + I need a script that returns every page in one of my Confluence spaces, not just the + first batch — it should keep fetching until there are no more. + Save it as a draft at `f/evals/global/confluence_all_pages` and leave it as an AI + draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/confluence_all_pages + valueIncludes: + - /wiki/ + - Basic + toolExpect: + requiredToolsUsed: + - write_script + # Either route to the integration's conventions is fine; the checklist is where the + # difference between them has to show up. + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the script loops until the API stops returning a next link, rather than making a single request + - the next cursor is read from the response's `_links.next` or the Link header, not from an invented field such as `next_cursor`, `nextPageToken` or `offset` + - it authenticates with HTTP Basic built from the Confluence resource's email and API token rather than a bearer token + - the script takes a Confluence resource as an input parameter rather than loose credential strings + - the result stays an AI draft and is not deployed + +# Unlike the Confluence case, both facts this needs — the {"result"} envelope and +# PATCH-not-PUT — are visible in ServiceNow's own shipped scripts. That makes it the +# control for whether the metadata earns its place when the scripts already answer. +- id: global-hub5-servicenow-assign-incident + prompt: |- + Write me a script that assigns an existing ServiceNow incident to a person, looking + that person up by their email address. + Save it as a draft at `f/evals/global/servicenow_assign_incident` and leave it as an + AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/servicenow_assign_incident + toolExpect: + requiredToolsUsed: + - write_script + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - it looks the person up in the sys_user table by email rather than assuming a sys_id was supplied + - it reads records out of the `result` envelope the ServiceNow API wraps responses in + - it sets assigned_to to the user's sys_id rather than to the raw email address + - it updates the incident with a partial update (PATCH) rather than a full replace that would clear the fields it did not send + - the result stays an AI draft and is not deployed + +# Outreach is JSON:API. Its content type and payload envelope are both stated in the +# metadata and visible in the shipped update scripts, so this is a second control. +- id: global-hub6-outreach-reschedule-task + prompt: |- + Write me a script that moves an existing Outreach task to a new due date. + Save it as a draft at `f/evals/global/outreach_reschedule_task` and leave it as an AI + draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/outreach_reschedule_task + toolExpect: + requiredToolsUsed: + - write_script + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the request sends the JSON:API content type application/vnd.api+json for its body + - the payload wraps the change as data.type and data.attributes rather than sending bare top-level fields + - it identifies the task by id inside the payload rather than only in the URL, and treats the id as a number + - it updates the existing task with PATCH rather than creating a new one + - the result stays an AI draft and is not deployed + # --- Windmill Hub reuse (search_hub_scripts + read_workspace_item on a hub/ path) --- # Holded's API is obscure enough that a model writing from memory cannot reproduce # its endpoint and `key` auth header — so the draft's fidelity to the published @@ -2358,6 +2457,78 @@ - the document type, document id, and recipient emails are inputs to the script - the result stays an AI draft and is not deployed +# Same task as global-hub1 with the hub never mentioned: this is what tells us the +# model reaches for the hub on its own rather than only when pointed at it. +- id: global-hub2-checks-hub-unprompted + prompt: |- + I want to email one of my Holded invoices to a customer from Windmill. + Save it as a draft script at `f/evals/global/holded_send_document` and leave it as + an AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 12 + validate: + draftCountExactly: 1 + requiredDrafts: + # No language assertion: the Holded script's body is `//native`, so a faithful + # fork lands on bun or bunnative depending on how literally it is copied. + - type: script + path: f/evals/global/holded_send_document + valueIncludes: + - api.holded.com/api/invoicing/v1/documents + - mailTemplateId + toolExpect: + requiredToolsUsed: + - search_hub_scripts + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the draft sends an existing Holded document by email rather than creating one + - the request targets Holded's document send endpoint, not an invented URL + - authentication uses Holded's own key header rather than a bearer token + - the result stays an AI draft and is not deployed + +# The fixture hub carries two Baremetrics scripts, neither for annotations, so nothing +# does what is asked. Its conventions — an `apiKey` resource, bearer auth, the +# api.baremetrics.com/v1 base — are only knowable by reading one of those near-misses. +- id: global-hub3-integration-as-example + prompt: |- + Write me a script that adds an annotation to my Baremetrics account. + Save it as a draft at `f/evals/global/baremetrics_create_annotation` and leave it as + an AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + # No language assertion: matching the hub script's own dialect is the point, + # and Baremetrics' scripts are `//native`, so both bun and bunnative are right. + - type: script + path: f/evals/global/baremetrics_create_annotation + valueIncludes: + - api.baremetrics.com/v1 + - apiKey + toolExpect: + requiredToolsUsed: + - search_hub_scripts + - write_script + # Reading a near-miss script and reading the integration's own metadata are two + # routes to the same conventions, so requiring either keeps the case about the + # outcome rather than about which route the model took. + requiredToolsAnyOf: + - - read_workspace_item + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the script takes a Baremetrics resource as an input parameter rather than a bare API key string + - the resource type it declares carries an apiKey field, matching how other Baremetrics hub scripts take credentials + - the request authenticates with a bearer token built from that resource + - the script posts an annotation rather than reading data back + - the result stays an AI draft and is not deployed + # The value of a secret variable is unreadable, so a metadata-only edit must leave # it untouched: passing any `value` here means inventing one, which silently # replaces the real secret at deploy. diff --git a/ai_evals/fixtures/frontend/global/hub/confluence.json b/ai_evals/fixtures/frontend/global/hub/confluence.json new file mode 100644 index 0000000000..29b5c0f11a --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/confluence.json @@ -0,0 +1,983 @@ +{ + "app": "confluence", + "display_name": "Confluence", + "description": "Atlassian's team workspace: pages, blog posts and spaces.", + "docs_url": "https://developer.atlassian.com/cloud/confluence/rest/v2/intro/", + "curated": true, + "meta": { + "api_docs": "https://developer.atlassian.com/cloud/confluence/rest/v2/intro/", + "shipped_surfaces": [ + "pages", + "blogposts", + "spaces", + "search", + "users" + ], + "validation": { + "status": "live_validated", + "method": "smoke test via `bun --env-file=.env.test -e` (+ `bun test` with the windmill-client fake for the trigger) against a free Confluence Cloud site (a test site) with an API token. All 13 actions + the trigger returned 2xx: full page lifecycle (create -> get -> update -> list -> delete), blog post create/update/delete, list_spaces, search_content (CQL), get_current_user, and the new_or_updated_page polling trigger.", + "sources": { + "pages": "HIGH \u2014 Pipedream actions + official v2 OpenAPI; create/get/update/list/delete all run live (2xx)", + "blogposts": "HIGH \u2014 Pipedream actions + official v2 OpenAPI; create/update/delete all run live (2xx)", + "spaces": "HIGH \u2014 Pipedream list-spaces + official v2 OpenAPI; list_spaces run live (2xx)", + "search": "HIGH \u2014 Pipedream search-content + official v1 REST; search_content (CQL) run live (2xx)", + "users": "HIGH \u2014 v1 /wiki/rest/api/user/current run live (2xx) with Basic auth", + "trigger": "HIGH \u2014 new_or_updated_page e2e-tested under bun test with the auto-fake; first run sets the watermark, a subsequent run emits a freshly created page" + }, + "low_confidence_fields": [] + }, + "pagination": { + "pattern": "cursor", + "request_params": { + "cursor": "cursor", + "limit": "limit" + }, + "response_fields": { + "next": "_links.next" + }, + "default_limit": 25, + "notes": "v2 endpoints return an opaque cursor inside the `_links.next` relative URL (?cursor=...) and also in the Link response header. The v1 /search endpoint paginates the same way." + }, + "gotchas": [ + "Two API versions are in play: pages / blog posts / spaces use the v2 REST API (/wiki/api/v2); CQL search and current-user use the v1 REST API (/wiki/rest/api).", + "Auth is HTTP Basic with the Atlassian account email as the username and an API token (NOT the account password) as the password.", + "v2 query params are kebab-case (body-format, created-date, get-draft, root-level); v2 JSON request-body fields are camelCase (spaceId, parentId).", + "Updating a page or blog post requires version.number = currentVersion + 1; the update_* scripts fetch the current version and increment it automatically.", + "spaceId / parentId in v2 request bodies are numeric IDs passed as strings, not space keys.", + "DELETE returns 204 with an empty body and trashes by default; pass purge=true to permanently remove an item that is already in the trash." + ], + "enums": { + "page_status": [ + "current", + "archived", + "deleted", + "trashed", + "draft", + "historical" + ], + "body_representation": [ + "storage", + "atlas_doc_format" + ], + "body_format_read": [ + "storage", + "atlas_doc_format", + "view", + "export_view", + "anonymous_export_view", + "styled_view", + "editor" + ], + "space_type": [ + "global", + "collaboration", + "knowledge_base", + "personal" + ], + "page_sort": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ] + }, + "errors": { + "401": "Authentication failed \u2014 verify the email + API token pair and that the token has not been revoked.", + "404": "Resource not found, or the authenticating account lacks permission to view it (Confluence often returns 404 rather than 403 for content the user cannot access)." + } + }, + "resource_type": { + "name": "confluence", + "description": "Confluence Cloud credentials. Authenticate with an Atlassian account email and an API token (HTTP Basic auth) against your site base URL.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "apiToken": { + "description": "API token for your Atlassian account. Create one at https://id.atlassian.com/manage-profile/security/api-tokens", + "type": "string" + }, + "baseUrl": { + "description": "Base URL of your Confluence Cloud site, e.g. https://your-domain.atlassian.net (no trailing slash, no /wiki).", + "type": "string" + }, + "email": { + "description": "Email of the Atlassian account used to authenticate.", + "type": "string" + } + }, + "required": [ + "email", + "apiToken", + "baseUrl" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28765, + "app": "confluence", + "summary": "Create Blog Post", + "description": "Create a new blog post in a space.", + "terms": "confluence create blog post create a new blog post in a space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Blog Post\n * Create a new blog post in a space. The body is supplied as a content string in the chosen representation (default \"storage\", i.e. Confluence storage HTML).\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n title: string,\n body: string,\n status: \"current\" | \"draft\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts`)\n\n const payload = {\n spaceId: space_id,\n status: status ?? \"current\",\n title,\n body: {\n representation: representation ?? \"storage\",\n value: body,\n },\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id", + "title", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28766, + "app": "confluence", + "summary": "Create Page", + "description": "Create a new page in a space.", + "terms": "confluence create page create a new page in a space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Page\n * Create a new page in a space. The body is supplied as a content string in the chosen representation (default \"storage\", i.e. Confluence storage HTML).\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n title: string,\n body: string,\n status: \"current\" | \"draft\" | undefined,\n parent_id: string | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages`)\n\n const payload = {\n spaceId: space_id,\n status: status ?? \"current\",\n title,\n parentId: parent_id,\n body: {\n representation: representation ?? \"storage\",\n value: body,\n },\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "parent_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id", + "title", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28767, + "app": "confluence", + "summary": "Delete Blog Post", + "description": "Delete a blog post by its ID (moved to trash by default; purge to permanently remove).", + "terms": "confluence delete blog post delete a blog post by its id (moved to trash by default; purge to permanently remove).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Delete Blog Post\n * Delete a blog post by its ID. By default the post is moved to the trash; set purge to permanently remove an already-trashed post.\n */\nexport async function main(\n auth: RT.Confluence,\n blog_post_id: string,\n purge: boolean | undefined,\n draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts/${blog_post_id}`)\n if (purge !== undefined) url.searchParams.append(\"purge\", String(purge))\n if (draft !== undefined) url.searchParams.append(\"draft\", String(draft))\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n if (response.status === 204) return { success: true }\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "blog_post_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "purge": { + "default": null, + "description": "", + "type": "boolean" + } + }, + "required": [ + "auth", + "blog_post_id" + ], + "type": "object" + } + }, + { + "version_id": 28768, + "app": "confluence", + "summary": "Delete Page", + "description": "Delete a page by its ID (moved to trash by default; purge to permanently remove).", + "terms": "confluence delete page delete a page by its id (moved to trash by default; purge to permanently remove).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Delete Page\n * Delete a page by its ID. By default the page is moved to the trash; set purge to permanently remove an already-trashed page.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n purge: boolean | undefined,\n draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages/${page_id}`)\n if (purge !== undefined) url.searchParams.append(\"purge\", String(purge))\n if (draft !== undefined) url.searchParams.append(\"draft\", String(draft))\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n if (response.status === 204) return { success: true }\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "purge": { + "default": null, + "description": "", + "type": "boolean" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28769, + "app": "confluence", + "summary": "Get Current User", + "description": "Returns the profile of the user that the API token authenticates as.", + "terms": "confluence get current user returns the profile of the user that the api token authenticates as.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Current User\n * Returns the profile of the user that the API token authenticates as (account ID, display name, email).\n */\nexport async function main(auth: RT.Confluence) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/rest/api/user/current`)\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28770, + "app": "confluence", + "summary": "Get Page by ID", + "description": "Retrieve a single page by its ID, optionally returning its body in a specific format or a previous version.", + "terms": "confluence get page by id retrieve a single page by its id, optionally returning its body in a specific format or a previous version.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Page by ID\n * Retrieve a single page by its ID, optionally returning its body in a specific format or a previous version.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n body_format:\n | \"storage\"\n | \"atlas_doc_format\"\n | \"view\"\n | \"export_view\"\n | \"anonymous_export_view\"\n | \"styled_view\"\n | \"editor\"\n | undefined,\n version: number | undefined,\n get_draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages/${page_id}`)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (version !== undefined) url.searchParams.append(\"version\", String(version))\n if (get_draft !== undefined)\n url.searchParams.append(\"get-draft\", String(get_draft))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format", + "view", + "export_view", + "anonymous_export_view", + "styled_view", + "editor" + ], + "originalType": "enum", + "type": "string" + }, + "get_draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28771, + "app": "confluence", + "summary": "Get Pages in Space", + "description": "Retrieve a paginated list of pages within a given space.", + "terms": "confluence get pages in space retrieve a paginated list of pages within a given space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Pages in Space\n * Retrieve a paginated list of pages within a given space.\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | \"title\"\n | \"-title\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/spaces/${space_id}/pages`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id" + ], + "type": "object" + } + }, + { + "version_id": 28772, + "app": "confluence", + "summary": "List Blog Posts", + "description": "Retrieve a paginated list of blog posts on the site, with optional filtering and sorting.", + "terms": "confluence list blog posts retrieve a paginated list of blog posts on the site, with optional filtering and sorting.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Blog Posts\n * Retrieve a paginated list of blog posts on the site, with optional filtering and sorting.\n */\nexport async function main(\n auth: RT.Confluence,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28773, + "app": "confluence", + "summary": "List Pages", + "description": "Retrieve a paginated list of pages on the site, with optional filtering and sorting.", + "terms": "confluence list pages retrieve a paginated list of pages on the site, with optional filtering and sorting.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Pages\n * Retrieve a paginated list of pages on the site, with optional filtering and sorting.\n */\nexport async function main(\n auth: RT.Confluence,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | \"title\"\n | \"-title\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28774, + "app": "confluence", + "summary": "List Spaces", + "description": "Retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.", + "terms": "confluence list spaces retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Spaces\n * Retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.\n */\nexport async function main(\n auth: RT.Confluence,\n keys: string[] | undefined,\n type: \"global\" | \"collaboration\" | \"knowledge_base\" | \"personal\" | undefined,\n status: \"current\" | \"archived\" | undefined,\n sort: \"id\" | \"-id\" | \"key\" | \"-key\" | \"name\" | \"-name\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/spaces`)\n if (keys !== undefined && keys.length > 0)\n url.searchParams.append(\"keys\", keys.join(\",\"))\n if (type !== undefined) url.searchParams.append(\"type\", type)\n if (status !== undefined) url.searchParams.append(\"status\", status)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "keys": { + "default": null, + "description": "", + "items": { + "type": "string" + }, + "originalType": "string[]", + "type": "array" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "key", + "-key", + "name", + "-name" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "type": { + "default": null, + "description": "", + "enum": [ + "global", + "collaboration", + "knowledge_base", + "personal" + ], + "originalType": "enum", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28775, + "app": "confluence", + "summary": "Search Content (CQL)", + "description": "Search for content using the Confluence Query Language (CQL).", + "terms": "confluence search content (cql) search for content using the confluence query language (cql).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Search Content (CQL)\n * Search for content using the Confluence Query Language (CQL), e.g. `type=page AND space=ENG AND text ~ \"release notes\"`.\n */\nexport async function main(\n auth: RT.Confluence,\n cql: string,\n cursor: string | undefined,\n limit: number | undefined,\n expand: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/rest/api/search`)\n url.searchParams.append(\"cql\", cql)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n if (expand !== undefined && expand !== \"\")\n url.searchParams.append(\"expand\", expand)\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "cql": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "expand": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "cql" + ], + "type": "object" + } + }, + { + "version_id": 28776, + "app": "confluence", + "summary": "Update Blog Post", + "description": "Update a blog post's title, body, or status. The current version is fetched automatically and incremented.", + "terms": "confluence update blog post update a blog post's title, body, or status. the current version is fetched automatically and incremented.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Blog Post\n * Update a blog post's title, body, or status. The current version is fetched automatically and incremented; omitted fields keep their existing values.\n */\nexport async function main(\n auth: RT.Confluence,\n blog_post_id: string,\n title: string | undefined,\n body: string | undefined,\n status: \"current\" | \"draft\" | \"archived\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined,\n version_message: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const repr = representation ?? \"storage\"\n const authHeader = \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`)\n\n // Fetch the current blog post to obtain the version number and any fields not being changed.\n const currentRes = await fetch(\n `${base}/wiki/api/v2/blogposts/${blog_post_id}?body-format=${repr}`,\n {\n method: \"GET\",\n headers: { Authorization: authHeader, Accept: \"application/json\" },\n }\n )\n if (!currentRes.ok) {\n throw new Error(`${currentRes.status} ${await currentRes.text()}`)\n }\n const current = (await currentRes.json()) as {\n title: string\n status: string\n version: { number: number }\n body?: { [key: string]: { value?: string } }\n }\n\n const payload = {\n id: blog_post_id,\n status: status ?? current.status,\n title: title ?? current.title,\n body: {\n representation: repr,\n value: body ?? current.body?.[repr]?.value ?? \"\",\n },\n version: {\n number: current.version.number + 1,\n message: version_message,\n },\n }\n\n const response = await fetch(\n `${base}/wiki/api/v2/blogposts/${blog_post_id}`,\n {\n method: \"PUT\",\n headers: {\n Authorization: authHeader,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "blog_post_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version_message": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "blog_post_id" + ], + "type": "object" + } + }, + { + "version_id": 28777, + "app": "confluence", + "summary": "Update Page", + "description": "Update a page's title, body, or status. The current version is fetched automatically and incremented.", + "terms": "confluence update page update a page's title, body, or status. the current version is fetched automatically and incremented.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Page\n * Update a page's title, body, or status. The current version is fetched automatically and incremented; omitted fields keep their existing values.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n title: string | undefined,\n body: string | undefined,\n status: \"current\" | \"draft\" | \"archived\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined,\n version_message: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const repr = representation ?? \"storage\"\n const authHeader = \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`)\n\n // Fetch the current page to obtain the version number and any fields not being changed.\n const currentRes = await fetch(\n `${base}/wiki/api/v2/pages/${page_id}?body-format=${repr}`,\n {\n method: \"GET\",\n headers: { Authorization: authHeader, Accept: \"application/json\" },\n }\n )\n if (!currentRes.ok) {\n throw new Error(`${currentRes.status} ${await currentRes.text()}`)\n }\n const current = (await currentRes.json()) as {\n title: string\n status: string\n version: { number: number }\n body?: { [key: string]: { value?: string } }\n }\n\n const payload = {\n id: page_id,\n status: status ?? current.status,\n title: title ?? current.title,\n body: {\n representation: repr,\n value: body ?? current.body?.[repr]?.value ?? \"\",\n },\n version: {\n number: current.version.number + 1,\n message: version_message,\n },\n }\n\n const response = await fetch(`${base}/wiki/api/v2/pages/${page_id}`, {\n method: \"PUT\",\n headers: {\n Authorization: authHeader,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version_message": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28778, + "app": "confluence", + "summary": "New or Updated Page", + "description": "Emits pages created or updated since the last poll, tracked via Windmill state. Optionally restrict to a single space.", + "terms": "confluence new or updated page emits pages created or updated since the last poll, tracked via windmill state. optionally restrict to a single space.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New or Updated Page\n * Emits pages created or updated since the last poll, tracked via Windmill state. Optionally restrict to a single space.\n */\nexport async function main(auth: RT.Confluence, space_id: string | undefined) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const lastChecked: number = (await wmill.getState()) ?? 0\n\n const path = space_id\n ? `/wiki/api/v2/spaces/${space_id}/pages`\n : `/wiki/api/v2/pages`\n const url = new URL(`${base}${path}`)\n url.searchParams.append(\"sort\", \"-modified-date\")\n url.searchParams.append(\"limit\", \"50\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const data = (await response.json()) as {\n results?: { version?: { createdAt?: string } }[]\n }\n const pages = data.results ?? []\n\n const ts = (p: { version?: { createdAt?: string } }) =>\n p.version?.createdAt ? Date.parse(p.version.createdAt) : 0\n const maxTs = pages.reduce((m, p) => Math.max(m, ts(p)), 0)\n\n // First run: set the watermark to the newest page seen and don't emit a backlog.\n if (!lastChecked) {\n if (maxTs) await wmill.setState(maxTs)\n return []\n }\n\n const fresh = pages\n .filter((p) => ts(p) > lastChecked)\n .sort((a, b) => ts(a) - ts(b))\n\n if (maxTs > lastChecked) {\n await wmill.setState(maxTs)\n }\n\n return fresh\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/fixtures/frontend/global/hub/outreach.json b/ai_evals/fixtures/frontend/global/hub/outreach.json new file mode 100644 index 0000000000..b63aad6849 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/outreach.json @@ -0,0 +1,1055 @@ +{ + "app": "outreach", + "display_name": "Outreach", + "description": "Sales engagement: prospects, sequences and tasks.", + "docs_url": "https://developers.outreach.io/api/", + "curated": true, + "meta": { + "api_docs": "https://developers.outreach.io/api/", + "shipped_surfaces": [ + "prospects", + "accounts", + "sequences", + "sequence_states", + "mailings", + "tasks", + "mailboxes" + ], + "validation": { + "status": "not_live_validated", + "method": "static \u2014 shapes derived from the official OpenAPI spec (https://api.outreach.io/api/v2/schema/openapi.json) + Pipedream actions; filters/pagination cross-checked against the spec's per-relationship filter notes and Airbyte source-outreach's live incremental-sync params; smoke test blocked: Outreach has no self-serve signup (demo-request only) and the developer portal requires an existing org admin account. Production-connector cross-validation pass (2026-06): checked ActivePieces (no outreach piece), n8n (no Outreach node), Microsoft PowerPlatformConnectors (no Outreach connector); corroborated against estuary/connectors source-outreach (newFilterSyntax [gte]/[lte] datetime cursors, page[size]/page[after] from links.next), singer-io/tap-outreach a.k.a. Stitch (old-syntax filter[updatedAt]=..inf + sort + count=false, links.next pagination), Supaglue (supaglue-labs, via fork: old-syntax ..inf with millisecond ISO datetimes; JSON:API create bodies for account/prospect/sequenceState incl. emails[] attribute and prospect/sequence/mailbox relationships), and friggframework api-module-library (task create/update with polymorphic subject relationship, live-test fixture). No contradictions found; no script changes required.", + "sources": { + "prospects": "MEDIUM \u2014 official OpenAPI spec + Pipedream create-prospect action; create body (attributes incl. emails[], account relationship) corroborated by Supaglue's production mapper; list filters corroborated by the Stitch tap; not run live", + "accounts": "MEDIUM \u2014 official OpenAPI spec + Pipedream create-account action; create body (name/domain attributes, owner relationship) corroborated by Supaglue; list filters corroborated by the Stitch tap; not run live", + "sequences": "MEDIUM \u2014 official OpenAPI spec; reads corroborated by Supaglue (sequences/sequenceSteps) and the Stitch tap; not run live", + "sequence_states": "MEDIUM \u2014 official OpenAPI spec + Pipedream add-prospect-sequence action; create body (prospect/sequence/mailbox relationships, mailbox required) matches Supaglue's production mapper exactly; not run live", + "mailings": "MEDIUM \u2014 official OpenAPI spec; list + updatedAt filter corroborated by the Stitch tap and Estuary; not run live", + "tasks": "MEDIUM \u2014 official OpenAPI spec; polymorphic subject relationship on create/update corroborated by friggframework's live-test fixture; list filters corroborated by the Stitch tap; not run live", + "mailboxes": "MEDIUM \u2014 official OpenAPI spec + Pipedream mailbox options resolver; list corroborated by the Stitch tap; not run live" + }, + "low_confidence_fields": [] + }, + "pagination": { + "pattern": "cursor", + "request_params": { + "limit": "page[size]", + "cursor": "page[after]" + }, + "response_fields": { + "next": "links.next (full URL; the page[after] value is its query param)" + }, + "default_limit": 50 + }, + "gotchas": [ + "JSON:API \u2014 every request with a body must send Content-Type: application/vnd.api+json or the API returns 415.", + "Write payloads wrap fields as { data: { type, attributes, relationships } }; ids are integers.", + "Filters: filter[attr]=value; multi filter[id]=1,2,3; ranges filter[updatedAt]=2024-01-01..inf (full ISO datetimes work too \u2014 Stitch's tap and Supaglue both send filter[updatedAt]=..inf in production; range bounds are inclusive); relationship filters filter[prospect][id]=123 (only [id] is reliably supported \u2014 attribute-based relationship filters were deprecated May 2023); sort=-createdAt for descending.", + "New filter syntax (newFilterSyntax=true) replaces ranges with filter[attr][gte]/[lte] and value lists with filter[attr][]=v \u2014 required when values contain ',' or '..'; Airbyte's connector uses it for datetime cursors, so prefer it for timestamp comparisons.", + "Task\u2194prospect association is set via the polymorphic `subject` relationship (type account|opportunity|prospect); the `prospect` relationship on tasks is readonly/derived.", + "Action endpoints (POST \u2026/tasks/{id}/actions/markComplete, \u2026/sequenceStates/{id}/actions/{pause|resume|finish}) take no request body and return 200 with the updated resource.", + "OAuth access tokens last 2h; refresh is grant_type=refresh_token at the same https://api.outreach.io/oauth/token endpoint, so Windmill auto-refresh works.", + "Scopes are ., e.g. prospects.all sequenceStates.all. All scopes registered in oauth_connect.json are enumerated verbatim in Outreach's official SDK (getoutreach/extensibility-sdk, src/manifest/api/Scopes.ts)." + ], + "enums": { + "task_action": [ + "action_item", + "call", + "email", + "in_person" + ], + "task_state": [ + "pending", + "incomplete", + "complete" + ], + "mailing_state": [ + "bounced", + "delivered", + "delivering", + "drafted", + "failed", + "opened", + "placeholder", + "queued", + "replied", + "scheduled" + ] + }, + "errors": { + "415": "Missing/wrong Content-Type \u2014 JSON:API requires application/vnd.api+json on requests with a body.", + "502": "Bad Gateway on list endpoints usually means the requested page[size] was too large to serve in time \u2014 retry with a smaller page[size] (Estuary's connector halves it and retries).", + "422": "Validation error \u2014 response carries errors[] with title/detail and a source.pointer to the offending attribute." + } + }, + "resource_type": { + "name": "outreach", + "description": "OAuth2 access token for the Outreach REST API (api.outreach.io). Connect with OAuth so the token refreshes automatically \u2014 Outreach has no long-lived API keys.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "token": { + "description": "OAuth2 access token, filled automatically when you connect with OAuth (recommended \u2014 tokens are refreshed for you). Pasting a token minted from your own Outreach OAuth app (https://developers.outreach.io/api/oauth/) also works but it expires after 2 hours, so only do that for one-off testing.", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28686, + "app": "outreach", + "summary": "Add Prospect to Sequence", + "description": "Adds an existing prospect to a sequence, sending from the selected mailbox.", + "terms": "outreach add prospect to sequence adds an existing prospect to a sequence, sending from the selected mailbox.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_sequence = string\n\n// Resolver: lists the org's sequences for the dropdown.\nexport async function sequence(auth: RT.Outreach) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequences\")\n url.searchParams.append(\"sort\", \"name\")\n url.searchParams.append(\"page[size]\", \"100\")\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { name: string } }[]\n }\n return data.map((s) => ({ value: String(s.id), label: s.attributes.name }))\n}\n\nexport type DynSelect_mailbox = string\n\n// Resolver: lists the org's sending mailboxes for the dropdown.\nexport async function mailbox(auth: RT.Outreach) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailboxes\")\n url.searchParams.append(\"page[size]\", \"100\")\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { email: string } }[]\n }\n return data.map((m) => ({ value: String(m.id), label: m.attributes.email }))\n}\n\n/**\n * Add Prospect to Sequence\n * Adds an existing prospect to a sequence, sending from the selected mailbox.\n */\nexport async function main(\n auth: RT.Outreach,\n prospect_id: number,\n sequence: DynSelect_sequence,\n mailbox: DynSelect_mailbox\n) {\n const response = await fetch(\n \"https://api.outreach.io/api/v2/sequenceStates\",\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: {\n type: \"sequenceState\",\n relationships: {\n prospect: { data: { type: \"prospect\", id: prospect_id } },\n sequence: { data: { type: \"sequence\", id: Number(sequence) } },\n mailbox: { data: { type: \"mailbox\", id: Number(mailbox) } },\n },\n },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "mailbox": { + "default": null, + "description": "", + "format": "dynselect-mailbox", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "sequence": { + "default": null, + "description": "", + "format": "dynselect-sequence", + "type": "object" + } + }, + "required": [ + "auth", + "prospect_id", + "sequence", + "mailbox" + ], + "type": "object" + } + }, + { + "version_id": 28687, + "app": "outreach", + "summary": "Complete Task", + "description": "Marks a task as complete.", + "terms": "outreach complete task marks a task as complete.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Complete Task\n * Marks a task as complete.\n */\nexport async function main(auth: RT.Outreach, task_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/tasks/${task_id}/actions/markComplete`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "task_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "task_id" + ], + "type": "object" + } + }, + { + "version_id": 28688, + "app": "outreach", + "summary": "Create Account", + "description": "Creates a company account in Outreach.", + "terms": "outreach create account creates a company account in outreach.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Account\n * Creates a company account in Outreach.\n */\nexport async function main(\n auth: RT.Outreach,\n name: string,\n domain: string | undefined,\n additional_attributes: { [key: string]: any } | undefined\n) {\n const attributes: { [key: string]: any } = { ...additional_attributes, name }\n if (domain !== undefined && domain !== \"\") {\n attributes.domain = domain\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/accounts\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data: { type: \"account\", attributes } }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "additional_attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "domain": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "name" + ], + "type": "object" + } + }, + { + "version_id": 28689, + "app": "outreach", + "summary": "Create Prospect", + "description": "Creates a new prospect in Outreach, optionally linked to an account.", + "terms": "outreach create prospect creates a new prospect in outreach, optionally linked to an account.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Prospect\n * Creates a new prospect in Outreach, optionally linked to an account.\n */\nexport async function main(\n auth: RT.Outreach,\n first_name: string | undefined,\n last_name: string | undefined,\n emails: string[] | undefined,\n title: string | undefined,\n company: string | undefined,\n account_id: number | undefined,\n additional_attributes: { [key: string]: any } | undefined\n) {\n const attributes: { [key: string]: any } = { ...additional_attributes }\n if (first_name !== undefined && first_name !== \"\") {\n attributes.firstName = first_name\n }\n if (last_name !== undefined && last_name !== \"\") {\n attributes.lastName = last_name\n }\n if (emails !== undefined && emails.length > 0) {\n attributes.emails = emails\n }\n if (title !== undefined && title !== \"\") {\n attributes.title = title\n }\n if (company !== undefined && company !== \"\") {\n attributes.company = company\n }\n\n const data: { [key: string]: any } = { type: \"prospect\", attributes }\n if (account_id !== undefined) {\n data.relationships = {\n account: { data: { type: \"account\", id: account_id } },\n }\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/prospects\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "additional_attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "company": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "emails": { + "default": null, + "description": "", + "items": { + "type": "string" + }, + "originalType": "string[]", + "type": "array" + }, + "first_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "last_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28690, + "app": "outreach", + "summary": "Create Task", + "description": "Creates a manual task, optionally tied to a prospect and assigned to an owner.", + "terms": "outreach create task creates a manual task, optionally tied to a prospect and assigned to an owner.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Task\n * Creates a manual task, optionally tied to a prospect and assigned to an owner.\n */\nexport async function main(\n auth: RT.Outreach,\n action: \"action_item\" | \"call\" | \"email\" | \"in_person\",\n due_at: string,\n note: string | undefined,\n prospect_id: number | undefined,\n owner_id: number | undefined\n) {\n const attributes: { [key: string]: any } = { action, dueAt: due_at }\n if (note !== undefined && note !== \"\") {\n attributes.note = note\n }\n\n const relationships: { [key: string]: any } = {}\n if (prospect_id !== undefined) {\n // the writable association is `subject`; the `prospect` relationship is readonly\n relationships.subject = { data: { type: \"prospect\", id: prospect_id } }\n }\n if (owner_id !== undefined) {\n relationships.owner = { data: { type: \"user\", id: owner_id } }\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/tasks\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data: { type: \"task\", attributes, relationships } }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "", + "enum": [ + "action_item", + "call", + "email", + "in_person" + ], + "originalType": "enum", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "due_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "note": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "owner_id": { + "default": null, + "description": "", + "type": "number" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "action", + "due_at" + ], + "type": "object" + } + }, + { + "version_id": 28691, + "app": "outreach", + "summary": "Get Account", + "description": "Retrieves a single account by its ID.", + "terms": "outreach get account retrieves a single account by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Account\n * Retrieves a single account by its ID.\n */\nexport async function main(auth: RT.Outreach, account_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/accounts/${account_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth", + "account_id" + ], + "type": "object" + } + }, + { + "version_id": 28692, + "app": "outreach", + "summary": "Get Mailing", + "description": "Retrieves a single mailing by its ID, including engagement attributes.", + "terms": "outreach get mailing retrieves a single mailing by its id, including engagement attributes.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Mailing\n * Retrieves a single mailing by its ID, including engagement attributes (opens, clicks, replies).\n */\nexport async function main(auth: RT.Outreach, mailing_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/mailings/${mailing_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "mailing_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "mailing_id" + ], + "type": "object" + } + }, + { + "version_id": 28693, + "app": "outreach", + "summary": "Get Prospect", + "description": "Retrieves a single prospect by its ID.", + "terms": "outreach get prospect retrieves a single prospect by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Prospect\n * Retrieves a single prospect by its ID.\n */\nexport async function main(auth: RT.Outreach, prospect_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/prospects/${prospect_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "prospect_id" + ], + "type": "object" + } + }, + { + "version_id": 28694, + "app": "outreach", + "summary": "Get Sequence", + "description": "Retrieves a single sequence by its ID.", + "terms": "outreach get sequence retrieves a single sequence by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Sequence\n * Retrieves a single sequence by its ID.\n */\nexport async function main(auth: RT.Outreach, sequence_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/sequences/${sequence_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "sequence_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "sequence_id" + ], + "type": "object" + } + }, + { + "version_id": 28695, + "app": "outreach", + "summary": "List Accounts", + "description": "Lists accounts with optional filters and cursor pagination.", + "terms": "outreach list accounts lists accounts with optional filters and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Accounts\n * Lists accounts with optional filters (name, domain, updatedAt range like \"2024-01-01..inf\") and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_name: string | undefined,\n filter_domain: string | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/accounts\")\n if (filter_name !== undefined && filter_name !== \"\") {\n url.searchParams.append(\"filter[name]\", filter_name)\n }\n if (filter_domain !== undefined && filter_domain !== \"\") {\n url.searchParams.append(\"filter[domain]\", filter_domain)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_domain": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28696, + "app": "outreach", + "summary": "List Mailboxes", + "description": "Lists the org's sending mailboxes, optionally filtered by email address.", + "terms": "outreach list mailboxes lists the org's sending mailboxes, optionally filtered by email address.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Mailboxes\n * Lists the org's sending mailboxes, optionally filtered by email address.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_email: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailboxes\")\n if (filter_email !== undefined && filter_email !== \"\") {\n url.searchParams.append(\"filter[email]\", filter_email)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_email": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28697, + "app": "outreach", + "summary": "List Mailings", + "description": "Lists mailings (sent/scheduled emails), filterable by prospect, state or updatedAt range.", + "terms": "outreach list mailings lists mailings (sent/scheduled emails), filterable by prospect, state or updatedat range.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Mailings\n * Lists mailings (sent/scheduled emails), filterable by prospect, state or updatedAt range.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_prospect_id: number | undefined,\n filter_state:\n | (\n | \"bounced\"\n | \"delivered\"\n | \"delivering\"\n | \"drafted\"\n | \"failed\"\n | \"opened\"\n | \"placeholder\"\n | \"queued\"\n | \"replied\"\n | \"scheduled\"\n )\n | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailings\")\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_state !== undefined) {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "enum": [ + "bounced", + "delivered", + "delivering", + "drafted", + "failed", + "opened", + "placeholder", + "queued", + "replied", + "scheduled" + ], + "originalType": "enum", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28698, + "app": "outreach", + "summary": "List Prospects", + "description": "Lists prospects with optional filters and cursor pagination.", + "terms": "outreach list prospects lists prospects with optional filters and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Prospects\n * Lists prospects with optional filters (email, name, updatedAt range like \"2024-01-01..inf\") and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_emails: string | undefined,\n filter_first_name: string | undefined,\n filter_last_name: string | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/prospects\")\n if (filter_emails !== undefined && filter_emails !== \"\") {\n url.searchParams.append(\"filter[emails]\", filter_emails)\n }\n if (filter_first_name !== undefined && filter_first_name !== \"\") {\n url.searchParams.append(\"filter[firstName]\", filter_first_name)\n }\n if (filter_last_name !== undefined && filter_last_name !== \"\") {\n url.searchParams.append(\"filter[lastName]\", filter_last_name)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_emails": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_first_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_last_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28699, + "app": "outreach", + "summary": "List Sequence States", + "description": "Lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.", + "terms": "outreach list sequence states lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Sequence States\n * Lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_prospect_id: number | undefined,\n filter_sequence_id: number | undefined,\n filter_state: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequenceStates\")\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_sequence_id !== undefined) {\n url.searchParams.append(\"filter[sequence][id]\", String(filter_sequence_id))\n }\n if (filter_state !== undefined && filter_state !== \"\") {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_sequence_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28700, + "app": "outreach", + "summary": "List Sequences", + "description": "Lists sequences with an optional name filter and cursor pagination.", + "terms": "outreach list sequences lists sequences with an optional name filter and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Sequences\n * Lists sequences with an optional name filter and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_name: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequences\")\n if (filter_name !== undefined && filter_name !== \"\") {\n url.searchParams.append(\"filter[name]\", filter_name)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28701, + "app": "outreach", + "summary": "List Tasks", + "description": "Lists tasks, filterable by state, task type, prospect or dueAt range.", + "terms": "outreach list tasks lists tasks, filterable by state, task type, prospect or dueat range.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Tasks\n * Lists tasks, filterable by state, task type, prospect or dueAt range (e.g. \"2024-01-01..inf\").\n */\nexport async function main(\n auth: RT.Outreach,\n filter_state: (\"pending\" | \"incomplete\" | \"complete\") | undefined,\n filter_task_type: string | undefined,\n filter_prospect_id: number | undefined,\n filter_due_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/tasks\")\n if (filter_state !== undefined) {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (filter_task_type !== undefined && filter_task_type !== \"\") {\n url.searchParams.append(\"filter[taskType]\", filter_task_type)\n }\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_due_at !== undefined && filter_due_at !== \"\") {\n url.searchParams.append(\"filter[dueAt]\", filter_due_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_due_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "enum": [ + "pending", + "incomplete", + "complete" + ], + "originalType": "enum", + "type": "string" + }, + "filter_task_type": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28702, + "app": "outreach", + "summary": "Pause, Resume or Finish Sequence State", + "description": "Performs a lifecycle action on a prospect's sequence state.", + "terms": "outreach pause, resume or finish sequence state performs a lifecycle action on a prospect's sequence state.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Pause, Resume or Finish Sequence State\n * Performs a lifecycle action on a prospect's sequence state.\n */\nexport async function main(\n auth: RT.Outreach,\n sequence_state_id: number,\n action: \"pause\" | \"resume\" | \"finish\"\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/sequenceStates/${sequence_state_id}/actions/${action}`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "", + "enum": [ + "pause", + "resume", + "finish" + ], + "originalType": "enum", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "sequence_state_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "sequence_state_id", + "action" + ], + "type": "object" + } + }, + { + "version_id": 28703, + "app": "outreach", + "summary": "Update Account", + "description": "Updates attributes of an existing account.", + "terms": "outreach update account updates attributes of an existing account.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Account\n * Updates attributes of an existing account (e.g. { \"industry\": \"Manufacturing\" }).\n */\nexport async function main(\n auth: RT.Outreach,\n account_id: number,\n attributes: { [key: string]: any }\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/accounts/${account_id}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: { type: \"account\", id: account_id, attributes },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth", + "account_id", + "attributes" + ], + "type": "object" + } + }, + { + "version_id": 28704, + "app": "outreach", + "summary": "Update Prospect", + "description": "Updates attributes of an existing prospect.", + "terms": "outreach update prospect updates attributes of an existing prospect.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Prospect\n * Updates attributes of an existing prospect (e.g. { \"title\": \"VP Sales\", \"occupation\": \"Sales\" }).\n */\nexport async function main(\n auth: RT.Outreach,\n prospect_id: number,\n attributes: { [key: string]: any }\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/prospects/${prospect_id}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: { type: \"prospect\", id: prospect_id, attributes },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "prospect_id", + "attributes" + ], + "type": "object" + } + }, + { + "version_id": 28705, + "app": "outreach", + "summary": "New Mailing", + "description": "Returns mailings created since the last run.", + "terms": "outreach new mailing returns mailings created since the last run.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New Mailing\n * Returns mailings created since the last run (first run sets the watermark and returns nothing).\n */\nexport async function main(auth: RT.Outreach) {\n const lastChecked: string | undefined = await wmill.getState()\n\n const url = new URL(\"https://api.outreach.io/api/v2/mailings\")\n url.searchParams.append(\"sort\", \"-createdAt\")\n url.searchParams.append(\"page[size]\", \"100\")\n url.searchParams.append(\"count\", \"false\")\n if (lastChecked) {\n url.searchParams.append(\"newFilterSyntax\", \"true\")\n url.searchParams.append(\"filter[createdAt][gte]\", lastChecked)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { createdAt: string } }[]\n }\n\n const newItems = lastChecked\n ? data.filter((m) => m.attributes.createdAt > lastChecked)\n : []\n const newWatermark =\n data[0]?.attributes.createdAt ?? lastChecked ?? new Date().toISOString()\n await wmill.setState(newWatermark)\n\n return newItems\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28706, + "app": "outreach", + "summary": "New Task", + "description": "Returns tasks created since the last run.", + "terms": "outreach new task returns tasks created since the last run.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New Task\n * Returns tasks created since the last run (first run sets the watermark and returns nothing).\n */\nexport async function main(auth: RT.Outreach) {\n const lastChecked: string | undefined = await wmill.getState()\n\n const url = new URL(\"https://api.outreach.io/api/v2/tasks\")\n url.searchParams.append(\"sort\", \"-createdAt\")\n url.searchParams.append(\"page[size]\", \"100\")\n url.searchParams.append(\"count\", \"false\")\n if (lastChecked) {\n url.searchParams.append(\"newFilterSyntax\", \"true\")\n url.searchParams.append(\"filter[createdAt][gte]\", lastChecked)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { createdAt: string } }[]\n }\n\n const newItems = lastChecked\n ? data.filter((t) => t.attributes.createdAt > lastChecked)\n : []\n const newWatermark =\n data[0]?.attributes.createdAt ?? lastChecked ?? new Date().toISOString()\n await wmill.setState(newWatermark)\n\n return newItems\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/fixtures/frontend/global/hub/servicenow.json b/ai_evals/fixtures/frontend/global/hub/servicenow.json new file mode 100644 index 0000000000..02121cf8ea --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/servicenow.json @@ -0,0 +1,672 @@ +{ + "app": "servicenow", + "display_name": "ServiceNow", + "description": "IT service management: incidents, records and attachments.", + "docs_url": "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_RESTAPI.html", + "curated": true, + "meta": { + "api_docs": "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_RESTAPI.html", + "shipped_surfaces": [ + "table", + "stats", + "attachment", + "import_set", + "user" + ], + "validation": { + "status": "live_validated", + "method": "All surfaces run live against a ServiceNow PDI (a developer instance, Zurich) on 2026-06-04 with Basic auth: Table CRUD (create / get / update PATCH+PUT / delete on incident, delete verified by 404), list_records (encoded query + fields + pagination), aggregate (Stats group-by count), get_current_user, list_tables, and the table/fields/group_by dynselect resolvers. Attachment upload (binary /file) -> list -> download -> delete round-trip with the downloaded bytes byte-for-byte matching the upload. insert_import_set + get_import_set_result against imp_user (transform status surfaced). Trigger e2e-tested via bun test with the windmill-client fake. All created records cleaned up.", + "sources": { + "table_crud": "HIGH - run live (create/get/update PATCH & PUT/delete on incident)", + "aggregate_stats": "HIGH - run live (/api/now/stats group-by count on incident)", + "attachment": "HIGH - run live (binary /file upload -> list -> download -> delete round-trip; downloaded bytes verified equal)", + "import_set": "HIGH - run live (POST /api/now/import/imp_user + GET result; top-level {import_set,staging_table,result} envelope, transform status surfaced)", + "user": "HIGH - run live (/api/now/ui/user/current_user -> admin sys_user)", + "trigger": "HIGH - e2e-tested live via bun test (first run sets watermark + no backlog, second run emits the new record)", + "dropdowns": "HIGH - run live (sys_db_object table list; sys_dictionary super_class inheritance walk confirmed - incident resolves 106 fields incl. task-inherited short_description/priority/state)" + }, + "low_confidence_fields": [ + "trigger datetime comparison assumes the integration user's timezone is UTC/GMT (validated with the PDI admin user, whose TZ is UTC); a non-UTC integration user could shift the sys_updated_on> watermark boundary by the offset" + ] + }, + "pagination": { + "pattern": "offset", + "request_params": { + "offset": "sysparm_offset", + "limit": "sysparm_limit" + }, + "response_fields": { + "next": "Link (response header, rel=\"next\")", + "total": "X-Total-Count (response header)" + }, + "default_limit": 10000, + "notes": "Table API pages with sysparm_offset + sysparm_limit. Each page response carries a Link header (RFC 5988) with rel=\"first\"/\"prev\"/\"next\"/\"last\" full URLs, plus X-Total-Count with the total match count. Add sysparm_no_count=true to skip the count on huge tables. The list_records action exposes limit+offset and returns one page; iterate offset until fewer than limit rows return." + }, + "gotchas": [ + "Base path is {instance_url}/api/now; instance_url lives on the resource. Auth is Basic (username:password) OR Bearer token \u2014 scripts send Bearer when auth.token is set, otherwise Basic. Both hit identical paths.", + "Table/Attachment/Stats reads/writes return a {\"result\": ...} envelope: list/stats -> result is an array, get/create/update -> result is an object. EXCEPTION: the Import Set API (POST /api/now/import/{staging}, GET .../{sys_id}) returns a TOP-LEVEL object {import_set, staging_table, result:[...]} with NO outer 'result' wrapper. Scripts return the raw JSON (envelope intact).", + "Datetimes are 'YYYY-MM-DD HH:MM:SS' in UTC with NO trailing 'Z'. Used directly in encoded queries, e.g. sys_updated_on>2026-01-01 00:00:00.", + "sysparm_query is an ENCODED query string: '^' = AND clause separator, '^OR' = OR, operators =, !=, LIKE, >=, <=, IN, STARTSWITH, ORDERBY, ORDERBYDESC. A literal '^' inside a value must be doubled to '^^'. Example: active=true^priority=1^ORDERBYDESCsys_updated_on.", + "System fields sys_id, sys_created_on, sys_updated_on, sys_created_by are auto-managed; do not send them on create/update.", + "DELETE (record and attachment) returns 204 No Content with an empty body \u2014 guard with `if (response.status === 204) return { success: true }` before response.json().", + "Update: PATCH partial-updates; PUT replaces the whole record (clears omitted fields). update_record sends PUT when replace=true, PATCH otherwise.", + "Attachment upload uses POST /api/now/attachment/file with table_name, table_sys_id, file_name in the QUERY STRING and the raw file bytes as the body (Content-Type = the file's MIME type), NOT multipart. Returns 201. Download is GET /api/now/attachment/{sys_id}/file (raw bytes).", + "Import Set transform runs SYNCHRONOUSLY. A result[].status of 'error' is an application-level failure even on HTTP 200/201 \u2014 inspect result[].status / error_message, don't trust the HTTP code alone.", + "Table-name DROPDOWN comes from GET /api/now/table/sys_db_object?sysparm_fields=name,label (use the 'name' as the value). A table's COLUMNS come from sys_dictionary \u2014 but sys_dictionary stores each field once, under the table where it is DEFINED, so name= returns ONLY that table's own fields. Extended tables (incident/change_request/sc_task all extend task) inherit columns like short_description/priority/assigned_to/state that live on the parent. To get the full column set, walk sys_db_object.super_class up the hierarchy (GET sys_db_object?sysparm_query=name=&sysparm_fields=super_class.name, repeat) and query GET sys_dictionary?sysparm_query=nameIN
,^elementISNOTEMPTY&sysparm_fields=element,column_label (elementISNOTEMPTY drops the collection/meta row).", + "Table API enforces row- and field-level ACLs: a 200 can omit fields the user can't read; inaccessible tables/ops return 403." + ], + "enums": { + "sysparm_display_value": [ + "true", + "false", + "all" + ], + "sysparm_view": [ + "desktop", + "mobile", + "both" + ], + "import_set_result_status": [ + "inserted", + "updated", + "ignored", + "error" + ], + "incident_state_note": "incident.state and priority/urgency/impact are instance-configurable; defaults: state 1=New,2=In Progress,3=On Hold,6=Resolved,7=Closed,8=Canceled; priority/urgency/impact 1-5. On task/incident, priority is CALCULATED from impact+urgency (a priority value sent on create/update is recomputed/ignored) \u2014 set impact+urgency instead. Never hardcode for generic actions." + }, + "errors": { + "401": "Authentication failed or OAuth token expired/invalid. Check username/password or refresh the bearer token.", + "403": "ACL denies the table or operation, or the user lacks the required role (e.g. rest_api_explorer / table ACL). Response: {\"error\":{\"message\":\"User Not Authorized\"}}.", + "404": "No Record found (bad sys_id) or Invalid table (bad/inaccessible table name, common Import Set staging-table error).", + "400": "Bad encoded query, invalid field name, or malformed request body." + } + }, + "resource_type": { + "name": "servicenow", + "description": "ServiceNow instance credential. Set instance_url to your instance base (https://.service-now.com). Authenticate with EITHER Basic auth (username + password) OR an OAuth 2.0 bearer token \u2014 scripts send a Bearer header when token is set, otherwise Basic auth.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "order": [ + "instance_url", + "username", + "password", + "token" + ], + "properties": { + "instance_url": { + "default": "", + "description": "Instance base URL, e.g. https://dev12345.service-now.com (no trailing slash). Every REST call is made against this host.", + "placeholder": "https://dev12345.service-now.com", + "type": "string" + }, + "password": { + "default": "", + "description": "Basic auth password. Leave blank when using an OAuth bearer token.", + "password": true, + "type": "string" + }, + "token": { + "default": "", + "description": "OAuth 2.0 access token (Bearer). Populated automatically when you connect via Windmill's ServiceNow OAuth flow (admin configures the instance + client id/secret in instance settings), or paste one manually. Provide this instead of username/password to authenticate with OAuth.", + "password": true, + "type": "string" + }, + "username": { + "default": "", + "description": "Basic auth username. Leave blank when using an OAuth bearer token.", + "type": "string" + } + }, + "required": [ + "instance_url" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28665, + "app": "servicenow", + "summary": "Aggregate Records", + "description": "Count records on a table (Aggregate API), optionally filtered by an encoded query and grouped by a field.", + "terms": "servicenow aggregate records count records on a table (aggregate api), optionally filtered by an encoded query and grouped by a field.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynSelect_group_by = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the group-by\n// options include fields inherited from parent tables, not just fields defined\n// on the chosen table (sys_dictionary stores each field under its defining\n// table \u2014 e.g. incident extends task, so priority/state live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent select: the chosen table's columns (sys_dictionary) to group by,\n// including columns inherited from parent tables.\nexport async function group_by(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Aggregate Records\n * Count records on a table (Aggregate API), optionally filtered by an encoded query and grouped by a field. Returns ServiceNow's stats envelope with the count per group.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n query: string | undefined,\n group_by: DynSelect_group_by | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/stats/${table}`)\n url.searchParams.append(\"sysparm_count\", \"true\")\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n if (group_by !== undefined && group_by !== \"\") {\n url.searchParams.append(\"sysparm_group_by\", group_by)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "group_by": { + "default": null, + "description": "", + "format": "dynselect-group_by", + "type": "object" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + }, + { + "version_id": 28666, + "app": "servicenow", + "summary": "Create Record", + "description": "Insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). Pick the table from a live dropdown and pass a column/value map.", + "terms": "servicenow create record insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). pick the table from a live dropdown and pass a column/value map.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Create Record\n * Insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). Pick the table from a live dropdown and pass a column/value map.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n body: { [key: string]: any }\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28667, + "app": "servicenow", + "summary": "Delete Attachment", + "description": "Delete an attachment by sys_id. Returns {success, sys_id} (ServiceNow replies 204 No Content).", + "terms": "servicenow delete attachment delete an attachment by sys_id. returns {success, sys_id} (servicenow replies 204 no content).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Delete Attachment\n * Delete an attachment by its sys_id. ServiceNow returns 204 No Content on success, so this returns {success, sys_id}.\n */\nexport async function main(auth: RT.Servicenow, attachment_sys_id: string) {\n const response = await fetch(\n `${auth.instance_url}/api/now/attachment/${attachment_sys_id}`,\n {\n method: \"DELETE\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return { success: true, sys_id: attachment_sys_id }\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "attachment_sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + } + }, + "required": [ + "auth", + "attachment_sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28668, + "app": "servicenow", + "summary": "Delete Record", + "description": "Delete a record by sys_id. Returns {success, sys_id} (ServiceNow replies 204 No Content).", + "terms": "servicenow delete record delete a record by sys_id. returns {success, sys_id} (servicenow replies 204 no content).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Delete Record\n * Delete a record by sys_id. ServiceNow returns 204 No Content on success, so this returns {success, sys_id}.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return { success: true, sys_id }\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28670, + "app": "servicenow", + "summary": "Get Current User", + "description": "Return the sys_user record of the authenticated user.", + "terms": "servicenow get current user return the sys_user record of the authenticated user.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Get Current User\n * Return the sys_user record of the authenticated user (resolves current_user, then fetches the full record from sys_user).\n */\nexport async function main(auth: RT.Servicenow) {\n const currentResponse = await fetch(\n `${auth.instance_url}/api/now/ui/user/current_user`,\n {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!currentResponse.ok) {\n throw new Error(`${currentResponse.status} ${await currentResponse.text()}`)\n }\n const { result } = (await currentResponse.json()) as {\n result: { user_sys_id: string }\n }\n\n const userResponse = await fetch(\n `${auth.instance_url}/api/now/table/sys_user/${result.user_sys_id}`,\n {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!userResponse.ok) {\n throw new Error(`${userResponse.status} ${await userResponse.text()}`)\n }\n\n return await userResponse.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28671, + "app": "servicenow", + "summary": "Get Import Set Result", + "description": "Fetch the outcome of a prior import row by the staging-row sys_id returned by Insert Into Import Set (result.sys_id).", + "terms": "servicenow get import set result fetch the outcome of a prior import row by the staging-row sys_id returned by insert into import set (result.sys_id).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Get Import Set Result\n * Fetch the outcome of a prior import row by the staging-row sys_id returned by Insert Into Import Set (result.sys_id), returning the transform result (target table, status, target record link).\n */\nexport async function main(\n auth: RT.Servicenow,\n staging_table: string,\n sys_id: string\n) {\n const url = new URL(\n `${auth.instance_url}/api/now/import/${staging_table}/${sys_id}`\n )\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "staging_table": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "staging_table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28672, + "app": "servicenow", + "summary": "Get Record", + "description": "Retrieve a single record by sys_id. Optionally restrict the returned columns, and choose raw vs. display values.", + "terms": "servicenow get record retrieve a single record by sys_id. optionally restrict the returned columns, and choose raw vs. display values.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynMultiselect_fields = string[]\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the column\n// list includes fields inherited from parent tables, not just fields defined on\n// the chosen table (sys_dictionary stores each field under its defining table \u2014\n// e.g. incident extends task, so short_description/priority live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent multi-select: the chosen table's columns (sys_dictionary), including\n// columns inherited from parent tables.\nexport async function fields(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Get Record\n * Retrieve a single record by sys_id. Optionally restrict the returned columns, and choose raw vs. display values.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string,\n fields: DynMultiselect_fields | undefined,\n display_value: \"false\" | \"true\" | \"all\" | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n if (fields && fields.length > 0) {\n url.searchParams.append(\"sysparm_fields\", fields.join(\",\"))\n }\n if (display_value !== undefined && display_value !== \"\") {\n url.searchParams.append(\"sysparm_display_value\", display_value)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "display_value": { + "default": null, + "description": "", + "enum": [ + "false", + "true", + "all" + ], + "originalType": "enum", + "type": "string" + }, + "fields": { + "default": null, + "description": "", + "format": "dynmultiselect-fields", + "type": "object" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28673, + "app": "servicenow", + "summary": "Insert Into Import Set", + "description": "Push a row into an import set staging table; the transform map runs synchronously. Inspect result[].status.", + "terms": "servicenow insert into import set push a row into an import set staging table; the transform map runs synchronously. inspect result[].status.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Insert Into Import Set\n * Push a row into an import set staging table; the associated transform map runs synchronously. Inspect result[].status (inserted/updated/ignored/error) \u2014 a transform error returns 200/201 with status \"error\".\n */\nexport async function main(\n auth: RT.Servicenow,\n staging_table: string,\n body: { [key: string]: any }\n) {\n const url = new URL(`${auth.instance_url}/api/now/import/${staging_table}`)\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "staging_table": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "staging_table", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28674, + "app": "servicenow", + "summary": "List Attachments", + "description": "List attachment metadata, optionally filtered with an encoded query (e.g. table_name=incident^table_sys_id=).", + "terms": "servicenow list attachments list attachment metadata, optionally filtered with an encoded query (e.g. table_name=incident^table_sys_id=).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * List Attachments\n * List attachment metadata (sys_id, file_name, content_type, size, download_link). Filter with an encoded query, e.g. table_name=incident^table_sys_id=.\n */\nexport async function main(\n auth: RT.Servicenow,\n query: string | undefined,\n limit: number | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/attachment`)\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n url.searchParams.append(\"sysparm_limit\", String(limit ?? 100))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28675, + "app": "servicenow", + "summary": "List Records", + "description": "Query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values.", + "terms": "servicenow list records query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynMultiselect_fields = string[]\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the column\n// list includes fields inherited from parent tables, not just fields defined on\n// the chosen table (sys_dictionary stores each field under its defining table \u2014\n// e.g. incident extends task, so short_description/priority live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent multi-select: the chosen table's columns (sys_dictionary), including\n// columns inherited from parent tables.\nexport async function fields(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * List Records\n * Query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values. Returns one page; iterate offset until fewer than limit rows return.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n query: string | undefined,\n limit: number | undefined,\n offset: number | undefined,\n fields: DynMultiselect_fields | undefined,\n display_value: \"false\" | \"true\" | \"all\" | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n url.searchParams.append(\"sysparm_limit\", String(limit ?? 100))\n if (offset !== undefined) {\n url.searchParams.append(\"sysparm_offset\", String(offset))\n }\n if (fields && fields.length > 0) {\n url.searchParams.append(\"sysparm_fields\", fields.join(\",\"))\n }\n if (display_value !== undefined && display_value !== \"\") {\n url.searchParams.append(\"sysparm_display_value\", display_value)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "display_value": { + "default": null, + "description": "", + "enum": [ + "false", + "true", + "all" + ], + "originalType": "enum", + "type": "string" + }, + "fields": { + "default": null, + "description": "", + "format": "dynmultiselect-fields", + "type": "object" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "offset": { + "default": null, + "description": "", + "type": "number" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + }, + { + "version_id": 28676, + "app": "servicenow", + "summary": "List Tables", + "description": "List the instance tables (sys_db_object) with API name, label, and sys_id. Optionally filter by a name/label substring.", + "terms": "servicenow list tables list the instance tables (sys_db_object) with api name, label, and sys_id. optionally filter by a name/label substring.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * List Tables\n * List the instance's tables (sys_db_object) with their API name, label, and sys_id. Optionally filter by a substring matched against name or label.\n */\nexport async function main(\n auth: RT.Servicenow,\n name_filter: string | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/sys_db_object`)\n url.searchParams.append(\"sysparm_fields\", \"name,label,sys_id\")\n if (name_filter !== undefined && name_filter !== \"\") {\n url.searchParams.append(\n \"sysparm_query\",\n `nameLIKE${name_filter}^ORlabelLIKE${name_filter}`\n )\n }\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "name_filter": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28677, + "app": "servicenow", + "summary": "Update Record", + "description": "Update a record by sys_id with a column/value map. PATCH (partial) by default; set replace=true to PUT (full overwrite).", + "terms": "servicenow update record update a record by sys_id with a column/value map. patch (partial) by default; set replace=true to put (full overwrite).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Update Record\n * Update a record by sys_id with a column/value map. PATCH (partial update) by default; set replace=true to PUT (full overwrite that clears omitted fields).\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string,\n body: { [key: string]: any },\n replace: boolean | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n\n const response = await fetch(url, {\n method: replace ? \"PUT\" : \"PATCH\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "replace": { + "default": null, + "description": "", + "type": "boolean" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28679, + "app": "servicenow", + "summary": "New or Updated Record", + "description": "Emits records whose timestamp is newer than the last poll, tracked via Windmill state. Defaults to sys_updated_on.", + "terms": "servicenow new or updated record emits records whose timestamp is newer than the last poll, tracked via windmill state. defaults to sys_updated_on.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * New or Updated Record\n * Emits records from a table whose timestamp is newer than the last poll, tracked via Windmill state. Defaults to sys_updated_on (set timestamp_field to sys_created_on for new-only). Optionally narrow with an extra encoded query.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n timestamp_field: \"sys_updated_on\" | \"sys_created_on\" | undefined,\n query: string | undefined\n) {\n const tsField =\n timestamp_field && timestamp_field !== \"\"\n ? timestamp_field\n : \"sys_updated_on\"\n const lastChecked: string | undefined = await wmill.getState()\n\n // ServiceNow datetimes are 'YYYY-MM-DD HH:MM:SS' in UTC (no trailing Z).\n const nowUtc = new Date().toISOString().slice(0, 19).replace(\"T\", \" \")\n\n // First run: set the watermark to now and don't emit a backlog.\n if (!lastChecked) {\n await wmill.setState(nowUtc)\n return []\n }\n\n let encoded = `${tsField}>${lastChecked}`\n if (query !== undefined && query !== \"\") {\n encoded += `^${query}`\n }\n encoded += `^ORDERBY${tsField}`\n\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n url.searchParams.append(\"sysparm_query\", encoded)\n url.searchParams.append(\"sysparm_limit\", \"200\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { result } = (await response.json()) as {\n result: Array<{ [key: string]: any }>\n }\n\n if (result.length > 0) {\n await wmill.setState(result[result.length - 1][tsField])\n }\n\n return result\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + }, + "timestamp_field": { + "default": null, + "description": "", + "enum": [ + "sys_updated_on", + "sys_created_on" + ], + "originalType": "enum", + "type": "string" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index b824936d2f..e100cf88c6 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -1111,9 +1111,9 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool { | "/api/settings/global/hub_base_url" | "/api/docs/search" | "/api/docs/page" - | "/api/integrations/hub/list" | "/api/embeddings/query_hub_scripts" - ) || route_path.starts_with("/api/scripts/hub/") + ) || route_path.starts_with("/api/integrations/hub/") + || route_path.starts_with("/api/scripts/hub/") || route_path.starts_with("/api/flows/hub/") || route_path.starts_with("/api/apps/hub/") } diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 1de99fb44f..6e4d6a1c71 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -78,6 +78,8 @@ pub struct HubScriptResult { id: i64, version_id: i64, summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, app: String, kind: String, score: f32, @@ -163,6 +165,9 @@ struct HubScript { id: i64, version_id: i64, summary: String, + // Nearly a fifth of hub scripts carry an explicit `"description": null`; a bare + // String here fails the whole blob and takes hub search down with it. + description: Option, app: String, kind: String, embedding: Vec, @@ -360,6 +365,10 @@ impl EmbeddingsDb { let mut hm = HashMap::new(); hm.insert("ask_id".to_string(), script.ask_id.clone().to_string()); hm.insert("summary".to_string(), script.summary.clone()); + hm.insert( + "description".to_string(), + script.description.clone().unwrap_or_default(), + ); hm.insert("app".to_string(), script.app.clone()); hm.insert("kind".to_string(), script.kind.clone()); hm.insert("id".to_string(), script.id.clone().to_string()); @@ -517,6 +526,10 @@ impl EmbeddingsDb { .get("summary") .ok_or(Error::msg("no summary"))? .to_owned(), + description: metadata + .get("description") + .filter(|d| !d.is_empty()) + .map(|d| d.to_owned()), app: metadata.get("app").ok_or(Error::msg("no app"))?.to_owned(), kind: metadata .get("kind") @@ -707,6 +720,31 @@ pub fn global_service() -> Router { mod tests { use super::trim_to_top_score; + // The blob carries an explicit `"description": null` for roughly a fifth of hub + // scripts, and an older hub omits the key entirely. A bare String here fails the + // whole 155 MB array and takes hub search down with it. + #[test] + fn reads_a_hub_script_whether_or_not_it_has_a_description() { + let present = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","description":"d","app":"a","kind":"script","embedding":[]}"#; + let null = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","description":null,"app":"a","kind":"script","embedding":[]}"#; + let missing = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","app":"a","kind":"script","embedding":[]}"#; + + assert_eq!( + serde_json::from_str::(present) + .unwrap() + .description, + Some("d".to_string()) + ); + for without in [null, missing] { + assert_eq!( + serde_json::from_str::(without) + .unwrap() + .description, + None + ); + } + } + #[test] fn trims_scores_more_than_5pct_below_top() { // top=1.0, cutoff at 0.95: 0.96 stays (0.04 drop), 0.93 is the first diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 67e08077c1..d8755f8995 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9909,6 +9909,13 @@ paths: properties: name: type: string + documented: + description: >- + whether the integration carries provider knowledge checked + against the live API, on top of the resource type and example + scripts /integrations/hub/{app}/meta returns for any + integration. Absent on a hub predating the flag. + type: boolean picks: description: how often the integration has been picked, absent on a hub that does not count picks type: integer @@ -9919,6 +9926,162 @@ paths: required: - name + /integrations/hub/{app}/meta: + get: + summary: get hub integration metadata + operationId: getHubIntegrationMeta + tags: + - integration + parameters: + - name: app + description: integration slug + in: path + required: true + schema: + type: string + responses: + "200": + description: integration metadata + content: + application/json: + schema: + type: object + properties: + app: + type: string + display_name: + type: string + description: + type: string + nullable: true + docs_url: + type: string + nullable: true + curated: + description: >- + whether the script set was pruned to idiomatic actions rather + than generated one per endpoint; null when nobody has assessed + it, which is distinct from a stated false + type: boolean + nullable: true + metadata_source: + description: >- + whether the provider knowledge was authored (curated) or + inferred from the shipped scripts (derived) + type: string + enum: + - curated + - derived + meta: + description: >- + the integration's authored meta.json verbatim; the content + repo owns its schema, so it is passed through unvalidated + type: object + nullable: true + additionalProperties: true + meta_updated_at: + type: string + nullable: true + derived: + description: facts computed from the integration's shipped scripts + type: object + properties: + api_hosts: + type: array + items: + type: object + properties: + host: + type: string + count: + type: number + required: + - host + - count + style: + type: string + enum: + - fetch + - sdk + - mixed + - unknown + languages: + type: object + additionalProperties: + type: number + script_counts: + type: object + properties: + total: + type: number + by_kind: + type: object + additionalProperties: + type: number + required: + - total + - by_kind + top_scripts: + type: array + items: + type: object + properties: + path: + type: string + ask_id: + type: number + version_id: + type: number + summary: + type: string + description: + type: string + nullable: true + kind: + type: string + language: + type: string + nullable: true + views: + type: number + votes: + type: number + required: + - path + - summary + - kind + required: + - api_hosts + - style + - languages + - script_counts + - top_scripts + resource_types: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + schema: + description: >- + the resource type's JSON schema; the hub stores it as text + and returns it as a JSON string, so a client must be ready + for either that or an object + required: + - name + - schema + # `derived` and `resource_types` are omitted by a hub older than the + # client, which reads every section as optional. + required: + - app + - display_name + /flows/hub/list: get: summary: list all hub flows @@ -10247,6 +10410,9 @@ paths: type: number summary: type: string + description: + type: string + nullable: true app: type: string version_id: @@ -10316,6 +10482,8 @@ paths: type: number summary: type: string + description: + type: string app: type: string kind: diff --git a/backend/windmill-api/src/integration.rs b/backend/windmill-api/src/integration.rs index 2def3e6ed0..10bd1fc24c 100644 --- a/backend/windmill-api/src/integration.rs +++ b/backend/windmill-api/src/integration.rs @@ -1,9 +1,16 @@ use crate::{db::DB, HTTP_CLIENT}; -use axum::{extract::Query, response::IntoResponse, routing::get, Extension, Router}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + routing::get, + Extension, Router, +}; use windmill_common::{error::Error, utils::query_elems_from_hub, HUB_BASE_URL}; pub fn global_service() -> Router { - Router::new().route("/hub/list", get(list_hub_integrations)) + Router::new() + .route("/hub/list", get(list_hub_integrations)) + .route("/hub/{app}/meta", get(get_hub_integration_meta)) } #[derive(serde::Deserialize)] @@ -29,3 +36,60 @@ async fn list_hub_integrations( .await?; Ok::<_, Error>((status_code, headers, response)) } + +/// Axum percent-decodes a path parameter, so an interpolated slug carrying `..`, `?` +/// or `#` re-targets the proxied GET at another path on the hub origin — with the +/// instance's hub credentials attached. Slugs are `[A-Za-z0-9_-]`; reject the rest. +fn is_hub_integration_slug(app: &str) -> bool { + !app.is_empty() + && app.len() <= 64 + && app + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Everything a caller needs to write code against one integration: its resource +/// types, the provider knowledge the content repo authored, and facts derived from +/// the shipped scripts. A hub older than the endpoint answers 404, which passes +/// through as-is. +async fn get_hub_integration_meta( + Path(app): Path, + Extension(db): Extension, +) -> impl IntoResponse { + if !is_hub_integration_slug(&app) { + return Err(Error::BadRequest(format!( + "Not a valid integration name: {app}" + ))); + } + let (status_code, headers, response) = query_elems_from_hub( + &HTTP_CLIENT, + &format!("{}/integrations/{}/meta", **HUB_BASE_URL.load(), app), + None, + &db, + ) + .await?; + Ok::<_, Error>((status_code, headers, response)) +} + +#[cfg(test)] +mod tests { + use super::is_hub_integration_slug; + + #[test] + fn rejects_slugs_that_would_re_target_the_proxied_request() { + assert!(is_hub_integration_slug("confluence")); + assert!(is_hub_integration_slug("aws-ses")); + assert!(is_hub_integration_slug("bamboo_hr")); + assert!(is_hub_integration_slug("RSS")); + + for escape in [ + "../../scripts/top", + "confluence?foo=bar", + "confluence#frag", + "confluence/meta", + "", + ] { + assert!(!is_hub_integration_slug(escape), "accepted {escape}"); + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 7964f1a908..b013c58ebc 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -974,7 +974,7 @@ Use \`patch_file\` for small, localized edits when you can copy an exact snippet ### Discovery - \`search_workspace(query, type)\`: Search workspace scripts and flows - \`get_runnable_details(path, type)\`: Get details (summary, description, schema, content) of a specific script or flow -- \`search_hub_scripts(query)\`: Search hub scripts +- \`search_hub_scripts(query, integration)\`: Search hub scripts, or list one integration's scripts by slug ### Data Tables - \`list_datatables()\`: List configured datatables with schema and table names only. Does not include columns. Use this directly for table-list or available-tables summaries. diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index e0df5c4a98..63bfa6e1b2 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1137,13 +1137,15 @@ describe('global AI tools', () => { expect(ScriptService.queryHubScripts).toHaveBeenCalledWith({ text: 'slack message', - kind: 'script' + kind: 'script', + app: undefined }) expect(ScriptService.getHubScriptContentByPath).not.toHaveBeenCalled() - expect(JSON.parse(raw)).toEqual([ + expect(JSON.parse(raw).results).toEqual([ { path: 'hub/7/slack/send_message', - summary: 'Send Message' + summary: 'Send Message', + integration: 'slack' } ]) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e0d1cf683c..b3bf4a8078 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -133,6 +133,7 @@ import { createToolDef, droppedOptionKeys, createSearchHubScriptsTool, + getHubIntegrationTool, executeTestRun, findAndReplace, isHubPath, @@ -1437,7 +1438,9 @@ Rules:${when( - You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value. - Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition. - When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it. -- Hub scripts are prebuilt integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Use search_hub_scripts to find one before hand-writing an integration, then read_workspace_item with type "script" and the returned hub path to get its code, language, and input schema. +- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub///\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing. +- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API. +- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself. ${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. ${pipelineBullet}` )}${when( @@ -3388,6 +3391,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, createSearchHubScriptsTool(false), + getHubIntegrationTool, searchNpmPackagesTool, searchDocsTool, readDocsPageTool, diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 4574df7355..a75d1b4440 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -38,6 +38,7 @@ vi.mock('$lib/components/flows/flowTree', () => ({ vi.mock('$lib/gen', () => ({ ScriptService: {}, FlowService: {}, + IntegrationService: {}, JobService: { getJob: vi.fn(), getJobUpdates: vi.fn() }, ScheduleService: { previewSchedule: vi.fn(), @@ -1890,7 +1891,7 @@ describe('createSearchHubScriptsTool', () => { toolId: 't1', toolCallbacks: { setToolStatus: vi.fn() } } as any) - return JSON.parse(raw) + return JSON.parse(raw).results } it('reports each script language alongside its content', async () => { @@ -1905,12 +1906,14 @@ describe('createSearchHubScriptsTool', () => { { path: 'hub/1/discord/send_a_message', summary: 'Send a message', + integration: 'discord', language: 'bunnative', content: '// hub/1/discord/send_a_message' }, { path: 'hub/2/slack/post_a_message', summary: 'Post a message', + integration: 'slack', language: 'python3', content: '// hub/2/slack/post_a_message' } @@ -1929,6 +1932,382 @@ describe('createSearchHubScriptsTool', () => { expect(results[0].content).toBeUndefined() expect(results[1].content).toBe('ok') }) + + // Browsing by app is what surfaces an integration's other scripts as examples, + // and only the top-scripts endpoint takes no query and applies no similarity + // floor, so the near-misses worth reading survive instead of being cut. + it('lists an integration through the top-scripts endpoint, with descriptions', async () => { + const { ScriptService } = await import('$lib/gen') + const getTopHubScripts = vi.fn(async () => ({ + asks: [{ ...hit(3, 'stripe', 'Create a Payout'), description: 'Send funds to your bank' }] + })) + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []), getTopHubScripts }) + + const { createSearchHubScriptsTool } = await import('./shared') + const raw = await createSearchHubScriptsTool().fn({ + args: { integration: 'stripe' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(getTopHubScripts).toHaveBeenCalledWith({ app: 'stripe', kind: 'script', limit: 20 }) + expect(ScriptService.queryHubScripts).not.toHaveBeenCalled() + expect(JSON.parse(raw).results).toEqual([ + { + path: 'hub/3/stripe/create_a_payout', + summary: 'Create a Payout', + integration: 'stripe', + description: 'Send funds to your bank' + } + ]) + }) + + // Narrowing a query to a slug is the only thing that searches an integration the + // user named: ranking alone surfaces other integrations whose scripts merely mention + // it, so a query that drops `app` silently searches the whole hub instead. + it('narrows a query to the integration it was given', async () => { + const { ScriptService } = await import('$lib/gen') + const queryHubScripts = vi.fn(async () => [hit(9, 'salesforce', 'SOSL Search')]) + Object.assign(ScriptService, { queryHubScripts, getTopHubScripts: vi.fn() }) + + const { createSearchHubScriptsTool } = await import('./shared') + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'look up an account', integration: 'salesforce' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(queryHubScripts).toHaveBeenCalledWith({ + text: 'look up an account', + kind: 'script', + app: 'salesforce' + }) + expect(ScriptService.getTopHubScripts).not.toHaveBeenCalled() + expect(JSON.parse(raw).results.map((r: any) => r.integration)).toEqual(['salesforce']) + }) + + // A search below the similarity floor otherwise dead-ends; the slug is what + // lets the model fall back to browsing the integration. + it('suggests matching integrations when the search finds nothing', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'refund a stripe charge' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw)).toEqual({ results: [], suggested_integrations: ['stripe'] }) + }) + + // Matching a query word anywhere inside a slug turns every short English word into + // a hit — `for` in salesforce, `the` in basis_theory — so a task that names no + // integration came back with a confident-looking list of them. + it('suggests nothing for a request that names no integration', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'salesforce' }, + { name: 'basis_theory' }, + { name: 'hackernews' }, + { name: 's3' }, + // A slug that is an ordinary word with a letter in front of it: matching + // one character in would read "node" as this integration. + { name: 'enode' } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'list all the invoices for the new month in a node script' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual([]) + }) + + // Google's integrations compress the vendor to a `g`, so the word a user says + // appears in the name the hub curates and nowhere in the slug. + it('reaches an integration whose slug compresses the vendor name', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'gsheets', display_name: 'Google Sheets' }, + { name: 'gdrive', display_name: 'Google Drive' }, + { name: 'smartsheet', display_name: null } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'add a row to a google sheet' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + // "google" names both products, and a suggestion list is allowed to offer both. + expect(JSON.parse(raw).suggested_integrations).toEqual(['gsheets', 'gdrive']) + }) + + // The hub names only the slugs its own titleisation gets wrong, so the ones it + // leaves null are still named here. + it('falls back to the local name table for a slug the hub does not name', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 'gcloud', display_name: null }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'deploy a service to google cloud' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual(['gcloud']) + }) + + // A slug shorter than the token floor is only reachable by an exact match. + it('still reaches a two-character integration slug', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { queryHubScripts: vi.fn(async () => []) }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 's3' }, { name: 'salesforce' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'upload a file to s3' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).suggested_integrations).toEqual(['s3']) + }) + + // Only about one hub integration in twelve carries authored provider knowledge, + // and a search result is where the model first sees a slug — so the mark has to + // ride along, or nothing tells it which integrations have verified notes to read. + it('marks the integrations get_hub_integration has knowledge for', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { + queryHubScripts: vi.fn(async () => [ + hit(1, 'confluence', 'Create page'), + hit(2, 'notion', 'Create page') + ]) + }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [ + { name: 'Confluence', documented: true }, + { name: 'notion', documented: false } + ]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'create a wiki page' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + const results = JSON.parse(raw).results + // the hub's slugs are case-sensitive, so the two casings are one integration + expect(results.find((r: any) => r.integration === 'confluence').documented).toBe(true) + expect(results.find((r: any) => r.integration === 'notion').documented).toBeUndefined() + }) + + // A hub predating the flag sends no `documented` at all; reading that as "yes" + // would send the model after notes that are not coming. + it('claims no knowledge when the hub does not report any', async () => { + const { ScriptService, IntegrationService } = await import('$lib/gen') + Object.assign(ScriptService, { + queryHubScripts: vi.fn(async () => [hit(1, 'confluence', 'Create page')]) + }) + Object.assign(IntegrationService, { + listHubIntegrations: vi.fn(async () => [{ name: 'confluence' }]) + }) + + const { createSearchHubScriptsTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const raw = await createSearchHubScriptsTool().fn({ + args: { query: 'create a wiki page' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + + expect(JSON.parse(raw).results[0].documented).toBeUndefined() + }) +}) + +describe('getHubIntegrationTool', () => { + // The plan-mode gate fails closed, so a read-only tool that is not tagged is + // silently unusable while planning. + it('is usable while planning, like the hub search beside it', async () => { + const { getHubIntegrationTool, createSearchHubScriptsTool } = await import('./shared') + expect(getHubIntegrationTool.planModeSafe).toBe(true) + expect(createSearchHubScriptsTool().planModeSafe).toBe(true) + }) + + const doc = { + app: 'confluence', + display_name: 'Confluence', + // Authored knowledge, but nobody has asserted whether the script set was pruned. + curated: null, + metadata_source: 'curated', + meta: { gotchas: ['Auth is Basic with an API token, not the password'] }, + derived: { + api_hosts: [{ host: 'api.atlassian.com', count: 12 }], + style: 'fetch', + languages: { bun: 14 }, + script_counts: { total: 14, by_kind: { script: 13 } }, + top_scripts: [ + { path: 'hub/1/confluence/create_page', summary: 'Create page', language: 'bun' } + ] + }, + resource_types: [{ name: 'confluence', schema: { type: 'object' } }] + } + + // Hand-validated provider knowledge and facts inferred from script bodies must + // stay under separate keys, or the model will report guesses as verified. + it('keeps authored notes apart from what was inferred from the scripts', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { getHubIntegrationMeta: vi.fn(async () => doc) }) + + const { getHubIntegrationTool } = await import('./shared') + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'confluence' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.verified_provider_notes).toEqual(doc.meta) + expect(parsed.scripts_note).toBeUndefined() + expect(parsed.observed_from_scripts).toEqual({ + api_hosts: ['api.atlassian.com'], + style: 'fetch', + languages: ['bun'], + script_counts: { total: 14, by_kind: { script: 13 } } + }) + }) + + // The hub repo owns this payload, so a hub older than this client can send a + // subset. Reading it must degrade to less content, never to a tool error. + it('returns what an older hub sent instead of failing on the missing sections', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => ({ + app: 'stripe', + display_name: 'Stripe', + curated: false + })) + }) + + const { getHubIntegrationTool } = await import('./shared') + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'stripe' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.integration).toBe('stripe') + expect(parsed.observed_from_scripts).toBeUndefined() + expect(parsed.resource_types).toEqual([]) + expect(parsed.example_scripts).toEqual([]) + }) + + // `curated` has three states and only `true` is a licence to call the scripts good + // examples. `false` and the unassessed `null` must both stay silent, or the 212 + // integrations nobody has looked at get characterised anyway. + it.each([ + [true, true], + [false, false], + [null, false] + ])('speaks about the script set only when curated is true (%s)', async (curated, expected) => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => ({ ...doc, curated })) + }) + + const { getHubIntegrationTool } = await import('./shared') + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'confluence' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(!!parsed.scripts_note).toBe(expected) + }) + + // A hub that times out has said nothing about whether the integration exists, and + // reporting it as absent would stick for the rest of the conversation. + it('does not report a transient hub failure as a missing integration', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => { + throw Object.assign(new Error('Service Unavailable'), { status: 503 }) + }), + listHubIntegrations: vi.fn(async () => [{ name: 'confluence' }]) + }) + + const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'confluence' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.error).toContain('Could not reach the hub') + expect(parsed.error).not.toContain('No hub metadata') + }) + + // A hub with no such integration and one too old to serve the endpoint both 404; + // neither may surface as a tool error, since the model can still read scripts. + it('suggests real slugs instead of failing when the integration is unknown', async () => { + const { IntegrationService } = await import('$lib/gen') + Object.assign(IntegrationService, { + getHubIntegrationMeta: vi.fn(async () => { + throw Object.assign(new Error('Not Found'), { status: 404 }) + }), + listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }]) + }) + + const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared') + clearHubIntegrationsCache() + const parsed = JSON.parse( + await getHubIntegrationTool.fn({ + args: { integration: 'stripe_billing' }, + toolId: 't1', + toolCallbacks: { setToolStatus: vi.fn() } + } as any) + ) + + expect(parsed.error).toContain('stripe_billing') + expect(parsed.suggested_integrations).toEqual(['stripe']) + }) }) describe('processToolCall confirmation hooks', () => { diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index af386b8617..0b751f150a 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -7,7 +7,8 @@ import type { UserDraftItemKind } from '$lib/gen' // The gate's two refusals, from a module that holds prose and one size limit: under the // shallow-import rule below, the rest of plan mode is not reachable from here. import { PLAN_MODE_MESSAGES } from './planModeMessages' -import { NONE } from './sessionCapabilities' +import { NONE, type SessionTool } from './sessionCapabilities' +import { integrationDisplayName } from '$lib/components/resourceTypeDisplay' // Import-free leaf, so it satisfies the shallow-import rule below. import { openItemPreviewAction, @@ -45,6 +46,7 @@ import { z } from 'zod' import { ScriptService, FlowService, + IntegrationService, JobService, type Job, type CompletedJob, @@ -1515,13 +1517,22 @@ function hasOptionalProperties(schema: Record | undefined): boolean const searchHubScriptsSchema = z.object({ query: z .string() - .describe('The query to search for, e.g. send email, list stripe invoices, etc..') + .optional() + .describe( + 'What you want the script to do, e.g. "send email", "list stripe invoices". Semantic search, so describe the intent. Omit when browsing with `integration` alone.' + ), + integration: z + .string() + .optional() + .describe( + "Integration slug (e.g. \"stripe\") from a previous result's `integration` or `suggested_integrations`, or the vendor the user named. Alone, lists that integration's scripts with descriptions, including ones that do not match your task but show how the integration is used. With `query`, narrows the search to it; pass it whenever the user names the vendor, since ranking alone can surface another integration's scripts that merely mention it." + ) }) const searchHubScriptsToolDef = createToolDef( searchHubScriptsSchema, 'search_hub_scripts', - 'Search for scripts in the hub' + "Search the Windmill Hub for prebuilt integration scripts, or list one integration's scripts with `integration`." ) /** The hub resolves a script by its version id alone; the app and summary @@ -1536,29 +1547,304 @@ export function isHubPath(path: string): boolean { return path.startsWith('hub/') } +const MAX_BROWSED_HUB_SCRIPTS = 20 +const MAX_FETCHED_HUB_SCRIPTS = 3 +const MAX_SUGGESTED_INTEGRATIONS = 5 + +/** Common shape of the two hub listings. Both carry a description, but the hub has + * none for roughly a fifth of its scripts, and says so as a null rather than by + * omitting the key. */ +type HubScriptHit = { + version_id: number + app: string + summary: string + description?: string | null +} + +type HubIntegration = { name: string; displayName?: string | null; documented: boolean } + +/** The integration slugs are a large but static list, so one fetch per session + * is enough. Only matched slugs ever reach the model, never the whole list. */ +let hubIntegrationsCache: Promise | undefined + +async function fetchHubIntegrations(): Promise { + try { + const integrations = await IntegrationService.listHubIntegrations({ kind: 'script' }) + return integrations.map((i) => ({ + name: i.name, + // The hub names only the slugs its own titleisation gets wrong, so a slug it + // leaves null can still have a name here — `gcloud` is "Google Cloud" in the + // word table this resolves through. + displayName: i.display_name ?? integrationDisplayName(i.name), + // A hub predating the flag omits it, and so does one with no authored + // notes. Both mean the same thing: nothing to read beyond what the + // metadata call returns for every integration. + documented: i.documented === true + })) + } catch (err) { + console.error('Could not list hub integrations', err) + return [] + } +} + +/** Concurrent callers share one in-flight fetch. An unreachable hub must degrade to + * "no suggestions" rather than turn the search into a tool error, so a failed or + * empty response is left uncached and retried. */ +function loadHubIntegrations(): Promise { + return (hubIntegrationsCache ??= fetchHubIntegrations().then((integrations) => { + if (!integrations.length) hubIntegrationsCache = undefined + return integrations + })) +} + +/** Which integrations carry provider knowledge checked against the live API, + * lowercased because a hub slug is case-sensitive and both casings can exist. The + * mark means there is more to read, never that the call is skippable elsewhere. */ +async function documentedIntegrations(): Promise> { + const available = await loadHubIntegrations() + return new Set(available.filter((i) => i.documented).map((i) => i.name.toLowerCase())) +} + +async function suggestHubIntegrations(query: string): Promise { + const available = await loadHubIntegrations() + const tokens = query + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 2) + // Ranked by how much of the query each name accounts for, because a vendor with + // several products shares one word across all of them: "google drive" matches + // every `g*` integration on "google", and only Google Drive on both words. The + // cap would otherwise cut the one the user named. + return available + .map(({ name, displayName }) => ({ + name, + matched: tokens.filter((t) => tokenMatchesIntegration(t, name, displayName)).length + })) + .filter(({ matched }) => matched > 0) + .sort((a, b) => b.matched - a.matched) + .map(({ name }) => name) + .slice(0, MAX_SUGGESTED_INTEGRATIONS) +} + +/** Matches a query word against an integration's slug and its name, which is what + * reaches a slug compressing the vendor to a letter: "google drive" matches `gdrive` + * only through its "Google Drive". A bare substring test + * instead makes every three-letter English word a hit — `for` in sales*for*ce, `the` + * in basis_*the*ory — so a token matches a whole word, or extends one from four + * characters on. Short tokens must equal a word, which is also what reaches the + * two-character slugs (`s3`, `wiz`) a length floor would hide. */ +function tokenMatchesIntegration( + token: string, + slug: string, + displayName?: string | null +): boolean { + const words = [slug, displayName ?? ''] + .join(' ') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + if (words.includes(token)) { + return true + } + if (token.length < 4) { + return false + } + const extends_ = (a: string, b: string) => a.startsWith(b) || b.startsWith(a) + return words.some((w) => w.length >= 4 && extends_(w, token)) +} + +export const clearHubIntegrationsCache = () => { + hubIntegrationsCache = undefined +} + +const getHubIntegrationSchema = z.object({ + integration: z + .string() + .describe( + 'Integration slug, e.g. "stripe". Take it from a search_hub_scripts result\'s `integration`, or guess the vendor name: a wrong guess comes back with the closest real slugs.' + ) +}) + +const getHubIntegrationToolDef = createToolDef( + getHubIntegrationSchema, + 'get_hub_integration', + 'Read how one integration works before writing code against it: the resource type it takes, its auth fields, and its most-used scripts as examples. For an integration a search marked `documented` it also returns provider knowledge checked against the live API: pagination, enums, error codes and gotchas.' +) + +/** Enough to show the integration's idiom; search_hub_scripts is the way to find + * a specific one. */ +const MAX_INTEGRATION_EXAMPLES = 5 + +/** How the authored notes were checked — method, per-surface confidence, the instance + * used — is provenance for a human reviewing the hub, and up to a third of the + * document. The verdict and any fields it flags as shaky are the parts a caller can + * act on, so those stay. */ +function withoutProvenance(meta: unknown): unknown { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + return meta + } + const { validation, ...rest } = meta as Record + const kept = Object.fromEntries( + Object.entries((validation as Record | undefined) ?? {}).filter( + ([key]) => key === 'status' || key === 'low_confidence_fields' + ) + ) + return Object.keys(kept).length ? { ...rest, validation: kept } : rest +} + +/** The hub keeps a resource type's schema in a text column and hands it back as a + * JSON string, so parse it rather than passing an escaped blob to the model. + * Anything already structured goes through untouched. */ +function parseResourceTypeSchema(schema: unknown): unknown { + if (typeof schema !== 'string') { + return schema + } + try { + return JSON.parse(schema) + } catch { + return schema + } +} + +export const getHubIntegrationTool = { + requires: NONE, + def: getHubIntegrationToolDef, + // Reads one hub document over a GET and writes nothing, so planning may use it. + planModeSafe: true, + fn: async ({ args, toolId, toolCallbacks }) => { + const { integration } = getHubIntegrationSchema.parse(args) + toolCallbacks.setToolStatus(toolId, { content: `Reading the ${integration} integration...` }) + + let doc: Awaited> + try { + doc = await IntegrationService.getHubIntegrationMeta({ app: integration }) + } catch (err) { + // Only a 404 means the integration is absent — an unknown slug, or a hub + // predating the endpoint. Reporting a timeout the same way would teach the + // model that a real integration does not exist for the rest of the chat. + const absent = (err as { status?: number } | undefined)?.status === 404 + const label = absent + ? `No hub integration named ${integration}` + : `Could not reach the hub for ${integration}` + toolCallbacks.setToolStatus(toolId, { content: label }) + const suggested = await suggestHubIntegrations(integration) + return JSON.stringify({ + error: absent + ? `No hub metadata for "${integration}".` + : `Could not reach the hub for "${integration}"; it may still exist. Read its scripts instead, or try again.`, + suggested_integrations: suggested + }) + } + + toolCallbacks.setToolStatus(toolId, { content: `Read the ${doc.display_name} integration` }) + // The hub owns this response's shape and can be older than this client, so + // every section is read as optional: a hub that sends less should return less, + // not fail the call. + const derived = doc.derived + return JSON.stringify({ + integration: doc.app, + display_name: doc.display_name, + ...(doc.description ? { description: doc.description } : {}), + ...(doc.docs_url ? { docs_url: doc.docs_url } : {}), + // Authored provider knowledge and facts inferred from the scripts stay + // separate: only the former was checked against the live API. + ...(doc.meta ? { verified_provider_notes: withoutProvenance(doc.meta) } : {}), + ...(derived + ? { + observed_from_scripts: { + api_hosts: derived.api_hosts?.map((h) => h.host) ?? [], + style: derived.style, + languages: Object.keys(derived.languages ?? {}), + script_counts: derived.script_counts + } + } + : {}), + // `curated` is three-state: only an integration's own meta.json asserts it, and + // anything else means nobody has assessed the script set. Speak only for a + // positive assertion, so an unassessed integration is never characterised + // either way; `script_counts` below is the factual signal for the rest. + ...(doc.curated === true + ? { + scripts_note: + 'These scripts were pruned to idiomatic actions rather than generated one per API endpoint, so they are worth following as style examples.' + } + : {}), + resource_types: (doc.resource_types ?? []).map((rt) => ({ + name: rt.name, + ...(rt.description ? { description: rt.description } : {}), + schema: parseResourceTypeSchema(rt.schema) + })), + example_scripts: (derived?.top_scripts ?? []).slice(0, MAX_INTEGRATION_EXAMPLES).map((s) => ({ + path: s.path, + summary: s.summary, + ...(s.description ? { description: s.description } : {}), + ...(s.language ? { language: s.language } : {}) + })) + }) + } +} satisfies SessionTool<{}> + export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ requires: NONE, def: searchHubScriptsToolDef, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { - content: 'Searching for hub scripts related to "' + args.query + '"...' - }) const parsedArgs = searchHubScriptsSchema.parse(args) - const scripts = await ScriptService.queryHubScripts({ - text: parsedArgs.query, - kind: 'script' - }) + // The hub's own query param is `app`; the tool exposes it as `integration`, + // since `app` already means a Windmill app everywhere else in this surface. + const { query, integration: app } = parsedArgs + if (!query && !app) { + return 'Pass query (what the script should do), integration (a slug to list), or both.' + } + const subject = query ? `"${query}"` : `the ${app} integration` + toolCallbacks.setToolStatus(toolId, { content: `Searching hub scripts for ${subject}...` }) + // Started alongside the search, not after it: this is where the model learns a + // slug exists, so it is where it has to learn how much get_hub_integration has + // to add for that slug. One cached fetch per session either way. + const documented = documentedIntegrations() + + // Listing an integration goes through the hub's top-scripts endpoint rather + // than the semantic one: it takes no query, and it applies no similarity + // floor, so the near-misses worth reading as examples of how the integration + // is used survive instead of being cut. + const scripts: HubScriptHit[] = query + ? await ScriptService.queryHubScripts({ text: query, kind: 'script', app }) + : (( + await ScriptService.getTopHubScripts({ + app, + kind: 'script', + limit: MAX_BROWSED_HUB_SCRIPTS + }) + ).asks ?? []) + + if (scripts.length === 0) { + // A whiffed search still leaves the integration browsable, which is what + // turns "no exact match" into a worked example to follow. Suggest against + // the slug too, so a browse for a misremembered one lands on the real name + // instead of dead-ending on an empty list. + const suggested = await suggestHubIntegrations([app, query].filter(Boolean).join(' ')) + toolCallbacks.setToolStatus(toolId, { content: `No hub script found for ${subject}` }) + return JSON.stringify({ results: [], suggested_integrations: suggested }) + } + // Each result costs a content fetch, so cap the fan-out when content is wanted. - const matches = withContent ? scripts.slice(0, 3) : scripts + const matches = withContent ? scripts.slice(0, MAX_FETCHED_HUB_SCRIPTS) : scripts toolCallbacks.setToolStatus(toolId, { - content: `Found ${matches.length} script${matches.length === 1 ? '' : 's'} in the hub related to "${parsedArgs.query}"` + content: `Found ${matches.length} hub script${matches.length === 1 ? '' : 's'} for ${subject}` }) + const documentedSlugs = await documented const results = await Promise.all( matches.map(async (s) => { const path = hubScriptPath(s) + const base = { + path, + summary: s.summary, + integration: s.app, + ...(documentedSlugs.has(s.app.toLowerCase()) ? { documented: true } : {}), + ...(s.description ? { description: s.description } : {}) + } if (!withContent) { - return { path, summary: s.summary } + return base } // The content fetch, not the listing above: these are the few candidates // the AI pulled to choose between, which is the closest signal we have. @@ -1567,18 +1853,17 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ // get_full, not the raw content endpoint: callers are told to match the // script's language, which raw content does not carry. const hub = await ScriptService.getHubScriptByPath({ path }) - return { path, summary: s.summary, language: hub.language, content: hub.content } + return { ...base, language: hub.language, content: hub.content } } catch (err) { // One unreachable script must not sink the whole search. return { - path, - summary: s.summary, + ...base, error: `Could not fetch content: ${err instanceof Error ? err.message : String(err)}` } } }) ) - return JSON.stringify(results) + return JSON.stringify({ results }) } })