mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
improvement(tools): retire direct execution (#7207)
* improvement(tools): retire direct execution * fix(tools): update operation model input test * fix(browser-use): validate operation payloads * fix(tools): address provider operation review * fix(tools): harden provider operation contracts * fix(tools): close operation lifecycle gaps * fix(tools): update supabase buckets atomically
This commit is contained in:
committed by
GitHub
parent
cb28b11759
commit
9f594c9116
@@ -19,6 +19,10 @@ When the user asks you to create a block:
|
||||
|
||||
Blocks depend on tool outputs. If the underlying tool response schema is not documented or live-verified, you MUST tell the user instead of guessing block outputs.
|
||||
|
||||
When block work changes tool execution, same-process work must use a registered
|
||||
`InternalToolConfig.operation`. Never add a Sim `/api/...` self-hop or the retired
|
||||
`directExecution` property.
|
||||
|
||||
- Do NOT invent block outputs for undocumented tool responses
|
||||
- Do NOT describe unknown JSON shapes as if they were confirmed
|
||||
- Do NOT wire fields into the block just because they seem likely to exist
|
||||
|
||||
@@ -68,7 +68,7 @@ Choose the tool boundary before writing the declaration:
|
||||
- Use `ToolConfig.request` only for an absolute external HTTP(S) provider endpoint.
|
||||
|
||||
Never point a tool at `/api/...`, construct an absolute URL back to Sim, declare
|
||||
`request.internal`, or add an API route merely to reuse code, normalize files, or authorize
|
||||
`request.internal`, add the retired `directExecution` property, or add an API route merely to reuse code, normalize files, or authorize
|
||||
resources. A real external/browser route and an in-process tool may share the same operation, but
|
||||
neither calls the other. Follow the full transport and handler rules in the `add-tools` skill.
|
||||
|
||||
@@ -171,7 +171,7 @@ Hard rules:
|
||||
- Never substitute secret plaintext into source or serialize plaintext provenance.
|
||||
- Never hand-roll private provenance headers/envelopes; the shared `executeTool` boundary owns
|
||||
transport and strips private metadata from functional results.
|
||||
- Never attach private provenance to an external URL or to `directExecution`. Project proven
|
||||
- Never attach private provenance to an external URL. Project proven
|
||||
model-visible external fields with `request.modelInput`; otherwise preserve ordinary request
|
||||
semantics. Use a registered in-process operation when encrypted provenance must cross the
|
||||
boundary.
|
||||
@@ -607,7 +607,7 @@ If creating V2 versions (API-aligned outputs):
|
||||
- [ ] Chose exactly one boundary per tool: registered `InternalToolConfig.operation` or absolute
|
||||
external HTTP(S) `ToolConfig.request`
|
||||
- [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal`, or
|
||||
has an HTTP fallback for an in-process operation
|
||||
`directExecution`, or has an HTTP fallback for an in-process operation
|
||||
- [ ] All params have correct visibility
|
||||
- [ ] All nullable fields use `?? null`
|
||||
- [ ] All optional outputs have `optional: true`
|
||||
|
||||
@@ -54,7 +54,7 @@ Every tool must use exactly one of these configurations:
|
||||
HTTP(S) provider endpoint.
|
||||
|
||||
Never set a tool URL to `/api/...`, construct an absolute URL back to Sim, declare
|
||||
`request.internal`, import a route module, or create an API route merely to normalize files,
|
||||
`request.internal`, add the retired `directExecution` property, import a route module, or create an API route merely to normalize files,
|
||||
authorize access, or reuse server code. A real browser/API route may remain as a thin adapter, but
|
||||
the route and the tool must call the same operation directly. A true cross-process/capability
|
||||
boundary uses an explicit server client and is not disguised as a tool self-hop.
|
||||
@@ -524,6 +524,7 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`
|
||||
HTTP(S) `ToolConfig.request`
|
||||
- [ ] No tool request points to `/api/...`, constructs a URL back to Sim, or declares
|
||||
`request.internal`
|
||||
- [ ] No tool declares `directExecution`; in-process work uses a registered operation
|
||||
- [ ] All params have explicit `required: true` or `required: false`
|
||||
- [ ] All params have appropriate `visibility`
|
||||
- [ ] All nullable response fields use `?? null`
|
||||
|
||||
@@ -508,7 +508,8 @@ Two rules the checks enforce:
|
||||
Webhook and polling routes are legitimate external ingress boundaries. They must not call this
|
||||
Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation
|
||||
or authorized application use case and call it directly from the trigger handler and any other
|
||||
server adapter. HTTP is reserved for an actual cross-process/capability boundary.
|
||||
server adapter. HTTP is reserved for an actual cross-process/capability boundary. Tool work uses a
|
||||
registered `InternalToolConfig.operation`; the retired `directExecution` property must not return.
|
||||
|
||||
### Trigger Definition
|
||||
- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
|
||||
|
||||
@@ -11,7 +11,12 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec
|
||||
|
||||
> Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**.
|
||||
|
||||
`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules.
|
||||
`@/tools/registry` is a ~9,000-line barrel importing every tool. External `ToolConfig` entries mix
|
||||
plain data (`params`, `outputs`, `name`) with request/response closures, while
|
||||
`InternalToolConfig` entries contain semantic input projection and load their server implementation
|
||||
through `lib/internal/tool-operations/registry.server.ts`. Request closures can still reach SDK
|
||||
clients, API helpers, and parsers, which is what makes the executable barrel expensive: reaching it
|
||||
costs ~4,700 additional modules.
|
||||
|
||||
`getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in.
|
||||
|
||||
@@ -95,4 +100,5 @@ The canvas route reached the registry through **four** redundant edges — `prov
|
||||
|
||||
Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph.
|
||||
|
||||
If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths.
|
||||
If it genuinely executes — builds an external request, transforms a response, or dispatches a
|
||||
registered internal operation — use `getTool`, and keep that file off client-reachable paths.
|
||||
|
||||
@@ -159,8 +159,9 @@ search, extraction, or "AI-powered" marketing terminology.
|
||||
- [ ] Sim-owned durable writes and internal execution handoffs that can enter workflows/models use
|
||||
field-scoped `request.secretProvenance`; authenticated receivers validate the exact selection
|
||||
and scope, strip private metadata, and persist, import, or propagate it at the owning boundary
|
||||
- [ ] Private provenance is never attached to external URLs or `directExecution`; proven
|
||||
model-visible external fields use projection, while other external inputs remain unchanged
|
||||
- [ ] Private provenance is never attached to external URLs; registered in-process operations
|
||||
preserve it through `operation.modelInput` / `operation.secretProvenance`, while proven
|
||||
model-visible external fields use request projection and other external inputs remain unchanged
|
||||
- [ ] No tool performs raw secret plaintext/source substitution or serializes plaintext provenance
|
||||
- [ ] No `transformResponse` or tool-local helper blanket-sanitizes ordinary third-party results;
|
||||
only execution-scoped, activated Sim provenance is projected at shared model/log boundaries
|
||||
|
||||
@@ -31,11 +31,11 @@ vi.mock('@/lib/oauth/credential-service', () => ({
|
||||
resolveCredentialAccessToken: mockResolveCredentialAccessToken,
|
||||
resolveOAuthAccountId: mockResolveOAuthAccountId,
|
||||
}))
|
||||
vi.mock('@/tools/netsuite/get_async_status', () => ({
|
||||
netsuiteGetAsyncStatusTool: { directExecution: mockGetAsyncStatus },
|
||||
vi.mock('@/lib/internal/netsuite/operations/get-async-status', () => ({
|
||||
executeNetsuiteGetAsyncStatusOperation: mockGetAsyncStatus,
|
||||
}))
|
||||
vi.mock('@/tools/netsuite/list_record_types', () => ({
|
||||
netsuiteListRecordTypesTool: { directExecution: mockListRecordTypes },
|
||||
vi.mock('@/lib/internal/netsuite/operations/list-record-types', () => ({
|
||||
executeNetsuiteListRecordTypesOperation: mockListRecordTypes,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/tools/netsuite/objects/route'
|
||||
|
||||
@@ -12,9 +12,9 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { executeNetsuiteGetAsyncStatusOperation } from '@/lib/internal/netsuite/operations/get-async-status'
|
||||
import { executeNetsuiteListRecordTypesOperation } from '@/lib/internal/netsuite/operations/list-record-types'
|
||||
import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
|
||||
import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status'
|
||||
import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types'
|
||||
import type { NetSuiteAuthParams } from '@/tools/netsuite/types'
|
||||
import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
@@ -180,14 +180,13 @@ async function executeDiscoveryTool(
|
||||
throwIfAborted(signal)
|
||||
switch (body.kind) {
|
||||
case 'record_types': {
|
||||
const execute = netsuiteListRecordTypesTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite record-type tool is not executable')
|
||||
return execute(auth, signal)
|
||||
return executeNetsuiteListRecordTypesOperation(auth, signal)
|
||||
}
|
||||
case 'async_tasks': {
|
||||
const execute = netsuiteGetAsyncStatusTool.directExecution
|
||||
if (!execute) throw new Error('NetSuite asynchronous-status tool is not executable')
|
||||
return execute({ ...auth, jobId: body.jobId, view: 'tasks' }, signal)
|
||||
return executeNetsuiteGetAsyncStatusOperation(
|
||||
{ ...auth, jobId: body.jobId, view: 'tasks' },
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -416,12 +416,12 @@ export function PreviewWorkflow({
|
||||
|
||||
// Check for direct error on the subflow block itself (e.g., loop resolution errors)
|
||||
// before falling back to children-derived status
|
||||
const directExecution = blockExecutionMap.get(blockId)
|
||||
const blockExecution = blockExecutionMap.get(blockId)
|
||||
const subflowExecutionStatus: ExecutionStatus | undefined =
|
||||
directExecution?.status === 'error'
|
||||
blockExecution?.status === 'error'
|
||||
? 'error'
|
||||
: (getSubflowExecutionStatus(blockId) ??
|
||||
(directExecution ? (directExecution.status as ExecutionStatus) : undefined))
|
||||
(blockExecution ? (blockExecution.status as ExecutionStatus) : undefined))
|
||||
|
||||
nodeArray.push({
|
||||
id: blockId,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
executeBitbucketGetFileOperation,
|
||||
executeBitbucketGetPipelineStepLogOperation,
|
||||
executeBitbucketGetPullRequestDiffOperation,
|
||||
executeBitbucketGetPullRequestDiffstatOperation,
|
||||
} from '@/lib/internal/bitbucket/operations'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeBitbucketTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'bitbucket_get_file':
|
||||
return executeToolOperationImplementation(executeBitbucketGetFileOperation, request)
|
||||
case 'bitbucket_get_pipeline_step_log':
|
||||
return executeToolOperationImplementation(
|
||||
executeBitbucketGetPipelineStepLogOperation,
|
||||
request
|
||||
)
|
||||
case 'bitbucket_get_pull_request_diff':
|
||||
return executeToolOperationImplementation(
|
||||
executeBitbucketGetPullRequestDiffOperation,
|
||||
request
|
||||
)
|
||||
case 'bitbucket_get_pull_request_diffstat':
|
||||
return executeToolOperationImplementation(
|
||||
executeBitbucketGetPullRequestDiffstatOperation,
|
||||
request
|
||||
)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported bitbucket tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { fileUrl } from '@/tools/bitbucket/get_file'
|
||||
import type { BitbucketGetFileParams } from '@/tools/bitbucket/types'
|
||||
import {
|
||||
assertBitbucketResponseOk,
|
||||
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
|
||||
bitbucketHeaders,
|
||||
bitbucketHeadRange,
|
||||
bitbucketJson,
|
||||
bitbucketMaxCharacters,
|
||||
bitbucketRawHead,
|
||||
normalizeBitbucketFileMetadata,
|
||||
} from '@/tools/bitbucket/utils'
|
||||
|
||||
export const executeBitbucketGetFileOperation: InternalToolOperationImplementation<
|
||||
BitbucketGetFileParams
|
||||
> = async (params, signal) => {
|
||||
bitbucketMaxCharacters(params.maxCharacters)
|
||||
const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server')
|
||||
const metadataResponse = await secureBitbucketRead(
|
||||
fileUrl(params, true),
|
||||
bitbucketHeaders(params.accessToken),
|
||||
256 * 1024,
|
||||
{ stripAuthOnRedirect: true, signal }
|
||||
)
|
||||
await assertBitbucketResponseOk(metadataResponse)
|
||||
const metadata = normalizeBitbucketFileMetadata(await bitbucketJson(metadataResponse))
|
||||
if (metadata.isBinary === true) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: null,
|
||||
binary: true,
|
||||
truncated: metadata.size === null ? null : metadata.size > 0,
|
||||
returnedBytes: 0,
|
||||
fullBytes: metadata.size,
|
||||
contentType: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const rawResponse = await secureBitbucketRead(
|
||||
fileUrl(params),
|
||||
bitbucketHeaders(params.accessToken, {
|
||||
json: false,
|
||||
range: bitbucketHeadRange(params.maxCharacters),
|
||||
}),
|
||||
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
|
||||
{ stripAuthOnRedirect: true, signal }
|
||||
)
|
||||
await assertBitbucketResponseOk(rawResponse)
|
||||
const raw = await bitbucketRawHead(rawResponse, params.maxCharacters, metadata.isBinary)
|
||||
const fullBytes = raw.fullBytes ?? metadata.size
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
...raw,
|
||||
truncated:
|
||||
raw.binary === true && raw.truncated === null && fullBytes !== null
|
||||
? fullBytes > 0
|
||||
: raw.truncated,
|
||||
fullBytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import {
|
||||
BITBUCKET_RANGE_NOT_SATISFIABLE,
|
||||
EMPTY_CONTENT_RANGE_PATTERN,
|
||||
stepLogUrl,
|
||||
} from '@/tools/bitbucket/get_pipeline_step_log'
|
||||
import type { BitbucketGetPipelineStepLogParams } from '@/tools/bitbucket/types'
|
||||
import {
|
||||
assertBitbucketResponseOk,
|
||||
BITBUCKET_LOG_TRANSFER_MAX_BYTES,
|
||||
bitbucketHeaders,
|
||||
bitbucketMaxCharacters,
|
||||
bitbucketRawTail,
|
||||
bitbucketTailRange,
|
||||
} from '@/tools/bitbucket/utils'
|
||||
|
||||
export const executeBitbucketGetPipelineStepLogOperation: InternalToolOperationImplementation<
|
||||
BitbucketGetPipelineStepLogParams
|
||||
> = async (params, signal) => {
|
||||
bitbucketMaxCharacters(params.maxCharacters, true)
|
||||
const { secureBitbucketRead } = await import('@/tools/bitbucket/utils.server')
|
||||
const response = await secureBitbucketRead(
|
||||
stepLogUrl(params),
|
||||
bitbucketHeaders(params.accessToken, {
|
||||
json: false,
|
||||
range: bitbucketTailRange(params.maxCharacters),
|
||||
}),
|
||||
BITBUCKET_LOG_TRANSFER_MAX_BYTES,
|
||||
{ stripAuthOnRedirect: true, signal }
|
||||
)
|
||||
if (
|
||||
response.status === BITBUCKET_RANGE_NOT_SATISFIABLE &&
|
||||
EMPTY_CONTENT_RANGE_PATTERN.test(response.headers.get('content-range') ?? '')
|
||||
) {
|
||||
await response.body?.cancel()
|
||||
return { success: true, output: { log: '', truncated: false, totalBytes: 0 } }
|
||||
}
|
||||
await assertBitbucketResponseOk(response)
|
||||
return { success: true, output: await bitbucketRawTail(response, params.maxCharacters) }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { pullRequestDiffUrl, transformDiff } from '@/tools/bitbucket/get_pull_request_diff'
|
||||
import type { BitbucketGetPullRequestDiffParams } from '@/tools/bitbucket/types'
|
||||
import {
|
||||
assertBitbucketResponseOk,
|
||||
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
|
||||
bitbucketHeaders,
|
||||
bitbucketHeadRange,
|
||||
bitbucketRepositoryPathQuery,
|
||||
} from '@/tools/bitbucket/utils'
|
||||
|
||||
export const executeBitbucketGetPullRequestDiffOperation: InternalToolOperationImplementation<
|
||||
BitbucketGetPullRequestDiffParams
|
||||
> = async (params, signal) => {
|
||||
const { secureBitbucketPullRequestRedirect } = await import('@/tools/bitbucket/utils.server')
|
||||
const headers = bitbucketHeaders(params.accessToken, {
|
||||
json: false,
|
||||
range: bitbucketHeadRange(params.maxCharacters),
|
||||
})
|
||||
const response = await secureBitbucketPullRequestRedirect(
|
||||
pullRequestDiffUrl(params),
|
||||
params.workspaceSlug,
|
||||
params.repoSlug,
|
||||
'diff',
|
||||
headers,
|
||||
BITBUCKET_RAW_TRANSFER_MAX_BYTES,
|
||||
{
|
||||
signal,
|
||||
targetQuery: { path: bitbucketRepositoryPathQuery(params.path), binary: 'false' },
|
||||
}
|
||||
)
|
||||
await assertBitbucketResponseOk(response)
|
||||
return transformDiff(response, params.maxCharacters)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import {
|
||||
decodedPathname,
|
||||
pullRequestDiffstatUrl,
|
||||
} from '@/tools/bitbucket/get_pull_request_diffstat'
|
||||
import type { BitbucketPaginatedPullRequestParams } from '@/tools/bitbucket/types'
|
||||
import {
|
||||
assertBitbucketResponseOk,
|
||||
bitbucketHeaders,
|
||||
bitbucketJson,
|
||||
bitbucketPageLength,
|
||||
normalizeBitbucketDiffstat,
|
||||
normalizeBitbucketPage,
|
||||
validateBitbucketPullRequestRedirect,
|
||||
} from '@/tools/bitbucket/utils'
|
||||
|
||||
export const executeBitbucketGetPullRequestDiffstatOperation: InternalToolOperationImplementation<
|
||||
BitbucketPaginatedPullRequestParams
|
||||
> = async (params, signal) => {
|
||||
const {
|
||||
resolveBitbucketPullRequestRedirect,
|
||||
secureBitbucketPullRequestRedirect,
|
||||
secureBitbucketRead,
|
||||
} = await import('@/tools/bitbucket/utils.server')
|
||||
const initialUrl = pullRequestDiffstatUrl(params)
|
||||
const headers = bitbucketHeaders(params.accessToken)
|
||||
let response: Response
|
||||
if (params.nextUrl !== undefined) {
|
||||
const continuation = validateBitbucketPullRequestRedirect(
|
||||
params.nextUrl,
|
||||
params.workspaceSlug,
|
||||
params.repoSlug,
|
||||
'diffstat'
|
||||
)
|
||||
const resolvedTarget = await resolveBitbucketPullRequestRedirect(
|
||||
initialUrl,
|
||||
params.workspaceSlug,
|
||||
params.repoSlug,
|
||||
'diffstat',
|
||||
headers,
|
||||
{ signal }
|
||||
)
|
||||
if (decodedPathname(continuation) !== decodedPathname(resolvedTarget)) {
|
||||
throw new Error('nextUrl does not belong to this Bitbucket pull request diffstat')
|
||||
}
|
||||
response = await secureBitbucketRead(continuation, headers, 2 * 1024 * 1024, {
|
||||
maxRedirects: 0,
|
||||
signal,
|
||||
})
|
||||
} else {
|
||||
response = await secureBitbucketPullRequestRedirect(
|
||||
initialUrl,
|
||||
params.workspaceSlug,
|
||||
params.repoSlug,
|
||||
'diffstat',
|
||||
headers,
|
||||
2 * 1024 * 1024,
|
||||
{
|
||||
signal,
|
||||
targetQuery: { pagelen: String(bitbucketPageLength(params.pageLen)) },
|
||||
}
|
||||
)
|
||||
}
|
||||
await assertBitbucketResponseOk(response)
|
||||
return {
|
||||
success: true,
|
||||
output: normalizeBitbucketPage(await bitbucketJson(response), normalizeBitbucketDiffstat),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { executeBitbucketGetFileOperation } from '@/lib/internal/bitbucket/operations/get-file'
|
||||
export { executeBitbucketGetPipelineStepLogOperation } from '@/lib/internal/bitbucket/operations/get-pipeline-step-log'
|
||||
export { executeBitbucketGetPullRequestDiffOperation } from '@/lib/internal/bitbucket/operations/get-pull-request-diff'
|
||||
export { executeBitbucketGetPullRequestDiffstatOperation } from '@/lib/internal/bitbucket/operations/get-pull-request-diffstat'
|
||||
@@ -0,0 +1,15 @@
|
||||
import { executeRunTaskOperation } from '@/lib/internal/browser-use/operations/run-task'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeBrowserUseTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'browser_use_run_task':
|
||||
return executeToolOperationImplementation(executeRunTaskOperation, request)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported browser-use tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { executeRunTaskOperation } from '@/lib/internal/browser-use/operations/run-task'
|
||||
|
||||
const mockFetch = vi.fn<typeof fetch>()
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('executeRunTaskOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('validates provider payloads while preserving the documented task output', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'session-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
status: 'finished',
|
||||
sessionId: 'session-1',
|
||||
output: { result: 'complete' },
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
memory: 'Opened the page',
|
||||
evaluationPreviousGoal: 'Succeeded',
|
||||
nextGoal: 'Finish',
|
||||
url: 'https://example.com',
|
||||
actions: ['{"click":{"index":1}}'],
|
||||
providerField: 'preserved',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
liveUrl: 'https://live.browser-use.com/session-1',
|
||||
publicShareUrl: 'https://browser-use.com/share/session-1',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
output: {
|
||||
id: 'task-1',
|
||||
success: true,
|
||||
output: { result: 'complete' },
|
||||
steps: [
|
||||
{
|
||||
number: 1,
|
||||
memory: 'Opened the page',
|
||||
evaluationPreviousGoal: 'Succeeded',
|
||||
nextGoal: 'Finish',
|
||||
url: 'https://example.com',
|
||||
actions: ['{"click":{"index":1}}'],
|
||||
providerField: 'preserved',
|
||||
},
|
||||
],
|
||||
liveUrl: 'https://live.browser-use.com/session-1',
|
||||
shareUrl: 'https://browser-use.com/share/session-1',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
error: undefined,
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
for (const [, request] of mockFetch.mock.calls) {
|
||||
expect(request).toEqual(
|
||||
expect.objectContaining({
|
||||
redirect: 'error',
|
||||
headers: expect.objectContaining({ 'X-Browser-Use-API-Key': 'api-key' }),
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the created profile session to fetch the live URL when task status omits it', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'profile-session' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ status: 'finished', output: 'done' }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
liveUrl: 'https://live.browser-use.com/profile-session',
|
||||
publicShareUrl: 'https://browser-use.com/share/profile-session',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
|
||||
const result = await executeRunTaskOperation({
|
||||
task: 'Open the page',
|
||||
apiKey: 'api-key',
|
||||
profile_id: 'profile-1',
|
||||
})
|
||||
|
||||
expect(result.output).toMatchObject({
|
||||
sessionId: 'profile-session',
|
||||
liveUrl: 'https://live.browser-use.com/profile-session',
|
||||
shareUrl: 'https://browser-use.com/share/profile-session',
|
||||
})
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'https://api.browser-use.com/api/v2/sessions/profile-session',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an actionable error for a terminal failed task', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'task-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ status: 'failed', output: 'Navigation could not reach the target' })
|
||||
)
|
||||
|
||||
const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: 'BrowserUse task failed: Navigation could not reach the target',
|
||||
output: {
|
||||
success: false,
|
||||
output: 'Navigation could not reach the target',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed successful create-task response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ sessionId: 'session-1' }))
|
||||
|
||||
await expect(
|
||||
executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
output: {
|
||||
id: '',
|
||||
success: false,
|
||||
output: null,
|
||||
steps: [],
|
||||
liveUrl: null,
|
||||
shareUrl: null,
|
||||
sessionId: null,
|
||||
},
|
||||
error: 'BrowserUse returned an invalid create-task response',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes non-Error provider failures', async () => {
|
||||
mockFetch.mockRejectedValueOnce('provider unavailable')
|
||||
|
||||
await expect(
|
||||
executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
output: {
|
||||
id: '',
|
||||
success: false,
|
||||
output: null,
|
||||
steps: [],
|
||||
liveUrl: null,
|
||||
shareUrl: null,
|
||||
sessionId: null,
|
||||
},
|
||||
error: 'Error creating task: provider unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an HTTP error', new Response('rejected', { status: 400, statusText: 'Bad Request' })],
|
||||
['a schema-invalid success', jsonResponse({ sessionId: 'session-1' })],
|
||||
])('stops a profile session when task creation returns %s', async (_case, taskResponse) => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'profile-session' }))
|
||||
.mockResolvedValueOnce(taskResponse)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
|
||||
const result = await executeRunTaskOperation({
|
||||
task: 'Open the page',
|
||||
apiKey: 'api-key',
|
||||
profile_id: 'profile-1',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://api.browser-use.com/api/v2/sessions/profile-session',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action: 'stop' }),
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates cancellation while still stopping a created profile session', async () => {
|
||||
const controller = new AbortController()
|
||||
const abortError = new DOMException('cancelled', 'AbortError')
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'profile-session' }))
|
||||
.mockImplementationOnce(async (_input, request) => {
|
||||
expect(request?.signal).toBe(controller.signal)
|
||||
controller.abort(abortError)
|
||||
throw abortError
|
||||
})
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
|
||||
await expect(
|
||||
executeRunTaskOperation(
|
||||
{ task: 'Open the page', apiKey: 'api-key', profile_id: 'profile-1' },
|
||||
controller.signal
|
||||
)
|
||||
).rejects.toBe(abortError)
|
||||
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://api.browser-use.com/api/v2/sessions/profile-session',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
)
|
||||
expect(mockFetch.mock.calls[2]?.[1]?.signal).not.toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('stops an automatically created task session when polling is cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
const abortError = new DOMException('cancelled', 'AbortError')
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'task-session' }))
|
||||
.mockImplementationOnce(async () => {
|
||||
controller.abort(abortError)
|
||||
throw abortError
|
||||
})
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
|
||||
await expect(
|
||||
executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' }, controller.signal)
|
||||
).rejects.toBe(abortError)
|
||||
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://api.browser-use.com/api/v2/sessions/task-session',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action: 'stop' }),
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('stops an automatically created task session when polling times out', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(1_000_000_000_000_000)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(jsonResponse({ id: 'task-1', sessionId: 'task-session' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ status: 'running', sessionId: 'task-session' }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({ shareUrl: 'https://browser-use.com/share/task-session' })
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
|
||||
const result = await executeRunTaskOperation({ task: 'Open the page', apiKey: 'api-key' })
|
||||
now.mockRestore()
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining('Task did not complete within the maximum polling time'),
|
||||
})
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'https://api.browser-use.com/api/v2/sessions/task-session',
|
||||
expect.objectContaining({ method: 'PATCH', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,580 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { z } from 'zod'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type {
|
||||
BrowserUseRunTaskParams,
|
||||
BrowserUseRunTaskResponse,
|
||||
BrowserUseTaskStep,
|
||||
} from '@/tools/browser_use/types'
|
||||
|
||||
const logger = createLogger('BrowserUseTool')
|
||||
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
const MAX_POLL_TIME_MS = getMaxExecutionTimeout()
|
||||
const MAX_CONSECUTIVE_ERRORS = 3
|
||||
const API_BASE = 'https://api.browser-use.com/api/v2'
|
||||
|
||||
const createSessionResponseSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
const sessionDetailsResponseSchema = z.object({
|
||||
liveUrl: z.string().nullable().optional(),
|
||||
publicShareUrl: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const taskStepSchema: z.ZodType<BrowserUseTaskStep> = z
|
||||
.object({
|
||||
number: z.number(),
|
||||
memory: z.string(),
|
||||
evaluationPreviousGoal: z.string(),
|
||||
nextGoal: z.string(),
|
||||
url: z.string(),
|
||||
screenshotUrl: z.string().nullable().optional(),
|
||||
actions: z.array(z.string()),
|
||||
duration: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const taskStatusResponseSchema = z.object({
|
||||
status: z.string(),
|
||||
sessionId: z.string().nullable().optional(),
|
||||
output: z.unknown().optional(),
|
||||
steps: z.array(taskStepSchema).optional(),
|
||||
})
|
||||
const SESSION_CLEANUP_TIMEOUT_MS = 10_000
|
||||
|
||||
const createTaskResponseSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
sessionId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const shareResponseSchema = z.object({
|
||||
shareUrl: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
interface BrowserUseTaskRequest {
|
||||
task: string
|
||||
sessionId?: string
|
||||
llm?: string
|
||||
startUrl?: string
|
||||
maxSteps?: number
|
||||
structuredOutput?: string
|
||||
flashMode?: boolean
|
||||
thinking?: boolean
|
||||
vision?: boolean | 'auto'
|
||||
systemPromptExtension?: string
|
||||
highlightElements?: boolean
|
||||
allowedDomains?: string[]
|
||||
secrets?: Record<string, unknown>
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
interface BrowserUseFetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH'
|
||||
body?: unknown
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
async function fetchBrowserUse(
|
||||
path: string,
|
||||
apiKey: string,
|
||||
options: BrowserUseFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
options.signal?.throwIfAborted()
|
||||
const hasBody = options.body !== undefined
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: {
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
'X-Browser-Use-API-Key': apiKey,
|
||||
},
|
||||
...(hasBody ? { body: JSON.stringify(options.body) } : {}),
|
||||
redirect: 'error',
|
||||
signal: options.signal,
|
||||
})
|
||||
options.signal?.throwIfAborted()
|
||||
return response
|
||||
}
|
||||
|
||||
async function waitForNextPoll(signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
return
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
|
||||
let abortHandler: (() => void) | undefined
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
abortHandler = () =>
|
||||
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'))
|
||||
signal.addEventListener('abort', abortHandler, { once: true })
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.race([sleep(POLL_INTERVAL_MS), aborted])
|
||||
} finally {
|
||||
if (abortHandler) signal.removeEventListener('abort', abortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
async function createSessionWithProfile(
|
||||
profileId: string,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ sessionId: string } | { error: string }> {
|
||||
try {
|
||||
const response = await fetchBrowserUse('/sessions', apiKey, {
|
||||
method: 'POST',
|
||||
body: { profileId: profileId.trim() },
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`Failed to create session with profile: ${errorText}`)
|
||||
return { error: `Failed to create session with profile: ${response.statusText}` }
|
||||
}
|
||||
|
||||
const parsed = createSessionResponseSchema.safeParse(await response.json())
|
||||
signal?.throwIfAborted()
|
||||
if (!parsed.success) {
|
||||
logger.error('BrowserUse returned an invalid create-session response')
|
||||
return { error: 'BrowserUse returned an invalid create-session response' }
|
||||
}
|
||||
const data = parsed.data
|
||||
logger.info(`Created session ${data.id} with profile ${profileId}`)
|
||||
return { sessionId: data.id }
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
logger.error('Error creating session with profile:', error)
|
||||
return { error: `Error creating session: ${getErrorMessage(error, 'Unknown error')}` }
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSession(sessionId: string, apiKey: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetchBrowserUse(`/sessions/${encodeURIComponent(sessionId)}`, apiKey, {
|
||||
method: 'PATCH',
|
||||
body: { action: 'stop' },
|
||||
signal: AbortSignal.timeout(SESSION_CLEANUP_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
logger.info(`Stopped session ${sessionId}`)
|
||||
} else {
|
||||
logger.warn(`Failed to stop session ${sessionId}: ${response.statusText}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.warn(`Error stopping session ${sessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessionLiveUrl(
|
||||
sessionId: string,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ liveUrl: string | null; publicShareUrl: string | null }> {
|
||||
try {
|
||||
const response = await fetchBrowserUse(`/sessions/${encodeURIComponent(sessionId)}`, apiKey, {
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
return { liveUrl: null, publicShareUrl: null }
|
||||
}
|
||||
const parsed = sessionDetailsResponseSchema.safeParse(await response.json())
|
||||
signal?.throwIfAborted()
|
||||
if (!parsed.success) {
|
||||
logger.warn(`BrowserUse returned an invalid session response for ${sessionId}`)
|
||||
return { liveUrl: null, publicShareUrl: null }
|
||||
}
|
||||
const data = parsed.data
|
||||
return {
|
||||
liveUrl: data.liveUrl ?? null,
|
||||
publicShareUrl: data.publicShareUrl ?? null,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
logger.warn(`Error fetching session ${sessionId}:`, error)
|
||||
return { liveUrl: null, publicShareUrl: null }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSecrets(
|
||||
variables: BrowserUseRunTaskParams['variables']
|
||||
): Record<string, unknown> {
|
||||
const secrets: Record<string, unknown> = {}
|
||||
if (!variables) return secrets
|
||||
|
||||
if (Array.isArray(variables)) {
|
||||
for (const row of variables) {
|
||||
const cells =
|
||||
typeof row.cells === 'object' && row.cells !== null
|
||||
? (row.cells as Record<string, unknown>)
|
||||
: undefined
|
||||
const key = cells?.Key ?? row.Key
|
||||
const value = cells?.Value ?? row.Value
|
||||
if (key && value !== undefined) {
|
||||
secrets[String(key)] = value
|
||||
}
|
||||
}
|
||||
} else if (typeof variables === 'object') {
|
||||
for (const [k, v] of Object.entries(variables)) {
|
||||
if (typeof v === 'string') secrets[k] = v
|
||||
}
|
||||
}
|
||||
return secrets
|
||||
}
|
||||
|
||||
function parseAllowedDomains(input?: string | string[]): string[] | undefined {
|
||||
if (!input) return undefined
|
||||
const arr = Array.isArray(input)
|
||||
? input
|
||||
: input
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
return arr.length > 0 ? arr : undefined
|
||||
}
|
||||
|
||||
function buildRequestBody(
|
||||
params: BrowserUseRunTaskParams,
|
||||
sessionId?: string
|
||||
): BrowserUseTaskRequest {
|
||||
const body: BrowserUseTaskRequest = { task: params.task }
|
||||
|
||||
if (sessionId) body.sessionId = sessionId
|
||||
if (params.model) body.llm = params.model
|
||||
if (params.startUrl?.trim()) body.startUrl = params.startUrl.trim()
|
||||
if (typeof params.maxSteps === 'number' && params.maxSteps > 0) body.maxSteps = params.maxSteps
|
||||
if (params.structuredOutput) body.structuredOutput = params.structuredOutput
|
||||
if (typeof params.flashMode === 'boolean') body.flashMode = params.flashMode
|
||||
if (typeof params.thinking === 'boolean') body.thinking = params.thinking
|
||||
if (typeof params.vision === 'boolean' || params.vision === 'auto') body.vision = params.vision
|
||||
if (params.systemPromptExtension) body.systemPromptExtension = params.systemPromptExtension
|
||||
if (typeof params.highlightElements === 'boolean')
|
||||
body.highlightElements = params.highlightElements
|
||||
|
||||
const allowedDomains = parseAllowedDomains(params.allowedDomains)
|
||||
if (allowedDomains) body.allowedDomains = allowedDomains
|
||||
|
||||
const secrets = normalizeSecrets(params.variables)
|
||||
if (Object.keys(secrets).length > 0) body.secrets = secrets
|
||||
|
||||
if (
|
||||
params.metadata &&
|
||||
typeof params.metadata === 'object' &&
|
||||
Object.keys(params.metadata).length > 0
|
||||
)
|
||||
body.metadata = params.metadata
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchTaskStatus(
|
||||
taskId: string,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<
|
||||
{ ok: true; data: z.infer<typeof taskStatusResponseSchema> } | { ok: false; error: string }
|
||||
> {
|
||||
try {
|
||||
const response = await fetchBrowserUse(`/tasks/${encodeURIComponent(taskId)}`, apiKey, {
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` }
|
||||
}
|
||||
|
||||
const parsed = taskStatusResponseSchema.safeParse(await response.json())
|
||||
signal?.throwIfAborted()
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: 'BrowserUse returned an invalid task-status response' }
|
||||
}
|
||||
return { ok: true, data: parsed.data }
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
return { ok: false, error: getErrorMessage(error, 'Network error') }
|
||||
}
|
||||
}
|
||||
|
||||
interface PollResult {
|
||||
success: boolean
|
||||
taskEnded: boolean
|
||||
output: unknown
|
||||
steps: BrowserUseTaskStep[]
|
||||
sessionId: string | null
|
||||
liveUrl: string | null
|
||||
publicShareUrl: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
initialSessionId: string | null
|
||||
signal?: AbortSignal
|
||||
onSessionId: (sessionId: string) => void
|
||||
}
|
||||
|
||||
async function pollForCompletion(
|
||||
taskId: string,
|
||||
apiKey: string,
|
||||
options: PollOptions
|
||||
): Promise<PollResult> {
|
||||
const { initialSessionId, signal, onSessionId } = options
|
||||
let consecutiveErrors = 0
|
||||
let sessionId = initialSessionId
|
||||
let liveUrl: string | null = null
|
||||
let publicShareUrl: string | null = null
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < MAX_POLL_TIME_MS) {
|
||||
signal?.throwIfAborted()
|
||||
const result = await fetchTaskStatus(taskId, apiKey, signal)
|
||||
|
||||
if (!result.ok) {
|
||||
consecutiveErrors++
|
||||
logger.warn(
|
||||
`Error polling task ${taskId} (attempt ${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${result.error}`
|
||||
)
|
||||
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
return {
|
||||
success: false,
|
||||
taskEnded: false,
|
||||
output: null,
|
||||
steps: [],
|
||||
sessionId,
|
||||
liveUrl,
|
||||
publicShareUrl,
|
||||
error: `Failed to poll task status after ${MAX_CONSECUTIVE_ERRORS} attempts: ${result.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
await waitForNextPoll(signal)
|
||||
continue
|
||||
}
|
||||
|
||||
consecutiveErrors = 0
|
||||
const taskData = result.data
|
||||
if (taskData.sessionId) {
|
||||
sessionId = taskData.sessionId
|
||||
onSessionId(taskData.sessionId)
|
||||
}
|
||||
const status = taskData.status
|
||||
|
||||
logger.info(`BrowserUse task ${taskId} status: ${status}`)
|
||||
|
||||
if (sessionId && !liveUrl) {
|
||||
const session = await fetchSessionLiveUrl(sessionId, apiKey, signal)
|
||||
if (session.liveUrl) {
|
||||
liveUrl = session.liveUrl
|
||||
logger.info(`BrowserUse live URL: ${liveUrl}`)
|
||||
}
|
||||
if (session.publicShareUrl) publicShareUrl = session.publicShareUrl
|
||||
}
|
||||
|
||||
if (['finished', 'failed', 'stopped'].includes(status)) {
|
||||
const output = taskData.output ?? null
|
||||
return {
|
||||
success: status === 'finished',
|
||||
taskEnded: true,
|
||||
output,
|
||||
steps: taskData.steps ?? [],
|
||||
sessionId,
|
||||
liveUrl,
|
||||
publicShareUrl,
|
||||
error:
|
||||
status === 'finished'
|
||||
? undefined
|
||||
: typeof output === 'string' && output.trim()
|
||||
? `BrowserUse task ${status}: ${output.trim()}`
|
||||
: `BrowserUse task ${status}`,
|
||||
}
|
||||
}
|
||||
|
||||
await waitForNextPoll(signal)
|
||||
}
|
||||
|
||||
const finalResult = await fetchTaskStatus(taskId, apiKey, signal)
|
||||
if (finalResult.ok && ['finished', 'failed', 'stopped'].includes(finalResult.data.status)) {
|
||||
const status = finalResult.data.status
|
||||
const output = finalResult.data.output ?? null
|
||||
const finalSessionId = finalResult.data.sessionId ?? sessionId
|
||||
if (finalSessionId) onSessionId(finalSessionId)
|
||||
return {
|
||||
success: status === 'finished',
|
||||
taskEnded: true,
|
||||
output,
|
||||
steps: finalResult.data.steps ?? [],
|
||||
sessionId: finalSessionId,
|
||||
liveUrl,
|
||||
publicShareUrl,
|
||||
error:
|
||||
status === 'finished'
|
||||
? undefined
|
||||
: typeof output === 'string' && output.trim()
|
||||
? `BrowserUse task ${status}: ${output.trim()}`
|
||||
: `BrowserUse task ${status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
taskEnded: false,
|
||||
output: null,
|
||||
steps: [],
|
||||
sessionId,
|
||||
liveUrl,
|
||||
publicShareUrl,
|
||||
error: `Task did not complete within the maximum polling time (${MAX_POLL_TIME_MS / 1000}s)`,
|
||||
}
|
||||
}
|
||||
|
||||
async function createShareUrl(
|
||||
sessionId: string,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetchBrowserUse(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/public-share`,
|
||||
apiKey,
|
||||
{
|
||||
method: 'POST',
|
||||
signal,
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(`Failed to create share URL for session ${sessionId}: ${response.statusText}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = shareResponseSchema.safeParse(await response.json())
|
||||
signal?.throwIfAborted()
|
||||
if (!parsed.success) {
|
||||
logger.warn(`BrowserUse returned an invalid share response for session ${sessionId}`)
|
||||
return null
|
||||
}
|
||||
return parsed.data.shareUrl ?? null
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
logger.warn(`Error creating share URL for session ${sessionId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOutput(): BrowserUseRunTaskResponse['output'] {
|
||||
return {
|
||||
id: '',
|
||||
success: false,
|
||||
output: null,
|
||||
steps: [],
|
||||
liveUrl: null,
|
||||
shareUrl: null,
|
||||
sessionId: null,
|
||||
}
|
||||
}
|
||||
|
||||
export const executeRunTaskOperation: InternalToolOperationImplementation<
|
||||
BrowserUseRunTaskParams
|
||||
> = async (
|
||||
params: BrowserUseRunTaskParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<BrowserUseRunTaskResponse> => {
|
||||
let profileSessionId: string | undefined
|
||||
let taskSessionId: string | null = null
|
||||
let taskEnded = false
|
||||
|
||||
if (params.profile_id) {
|
||||
logger.info(`Creating session with profile ID: ${params.profile_id}`)
|
||||
const sessionResult = await createSessionWithProfile(params.profile_id, params.apiKey, signal)
|
||||
if ('error' in sessionResult) {
|
||||
return { success: false, output: emptyOutput(), error: sessionResult.error }
|
||||
}
|
||||
profileSessionId = sessionResult.sessionId
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = buildRequestBody(params, profileSessionId)
|
||||
logger.info('Creating BrowserUse task', { hasSession: !!profileSessionId })
|
||||
const response = await fetchBrowserUse('/tasks', params.apiKey, {
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`Failed to create task: ${errorText}`)
|
||||
return {
|
||||
success: false,
|
||||
output: emptyOutput(),
|
||||
error: `Failed to create task: ${response.statusText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = createTaskResponseSchema.safeParse(await response.json())
|
||||
signal?.throwIfAborted()
|
||||
if (!parsed.success) {
|
||||
logger.error('BrowserUse returned an invalid create-task response')
|
||||
return {
|
||||
success: false,
|
||||
output: emptyOutput(),
|
||||
error: 'BrowserUse returned an invalid create-task response',
|
||||
}
|
||||
}
|
||||
const data = parsed.data
|
||||
const taskId = data.id
|
||||
const initialSessionId = profileSessionId ?? data.sessionId ?? null
|
||||
taskSessionId = initialSessionId
|
||||
logger.info(`Created BrowserUse task ${taskId}`, { sessionId: initialSessionId })
|
||||
|
||||
const result = await pollForCompletion(taskId, params.apiKey, {
|
||||
initialSessionId,
|
||||
signal,
|
||||
onSessionId: (discoveredSessionId) => {
|
||||
taskSessionId = discoveredSessionId
|
||||
},
|
||||
})
|
||||
taskEnded = result.taskEnded
|
||||
|
||||
const finalSessionId = result.sessionId ?? initialSessionId
|
||||
const shareUrl =
|
||||
result.publicShareUrl ??
|
||||
(finalSessionId ? await createShareUrl(finalSessionId, params.apiKey, signal) : null)
|
||||
|
||||
return {
|
||||
success: result.success && !result.error,
|
||||
output: {
|
||||
id: taskId,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
steps: result.steps,
|
||||
liveUrl: result.liveUrl,
|
||||
shareUrl,
|
||||
sessionId: finalSessionId,
|
||||
},
|
||||
error: result.error,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
logger.error('Error creating BrowserUse task:', error)
|
||||
return {
|
||||
success: false,
|
||||
output: emptyOutput(),
|
||||
error: `Error creating task: ${getErrorMessage(error, 'Unknown error')}`,
|
||||
}
|
||||
} finally {
|
||||
const sessionsToStop = new Set<string>()
|
||||
if (profileSessionId) sessionsToStop.add(profileSessionId)
|
||||
if (!taskEnded && taskSessionId) sessionsToStop.add(taskSessionId)
|
||||
for (const sessionId of sessionsToStop) {
|
||||
await stopSession(sessionId, params.apiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
executeCbinsightsChatOperation,
|
||||
executeCbinsightsGetCommercialMaturityHistoryOperation,
|
||||
executeCbinsightsGetExitProbabilityHistoryOperation,
|
||||
executeCbinsightsGetMosaicHistoryOperation,
|
||||
executeCbinsightsGetOrgBusinessRelationshipsOperation,
|
||||
executeCbinsightsGetOrgFundingsOperation,
|
||||
executeCbinsightsGetOrgFundingWindowOperation,
|
||||
executeCbinsightsGetOrgInvestmentsOperation,
|
||||
executeCbinsightsGetOrgManagementAndBoardOperation,
|
||||
executeCbinsightsGetOrgOutlookOperation,
|
||||
executeCbinsightsGetOrgPortfolioExitsOperation,
|
||||
executeCbinsightsGetOrgRevenueOperation,
|
||||
executeCbinsightsGetScoutingReportOperation,
|
||||
executeCbinsightsGetStrategyMapOperation,
|
||||
executeCbinsightsListBusinessRelationshipsOperation,
|
||||
executeCbinsightsListFundingsOperation,
|
||||
executeCbinsightsListFundingWindowOperation,
|
||||
executeCbinsightsListInvestmentsOperation,
|
||||
executeCbinsightsListManagementAndBoardOperation,
|
||||
executeCbinsightsListOutlookOperation,
|
||||
executeCbinsightsListPortfolioExitsOperation,
|
||||
executeCbinsightsListRevenueOperation,
|
||||
executeCbinsightsLookupOrganizationsOperation,
|
||||
executeCbinsightsRagOperation,
|
||||
executeCbinsightsSearchFirmographicsOperation,
|
||||
} from '@/lib/internal/cbinsights/operations'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeCbinsightsTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'cbinsights_chat':
|
||||
return executeToolOperationImplementation(executeCbinsightsChatOperation, request)
|
||||
case 'cbinsights_get_commercial_maturity_history':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetCommercialMaturityHistoryOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_exit_probability_history':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetExitProbabilityHistoryOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_mosaic_history':
|
||||
return executeToolOperationImplementation(executeCbinsightsGetMosaicHistoryOperation, request)
|
||||
case 'cbinsights_get_org_business_relationships':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetOrgBusinessRelationshipsOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_org_funding_window':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetOrgFundingWindowOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_org_fundings':
|
||||
return executeToolOperationImplementation(executeCbinsightsGetOrgFundingsOperation, request)
|
||||
case 'cbinsights_get_org_investments':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetOrgInvestmentsOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_org_management_and_board':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetOrgManagementAndBoardOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_org_outlook':
|
||||
return executeToolOperationImplementation(executeCbinsightsGetOrgOutlookOperation, request)
|
||||
case 'cbinsights_get_org_portfolio_exits':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetOrgPortfolioExitsOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_org_revenue':
|
||||
return executeToolOperationImplementation(executeCbinsightsGetOrgRevenueOperation, request)
|
||||
case 'cbinsights_get_scouting_report':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsGetScoutingReportOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_get_strategy_map':
|
||||
return executeToolOperationImplementation(executeCbinsightsGetStrategyMapOperation, request)
|
||||
case 'cbinsights_list_business_relationships':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsListBusinessRelationshipsOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_list_funding_window':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsListFundingWindowOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_list_fundings':
|
||||
return executeToolOperationImplementation(executeCbinsightsListFundingsOperation, request)
|
||||
case 'cbinsights_list_investments':
|
||||
return executeToolOperationImplementation(executeCbinsightsListInvestmentsOperation, request)
|
||||
case 'cbinsights_list_management_and_board':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsListManagementAndBoardOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_list_outlook':
|
||||
return executeToolOperationImplementation(executeCbinsightsListOutlookOperation, request)
|
||||
case 'cbinsights_list_portfolio_exits':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsListPortfolioExitsOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_list_revenue':
|
||||
return executeToolOperationImplementation(executeCbinsightsListRevenueOperation, request)
|
||||
case 'cbinsights_lookup_organizations':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsLookupOrganizationsOperation,
|
||||
request
|
||||
)
|
||||
case 'cbinsights_rag':
|
||||
return executeToolOperationImplementation(executeCbinsightsRagOperation, request)
|
||||
case 'cbinsights_search_firmographics':
|
||||
return executeToolOperationImplementation(
|
||||
executeCbinsightsSearchFirmographicsOperation,
|
||||
request
|
||||
)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported cbinsights tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsChatParams } from '@/tools/cbinsights/chat'
|
||||
import {
|
||||
asArray,
|
||||
asString,
|
||||
asStringArray,
|
||||
cbInsightsRequest,
|
||||
compactBody,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsChatOperation: InternalToolOperationImplementation<
|
||||
CbInsightsChatParams
|
||||
> = async (params, signal) => {
|
||||
const message = params.message?.trim()
|
||||
if (!message) throw new Error('CB Insights "message" is required')
|
||||
|
||||
return cbInsightsRequest<{
|
||||
chatID?: unknown
|
||||
title?: unknown
|
||||
message?: unknown
|
||||
sources?: unknown
|
||||
relatedContent?: unknown
|
||||
suggestions?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/chatcbi',
|
||||
body: compactBody({ message, chatID: params.chatId?.trim() }),
|
||||
},
|
||||
(data) => ({
|
||||
chatId: asString(data.chatID),
|
||||
title: asString(data.title),
|
||||
message: asString(data.message),
|
||||
sources: asArray(data.sources),
|
||||
relatedContent: asArray(data.relatedContent),
|
||||
suggestions: asStringArray(data.suggestions),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsCommercialMaturityHistoryParams } from '@/tools/cbinsights/get_commercial_maturity_history'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
compactBody,
|
||||
parseOptionalStringParam,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetCommercialMaturityHistoryOperation: InternalToolOperationImplementation<
|
||||
CbInsightsCommercialMaturityHistoryParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{ commercialMaturityHistory?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/commercialmaturityhistory`,
|
||||
body: compactBody({
|
||||
startDate: parseOptionalStringParam(params.startDate, 'startDate'),
|
||||
endDate: parseOptionalStringParam(params.endDate, 'endDate'),
|
||||
}),
|
||||
},
|
||||
(data) => ({ commercialMaturityHistory: asArray(data.commercialMaturityHistory) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsExitProbabilityHistoryParams } from '@/tools/cbinsights/get_exit_probability_history'
|
||||
import {
|
||||
asArray,
|
||||
asString,
|
||||
cbInsightsRequest,
|
||||
compactBody,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetExitProbabilityHistoryOperation: InternalToolOperationImplementation<
|
||||
CbInsightsExitProbabilityHistoryParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{ ipo?: unknown; mna?: unknown; incompleteRoundType?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/exitprobabilityhistory`,
|
||||
body: compactBody({
|
||||
startDate: params.startDate?.trim(),
|
||||
endDate: params.endDate?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({
|
||||
ipo: asArray(data.ipo),
|
||||
mna: asArray(data.mna),
|
||||
incompleteRoundType: asString(data.incompleteRoundType),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsMosaicHistoryParams } from '@/tools/cbinsights/get_mosaic_history'
|
||||
import { asArray, cbInsightsRequest, compactBody, requireOrgId } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetMosaicHistoryOperation: InternalToolOperationImplementation<
|
||||
CbInsightsMosaicHistoryParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
overall?: unknown
|
||||
management?: unknown
|
||||
market?: unknown
|
||||
momentum?: unknown
|
||||
money?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/mosaichistory`,
|
||||
body: compactBody({ startDate: params.startDate?.trim() }),
|
||||
},
|
||||
(data) => ({
|
||||
overall: asArray(data.overall),
|
||||
management: asArray(data.management),
|
||||
market: asArray(data.market),
|
||||
momentum: asArray(data.momentum),
|
||||
money: asArray(data.money),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgBusinessRelationshipsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{ businessRelationships?: unknown }>(
|
||||
params,
|
||||
{ path: `/v2/organizations/${orgId}/businessrelationships` },
|
||||
(data) => ({ businessRelationships: asArray(data.businessRelationships) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import {
|
||||
asNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
cbInsightsRequest,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgFundingWindowOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
windowStart?: unknown
|
||||
windowEnd?: unknown
|
||||
cohortNextRoundRate?: unknown
|
||||
cohortCriteria?: unknown
|
||||
latestFunding?: unknown
|
||||
}>(
|
||||
params,
|
||||
{ path: `/v2/organizations/${orgId}/fundingwindow` },
|
||||
(data) => ({
|
||||
windowStart: asString(data.windowStart),
|
||||
windowEnd: asString(data.windowEnd),
|
||||
cohortNextRoundRate: asNumber(data.cohortNextRoundRate),
|
||||
cohortCriteria: asRecord(data.cohortCriteria),
|
||||
latestFunding: asRecord(data.latestFunding),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgFundingsParams } from '@/tools/cbinsights/get_org_fundings'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgFundingsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgFundingsParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
fundings?: unknown
|
||||
capTableHistory?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/financialtransactions/fundings`,
|
||||
body: compactBody({
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({
|
||||
fundings: asArray(data.fundings),
|
||||
capTableHistory: asArray(data.capTableHistory),
|
||||
...pageInfo(data),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgInvestmentsParams } from '@/tools/cbinsights/get_org_investments'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgInvestmentsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgInvestmentsParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
investments?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/financialtransactions/investments`,
|
||||
body: compactBody({
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({ investments: asArray(data.investments), ...pageInfo(data) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgManagementParams } from '@/tools/cbinsights/get_org_management_and_board'
|
||||
import {
|
||||
asArray,
|
||||
asNumber,
|
||||
cbInsightsRequest,
|
||||
compactBody,
|
||||
parseIdListParam,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgManagementAndBoardOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgManagementParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{ people?: unknown; mosaicManagement?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/managementandboard`,
|
||||
body: compactBody({ titleIds: parseIdListParam(params.titleIds, 'titleIds') }),
|
||||
},
|
||||
(data) => ({
|
||||
people: asArray(data.people),
|
||||
mosaicManagement: asNumber(data.mosaicManagement),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import { asRecord, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgOutlookOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
mosaicScore?: unknown
|
||||
commercialMaturity?: unknown
|
||||
exitProbability?: unknown
|
||||
}>(
|
||||
params,
|
||||
{ path: `/v2/organizations/${orgId}/outlook` },
|
||||
(data) => ({
|
||||
mosaicScore: asRecord(data.mosaicScore),
|
||||
commercialMaturity: asRecord(data.commercialMaturity),
|
||||
exitProbability: asRecord(data.exitProbability),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgPortfolioExitsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{ portfolioExits?: unknown }>(
|
||||
params,
|
||||
{ path: `/v2/organizations/${orgId}/financialtransactions/portfolioexits` },
|
||||
(data) => ({ portfolioExits: asArray(data.portfolioExits) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import {
|
||||
asArray,
|
||||
asNumber,
|
||||
asString,
|
||||
cbInsightsRequest,
|
||||
requireOrgId,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetOrgRevenueOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
orgId?: unknown
|
||||
orgName?: unknown
|
||||
orgUrl?: unknown
|
||||
revenue?: unknown
|
||||
}>(
|
||||
params,
|
||||
{ path: `/v2/organizations/${orgId}/revenuebyyear` },
|
||||
(data) => ({
|
||||
orgId: asNumber(data.orgId),
|
||||
orgName: asString(data.orgName),
|
||||
orgUrl: asString(data.orgUrl),
|
||||
revenue: asArray(data.revenue),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import {
|
||||
asRecord,
|
||||
asString,
|
||||
cbInsightsRequest,
|
||||
requireOrgId,
|
||||
SCOUTING_REPORT_TIMEOUT_MS,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetScoutingReportOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{
|
||||
orgInfo?: unknown
|
||||
reportMarkdown?: unknown
|
||||
reportJson?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: `/v2/organizations/${orgId}/scoutingreport`,
|
||||
timeoutMs: SCOUTING_REPORT_TIMEOUT_MS,
|
||||
},
|
||||
(data) => ({
|
||||
orgInfo: asRecord(data.orgInfo),
|
||||
reportMarkdown: asString(data.reportMarkdown),
|
||||
reportJson: asString(data.reportJson),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
|
||||
import { asArray, asString, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsGetStrategyMapOperation: InternalToolOperationImplementation<
|
||||
CbInsightsOrgParams
|
||||
> = async (params, signal) => {
|
||||
const orgId = requireOrgId(params.orgId)
|
||||
return cbInsightsRequest<{ orgName?: unknown; logoUrl?: unknown; categories?: unknown }>(
|
||||
params,
|
||||
{ path: `/v2/organizations/${orgId}/strategymap` },
|
||||
(data) => ({
|
||||
orgName: asString(data.orgName),
|
||||
logoUrl: asString(data.logoUrl),
|
||||
categories: asArray(data.categories),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export { executeCbinsightsChatOperation } from '@/lib/internal/cbinsights/operations/chat'
|
||||
export { executeCbinsightsGetCommercialMaturityHistoryOperation } from '@/lib/internal/cbinsights/operations/get-commercial-maturity-history'
|
||||
export { executeCbinsightsGetExitProbabilityHistoryOperation } from '@/lib/internal/cbinsights/operations/get-exit-probability-history'
|
||||
export { executeCbinsightsGetMosaicHistoryOperation } from '@/lib/internal/cbinsights/operations/get-mosaic-history'
|
||||
export { executeCbinsightsGetOrgBusinessRelationshipsOperation } from '@/lib/internal/cbinsights/operations/get-org-business-relationships'
|
||||
export { executeCbinsightsGetOrgFundingWindowOperation } from '@/lib/internal/cbinsights/operations/get-org-funding-window'
|
||||
export { executeCbinsightsGetOrgFundingsOperation } from '@/lib/internal/cbinsights/operations/get-org-fundings'
|
||||
export { executeCbinsightsGetOrgInvestmentsOperation } from '@/lib/internal/cbinsights/operations/get-org-investments'
|
||||
export { executeCbinsightsGetOrgManagementAndBoardOperation } from '@/lib/internal/cbinsights/operations/get-org-management-and-board'
|
||||
export { executeCbinsightsGetOrgOutlookOperation } from '@/lib/internal/cbinsights/operations/get-org-outlook'
|
||||
export { executeCbinsightsGetOrgPortfolioExitsOperation } from '@/lib/internal/cbinsights/operations/get-org-portfolio-exits'
|
||||
export { executeCbinsightsGetOrgRevenueOperation } from '@/lib/internal/cbinsights/operations/get-org-revenue'
|
||||
export { executeCbinsightsGetScoutingReportOperation } from '@/lib/internal/cbinsights/operations/get-scouting-report'
|
||||
export { executeCbinsightsGetStrategyMapOperation } from '@/lib/internal/cbinsights/operations/get-strategy-map'
|
||||
export { executeCbinsightsListBusinessRelationshipsOperation } from '@/lib/internal/cbinsights/operations/list-business-relationships'
|
||||
export { executeCbinsightsListFundingWindowOperation } from '@/lib/internal/cbinsights/operations/list-funding-window'
|
||||
export { executeCbinsightsListFundingsOperation } from '@/lib/internal/cbinsights/operations/list-fundings'
|
||||
export { executeCbinsightsListInvestmentsOperation } from '@/lib/internal/cbinsights/operations/list-investments'
|
||||
export { executeCbinsightsListManagementAndBoardOperation } from '@/lib/internal/cbinsights/operations/list-management-and-board'
|
||||
export { executeCbinsightsListOutlookOperation } from '@/lib/internal/cbinsights/operations/list-outlook'
|
||||
export { executeCbinsightsListPortfolioExitsOperation } from '@/lib/internal/cbinsights/operations/list-portfolio-exits'
|
||||
export { executeCbinsightsListRevenueOperation } from '@/lib/internal/cbinsights/operations/list-revenue'
|
||||
export { executeCbinsightsLookupOrganizationsOperation } from '@/lib/internal/cbinsights/operations/lookup-organizations'
|
||||
export { executeCbinsightsRagOperation } from '@/lib/internal/cbinsights/operations/rag'
|
||||
export { executeCbinsightsSearchFirmographicsOperation } from '@/lib/internal/cbinsights/operations/search-firmographics'
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListBusinessRelationshipsParams } from '@/tools/cbinsights/list_business_relationships'
|
||||
import {
|
||||
asArray,
|
||||
asString,
|
||||
cbInsightsRequest,
|
||||
compactBody,
|
||||
requireOrgIds,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListBusinessRelationshipsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListBusinessRelationshipsParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{ orgs?: unknown; nextPageToken?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/businessrelationships',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs), nextPageToken: asString(data.nextPageToken) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListFundingWindowParams } from '@/tools/cbinsights/list_funding_window'
|
||||
import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListFundingWindowOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListFundingWindowParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{ orgs?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/outlook/fundingwindow',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListFundingsParams } from '@/tools/cbinsights/list_fundings'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
requireOrgIds,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListFundingsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListFundingsParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{
|
||||
orgs?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/financialtransactions/fundings',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListInvestmentsParams } from '@/tools/cbinsights/list_investments'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
requireOrgIds,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListInvestmentsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListInvestmentsParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{
|
||||
orgs?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/financialtransactions/investments',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListManagementAndBoardParams } from '@/tools/cbinsights/list_management_and_board'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
compactBody,
|
||||
parseIdListParam,
|
||||
requireOrgIds,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListManagementAndBoardOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListManagementAndBoardParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{ orgs?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/managementandboard',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
titleIds: parseIdListParam(params.titleIds, 'titleIds'),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListOutlookParams } from '@/tools/cbinsights/list_outlook'
|
||||
import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListOutlookOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListOutlookParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{ orgs?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/outlook',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListPortfolioExitsParams } from '@/tools/cbinsights/list_portfolio_exits'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
requireOrgIds,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListPortfolioExitsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListPortfolioExitsParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{
|
||||
orgs?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/financialtransactions/portfolioexits',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsListRevenueParams } from '@/tools/cbinsights/list_revenue'
|
||||
import { asArray, cbInsightsRequest, compactBody, requireOrgIds } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsListRevenueOperation: InternalToolOperationImplementation<
|
||||
CbInsightsListRevenueParams
|
||||
> = async (params, signal) =>
|
||||
cbInsightsRequest<{ orgs?: unknown }>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/revenuebyyear',
|
||||
body: compactBody({
|
||||
orgIds: requireOrgIds(params.orgIds),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs) }),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsLookupOrganizationsParams } from '@/tools/cbinsights/lookup_organizations'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
parseStringListParam,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsLookupOrganizationsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsLookupOrganizationsParams
|
||||
> = async (params, signal) => {
|
||||
const names = parseStringListParam(params.names, 'names')
|
||||
const urls = parseStringListParam(params.urls, 'urls')
|
||||
const profileUrl = params.profileUrl?.trim()
|
||||
|
||||
if (!names && !urls && !profileUrl) {
|
||||
throw new Error('CB Insights lookup requires at least one of "names", "urls", or "profileUrl"')
|
||||
}
|
||||
if (profileUrl && (names || urls)) {
|
||||
throw new Error(
|
||||
'CB Insights rejects "profileUrl" combined with "names" or "urls" — pass only one'
|
||||
)
|
||||
}
|
||||
|
||||
return cbInsightsRequest<{
|
||||
orgs?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{
|
||||
path: '/v2/organizations',
|
||||
body: compactBody({
|
||||
names,
|
||||
urls,
|
||||
profileUrl,
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
},
|
||||
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsRagParams } from '@/tools/cbinsights/rag'
|
||||
import { asString, asStringArray, cbInsightsRequest } from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsRagOperation: InternalToolOperationImplementation<
|
||||
CbInsightsRagParams
|
||||
> = async (params, signal) => {
|
||||
const message = params.message?.trim()
|
||||
if (!message) throw new Error('CB Insights "message" is required')
|
||||
if (message.length > 10_000) {
|
||||
throw new Error('CB Insights "message" must be under 10,000 characters')
|
||||
}
|
||||
|
||||
return cbInsightsRequest<{ data?: unknown; guidance?: unknown }>(
|
||||
params,
|
||||
{ path: '/v2/cbirag', body: { message } },
|
||||
(data) => ({ data: asString(data.data), guidance: asStringArray(data.guidance) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { CbInsightsFirmographicsParams } from '@/tools/cbinsights/search_firmographics'
|
||||
import { sortDirection } from '@/tools/cbinsights/search_firmographics'
|
||||
import {
|
||||
asArray,
|
||||
cbInsightsRequest,
|
||||
clampLimit,
|
||||
compactBody,
|
||||
pageInfo,
|
||||
parseBooleanParam,
|
||||
parseIdListParam,
|
||||
parseIntegerParam,
|
||||
parseNumberParam,
|
||||
parseOptionalOrgIds,
|
||||
parseStringListParam,
|
||||
} from '@/tools/cbinsights/utils'
|
||||
|
||||
export const executeCbinsightsSearchFirmographicsOperation: InternalToolOperationImplementation<
|
||||
CbInsightsFirmographicsParams
|
||||
> = async (params, signal) => {
|
||||
const filters = compactBody({
|
||||
keyword: params.keyword?.trim(),
|
||||
orgIds: parseOptionalOrgIds(params.orgIds),
|
||||
orgNames: parseStringListParam(params.orgNames, 'orgNames'),
|
||||
urls: parseStringListParam(params.urls, 'urls'),
|
||||
tickers: parseStringListParam(params.tickers, 'tickers'),
|
||||
marketIds: parseIdListParam(params.marketIds, 'marketIds'),
|
||||
marketNames: parseStringListParam(params.marketNames, 'marketNames'),
|
||||
industryIds: parseIdListParam(params.industryIds, 'industryIds'),
|
||||
sectorIds: parseIdListParam(params.sectorIds, 'sectorIds'),
|
||||
subindustryIds: parseIdListParam(params.subindustryIds, 'subindustryIds'),
|
||||
businessModelIds: parseIdListParam(params.businessModelIds, 'businessModelIds'),
|
||||
technologyIds: parseIdListParam(params.technologyIds, 'technologyIds'),
|
||||
collectionIds: parseIdListParam(params.collectionIds, 'collectionIds'),
|
||||
countryIds: parseIdListParam(params.countryIds, 'countryIds'),
|
||||
stateProvinceIds: parseIdListParam(params.stateProvinceIds, 'stateProvinceIds'),
|
||||
cityIds: parseIdListParam(params.cityIds, 'cityIds'),
|
||||
continentIds: parseIdListParam(params.continentIds, 'continentIds'),
|
||||
regionIds: parseIdListParam(params.regionIds, 'regionIds'),
|
||||
orgStatusIds: parseIdListParam(params.orgStatusIds, 'orgStatusIds'),
|
||||
investorOrgIds: parseIdListParam(params.investorOrgIds, 'investorOrgIds'),
|
||||
investorTypeIds: parseIdListParam(params.investorTypeIds, 'investorTypeIds'),
|
||||
fundingInvestorTypeIds: parseIdListParam(
|
||||
params.fundingInvestorTypeIds,
|
||||
'fundingInvestorTypeIds'
|
||||
),
|
||||
lastFundingRoundIds: parseIdListParam(params.lastFundingRoundIds, 'lastFundingRoundIds'),
|
||||
lastFundingRoundCategoryIds: parseIdListParam(
|
||||
params.lastFundingRoundCategoryIds,
|
||||
'lastFundingRoundCategoryIds'
|
||||
),
|
||||
minCurrentHeadcount: parseIntegerParam(params.minCurrentHeadcount, 'minCurrentHeadcount'),
|
||||
maxCurrentHeadcount: parseIntegerParam(params.maxCurrentHeadcount, 'maxCurrentHeadcount'),
|
||||
minTotalFundingInMillions: parseNumberParam(
|
||||
params.minTotalFundingInMillions,
|
||||
'minTotalFundingInMillions'
|
||||
),
|
||||
maxTotalFundingInMillions: parseNumberParam(
|
||||
params.maxTotalFundingInMillions,
|
||||
'maxTotalFundingInMillions'
|
||||
),
|
||||
minValuationInMillions: parseNumberParam(
|
||||
params.minValuationInMillions,
|
||||
'minValuationInMillions'
|
||||
),
|
||||
maxValuationInMillions: parseNumberParam(
|
||||
params.maxValuationInMillions,
|
||||
'maxValuationInMillions'
|
||||
),
|
||||
minLastFundingDate: params.minLastFundingDate?.trim(),
|
||||
maxLastFundingDate: params.maxLastFundingDate?.trim(),
|
||||
vcBacked: parseBooleanParam(params.vcBacked, 'vcBacked'),
|
||||
})
|
||||
|
||||
/*
|
||||
* The guard has to measure the *filters* alone. Folding limit, the page
|
||||
* token, or the sort into the same object would let a request carrying only
|
||||
* paging past it — which is an unfiltered search over the whole database,
|
||||
* and it still spends credits.
|
||||
*/
|
||||
if (Object.keys(filters).length === 0) {
|
||||
throw new Error('CB Insights firmographics search requires at least one search parameter')
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
...filters,
|
||||
...compactBody({
|
||||
limit: clampLimit(params.limit),
|
||||
nextPageToken: params.nextPageToken?.trim(),
|
||||
}),
|
||||
}
|
||||
|
||||
/* The API takes one sort object; the block exposes it as two plain fields
|
||||
so neither has to be typed as JSON. */
|
||||
const sortField = params.sortField?.trim()
|
||||
if (sortField) {
|
||||
body.sort = { field: sortField, direction: sortDirection(params.sortDirection) }
|
||||
}
|
||||
|
||||
return cbInsightsRequest<{
|
||||
orgs?: unknown
|
||||
nextPageToken?: unknown
|
||||
totalHits?: unknown
|
||||
totalHitsRelation?: unknown
|
||||
}>(
|
||||
params,
|
||||
{ path: '/v2/firmographics', body },
|
||||
(data) => ({ orgs: asArray(data.orgs), ...pageInfo(data) }),
|
||||
signal
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { executeGetZoneSettingsOperation } from '@/lib/internal/cloudflare/operations/get-zone-settings'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeCloudflareTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'cloudflare_get_zone_settings':
|
||||
return executeToolOperationImplementation(executeGetZoneSettingsOperation, request)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported cloudflare tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { mapZoneSetting, zoneSettingUrl } from '@/tools/cloudflare/get_zone_settings'
|
||||
import type {
|
||||
CloudflareEnvelope,
|
||||
CloudflareGetZoneSettingsParams,
|
||||
CloudflareRawZoneSetting,
|
||||
} from '@/tools/cloudflare/types'
|
||||
import {
|
||||
cloudflareErrorMessage,
|
||||
cloudflareHeaders,
|
||||
MAX_ZONE_SETTING_IDS,
|
||||
requestedZoneSettingIds,
|
||||
} from '@/tools/cloudflare/utils'
|
||||
|
||||
export const executeGetZoneSettingsOperation: InternalToolOperationImplementation<
|
||||
CloudflareGetZoneSettingsParams
|
||||
> = async (params, signal) => {
|
||||
const settingIds = requestedZoneSettingIds(params.settingIds)
|
||||
if (settingIds.length > MAX_ZONE_SETTING_IDS) {
|
||||
return {
|
||||
success: false,
|
||||
output: { settings: [], unreadable: [] },
|
||||
error: `Too many settings requested: ${settingIds.length}. Cloudflare reads one setting per request, so at most ${MAX_ZONE_SETTING_IDS} can be read in a single call.`,
|
||||
}
|
||||
}
|
||||
|
||||
const zoneId = params.zoneId.trim()
|
||||
const headers = cloudflareHeaders(params.apiKey)
|
||||
|
||||
const reads = await Promise.all(
|
||||
settingIds.map(async (settingId) => {
|
||||
try {
|
||||
const response = await fetch(zoneSettingUrl(zoneId, settingId), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal,
|
||||
})
|
||||
const data = (await response.json()) as CloudflareEnvelope<CloudflareRawZoneSetting>
|
||||
if (!data.success) {
|
||||
return {
|
||||
settingId,
|
||||
error: cloudflareErrorMessage(data, `Failed to read zone setting ${settingId}`),
|
||||
}
|
||||
}
|
||||
return { settingId, setting: mapZoneSetting(settingId, data.result) }
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
return {
|
||||
settingId,
|
||||
error: getErrorMessage(error, `Failed to read zone setting ${settingId}`),
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const settings = reads.flatMap((read) => (read.setting ? [read.setting] : []))
|
||||
const unreadable = reads.flatMap((read) =>
|
||||
read.error ? [{ id: read.settingId, error: read.error }] : []
|
||||
)
|
||||
|
||||
if (settings.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { settings, unreadable },
|
||||
error: unreadable[0]?.error ?? 'Failed to get zone settings',
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, output: { settings, unreadable } }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { executeUpdateSloOperation } from '@/lib/internal/datadog/operations/update-slo'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeDatadogTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'datadog_update_slo':
|
||||
return executeToolOperationImplementation(executeUpdateSloOperation, request)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported datadog tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { UpdateSloParams } from '@/tools/datadog/types'
|
||||
import {
|
||||
datadogApiUrl,
|
||||
datadogErrorMessage,
|
||||
datadogHeaders,
|
||||
datadogPathSegment,
|
||||
mergeSloUpdatePayload,
|
||||
} from '@/tools/datadog/utils'
|
||||
|
||||
export const executeUpdateSloOperation: InternalToolOperationImplementation<
|
||||
UpdateSloParams
|
||||
> = async (params, signal) => {
|
||||
const url = datadogApiUrl(params.site, `/api/v1/slo/${datadogPathSegment(params.sloId)}`)
|
||||
const headers = datadogHeaders(params)
|
||||
|
||||
const existingResponse = await fetch(url, { method: 'GET', headers, signal })
|
||||
if (!existingResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { slo: { id: '', name: '', type: '' } },
|
||||
error: `Could not load SLO ${params.sloId} before updating it: ${await datadogErrorMessage(existingResponse)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await existingResponse.json()
|
||||
const stored = existing.data
|
||||
if (!stored || typeof stored !== 'object') {
|
||||
return {
|
||||
success: false,
|
||||
output: { slo: { id: '', name: '', type: '' } },
|
||||
error: `Datadog returned no SLO for id ${params.sloId}`,
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(mergeSloUpdatePayload(stored, params)),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { slo: { id: '', name: '', type: '' } },
|
||||
error: await datadogErrorMessage(response),
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { slo: data.data?.[0] ?? { id: '', name: '', type: '' } },
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,15 @@
|
||||
import { createExecutionContext } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getGitHubLatestCommit: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeGitHubCommentOperation: vi.fn(),
|
||||
executeGitHubCommentV2Operation: vi.fn(),
|
||||
getGitHubLatestCommit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/internal/github/operations', () => ({
|
||||
executeGitHubCommentOperation: mocks.executeGitHubCommentOperation,
|
||||
executeGitHubCommentV2Operation: mocks.executeGitHubCommentV2Operation,
|
||||
getGitHubLatestCommit: mocks.getGitHubLatestCommit,
|
||||
}))
|
||||
|
||||
@@ -16,9 +22,36 @@ import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/t
|
||||
describe('executeGitHubTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.executeGitHubCommentOperation.mockResolvedValue({ success: true, output: {} })
|
||||
mocks.executeGitHubCommentV2Operation.mockResolvedValue({ success: true, output: {} })
|
||||
mocks.getGitHubLatestCommit.mockResolvedValue({ success: true, output: {} })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['github_comment', mocks.executeGitHubCommentOperation],
|
||||
['github_comment_v2', mocks.executeGitHubCommentV2Operation],
|
||||
])('dispatches %s to its typed operation', async (toolId, operation) => {
|
||||
const controller = new AbortController()
|
||||
const input = {
|
||||
owner: 'simstudioai',
|
||||
repo: 'sim',
|
||||
pullNumber: 7,
|
||||
body: 'Looks good',
|
||||
apiKey: 'token',
|
||||
}
|
||||
const request: InternalToolOperationCall = {
|
||||
toolId,
|
||||
input,
|
||||
headers: new Headers(),
|
||||
context: createExecutionContext(),
|
||||
requestId: 'request-1',
|
||||
signal: controller.signal,
|
||||
}
|
||||
|
||||
expect((await executeGitHubTool(request)).status).toBe(200)
|
||||
expect(operation).toHaveBeenCalledWith(input, controller.signal, request.context)
|
||||
})
|
||||
|
||||
it.each(['github_latest_commit', 'github_latest_commit_v2'])(
|
||||
'dispatches %s to the same typed operation',
|
||||
async (toolId) => {
|
||||
|
||||
@@ -2,11 +2,14 @@ import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { z } from 'zod'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import { GitHubOperationError } from '@/lib/internal/github/errors'
|
||||
import { getGitHubLatestCommit } from '@/lib/internal/github/operations'
|
||||
import {
|
||||
executeGitHubCommentOperation,
|
||||
executeGitHubCommentV2Operation,
|
||||
getGitHubLatestCommit,
|
||||
} from '@/lib/internal/github/operations'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
const TOOL_IDS = new Set(['github_latest_commit', 'github_latest_commit_v2'])
|
||||
|
||||
const inputSchema = z.object({
|
||||
owner: z.string().min(1, 'Owner is required'),
|
||||
repo: z.string().min(1, 'Repo is required'),
|
||||
@@ -16,23 +19,31 @@ const inputSchema = z.object({
|
||||
|
||||
export const executeGitHubTool: InternalToolOperationHandler = async (request) => {
|
||||
request.signal?.throwIfAborted()
|
||||
if (!TOOL_IDS.has(request.toolId)) {
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported GitHub tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const parsed = inputSchema.safeParse(request.input)
|
||||
if (!parsed.success) {
|
||||
return Response.json({ success: false, error: 'Invalid request data' }, { status: 400 })
|
||||
}
|
||||
try {
|
||||
return Response.json(
|
||||
await getGitHubLatestCommit(parsed.data, {
|
||||
requestId: request.requestId,
|
||||
signal: request.signal,
|
||||
})
|
||||
)
|
||||
switch (request.toolId) {
|
||||
case 'github_comment':
|
||||
return executeToolOperationImplementation(executeGitHubCommentOperation, request)
|
||||
case 'github_comment_v2':
|
||||
return executeToolOperationImplementation(executeGitHubCommentV2Operation, request)
|
||||
case 'github_latest_commit':
|
||||
case 'github_latest_commit_v2': {
|
||||
const parsed = inputSchema.safeParse(request.input)
|
||||
if (!parsed.success) {
|
||||
return Response.json({ success: false, error: 'Invalid request data' }, { status: 400 })
|
||||
}
|
||||
return Response.json(
|
||||
await getGitHubLatestCommit(parsed.data, {
|
||||
requestId: request.requestId,
|
||||
signal: request.signal,
|
||||
})
|
||||
)
|
||||
}
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported GitHub tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
request.signal?.throwIfAborted()
|
||||
const status = isPayloadSizeLimitError(error)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
@@ -10,10 +11,47 @@ import {
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { GitHubOperationError } from '@/lib/internal/github/errors'
|
||||
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
|
||||
import type { LatestCommitParams, LatestCommitResponse } from '@/tools/github/types'
|
||||
import { formatGitHubErrorMessage } from '@/tools/github/response-parsers'
|
||||
import type {
|
||||
CreateCommentParams,
|
||||
LatestCommitParams,
|
||||
LatestCommitResponse,
|
||||
} from '@/tools/github/types'
|
||||
import { secureGitHubRequest } from '@/tools/github/utils.server'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('GitHubLatestCommitOperation')
|
||||
const MAX_COMMIT_RESPONSE_BYTES = 10 * 1024 * 1024
|
||||
const GITHUB_API_BASE = 'https://api.github.com'
|
||||
|
||||
interface ReviewCommentBody {
|
||||
body: string
|
||||
event: 'COMMENT'
|
||||
}
|
||||
|
||||
interface FileCommentBodyBase {
|
||||
body: string
|
||||
commit_id: string | undefined
|
||||
path: string | undefined
|
||||
}
|
||||
|
||||
type FileCommentBody =
|
||||
| (FileCommentBodyBase & { subject_type: 'file' })
|
||||
| (FileCommentBodyBase & { line: number; side: string })
|
||||
|
||||
interface GitHubCommentPayload {
|
||||
id?: number
|
||||
body?: string
|
||||
html_url?: string
|
||||
user?: unknown
|
||||
path?: string
|
||||
line?: number
|
||||
position?: number
|
||||
side?: string
|
||||
commit_id?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
interface GitHubCommitFile {
|
||||
filename: string
|
||||
@@ -51,6 +89,228 @@ export interface GitHubOperationContext {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
function githubHeaders(apiKey: string): Record<string, string> {
|
||||
return {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
}
|
||||
}
|
||||
|
||||
function pullRequestUrl(params: CreateCommentParams): string {
|
||||
return `${GITHUB_API_BASE}/repos/${params.owner}/${params.repo}/pulls/${params.pullNumber}`
|
||||
}
|
||||
|
||||
function isFileCommentRequest(params: CreateCommentParams): boolean {
|
||||
return params.commentType === 'file_comment' && Boolean(params.path)
|
||||
}
|
||||
|
||||
function needsCommitLookup(params: CreateCommentParams): boolean {
|
||||
return isFileCommentRequest(params) && !params.commitId
|
||||
}
|
||||
|
||||
function toLineNumber(value: unknown): number | undefined {
|
||||
let parsed: number
|
||||
if (typeof value === 'number') {
|
||||
parsed = value
|
||||
} else {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('GitHub line must be a positive integer')
|
||||
}
|
||||
if (!value.trim()) return undefined
|
||||
parsed = Number(value.trim())
|
||||
}
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`GitHub line must be a valid number, but line was ${String(value)}`)
|
||||
}
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new Error(
|
||||
`GitHub line numbers are whole numbers, but line was ${parsed}. Set line to the integer line number in the diff.`
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function fileCommentBody(
|
||||
params: CreateCommentParams,
|
||||
commitId: string | undefined
|
||||
): FileCommentBody {
|
||||
const base = {
|
||||
body: params.body,
|
||||
commit_id: commitId,
|
||||
path: params.path,
|
||||
}
|
||||
const line = toLineNumber(params.line)
|
||||
if (line === undefined) return { ...base, subject_type: 'file' }
|
||||
if (line < 1) throw new Error('GitHub line numbers must be positive integers')
|
||||
return { ...base, line, side: params.side || 'RIGHT' }
|
||||
}
|
||||
|
||||
function commentEndpointUrl(params: CreateCommentParams): string {
|
||||
return isFileCommentRequest(params)
|
||||
? `${pullRequestUrl(params)}/comments`
|
||||
: `${pullRequestUrl(params)}/reviews`
|
||||
}
|
||||
|
||||
function commentRequestBody(
|
||||
params: CreateCommentParams,
|
||||
commitId: string | undefined
|
||||
): FileCommentBody | ReviewCommentBody {
|
||||
if (isFileCommentRequest(params)) return fileCommentBody(params, commitId)
|
||||
return { body: params.body, event: 'COMMENT' }
|
||||
}
|
||||
|
||||
function readHeadSha(pullRequest: unknown): string | undefined {
|
||||
if (!isRecordLike(pullRequest) || !isRecordLike(pullRequest.head)) return undefined
|
||||
const sha = pullRequest.head.sha
|
||||
return typeof sha === 'string' && sha ? sha : undefined
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key]
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
function readCommentPayload(value: unknown): GitHubCommentPayload {
|
||||
if (!isRecordLike(value)) return {}
|
||||
const submittedAt = readString(value, 'submitted_at')
|
||||
return {
|
||||
id: readNumber(value, 'id'),
|
||||
body: readString(value, 'body'),
|
||||
html_url: readString(value, 'html_url'),
|
||||
user: value.user,
|
||||
path: readString(value, 'path'),
|
||||
line: readNumber(value, 'line'),
|
||||
position: readNumber(value, 'position'),
|
||||
side: readString(value, 'side'),
|
||||
commit_id: readString(value, 'commit_id'),
|
||||
created_at: readString(value, 'created_at') ?? submittedAt,
|
||||
updated_at: readString(value, 'updated_at') ?? submittedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function assertGitHubResponseOk(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (response.ok) return
|
||||
|
||||
const text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES,
|
||||
label: 'GitHub error response',
|
||||
signal,
|
||||
}).catch(() => '')
|
||||
let data: unknown = text
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
}
|
||||
|
||||
throw new GitHubOperationError(
|
||||
formatGitHubErrorMessage(data) ?? `${fallback} (HTTP ${response.status})`,
|
||||
response.status
|
||||
)
|
||||
}
|
||||
|
||||
async function createComment(
|
||||
params: CreateCommentParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<GitHubCommentPayload> {
|
||||
const headers = githubHeaders(params.apiKey)
|
||||
|
||||
let commitId = params.commitId
|
||||
if (needsCommitLookup(params)) {
|
||||
const pullRequestResponse = await secureGitHubRequest(pullRequestUrl(params), {
|
||||
headers,
|
||||
signal,
|
||||
})
|
||||
await assertGitHubResponseOk(
|
||||
pullRequestResponse,
|
||||
`Failed to load pull request ${params.owner}/${params.repo}#${params.pullNumber}`,
|
||||
signal
|
||||
)
|
||||
const pullRequest = await readResponseJsonWithLimit<unknown>(pullRequestResponse, {
|
||||
maxBytes: MAX_COMMIT_RESPONSE_BYTES,
|
||||
label: 'GitHub pull request response',
|
||||
signal,
|
||||
})
|
||||
commitId = readHeadSha(pullRequest)
|
||||
if (!commitId) {
|
||||
throw new Error(
|
||||
`GitHub returned no head commit SHA for pull request ${params.owner}/${params.repo}#${params.pullNumber}. Set commitId to comment on a specific commit.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await secureGitHubRequest(commentEndpointUrl(params), {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(commentRequestBody(params, commitId)),
|
||||
signal,
|
||||
})
|
||||
await assertGitHubResponseOk(response, 'Failed to create comment', signal)
|
||||
return readCommentPayload(
|
||||
await readResponseJsonWithLimit<unknown>(response, {
|
||||
maxBytes: MAX_COMMIT_RESPONSE_BYTES,
|
||||
label: 'GitHub comment response',
|
||||
signal,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function executeGitHubCommentOperation(
|
||||
params: CreateCommentParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolResponse> {
|
||||
const data = await createComment(params, signal)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: `Comment created: "${data.body}"`,
|
||||
metadata: {
|
||||
id: data.id,
|
||||
html_url: data.html_url,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
path: data.path,
|
||||
line: data.line || data.position,
|
||||
side: data.side,
|
||||
commit_id: data.commit_id,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGitHubCommentV2Operation(
|
||||
params: CreateCommentParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<ToolResponse> {
|
||||
const data = await createComment(params, signal)
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: data.id,
|
||||
body: data.body,
|
||||
html_url: data.html_url,
|
||||
user: data.user,
|
||||
path: data.path ?? null,
|
||||
line: data.line ?? data.position ?? null,
|
||||
side: data.side ?? null,
|
||||
commit_id: data.commit_id ?? null,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChangedFileContent(
|
||||
file: GitHubCommitFile,
|
||||
apiKey: string,
|
||||
|
||||
@@ -7,12 +7,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
download: vi.fn(),
|
||||
exportFile: vi.fn(),
|
||||
move: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/internal/google-drive/operations', () => ({
|
||||
executeGoogleDriveDownload: mocks.download,
|
||||
executeGoogleDriveExport: mocks.exportFile,
|
||||
executeGoogleDriveMove: mocks.move,
|
||||
executeGoogleDriveUpload: mocks.upload,
|
||||
}))
|
||||
|
||||
@@ -24,6 +26,11 @@ import type { InternalToolOperationCall } from '@/lib/internal/tool-operations/t
|
||||
const INPUTS = {
|
||||
google_drive_download: { accessToken: 'token', fileId: 'file-1' },
|
||||
google_drive_export: { accessToken: 'token', fileId: 'file-1', mimeType: 'application/pdf' },
|
||||
google_drive_move: {
|
||||
accessToken: 'token',
|
||||
fileId: 'file-1',
|
||||
destinationFolderId: 'folder-1',
|
||||
},
|
||||
google_drive_upload: {
|
||||
accessToken: 'token',
|
||||
fileName: 'notes.txt',
|
||||
@@ -34,6 +41,7 @@ const INPUTS = {
|
||||
const OPERATIONS = {
|
||||
google_drive_download: mocks.download,
|
||||
google_drive_export: mocks.exportFile,
|
||||
google_drive_move: mocks.move,
|
||||
google_drive_upload: mocks.upload,
|
||||
} as const
|
||||
|
||||
@@ -119,6 +127,24 @@ describe('executeGoogleDriveTool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('maps oversized move responses to 413', async () => {
|
||||
mocks.move.mockRejectedValueOnce(
|
||||
new PayloadSizeLimitError({
|
||||
label: 'Google Drive move response',
|
||||
maxBytes: 10,
|
||||
observedBytes: 11,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await executeGoogleDriveTool(request('google_drive_move'))
|
||||
|
||||
expect(response.status).toBe(413)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining('Google Drive move response'),
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates cancellation before and after operation work', async () => {
|
||||
const before = new AbortController()
|
||||
before.abort(new DOMException('cancelled', 'AbortError'))
|
||||
|
||||
@@ -10,11 +10,13 @@ import { GoogleDriveOperationError } from '@/lib/internal/google-drive/errors'
|
||||
import {
|
||||
googleDriveDownloadInputSchema,
|
||||
googleDriveExportInputSchema,
|
||||
googleDriveMoveInputSchema,
|
||||
googleDriveUploadInputSchema,
|
||||
} from '@/lib/internal/google-drive/input'
|
||||
import {
|
||||
executeGoogleDriveDownload,
|
||||
executeGoogleDriveExport,
|
||||
executeGoogleDriveMove,
|
||||
executeGoogleDriveUpload,
|
||||
type GoogleDriveOperationContext,
|
||||
} from '@/lib/internal/google-drive/operations'
|
||||
@@ -56,6 +58,10 @@ async function dispatch(
|
||||
const input = parseInput(googleDriveExportInputSchema, request.input)
|
||||
return input instanceof Response ? input : executeGoogleDriveExport(input, context)
|
||||
}
|
||||
case 'google_drive_move': {
|
||||
const input = parseInput(googleDriveMoveInputSchema, request.input)
|
||||
return input instanceof Response ? input : executeGoogleDriveMove(input, context)
|
||||
}
|
||||
case 'google_drive_upload': {
|
||||
const input = parseInput(googleDriveUploadInputSchema, request.input)
|
||||
return input instanceof Response ? input : executeGoogleDriveUpload(input, context)
|
||||
@@ -78,8 +84,9 @@ function unexpectedResponse(request: InternalToolOperationCall, error: unknown):
|
||||
toolId: request.toolId,
|
||||
})
|
||||
const status =
|
||||
['google_drive_download', 'google_drive_export'].includes(request.toolId) &&
|
||||
isPayloadSizeLimitError(error)
|
||||
['google_drive_download', 'google_drive_export', 'google_drive_move'].includes(
|
||||
request.toolId
|
||||
) && isPayloadSizeLimitError(error)
|
||||
? 413
|
||||
: 500
|
||||
return Response.json({ success: false, error: message }, { status })
|
||||
|
||||
@@ -27,6 +27,14 @@ export const googleDriveExportInputSchema = z.object({
|
||||
fileName: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export const googleDriveMoveInputSchema = z.object({
|
||||
accessToken: googleAccessTokenSchema,
|
||||
fileId: z.string().trim().min(1, 'File ID is required'),
|
||||
destinationFolderId: z.string().trim().min(1, 'Destination folder ID is required'),
|
||||
removeFromCurrent: z.boolean().optional().default(true),
|
||||
})
|
||||
|
||||
export type GoogleDriveUploadInput = z.output<typeof googleDriveUploadInputSchema>
|
||||
export type GoogleDriveDownloadInput = z.output<typeof googleDriveDownloadInputSchema>
|
||||
export type GoogleDriveExportInput = z.output<typeof googleDriveExportInputSchema>
|
||||
export type GoogleDriveMoveInput = z.output<typeof googleDriveMoveInputSchema>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { resolveGoogleDriveUploadFile } from '@/lib/internal/google-drive/file-i
|
||||
import type {
|
||||
GoogleDriveDownloadInput,
|
||||
GoogleDriveExportInput,
|
||||
GoogleDriveMoveInput,
|
||||
GoogleDriveUploadInput,
|
||||
} from '@/lib/internal/google-drive/input'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
@@ -292,6 +293,64 @@ export async function executeGoogleDriveExport(
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGoogleDriveMove(
|
||||
input: GoogleDriveMoveInput,
|
||||
context: GoogleDriveOperationContext
|
||||
) {
|
||||
context.signal?.throwIfAborted()
|
||||
const query = new URLSearchParams({
|
||||
addParents: input.destinationFolderId,
|
||||
fields: ALL_FILE_FIELDS,
|
||||
supportsAllDrives: 'true',
|
||||
})
|
||||
|
||||
if (input.removeFromCurrent) {
|
||||
const metadataResponse = await requestGoogleDrive({
|
||||
accessToken: input.accessToken,
|
||||
label: 'moveMetadataUrl',
|
||||
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
|
||||
signal: context.signal,
|
||||
url: `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}?fields=parents&supportsAllDrives=true`,
|
||||
})
|
||||
if (!metadataResponse.ok) {
|
||||
await providerJsonError(
|
||||
metadataResponse,
|
||||
'Failed to retrieve file metadata',
|
||||
metadataResponse.status,
|
||||
context.signal
|
||||
)
|
||||
}
|
||||
const metadata = await responseObject(metadataResponse)
|
||||
if (Array.isArray(metadata.parents) && metadata.parents.length > 0) {
|
||||
query.set(
|
||||
'removeParents',
|
||||
metadata.parents.filter((parent): parent is string => typeof parent === 'string').join(',')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await requestGoogleDrive({
|
||||
accessToken: input.accessToken,
|
||||
body: JSON.stringify({}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
label: 'moveFileUrl',
|
||||
maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES,
|
||||
method: 'PATCH',
|
||||
signal: context.signal,
|
||||
url: `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}?${query.toString()}`,
|
||||
})
|
||||
if (!response.ok) {
|
||||
await providerJsonError(
|
||||
response,
|
||||
'Failed to move Google Drive file',
|
||||
response.status,
|
||||
context.signal
|
||||
)
|
||||
}
|
||||
const file = await responseObject(response)
|
||||
return { success: true, output: { file } }
|
||||
}
|
||||
|
||||
function uploadMetadata(input: GoogleDriveUploadInput, requestedMimeType: string) {
|
||||
return {
|
||||
name: input.fileName,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
executeManagedAgentArchiveSessionOperation,
|
||||
executeManagedAgentCreateSessionOperation,
|
||||
executeManagedAgentDeleteSessionOperation,
|
||||
executeManagedAgentGetSessionOperation,
|
||||
executeManagedAgentInterruptSessionOperation,
|
||||
executeManagedAgentListEventsOperation,
|
||||
executeManagedAgentRespondCustomToolOperation,
|
||||
executeManagedAgentRespondToolConfirmationOperation,
|
||||
executeManagedAgentRunSessionOperation,
|
||||
executeManagedAgentSendMessageOperation,
|
||||
executeManagedAgentUpdateSessionOperation,
|
||||
} from '@/lib/internal/managed-agent/operations'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeManagedAgentTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'managed_agent_archive_session':
|
||||
return executeToolOperationImplementation(executeManagedAgentArchiveSessionOperation, request)
|
||||
case 'managed_agent_create_session':
|
||||
return executeToolOperationImplementation(executeManagedAgentCreateSessionOperation, request)
|
||||
case 'managed_agent_delete_session':
|
||||
return executeToolOperationImplementation(executeManagedAgentDeleteSessionOperation, request)
|
||||
case 'managed_agent_get_session':
|
||||
return executeToolOperationImplementation(executeManagedAgentGetSessionOperation, request)
|
||||
case 'managed_agent_interrupt_session':
|
||||
return executeToolOperationImplementation(
|
||||
executeManagedAgentInterruptSessionOperation,
|
||||
request
|
||||
)
|
||||
case 'managed_agent_list_events':
|
||||
return executeToolOperationImplementation(executeManagedAgentListEventsOperation, request)
|
||||
case 'managed_agent_respond_custom_tool':
|
||||
return executeToolOperationImplementation(
|
||||
executeManagedAgentRespondCustomToolOperation,
|
||||
request
|
||||
)
|
||||
case 'managed_agent_respond_tool_confirmation':
|
||||
return executeToolOperationImplementation(
|
||||
executeManagedAgentRespondToolConfirmationOperation,
|
||||
request
|
||||
)
|
||||
case 'managed_agent_run_session':
|
||||
return executeToolOperationImplementation(executeManagedAgentRunSessionOperation, request)
|
||||
case 'managed_agent_send_message':
|
||||
return executeToolOperationImplementation(executeManagedAgentSendMessageOperation, request)
|
||||
case 'managed_agent_update_session':
|
||||
return executeToolOperationImplementation(executeManagedAgentUpdateSessionOperation, request)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported managed-agent tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { archiveSession } from '@/lib/managed-agents/session-client'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentArchiveSessionParams,
|
||||
ManagedAgentArchiveSessionResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentArchiveSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentArchiveSessionParams
|
||||
> = async (params, signal): Promise<ManagedAgentArchiveSessionResponse> => {
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: { sessionId: '', archived: false }, error: target.error }
|
||||
}
|
||||
|
||||
try {
|
||||
await archiveSession({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return { success: true, output: { sessionId: target.sessionId, archived: true } }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, archived: false },
|
||||
error: getErrorMessage(error, 'Failed to archive Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import {
|
||||
type CreateSessionInput,
|
||||
createSession,
|
||||
getEnvironmentType,
|
||||
} from '@/lib/managed-agents/session-client'
|
||||
import {
|
||||
isTruthyAck,
|
||||
normalizeFiles,
|
||||
normalizeMemoryAccess,
|
||||
normalizeSessionParameters,
|
||||
normalizeStringList,
|
||||
} from '@/tools/managed_agent/normalizers'
|
||||
import type {
|
||||
ManagedAgentCreateSessionParams,
|
||||
ManagedAgentCreateSessionResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentCreateSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentCreateSessionParams
|
||||
> = async (params, signal, context): Promise<ManagedAgentCreateSessionResponse> => {
|
||||
const apiKey = params.accessToken
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: '', started: false },
|
||||
error: 'No Claude Platform credential is selected, or it could not be resolved.',
|
||||
}
|
||||
}
|
||||
|
||||
const agentId = params.agent?.trim()
|
||||
const environmentId = params.environment?.trim()
|
||||
if (!agentId || !environmentId) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: '', started: false },
|
||||
error: 'An agent and an environment are required.',
|
||||
}
|
||||
}
|
||||
|
||||
const vaultIds = normalizeStringList(params.vaults)
|
||||
if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: '', started: false },
|
||||
error:
|
||||
'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).',
|
||||
}
|
||||
}
|
||||
|
||||
const files = normalizeFiles(params.files)
|
||||
const sessionParameters = normalizeSessionParameters(params.sessionParameters)
|
||||
const memoryStoreId = params.memoryStoreId?.trim() || undefined
|
||||
const memoryAccess = normalizeMemoryAccess(params.memoryAccess)
|
||||
const memoryInstructions = params.memoryInstructions?.trim() || undefined
|
||||
const initialMessage = (params.userMessage ?? '').toString().trim() || undefined
|
||||
|
||||
const workflowId = context?.workflowId.trim()
|
||||
const title = workflowId ? `Sim workflow ${workflowId}` : undefined
|
||||
|
||||
// Self-hosted environments reject `resources`, so the payload must know the
|
||||
// execution model. The API is authoritative; the block's hint is a fallback.
|
||||
const hinted =
|
||||
params.environmentType === 'self_hosted' || params.environmentType === 'cloud'
|
||||
? params.environmentType
|
||||
: undefined
|
||||
const environmentType =
|
||||
(await getEnvironmentType({ apiKey, environmentId, ...(signal ? { signal } : {}) })) ?? hinted
|
||||
|
||||
const createInput: CreateSessionInput = {
|
||||
apiKey,
|
||||
agentId,
|
||||
environmentId,
|
||||
...(environmentType ? { environmentType } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(vaultIds.length > 0 ? { vaultIds } : {}),
|
||||
...(memoryStoreId ? { memoryStoreId } : {}),
|
||||
...(memoryStoreId && memoryAccess ? { memoryAccess } : {}),
|
||||
...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}),
|
||||
...(files.length > 0 ? { files } : {}),
|
||||
...(sessionParameters ? { sessionParameters } : {}),
|
||||
...(initialMessage ? { initialMessage } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await createSession(createInput)
|
||||
return {
|
||||
success: true,
|
||||
output: { sessionId: session.id, started: Boolean(initialMessage) },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: '', started: false },
|
||||
error: getErrorMessage(error, 'Failed to create Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { deleteSession } from '@/lib/managed-agents/session-client'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentDeleteSessionParams,
|
||||
ManagedAgentDeleteSessionResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentDeleteSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentDeleteSessionParams
|
||||
> = async (params, signal): Promise<ManagedAgentDeleteSessionResponse> => {
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: { sessionId: '', deleted: false }, error: target.error }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSession({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return { success: true, output: { sessionId: target.sessionId, deleted: true } }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, deleted: false },
|
||||
error: getErrorMessage(error, 'Failed to delete Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { resolvePendingToolGates, retrieveSession } from '@/lib/managed-agents/session-client'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentGetSessionParams,
|
||||
ManagedAgentGetSessionResponse,
|
||||
ManagedAgentPendingTool,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
const logger = createLogger('ManagedAgentGetSession')
|
||||
const REQUIRES_ACTION = 'requires_action'
|
||||
|
||||
export const executeManagedAgentGetSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentGetSessionParams
|
||||
> = async (params, signal): Promise<ManagedAgentGetSessionResponse> => {
|
||||
const emptyOutput = {
|
||||
sessionId: '',
|
||||
status: '',
|
||||
requiresAction: false,
|
||||
pendingTools: [] as ManagedAgentPendingTool[],
|
||||
}
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: emptyOutput, error: target.error }
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await retrieveSession({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
|
||||
const requiresAction =
|
||||
snapshot.status === 'idle' && snapshot.stopReason?.type === REQUIRES_ACTION
|
||||
const eventIds = snapshot.stopReason?.eventIds ?? []
|
||||
// Only pay for the events call when the session is actually blocked.
|
||||
const pendingTools =
|
||||
requiresAction && eventIds.length > 0
|
||||
? await resolvePendingToolGates({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
eventIds,
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
: []
|
||||
|
||||
// A blocked session that names no blocking events is an anomaly: it waits
|
||||
// indefinitely, but nothing here can say for what. `requiresAction` stays
|
||||
// true because that is the truth — reporting false would tell a workflow
|
||||
// the session is fine while it is parked forever — so log it instead, so
|
||||
// the dead end is visible rather than silent.
|
||||
if (requiresAction && pendingTools.length === 0) {
|
||||
logger.warn('Managed Agent session requires action but reported no blocking event ids', {
|
||||
sessionId: target.sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
sessionId: target.sessionId,
|
||||
status: snapshot.status ?? '',
|
||||
...(snapshot.stopReason?.type ? { stopReason: snapshot.stopReason.type } : {}),
|
||||
requiresAction,
|
||||
pendingTools,
|
||||
...(snapshot.metadata ? { metadata: snapshot.metadata } : {}),
|
||||
...(snapshot.title ? { title: snapshot.title } : {}),
|
||||
...(snapshot.usage?.inputTokens !== undefined
|
||||
? { inputTokens: snapshot.usage.inputTokens }
|
||||
: {}),
|
||||
...(snapshot.usage?.outputTokens !== undefined
|
||||
? { outputTokens: snapshot.usage.outputTokens }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { ...emptyOutput, sessionId: target.sessionId },
|
||||
error: getErrorMessage(error, 'Failed to read Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { executeManagedAgentArchiveSessionOperation } from '@/lib/internal/managed-agent/operations/archive-session'
|
||||
export { executeManagedAgentCreateSessionOperation } from '@/lib/internal/managed-agent/operations/create-session'
|
||||
export { executeManagedAgentDeleteSessionOperation } from '@/lib/internal/managed-agent/operations/delete-session'
|
||||
export { executeManagedAgentGetSessionOperation } from '@/lib/internal/managed-agent/operations/get-session'
|
||||
export { executeManagedAgentInterruptSessionOperation } from '@/lib/internal/managed-agent/operations/interrupt-session'
|
||||
export { executeManagedAgentListEventsOperation } from '@/lib/internal/managed-agent/operations/list-events'
|
||||
export { executeManagedAgentRespondCustomToolOperation } from '@/lib/internal/managed-agent/operations/respond-custom-tool'
|
||||
export { executeManagedAgentRespondToolConfirmationOperation } from '@/lib/internal/managed-agent/operations/respond-tool-confirmation'
|
||||
export { executeManagedAgentRunSessionOperation } from '@/lib/internal/managed-agent/operations/run-session'
|
||||
export { executeManagedAgentSendMessageOperation } from '@/lib/internal/managed-agent/operations/send-message'
|
||||
export { executeManagedAgentUpdateSessionOperation } from '@/lib/internal/managed-agent/operations/update-session'
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { sendSessionEvents } from '@/lib/managed-agents/session-client'
|
||||
import { INTERRUPT_TIMEOUT_MS } from '@/tools/managed_agent/interrupt_session'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentInterruptSessionParams,
|
||||
ManagedAgentInterruptSessionResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentInterruptSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentInterruptSessionParams
|
||||
> = async (params, signal): Promise<ManagedAgentInterruptSessionResponse> => {
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: { sessionId: '', interrupted: false }, error: target.error }
|
||||
}
|
||||
|
||||
try {
|
||||
await sendSessionEvents({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
events: [{ type: 'user.interrupt' }],
|
||||
// Bounded so a stalled connection can't hang the operation. The
|
||||
// workflow's own signal still cancels earlier when present; `any`
|
||||
// resolves on whichever fires first.
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(INTERRUPT_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(INTERRUPT_TIMEOUT_MS),
|
||||
})
|
||||
return { success: true, output: { sessionId: target.sessionId, interrupted: true } }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, interrupted: false },
|
||||
error: getErrorMessage(error, 'Failed to interrupt Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { listSessionEventsPage } from '@/lib/managed-agents/session-client'
|
||||
import { DEFAULT_EVENT_LIMIT } from '@/tools/managed_agent/list_events'
|
||||
import { normalizeStringList } from '@/tools/managed_agent/normalizers'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentListEventsParams,
|
||||
ManagedAgentListEventsResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentListEventsOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentListEventsParams
|
||||
> = async (params, signal): Promise<ManagedAgentListEventsResponse> => {
|
||||
const emptyOutput = {
|
||||
sessionId: '',
|
||||
events: [] as unknown[],
|
||||
count: 0,
|
||||
assistantText: '',
|
||||
truncated: false,
|
||||
}
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: emptyOutput, error: target.error }
|
||||
}
|
||||
|
||||
const types = normalizeStringList(params.eventTypes)
|
||||
// Floor BEFORE the positivity check: a fractional limit like 0.5 would pass
|
||||
// `> 0` and then floor to 0, which reads as "no cap" downstream and returns
|
||||
// the whole history. Anything that does not floor to a positive integer
|
||||
// falls back to the default rather than silently becoming unbounded.
|
||||
const requested = Math.floor(Number(params.limit))
|
||||
const maxItems = Number.isFinite(requested) && requested > 0 ? requested : DEFAULT_EVENT_LIMIT
|
||||
|
||||
try {
|
||||
const { events, total } = await listSessionEventsPage({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
maxItems,
|
||||
...(types.length > 0 ? { types } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
|
||||
let assistantText = ''
|
||||
for (const event of events) {
|
||||
// Skip idless events: those are stream-only previews, and the persisted
|
||||
// copy carrying the same text arrives separately.
|
||||
if (event.type !== 'agent.message' || !event.id || !Array.isArray(event.content)) continue
|
||||
for (const block of event.content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string') assistantText += block.text
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
sessionId: target.sessionId,
|
||||
events,
|
||||
count: events.length,
|
||||
assistantText,
|
||||
// Compared against the untrimmed history size, not the limit: a
|
||||
// session holding exactly `maxItems` events dropped nothing and must
|
||||
// not be reported as a partial read.
|
||||
truncated: total > events.length,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { ...emptyOutput, sessionId: target.sessionId },
|
||||
error: getErrorMessage(error, 'Failed to list Managed Agent session events'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { sendCustomToolResults } from '@/lib/managed-agents/session-client'
|
||||
import { isTruthyAck } from '@/tools/managed_agent/normalizers'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentCustomToolResultParams,
|
||||
ManagedAgentCustomToolResultResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentRespondCustomToolOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentCustomToolResultParams
|
||||
> = async (params, signal): Promise<ManagedAgentCustomToolResultResponse> => {
|
||||
const emptyOutput = { sessionId: '', answeredToolUseId: '' }
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: emptyOutput, error: target.error }
|
||||
}
|
||||
|
||||
const customToolUseId = params.customToolUseId?.trim()
|
||||
if (!customToolUseId) {
|
||||
return {
|
||||
success: false,
|
||||
output: { ...emptyOutput, sessionId: target.sessionId },
|
||||
error: 'A custom tool-use event id is required. Read it from Get Session pendingTools[].id.',
|
||||
}
|
||||
}
|
||||
|
||||
// The result may legitimately be empty (a tool that returns nothing), so
|
||||
// only the id is required — an absent result is sent as an empty string.
|
||||
const result = (params.result ?? '').toString()
|
||||
const isError = isTruthyAck(params.isError)
|
||||
|
||||
try {
|
||||
await sendCustomToolResults({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
results: [{ customToolUseId, content: result, isError }],
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: { sessionId: target.sessionId, answeredToolUseId: customToolUseId },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { ...emptyOutput, sessionId: target.sessionId },
|
||||
error: getErrorMessage(error, 'Failed to send custom tool result'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { sendToolConfirmations } from '@/lib/managed-agents/session-client'
|
||||
import { normalizeStringList } from '@/tools/managed_agent/normalizers'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentToolConfirmationParams,
|
||||
ManagedAgentToolConfirmationResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentRespondToolConfirmationOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentToolConfirmationParams
|
||||
> = async (params, signal): Promise<ManagedAgentToolConfirmationResponse> => {
|
||||
const emptyOutput = { sessionId: '', decision: '', confirmedToolUseIds: [] as string[] }
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: emptyOutput, error: target.error }
|
||||
}
|
||||
|
||||
const decision = (params.decision ?? '').toString().trim().toLowerCase()
|
||||
if (decision !== 'allow' && decision !== 'deny') {
|
||||
return {
|
||||
success: false,
|
||||
output: { ...emptyOutput, sessionId: target.sessionId },
|
||||
error: "Decision must be 'allow' or 'deny'.",
|
||||
}
|
||||
}
|
||||
|
||||
const toolUseIds = normalizeStringList(params.toolUseIds)
|
||||
if (toolUseIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { ...emptyOutput, sessionId: target.sessionId, decision },
|
||||
error:
|
||||
'At least one tool-use event id is required. Read them from Get Session pendingTools[].id.',
|
||||
}
|
||||
}
|
||||
|
||||
const denyMessage = params.denyMessage?.trim()
|
||||
try {
|
||||
await sendToolConfirmations({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
confirmations: toolUseIds.map((toolUseId) => ({
|
||||
toolUseId,
|
||||
result: decision,
|
||||
...(decision === 'deny' && denyMessage ? { denyMessage } : {}),
|
||||
})),
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: { sessionId: target.sessionId, decision, confirmedToolUseIds: toolUseIds },
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, decision, confirmedToolUseIds: [] },
|
||||
error: getErrorMessage(error, 'Failed to send tool confirmation'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { runManagedAgentSession } from '@/lib/managed-agents/run-session'
|
||||
import {
|
||||
isTruthyAck,
|
||||
normalizeFiles,
|
||||
normalizeMemoryAccess,
|
||||
normalizeSessionParameters,
|
||||
normalizeStringList,
|
||||
} from '@/tools/managed_agent/normalizers'
|
||||
import type {
|
||||
ManagedAgentRunSessionParams,
|
||||
ManagedAgentRunSessionResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentRunSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentRunSessionParams
|
||||
> = async (params, signal, context): Promise<ManagedAgentRunSessionResponse> => {
|
||||
const apiKey = params.accessToken
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
output: { content: '', sessionId: '' },
|
||||
error: 'No Claude Platform credential is selected, or it could not be resolved.',
|
||||
}
|
||||
}
|
||||
|
||||
const agentId = params.agent?.trim()
|
||||
const environmentId = params.environment?.trim()
|
||||
if (!agentId || !environmentId) {
|
||||
return {
|
||||
success: false,
|
||||
output: { content: '', sessionId: '' },
|
||||
error: 'An agent and an environment are required.',
|
||||
}
|
||||
}
|
||||
|
||||
const vaultIds = normalizeStringList(params.vaults)
|
||||
if (vaultIds.length > 0 && !isTruthyAck(params.vaultsAck)) {
|
||||
return {
|
||||
success: false,
|
||||
output: { content: '', sessionId: '' },
|
||||
error:
|
||||
'Vault authorization is required — check the "I am authorized to use these vaults" acknowledgement on the block, or remove the selected vault(s).',
|
||||
}
|
||||
}
|
||||
|
||||
const files = normalizeFiles(params.files)
|
||||
const sessionParameters = normalizeSessionParameters(params.sessionParameters)
|
||||
const memoryStoreId = params.memoryStoreId?.trim() || undefined
|
||||
const memoryAccess = normalizeMemoryAccess(params.memoryAccess)
|
||||
const memoryInstructions = params.memoryInstructions?.trim() || undefined
|
||||
|
||||
// Title the Anthropic session so it is traceable to its Sim workflow from
|
||||
// the Claude Platform console. Only the workflow id is available in the
|
||||
// client-safe execution context (names would require a DB lookup).
|
||||
const workflowId = context?.workflowId.trim()
|
||||
const title = workflowId ? `Sim workflow ${workflowId}` : undefined
|
||||
|
||||
const environmentType =
|
||||
params.environmentType === 'self_hosted' || params.environmentType === 'cloud'
|
||||
? params.environmentType
|
||||
: undefined
|
||||
|
||||
const result = await runManagedAgentSession({
|
||||
apiKey,
|
||||
agentId,
|
||||
environmentId,
|
||||
userMessage: (params.userMessage ?? '').toString(),
|
||||
...(environmentType ? { environmentType } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(vaultIds.length > 0 ? { vaultIds } : {}),
|
||||
...(memoryStoreId ? { memoryStoreId } : {}),
|
||||
...(memoryStoreId && memoryAccess ? { memoryAccess } : {}),
|
||||
...(memoryStoreId && memoryInstructions ? { memoryInstructions } : {}),
|
||||
...(files.length > 0 ? { files } : {}),
|
||||
...(sessionParameters ? { sessionParameters } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { content: result.content, sessionId: result.sessionId ?? '' },
|
||||
error: result.error ?? 'Managed Agent session failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: result.content,
|
||||
sessionId: result.sessionId ?? '',
|
||||
...(result.inputTokens !== undefined ? { inputTokens: result.inputTokens } : {}),
|
||||
...(result.outputTokens !== undefined ? { outputTokens: result.outputTokens } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { sendUserMessage } from '@/lib/managed-agents/session-client'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentSendMessageParams,
|
||||
ManagedAgentSendMessageResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentSendMessageOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentSendMessageParams
|
||||
> = async (params, signal): Promise<ManagedAgentSendMessageResponse> => {
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: { sessionId: '', sent: false }, error: target.error }
|
||||
}
|
||||
|
||||
const text = (params.userMessage ?? '').toString().trim()
|
||||
if (!text) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, sent: false },
|
||||
error: 'A user message is required.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendUserMessage({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
text,
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return { success: true, output: { sessionId: target.sessionId, sent: true } }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, sent: false },
|
||||
error: getErrorMessage(error, 'Failed to send message to Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { updateSession } from '@/lib/managed-agents/session-client'
|
||||
import { isTruthyAck, normalizeSessionParameters } from '@/tools/managed_agent/normalizers'
|
||||
import { resolveSessionTarget } from '@/tools/managed_agent/shared'
|
||||
import type {
|
||||
ManagedAgentUpdateSessionParams,
|
||||
ManagedAgentUpdateSessionResponse,
|
||||
} from '@/tools/managed_agent/types'
|
||||
|
||||
export const executeManagedAgentUpdateSessionOperation: InternalToolOperationImplementation<
|
||||
ManagedAgentUpdateSessionParams
|
||||
> = async (params, signal): Promise<ManagedAgentUpdateSessionResponse> => {
|
||||
const target = resolveSessionTarget(params)
|
||||
if (!target.ok) {
|
||||
return { success: false, output: { sessionId: '', updated: false }, error: target.error }
|
||||
}
|
||||
|
||||
// A whitespace-only title is treated as "not provided", not as a request to
|
||||
// blank the session's title — otherwise a stray space in the field would
|
||||
// both slip past the guard below and silently clear an existing title.
|
||||
const trimmedTitle = params.title?.trim()
|
||||
const title = trimmedTitle ? trimmedTitle : undefined
|
||||
|
||||
// Clearing metadata needs its own explicit signal. An empty metadata table
|
||||
// cannot mean "clear": a table the author never touched is also empty, so
|
||||
// inferring intent from emptiness would wipe a session's metadata on every
|
||||
// title-only update. `{}` is only sent when the author asks for it.
|
||||
const clearMetadata = isTruthyAck(params.clearMetadata)
|
||||
const metadata = clearMetadata ? {} : normalizeSessionParameters(params.sessionParameters)
|
||||
if (title === undefined && metadata === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, updated: false },
|
||||
error: 'Provide a title or metadata to update, or check "Clear metadata".',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await updateSession({
|
||||
apiKey: target.apiKey,
|
||||
sessionId: target.sessionId,
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
sessionId: target.sessionId,
|
||||
updated: true,
|
||||
...(snapshot.metadata ? { metadata: snapshot.metadata } : {}),
|
||||
...(snapshot.title ? { title: snapshot.title } : {}),
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: { sessionId: target.sessionId, updated: false },
|
||||
error: getErrorMessage(error, 'Failed to update Managed Agent session'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { executeAddUserAppRoleAssignmentOperation } from '@/lib/internal/microsoft-ad/operations/add-user-app-role-assignment'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeMicrosoftAdTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'microsoft_ad_add_user_app_role_assignment':
|
||||
return executeToolOperationImplementation(executeAddUserAppRoleAssignmentOperation, request)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported microsoft-ad tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { executeAddUserAppRoleAssignmentOperation } from '@/lib/internal/microsoft-ad/operations/add-user-app-role-assignment'
|
||||
|
||||
const INPUT = {
|
||||
accessToken: 'access-token',
|
||||
userId: '11111111-1111-4111-8111-111111111111',
|
||||
resourceId: '22222222-2222-4222-8222-222222222222',
|
||||
appRoleId: '33333333-3333-4333-8333-333333333333',
|
||||
}
|
||||
|
||||
describe('executeAddUserAppRoleAssignmentOperation', () => {
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('rejects malformed successful Graph JSON instead of fabricating a null assignment', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('not-json'))
|
||||
|
||||
await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow(
|
||||
'Microsoft Graph returned malformed JSON for the app role assignment'
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a successful non-object assignment payload', async () => {
|
||||
fetchMock.mockResolvedValueOnce(Response.json(null))
|
||||
|
||||
await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow(
|
||||
'Microsoft Graph returned an invalid app role assignment'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a successful empty assignment payload', async () => {
|
||||
fetchMock.mockResolvedValueOnce(Response.json({}))
|
||||
|
||||
await expect(executeAddUserAppRoleAssignmentOperation(INPUT)).rejects.toThrow(
|
||||
'Microsoft Graph returned an invalid app role assignment'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import {
|
||||
mapAppRoleAssignment,
|
||||
readIdentifiers,
|
||||
} from '@/tools/microsoft_ad/add_user_app_role_assignment'
|
||||
import type {
|
||||
MicrosoftAdAddUserAppRoleAssignmentParams,
|
||||
MicrosoftAdAddUserAppRoleAssignmentResponse,
|
||||
} from '@/tools/microsoft_ad/types'
|
||||
import { extractGraphErrorMessage, resolveGraphUserObjectId } from '@/tools/microsoft_ad/utils'
|
||||
|
||||
export const executeAddUserAppRoleAssignmentOperation: InternalToolOperationImplementation<
|
||||
MicrosoftAdAddUserAppRoleAssignmentParams
|
||||
> = async (params, signal): Promise<MicrosoftAdAddUserAppRoleAssignmentResponse> => {
|
||||
const { userId, resourceId, appRoleId } = readIdentifiers(params)
|
||||
const principalId = await resolveGraphUserObjectId(userId, params.accessToken, signal)
|
||||
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/appRoleAssignments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ principalId, resourceId, appRoleId }),
|
||||
signal,
|
||||
}
|
||||
)
|
||||
let body: unknown
|
||||
try {
|
||||
body = await response.json()
|
||||
} catch {
|
||||
signal?.throwIfAborted()
|
||||
if (response.ok) {
|
||||
throw new Error('Microsoft Graph returned malformed JSON for the app role assignment')
|
||||
}
|
||||
body = {}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
if (!response.ok) {
|
||||
throw new Error(extractGraphErrorMessage(body, 'Failed to grant the app role to the user'))
|
||||
}
|
||||
if (!isRecordLike(body) || typeof body.id !== 'string' || !body.id.trim()) {
|
||||
throw new Error('Microsoft Graph returned an invalid app role assignment')
|
||||
}
|
||||
|
||||
return { success: true, output: { assignment: mapAppRoleAssignment(body) } }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
executeNetsuiteAttachRecordOperation,
|
||||
executeNetsuiteBatchCreateRecordsOperation,
|
||||
executeNetsuiteBatchDeleteRecordsOperation,
|
||||
executeNetsuiteBatchGetRecordsOperation,
|
||||
executeNetsuiteBatchUpdateRecordsOperation,
|
||||
executeNetsuiteBatchUpsertRecordsOperation,
|
||||
executeNetsuiteCreateRecordOperation,
|
||||
executeNetsuiteDeleteRecordOperation,
|
||||
executeNetsuiteDetachRecordOperation,
|
||||
executeNetsuiteExecuteActionOperation,
|
||||
executeNetsuiteExecuteDatasetOperation,
|
||||
executeNetsuiteExecuteSuiteQLOperation,
|
||||
executeNetsuiteGetAsyncResultOperation,
|
||||
executeNetsuiteGetAsyncStatusOperation,
|
||||
executeNetsuiteGetGovernanceLimitsOperation,
|
||||
executeNetsuiteGetRecordFormOperation,
|
||||
executeNetsuiteGetRecordMetadataOperation,
|
||||
executeNetsuiteGetRecordOperation,
|
||||
executeNetsuiteGetSelectOptionsOperation,
|
||||
executeNetsuiteGetServerTimeOperation,
|
||||
executeNetsuiteGetSubresourceOperation,
|
||||
executeNetsuiteListDatasetsOperation,
|
||||
executeNetsuiteListRecordsOperation,
|
||||
executeNetsuiteListRecordTypesOperation,
|
||||
executeNetsuiteTransformRecordOperation,
|
||||
executeNetsuiteUpdateRecordOperation,
|
||||
executeNetsuiteUpsertRecordOperation,
|
||||
} from '@/lib/internal/netsuite/operations'
|
||||
import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute'
|
||||
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
|
||||
|
||||
export const executeNetsuiteTool: InternalToolOperationHandler = async (request) => {
|
||||
switch (request.toolId) {
|
||||
case 'netsuite_attach_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteAttachRecordOperation, request)
|
||||
case 'netsuite_batch_create_records':
|
||||
return executeToolOperationImplementation(executeNetsuiteBatchCreateRecordsOperation, request)
|
||||
case 'netsuite_batch_delete_records':
|
||||
return executeToolOperationImplementation(executeNetsuiteBatchDeleteRecordsOperation, request)
|
||||
case 'netsuite_batch_get_records':
|
||||
return executeToolOperationImplementation(executeNetsuiteBatchGetRecordsOperation, request)
|
||||
case 'netsuite_batch_update_records':
|
||||
return executeToolOperationImplementation(executeNetsuiteBatchUpdateRecordsOperation, request)
|
||||
case 'netsuite_batch_upsert_records':
|
||||
return executeToolOperationImplementation(executeNetsuiteBatchUpsertRecordsOperation, request)
|
||||
case 'netsuite_create_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteCreateRecordOperation, request)
|
||||
case 'netsuite_delete_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteDeleteRecordOperation, request)
|
||||
case 'netsuite_detach_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteDetachRecordOperation, request)
|
||||
case 'netsuite_execute_action':
|
||||
return executeToolOperationImplementation(executeNetsuiteExecuteActionOperation, request)
|
||||
case 'netsuite_execute_dataset':
|
||||
return executeToolOperationImplementation(executeNetsuiteExecuteDatasetOperation, request)
|
||||
case 'netsuite_execute_suiteql':
|
||||
return executeToolOperationImplementation(executeNetsuiteExecuteSuiteQLOperation, request)
|
||||
case 'netsuite_get_async_result':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetAsyncResultOperation, request)
|
||||
case 'netsuite_get_async_status':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetAsyncStatusOperation, request)
|
||||
case 'netsuite_get_governance_limits':
|
||||
return executeToolOperationImplementation(
|
||||
executeNetsuiteGetGovernanceLimitsOperation,
|
||||
request
|
||||
)
|
||||
case 'netsuite_get_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetRecordOperation, request)
|
||||
case 'netsuite_get_record_form':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetRecordFormOperation, request)
|
||||
case 'netsuite_get_record_metadata':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetRecordMetadataOperation, request)
|
||||
case 'netsuite_get_select_options':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetSelectOptionsOperation, request)
|
||||
case 'netsuite_get_server_time':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetServerTimeOperation, request)
|
||||
case 'netsuite_get_subresource':
|
||||
return executeToolOperationImplementation(executeNetsuiteGetSubresourceOperation, request)
|
||||
case 'netsuite_list_datasets':
|
||||
return executeToolOperationImplementation(executeNetsuiteListDatasetsOperation, request)
|
||||
case 'netsuite_list_record_types':
|
||||
return executeToolOperationImplementation(executeNetsuiteListRecordTypesOperation, request)
|
||||
case 'netsuite_list_records':
|
||||
return executeToolOperationImplementation(executeNetsuiteListRecordsOperation, request)
|
||||
case 'netsuite_transform_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteTransformRecordOperation, request)
|
||||
case 'netsuite_update_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteUpdateRecordOperation, request)
|
||||
case 'netsuite_upsert_record':
|
||||
return executeToolOperationImplementation(executeNetsuiteUpsertRecordOperation, request)
|
||||
default:
|
||||
return Response.json(
|
||||
{ success: false, error: `Unsupported netsuite tool: ${request.toolId}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteAttachParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizeRelatedType,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteAttachRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteAttachParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const relatedType = normalizeRelatedType(params.relatedType)
|
||||
const roleId = optionalTrim(params.roleId)
|
||||
const roleExternalId = optionalTrim(params.roleExternalId)
|
||||
if (roleId && roleExternalId) {
|
||||
throw new Error('Provide either a contact role ID or external ID, not both')
|
||||
}
|
||||
if (relatedType === 'file' && (roleId || roleExternalId)) {
|
||||
throw new Error('Contact roles cannot be provided when attaching a file')
|
||||
}
|
||||
return {
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: '!attach', label: 'Attach operation' },
|
||||
{ value: relatedType, label: 'Related type' },
|
||||
{ value: params.relatedId, label: 'Related ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
body: roleId
|
||||
? { role: { id: roleId } }
|
||||
: roleExternalId
|
||||
? { role: { externalId: roleExternalId } }
|
||||
: {},
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteBatchWriteParams } from '@/tools/netsuite/types'
|
||||
import { buildBatchWriteRequest, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteBatchCreateRecordsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteBatchWriteParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(params, () => buildBatchWriteRequest('POST', params), signal)
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteBatchDeleteParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizeBatchIds,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteBatchDeleteRecordsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteBatchDeleteParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key')
|
||||
return {
|
||||
method: 'DELETE',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 202, body: 'none' },
|
||||
responseLocation: 'async-job',
|
||||
query: { ids: normalizeBatchIds(params.ids) },
|
||||
headers: {
|
||||
Prefer: 'respond-async',
|
||||
...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteBatchGetParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizeBatchIds,
|
||||
normalizeOptionalBoolean,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteBatchGetRecordsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteBatchGetParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const idempotencyKey = optionalTrim(params.idempotencyKey, 'Idempotency key')
|
||||
return {
|
||||
method: 'GET',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 202, body: 'none' },
|
||||
responseLocation: 'async-job',
|
||||
query: {
|
||||
expandRecords: true,
|
||||
ids: normalizeBatchIds(params.ids),
|
||||
fields: optionalTrim(params.fields, 'Fields'),
|
||||
expand: optionalTrim(params.expand, 'Expand'),
|
||||
expandSubResources: normalizeOptionalBoolean(
|
||||
params.expandSubResources,
|
||||
'Expand subresources'
|
||||
),
|
||||
},
|
||||
headers: {
|
||||
Prefer: 'respond-async',
|
||||
...(idempotencyKey ? { 'X-NetSuite-idempotency-key': idempotencyKey } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteBatchWriteParams } from '@/tools/netsuite/types'
|
||||
import { buildBatchWriteRequest, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteBatchUpdateRecordsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteBatchWriteParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(params, () => buildBatchWriteRequest('PATCH', params), signal)
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteBatchWriteParams } from '@/tools/netsuite/types'
|
||||
import { buildBatchWriteRequest, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteBatchUpsertRecordsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteBatchWriteParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(params, () => buildBatchWriteRequest('PUT', params), signal)
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteCreateRecordParams } from '@/tools/netsuite/types'
|
||||
import { buildRecordPath, executeNetSuiteRequest, optionalTrim } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteCreateRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteCreateRecordParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const replace = optionalTrim(params.replace, 'Replace sublists')
|
||||
return {
|
||||
method: 'POST',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: replace ? { status: 201, body: 'object' } : { status: 204, body: 'none' },
|
||||
responseLocation: 'resource',
|
||||
query: { replace },
|
||||
body: params.body,
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteDeleteRecordParams } from '@/tools/netsuite/types'
|
||||
import { buildRecordPath, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteDeleteRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteDeleteRecordParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'DELETE',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteRelationshipParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizeRelatedType,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteDetachRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteRelationshipParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: '!detach', label: 'Detach operation' },
|
||||
{ value: normalizeRelatedType(params.relatedType), label: 'Related type' },
|
||||
{ value: params.relatedId, label: 'Related ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteExecuteActionParams } from '@/tools/netsuite/types'
|
||||
import { buildRecordPath, executeNetSuiteRequest, requiredTrim } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteExecuteActionOperation: InternalToolOperationImplementation<
|
||||
NetSuiteExecuteActionParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: `@${requiredTrim(params.action, 'Action')}`, label: 'Action' }
|
||||
),
|
||||
success: { status: 200, body: 'object', validator: 'record-action' },
|
||||
body: params.body ?? {},
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteExecuteDatasetParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
encodePathSegment,
|
||||
executeNetSuiteRequest,
|
||||
normalizePagination,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteExecuteDatasetOperation: InternalToolOperationImplementation<
|
||||
NetSuiteExecuteDatasetParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: `/services/rest/query/v1/dataset/${encodePathSegment(params.datasetId, 'Dataset ID')}/result`,
|
||||
success: { status: 200, body: 'object', validator: 'collection-page' },
|
||||
query: normalizePagination(params.limit, params.offset),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteSuiteQLParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest, normalizePagination, requiredTrim } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteExecuteSuiteQLOperation: InternalToolOperationImplementation<
|
||||
NetSuiteSuiteQLParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: '/services/rest/query/v1/suiteql',
|
||||
success: { status: 200, body: 'object', validator: 'suiteql-page' },
|
||||
query: normalizePagination(params.limit, params.offset),
|
||||
headers: { Prefer: 'transient' },
|
||||
body: { q: requiredTrim(params.query, 'SuiteQL query') },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteGetAsyncResultParams } from '@/tools/netsuite/types'
|
||||
import { encodePathSegment, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetAsyncResultOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetAsyncResultParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}/task/${encodePathSegment(params.taskId, 'Task ID')}/result`,
|
||||
success: { status: 200, body: 'optional-object' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteGetAsyncStatusParams } from '@/tools/netsuite/types'
|
||||
import { encodePathSegment, executeNetSuiteRequest, requiredTrim } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetAsyncStatusOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetAsyncStatusParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const view = params.view ?? 'job'
|
||||
if (view !== 'job' && view !== 'tasks' && view !== 'task') {
|
||||
throw new Error('Async status view must be job, tasks, or task')
|
||||
}
|
||||
const jobPath = `/services/rest/async/v1/job/${encodePathSegment(params.jobId, 'Job ID')}`
|
||||
if (view === 'job') {
|
||||
return {
|
||||
method: 'GET',
|
||||
path: jobPath,
|
||||
success: { status: 200, body: 'object', validator: 'async-job' },
|
||||
}
|
||||
}
|
||||
const taskPath =
|
||||
view === 'task'
|
||||
? `/${encodePathSegment(requiredTrim(params.taskId ?? '', 'Task ID'), 'Task ID')}`
|
||||
: ''
|
||||
return {
|
||||
method: 'GET',
|
||||
path: `${jobPath}/task${taskPath}`,
|
||||
success: {
|
||||
status: 200,
|
||||
body: 'object',
|
||||
validator: view === 'task' ? 'async-task' : 'async-task-collection',
|
||||
},
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteSystemParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetGovernanceLimitsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteSystemParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/system/v1/governanceLimits',
|
||||
success: { status: 200, body: 'object', validator: 'governance-limits' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteGetRecordFormParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizeOptionalBoolean,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetRecordFormOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetRecordFormParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const recordId = optionalTrim(params.recordId)
|
||||
return {
|
||||
method: recordId ? 'PATCH' : 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
...(recordId ? [{ value: recordId, label: 'Record ID' }] : [])
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
query: {
|
||||
fields: optionalTrim(params.fields),
|
||||
expand: optionalTrim(params.expand),
|
||||
expandSubResources: normalizeOptionalBoolean(
|
||||
params.expandSubResources,
|
||||
'Expand subresources'
|
||||
),
|
||||
},
|
||||
headers: {
|
||||
Accept: `application/vnd.oracle.resource+json; type=${recordId ? 'edit-form' : 'create-form'}`,
|
||||
},
|
||||
body: params.body ?? {},
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import { getMetadataAccept } from '@/tools/netsuite/get_record_metadata'
|
||||
import type { NetSuiteGetRecordMetadataParams } from '@/tools/netsuite/types'
|
||||
import { encodePathSegment, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetRecordMetadataOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetRecordMetadataParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: `/services/rest/record/v1/metadata-catalog/${encodePathSegment(params.recordType, 'Record type')}`,
|
||||
success: { status: 200, body: 'object' },
|
||||
headers: { Accept: getMetadataAccept(params.format) },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteGetRecordParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizeOptionalBoolean,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetRecordParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' }
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
query: {
|
||||
fields: optionalTrim(params.fields),
|
||||
expand: optionalTrim(params.expand),
|
||||
expandSubResources: normalizeOptionalBoolean(
|
||||
params.expandSubResources,
|
||||
'Expand subresources'
|
||||
),
|
||||
},
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteGetSelectOptionsParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizePagination,
|
||||
optionalTrim,
|
||||
requiredTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetSelectOptionsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetSelectOptionsParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => {
|
||||
const recordId = optionalTrim(params.recordId)
|
||||
const pagination = normalizePagination(params.limit, params.offset)
|
||||
return {
|
||||
method: recordId ? 'PATCH' : 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
...(recordId ? [{ value: recordId, label: 'Record ID' }] : [])
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
query: {
|
||||
...pagination,
|
||||
fields: requiredTrim(params.fields, 'Fields'),
|
||||
q: optionalTrim(params.q),
|
||||
},
|
||||
headers: { Accept: 'application/vnd.oracle.resource+json; type=select-options' },
|
||||
body: params.body ?? {},
|
||||
}
|
||||
},
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteSystemParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetServerTimeOperation: InternalToolOperationImplementation<
|
||||
NetSuiteSystemParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/system/v1/serverTime',
|
||||
success: { status: 200, body: 'object', validator: 'server-time' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteGetSubresourceParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
buildSubresourcePath,
|
||||
executeNetSuiteRequest,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteGetSubresourceOperation: InternalToolOperationImplementation<
|
||||
NetSuiteGetSubresourceParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
...buildSubresourcePath(params.subresourcePath)
|
||||
),
|
||||
success: { status: 200, body: 'object' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
export { executeNetsuiteAttachRecordOperation } from '@/lib/internal/netsuite/operations/attach-record'
|
||||
export { executeNetsuiteBatchCreateRecordsOperation } from '@/lib/internal/netsuite/operations/batch-create-records'
|
||||
export { executeNetsuiteBatchDeleteRecordsOperation } from '@/lib/internal/netsuite/operations/batch-delete-records'
|
||||
export { executeNetsuiteBatchGetRecordsOperation } from '@/lib/internal/netsuite/operations/batch-get-records'
|
||||
export { executeNetsuiteBatchUpdateRecordsOperation } from '@/lib/internal/netsuite/operations/batch-update-records'
|
||||
export { executeNetsuiteBatchUpsertRecordsOperation } from '@/lib/internal/netsuite/operations/batch-upsert-records'
|
||||
export { executeNetsuiteCreateRecordOperation } from '@/lib/internal/netsuite/operations/create-record'
|
||||
export { executeNetsuiteDeleteRecordOperation } from '@/lib/internal/netsuite/operations/delete-record'
|
||||
export { executeNetsuiteDetachRecordOperation } from '@/lib/internal/netsuite/operations/detach-record'
|
||||
export { executeNetsuiteExecuteActionOperation } from '@/lib/internal/netsuite/operations/execute-action'
|
||||
export { executeNetsuiteExecuteDatasetOperation } from '@/lib/internal/netsuite/operations/execute-dataset'
|
||||
export { executeNetsuiteExecuteSuiteQLOperation } from '@/lib/internal/netsuite/operations/execute-suiteql'
|
||||
export { executeNetsuiteGetAsyncResultOperation } from '@/lib/internal/netsuite/operations/get-async-result'
|
||||
export { executeNetsuiteGetAsyncStatusOperation } from '@/lib/internal/netsuite/operations/get-async-status'
|
||||
export { executeNetsuiteGetGovernanceLimitsOperation } from '@/lib/internal/netsuite/operations/get-governance-limits'
|
||||
export { executeNetsuiteGetRecordOperation } from '@/lib/internal/netsuite/operations/get-record'
|
||||
export { executeNetsuiteGetRecordFormOperation } from '@/lib/internal/netsuite/operations/get-record-form'
|
||||
export { executeNetsuiteGetRecordMetadataOperation } from '@/lib/internal/netsuite/operations/get-record-metadata'
|
||||
export { executeNetsuiteGetSelectOptionsOperation } from '@/lib/internal/netsuite/operations/get-select-options'
|
||||
export { executeNetsuiteGetServerTimeOperation } from '@/lib/internal/netsuite/operations/get-server-time'
|
||||
export { executeNetsuiteGetSubresourceOperation } from '@/lib/internal/netsuite/operations/get-subresource'
|
||||
export { executeNetsuiteListDatasetsOperation } from '@/lib/internal/netsuite/operations/list-datasets'
|
||||
export { executeNetsuiteListRecordTypesOperation } from '@/lib/internal/netsuite/operations/list-record-types'
|
||||
export { executeNetsuiteListRecordsOperation } from '@/lib/internal/netsuite/operations/list-records'
|
||||
export { executeNetsuiteTransformRecordOperation } from '@/lib/internal/netsuite/operations/transform-record'
|
||||
export { executeNetsuiteUpdateRecordOperation } from '@/lib/internal/netsuite/operations/update-record'
|
||||
export { executeNetsuiteUpsertRecordOperation } from '@/lib/internal/netsuite/operations/upsert-record'
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteListDatasetsParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest, normalizePagination } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteListDatasetsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteListDatasetsParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/query/v1/dataset/',
|
||||
success: { status: 200, body: 'object', validator: 'collection-page' },
|
||||
query: normalizePagination(params.limit, params.offset),
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteListRecordTypesParams } from '@/tools/netsuite/types'
|
||||
import { executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteListRecordTypesOperation: InternalToolOperationImplementation<
|
||||
NetSuiteListRecordTypesParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: '/services/rest/record/v1/metadata-catalog',
|
||||
success: { status: 200, body: 'object', validator: 'metadata-catalog' },
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteListRecordsParams } from '@/tools/netsuite/types'
|
||||
import {
|
||||
buildRecordPath,
|
||||
executeNetSuiteRequest,
|
||||
normalizePagination,
|
||||
optionalTrim,
|
||||
} from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteListRecordsOperation: InternalToolOperationImplementation<
|
||||
NetSuiteListRecordsParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'GET',
|
||||
path: buildRecordPath({ value: params.recordType, label: 'Record type' }),
|
||||
success: { status: 200, body: 'object', validator: 'collection-page' },
|
||||
query: {
|
||||
...normalizePagination(params.limit, params.offset),
|
||||
q: optionalTrim(params.q, 'Filter'),
|
||||
},
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteTransformRecordParams } from '@/tools/netsuite/types'
|
||||
import { buildRecordPath, executeNetSuiteRequest } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteTransformRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteTransformRecordParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'POST',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Source record type' },
|
||||
{ value: params.recordId, label: 'Record ID' },
|
||||
{ value: '!transform', label: 'Transform operation' },
|
||||
{ value: params.targetRecordType, label: 'Target record type' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
responseLocation: 'resource-optional',
|
||||
body: params.body ?? {},
|
||||
}),
|
||||
signal
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
|
||||
import type { NetSuiteUpdateRecordParams } from '@/tools/netsuite/types'
|
||||
import { buildRecordPath, executeNetSuiteRequest, optionalTrim } from '@/tools/netsuite/utils'
|
||||
|
||||
export const executeNetsuiteUpdateRecordOperation: InternalToolOperationImplementation<
|
||||
NetSuiteUpdateRecordParams
|
||||
> = (params, signal) =>
|
||||
executeNetSuiteRequest(
|
||||
params,
|
||||
() => ({
|
||||
method: 'PATCH',
|
||||
path: buildRecordPath(
|
||||
{ value: params.recordType, label: 'Record type' },
|
||||
{ value: params.recordId, label: 'Record ID' }
|
||||
),
|
||||
success: { status: 204, body: 'none' },
|
||||
responseLocation: 'resource',
|
||||
query: { replace: optionalTrim(params.replace, 'Replace sublists') },
|
||||
body: params.body,
|
||||
}),
|
||||
signal
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user