diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c24e27f9e0b..e7e67faecf6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,5 +24,6 @@ # Client-side stream consumption, hydration, and reconnect. /apps/sim/app/workspace/*/home/hooks/index.ts @simstudioai/mothership /apps/sim/app/workspace/*/home/hooks/use-chat.ts @simstudioai/mothership -/apps/sim/app/workspace/*/home/hooks/use-file-preview-sessions.ts @simstudioai/mothership +/apps/sim/app/workspace/*/home/hooks/preview/ @simstudioai/mothership +/apps/sim/app/workspace/*/home/hooks/stream/ @simstudioai/mothership /apps/sim/hooks/queries/tasks.ts @simstudioai/mothership diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index 4a6aab428ad..03f85553aa6 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -35,8 +35,15 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Apply migrations + - name: Apply database schema changes working-directory: ./packages/db env: DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || github.ref == 'refs/heads/dev' && secrets.DEV_DATABASE_URL || secrets.STAGING_DATABASE_URL }} - run: bun run ./scripts/migrate.ts \ No newline at end of file + run: | + if [ "${{ github.ref }}" = "refs/heads/dev" ]; then + echo "Dev environment detected — pushing schema with drizzle-kit (db:push)" + bun run db:push --force + else + echo "Applying versioned migrations (db:migrate)" + bun run ./scripts/migrate.ts + fi \ No newline at end of file diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 2ad53ceaf7f..9ad44ddc28e 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -272,6 +272,7 @@ export const blockTypeToIconMap: Record = { file: DocumentIcon, file_v2: DocumentIcon, file_v4: DocumentIcon, + file_v5: DocumentIcon, findymail: FindymailIcon, firecrawl: FirecrawlIcon, fireflies: FirefliesIcon, diff --git a/apps/docs/content/docs/en/triggers/slack.mdx b/apps/docs/content/docs/en/triggers/slack.mdx index cc5d20b042c..2998aae7d1c 100644 --- a/apps/docs/content/docs/en/triggers/slack.mdx +++ b/apps/docs/content/docs/en/triggers/slack.mdx @@ -32,7 +32,7 @@ Trigger workflow from Slack events like mentions, messages, and reactions | Parameter | Type | Description | | --------- | ---- | ----------- | | `event` | object | Slack event data | -| ↳ `event_type` | string | Type of Slack event \(e.g., app_mention, message\) | +| ↳ `event_type` | string | Type of Slack payload: an Events API event \(e.g., app_mention, message\), an interactivity type \(e.g., block_actions\), or "slash_command" for slash commands | | ↳ `subtype` | string | Message subtype \(e.g., channel_join, channel_leave, bot_message, file_share\). Null for regular user messages | | ↳ `channel` | string | Slack channel ID where the event occurred | | ↳ `channel_name` | string | Human-readable channel name | @@ -40,13 +40,22 @@ Trigger workflow from Slack events like mentions, messages, and reactions | ↳ `user` | string | User ID who triggered the event | | ↳ `user_name` | string | Username who triggered the event | | ↳ `bot_id` | string | Bot ID if the message was sent by a bot. Null for human users | -| ↳ `text` | string | Message text content | +| ↳ `text` | string | Message text content. For slash commands, the text after the command. For interactivity, the source message text \(falls back to the triggering action value\) | | ↳ `timestamp` | string | Message timestamp from the triggering event | | ↳ `thread_ts` | string | Parent thread timestamp \(if message is in a thread\) | | ↳ `team_id` | string | Slack workspace/team ID | | ↳ `event_id` | string | Unique event identifier | | ↳ `reaction` | string | Emoji reaction name \(e.g., thumbsup\). Present for reaction_added/reaction_removed events | | ↳ `item_user` | string | User ID of the original message author. Present for reaction_added/reaction_removed events | +| ↳ `command` | string | Slash command name including the leading slash \(e.g., /deploy\). Present for slash commands | +| ↳ `action_id` | string | action_id of the first interactive element triggered. Present for block_actions \(button/select clicks\) | +| ↳ `action_value` | string | Value carried by the first interactive element \(button value, selected option, date, etc.\). Present for block_actions | +| ↳ `actions` | json | Full array of interactive actions from the payload, preserving every element and its value. Present for block_actions | +| ↳ `response_url` | string | Temporary URL to post a response back to the originating message or command. Present for interactivity and slash commands | +| ↳ `trigger_id` | string | Short-lived trigger ID used to open a modal in response. Present for interactivity and slash commands | +| ↳ `callback_id` | string | Callback ID of the shortcut or view. Present for shortcuts and modal submissions | +| ↳ `api_app_id` | string | Slack app ID. Present for interactivity and slash commands | +| ↳ `message_ts` | string | Timestamp of the message the interaction originated from. Present for block_actions | | ↳ `hasFiles` | boolean | Whether the message has file attachments | | ↳ `files` | file[] | File attachments downloaded from the message \(if includeFiles is enabled and bot token is provided\) | diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index dde8e67e442..7232eb36be5 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -39,6 +39,14 @@ async function main() { hasRedis: !!env.REDIS_URL, }) + // Register the HTTP handler before Socket.IO attaches: engine.io captures + // pre-existing `request` listeners and forwards only non-`/socket.io/` + // requests to them, making it the single dispatcher for the shared port. + // The handler itself is assigned after the room manager exists, before listen(). + // biome-ignore lint/style/useConst: must be declared before the request listener closure; assigned only after the room manager exists + let httpHandler: ReturnType | undefined + httpServer.on('request', (req, res) => httpHandler?.(req, res)) + // Create Socket.IO server with Redis adapter if configured const io = await createSocketIOServer(httpServer) @@ -49,8 +57,7 @@ async function main() { io.use(authenticateSocket) // Set up HTTP handler for health checks and internal APIs - const httpHandler = createHttpHandler(roomManager, logger) - httpServer.on('request', httpHandler) + httpHandler = createHttpHandler(roomManager, logger) // Global error handlers process.on('uncaughtException', (error) => { diff --git a/apps/sim/app/api/admin/mothership/route.ts b/apps/sim/app/api/admin/mothership/route.ts index a5eb0245de1..d15d28a6f92 100644 --- a/apps/sim/app/api/admin/mothership/route.ts +++ b/apps/sim/app/api/admin/mothership/route.ts @@ -172,3 +172,59 @@ export const GET = withRouteHandler(async (req: NextRequest) => { ) } }) + +export const DELETE = withRouteHandler(async (req: NextRequest) => { + const userId = await getAuthorizedAdminUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const adminKey = env.MOTHERSHIP_API_ADMIN_KEY + if (!adminKey) { + return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 }) + } + + const { searchParams } = new URL(req.url) + const queryValidation = adminMothershipQuerySchema.safeParse(searchParamsToObject(searchParams)) + if (!queryValidation.success) return validationErrorResponse(queryValidation.error) + const { env: environment, endpoint } = queryValidation.data + + if (!isValidEndpoint(endpoint)) { + return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 }) + } + + const baseUrl = await getMothershipUrl(environment, userId) + if (!baseUrl) { + return NextResponse.json( + { error: `No URL configured for environment: ${environment}` }, + { status: 400 } + ) + } + + const forwardParams = new URLSearchParams() + searchParams.forEach((value, key) => { + if (key !== 'env' && key !== 'endpoint') { + forwardParams.set(key, value) + } + }) + + const qs = forwardParams.toString() + const targetUrl = `${baseUrl}/api/admin/${endpoint}${qs ? `?${qs}` : ''}` + + try { + const upstream = await fetch(targetUrl, { + method: 'DELETE', + headers: { 'x-api-key': adminKey }, + }) + + const data = await upstream.json() + return NextResponse.json(data, { status: upstream.status }) + } catch (error) { + return NextResponse.json( + { + error: `Failed to reach mothership (${environment}): ${getErrorMessage(error, 'Unknown error')}`, + }, + { status: 502 } + ) + } +}) diff --git a/apps/sim/app/api/billing/member-credits/route.ts b/apps/sim/app/api/billing/member-credits/route.ts new file mode 100644 index 00000000000..08d2a5c7780 --- /dev/null +++ b/apps/sim/app/api/billing/member-credits/route.ts @@ -0,0 +1,38 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getMyMemberCreditsContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { checkOrgMemberUsageLimit } from '@/lib/billing/calculations/usage-monitor' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** + * GET /api/billing/member-credits?workspaceId=... + * + * Returns the caller's OWN per-member usage and cap inside the workspace's + * organization, in DOLLARS (the DB unit) so the client's `formatCredits` does the + * single dollars→credits conversion. Own-data only, so no admin gate (unlike the + * org/member admin route). Reuses {@link checkOrgMemberUsageLimit}, which yields a + * null limit — and the chip falls back to the plan-level view — whenever no + * per-member cap applies: non-hosted, the workspace isn't org-owned, or no cap is + * set for this member. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getMyMemberCreditsContract, request, {}) + if (!parsed.success) return parsed.response + + const { workspaceId } = parsed.data.query + const { currentUsage, limit } = await checkOrgMemberUsageLimit(session.user.id, workspaceId) + + return NextResponse.json({ + success: true, + data: { + usedDollars: currentUsage, + limitDollars: limit, + }, + }) +}) diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts new file mode 100644 index 00000000000..42769756897 --- /dev/null +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckInternalApiKey, + mockRecordUsage, + mockRecordCumulativeUsage, + mockCheckAndBillOverageThreshold, +} = vi.hoisted(() => ({ + mockCheckInternalApiKey: vi.fn(), + mockRecordUsage: vi.fn(), + mockRecordCumulativeUsage: vi.fn(), + mockCheckAndBillOverageThreshold: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/http', () => ({ + checkInternalApiKey: mockCheckInternalApiKey, +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withIncomingGoSpan: ( + _headers: unknown, + _span: unknown, + _attrs: unknown, + fn: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }), +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + recordUsage: mockRecordUsage, + recordCumulativeUsage: mockRecordCumulativeUsage, +})) + +vi.mock('@/lib/billing/threshold-billing', () => ({ + checkAndBillOverageThreshold: mockCheckAndBillOverageThreshold, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isBillingEnabled: true, +})) + +import { POST } from '@/app/api/billing/update-cost/route' + +describe('POST /api/billing/update-cost — workspaceId attribution', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckInternalApiKey.mockReturnValue({ success: true }) + mockRecordUsage.mockResolvedValue(undefined) + mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 }) + mockCheckAndBillOverageThreshold.mockResolvedValue(undefined) + }) + + it('stamps workspaceId onto recorded usage when provided (no idempotency key)', async () => { + const res = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', cost: 0.5, model: 'gpt', source: 'mcp_copilot', workspaceId: 'ws-1' }, + { 'x-api-key': 'internal' } + ) + ) + expect(res.status).toBe(200) + expect(mockRecordUsage).toHaveBeenCalledTimes(1) + expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({ + userId: 'user-1', + workspaceId: 'ws-1', + }) + }) + + it('records cumulative cost via monotonic top-up when an idempotency key is present', async () => { + const res = await POST( + createMockRequest( + 'POST', + { + userId: 'user-1', + cost: 0.4662453, + model: 'claude-opus-4.8', + source: 'workspace-chat', + workspaceId: 'ws-1', + idempotencyKey: 'msg-1-billing', + inputTokens: 461371, + outputTokens: 1686, + }, + { 'x-api-key': 'internal' } + ) + ) + expect(res.status).toBe(200) + expect(mockRecordUsage).not.toHaveBeenCalled() + expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(1) + expect(mockRecordCumulativeUsage.mock.calls[0][0]).toMatchObject({ + userId: 'user-1', + workspaceId: 'ws-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.4662453, + eventKey: 'update-cost:msg-1-billing', + }) + expect(mockCheckAndBillOverageThreshold).toHaveBeenCalledWith('user-1') + }) + + it('returns 409 and skips overage when the cumulative is not higher (duplicate flush)', async () => { + mockRecordCumulativeUsage.mockResolvedValue({ billed: false, delta: 0, total: 0.4662453 }) + const res = await POST( + createMockRequest( + 'POST', + { + userId: 'user-1', + cost: 0.4662453, + model: 'claude-opus-4.8', + source: 'workspace-chat', + workspaceId: 'ws-1', + idempotencyKey: 'msg-1-billing', + }, + { 'x-api-key': 'internal' } + ) + ) + expect(res.status).toBe(409) + expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled() + }) + + it('rejects with 400 when workspaceId is omitted (contract-required, fail loud)', async () => { + const res = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', cost: 0.5, model: 'gpt', source: 'copilot' }, + { 'x-api-key': 'internal' } + ) + ) + expect(res.status).toBe(400) + expect(mockRecordUsage).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 8492434ebd7..42b615b4d25 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -4,7 +4,7 @@ import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { billingUpdateCostContract } from '@/lib/api/contracts/subscription' import { parseRequest } from '@/lib/api/server' -import { recordUsage } from '@/lib/billing/core/usage-log' +import { recordCumulativeUsage, recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -12,7 +12,6 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isBillingEnabled } from '@/lib/core/config/feature-flags' -import { type AtomicClaimResult, billingIdempotency } from '@/lib/core/idempotency/service' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -42,8 +41,6 @@ export const POST = withRouteHandler((req: NextRequest) => async function updateCostInner(req: NextRequest, span: Span): Promise { const requestId = generateRequestId() const startTime = Date.now() - let claim: AtomicClaimResult | null = null - let usageCommitted = false try { logger.info(`[${requestId}] Update cost request started`) @@ -110,7 +107,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise { - logger.warn(`[${requestId}] Failed to release idempotency claim`, { - error: toError(releaseErr).message, - normalizedKey: claim?.normalizedKey, - }) - }) - } else if (claim?.claimed && usageCommitted) { - logger.warn( - `[${requestId}] Error occurred after usage committed; retaining idempotency claim to prevent double-billing`, - { normalizedKey: claim.normalizedKey } - ) - } - + // The cumulative top-up runs in a single transaction (and a plain append is + // a single insert), so a failure here leaves nothing partially committed — + // a retry re-evaluates the max idempotently. No claim to release. span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.InternalError) span.setAttribute(TraceAttr.HttpStatusCode, 500) span.setAttribute(TraceAttr.BillingDurationMs, duration) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts new file mode 100644 index 00000000000..0e410018d60 --- /dev/null +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockFlags, + mockDbLimit, + mockCheckInternalApiKey, + mockCheckServerSideUsageLimits, + mockCheckOrgMemberUsageLimit, +} = vi.hoisted(() => ({ + mockFlags: { isHosted: true }, + mockDbLimit: vi.fn(), + mockCheckInternalApiKey: vi.fn(), + mockCheckServerSideUsageLimits: vi.fn(), + mockCheckOrgMemberUsageLimit: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ from: () => ({ where: () => ({ limit: mockDbLimit }) }) }), + }, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkServerSideUsageLimits: mockCheckServerSideUsageLimits, + checkOrgMemberUsageLimit: mockCheckOrgMemberUsageLimit, +})) + +vi.mock('@/lib/copilot/request/http', () => ({ + checkInternalApiKey: mockCheckInternalApiKey, +})) + +vi.mock('@/lib/copilot/request/otel', () => ({ + withIncomingGoSpan: ( + _headers: unknown, + _span: unknown, + _attrs: unknown, + fn: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown + ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }), +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isHosted() { + return mockFlags.isHosted + }, +})) + +import { POST } from '@/app/api/copilot/api-keys/validate/route' + +function request(body: Record) { + return createMockRequest('POST', body, { 'x-api-key': 'internal' }) +} + +describe('POST /api/copilot/api-keys/validate — per-member enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlags.isHosted = true + mockCheckInternalApiKey.mockReturnValue({ success: true }) + mockDbLimit.mockResolvedValue([{ id: 'user-1' }]) + mockCheckServerSideUsageLimits.mockResolvedValue({ + isExceeded: false, + currentUsage: 0, + limit: 100, + }) + mockCheckOrgMemberUsageLimit.mockResolvedValue({ + isExceeded: false, + currentUsage: 0, + limit: null, + }) + }) + + it('returns 402 when the pooled/personal limit is exceeded (existing behavior)', async () => { + mockCheckServerSideUsageLimits.mockResolvedValue({ + isExceeded: true, + currentUsage: 200, + limit: 100, + }) + const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' })) + expect(res.status).toBe(402) + expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled() + }) + + it('returns 402 when the per-member org-workspace cap is exceeded', async () => { + mockCheckOrgMemberUsageLimit.mockResolvedValue({ + isExceeded: true, + currentUsage: 5, + limit: 4, + }) + const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' })) + expect(res.status).toBe(402) + expect(mockCheckOrgMemberUsageLimit).toHaveBeenCalledWith('user-1', 'ws-1') + }) + + it('returns 200 when under both limits', async () => { + const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' })) + expect(res.status).toBe(200) + }) + + it('rejects with 400 when workspaceId is omitted (contract-required, fail closed)', async () => { + const res = await POST(request({ userId: 'user-1' })) + expect(res.status).toBe(400) + expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled() + }) + + it('skips the per-member check when not hosted', async () => { + mockFlags.isHosted = false + const res = await POST(request({ userId: 'user-1', workspaceId: 'ws-1' })) + expect(res.status).toBe(200) + expect(mockCheckOrgMemberUsageLimit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 8e280d40000..c011f663ed2 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -5,12 +5,16 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' -import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + checkOrgMemberUsageLimit, + checkServerSideUsageLimits, +} from '@/lib/billing/calculations/usage-monitor' import { CopilotValidateOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { isHosted } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') @@ -71,7 +75,7 @@ export const POST = withRouteHandler((req: NextRequest) => ) if (!parsed.success) return parsed.response - const { userId } = parsed.data.body + const { userId, workspaceId } = parsed.data.body span.setAttribute(TraceAttr.UserId, userId) const [existingUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1) @@ -104,6 +108,28 @@ export const POST = withRouteHandler((req: NextRequest) => return new NextResponse(null, { status: 402 }) } + // Per-member org-workspace cap (hosted-only). Blocks the mothership/copilot + // chat request itself when the user is over their personal credit limit for + // the org that owns this workspace, independent of the pooled org limit. + // workspaceId is contract-required, so the gate can't be silently skipped. + if (isHosted) { + const memberCheck = await checkOrgMemberUsageLimit(userId, workspaceId) + if (memberCheck.isExceeded) { + logger.info('[API VALIDATION] Per-member org usage limit exceeded', { + userId, + workspaceId, + currentUsage: memberCheck.currentUsage, + limit: memberCheck.limit, + }) + span.setAttribute( + TraceAttr.CopilotValidateOutcome, + CopilotValidateOutcome.UsageExceeded + ) + span.setAttribute(TraceAttr.HttpStatusCode, 402) + return new NextResponse(null, { status: 402 }) + } + } + span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.Ok) span.setAttribute(TraceAttr.HttpStatusCode, 200) return new NextResponse(null, { status: 200 }) diff --git a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts deleted file mode 100644 index 024ec0ace88..00000000000 --- a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - addCopilotAutoAllowedToolContract, - removeCopilotAutoAllowedToolContract, -} from '@/lib/api/contracts/copilot' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CopilotAutoAllowedToolsAPI') - -/** Headers for server-to-server calls to the copilot backend. */ -function copilotHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - } - if (env.COPILOT_API_KEY) { - headers['x-api-key'] = env.COPILOT_API_KEY - } - return headers -} - -/** - * GET - Fetch user's auto-allowed integration tools - */ -export const GET = withRouteHandler(async () => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - - const res = await fetchGo( - `${mothershipBaseURL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}`, - { - method: 'GET', - headers: copilotHeaders(), - spanName: 'sim → go /api/tool-preferences/auto-allowed', - operation: 'list_auto_allowed_tools', - attributes: { [TraceAttr.UserId]: userId }, - } - ) - - if (!res.ok) { - logger.warn('Copilot returned error for list auto-allowed', { status: res.status }) - return NextResponse.json({ autoAllowedTools: [] }) - } - - const payload = await res.json() - return NextResponse.json({ autoAllowedTools: payload?.autoAllowedTools || [] }) - } catch (error) { - logger.error('Failed to fetch auto-allowed tools', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) - -/** - * POST - Add a tool to the auto-allowed list - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const parsed = await parseRequest( - addCopilotAutoAllowedToolContract, - request, - {}, - { - validationErrorResponse: () => - NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }), - invalidJsonResponse: () => - NextResponse.json({ error: 'toolId must be a string' }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - const { toolId } = parsed.data.body - - const res = await fetchGo(`${mothershipBaseURL}/api/tool-preferences/auto-allowed`, { - method: 'POST', - headers: copilotHeaders(), - body: JSON.stringify({ userId, toolId }), - spanName: 'sim → go /api/tool-preferences/auto-allowed', - operation: 'add_auto_allowed_tool', - attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ToolId]: toolId }, - }) - - if (!res.ok) { - logger.warn('Copilot returned error for add auto-allowed', { status: res.status }) - return NextResponse.json({ error: 'Failed to add tool' }, { status: 500 }) - } - - const payload = await res.json() - return NextResponse.json({ - success: true, - autoAllowedTools: payload?.autoAllowedTools || [], - }) - } catch (error) { - logger.error('Failed to add auto-allowed tool', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) - -/** - * DELETE - Remove a tool from the auto-allowed list - */ -export const DELETE = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const parsed = await parseRequest( - removeCopilotAutoAllowedToolContract, - request, - {}, - { - validationErrorResponse: () => - NextResponse.json({ error: 'toolId query parameter is required' }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - const { toolId } = parsed.data.query - - const res = await fetchGo( - `${mothershipBaseURL}/api/tool-preferences/auto-allowed?userId=${encodeURIComponent(userId)}&toolId=${encodeURIComponent(toolId)}`, - { - method: 'DELETE', - headers: copilotHeaders(), - spanName: 'sim → go /api/tool-preferences/auto-allowed', - operation: 'remove_auto_allowed_tool', - attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ToolId]: toolId }, - } - ) - - if (!res.ok) { - logger.warn('Copilot returned error for remove auto-allowed', { status: res.status }) - return NextResponse.json({ error: 'Failed to remove tool' }, { status: 500 }) - } - - const payload = await res.json() - return NextResponse.json({ - success: true, - autoAllowedTools: payload?.autoAllowedTools || [], - }) - } catch (error) { - logger.error('Failed to remove auto-allowed tool', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/copilot/byok/route.ts b/apps/sim/app/api/copilot/byok/route.ts new file mode 100644 index 00000000000..35355fca914 --- /dev/null +++ b/apps/sim/app/api/copilot/byok/route.ts @@ -0,0 +1,121 @@ +import { db } from '@sim/db' +import { settings, user } from '@sim/db/schema' +import { getErrorMessage } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { + deleteCopilotByokKeyContract, + listCopilotByokKeysContract, + upsertCopilotByokKeyContract, +} from '@/lib/api/contracts/copilot' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** + * Enterprise BYOK key management for the current workspace's mothership. + * + * Unlike the cross-environment admin inspector (`/api/admin/mothership`), this + * talks to the SAME copilot the workspace's mothership actually runs on — + * `SIM_AGENT_API_URL` (local in dev, prod copilot in prod) — and authenticates + * with the hosted internal key (`COPILOT_API_KEY`), the exact credential + * mothership chat uses. Copilot requires that key (`SIM_AGENT_API_KEY`) and + * rejects self-hosted callers, so BYOK can only ever be written through our + * hosted Sim. The route is superuser-gated; the workspace id rides in the + * request and is resolved by the caller from the route. + */ +async function getAuthorizedSuperUserId(): Promise { + const session = await getSession() + if (!session?.user?.id) return null + + const [currentUser] = await db + .select({ role: user.role, superUserModeEnabled: settings.superUserModeEnabled }) + .from(user) + .leftJoin(settings, eq(settings.userId, user.id)) + .where(eq(user.id, session.user.id)) + .limit(1) + + const authorized = currentUser?.role === 'admin' && (currentUser.superUserModeEnabled ?? false) + return authorized ? session.user.id : null +} + +async function forwardToCopilot( + method: 'GET' | 'POST' | 'DELETE', + query: URLSearchParams, + body?: string +) { + const headers: Record = { ...getMothershipSourceEnvHeaders() } + if (env.COPILOT_API_KEY) headers['x-api-key'] = env.COPILOT_API_KEY + if (body !== undefined) headers['Content-Type'] = 'application/json' + + const qs = query.toString() + const targetUrl = `${SIM_AGENT_API_URL}/api/admin/byok${qs ? `?${qs}` : ''}` + + try { + const upstream = await fetch(targetUrl, { + method, + headers, + ...(body !== undefined ? { body } : {}), + }) + const text = await upstream.text() + // boundary-raw-fetch: copilot returns JSON; tolerate an empty body. + const data = text ? JSON.parse(text) : {} + return NextResponse.json(data, { status: upstream.status }) + } catch (error) { + return NextResponse.json( + { error: `Failed to reach copilot: ${getErrorMessage(error, 'Unknown error')}` }, + { status: 502 } + ) + } +} + +export const GET = withRouteHandler(async (req: NextRequest) => { + const userId = await getAuthorizedSuperUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(listCopilotByokKeysContract, req, {}) + if (!parsed.success) return parsed.response + + return forwardToCopilot( + 'GET', + new URLSearchParams({ workspaceId: parsed.data.query.workspaceId }) + ) +}) + +export const POST = withRouteHandler(async (req: NextRequest) => { + const userId = await getAuthorizedSuperUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(upsertCopilotByokKeyContract, req, {}) + if (!parsed.success) return parsed.response + + // Bind the audit field to the authenticated superuser, ignoring any + // client-supplied createdBy so provisioning is always attributable. + const body = JSON.stringify({ ...parsed.data.body, createdBy: userId }) + return forwardToCopilot('POST', new URLSearchParams(), body) +}) + +export const DELETE = withRouteHandler(async (req: NextRequest) => { + const userId = await getAuthorizedSuperUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(deleteCopilotByokKeyContract, req, {}) + if (!parsed.success) return parsed.response + + return forwardToCopilot( + 'DELETE', + new URLSearchParams({ + workspaceId: parsed.data.query.workspaceId, + provider: parsed.data.query.provider, + }) + ) +}) diff --git a/apps/sim/app/api/copilot/byok/validate/route.ts b/apps/sim/app/api/copilot/byok/validate/route.ts new file mode 100644 index 00000000000..0a3a949f25e --- /dev/null +++ b/apps/sim/app/api/copilot/byok/validate/route.ts @@ -0,0 +1,68 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { validateCopilotByokContract } from '@/lib/api/contracts/copilot' +import { parseRequest } from '@/lib/api/server' +import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { checkInternalApiKey } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CopilotByokValidate') + +/** + * Authoritative entitlement gate for enterprise BYOK, called server-to-server by + * the mothership (Go) before it uses a workspace's own provider key. Gated by + * INTERNAL_API_SECRET — never exposed to the browser. + * + * Returns 200 when EITHER: + * - the requesting user is a superuser admin (platform admin with superuser + * mode on), who may use BYOK on any workspace for management/testing; OR + * - the user is a member of the workspace (prevents one org from causing + * another org's stored key to be used) AND the workspace is on an + * enterprise plan. + * + * Any other case returns 403 (not entitled) or 401 (bad internal auth). The Go + * caller fails closed to hosted keys on anything but a 200. + */ +export const POST = withRouteHandler(async (req: NextRequest) => { + const auth = checkInternalApiKey(req) + if (!auth.success) { + return new NextResponse(null, { status: 401 }) + } + + const parsed = await parseRequest(validateCopilotByokContract, req, {}) + if (!parsed.success) return parsed.response + + const { workspaceId, userId } = parsed.data.body + + try { + // Superuser admins may use BYOK on any workspace (management/testing). + const { effectiveSuperUser } = await verifyEffectiveSuperUser(userId) + if (effectiveSuperUser) { + return new NextResponse(null, { status: 200 }) + } + + // Everyone else must be a workspace member on an enterprise plan. The + // membership check prevents one org from using another org's stored key. + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) { + logger.warn('BYOK validate denied: user is not a member of the workspace', { + workspaceId, + userId, + }) + return new NextResponse(null, { status: 403 }) + } + + const eligible = await isWorkspaceOnEnterprisePlan(workspaceId) + if (!eligible) { + logger.warn('BYOK validate denied: workspace is not on an enterprise plan', { workspaceId }) + return new NextResponse(null, { status: 403 }) + } + + return new NextResponse(null, { status: 200 }) + } catch (error) { + logger.error('BYOK validation failed', { error, workspaceId }) + return NextResponse.json({ error: 'Failed to validate BYOK entitlement' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 25416137472..c18e62548e6 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -58,25 +58,19 @@ export const POST = withRouteHandler((request: NextRequest) => [TraceAttr.UserId]: authenticatedUserId, }) - if (!chatId) { - const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { - logger.warn('getLatestRunForStream failed while resolving chatId for abort', { - streamId, - error: getErrorMessage(err), - }) - return null + const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { + logger.warn('getLatestRunForStream failed while resolving abort context', { + streamId, + error: getErrorMessage(err), }) - if (run?.chatId) { - chatId = run.chatId - } + return null + }) + if (!chatId && run?.chatId) { + chatId = run.chatId } + const workspaceId = run?.workspaceId ?? undefined if (chatId) rootSpan.setAttribute(TraceAttr.ChatId, chatId) - // Local abort before Go — lets the lifecycle classifier see - // `signal.aborted` with an explicit-stop reason before Go's - // context-canceled error propagates back. Go's endpoint runs - // second for billing-ledger flush; Go's context is already - // cancelled by then. const aborted = await abortActiveStream(streamId) rootSpan.setAttribute(TraceAttr.CopilotAbortLocalAborted, aborted) @@ -101,6 +95,7 @@ export const POST = withRouteHandler((request: NextRequest) => messageId: streamId, userId: authenticatedUserId, ...(chatId ? { chatId } : {}), + ...(workspaceId ? { workspaceId } : {}), }), spanName: 'sim → go /api/streams/explicit-abort', operation: 'explicit_abort', diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts deleted file mode 100644 index a3b2f9b0a1a..00000000000 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Tests for copilot stats API route - * - * @vitest-environment node - */ -import { copilotHttpMock, copilotHttpMockFns, createEnvMock, createMockRequest } from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({ - mockFetch: vi.fn(), - mockGetMothershipBaseURL: vi.fn(), -})) - -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) - -vi.mock('@/lib/copilot/constants', () => ({ - SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', - SIM_AGENT_API_URL: 'https://agent.sim.example.com', - COPILOT_MODES: ['ask', 'build', 'plan'] as const, - COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const, -})) - -vi.mock('@/lib/copilot/server/agent-url', () => ({ - getMothershipBaseURL: mockGetMothershipBaseURL, -})) - -vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) - -import { POST } from '@/app/api/copilot/stats/route' - -// `fetchGo` reads `response.status` and `response.headers.get('content-length')` -// to stamp span attributes, so mock responses need both fields or the call -// path throws before the route handler sees the body. -function buildMockResponse(init: { - ok: boolean - status?: number - json: () => Promise -}): Record { - return { - ok: init.ok, - status: init.status ?? (init.ok ? 200 : 500), - headers: new Headers(), - json: init.json, - } -} - -describe('Copilot Stats API Route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com') - global.fetch = mockFetch - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe('POST', () => { - it('should return 401 when user is not authenticated', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: null, - isAuthenticated: false, - }) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffCreated: true, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ error: 'Unauthorized' }) - }) - - it('should successfully forward stats to Sim Agent', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - mockFetch.mockResolvedValueOnce( - buildMockResponse({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - ) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffCreated: true, - diffAccepted: true, - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ success: true }) - - expect(mockFetch).toHaveBeenCalledWith( - 'https://agent.sim.example.com/api/stats', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - 'x-api-key': 'test-api-key', - }), - body: JSON.stringify({ - messageId: 'message-123', - diffCreated: true, - diffAccepted: true, - }), - }) - ) - }) - - it('should return 400 for invalid request body - missing messageId', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - const req = createMockRequest('POST', { - diffCreated: true, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Invalid request body for copilot stats') - }) - - it('should return 400 for invalid request body - missing diffCreated', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Invalid request body for copilot stats') - }) - - it('should return 400 for invalid request body - missing diffAccepted', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffCreated: true, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Invalid request body for copilot stats') - }) - - it('should return 400 when upstream Sim Agent returns error', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - mockFetch.mockResolvedValueOnce( - buildMockResponse({ - ok: false, - json: () => Promise.resolve({ error: 'Invalid message ID' }), - }) - ) - - const req = createMockRequest('POST', { - messageId: 'invalid-message', - diffCreated: true, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData).toEqual({ success: false, error: 'Invalid message ID' }) - }) - - it('should handle upstream error with message field', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - mockFetch.mockResolvedValueOnce( - buildMockResponse({ - ok: false, - json: () => Promise.resolve({ message: 'Rate limit exceeded' }), - }) - ) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffCreated: true, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData).toEqual({ success: false, error: 'Rate limit exceeded' }) - }) - - it('should handle upstream error with no JSON response', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - mockFetch.mockResolvedValueOnce( - buildMockResponse({ - ok: false, - json: () => Promise.reject(new Error('Not JSON')), - }) - ) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffCreated: true, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData).toEqual({ success: false, error: 'Upstream error' }) - }) - - it('should handle network errors gracefully', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - mockFetch.mockRejectedValueOnce(new Error('Network error')) - - const req = createMockRequest('POST', { - messageId: 'message-123', - diffCreated: true, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to forward copilot stats') - }) - - it('should handle JSON parsing errors in request body', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/stats', { - method: 'POST', - body: '{invalid-json', - headers: { - 'Content-Type': 'application/json', - }, - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Invalid request body for copilot stats') - }) - - it('should forward stats with diffCreated=false and diffAccepted=false', async () => { - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ - userId: 'user-123', - isAuthenticated: true, - }) - - mockFetch.mockResolvedValueOnce( - buildMockResponse({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - ) - - const req = createMockRequest('POST', { - messageId: 'message-456', - diffCreated: false, - diffAccepted: false, - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ - messageId: 'message-456', - diffCreated: false, - diffAccepted: false, - }), - }) - ) - }) - }) -}) diff --git a/apps/sim/app/api/copilot/stats/route.ts b/apps/sim/app/api/copilot/stats/route.ts deleted file mode 100644 index f19b9e8df47..00000000000 --- a/apps/sim/app/api/copilot/stats/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { copilotStatsContract } from '@/lib/api/contracts/copilot' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { - authenticateCopilotRequestSessionOnly, - createInternalServerErrorResponse, - createRequestTracker, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -export const POST = withRouteHandler(async (req: NextRequest) => { - const tracker = createRequestTracker() - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - - const parsed = await parseRequest( - copilotStatsContract, - req, - {}, - { - validationErrorResponse: (error) => - validationErrorResponse(error, 'Invalid request body for copilot stats'), - invalidJsonResponse: () => - NextResponse.json( - { error: 'Invalid request body for copilot stats', details: [] }, - { status: 400 } - ), - } - ) - if (!parsed.success) return parsed.response - - const { messageId, diffCreated, diffAccepted } = parsed.data.body - - const payload: Record = { - messageId, - diffCreated, - diffAccepted, - } - - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const agentRes = await fetchGo(`${mothershipBaseURL}/api/stats`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify(payload), - spanName: 'sim → go /api/stats', - operation: 'stats_ingest', - }) - - let agentJson: any = null - try { - agentJson = await agentRes.json() - } catch {} - - if (!agentRes.ok) { - const message = (agentJson && (agentJson.error || agentJson.message)) || 'Upstream error' - return NextResponse.json({ success: false, error: message }, { status: 400 }) - } - - return NextResponse.json({ success: true }) - } catch (error) { - return createInternalServerErrorResponse('Failed to forward copilot stats') - } -}) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index a0fa8457f03..03de2e41c4b 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -5,6 +5,12 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { fileServeParamsSchema, fileServeQuerySchema } from '@/lib/api/contracts/storage-transfer' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { + DocCompileUserError, + getE2BDocFormat, + loadCompiledDocByExt, +} from '@/lib/copilot/tools/server/files/doc-compile' +import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' @@ -72,14 +78,55 @@ async function compileDocumentIfNeeded( if (raw) return { buffer, contentType: getContentType(filename) } const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase() + const extNoDot = ext.replace(/^\./, '') const format = COMPILABLE_FORMATS[ext] - if (!format) return { buffer, contentType: getContentType(filename) } - const magicLen = format.magic.length - if (buffer.length >= magicLen && buffer.subarray(0, magicLen).equals(format.magic)) { + // Already a binary file (uploaded or pre-compiled)? Serve as-is. + if (format) { + const magicLen = format.magic.length + if (buffer.length >= magicLen && buffer.subarray(0, magicLen).equals(format.magic)) { + return { buffer, contentType: getContentType(filename) } + } + } + + // .xlsx is a ZIP container with no JS compile path. An uploaded/binary xlsx + // must short-circuit here (it isn't in COMPILABLE_FORMATS) — otherwise every + // xlsx open would utf-8-decode the whole binary and do an always-miss S3 GET. + // Only a Python-source xlsx (UTF-8 text, no ZIP magic) falls through. + if ( + extNoDot === 'xlsx' && + buffer.length >= ZIP_MAGIC.length && + buffer.subarray(0, ZIP_MAGIC.length).equals(ZIP_MAGIC) + ) { return { buffer, contentType: getContentType(filename) } } + // Generated docs render from a content-addressed compiled binary that is built + // exactly ONCE per edit_content/create (at write time) and stored in S3. Serve + // only LOADS it — it must never compile, or it would re-run E2B on every preview + // fetch, including against the incomplete source mid-generation. A hit returns + // the (possibly partial) committed doc; a miss in the E2B regime means the doc + // is still being generated → 409, and the client polls until the artifact lands. + if (workspaceId && (format || extNoDot === 'xlsx')) { + const source = buffer.toString('utf-8') + // Load the prebuilt artifact directly from S3 (content-addressed). No extra + // in-memory layer here: the store is the source of truth, the client (react + // query) already caches the bytes, and this branch never recomputes. + const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot) + if (stored) { + return { buffer: stored.buffer, contentType: stored.contentType } + } + + if (isE2BDocEnabled && getE2BDocFormat(filename)) { + // Artifact not built yet (still generating, or the source didn't compile at + // write time). Signal "not ready" without compiling — handled as 409. + throw new DocCompileUserError('Document is still being generated') + } + } + + if (!format) return { buffer, contentType: getContentType(filename) } + + // E2B disabled and no stored artifact → compile JS source via isolated-vm. const code = buffer.toString('utf-8') const cacheKey = sha256Hex(`${ext}${code}${workspaceId ?? ''}`) const cached = compiledDocCache.get(cacheKey) @@ -106,6 +153,24 @@ function getWorkspaceIdForCompile(key: string): string | undefined { return parseWorkspaceFileKey(key) ?? undefined } +const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable' +const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' + +/** + * Cache-Control for a served file. A versioned request (`?v=`) addresses + * content-immutable bytes — generated docs are content-addressed and the version + * bumps on every edit — so the browser may cache it indefinitely; re-opens and + * focus refetches then resolve from cache with no round trip. Unversioned workspace + * reads stay revalidated because the same storage key is edited in place. + */ +function resolveServeCacheControl( + versioned: boolean, + context: string | undefined +): string | undefined { + if (versioned) return IMMUTABLE_CACHE_CONTROL + return context === 'workspace' ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined +} + export const GET = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { try { @@ -143,8 +208,10 @@ export const GET = withRouteHandler( const query = fileServeQuerySchema.parse({ raw: request.nextUrl.searchParams.get('raw'), + v: request.nextUrl.searchParams.get('v'), }) const raw = query.raw === '1' + const versioned = query.v != null const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -159,11 +226,22 @@ export const GET = withRouteHandler( const userId = authResult.userId if (isUsingCloudStorage()) { - return await handleCloudProxy(cloudKey, userId, raw, request.signal) + return await handleCloudProxy(cloudKey, userId, raw, versioned, request.signal) } - return await handleLocalFile(cloudKey, userId, raw, request.signal) + return await handleLocalFile(cloudKey, userId, raw, versioned, request.signal) } catch (error) { + // An in-progress/incomplete doc source fails to compile — this is expected + // mid-generation, not a server fault. Return 409 (not 500) so it isn't an + // alarming error; the client re-fetches once the doc finishes (the serve + // URL is busted on the file's updatedAt). + if (error instanceof DocCompileUserError) { + logger.info('Serve: document still compiling, returning 409', { + message: error.message, + }) + return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 }) + } + logger.error('Error serving file:', error) if (error instanceof FileNotFoundError) { @@ -179,6 +257,7 @@ async function handleLocalFile( filename: string, userId: string, raw: boolean, + versioned: boolean, signal: AbortSignal | undefined ): Promise { const ownerKey = `user:${userId}` @@ -225,7 +304,7 @@ async function handleLocalFile( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: contextParam === 'workspace' ? 'private, no-cache, must-revalidate' : undefined, + cacheControl: resolveServeCacheControl(versioned, contextParam), }) } catch (error) { logger.error('Error reading local file:', error) @@ -237,6 +316,7 @@ async function handleCloudProxy( cloudKey: string, userId: string, raw = false, + versioned = false, signal: AbortSignal | undefined = undefined ): Promise { const ownerKey = `user:${userId}` @@ -291,7 +371,7 @@ async function handleCloudProxy( buffer: fileBuffer, contentType, filename: displayName, - cacheControl: context === 'workspace' ? 'private, no-cache, must-revalidate' : undefined, + cacheControl: resolveServeCacheControl(versioned, context), }) } catch (error) { logger.error('Error downloading from cloud storage:', error) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 3b191146c48..958361bfb7b 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -12,10 +12,22 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockExecuteInE2B, mockExecuteInIsolatedVM, mockUploadFile } = vi.hoisted(() => ({ +const { + mockExecuteInE2B, + mockExecuteInIsolatedVM, + mockGetWorkspaceFile, + mockUpdateWorkspaceFileContent, + mockUploadFile, + mockValidateWorkspaceFileWriteTarget, + mockWriteWorkspaceFileByPath, +} = vi.hoisted(() => ({ mockExecuteInE2B: vi.fn(), mockExecuteInIsolatedVM: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockUpdateWorkspaceFileContent: vi.fn(), mockUploadFile: vi.fn(), + mockValidateWorkspaceFileWriteTarget: vi.fn(), + mockWriteWorkspaceFileByPath: vi.fn(), })) vi.mock('@/lib/execution/isolated-vm', () => ({ @@ -37,9 +49,40 @@ vi.mock('@/lib/copilot/request/tools/files', () => ({ }, normalizeOutputWorkspaceFileName: vi.fn((p: string) => p.replace(/^files\//, '')), resolveOutputFormat: vi.fn(() => 'json'), + getOutputFileDeclarations: vi.fn((params: Record) => { + if (Array.isArray(params.outputs?.files)) { + return params.outputs.files.map((file: Record) => ({ + path: file.path, + mode: file.mode === 'overwrite' ? 'overwrite' : 'create', + sandboxPath: file.sandboxPath, + mimeType: file.mimeType, + format: file.format, + })) + } + return params.outputPath + ? [ + { + path: params.overwriteFileId || params.outputPath, + mode: params.overwriteFileId ? 'overwrite' : 'create', + sandboxPath: params.outputSandboxPath, + mimeType: params.outputMimeType, + format: params.outputFormat, + formatPath: params.outputPath, + overwriteFileId: params.overwriteFileId, + }, + ] + : [] + }), +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + validateWorkspaceFileWriteTarget: mockValidateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + updateWorkspaceFileContent: mockUpdateWorkspaceFileContent, uploadWorkspaceFile: vi.fn(), })) @@ -79,6 +122,35 @@ describe('Function Execute API Route', () => { stdout: 'e2b output', sandboxId: 'test-sandbox-id', }) + mockGetWorkspaceFile.mockResolvedValue({ + id: 'wf_existing', + name: 'existing.png', + size: 10, + type: 'image/png', + url: '/api/files/view/existing', + key: 'workspace/existing.png', + }) + mockUpdateWorkspaceFileContent.mockResolvedValue({ + id: 'wf_existing', + name: 'existing.png', + size: 20, + type: 'image/png', + url: '/api/files/view/existing', + key: 'workspace/existing.png', + }) + mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ + mode: target.mode, + vfsPath: target.path, + })) + mockWriteWorkspaceFileByPath.mockImplementation(async ({ target, buffer }) => ({ + id: `wf_${String(target.path).split('/').pop()?.replace(/\W+/g, '_') || 'file'}`, + name: String(target.path).split('/').pop() || 'file', + vfsPath: target.path, + downloadUrl: `/api/files/view/${encodeURIComponent(target.path)}`, + mode: target.mode, + size: buffer.length, + contentType: target.mimeType || 'application/octet-stream', + })) }) describe('Security Tests', () => { @@ -268,6 +340,196 @@ describe('Function Execute API Route', () => { expect(isLargeValueRef(data.output.result.text)).toBe(true) }) + it('exports multiple declared sandbox output files', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { + '/home/user/chart.png': 'iVBORw0KGgo=', + '/home/user/summary.json': '{"ok":true}', + }, + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/reports/chart.png', + mode: 'create', + sandboxPath: '/home/user/chart.png', + mimeType: 'image/png', + }, + { + path: 'files/reports/summary.json', + mode: 'overwrite', + sandboxPath: '/home/user/summary.json', + mimeType: 'application/json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockExecuteInE2B).toHaveBeenCalledWith( + expect.objectContaining({ + outputSandboxPaths: ['/home/user/chart.png', '/home/user/summary.json'], + }) + ) + expect(mockValidateWorkspaceFileWriteTarget).toHaveBeenCalledTimes(2) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2) + expect(mockWriteWorkspaceFileByPath).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + target: expect.objectContaining({ path: 'files/reports/chart.png', mode: 'create' }), + }) + ) + expect(mockWriteWorkspaceFileByPath).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + target: expect.objectContaining({ + path: 'files/reports/summary.json', + mode: 'overwrite', + }), + }) + ) + expect(data.output.result.files).toHaveLength(2) + expect(data.resources).toEqual([ + expect.objectContaining({ path: 'files/reports/chart.png' }), + expect.objectContaining({ path: 'files/reports/summary.json' }), + ]) + }) + + it('prevalidates all sandbox output destinations before writing any files', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { + '/home/user/first.json': '{"first":true}', + '/home/user/second.json': '{"second":true}', + }, + }) + mockValidateWorkspaceFileWriteTarget + .mockResolvedValueOnce({ mode: 'create', vfsPath: 'files/first.json' }) + .mockRejectedValueOnce(new Error('Directory not yet created: files/missing')) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/first.json', + mode: 'create', + sandboxPath: '/home/user/first.json', + }, + { + path: 'files/missing/second.json', + mode: 'create', + sandboxPath: '/home/user/second.json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toContain('Directory not yet created') + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('rejects duplicate sandbox output destinations before writing files', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { + '/home/user/first.json': '{"first":true}', + '/home/user/second.json': '{"second":true}', + }, + }) + mockValidateWorkspaceFileWriteTarget.mockResolvedValue({ + mode: 'create', + vfsPath: 'files/dupe.json', + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/dupe.json', + mode: 'create', + sandboxPath: '/home/user/first.json', + }, + { + path: 'files/dupe.json', + mode: 'create', + sandboxPath: '/home/user/second.json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.success).toBe(false) + expect(data.error).toContain('Duplicate sandbox output destination') + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('returns a targeted error when a declared sandbox output is missing', async () => { + featureFlagsMock.isE2bEnabled = true + mockExecuteInE2B.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: {}, + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/missing.json', + mode: 'create', + sandboxPath: '/home/user/missing.json', + }, + ], + }, + }) + + const response = await POST(req) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.success).toBe(false) + expect(data.error).toContain('Sandbox file "/home/user/missing.json" was not found') + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + it('should return computed result for multi-line code', async () => { mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' }) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 92dbaa87ef2..a3035607e52 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -5,9 +5,15 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { FORMAT_TO_CONTENT_TYPE, + getOutputFileDeclarations, normalizeOutputWorkspaceFileName, + type OutputFileDeclaration, resolveOutputFormat, } from '@/lib/copilot/request/tools/files' +import { + validateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath, +} from '@/lib/copilot/vfs/resource-writer' import { isE2bEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -29,7 +35,6 @@ import { import { compactExecutionPayload } from '@/lib/execution/payloads/serializer' import { materializeLargeValueRef } from '@/lib/execution/payloads/store' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' @@ -48,6 +53,8 @@ const TAG_PATTERN = createReferencePattern() const E2B_JS_WRAPPER_LINES = 3 const E2B_PYTHON_WRAPPER_LINES = 1 +const MAX_SANDBOX_OUTPUT_FILES = 20 +const MAX_SANDBOX_OUTPUT_BYTES = 50 * 1024 * 1024 /** Matches valid JS identifier names (letters, digits, underscore; no leading digit). */ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/ @@ -672,6 +679,37 @@ function resolveCodeVariables( * This handles the common case where print() or console.log() adds a trailing \n * that users don't expect to see in the output */ +/** + * Heuristic: did the sandbox die from an infrastructure failure (OOM kill, + * timeout, lost connection) rather than a normal code error? Python/JS code + * exceptions surface via execution.error; an OOM kill instead makes runCode + * throw, often with an empty or cryptic message. + */ +function isLikelySandboxKill(error: any): boolean { + const msg = `${error?.name ?? ''} ${error?.message ?? ''} ${error?.code ?? ''}` + .toLowerCase() + .trim() + if (!msg) return true + return [ + 'out of memory', + 'oom', + 'killed', + 'sigkill', + 'code 137', + 'signal 9', + 'terminated', + 'econnreset', + 'epipe', + 'socket hang up', + 'connection closed', + 'connection reset', + 'websocket', + 'timed out', + 'timeout', + 'deadline', + ].some((s) => msg.includes(s)) +} + function cleanStdout(stdout: string): string { if (stdout.endsWith('\n')) { return stdout.slice(0, -1) @@ -865,6 +903,8 @@ async function maybeExportSandboxFileToWorkspace(args: { outputFormat?: string outputMimeType?: string outputSandboxPath?: string + overwriteFileId?: string + outputMode?: 'create' | 'overwrite' exportedFileContent?: string stdout: string executionTime: number @@ -877,6 +917,8 @@ async function maybeExportSandboxFileToWorkspace(args: { outputFormat, outputMimeType, outputSandboxPath, + overwriteFileId, + outputMode, exportedFileContent, stdout, executionTime, @@ -933,28 +975,282 @@ async function maybeExportSandboxFileToWorkspace(args: { ? Buffer.from(exportedFileContent, 'base64') : Buffer.from(exportedFileContent, 'utf-8') - const uploaded = await uploadWorkspaceFile( - resolvedWorkspaceId, - authUserId, - fileBuffer, - fileName, - resolvedMimeType + const targetPath = overwriteFileId || outputPath + const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') + + try { + const written = await writeWorkspaceFileByPath({ + workspaceId: resolvedWorkspaceId, + userId: authUserId, + target: { + path: targetPath, + mode, + mimeType: outputMimeType, + }, + buffer: fileBuffer, + inferredMimeType: resolvedMimeType, + }) + logger.info('Sandbox file exported to workspace', { + fileId: written.id, + vfsPath: written.vfsPath, + sandboxPath: outputSandboxPath, + mode, + mimeType: resolvedMimeType, + size: fileBuffer.length, + }) + return NextResponse.json({ + success: true, + output: { + result: { + message: `Sandbox file exported to ${written.vfsPath}`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, + sandboxPath: outputSandboxPath, + }, + stdout: cleanStdout(stdout), + executionTime, + }, + resources: [{ type: 'file', id: written.id, title: written.name, path: written.vfsPath }], + }) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to export sandbox file', + output: { result: null, stdout: cleanStdout(stdout), executionTime }, + }, + { status: 400 } + ) + } +} + +async function maybeExportSandboxFilesToWorkspace(args: { + authUserId: string + workflowId?: string + workspaceId?: string + outputFiles: OutputFileDeclaration[] + exportedFiles?: Record + exportedFileContent?: string + stdout: string + executionTime: number +}) { + const sandboxFiles = args.outputFiles.filter((file) => file.sandboxPath) + if (sandboxFiles.length === 0) return null + if (sandboxFiles.length > MAX_SANDBOX_OUTPUT_FILES) { + return NextResponse.json( + { + success: false, + error: `Too many sandbox output files requested (${sandboxFiles.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + + if (sandboxFiles.length === 1) { + const file = sandboxFiles[0] + return maybeExportSandboxFileToWorkspace({ + authUserId: args.authUserId, + workflowId: args.workflowId, + workspaceId: args.workspaceId, + outputPath: file.formatPath ?? file.path, + outputFormat: file.format, + outputMimeType: file.mimeType, + outputSandboxPath: file.sandboxPath, + outputMode: file.mode, + exportedFileContent: + (file.sandboxPath ? args.exportedFiles?.[file.sandboxPath] : undefined) ?? + args.exportedFileContent, + stdout: args.stdout, + executionTime: args.executionTime, + }) + } + + const resolvedWorkspaceId = + args.workspaceId || + (args.workflowId ? (await getWorkflowById(args.workflowId))?.workspaceId : undefined) + if (!resolvedWorkspaceId) { + return NextResponse.json( + { + success: false, + error: 'Workspace context required to save sandbox files to workspace', + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + + const preparedFiles = [] + let totalOutputBytes = 0 + for (const file of sandboxFiles) { + const sandboxPath = file.sandboxPath! + const content = args.exportedFiles?.[sandboxPath] + if (content === undefined) { + return NextResponse.json( + { + success: false, + error: `Sandbox file "${sandboxPath}" was not found or could not be read`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 500 } + ) + } + const fileName = normalizeOutputWorkspaceFileName(file.formatPath ?? file.path) + const resolvedMimeType = + file.mimeType || + FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)] || + 'application/octet-stream' + const isBinary = !new Set(Object.values(FORMAT_TO_CONTENT_TYPE)).has(resolvedMimeType) + const size = Buffer.byteLength(content, isBinary ? 'base64' : 'utf-8') + totalOutputBytes += size + if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) { + return NextResponse.json( + { + success: false, + error: `Sandbox output files exceed ${MAX_SANDBOX_OUTPUT_BYTES} bytes total`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + preparedFiles.push({ + file, + sandboxPath, + content, + resolvedMimeType, + isBinary, + size, + target: { + path: file.path, + mode: file.mode ?? 'create', + mimeType: file.mimeType, + }, + }) + } + + let validationPaths: string[] + try { + const validations = await Promise.all( + preparedFiles.map((prepared) => + validateWorkspaceFileWriteTarget({ + workspaceId: resolvedWorkspaceId, + userId: args.authUserId, + target: prepared.target, + }) + ) + ) + validationPaths = validations.map((validation) => validation.vfsPath) + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Invalid sandbox output destination', + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + const duplicateDestination = validationPaths.find( + (vfsPath, index) => validationPaths.indexOf(vfsPath) !== index ) + if (duplicateDestination) { + return NextResponse.json( + { + success: false, + error: `Duplicate sandbox output destination: ${duplicateDestination}`, + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } + + const writtenFiles = [] + try { + for (const prepared of preparedFiles) { + const buffer = prepared.isBinary + ? Buffer.from(prepared.content, 'base64') + : Buffer.from(prepared.content, 'utf-8') + const written = await writeWorkspaceFileByPath({ + workspaceId: resolvedWorkspaceId, + userId: args.authUserId, + target: prepared.target, + buffer, + inferredMimeType: prepared.resolvedMimeType, + }) + logger.info('Sandbox file exported to workspace', { + fileId: written.id, + vfsPath: written.vfsPath, + sandboxPath: prepared.sandboxPath, + mode: prepared.file.mode ?? 'create', + mimeType: prepared.resolvedMimeType, + size: prepared.size, + }) + writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath }) + } + } catch (error) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to export sandbox files', + output: { + result: null, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + }, + }, + { status: 400 } + ) + } return NextResponse.json({ success: true, output: { result: { - message: `Sandbox file exported to files/${fileName}`, - fileId: uploaded.id, - fileName, - downloadUrl: uploaded.url, - sandboxPath: outputSandboxPath, + message: `Exported ${writtenFiles.length} sandbox files`, + files: writtenFiles.map((file) => ({ + fileId: file.id, + fileName: file.name, + vfsPath: file.vfsPath, + backingVfsPath: file.backingVfsPath, + downloadUrl: file.downloadUrl, + sandboxPath: file.sandboxPath, + })), }, - stdout: cleanStdout(stdout), - executionTime, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, }, - resources: [{ type: 'file', id: uploaded.id, title: fileName }], + resources: writtenFiles.map((file) => ({ + type: 'file', + id: file.id, + title: file.name, + path: file.vfsPath, + })), }) } @@ -990,6 +1286,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { outputFormat, outputMimeType, outputSandboxPath, + overwriteFileId, + outputs, envVars = {}, blockData = {}, blockNameMapping = {}, @@ -1007,6 +1305,26 @@ export const POST = withRouteHandler(async (req: NextRequest) => { _sandboxFiles, } = body sourceCodeForErrors = sourceCode + const outputFiles = getOutputFileDeclarations({ + outputs, + outputPath, + outputFormat, + outputMimeType, + outputSandboxPath, + overwriteFileId, + }) + const outputSandboxPaths = outputFiles + .map((file) => file.sandboxPath) + .filter((path): path is string => Boolean(path)) + if (outputSandboxPaths.length > MAX_SANDBOX_OUTPUT_FILES) { + return NextResponse.json( + { + success: false, + error: `Too many sandbox output files requested (${outputSandboxPaths.length}). Maximum is ${MAX_SANDBOX_OUTPUT_FILES}.`, + }, + { status: 400 } + ) + } const executionParams = { ...params } executionParams._context = undefined @@ -1108,12 +1426,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxId, error: shellError, exportedFileContent, + exportedFiles, } = await executeShellInE2B({ code: resolvedCode, envs: shellEnvs, timeoutMs: timeout, sandboxFiles: _sandboxFiles, outputSandboxPath, + outputSandboxPaths, }) const executionTime = Date.now() - execStart @@ -1136,15 +1456,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (outputSandboxPath) { - const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + if (outputSandboxPaths.length > 0 || outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ authUserId: auth.userId, workflowId, workspaceId, - outputPath, - outputFormat, - outputMimeType, - outputSandboxPath, + outputFiles, + exportedFiles, exportedFileContent, stdout: shellStdout, executionTime, @@ -1237,12 +1555,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxId, error: e2bError, exportedFileContent, + exportedFiles, } = await executeInE2B({ code: codeForE2B, language: CodeLanguage.JavaScript, timeoutMs: timeout, sandboxFiles: _sandboxFiles, outputSandboxPath, + outputSandboxPaths, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -1273,15 +1593,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (outputSandboxPath) { - const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + if (outputSandboxPaths.length > 0 || outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ authUserId: auth.userId, workflowId, workspaceId, - outputPath, - outputFormat, - outputMimeType, - outputSandboxPath, + outputFiles, + exportedFiles, exportedFileContent, stdout, executionTime, @@ -1324,12 +1642,14 @@ export const POST = withRouteHandler(async (req: NextRequest) => { sandboxId, error: e2bError, exportedFileContent, + exportedFiles, } = await executeInE2B({ code: codeForE2B, language: CodeLanguage.Python, timeoutMs: timeout, sandboxFiles: _sandboxFiles, outputSandboxPath, + outputSandboxPaths, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -1360,15 +1680,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } - if (outputSandboxPath) { - const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + if (outputSandboxPaths.length > 0 || outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFilesToWorkspace({ authUserId: auth.userId, workflowId, workspaceId, - outputPath, - outputFormat, - outputMimeType, - outputSandboxPath, + outputFiles, + exportedFiles, exportedFileContent, stdout, executionTime, @@ -1546,6 +1864,24 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } + if (isLikelySandboxKill(error)) { + const underlying = (error?.message || String(error)).slice(0, 300) + logger.warn(`[${requestId}] Sandbox terminated before completion (likely OOM or timeout)`, { + executionTime, + underlying, + }) + const killResponse = { + success: false, + error: + 'The sandbox was terminated before finishing — most likely it ran out of memory or hit the time limit while processing large or combined inputs. Mount and process fewer/smaller files at once (e.g. one file at a time), or stream and aggregate incrementally instead of loading everything into memory. ' + + `(underlying: ${underlying || 'no detail; sandbox died'})`, + output: { result: null, stdout: cleanStdout(stdout), executionTime }, + } + return routeContext + ? functionJsonResponse(killResponse, routeContext, { status: 500 }) + : NextResponse.json(killResponse, { status: 500 }) + } + logger.error(`[${requestId}] Function execution failed`, { error: error.message || 'Unknown error', stack: error.stack, diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index 03bf505a4df..c2754b79db9 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { guardrailsValidateContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateHallucination } from '@/lib/guardrails/validate_hallucination' @@ -175,6 +176,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } throw err } + + // Gate the actor's usage before incurring hosted LLM + RAG cost. In a normal + // workflow run this already passed at preprocessing; this also blocks direct + // calls to this route by an over-limit or frozen actor. + const usage = await checkActorUsageLimits(auth.userId, resolvedWorkspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' }, + { status: 402 } + ) + } } const inputStr = convertInputToString(input) @@ -216,6 +228,33 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestId ) + // Bill the guardrail's LLM scoring cost (hallucination only; BYOK/non-hosted + // already resolve to 0). Attributed to the caller + the workflow's workspace + // so it lands in the per-member meter. Best-effort — never fail validation on + // a billing error. + if ( + resolvedWorkspaceId && + typeof validationResult.cost === 'number' && + validationResult.cost > 0 + ) { + const { recordUsage } = await import('@/lib/billing/core/usage-log') + await recordUsage({ + userId: auth.userId, + workspaceId: resolvedWorkspaceId, + entries: [ + { + category: 'model', + source: 'workflow', + description: `guardrail-hallucination:${model ?? 'unknown'}`, + cost: validationResult.cost, + sourceReference: `guardrail:${workflowId ?? 'unknown'}:${requestId}`, + }, + ], + }).catch((billingError) => { + logger.error(`[${requestId}] Failed to record guardrail usage`, { error: billingError }) + }) + } + logger.info(`[${requestId}] Validation completed`, { passed: validationResult.passed, hasError: !!validationResult.error, @@ -301,6 +340,7 @@ async function executeValidation( reasoning?: string detectedEntities?: any[] maskedText?: string + cost?: number }> { // Use TypeScript validators for all validation types if (validationType === 'json') { diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index dc770f1520b..80af0ba8cc1 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -43,6 +43,10 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {}, })) +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), +})) + vi.mock('@sim/audit', () => auditMock) import { @@ -343,7 +347,8 @@ describe('Knowledge Base Documents API Route', () => { expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith( validDocumentData, 'kb-123', - expect.any(String) + expect.any(String), + 'user-123' ) }) @@ -444,7 +449,8 @@ describe('Knowledge Base Documents API Route', () => { expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith( validBulkData.documents, 'kb-123', - expect.any(String) + expect.any(String), + 'user-123' ) expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled() }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index b159fcf52f1..371fc1512c7 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -12,6 +12,7 @@ import { import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { bulkDocumentOperation, @@ -163,11 +164,27 @@ export const POST = withRouteHandler( const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId + // Gate KB indexing (pooled + per-member) before accepting work. Runs even for + // legacy KBs with no workspace — the uploader's pooled/frozen status is still + // enforced (per-member is simply skipped when there's no org workspace). The + // authoritative backstop also runs in processDocumentAsync for non-HTTP paths + // (connector/cron/retry). + const usage = await checkActorUsageLimits(userId, kbWorkspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { + error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + }, + { status: 402 } + ) + } + if (body.bulk === true) { const createdDocuments = await createDocumentRecords( body.documents, knowledgeBaseId, - requestId + requestId, + userId ) logger.info( @@ -247,7 +264,12 @@ export const POST = withRouteHandler( } const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body - const newDocument = await createSingleDocument(singleDocumentData, knowledgeBaseId, requestId) + const newDocument = await createSingleDocument( + singleDocumentData, + knowledgeBaseId, + requestId, + userId + ) try { const { PlatformEvents } = await import('@/lib/core/telemetry') diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts index 212aebce619..4309eb040d6 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts @@ -27,6 +27,10 @@ vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), +})) + vi.mock('@/lib/knowledge/documents/service', () => ({ createDocumentRecords: vi.fn(), deleteDocument: vi.fn(), diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index b2cb988c37a..40220dd0732 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -10,6 +10,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentRecords, @@ -72,6 +73,21 @@ export const POST = withRouteHandler( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + // Gate usage before any create/delete so an over-limit upsert is rejected up + // front and never deletes the existing (already-indexed) document. Runs even + // for legacy KBs with no workspace — the uploader's pooled/frozen status is + // still enforced (per-member is skipped when there's no org workspace). + const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId + const usage = await checkActorUsageLimits(userId, kbWorkspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { + error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + }, + { status: 402 } + ) + } + let existingDocumentId: string | null = null let isUpdate = false @@ -129,7 +145,8 @@ export const POST = withRouteHandler( }, ], knowledgeBaseId, - requestId + requestId, + userId ) const firstDocument = createdDocuments[0] diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 0b36497d0ad..f7451d46c17 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -158,7 +158,9 @@ describe('Knowledge Search API Route', () => { parallelLimit: 15, singleQueryOptimized: true, }) - mockGenerateSearchEmbedding.mockClear().mockResolvedValue([0.1, 0.2, 0.3, 0.4, 0.5]) + mockGenerateSearchEmbedding + .mockClear() + .mockResolvedValue({ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], isBYOK: false }) mockGetDocumentMetadataByIds.mockClear().mockResolvedValue({ doc1: { filename: 'Document 1', sourceUrl: null }, doc2: { filename: 'Document 2', sourceUrl: null }, @@ -997,7 +999,7 @@ describe('Knowledge Search API Route', () => { singleQueryOptimized: true, }) - mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active': { filename: 'Active Document.pdf', @@ -1145,7 +1147,7 @@ describe('Knowledge Search API Route', () => { singleQueryOptimized: true, }) - mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false }) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null }, }) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 482ee61950b..9dd40280f82 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -4,7 +4,8 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { type NextRequest, NextResponse } from 'next/server' import { knowledgeSearchBodySchema } from '@/lib/api/contracts/knowledge' import { parseJsonBody, validationErrorResponse } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -37,7 +38,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsedBody = await parseJsonBody(request) if (!parsedBody.success) return parsedBody.response const body = parsedBody.data as Record - const { workflowId, ...searchParams } = body + const { workflowId, skipUsageBilling, ...searchParams } = body const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -45,6 +46,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const userId = auth.userId + // Only the internal workflow tool may suppress route metering (it rolls the + // cost into the executor's usage instead). Session/API-key callers cannot set + // skipUsageBilling to dodge their own embedding/reranker charge. + const shouldMeter = !(skipUsageBilling === true && auth.authType === AuthType.INTERNAL_JWT) + if (workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId: workflowId as string, @@ -219,6 +225,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + // Gate the actor before incurring hosted embedding cost, unless this is the + // internal workflow tool (already gated at preprocessing, rolls cost up). Tag-only + // search is free, so only the query path is gated. + if (shouldMeter && hasQuery) { + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' }, + { status: 402 } + ) + } + } + const queryEmbeddingPromise = hasQuery ? generateSearchEmbedding(validatedData.query!, queryEmbeddingModel, workspaceId) : Promise.resolve(null) @@ -273,7 +292,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else if (hasQuery && hasFilters) { logger.debug(`[${requestId}] Executing tag + vector search with filters:`, structuredFilters) const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) - const queryVector = JSON.stringify(await queryEmbeddingPromise) + const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) results = await handleTagAndVectorSearch({ knowledgeBaseIds: accessibleKbIds, @@ -284,7 +303,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } else if (hasQuery && !hasFilters) { const strategy = getQueryStrategy(accessibleKbIds.length, candidateTopK) - const queryVector = JSON.stringify(await queryEmbeddingPromise) + const queryVector = JSON.stringify((await queryEmbeddingPromise)?.embedding ?? null) results = await handleVectorOnlySearch({ knowledgeBaseIds: accessibleKbIds, @@ -359,7 +378,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { validatedData.query!, getEmbeddingModelInfo(queryEmbeddingModel).tokenizerProvider ) - cost = calculateCost(queryEmbeddingModel, tokenCount.count, 0, false) + // BYOK query embeddings incur no Sim cost, so don't bill (or roll up) them. + const queryEmbeddingResult = await queryEmbeddingPromise + if (!queryEmbeddingResult?.isBYOK) { + cost = calculateCost(queryEmbeddingModel, tokenCount.count, 0, false) + } } catch (error) { logger.warn(`[${requestId}] Failed to calculate cost for search query`, { error: getErrorMessage(error, 'Unknown error'), @@ -393,6 +416,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } + // Record query-embedding + reranker cost for standalone callers (UI, copilot, + // guardrail RAG). The workflow tool sets skipUsageBilling and rolls the cost + // up via the executor instead, so this never double-bills; BYOK already + // resolved to 0 above. + if (shouldMeter && workspaceId && cost && cost.total > 0) { + const { recordUsage } = await import('@/lib/billing/core/usage-log') + await recordUsage({ + userId, + workspaceId, + entries: [ + { + category: 'model', + source: 'knowledge-base', + description: queryEmbeddingModel, + cost: cost.total, + sourceReference: `kb-search:${requestId}`, + }, + ], + }).catch((billingError) => { + logger.error(`[${requestId}] Failed to record KB search usage`, { error: billingError }) + }) + } + const tagDefsResults = await Promise.all( accessibleKbIds.map(async (kbId) => { try { diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 526fb12c73b..3a2bcd0898b 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -184,7 +184,7 @@ describe('Knowledge Search Utils', () => { }), }) ) - expect(result).toEqual([0.1, 0.2, 0.3]) + expect(result.embedding).toEqual([0.1, 0.2, 0.3]) // Clean up Object.keys(env).forEach((key) => delete (env as any)[key]) @@ -214,7 +214,7 @@ describe('Knowledge Search Utils', () => { }), }) ) - expect(result).toEqual([0.1, 0.2, 0.3]) + expect(result.embedding).toEqual([0.1, 0.2, 0.3]) // Clean up Object.keys(env).forEach((key) => delete (env as any)[key]) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 0d43f43a59f..9286efd9b8c 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -1,64 +1,14 @@ -import { db } from '@sim/db' -import { - jobExecutionLogs, - pausedExecutions, - permissions, - workflow, - workflowDeploymentVersion, - workflowExecutionLogs, -} from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - and, - asc, - desc, - eq, - gt, - gte, - inArray, - isNotNull, - isNull, - lt, - lte, - ne, - or, - type SQL, - sql, -} from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { listLogsContract, type WorkflowLogSummary } from '@/lib/api/contracts/logs' +import { listLogsContract } from '@/lib/api/contracts/logs' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { jobCostTotal } from '@/lib/logs/fetch-log-detail' -import { buildFilterConditions } from '@/lib/logs/filters' -import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' +import { listLogs } from '@/lib/logs/list-logs' const logger = createLogger('LogsAPI') -type SortBy = 'date' | 'duration' | 'cost' | 'status' -type SortOrder = 'asc' | 'desc' - -interface CursorData { - v: string | number | null - id: string -} - -function encodeCursor(data: CursorData): string { - return Buffer.from(JSON.stringify(data)).toString('base64') -} - -function decodeCursor(cursor: string): CursorData | null { - try { - const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) - if (typeof parsed?.id !== 'string') return null - return parsed as CursorData - } catch { - return null - } -} - export const GET = withRouteHandler(async (request: NextRequest) => { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { @@ -73,402 +23,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const params = parsed.data.query - const sortBy = params.sortBy as SortBy - const sortOrder = params.sortOrder as SortOrder - const cursor = params.cursor ? decodeCursor(params.cursor) : null - - const workflowSortExpr: SQL = (() => { - switch (sortBy) { - case 'duration': - return sql`${workflowExecutionLogs.totalDurationMs}` - case 'cost': - // Indexed projection of the usage_log ledger (dollars); no live aggregation. - return sql`${workflowExecutionLogs.costTotal}` - case 'status': - return sql`${workflowExecutionLogs.status}` - default: - return sql`${workflowExecutionLogs.startedAt}` - } - })() - - const jobSortExpr: SQL = (() => { - switch (sortBy) { - case 'duration': - return sql`${jobExecutionLogs.totalDurationMs}` - case 'cost': - return sql`(${jobExecutionLogs.cost}->>'total')::numeric` - case 'status': - return sql`${jobExecutionLogs.status}` - default: - return sql`${jobExecutionLogs.startedAt}` - } - })() - - const dir = sortOrder === 'asc' ? asc : desc - const nullsLast = sql`NULLS LAST` - const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}` - - const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => { - if (!cursor) return undefined - const v = cursor.v - const id = cursor.id - const cmp = sortOrder === 'asc' ? sql`>` : sql`<` - if (v === null) { - return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` - } - return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` - } - - const fetchSize = params.limit + 1 - - // Build workflow log conditions - const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, params.workspaceId)] - - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const levelConditions: SQL[] = [] - - for (const level of levels) { - if (level === 'error') { - levelConditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (level === 'info') { - const c = and( - eq(workflowExecutionLogs.level, 'info'), - isNotNull(workflowExecutionLogs.endedAt) - ) - if (c) levelConditions.push(c) - } else if (level === 'running') { - const c = and( - eq(workflowExecutionLogs.level, 'info'), - isNull(workflowExecutionLogs.endedAt) - ) - if (c) levelConditions.push(c) - } else if (level === 'pending') { - const c = and( - eq(workflowExecutionLogs.level, 'info'), - or( - sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, - and( - isNotNull(pausedExecutions.status), - sql`${pausedExecutions.status} != 'fully_resumed'` - ) - ) - ) - if (c) levelConditions.push(c) - } - } - - if (levelConditions.length > 0) { - workflowConditions.push( - levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)! - ) - } - } - - if (params.folderIds) { - params.folderIds = await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) - } - - const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: false }) - if (commonFilters) workflowConditions.push(commonFilters) - - const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) - if (workflowCursorCond) workflowConditions.push(workflowCursorCond) - - // Decide whether to include job logs - const hasWorkflowSpecificFilters = !!( - params.workflowIds || - params.folderIds || - params.workflowName || - params.folderName - ) - const triggersList = params.triggers?.split(',').filter(Boolean) || [] - const triggersExcludeJobs = - triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership') - const levelList = - params.level && params.level !== 'all' ? params.level.split(',').filter(Boolean) : [] - const levelExcludesJobs = - levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info') - const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs - - const workflowQuery = db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - costTotal: workflowExecutionLogs.costTotal, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - pausedStatus: pausedExecutions.status, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - sortValue: sql`${workflowSortExpr}`.as('sort_value'), - }) - .from(workflowExecutionLogs) - .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(and(...workflowConditions)) - .orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id)) - .limit(fetchSize) - - const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, params.workspaceId)] - - if (includeJobLogs) { - jobConditions.push( - sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` - ) - - if (params.level && params.level !== 'all') { - const levels = params.level.split(',').filter(Boolean) - const jobLevelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) - } else if (level === 'info') { - const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) - if (c) jobLevelConditions.push(c) - } - } - if (jobLevelConditions.length > 0) { - jobConditions.push( - jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! - ) - } - } - - if (triggersList.length > 0 && !triggersList.includes('all')) { - jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) - } - - if (params.startDate) { - jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(params.startDate))) - } - if (params.endDate) { - jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(params.endDate))) - } - - if (params.search) { - jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${params.search}%`}`) - } - if (params.executionId) { - jobConditions.push(eq(jobExecutionLogs.executionId, params.executionId)) - } - - if (params.costOperator && params.costValue !== undefined) { - const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` - const ops = { - '=': sql`=`, - '>': sql`>`, - '<': sql`<`, - '>=': sql`>=`, - '<=': sql`<=`, - '!=': sql`!=`, - } as const - jobConditions.push(sql`${costField} ${ops[params.costOperator]} ${params.costValue}`) - } - - if (params.durationOperator && params.durationValue !== undefined) { - const durationOps: Record< - string, - (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined - > = { - '=': (f, v) => eq(f, v), - '>': (f, v) => gt(f, v), - '<': (f, v) => lt(f, v), - '>=': (f, v) => gte(f, v), - '<=': (f, v) => lte(f, v), - '!=': (f, v) => ne(f, v), - } - const durationCond = durationOps[params.durationOperator]?.( - jobExecutionLogs.totalDurationMs, - params.durationValue - ) - if (durationCond) jobConditions.push(durationCond) - } - - const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) - if (jobCursorCond) jobConditions.push(jobCursorCond) - } - - const jobQuery = includeJobLogs - ? db - .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, - jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, - sortValue: sql`${jobSortExpr}`.as('sort_value'), - }) - .from(jobExecutionLogs) - .where(and(...jobConditions)) - .orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id)) - .limit(fetchSize) - : Promise.resolve([]) - - const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery]) - - type RowWithSort = { - id: string - sortValue: unknown - summary: WorkflowLogSummary - } - - const workflowMapped: RowWithSort[] = workflowRows.map((log) => { - const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) - const resumedCount = Number(log.pausedResumedCount ?? 0) - const hasPendingPause = - (totalPauseCount > 0 && resumedCount < totalPauseCount) || - (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') - - const summary: WorkflowLogSummary = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - workflow: log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt?.toISOString() ?? null, - updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, - } - : null, - jobTitle: null, - // List cost is the cost_total projection (faithful ledger sum). Null until - // completion (running) or until the one-time legacy backfill populates it. - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, - pauseSummary: { - status: log.pausedStatus ?? null, - total: totalPauseCount, - resumed: resumedCount, - }, - hasPendingPause, - } - return { id: log.id, sortValue: log.sortValue, summary } - }) - - const jobMapped: RowWithSort[] = (jobRows as Awaited).map((log) => { - const summary: WorkflowLogSummary = { - id: log.id, - workflowId: null, - executionId: log.executionId, - deploymentVersionId: null, - deploymentVersion: null, - deploymentVersionName: null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - workflow: null, - jobTitle: log.jobTitle ?? null, - cost: jobCostTotal(log.cost), - pauseSummary: { status: null, total: 0, resumed: 0 }, - hasPendingPause: false, - } - return { id: log.id, sortValue: log.sortValue, summary } - }) - - const compareSortValues = (a: unknown, b: unknown): number => { - if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime() - if (typeof a === 'number' && typeof b === 'number') return a - b - const aStr = String(a) - const bStr = String(b) - if (sortBy === 'date') { - return new Date(aStr).getTime() - new Date(bStr).getTime() - } - const aNum = Number(aStr) - const bNum = Number(bStr) - if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return aNum - bNum - return aStr.localeCompare(bStr) - } - - const merged = [...workflowMapped, ...jobMapped].sort((a, b) => { - const aNull = a.sortValue === null || a.sortValue === undefined - const bNull = b.sortValue === null || b.sortValue === undefined - // Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent. - if (aNull && !bNull) return 1 - if (!aNull && bNull) return -1 - if (!aNull && !bNull) { - const cmp = compareSortValues(a.sortValue, b.sortValue) - if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp - } - const idCmp = a.id.localeCompare(b.id) - return sortOrder === 'asc' ? idCmp : -idCmp - }) - - const page = merged.slice(0, params.limit) - const hasMore = merged.length > params.limit - let nextCursor: string | null = null - if (hasMore && page.length > 0) { - const last = page[page.length - 1] - const v = last.sortValue - const cursorV = - v instanceof Date - ? v.toISOString() - : typeof v === 'number' || typeof v === 'string' - ? v - : v == null - ? null - : String(v) - nextCursor = encodeCursor({ v: cursorV, id: last.id }) - } + const result = await listLogs(params, userId) logger.debug('Listed logs', { workspaceId: params.workspaceId, - count: page.length, - hasMore, - sortBy, - sortOrder, + count: result.data.length, + hasMore: result.nextCursor !== null, + sortBy: params.sortBy, + sortOrder: params.sortOrder, }) - return NextResponse.json({ - data: page.map((row) => row.summary), - nextCursor, - }) + return NextResponse.json(result) }) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index b29abfa514d..7f0b5828500 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -105,7 +105,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, type: 'mothership', title: null, - model: 'claude-opus-4-6', + model: 'claude-opus-4-8', updatedAt: now, lastSeenAt: now, }) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 43a0bdfd965..3a49cc52ef7 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -14,8 +14,9 @@ import { import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' import type { StreamEvent } from '@/lib/copilot/request/types' +import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' +import { buildUserSkillTool } from '@/lib/mothership/skills' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -96,15 +97,33 @@ export const POST = withRouteHandler(async (req: NextRequest) => { messages, responseFormat, workspaceId, - userId, + userId: bodyUserId, chatId, messageId: providedMessageId, requestId: providedRequestId, fileAttachments, workflowId, executionId, + userMetadata, } = validation.data.body + // Bind the billing actor to the authenticated identity. The executor mints + // the internal JWT with the workflow owner's userId, so a token issued for + // one user must never be used to attribute mothership-block cost to another + // user via a forged body.userId. When the token carries a userId we require + // the body to match it; the JWT userId is authoritative. + if (auth.userId && auth.userId !== bodyUserId) { + logger.warn('Mothership execute userId does not match authenticated identity', { + tokenUserId: auth.userId, + bodyUserId, + }) + return NextResponse.json( + { error: 'userId does not match authenticated identity' }, + { status: 403 } + ) + } + const userId = auth.userId ?? bodyUserId + await assertActiveWorkspaceAccess(workspaceId, userId) const effectiveChatId = chatId || generateId() @@ -116,34 +135,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workflowId, executionId, }) - const [workspaceContext, integrationTools, mothershipToolRuntime, userPermission] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), - buildMothershipToolsForRequest({ workspaceId, userId }), - getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), - ]) - const workspaceContextWithMothershipTools = [ - workspaceContext, - mothershipToolRuntime.catalogContext, - ] - .filter(Boolean) - .join('\n\n') - + const [workspaceContext, integrationTools, userSkillTool, userPermission] = await Promise.all([ + generateWorkspaceContext(workspaceId, userId), + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildUserSkillTool(workspaceId), + getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null), + ]) const requestPayload: Record = { messages, responseFormat, userId, + // Go's auth middleware reads workspaceId off the request body to forward + // to /api/copilot/api-keys/validate (per-member org usage gate). Omitting + // it makes that validation 400 ("API key validation failed"), which kills + // the block. The chat path sends it via buildCopilotRequestPayload; the + // block path must too. + workspaceId, chatId: effectiveChatId, mode: 'agent', messageId, isHosted: true, - workspaceContext: workspaceContextWithMothershipTools, + workspaceContext, + ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), + ...(userMetadata ? { userMetadata } : {}), ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(mothershipToolRuntime.tools.length > 0 - ? { mothershipTools: mothershipToolRuntime.tools } - : {}), + ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), } @@ -159,6 +176,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { streamId: messageId, userId, chatId: effectiveChatId, + workspaceId, }).catch((error) => { reqLogger.warn('Failed to send explicit abort for mothership execution', { error: toError(error).message, diff --git a/apps/sim/app/api/mothership/settings/route.ts b/apps/sim/app/api/mothership/settings/route.ts deleted file mode 100644 index 49845ad4e7a..00000000000 --- a/apps/sim/app/api/mothership/settings/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { db, settings, user } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - getMothershipSettingsContract, - updateMothershipSettingsContract, -} from '@/lib/api/contracts/mothership-settings' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getMothershipSettings, - updateMothershipSettings, -} from '@/lib/mothership/settings/operations' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('MothershipSettingsAPI') - -async function isEffectiveSuperUser(userId: string): Promise { - const [row] = await db - .select({ - role: user.role, - superUserModeEnabled: settings.superUserModeEnabled, - }) - .from(user) - .leftJoin(settings, eq(settings.userId, user.id)) - .where(eq(user.id, userId)) - .limit(1) - - return row?.role === 'admin' && (row.superUserModeEnabled ?? false) -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - if (!(await isEffectiveSuperUser(auth.userId))) { - return NextResponse.json({ error: 'Super admin mode required' }, { status: 403 }) - } - - const parsed = await parseRequest(getMothershipSettingsContract, request, {}) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.query - const userPermission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId) - if (!userPermission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - const settings = await getMothershipSettings(workspaceId) - return NextResponse.json({ data: settings }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Mothership settings`, error) - return NextResponse.json({ error: 'Failed to fetch Mothership settings' }, { status: 500 }) - } -}) - -export const PUT = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - if (!(await isEffectiveSuperUser(auth.userId))) { - return NextResponse.json({ error: 'Super admin mode required' }, { status: 403 }) - } - - const parsed = await parseRequest(updateMothershipSettingsContract, request, {}) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.body - const userPermission = await getUserEntityPermissions(auth.userId, 'workspace', workspaceId) - if (!userPermission || (userPermission !== 'admin' && userPermission !== 'write')) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - const settings = await updateMothershipSettings(parsed.data.body) - return NextResponse.json({ success: true, data: settings }) - } catch (error) { - logger.error(`[${requestId}] Error updating Mothership settings`, error) - return NextResponse.json({ error: 'Failed to update Mothership settings' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts new file mode 100644 index 00000000000..ec4669e9549 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { auditMock, createMockRequest, createSession } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockIsOrganizationOwnerOrAdmin, + mockGetOrgMemberUsageLimit, + mockGetOrgMemberWorkspaceUsage, + mockSetOrgMemberUsageLimit, + mockFlags, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockIsOrganizationOwnerOrAdmin: vi.fn(), + mockGetOrgMemberUsageLimit: vi.fn(), + mockGetOrgMemberWorkspaceUsage: vi.fn(), + mockSetOrgMemberUsageLimit: vi.fn(), + mockFlags: { isHosted: true }, +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('@sim/audit', () => auditMock) + +vi.mock('@/lib/billing/core/organization', () => ({ + isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin, +})) + +vi.mock('@/lib/billing/organizations/member-limits', () => ({ + getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit, + getOrgMemberWorkspaceUsage: mockGetOrgMemberWorkspaceUsage, + setOrgMemberUsageLimit: mockSetOrgMemberUsageLimit, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isHosted() { + return mockFlags.isHosted + }, +})) + +import { GET, PUT } from '@/app/api/organizations/[id]/members/[memberId]/usage-limit/route' + +function context() { + return { params: Promise.resolve({ id: 'org-1', memberId: 'user-2' }) } +} + +function putRequest(body: unknown) { + return createMockRequest('PUT', body) +} + +function getRequest() { + return createMockRequest('GET') +} + +describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlags.isHosted = true + mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) + mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1) // $1 -> 200 credits + mockGetOrgMemberUsageLimit.mockResolvedValue(2) // $2 -> 400 credits + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const res = await GET(getRequest(), context()) + expect(res.status).toBe(401) + }) + + it('returns 404 when not hosted', async () => { + mockFlags.isHosted = false + const res = await GET(getRequest(), context()) + expect(res.status).toBe(404) + }) + + it('returns 403 for non-admin callers', async () => { + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + const res = await GET(getRequest(), context()) + expect(res.status).toBe(403) + }) + + it('returns credits used and limit converted to credits', async () => { + const res = await GET(getRequest(), context()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + success: true, + data: { + creditsUsed: 200, + creditLimit: 400, + }, + }) + }) + + it('returns null creditLimit when no cap is set', async () => { + mockGetOrgMemberUsageLimit.mockResolvedValue(null) + const res = await GET(getRequest(), context()) + const body = await res.json() + expect(body.data.creditLimit).toBeNull() + }) +}) + +describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlags.isHosted = true + mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) + mockSetOrgMemberUsageLimit.mockResolvedValue(undefined) + }) + + it('returns 404 when not hosted', async () => { + mockFlags.isHosted = false + const res = await PUT(putRequest({ creditLimit: 400 }), context()) + expect(res.status).toBe(404) + expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() + }) + + it('returns 403 for non-admin callers', async () => { + mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false) + const res = await PUT(putRequest({ creditLimit: 400 }), context()) + expect(res.status).toBe(403) + expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() + }) + + it('persists the limit as dollars (credits / 200) and audits', async () => { + const res = await PUT(putRequest({ creditLimit: 400 }), context()) + expect(res.status).toBe(200) + expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', 2, 'admin-1') + expect(auditMock.recordAudit).toHaveBeenCalledTimes(1) + await expect(res.json()).resolves.toEqual({ + success: true, + message: 'Member credit limit updated successfully', + data: { creditLimit: 400 }, + }) + }) + + it('clears the cap when creditLimit is null', async () => { + const res = await PUT(putRequest({ creditLimit: null }), context()) + expect(res.status).toBe(200) + expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1') + }) + + it('rejects a negative credit limit with 400', async () => { + const res = await PUT(putRequest({ creditLimit: -5 }), context()) + expect(res.status).toBe(400) + expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts new file mode 100644 index 00000000000..ad04ae4bbff --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.ts @@ -0,0 +1,130 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + getOrganizationMemberUsageLimitContract, + updateOrganizationMemberUsageLimitContract, +} from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion' +import { + getOrgMemberUsageLimit, + getOrgMemberWorkspaceUsage, + setOrgMemberUsageLimit, +} from '@/lib/billing/organizations/member-limits' +import { isHosted } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgMemberUsageLimitAPI') + +/** + * GET /api/organizations/[id]/members/[memberId]/usage-limit + * + * Returns the member's current-period credits used inside the org's workspaces + * and their per-member credit cap (both in credits). Owner/admin only and + * hosted-only (the feature is meaningless where Sim does not own the DB/billing). + * `memberId` is the target user id, so external members are supported. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!isHosted) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const parsed = await parseRequest(getOrganizationMemberUsageLimitContract, request, context) + if (!parsed.success) return parsed.response + + const { id: organizationId, memberId } = parsed.data.params + + const hasAccess = await isOrganizationOwnerOrAdmin(session.user.id, organizationId) + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + const [usage, limitDollars] = await Promise.all([ + getOrgMemberWorkspaceUsage(organizationId, memberId), + getOrgMemberUsageLimit(organizationId, memberId), + ]) + + return NextResponse.json({ + success: true, + data: { + creditsUsed: dollarsToCredits(usage), + creditLimit: limitDollars === null ? null : dollarsToCredits(limitDollars), + }, + }) + } +) + +/** + * PUT /api/organizations/[id]/members/[memberId]/usage-limit + * + * Sets (or clears, when `creditLimit` is null) the member's per-org credit cap. + * Owner/admin only and hosted-only. The target need not be an org `member` row, + * so external members are supported. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (!isHosted) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const parsed = await parseRequest(updateOrganizationMemberUsageLimitContract, request, context) + if (!parsed.success) return parsed.response + + const { id: organizationId, memberId } = parsed.data.params + const { creditLimit } = parsed.data.body + + const hasAccess = await isOrganizationOwnerOrAdmin(session.user.id, organizationId) + if (!hasAccess) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + const limitDollars = creditLimit === null ? null : creditsToDollars(creditLimit) + await setOrgMemberUsageLimit(organizationId, memberId, limitDollars, session.user.id) + + logger.info('Updated per-member usage limit', { + organizationId, + memberId, + creditLimit, + updatedBy: session.user.id, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: + creditLimit === null + ? `Cleared credit limit for member ${memberId}` + : `Set credit limit for member ${memberId} to ${creditLimit} credits`, + metadata: { + targetUserId: memberId, + creditLimit, + }, + request, + }) + + return NextResponse.json({ + success: true, + message: 'Member credit limit updated successfully', + data: { creditLimit }, + }) + } +) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 7c2070b45e1..5a945101be7 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -11,6 +11,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills' import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -47,8 +48,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const result = await listSkills({ workspaceId }) + const data = result.map((s) => ({ ...s, readOnly: isBuiltinSkillId(s.id) })) - return NextResponse.json({ data: result }, { status: 200 }) + return NextResponse.json({ data }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching skills:`, error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts new file mode 100644 index 00000000000..a3181729831 --- /dev/null +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockRecordUsage, + mockCheckActorUsageLimits, + mockGetWorkspaceBilledAccountUserId, + mockVerifyWorkspaceMembership, + mockChatRows, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockRecordUsage: vi.fn(), + mockCheckActorUsageLimits: vi.fn(), + mockGetWorkspaceBilledAccountUserId: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), + mockChatRows: { value: [] as Array> }, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => { + const chain: Record = {} + chain.from = () => chain + chain.leftJoin = () => chain + chain.where = () => chain + chain.limit = () => Promise.resolve(mockChatRows.value) + return chain + }, + }, +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage })) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: mockCheckActorUsageLimits, +})) + +vi.mock('@/lib/workspaces/utils', () => ({ + getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +vi.mock('@/lib/core/config/env', () => ({ env: { ELEVENLABS_API_KEY: 'test-key' } })) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isBillingEnabled: false, + getCostMultiplier: () => 1, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = vi.fn().mockResolvedValue({ allowed: true }) + }, +})) + +vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: vi.fn(() => false) })) + +import { POST } from '@/app/api/speech/token/route' + +const publicChatRow = { + id: 'chat-1', + userId: 'owner-1', + isActive: true, + authType: 'public', + password: null, + workspaceId: 'ws-1', +} + +beforeEach(() => { + vi.clearAllMocks() + mockChatRows.value = [] + mockGetSession.mockResolvedValue({ user: { id: 'member-1' } }) + mockRecordUsage.mockResolvedValue(undefined) + mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) + mockGetWorkspaceBilledAccountUserId.mockResolvedValue('billed-acct') + mockVerifyWorkspaceMembership.mockResolvedValue('admin') + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ token: 'tok-123' }), + // double-cast-allowed: minimal fetch stub for the ElevenLabs token call + }) as unknown as typeof fetch +}) + +describe('POST /api/speech/token — usage attribution', () => { + it('editor voice: bills the session user and stamps the verified workspace', async () => { + const res = await POST(createMockRequest('POST', { workspaceId: 'ws-1' })) + + expect(res.status).toBe(200) + expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('member-1', 'ws-1') + expect(mockRecordUsage).toHaveBeenCalledTimes(1) + expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({ + userId: 'member-1', + workspaceId: 'ws-1', + }) + }) + + it('editor voice: rejects an unverified workspace id (requires an attributable workspace)', async () => { + mockVerifyWorkspaceMembership.mockResolvedValue(null) + + const res = await POST(createMockRequest('POST', { workspaceId: 'ws-not-mine' })) + + expect(res.status).toBe(400) + expect(mockRecordUsage).not.toHaveBeenCalled() + }) + + it('deployed chat: bills the workspace billed account and stamps the chat workspace', async () => { + mockChatRows.value = [publicChatRow] + + const res = await POST(createMockRequest('POST', { chatId: 'chat-1' })) + + expect(res.status).toBe(200) + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockGetWorkspaceBilledAccountUserId).toHaveBeenCalledWith('ws-1') + expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({ + userId: 'billed-acct', + workspaceId: 'ws-1', + }) + }) + + it('deployed chat: falls back to the chat owner when no billed account resolves', async () => { + mockChatRows.value = [publicChatRow] + mockGetWorkspaceBilledAccountUserId.mockResolvedValue(null) + + const res = await POST(createMockRequest('POST', { chatId: 'chat-1' })) + + expect(res.status).toBe(200) + expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({ + userId: 'owner-1', + workspaceId: 'ws-1', + }) + }) +}) diff --git a/apps/sim/app/api/speech/token/route.ts b/apps/sim/app/api/speech/token/route.ts index 3b2007173ad..116cb5f822d 100644 --- a/apps/sim/app/api/speech/token/route.ts +++ b/apps/sim/app/api/speech/token/route.ts @@ -1,13 +1,13 @@ import { createHash } from 'node:crypto' import { db } from '@sim/db' -import { chat } from '@sim/db/schema' +import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { speechTokenBodySchema } from '@/lib/api/contracts/media/speech' import { getSession } from '@/lib/auth' -import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' import { env } from '@/lib/core/config/env' import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' @@ -15,6 +15,8 @@ import { RateLimiter } from '@/lib/core/rate-limiter' import { validateAuthToken } from '@/lib/core/security/deployment' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' const logger = createLogger('SpeechTokenAPI') @@ -41,7 +43,7 @@ const rateLimiter = new RateLimiter() async function validateChatAuth( request: NextRequest, chatId: string -): Promise<{ valid: boolean; ownerId?: string }> { +): Promise<{ valid: boolean; ownerId?: string; workspaceId?: string | null }> { try { const chatResult = await db .select({ @@ -50,8 +52,10 @@ async function validateChatAuth( isActive: chat.isActive, authType: chat.authType, password: chat.password, + workspaceId: workflow.workspaceId, }) .from(chat) + .leftJoin(workflow, eq(workflow.id, chat.workflowId)) .where(eq(chat.id, chatId)) .limit(1) @@ -62,13 +66,13 @@ async function validateChatAuth( const chatData = chatResult[0] if (chatData.authType === 'public') { - return { valid: true, ownerId: chatData.userId } + return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId } } const cookieName = `chat_auth_${chatId}` const authCookie = request.cookies.get(cookieName) if (authCookie && validateAuthToken(authCookie.value, chatId, chatData.password)) { - return { valid: true, ownerId: chatData.userId } + return { valid: true, ownerId: chatData.userId, workspaceId: chatData.workspaceId } } return { valid: false } @@ -86,19 +90,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => { body.success && typeof body.data.chatId === 'string' ? body.data.chatId : undefined let billingUserId: string | undefined + let workspaceId: string | undefined if (chatId) { const chatAuth = await validateChatAuth(request, chatId) if (!chatAuth.valid) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - billingUserId = chatAuth.ownerId + // A deployed chat is used by anonymous end-users, so the cost belongs to the + // workspace's billed account (the deployment's payer) — matching how the + // chat's workflow execution bills. Fall back to the chat owner only when no + // billed account resolves. + workspaceId = chatAuth.workspaceId ?? undefined + const billedAccountUserId = workspaceId + ? await getWorkspaceBilledAccountUserId(workspaceId) + : null + billingUserId = billedAccountUserId ?? chatAuth.ownerId } else { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } billingUserId = session.user.id + // Editor voice: only attribute to a workspace the caller actually belongs to, + // so a client-supplied id can't misattribute (or dodge) per-member usage. + const requestedWorkspaceId = + body.success && typeof body.data.workspaceId === 'string' + ? body.data.workspaceId + : undefined + if (requestedWorkspaceId) { + const permission = await verifyWorkspaceMembership(session.user.id, requestedWorkspaceId) + if (permission) workspaceId = requestedWorkspaceId + } + // Editor voice is always workspace-scoped; require an attributable workspace + // so per-member usage can't be skipped and the cost stamped workspace-less. + if (!workspaceId) { + return NextResponse.json({ error: 'Workspace context is required.' }, { status: 400 }) + } } if (isBillingEnabled) { @@ -121,12 +149,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } if (billingUserId) { - const usageCheck = await checkServerSideUsageLimits(billingUserId) + const usageCheck = await checkActorUsageLimits(billingUserId, workspaceId) if (usageCheck.isExceeded) { return NextResponse.json( { error: usageCheck.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + scope: usageCheck.scope, }, { status: 402 } ) @@ -162,6 +191,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await recordUsage({ userId: billingUserId, + workspaceId, entries: [ { category: 'fixed', diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts index 8c6225d72d9..341f58662b0 100644 --- a/apps/sim/app/api/table/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -37,6 +37,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro rowIds, limit, requestId, + triggeredByUserId: auth.userId, }) return NextResponse.json({ success: true, data: { dispatchId } }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 5b9f960896a..b2ce9b54be6 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -67,6 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro group: validated.group, outputColumns: validated.outputColumns, autoRun: validated.autoRun, + actorUserId: authResult.userId, }, requestId ) @@ -103,6 +104,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R { tableId, groupId: validated.groupId, + actorUserId: authResult.userId, ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), ...(validated.name !== undefined ? { name: validated.name } : {}), ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts index 96befd1501c..f04827d1ab1 100644 --- a/apps/sim/app/api/table/[tableId]/import/route.ts +++ b/apps/sim/app/api/table/[tableId]/import/route.ts @@ -285,7 +285,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro const inserted = insertedRows.length // Fire trigger + scheduler AFTER the tx commits — both read through the // global db connection and would otherwise see no rows. - dispatchAfterBatchInsert(finalTable, insertedRows, requestId) + dispatchAfterBatchInsert(finalTable, insertedRows, requestId, authResult.userId) logger.info(`[${requestId}] Append CSV imported`, { tableId: table.id, diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index fe7452230ee..13a0762c68b 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -129,6 +129,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR rowId, data: validated.data as RowData, workspaceId: validated.workspaceId, + actorUserId: authResult.userId, }, table, requestId diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 9e2e18a06d3..7b27e463c5d 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -358,6 +358,7 @@ export const PUT = withRouteHandler( filter: validated.filter as Filter, data: validated.data as RowData, limit: validated.limit, + actorUserId: authResult.userId, }, requestId ) @@ -544,6 +545,7 @@ export const PATCH = withRouteHandler( tableId, updates: validated.updates as Array<{ rowId: string; data: RowData }>, workspaceId: validated.workspaceId, + actorUserId: authResult.userId, }, table, requestId diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 61648a2a4d9..22cf2cbe207 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -1,3 +1,4 @@ +import { Buffer, isUtf8 } from 'buffer' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -7,8 +8,10 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { generateRequestId } from '@/lib/core/utils/request' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer, @@ -18,11 +21,14 @@ import { uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, } from '@/lib/workspaces/permissions/utils' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import type { UserFile } from '@/executor/types' export const dynamic = 'force-dynamic' @@ -122,6 +128,46 @@ const extractFileIdsFromInput = (fileInput: unknown): string[] => { .filter((id) => id.length > 0) } +/** Per-file download cap for the content operation. Aligned with the durable large-value ceiling. */ +const MAX_GET_CONTENT_FILE_BYTES = 64 * 1024 * 1024 +/** Combined extracted-text cap so the content array stays within the large-value-ref ceiling. */ +const MAX_GET_CONTENT_TOTAL_BYTES = 64 * 1024 * 1024 + +const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0) + +/** + * Download a stored file and extract its text content. Parseable types (PDF, DOCX, + * CSV, etc.) go through the shared file-parsers; other UTF-8 files are returned as + * raw text; binary files yield a short placeholder rather than corrupt bytes. + */ +const extractUserFileTextContent = async ( + userFile: UserFile, + requestId: string +): Promise => { + const buffer = await downloadFileFromStorage(userFile, requestId, logger, { + maxBytes: MAX_GET_CONTENT_FILE_BYTES, + }) + + const extension = getFileExtension(userFile.name) + if (extension && isSupportedFileType(extension)) { + try { + const result = await parseBuffer(buffer, extension) + return result.content ?? '' + } catch (error) { + logger.warn('Falling back to raw text after parser failure', { + name: userFile.name, + error: getErrorMessage(error, 'Unknown error'), + }) + } + } + + if (isLikelyTextBuffer(buffer)) { + return buffer.toString('utf-8') + } + + return `[Binary file: ${userFile.name} (${userFile.type || 'application/octet-stream'}, ${buffer.length} bytes). Cannot extract text content.]` +} + export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) if (!auth.success) { @@ -231,6 +277,69 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } + case 'content': { + const { fileId, fileInput } = body + const requestId = generateRequestId() + + const selectedFileIds = Array.isArray(fileId) + ? fileId.map((id) => id.trim()).filter(Boolean) + : fileId + ? normalizeFileIdList(fileId) + : extractFileIdsFromInput(fileInput) + const selectedInputFiles = fileId ? [] : extractUserFilesFromInput(fileInput) + + if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { + return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) + } + + const workspaceFiles = await Promise.all( + selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) + ) + const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) + if (missingFileId) { + return NextResponse.json( + { success: false, error: `File not found: "${missingFileId}"` }, + { status: 404 } + ) + } + + const userFiles: UserFile[] = workspaceFiles + .map((file) => workspaceFileToUserFile(file)) + .filter((file): file is NonNullable> => + Boolean(file) + ) + .concat(selectedInputFiles) + + const contents: string[] = [] + let totalBytes = 0 + for (const userFile of userFiles) { + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return denied + + const content = await extractUserFileTextContent(userFile, requestId) + totalBytes += Buffer.byteLength(content, 'utf8') + if (totalBytes > MAX_GET_CONTENT_TOTAL_BYTES) { + return NextResponse.json( + { + success: false, + error: `Combined file content is too large to return safely. Maximum is ${ + MAX_GET_CONTENT_TOTAL_BYTES / (1024 * 1024) + } MB.`, + }, + { status: 413 } + ) + } + contents.push(content) + } + + logger.info('File content extracted', { count: contents.length }) + + return NextResponse.json({ + success: true, + data: { contents }, + }) + } + case 'write': { const { fileName, content, contentType } = body const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 27e1035558b..dafed80b7d2 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -5,6 +5,7 @@ import { v1UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSingleDocument, @@ -139,6 +140,18 @@ export const POST = withRouteHandler( ) if (result instanceof NextResponse) return result + // Fast usage gate before the storage write + indexing (the async backstop + // in processDocumentAsync still covers non-HTTP paths). + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { + error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + }, + { status: 402 } + ) + } + const buffer = Buffer.from(await file.arrayBuffer()) const contentType = file.type || 'application/octet-stream' @@ -158,7 +171,8 @@ export const POST = withRouteHandler( mimeType: contentType, }, knowledgeBaseId, - requestId + requestId, + userId ) const documentData: DocumentData = { diff --git a/apps/sim/app/api/v1/knowledge/search/route.test.ts b/apps/sim/app/api/v1/knowledge/search/route.test.ts index 3c854e4e5b1..b95bd329784 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.test.ts @@ -42,6 +42,10 @@ vi.mock('@/app/api/knowledge/search/utils', () => ({ vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), +})) + vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, validateWorkspaceAccess: mockValidateWorkspaceAccess, diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 32679d24b6b..542fe5606d7 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -1,8 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { v1KnowledgeSearchContract } from '@/lib/api/contracts/v1/knowledge' import { parseRequest } from '@/lib/api/server' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' +import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' @@ -37,6 +39,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId) if (accessError) return accessError + // A query incurs hosted embedding (+ optional rerank) cost — gate the actor's + // usage and frozen status before spending. Tag-only search is free, so skip it. + if (query && query.trim().length > 0) { + const usage = await checkActorUsageLimits(userId, workspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' }, + { status: 402 } + ) + } + } + const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) ? parsed.data.body.knowledgeBaseIds : [parsed.data.body.knowledgeBaseIds] @@ -148,6 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const queryEmbeddingModel = embeddingModels[0] let results: SearchResult[] + let queryEmbeddingIsBYOK: boolean | null = null if (!hasQuery && hasFilters) { results = await handleTagOnlySearch({ @@ -157,9 +172,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } else if (hasQuery && hasFilters) { const strategy = getQueryStrategy(accessibleKbIds.length, topK) - const queryVector = JSON.stringify( - await generateSearchEmbedding(query!, queryEmbeddingModel, workspaceId) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) results = await handleTagAndVectorSearch({ knowledgeBaseIds: accessibleKbIds, topK, @@ -169,9 +188,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) } else if (hasQuery) { const strategy = getQueryStrategy(accessibleKbIds.length, topK) - const queryVector = JSON.stringify( - await generateSearchEmbedding(query!, queryEmbeddingModel, workspaceId) + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + const queryVector = JSON.stringify(queryEmbeddingResult.embedding) results = await handleVectorOnlySearch({ knowledgeBaseIds: accessibleKbIds, topK, @@ -185,6 +208,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v1-kb-search:${requestId}`, + }) + } + const tagDefsResults = await Promise.all( accessibleKbIds.map(async (kbId) => { try { diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index f72b961c896..122603b7133 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -144,6 +144,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR rowId, data: rowDataNameToId(validated.data as RowData, idByName), workspaceId: validated.workspaceId, + actorUserId: userId, }, table, requestId diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 669e13d1cf2..28c4e209cd0 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -350,6 +350,7 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR filter: filterNamesToIds(validated.filter as Filter, idByName), data: patchData, limit: validated.limit, + actorUserId: userId, }, requestId ) diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 7e37542002c..160d55171e0 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -7,6 +7,10 @@ import { wandGenerateContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getBYOKKey } from '@/lib/api-key/byok' import { getSession } from '@/lib/auth' +import { + checkActorUsageLimits, + checkBillingBlocked, +} from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' @@ -14,7 +18,6 @@ import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-f import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enrichTableSchema } from '@/lib/table/llm/wand' -import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils' import { getModelPricing } from '@/providers/utils' @@ -170,6 +173,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { stream = false, history = [], workflowId, + workspaceId: requestedWorkspaceId, generationType, wandContext = {}, } = body @@ -221,23 +225,33 @@ export const POST = withRouteHandler(async (req: NextRequest) => { { status: 403 } ) } + } else if (requestedWorkspaceId) { + // No workflow entity to resolve from (e.g. table-schema wand or an + // unhydrated editor); attribute to the workspace the wand is running in, + // but only when the caller is a member so usage can't be misattributed. + const permission = await verifyWorkspaceMembership(session.user.id, requestedWorkspaceId) + if (permission) { + workspaceId = requestedWorkspaceId + } } - let billingUserId = session.user.id - if (workspaceId) { - const workspaceBilledAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) - if (!workspaceBilledAccountUserId) { - logger.error(`[${requestId}] Unable to resolve billed account for workspace`, { - workspaceId, - }) - return NextResponse.json( - { success: false, error: 'Unable to resolve billing account for this workspace' }, - { status: 500 } - ) - } - billingUserId = workspaceBilledAccountUserId + // Per-member usage must be attributable to an org workspace. The editor always + // supplies a workflow or a workspace the caller belongs to; refuse to run rather + // than stamp usage workspace-less (which would silently skip the per-member cap). + if (!workspaceId) { + return NextResponse.json( + { success: false, error: 'Workspace context is required.' }, + { status: 400 } + ) } + // Wand is always an interactive, session-authenticated editor action, so the + // person using it is the billing actor — matching client-side executions and + // editor voice rather than the workspace billed account. deriveBillingContext + // still routes payment to the org for org-scoped members; per-member usage is + // attributed to the member who actually used the wand. + const billingUserId = session.user.id + let isBYOK = false let activeOpenAIKey = openaiApiKey @@ -258,6 +272,35 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ) } + // BYOK incurs no Sim-metered cost, so it skips usage gating — but a frozen / + // billing-blocked account is locked out of everything, so still check that. + // Non-BYOK runs the full actor gate, which already includes the billing-blocked + // check, so it isn't repeated here. + if (isBYOK) { + const blocked = await checkBillingBlocked(billingUserId) + if (blocked.blocked) { + return NextResponse.json( + { + success: false, + error: blocked.message || 'Account is not in good standing. Please contact support.', + }, + { status: 402 } + ) + } + } else { + const usage = await checkActorUsageLimits(billingUserId, workspaceId) + if (usage.isExceeded) { + return NextResponse.json( + { + success: false, + error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.', + scope: usage.scope, + }, + { status: 402 } + ) + } + } + let finalSystemPrompt = systemPrompt || 'You are a helpful AI assistant. Generate content exactly as requested by the user.' diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index f8a4113021a..c4cbd1acf63 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' +import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -173,37 +174,21 @@ export const PATCH = withRouteHandler( }) } - // Handle name/description updates - const updateData: { name?: string; description?: string | null } = {} - if (name !== undefined) { - updateData.name = name - } - if (description !== undefined) { - updateData.description = description - } - - const [updated] = await db - .update(workflowDeploymentVersion) - .set(updateData) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) - ) - ) - .returning({ - id: workflowDeploymentVersion.id, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - }) + // Handle name/description updates (shared with the update_deployment_version copilot tool) + const updated = await updateDeploymentVersionMetadata({ + workflowId: id, + version: versionNum, + name, + description, + }) if (!updated) { return createErrorResponse('Deployment version not found', 404) } logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, { - name: updateData.name, - description: updateData.description, + name, + description, }) return createSuccessResponse({ name: updated.name, description: updated.description }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 7a110caa13a..800d4bd6873 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -827,7 +827,10 @@ async function handleExecutePost( } const effectiveWorkflowStateOverride = - sanitizedWorkflowStateOverride || cachedWorkflowData || undefined + // double-cast-allowed: workflowStateSchema is structurally a supertype of the executor's reactflow-typed override (edges[].style is Record vs CSSProperties); validated bodies carry store-shaped values so the runtime shape matches + (sanitizedWorkflowStateOverride as unknown as ExecutionMetadata['workflowStateOverride']) || + cachedWorkflowData || + undefined const largeValueExecutionIds = [executionId] const largeValueKeys: string[] = [] const fileKeys: string[] = [] diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 2917bf8e917..90f093adfa6 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -20,6 +20,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows' const mockLoadWorkflowFromNormalizedTables = workflowsPersistenceUtilsMockFns.mockLoadWorkflowFromNormalizedTables @@ -182,6 +183,55 @@ describe('Workflow By ID API Route', () => { expect(data.data.id).toBe('workflow-123') }) + it('omits null workflow description from state metadata so response validates', async () => { + const mockWorkflow = { + id: 'workflow-null-description', + userId: 'user-123', + name: 'No Description Workflow', + description: null, + workspaceId: 'workspace-456', + folderId: null, + sortOrder: 0, + color: '#3972F6', + lastSynced: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + isDeployed: false, + deployedAt: null, + isPublicApi: false, + locked: false, + runCount: 0, + lastRunAt: null, + archivedAt: null, + variables: {}, + } + + mockGetSession({ user: { id: 'user-123' } }) + mockGetWorkflowById.mockResolvedValue(mockWorkflow) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: mockWorkflow, + workspacePermission: 'admin', + }) + mockLoadWorkflowFromNormalizedTables.mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-null-description') + const params = Promise.resolve({ id: 'workflow-null-description' }) + + const response = await GET(req, { params }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data.state.metadata).toEqual({ name: 'No Description Workflow' }) + expect(getWorkflowResponseDataSchema.safeParse(data.data).success).toBe(true) + }) + it.concurrent('should allow access when user has workspace permissions', async () => { const mockWorkflow = { id: 'workflow-123', diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 88692533f0c..3aff4179063 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -108,6 +108,12 @@ export const GET = withRouteHandler( stampedVariables[variableId] = { ...variable, workflowId } } } + const workflowStateMetadata = { + name: responseWorkflowData.name, + ...(typeof responseWorkflowData.description === 'string' + ? { description: responseWorkflowData.description } + : {}), + } if (snapshot.normalizedData) { const finalWorkflowData = { @@ -120,10 +126,7 @@ export const GET = withRouteHandler( lastSaved: Date.now(), isDeployed: responseWorkflowData.isDeployed || false, deployedAt: responseWorkflowData.deployedAt, - metadata: { - name: responseWorkflowData.name, - description: responseWorkflowData.description, - }, + metadata: workflowStateMetadata, }, variables: stampedVariables, } @@ -145,10 +148,7 @@ export const GET = withRouteHandler( lastSaved: Date.now(), isDeployed: responseWorkflowData.isDeployed || false, deployedAt: responseWorkflowData.deployedAt, - metadata: { - name: responseWorkflowData.name, - description: responseWorkflowData.description, - }, + metadata: workflowStateMetadata, }, variables: stampedVariables, } diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index 223f6b1e02a..68c342b3cda 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -31,6 +31,19 @@ export function createSuccessResponse(data: any) { * This is the single source of truth for redeployment detection — used by * both the /deploy and /status endpoints to ensure consistent results. */ +/** + * Pure redeployment-change comparison shared by checkNeedsRedeployment and the + * VFS deployment serializer so both surfaces agree. Returns false when either + * side is missing. + */ +export function computeNeedsRedeployment( + currentSnapshot: WorkflowState | null | undefined, + activeState: WorkflowState | null | undefined +): boolean { + if (!activeState || !currentSnapshot) return false + return hasWorkflowChanged(currentSnapshot, activeState) +} + export async function checkNeedsRedeployment(workflowId: string): Promise { const [active] = await db .select({ state: workflowDeploymentVersion.state }) @@ -44,12 +57,8 @@ export async function checkNeedsRedeployment(workflowId: string): Promise - /** JSON body schema owned by the concrete route.ts boundary. */ - previewBodySchema: z.ZodType<{ code: string }> -} - -/** - * Build a Next.js POST handler for one of the document preview endpoints. - * - * Everything security-relevant (session, workspace membership, JSON shape, - * empty/oversized code) is enforced before we ever reach the isolated-vm - * sandbox, and `runSandboxTask` is always invoked with the session owner key - * + `req.signal` so pool fairness and client-disconnect cancellation behave - * identically across all three formats. - */ -export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) { - const logger = createLogger(`${config.label}PreviewAPI`) - - const previewContract = defineRouteContract({ - method: 'POST', - path: '/api/workspaces/[id]/_preview', - params: config.routeParamsSchema, - body: config.previewBodySchema, - response: { mode: 'json', schema: config.previewBodySchema }, - }) - - return async function POST(req: NextRequest, context: { params: Promise<{ id: string }> }) { - const paramsResult = config.routeParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const parsed = await parseRequest(previewContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json( - { error: getValidationErrorMessage(error, 'code is required') }, - { status: 400 } - ), - invalidJsonResponse: () => - NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - const { code } = parsed.data.body - - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) - } - - const buffer = await runSandboxTask( - config.taskId, - { code, workspaceId }, - { ownerKey: `user:${session.user.id}`, signal: req.signal } - ) - - return new NextResponse(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': config.contentType, - 'Content-Length': String(buffer.length), - 'Cache-Control': 'private, no-store', - }, - }) - } catch (err) { - const message = toError(err).message - if (err instanceof SandboxUserCodeError) { - logger.warn(`${config.label} preview user code failed`, { - error: message, - errorName: err.name, - workspaceId, - }) - return NextResponse.json({ error: message, errorName: err.name }, { status: 422 }) - } - logger.error(`${config.label} preview generation failed`, { error: message, workspaceId }) - return NextResponse.json({ error: message }, { status: 500 }) - } - } -} diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts deleted file mode 100644 index 6f14fd0649a..00000000000 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @vitest-environment node - */ -import { authMockFns, workflowsApiUtilsMock, workflowsApiUtilsMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' - -const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { - class SandboxUserCodeError extends Error { - constructor(message: string, name: string) { - super(message) - this.name = name - } - } - return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } -}) - -const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership - -vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) - -vi.mock('@/lib/execution/sandbox/run-task', () => ({ - runSandboxTask: mockRunSandboxTask, - SandboxUserCodeError, -})) - -import { POST } from '@/app/api/workspaces/[id]/docx/preview/route' - -const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - -describe('DOCX preview API route', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue(true) - mockRunSandboxTask.mockResolvedValue(Buffer.from('PK\x03\x04docx')) - }) - - it('returns a generated DOCX for authorized workspace members', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe(DOCX_MIME) - expect(response.headers.get('Cache-Control')).toBe('private, no-store') - expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1') - expect(mockRunSandboxTask).toHaveBeenCalledWith( - 'docx-generate', - { code: 'return 1', workspaceId: 'workspace-1' }, - { ownerKey: 'user:user-1', signal: request.signal } - ) - expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('PK\x03\x04docx') - }) - - it('rejects requests without code', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: 'code is required' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('rejects oversized preview source payloads', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(413) - await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 401 for unauthenticated requests', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(401) - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled() - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 403 when the user is not a workspace member', async () => { - mockVerifyWorkspaceMembership.mockResolvedValue(false) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 400 for requests with invalid JSON bodies', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: '{ not valid json', - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 500 when DOCX generation throws', async () => { - mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed')) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) - }) - - it('returns 422 when user code throws inside the sandbox', async () => { - mockRunSandboxTask.mockRejectedValue( - new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/docx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'const x = ' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(422) - await expect(response.json()).resolves.toEqual({ - error: 'Invalid or unexpected token', - errorName: 'SyntaxError', - }) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts deleted file mode 100644 index 0e759a02f9b..00000000000 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { workspaceParamsSchema, workspacePreviewBodySchema } from '@/lib/api/contracts/workspaces' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -/** - * POST /api/workspaces/[id]/docx/preview - * Compile docx source code and return the binary DOCX for streaming preview. - */ -export const POST = withRouteHandler( - createDocumentPreviewRoute({ - taskId: 'docx-generate', - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - label: 'DOCX', - routeParamsSchema: workspaceParamsSchema, - previewBodySchema: workspacePreviewBodySchema, - }) -) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index 7324c915c20..f5952ecfe77 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -4,6 +4,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' +import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' +import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' @@ -51,11 +54,15 @@ export const GET = withRouteHandler( } const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' + // In the E2B regime ALL four formats compile in the doc sandbox (Node for + // pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so + // a stale file can't trigger an E2B compile when the sandbox is disabled. + const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(fileRecord.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - if (!taskId && !isMermaidFile) { + if (!e2bFmt && !taskId && !isMermaidFile) { return NextResponse.json( - { error: `Compiled check only supports .docx, .pptx, .pdf, and .mmd files` }, + { error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` }, { status: 422 } ) } @@ -81,6 +88,20 @@ export const GET = withRouteHandler( return NextResponse.json(await validateMermaidSource(code)) } + if (e2bFmt) { + // Loads the compile-once artifact if present, else compiles via E2B once + // (and recalc-scans xlsx formulas). Only a script error is { ok: false }; + // infra failures rethrow → 500, so the agent isn't told to "fix its script" + // during an E2B/S3 outage. + const result = await runE2BCompiledCheck({ + source: code, + fileName: fileRecord.name, + workspaceId, + ext, + }) + return NextResponse.json(result) + } + try { if (!taskId) { return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 }) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts deleted file mode 100644 index 2dd189f89c7..00000000000 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * @vitest-environment node - */ -import { authMockFns, workflowsApiUtilsMock, workflowsApiUtilsMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' - -const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { - class SandboxUserCodeError extends Error { - constructor(message: string, name: string) { - super(message) - this.name = name - } - } - return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } -}) - -const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership - -vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) - -vi.mock('@/lib/execution/sandbox/run-task', () => ({ - runSandboxTask: mockRunSandboxTask, - SandboxUserCodeError, -})) - -import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route' - -describe('PDF preview API route', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue(true) - mockRunSandboxTask.mockResolvedValue(Buffer.from('%PDF-test')) - }) - - it('returns a generated PDF for authorized workspace members', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe('application/pdf') - expect(response.headers.get('Cache-Control')).toBe('private, no-store') - expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1') - expect(mockRunSandboxTask).toHaveBeenCalledWith( - 'pdf-generate', - { code: 'return 1', workspaceId: 'workspace-1' }, - { ownerKey: 'user:user-1', signal: request.signal } - ) - expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('%PDF-test') - }) - - it('rejects requests without code', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: 'code is required' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('rejects oversized preview source payloads', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(413) - await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 401 for unauthenticated requests', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(401) - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled() - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 403 when the user is not a workspace member', async () => { - mockVerifyWorkspaceMembership.mockResolvedValue(false) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 400 for requests with invalid JSON bodies', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: '{ not valid json', - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 500 when PDF generation throws', async () => { - mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed')) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) - }) - - it('returns 422 when user code throws inside the sandbox', async () => { - mockRunSandboxTask.mockRejectedValue( - new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pdf/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'const x = ' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(422) - await expect(response.json()).resolves.toEqual({ - error: 'Invalid or unexpected token', - errorName: 'SyntaxError', - }) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts deleted file mode 100644 index 6d1c1231119..00000000000 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { workspaceParamsSchema, workspacePreviewBodySchema } from '@/lib/api/contracts/workspaces' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -/** - * POST /api/workspaces/[id]/pdf/preview - * Compile PDF-Lib source code and return the binary PDF for streaming preview. - */ -export const POST = withRouteHandler( - createDocumentPreviewRoute({ - taskId: 'pdf-generate', - contentType: 'application/pdf', - label: 'PDF', - routeParamsSchema: workspaceParamsSchema, - previewBodySchema: workspacePreviewBodySchema, - }) -) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts deleted file mode 100644 index 900dd41f639..00000000000 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @vitest-environment node - */ -import { authMockFns, workflowsApiUtilsMock, workflowsApiUtilsMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' - -const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => { - class SandboxUserCodeError extends Error { - constructor(message: string, name: string) { - super(message) - this.name = name - } - } - return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError } -}) - -const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership - -vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) - -vi.mock('@/lib/execution/sandbox/run-task', () => ({ - runSandboxTask: mockRunSandboxTask, - SandboxUserCodeError, -})) - -import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route' - -const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' - -describe('PPTX preview API route', () => { - beforeEach(() => { - vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue(true) - mockRunSandboxTask.mockResolvedValue(Buffer.from('PK\x03\x04pptx')) - }) - - it('returns a generated PPTX for authorized workspace members', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(200) - expect(response.headers.get('Content-Type')).toBe(PPTX_MIME) - expect(response.headers.get('Cache-Control')).toBe('private, no-store') - expect(mockVerifyWorkspaceMembership).toHaveBeenCalledWith('user-1', 'workspace-1') - expect(mockRunSandboxTask).toHaveBeenCalledWith( - 'pptx-generate', - { code: 'return 1', workspaceId: 'workspace-1' }, - { ownerKey: 'user:user-1', signal: request.signal } - ) - expect(Buffer.from(await response.arrayBuffer()).toString()).toBe('PK\x03\x04pptx') - }) - - it('rejects requests without code', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: 'code is required' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('rejects oversized preview source payloads', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'x'.repeat(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1) }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(413) - await expect(response.json()).resolves.toEqual({ error: 'code exceeds maximum size' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 401 for unauthenticated requests', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(401) - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockVerifyWorkspaceMembership).not.toHaveBeenCalled() - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 403 when the user is not a workspace member', async () => { - mockVerifyWorkspaceMembership.mockResolvedValue(false) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 400 for requests with invalid JSON bodies', async () => { - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: '{ not valid json', - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: 'Invalid or missing JSON body' }) - expect(mockRunSandboxTask).not.toHaveBeenCalled() - }) - - it('returns 500 when PPTX generation throws', async () => { - mockRunSandboxTask.mockRejectedValue(new Error('boom: sandbox failed')) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'return 1' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' }) - }) - - it('returns 422 when user code throws inside the sandbox', async () => { - mockRunSandboxTask.mockRejectedValue( - new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError') - ) - - const request = new NextRequest( - 'http://localhost:3000/api/workspaces/workspace-1/pptx/preview', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ code: 'const x = ' }), - } - ) - - const response = await POST(request, { - params: Promise.resolve({ id: 'workspace-1' }), - }) - - expect(response.status).toBe(422) - await expect(response.json()).resolves.toEqual({ - error: 'Invalid or unexpected token', - errorName: 'SyntaxError', - }) - }) -}) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts deleted file mode 100644 index 9d4631c67fa..00000000000 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { workspaceParamsSchema, workspacePreviewBodySchema } from '@/lib/api/contracts/workspaces' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -/** - * POST /api/workspaces/[id]/pptx/preview - * Compile PptxGenJS source code and return the binary PPTX for streaming preview. - */ -export const POST = withRouteHandler( - createDocumentPreviewRoute({ - taskId: 'pptx-generate', - contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - label: 'PPTX', - routeParamsSchema: workspaceParamsSchema, - previewBodySchema: workspacePreviewBodySchema, - }) -) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index 35de183ddfc..84281727c6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -5,10 +5,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared' import { PreviewToolbar } from './preview-toolbar' import { bindPreviewWheelZoom } from './preview-wheel-zoom' +import { useDocPreviewBinary } from './use-doc-preview-binary' const logger = createLogger('DocxPreview') @@ -62,21 +62,15 @@ function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercen export const DocxPreview = memo(function DocxPreview({ file, workspaceId, - streamingContent, }: { file: WorkspaceFileRecord workspaceId: string - streamingContent?: string }) { const containerRef = useRef(null) const scrollContainerRef = useRef(null) - const lastSuccessfulHtmlRef = useRef('') const zoomPercentRef = useRef(100) - const { - data: fileData, - isLoading, - error: fetchError, - } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + const preview = useDocPreviewBinary(workspaceId, file) + const fileData = preview.data const [renderError, setRenderError] = useState(null) const [rendering, setRendering] = useState(false) const [hasRenderedPreview, setHasRenderedPreview] = useState(false) @@ -182,7 +176,7 @@ export const DocxPreview = memo(function DocxPreview({ }, [pageCount, documentRenderVersion]) useEffect(() => { - if (!containerRef.current || !fileData || streamingContent !== undefined) return + if (!containerRef.current || !fileData) return let cancelled = false @@ -200,7 +194,6 @@ export const DocxPreview = memo(function DocxPreview({ }) if (!cancelled && containerRef.current) { applyPostRenderStyling() - lastSuccessfulHtmlRef.current = containerRef.current.innerHTML setHasRenderedPreview(true) setDocumentRenderVersion((version) => version + 1) } @@ -221,86 +214,12 @@ export const DocxPreview = memo(function DocxPreview({ return () => { cancelled = true } - }, [fileData, streamingContent, applyPostRenderStyling]) + }, [fileData, applyPostRenderStyling]) - useEffect(() => { - if (streamingContent === undefined || !containerRef.current) return - if (streamingContent.trim().length === 0) return - - let cancelled = false - const controller = new AbortController() - - const debounceTimer = setTimeout(async () => { - const container = containerRef.current - if (!container || cancelled) return - - const previousHtml = lastSuccessfulHtmlRef.current - - try { - setRendering(true) - - // boundary-raw-fetch: route returns binary DOCX (read via response.arrayBuffer()), not JSON - const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: streamingContent }), - signal: controller.signal, - }) - if (!response.ok) { - const err = await response.json().catch(() => ({ error: 'Preview failed' })) - throw new Error(err.error || 'Preview failed') - } - - const arrayBuffer = await response.arrayBuffer() - if (cancelled || !containerRef.current) return - if (arrayBuffer.byteLength === 0) return - - const { renderAsync } = await import('docx-preview') - if (cancelled || !containerRef.current) return - - containerRef.current.innerHTML = '' - await renderAsync(new Uint8Array(arrayBuffer), containerRef.current, undefined, { - inWrapper: true, - ignoreWidth: false, - ignoreHeight: false, - }) - - if (!cancelled && containerRef.current) { - applyPostRenderStyling() - lastSuccessfulHtmlRef.current = containerRef.current.innerHTML - setHasRenderedPreview(true) - setDocumentRenderVersion((version) => version + 1) - } - } catch (err) { - if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { - if (containerRef.current && previousHtml) { - containerRef.current.innerHTML = previousHtml - applyPostRenderStyling() - setHasRenderedPreview(true) - setDocumentRenderVersion((version) => version + 1) - } - const msg = toError(err).message || 'Failed to render document' - logger.info('Transient DOCX streaming preview error (suppressed)', { error: msg }) - } - } finally { - if (!cancelled) { - setRendering(false) - } - } - }, 500) - - return () => { - cancelled = true - clearTimeout(debounceTimer) - controller.abort() - } - }, [streamingContent, workspaceId, applyPostRenderStyling]) - - const error = streamingContent !== undefined ? null : resolvePreviewError(fetchError, renderError) + const error = resolvePreviewError(preview.error, renderError) if (error) return - const showSkeleton = - !hasRenderedPreview && (streamingContent !== undefined || isLoading || rendering) + const showSkeleton = !hasRenderedPreview && (!fileData || rendering) const scrollToPage = (page: number) => { const scrollContainer = scrollContainerRef.current diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts index 2eb2c96810b..f405f181609 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -36,7 +36,11 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([ ...SUPPORTED_CODE_EXTENSIONS, ]) -const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs']) +const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/pdf', + 'text/x-pdflibjs', + 'text/x-python-pdf', +]) const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) @@ -78,6 +82,7 @@ const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx']) const XLSX_PREVIEWABLE_MIME_TYPES = new Set([ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'text/x-python-xlsx', ]) const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx']) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 091ae68c3c5..e8e77532305 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import dynamic from 'next/dynamic' import { Skeleton } from '@/components/emcn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' @@ -10,6 +9,7 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' import { resolveFileCategory } from './file-category' import type { StreamingMode } from './text-editor-state' +import { useDocPreviewBinary } from './use-doc-preview-binary' export type { StreamingMode } from './text-editor-state' @@ -89,14 +89,7 @@ export function FileViewer({ } if (category === 'iframe-previewable') { - return ( - - ) + return } if (category === 'image-previewable') { @@ -112,37 +105,15 @@ export function FileViewer({ } if (category === 'docx-previewable') { - return ( - - ) + return } if (category === 'pptx-previewable') { - return ( - - ) + return } if (category === 'xlsx-previewable') { - return ( - - ) + return } return @@ -151,91 +122,31 @@ export function FileViewer({ const IframePreview = memo(function IframePreview({ file, workspaceId, - streamingContent, }: { file: WorkspaceFileRecord workspaceId: string - streamingContent?: string }) { - const [streamingBuffer, setStreamingBuffer] = useState(null) - const streamingBufferRef = useRef(null) - const streamingBufferSeqRef = useRef(0) - const [streamingBufferSeq, setStreamingBufferSeq] = useState(0) - const [rendering, setRendering] = useState(false) + const preview = useDocPreviewBinary(workspaceId, file) - useEffect(() => { - if (streamingContent === undefined) return - - let cancelled = false - const controller = new AbortController() - - const debounceTimer = setTimeout(async () => { - if (cancelled) return - - try { - setRendering(true) - - // boundary-raw-fetch: route returns binary PDF (read via response.arrayBuffer()), not JSON - const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: streamingContent }), - signal: controller.signal, - }) - if (!response.ok) { - const err = await response.json().catch(() => ({ error: 'Preview failed' })) - throw new Error(err.error || 'Preview failed') - } - - const buf = await response.arrayBuffer() - if (cancelled) return - - streamingBufferRef.current = buf - streamingBufferSeqRef.current += 1 - setStreamingBuffer(buf) - setStreamingBufferSeq(streamingBufferSeqRef.current) - } catch (err) { - if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { - const msg = toError(err).message || 'Failed to render PDF' - logger.info('Transient PDF streaming preview error (suppressed)', { error: msg }) - } - } finally { - if (!cancelled) setRendering(false) - } - }, 500) - - return () => { - cancelled = true - clearTimeout(debounceTimer) - controller.abort() - } - }, [streamingContent, workspaceId]) - - const staticSource = useMemo( - () => ({ - kind: 'url', - url: `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, - }), - [file.key] + const bufferSource = useMemo( + () => (preview.data ? { kind: 'buffer', buffer: preview.data } : null), + [preview.data] ) - const streamingSource = useMemo( - () => (streamingBuffer ? { kind: 'buffer', buffer: streamingBuffer } : null), - [streamingBuffer] - ) + const error = resolvePreviewError(preview.error, null) + if (error) return - if (streamingContent !== undefined) { - if ( - !streamingSource || - streamingSource.kind !== 'buffer' || - streamingSource.buffer.byteLength === 0 - ) { - return
{PDF_PAGE_SKELETON}
- } - return + if (!bufferSource) { + return
{PDF_PAGE_SKELETON}
} - return + return ( + + ) }) function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts index f86f7e36358..521016faa7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts @@ -1,3 +1,4 @@ +export { resolveFileCategory } from './file-category' export type { PreviewMode } from './file-viewer' export { FileViewer, isPreviewable, isTextEditable } from './file-viewer' export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx index ba283a6c5c2..ae539a66101 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx @@ -2,7 +2,6 @@ import { memo, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { Skeleton } from '@/components/emcn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { PptxSandboxHost } from '@/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host' @@ -10,7 +9,7 @@ import { PreviewError, resolvePreviewError, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' -import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' +import { useDocPreviewBinary } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary' const logger = createLogger('PptxPreview') @@ -45,80 +44,24 @@ function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number) export const PptxPreview = memo(function PptxPreview({ file, workspaceId, - streamingContent, }: { file: WorkspaceFileRecord workspaceId: string - streamingContent?: string }) { - const { - data: fileData, - error: fetchError, - dataUpdatedAt, - } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + const preview = useDocPreviewBinary(workspaceId, file) + const fileData = preview.data + const cacheKey = pptxCacheKey(file.id, preview.dataUpdatedAt, fileData?.byteLength ?? 0) - const cacheKey = pptxCacheKey(file.id, dataUpdatedAt, fileData?.byteLength ?? 0) - - const [streamBuffer, setStreamBuffer] = useState(null) - const [streamVersion, setStreamVersion] = useState(0) const [hasRendered, setHasRendered] = useState(false) const [renderError, setRenderError] = useState(null) - const isStreaming = streamingContent !== undefined - - useEffect(() => { - if (!isStreaming) return - - let cancelled = false - const controller = new AbortController() - - const debounceTimer = setTimeout(async () => { - if (cancelled) return - try { - // boundary-raw-fetch: route returns binary PPTX (read via response.arrayBuffer()), not JSON - const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: streamingContent }), - signal: controller.signal, - }) - if (!response.ok) { - const err = await response.json().catch(() => ({ error: 'Preview failed' })) - throw new Error(err.error || 'Preview failed') - } - if (cancelled) return - const arrayBuffer = await response.arrayBuffer() - if (cancelled) return - setRenderError(null) - setStreamBuffer(arrayBuffer) - setStreamVersion((version) => version + 1) - } catch (err) { - if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { - const msg = toError(err).message || 'Failed to render presentation' - logger.info('Transient PPTX streaming preview error (suppressed)', { error: msg }) - } - } - }, 500) - - return () => { - cancelled = true - clearTimeout(debounceTimer) - controller.abort() - } - }, [isStreaming, streamingContent, workspaceId]) useEffect(() => { setRenderError(null) setHasRendered(false) - if (!isStreaming) setStreamBuffer(null) - }, [cacheKey, isStreaming]) - - const activeBuffer = isStreaming ? streamBuffer : fileData - const activeRenderKey = isStreaming - ? `${file.id}:stream:${streamVersion}:${streamBuffer?.byteLength ?? 0}` - : cacheKey + }, [cacheKey]) function handleRenderStart() { - if (!isStreaming) setRenderError(null) + setRenderError(null) } function handleRenderComplete() { @@ -126,27 +69,23 @@ export const PptxPreview = memo(function PptxPreview({ } function handleRenderError(message: string) { - if (isStreaming) { - logger.info('Transient PPTX streaming render error (suppressed)', { error: message }) - return - } logger.error('PPTX render failed', { error: message }) setRenderError(message || 'Failed to render presentation') } - const error = isStreaming ? null : resolvePreviewError(fetchError, renderError) + const error = resolvePreviewError(preview.error, renderError) if (error) return - if (!activeBuffer) { + if (!fileData) { return PPTX_SLIDE_SKELETON } return (
void }) { const { push: navigate } = useRouter() + // Pace the reveal so streamed markdown builds at a steady cadence instead of + // jumping per server chunk. `snapOnNonAppend` shows in-place rewrites (patch) + // in full immediately so a diff never appears to retype from the top. + const revealedContent = useSmoothText(content, isStreaming, { snapOnNonAppend: true }) const { ref: autoScrollRef, spacerRef } = useScrollAnchor( isStreaming && !disableAutoScroll, - content + revealedContent ) const contentRef = useRef(content) contentRef.current = content const { frontMatterData, markdownContent } = useMemo(() => { - if (isStreaming) return { frontMatterData: null, markdownContent: content } + if (isStreaming) return { frontMatterData: null, markdownContent: revealedContent } try { const parsed = matter(content) const hasFrontMatter = Object.keys(parsed.data).length > 0 @@ -886,7 +903,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ } catch { return { frontMatterData: null, markdownContent: content } } - }, [content, isStreaming]) + }, [content, revealedContent, isStreaming]) const ctxValue = useMemo( () => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null), @@ -917,6 +934,8 @@ const MarkdownPreview = memo(function MarkdownPreview({ {frontMatterData && } { + it('reports empty when nothing is committed and no binary has resolved', () => { + const result = resolveDocPreviewBinary({ + data: undefined, + isPlaceholderData: false, + error: null, + lastGood: null, + hasCommittedContent: false, + }) + + expect(result.state).toBe('empty') + expect(result.data).toBeNull() + expect(result.error).toBeNull() + }) + + it('reports loading when committed but the first fetch has not resolved', () => { + const result = resolveDocPreviewBinary({ + data: undefined, + isPlaceholderData: false, + error: null, + lastGood: null, + hasCommittedContent: true, + }) + + expect(result.state).toBe('loading') + expect(result.data).toBeNull() + }) + + it('reports ready and advances the head on a fresh success', () => { + const fresh = buffer(1) + const result = resolveDocPreviewBinary({ + data: fresh, + isPlaceholderData: false, + error: null, + lastGood: null, + hasCommittedContent: true, + }) + + expect(result.state).toBe('ready') + expect(result.data).toBe(fresh) + expect(result.lastGood).toBe(fresh) + }) + + it('holds the previous binary as stale while a new version is fetching', () => { + const previous = buffer(1) + const result = resolveDocPreviewBinary({ + data: previous, + isPlaceholderData: true, + error: null, + lastGood: previous, + hasCommittedContent: true, + }) + + expect(result.state).toBe('stale') + expect(result.data).toBe(previous) + }) + + it('falls back to the last good binary and suppresses the error after a failed refetch', () => { + const previous = buffer(1) + const result = resolveDocPreviewBinary({ + data: undefined, + isPlaceholderData: false, + error: new Error('boom'), + lastGood: previous, + hasCommittedContent: true, + }) + + expect(result.state).toBe('stale') + expect(result.data).toBe(previous) + expect(result.error).toBeNull() + }) + + it('surfaces the error only when there is no binary to fall back to', () => { + const err = new Error('missing artifact') + const result = resolveDocPreviewBinary({ + data: undefined, + isPlaceholderData: false, + error: err, + lastGood: null, + hasCommittedContent: true, + }) + + expect(result.data).toBeNull() + expect(result.error).toBe(err) + }) +}) + +describe('stepDocPreviewBinary', () => { + it('shows loading for a committed file whose first fetch has not resolved', () => { + const step = stepDocPreviewBinary({ + fileChanged: false, + data: undefined, + isPlaceholderData: false, + error: null, + hasCommittedContent: true, + prevHasResolvedForFile: false, + prevLastGood: null, + }) + + expect(step.resolved.state).toBe('loading') + expect(step.hasResolvedForFile).toBe(false) + expect(step.lastGood).toBeNull() + }) + + it('advances the head and records resolution on a fresh success', () => { + const fresh = buffer(1) + const step = stepDocPreviewBinary({ + fileChanged: false, + data: fresh, + isPlaceholderData: false, + error: null, + hasCommittedContent: true, + prevHasResolvedForFile: false, + prevLastGood: null, + }) + + expect(step.resolved.state).toBe('ready') + expect(step.resolved.data).toBe(fresh) + expect(step.hasResolvedForFile).toBe(true) + expect(step.lastGood).toBe(fresh) + }) + + it('ignores the prior-file placeholder on a file change (no cross-file bleed)', () => { + const priorFileBytes = buffer(1) + const step = stepDocPreviewBinary({ + fileChanged: true, + data: priorFileBytes, + isPlaceholderData: true, + error: null, + hasCommittedContent: true, + prevHasResolvedForFile: true, + prevLastGood: priorFileBytes, + }) + + expect(step.resolved.state).toBe('loading') + expect(step.resolved.data).toBeNull() + expect(step.hasResolvedForFile).toBe(false) + expect(step.lastGood).toBeNull() + }) + + it('holds the previous version as stale during a same-file recompile', () => { + const v1 = buffer(1) + const step = stepDocPreviewBinary({ + fileChanged: false, + data: v1, + isPlaceholderData: true, + error: null, + hasCommittedContent: true, + prevHasResolvedForFile: true, + prevLastGood: v1, + }) + + expect(step.resolved.state).toBe('stale') + expect(step.resolved.data).toBe(v1) + expect(step.hasResolvedForFile).toBe(true) + }) + + it('keeps the last good binary and suppresses the error after a failed refetch', () => { + const v1 = buffer(1) + const step = stepDocPreviewBinary({ + fileChanged: false, + data: undefined, + isPlaceholderData: false, + error: new Error('boom'), + hasCommittedContent: true, + prevHasResolvedForFile: true, + prevLastGood: v1, + }) + + expect(step.resolved.state).toBe('stale') + expect(step.resolved.data).toBe(v1) + expect(step.resolved.error).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts new file mode 100644 index 00000000000..5819182db44 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary.ts @@ -0,0 +1,161 @@ +'use client' + +import { useRef } from 'react' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' + +export type DocPreviewState = 'empty' | 'loading' | 'ready' | 'stale' + +export interface DocPreviewBinary { + data: ArrayBuffer | null + state: DocPreviewState + error: Error | null + dataUpdatedAt: number +} + +type DocPreviewFile = Pick + +interface ResolveDocPreviewArgs { + data: ArrayBuffer | undefined + isPlaceholderData: boolean + error: Error | null + lastGood: ArrayBuffer | null + hasCommittedContent: boolean +} + +interface ResolvedDocPreview { + data: ArrayBuffer | null + state: DocPreviewState + error: Error | null + lastGood: ArrayBuffer | null +} + +/** + * Pure resolution of which binary to render and the render state, given the binary + * query's current data, whether it is keep-previous placeholder data, the last + * successfully fetched binary, and whether the file has committed content. + * + * A fresh (non-placeholder) success advances the head and renders `ready`. A + * placeholder result (a recompile fetching a new version) or a fall back to the + * last good binary after an error renders `stale`. Only the absence of any binary + * yields `loading` (committed, still fetching) or `empty` (nothing committed). An + * error is surfaced only when there is no binary to render. + */ +export function resolveDocPreviewBinary({ + data, + isPlaceholderData, + error, + lastGood, + hasCommittedContent, +}: ResolveDocPreviewArgs): ResolvedDocPreview { + const fresh = data && !isPlaceholderData ? data : null + const nextLastGood = fresh ?? lastGood + const resolvedData = data ?? nextLastGood ?? null + + let state: DocPreviewState + if (resolvedData) { + state = fresh ? 'ready' : 'stale' + } else { + state = hasCommittedContent ? 'loading' : 'empty' + } + + return { + data: resolvedData, + state, + error: resolvedData ? null : error, + lastGood: nextLastGood, + } +} + +interface DocPreviewStepArgs { + fileChanged: boolean + data: ArrayBuffer | undefined + isPlaceholderData: boolean + error: Error | null + hasCommittedContent: boolean + prevHasResolvedForFile: boolean + prevLastGood: ArrayBuffer | null +} + +interface DocPreviewStep { + resolved: ResolvedDocPreview + hasResolvedForFile: boolean + lastGood: ArrayBuffer | null +} + +/** + * Pure per-render step for {@link useDocPreviewBinary}: folds the prior last-good + * binary and the "has a fresh binary resolved for this file yet" flag with the + * current query result. On a file change the prior file's last-good and resolved + * flag are dropped, and the keep-previous placeholder (which still holds the prior + * file's bytes) is ignored until a fresh binary resolves for the new file. + */ +export function stepDocPreviewBinary({ + fileChanged, + data, + isPlaceholderData, + error, + hasCommittedContent, + prevHasResolvedForFile, + prevLastGood, +}: DocPreviewStepArgs): DocPreviewStep { + const lastGood = fileChanged ? null : prevLastGood + const hasResolvedForFile = + (fileChanged ? false : prevHasResolvedForFile) || (Boolean(data) && !isPlaceholderData) + const placeholderFromPriorFile = isPlaceholderData && !hasResolvedForFile + const resolved = resolveDocPreviewBinary({ + data: placeholderFromPriorFile ? undefined : data, + isPlaceholderData, + error, + lastGood, + hasCommittedContent, + }) + return { resolved, hasResolvedForFile, lastGood: resolved.lastGood } +} + +/** + * Resolves the compiled binary to render for a generated or uploaded document and + * retains the last successfully fetched binary as a fallback. + * + * A compiled-doc preview is a function of the file record (`size`, `updatedAt`) + * and the binary serve route, never of the streaming tool session. While a + * recompile is fetching (a new `updatedAt`) the previously fetched binary keeps + * rendering; if a fetch errors after a prior success the last good binary is held + * rather than dropping to an error. A skeleton (`empty`/`loading`) shows only when + * no binary has ever resolved for the file. On a file switch the keep-previous + * placeholder (which still holds the prior file's bytes) is ignored until a fresh + * binary resolves for the new file, so one viewer never renders another file's content. + */ +export function useDocPreviewBinary(workspaceId: string, file: DocPreviewFile): DocPreviewBinary { + const query = useWorkspaceFileBinary(workspaceId, file.id, file.key, { + enabled: (file.size ?? 0) > 0, + version: Number(new Date(file.updatedAt)) || file.size, + }) + + const lastGoodRef = useRef(null) + const fileIdRef = useRef(file.id) + const hasResolvedForFileRef = useRef(false) + const fileChanged = fileIdRef.current !== file.id + if (fileChanged) { + fileIdRef.current = file.id + } + + const step = stepDocPreviewBinary({ + fileChanged, + data: query.data, + isPlaceholderData: query.isPlaceholderData, + error: (query.error as Error | null) ?? null, + hasCommittedContent: (file.size ?? 0) > 0, + prevHasResolvedForFile: hasResolvedForFileRef.current, + prevLastGood: lastGoodRef.current, + }) + hasResolvedForFileRef.current = step.hasResolvedForFile + lastGoodRef.current = step.lastGood + + return { + data: step.resolved.data, + state: step.resolved.state, + error: step.resolved.error, + dataUpdatedAt: query.dataUpdatedAt, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index 7d468c94266..c2faa5da541 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -1,19 +1,15 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { WorkBook } from 'xlsx' import { Button, Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { - useUpdateWorkspaceFileContent, - useWorkspaceFileBinary, -} from '@/hooks/queries/workspace-files' -import type { DataTableHandle } from './data-table' import { DataTable } from './data-table' import { PreviewError, resolvePreviewError } from './preview-shared' +import { useDocPreviewBinary } from './use-doc-preview-binary' const logger = createLogger('XlsxPreview') @@ -54,37 +50,18 @@ const XLSX_SKELETON = ( export const XlsxPreview = memo(function XlsxPreview({ file, workspaceId, - canEdit, - onSaveStatusChange, - saveRef, }: { file: WorkspaceFileRecord workspaceId: string - canEdit: boolean - onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void - saveRef?: React.MutableRefObject<(() => Promise) | null> }) { - const { - data: fileData, - isLoading, - error: fetchError, - } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + const preview = useDocPreviewBinary(workspaceId, file) + const fileData = preview.data const [sheetNames, setSheetNames] = useState([]) const [activeSheet, setActiveSheet] = useState(0) const [currentSheet, setCurrentSheet] = useState(null) const [renderError, setRenderError] = useState(null) - const [isDirty, setIsDirty] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const isSavingRef = useRef(false) const workbookRef = useRef(null) - const xlsxModuleRef = useRef(null) - const dataTableRef = useRef(null) - const updateContent = useUpdateWorkspaceFileContent() - const updateContentRef = useRef(updateContent) - updateContentRef.current = updateContent - const onSaveStatusChangeRef = useRef(onSaveStatusChange) - onSaveStatusChangeRef.current = onSaveStatusChange useEffect(() => { if (!fileData) return @@ -95,9 +72,7 @@ export const XlsxPreview = memo(function XlsxPreview({ async function parse() { try { setRenderError(null) - setIsDirty(false) const XLSX = await import('xlsx') - xlsxModuleRef.current = XLSX const workbook = XLSX.read(new Uint8Array(data), { type: 'array' }) if (!cancelled) { workbookRef.current = workbook @@ -157,122 +132,9 @@ export const XlsxPreview = memo(function XlsxPreview({ } }, [sheetNames, activeSheet]) - const handleCellChange = useCallback( - (row: number, col: number, value: string) => { - const wb = workbookRef.current - const XLSX = xlsxModuleRef.current - if (wb && XLSX) { - const sheetName = sheetNames[activeSheet] - const ws = wb.Sheets[sheetName] - if (ws) { - const cellAddr = XLSX.utils.encode_cell({ r: row + 1, c: col }) - const numValue = Number(value) - ws[cellAddr] = - value !== '' && !Number.isNaN(numValue) ? { t: 'n', v: numValue } : { t: 's', v: value } - } - } - setCurrentSheet((prev) => { - if (!prev) return prev - const newRows = prev.rows.map((r, ri) => - ri === row ? r.map((v, ci) => (ci === col ? value : v)) : r - ) - return { ...prev, rows: newRows } - }) - setIsDirty(true) - }, - [activeSheet, sheetNames] - ) - - const handleHeaderChange = useCallback( - (col: number, value: string) => { - const wb = workbookRef.current - const XLSX = xlsxModuleRef.current - if (wb && XLSX) { - const sheetName = sheetNames[activeSheet] - const ws = wb.Sheets[sheetName] - if (ws) { - const cellAddr = XLSX.utils.encode_cell({ r: 0, c: col }) - ws[cellAddr] = { t: 's', v: value } - } - } - setCurrentSheet((prev) => { - if (!prev) return prev - const newHeaders = prev.headers.map((h, i) => (i === col ? value : h)) - return { ...prev, headers: newHeaders } - }) - setIsDirty(true) - }, - [activeSheet, sheetNames] - ) - - const handleSave = useCallback(async () => { - dataTableRef.current?.commitEdit() - const wb = workbookRef.current - if (!wb || isSavingRef.current) return - - try { - isSavingRef.current = true - setIsSaving(true) - onSaveStatusChangeRef.current?.('saving') - - const XLSX = await import('xlsx') - const binary: number[] = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) - const bytes = new Uint8Array(binary) - - const chunkSize = 8192 - const parts: string[] = [] - for (let i = 0; i < bytes.length; i += chunkSize) { - parts.push(String.fromCharCode(...bytes.slice(i, i + chunkSize))) - } - const base64 = btoa(parts.join('')) - - await updateContentRef.current.mutateAsync({ - workspaceId, - fileId: file.id, - content: base64, - encoding: 'base64', - }) - - setIsDirty(false) - onSaveStatusChangeRef.current?.('saved') - } catch (err) { - logger.error('XLSX save failed', { error: toError(err).message }) - onSaveStatusChangeRef.current?.('error') - } finally { - isSavingRef.current = false - setIsSaving(false) - } - }, [workspaceId, file.id]) - - useEffect(() => { - if (!saveRef) return - saveRef.current = handleSave - return () => { - if (saveRef.current === handleSave) saveRef.current = null - } - }, [handleSave, saveRef]) - - useEffect(() => { - if (!canEdit) return - const onKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 's') { - e.preventDefault() - handleSave() - } - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, [canEdit, handleSave]) - - const editConfig = useMemo( - () => - canEdit ? { onCellChange: handleCellChange, onHeaderChange: handleHeaderChange } : undefined, - [canEdit, handleCellChange, handleHeaderChange] - ) - - const error = resolvePreviewError(fetchError, renderError) + const error = resolvePreviewError(preview.error, renderError) if (error) return - if (isLoading || currentSheet === null) return XLSX_SKELETON + if (!fileData || currentSheet === null) return XLSX_SKELETON return (
@@ -295,25 +157,9 @@ export const XlsxPreview = memo(function XlsxPreview({ ))}
- {canEdit && isDirty && ( - - )}
- + {currentSheet.truncated && (

Showing first {XLSX_MAX_ROWS.toLocaleString()} rows. Download the file to view all data. diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx index aa6d45c884d..78943da88c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx @@ -8,6 +8,7 @@ import { Credit } from '@/components/emcn/icons' import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants' import { formatCredits } from '@/lib/billing/credits/conversion' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' +import { useMyMemberCredits } from '@/hooks/queries/organization' import { usePlanView } from '@/hooks/queries/plan-view' import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription' import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace' @@ -30,6 +31,7 @@ function CreditsChipInner() { const router = useRouter() const queryClient = useQueryClient() const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: memberCredits, isLoading: memberLoading } = useMyMemberCredits(workspaceId) const upgradeHref = `/workspace/${workspaceId}/upgrade` @@ -43,22 +45,7 @@ function CreditsChipInner() { prefetchWorkspaceSettings(queryClient, workspaceId) }, [router, queryClient, upgradeHref, workspaceId]) - if (isLoading || !hasData || !data?.data) return null - if (!planView.showCredits) return null - - const { usageLimit, currentUsage, creditBalance } = data.data - - /** - * Credits remaining = unused plan allowance plus any purchased credit balance. - * Uncapped plans (limit at/above the on-demand threshold) render as ∞ via - * `formatCredits`, so short-circuit instead of subtracting usage from it. - */ - const remainingCredits = - usageLimit >= ON_DEMAND_UNLIMITED - ? ON_DEMAND_UNLIMITED - : Math.max(0, usageLimit + creditBalance - currentUsage) - - return ( + const renderChip = (dollars: number) => ( router.push(upgradeHref)} @@ -66,7 +53,47 @@ function CreditsChipInner() { onFocus={prefetchUpgrade} leftIcon={Credit} > - {formatCredits(remainingCredits)} + {formatCredits(dollars)} ) + + // Wait for the per-member cap result before rendering: until it resolves, + // `limitDollars` is null and a capped member would briefly see the larger + // pooled number. Disabled (no workspace) → not loading, so non-org users are + // unaffected; cached after the first load (30s staleTime), so it's a one-time + // wait, not a per-navigation one. + if (memberLoading) return null + + /** + * Pooled/plan remaining (dollars): unused plan allowance plus any purchased + * credit balance. Null when the plan-based chip wouldn't show on its own (data + * not ready, or the plan isn't credit-metered). `ON_DEMAND_UNLIMITED` means + * effectively unbounded — rendered as ∞ — so short-circuit instead of + * subtracting usage from the sentinel. + */ + const pooledData = !isLoading && hasData && planView.showCredits ? (data?.data ?? null) : null + const pooledRemaining = + pooledData === null + ? null + : pooledData.usageLimit >= ON_DEMAND_UNLIMITED + ? ON_DEMAND_UNLIMITED + : Math.max(0, pooledData.usageLimit + pooledData.creditBalance - pooledData.currentUsage) + + /** + * A per-member cap is the authoritative personal remaining, but the actor gate + * blocks on the pooled cap first — so show the tighter of the two, or a member + * could see credits left while every action 402s on org/plan usage. Clamp at 0. + * Fall back to personal alone when pooled isn't available/shown, so a capped + * member still sees a balance even where the plan chip would be hidden. + */ + const limitDollars = memberCredits?.limitDollars ?? null + if (limitDollars !== null) { + const personalRemaining = Math.max(0, limitDollars - (memberCredits?.usedDollars ?? 0)) + return renderChip( + pooledRemaining === null ? personalRemaining : Math.min(personalRemaining, pooledRemaining) + ) + } + + if (pooledRemaining === null) return null + return renderChip(pooledRemaining) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 85513baaa74..eb42f24729c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -7,9 +7,24 @@ import type { ToolCallData } from '../../../../types' import { getAgentIcon } from '../../utils' import { ToolCallItem } from './tool-call-item' +/** + * A subagent group nested inside another agent's output. Carries the same shape + * as a top-level group so {@link AgentGroup} can render it recursively, which is + * how deterministic parent/child nesting (e.g. Deploy inside Workflow) is drawn. + */ +export interface NestedAgentGroup { + id: string + agentName: string + agentLabel: string + items: AgentGroupItem[] + isDelegating: boolean + isOpen: boolean +} + export type AgentGroupItem = | { type: 'text'; content: string } | { type: 'tool'; data: ToolCallData } + | { type: 'agent_group'; group: NestedAgentGroup } interface AgentGroupProps { agentName: string @@ -17,7 +32,6 @@ interface AgentGroupProps { items: AgentGroupItem[] isDelegating?: boolean isStreaming?: boolean - autoCollapse?: boolean defaultExpanded?: boolean } @@ -27,7 +41,8 @@ function isToolDone(status: ToolCallData['status']): boolean { status === 'error' || status === 'cancelled' || status === 'skipped' || - status === 'rejected' + status === 'rejected' || + status === 'interrupted' ) } @@ -37,7 +52,6 @@ export function AgentGroup({ items, isDelegating = false, isStreaming = false, - autoCollapse = false, defaultExpanded = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) @@ -46,43 +60,29 @@ export function AgentGroup({ (item): item is Extract => item.type === 'tool' ) const allDone = toolItems.length > 0 && toolItems.every((t) => isToolDone(t.data.status)) - - const [expanded, setExpanded] = useState(defaultExpanded || !allDone) - const didAutoCollapseRef = useRef(allDone) - const wasAutoExpandedRef = useRef(defaultExpanded) - - useEffect(() => { - if (defaultExpanded) { - wasAutoExpandedRef.current = true - setExpanded(true) - return - } - - if (wasAutoExpandedRef.current && allDone) { - wasAutoExpandedRef.current = false - setExpanded(false) - } - }, [defaultExpanded, allDone]) - - useEffect(() => { - if (!autoCollapse || didAutoCollapseRef.current) return - didAutoCollapseRef.current = true - setExpanded(false) - }, [autoCollapse]) + // Only a live turn can be delegating. Once the turn is terminal (complete, + // errored, or stopped) no subagent should spin — even one aborted before its + // first tool call, where `allDone` is false because there are no tools yet. + const showDelegatingSpinner = isStreaming && isDelegating && !allDone + + // Expand only while the turn is live and the group is still open or working. + // Once the turn ends (isStreaming false) — or a subagent closes mid-turn — the + // group auto-collapses, so finished subagent blocks never stay expanded. A + // manual toggle pins the choice for the rest of the message. + const autoExpanded = isStreaming && (defaultExpanded || !allDone) + const [manualExpanded, setManualExpanded] = useState(null) + const expanded = manualExpanded ?? autoExpanded return (

{hasItems ? (
) } case 'agent_group': { - const toolItems = segment.items.filter((item) => item.type === 'tool') - const allToolsDone = - toolItems.length === 0 || - toolItems.every((t) => t.type === 'tool' && isToolDone(t.data.status)) - const hasFollowingText = segments.slice(i + 1).some((s) => s.type === 'text') return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index e09f02b93f2..5bcc8b1aa4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -20,7 +20,7 @@ import { Wrench, } from '@/components/emcn' import { Calendar, Table as TableIcon } from '@/components/emcn/icons' -import { AgentIcon } from '@/components/icons' +import { AgentIcon, ImageIcon, TTSIcon, VideoIcon } from '@/components/icons' export type IconComponent = ComponentType> @@ -58,6 +58,11 @@ const TOOL_ICONS: Record = { context_compaction: Asterisk, open_resource: Eye, file: File, + media: VideoIcon, + generate_image: ImageIcon, + generate_video: VideoIcon, + generate_audio: TTSIcon, + ffmpeg: Wrench, } export function getAgentIcon(name: string): IconComponent { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index e3e07a3517d..965987807b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -21,18 +21,20 @@ import { markRunToolManuallyStopped, reportManualRunToolStop, } from '@/lib/copilot/tools/client/run-tool-execution' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { FileViewer, type PreviewMode, + resolveFileCategory, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' import { RESOURCE_TAB_ICON_BUTTON_CLASS, RESOURCE_TAB_ICON_CLASS, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' -import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions' +import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { GenericResourceData, MothershipResource, @@ -115,39 +117,28 @@ export const ResourceContent = memo(function ResourceContent({ }, [workspaceId, streamFileName]) const disableStreamingAutoScroll = previewSession?.operation === 'patch' - const rawPreviewText = previewSession?.previewText - const streamingPreviewText = - previewSession && - typeof rawPreviewText === 'string' && + const isTextPreview = + !!previewSession && resolveFileCategory(null, previewSession.fileName) === 'text-editable' + const textStreamingContent = + isTextPreview && + typeof previewSession?.previewText === 'string' && hasRenderableFilePreviewContent(previewSession) - ? rawPreviewText - : undefined - const pendingOrStreamingFilePreviewText = - previewSession?.fileId === resource.id && - typeof rawPreviewText === 'string' && - hasRenderableFilePreviewContent(previewSession) - ? rawPreviewText + ? previewSession.previewText : undefined if (resource.id === 'streaming-file') { return (
- {streamingPreviewText !== undefined ? ( - - ) : ( -
-

Processing file…

-
- )} +
) } @@ -162,8 +153,11 @@ export const ResourceContent = memo(function ResourceContent({ key={resource.id} workspaceId={workspaceId} fileId={resource.id} + filePath={resource.path} previewMode={previewMode} - streamingContent={pendingOrStreamingFilePreviewText} + streamingContent={ + previewSession?.fileId === resource.id ? textStreamingContent : undefined + } streamingMode='replace' disableStreamingAutoScroll={disableStreamingAutoScroll} previewContextKey={previewContextKey} @@ -218,7 +212,13 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps) case 'workflow': return case 'file': - return + return ( + + ) case 'knowledgebase': return ( @@ -426,12 +426,22 @@ const fileLogger = createLogger('EmbeddedFileActions') interface EmbeddedFileActionsProps { workspaceId: string fileId: string + filePath?: string } -function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps) { +function EmbeddedFileActions({ workspaceId, fileId, filePath }: EmbeddedFileActionsProps) { const router = useRouter() const { data: files = [] } = useWorkspaceFiles(workspaceId) - const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId]) + const file = useMemo( + () => + files.find( + (f) => + f.id === fileId || + (filePath && + canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === filePath) + ), + [files, fileId, filePath] + ) const handleDownload = async () => { if (!file) return @@ -443,7 +453,7 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps) } const handleOpenInFiles = () => { - router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(fileId)}`) + router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(file?.id ?? fileId)}`) } return ( @@ -521,6 +531,7 @@ function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { interface EmbeddedFileProps { workspaceId: string fileId: string + filePath?: string previewMode?: PreviewMode streamingContent?: string streamingMode?: 'append' | 'replace' @@ -531,6 +542,7 @@ interface EmbeddedFileProps { function EmbeddedFile({ workspaceId, fileId, + filePath, previewMode, streamingContent, streamingMode, @@ -539,7 +551,16 @@ function EmbeddedFile({ }: EmbeddedFileProps) { const { canEdit } = useUserPermissionsContext() const { data: files = [], isLoading, isFetching } = useWorkspaceFiles(workspaceId) - const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId]) + const file = useMemo( + () => + files.find( + (f) => + f.id === fileId || + (filePath && + canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === filePath) + ), + [files, fileId, filePath] + ) if (isLoading || (isFetching && !file)) return LOADING_SKELETON diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index 48953eb1e90..a07620ebb17 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -6,7 +6,7 @@ import { cn } from '@/lib/core/utils/cn' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { RICH_PREVIEWABLE_EXTENSIONS } from '@/app/workspace/[workspaceId]/files/components/file-viewer' -import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions' +import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { GenericResourceData, MothershipResource, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 668c7fdd703..b95300c6650 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -13,7 +13,7 @@ import { } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' -import { Button, Paperclip, Slash, Tooltip } from '@/components/emcn' +import { Button, Paperclip, Slash, Tooltip, toast } from '@/components/emcn' import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { cn } from '@/lib/core/utils/cn' @@ -402,8 +402,20 @@ export const UserInput = forwardRef(function Us valueRef.current = converted } - function handleUsageLimitExceeded() { - navigateToSettings({ section: 'billing' }) + function handleUsageLimitExceeded(message?: string, isMemberLimit?: boolean) { + // A per-member cap can only be raised by an org admin, so don't offer Upgrade + // (the member can't act on it) — the message already tells them to ask an admin. + toast.error( + message || 'You are out of credits.', + isMemberLimit + ? undefined + : { + action: { + label: 'Upgrade', + onClick: () => navigateToSettings({ section: 'billing' }), + }, + } + ) } const { @@ -414,6 +426,7 @@ export const UserInput = forwardRef(function Us } = useSpeechToText({ onTranscript: handleTranscript, onUsageLimitExceeded: handleUsageLimitExceeded, + workspaceId, }) const toggleListening = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 3541f001437..d564ecf946b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' @@ -8,6 +8,12 @@ import { Button } from '@/components/emcn' import { PanelLeft } from '@/components/emcn/icons' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { + buildWorkflowAliasWorkflowEntries, + resolveWorkflowAliasPath, + resolveWorkspacePlanAliasPath, +} from '@/lib/copilot/vfs/workflow-aliases' import { LandingPromptStorage, type LandingWorkflowSeed, @@ -19,10 +25,13 @@ import { } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' +import { useFolders } from '@/hooks/queries/folders' import { useMarkMothershipChatRead, useMothershipChatHistory, } from '@/hooks/queries/mothership-chats' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -50,6 +59,9 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() const firstName = userName?.split(' ')[0] ?? '' + const { data: workspaceFiles = [] } = useWorkspaceFiles(workspaceId) + const { data: workflows = [] } = useWorkflows(workspaceId) + const { data: folders = [] } = useFolders(workspaceId) const posthog = usePostHog() const posthogRef = useRef(posthog) posthogRef.current = posthog @@ -291,10 +303,61 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom removeResource(resolved.type, resolved.id) } + const workflowAliasEntries = useMemo( + () => + buildWorkflowAliasWorkflowEntries( + workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId ?? null, + })), + folders.map((folder) => ({ + folderId: folder.id, + folderName: folder.name, + parentId: folder.parentId ?? null, + })) + ), + [folders, workflows] + ) + + const resolveFileResource = useCallback( + (resource: MothershipResource): MothershipResource => { + if (resource.type !== 'file') return resource + + const reference = (resource.path || resource.id).trim() + const workspacePlanAlias = resolveWorkspacePlanAliasPath(reference) + const workflowAlias = workspacePlanAlias + ? null + : resolveWorkflowAliasPath(reference, workflowAliasEntries) + const alias = workspacePlanAlias || workflowAlias + const targetPath = alias && alias.kind !== 'plans_dir' ? alias.backingPath : reference + + const file = workspaceFiles.find((candidate) => { + const candidatePath = canonicalWorkspaceFilePath({ + folderPath: candidate.folderPath, + name: candidate.name, + }) + return ( + candidate.id === reference || candidatePath === reference || candidatePath === targetPath + ) + }) + + if (!file) return resource + return { + ...resource, + id: file.id, + title: resource.title || file.name, + path: alias ? reference : resource.path, + } + }, + [workflowAliasEntries, workspaceFiles] + ) + function handleWorkspaceResourceSelect(resource: MothershipResource) { - const wasAdded = addResource(resource) + const resolvedResource = resolveFileResource(resource) + const wasAdded = addResource(resolvedResource) if (!wasAdded) { - setActiveResourceId(resource.id) + setActiveResourceId(resolvedResource.id) } handleResourceEvent() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts new file mode 100644 index 00000000000..6406eb9c7ca --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { FilePreviewSession } from '@/lib/copilot/request/session' +import { deriveFilePreviewSession } from './apply-file-preview-phase' + +const NOW = '2026-06-08T00:00:00.000Z' + +function session(overrides: Partial): FilePreviewSession { + return { + schemaVersion: 1, + id: 'tool-1', + streamId: 'stream-1', + toolCallId: 'tool-1', + status: 'streaming', + fileName: 'deck.pptx', + previewText: '', + previewVersion: 1, + updatedAt: NOW, + ...overrides, + } +} + +describe('deriveFilePreviewSession', () => { + it('starts a pending session keyed to the tool call', () => { + const next = deriveFilePreviewSession( + undefined, + { previewPhase: 'file_preview_start', toolCallId: 'tool-1', toolName: 'workspace_file' }, + 'stream-1', + NOW + ) + expect(next.status).toBe('pending') + expect(next.id).toBe('tool-1') + expect(next.previewVersion).toBe(0) + expect(next.streamId).toBe('stream-1') + }) + + it('captures target identity (fileId, name, kind, operation)', () => { + const next = deriveFilePreviewSession( + session({ status: 'pending' }), + { + previewPhase: 'file_preview_target', + toolCallId: 'tool-1', + toolName: 'workspace_file', + operation: 'append', + target: { kind: 'file_id', fileId: 'file-9', fileName: 'deck.pptx' }, + }, + 'stream-1', + NOW + ) + expect(next.fileId).toBe('file-9') + expect(next.targetKind).toBe('file_id') + expect(next.operation).toBe('append') + expect(next.status).toBe('pending') + }) + + it('appends delta content and advances the version monotonically when none supplied', () => { + const prev = session({ previewText: 'slide one', previewVersion: 2 }) + const next = deriveFilePreviewSession( + prev, + { + previewPhase: 'file_preview_content', + toolCallId: 'tool-1', + toolName: 'workspace_file', + content: ' slide two', + contentMode: 'delta', + fileName: 'deck.pptx', + }, + 'stream-1', + NOW + ) + expect(next.status).toBe('streaming') + expect(next.previewText).toBe('slide one slide two') + expect(next.previewVersion).toBe(3) + }) + + it('appends delta content and uses the supplied version verbatim', () => { + const prev = session({ previewText: 'slide one', previewVersion: 2 }) + const next = deriveFilePreviewSession( + prev, + { + previewPhase: 'file_preview_content', + toolCallId: 'tool-1', + toolName: 'workspace_file', + content: ' slide two', + contentMode: 'delta', + previewVersion: 9, + fileName: 'deck.pptx', + }, + 'stream-1', + NOW + ) + expect(next.previewText).toBe('slide one slide two') + expect(next.previewVersion).toBe(9) + }) + + it('replaces text on a snapshot and carries forward prior fileId', () => { + const prev = session({ previewText: 'old', fileId: 'file-9', previewVersion: 4 }) + const next = deriveFilePreviewSession( + prev, + { + previewPhase: 'file_preview_content', + toolCallId: 'tool-1', + toolName: 'workspace_file', + content: 'fresh snapshot', + contentMode: 'snapshot', + previewVersion: 5, + fileName: 'deck.pptx', + }, + undefined, + NOW + ) + expect(next.previewText).toBe('fresh snapshot') + expect(next.fileId).toBe('file-9') + expect(next.streamId).toBe('stream-1') + }) + + it('marks completion with completedAt and a resolved version', () => { + const prev = session({ previewText: 'final', previewVersion: 7, fileId: 'file-9' }) + const next = deriveFilePreviewSession( + prev, + { + previewPhase: 'file_preview_complete', + toolCallId: 'tool-1', + toolName: 'workspace_file', + fileId: 'file-9', + }, + 'stream-1', + NOW + ) + expect(next.status).toBe('complete') + expect(next.completedAt).toBe(NOW) + expect(next.previewVersion).toBe(7) + expect(next.fileId).toBe('file-9') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts new file mode 100644 index 00000000000..e4bb638bb5c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts @@ -0,0 +1,93 @@ +import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' +import type { + FilePreviewSession, + FilePreviewTargetKind, +} from '@/lib/copilot/request/session/file-preview-session-contract' + +function toTargetKind(value: string | undefined): FilePreviewTargetKind | undefined { + return value === 'new_file' || value === 'file_id' ? value : undefined +} + +/** + * Derives the next {@link FilePreviewSession} from a synthetic preview-phase + * payload and the prior session for that tool call. Pure: identity fields prefer + * the payload then fall back to the previous session, content accumulates by + * delta/snapshot, and `previewVersion` advances monotonically. `now` is injected + * so the result is deterministic; the caller supplies a single timestamp used for + * both `updatedAt` and (on completion) `completedAt`. + */ +export function deriveFilePreviewSession( + prev: FilePreviewSession | undefined, + payload: SyntheticFilePreviewPayload, + streamId: string | undefined, + now: string +): FilePreviewSession { + const id = payload.toolCallId + + let targetKind = prev?.targetKind + let fileId = prev?.fileId + let fileName = prev?.fileName ?? '' + let operation = prev?.operation + let edit = prev?.edit + + if (payload.previewPhase === 'file_preview_target') { + targetKind = toTargetKind(payload.target.kind) ?? targetKind + fileId = payload.target.fileId ?? fileId + fileName = payload.target.fileName ?? fileName + operation = payload.operation ?? operation + } else if (payload.previewPhase === 'file_preview_content') { + targetKind = toTargetKind(payload.targetKind) ?? targetKind + fileId = payload.fileId ?? fileId + fileName = payload.fileName ?? fileName + operation = payload.operation ?? operation + edit = payload.edit ?? edit + } else if (payload.previewPhase === 'file_preview_edit_meta') { + edit = payload.edit ?? edit + } else if (payload.previewPhase === 'file_preview_complete') { + fileId = payload.fileId ?? fileId + } + + const base: FilePreviewSession = { + schemaVersion: 1, + id, + streamId: streamId ?? prev?.streamId ?? '', + toolCallId: id, + status: prev?.status ?? 'pending', + fileName, + ...(fileId ? { fileId } : {}), + ...(targetKind ? { targetKind } : {}), + ...(operation ? { operation } : {}), + ...(edit ? { edit } : {}), + previewText: prev?.previewText ?? '', + previewVersion: prev?.previewVersion ?? 0, + updatedAt: now, + ...(prev?.completedAt ? { completedAt: prev.completedAt } : {}), + } + + switch (payload.previewPhase) { + case 'file_preview_start': + case 'file_preview_target': + case 'file_preview_edit_meta': + return base + + case 'file_preview_content': { + const previewText = + payload.contentMode === 'delta' + ? (prev?.previewText ?? '') + payload.content + : payload.content + const previewVersion = + typeof payload.previewVersion === 'number' && Number.isFinite(payload.previewVersion) + ? payload.previewVersion + : (prev?.previewVersion ?? 0) + 1 + return { ...base, status: 'streaming', previewText, previewVersion } + } + + case 'file_preview_complete': + return { + ...base, + status: 'complete', + previewVersion: payload.previewVersion ?? prev?.previewVersion ?? 0, + completedAt: now, + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/index.ts new file mode 100644 index 00000000000..7c6258acf61 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/index.ts @@ -0,0 +1,5 @@ +export { useFilePreviewController } from './use-file-preview-controller' +export { + hasRenderableFilePreviewContent, + shouldReplaceSession, +} from './use-file-preview-sessions' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts new file mode 100644 index 00000000000..d413d080bfb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts @@ -0,0 +1,412 @@ +'use client' + +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useCallback, + useRef, +} from 'react' +import { useQueryClient } from '@tanstack/react-query' +import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' +import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' +import { + buildCompletedPreviewSessions, + type FilePreviewSessionsState, + hasRenderableFilePreviewContent, + INITIAL_FILE_PREVIEW_SESSIONS_STATE, + reduceFilePreviewSessions, + useFilePreviewSessions, +} from '@/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions' +import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +interface FilePreviewControllerDeps { + workspaceId: string + setResources: Dispatch> + setActiveResourceId: Dispatch> + activeResourceIdRef: MutableRefObject +} + +function asPayloadRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined +} + +/** + * Owns the file-preview state machine for the mothership chat: the preview-session + * store, the streaming-to-committed resource handoff bookkeeping, and translation + * of synthetic preview-phase events into session updates and resource chrome. The + * viewer renders committed binaries (see `useDocPreviewBinary`); this controller + * decides which resource is active and when a generated file is promoted from the + * synthetic `streaming-file` placeholder to its real workspace file. + */ +export function useFilePreviewController({ + workspaceId, + setResources, + setActiveResourceId, + activeResourceIdRef, +}: FilePreviewControllerDeps) { + const queryClient = useQueryClient() + + const previewActivationOwnerRef = useRef>(new Map()) + const completedPreviewResourceHandoffRef = useRef< + Map + >(new Map()) + + const rememberPreviewActivationOwner = useCallback( + (session: FilePreviewSession) => { + if (!session.fileId || previewActivationOwnerRef.current.has(session.id)) { + return + } + previewActivationOwnerRef.current.set(session.id, activeResourceIdRef.current) + }, + [activeResourceIdRef] + ) + + const shouldAutoActivatePreviewSession = useCallback( + (session: FilePreviewSession) => { + if (!session.fileId) { + return false + } + const currentActiveResourceId = activeResourceIdRef.current + const activationOwnerId = previewActivationOwnerRef.current.get(session.id) + return ( + currentActiveResourceId === null || + currentActiveResourceId === session.fileId || + currentActiveResourceId === 'streaming-file' || + currentActiveResourceId === activationOwnerId + ) + }, + [activeResourceIdRef] + ) + + const seedCompletedPreviewContentCache = useCallback( + (fileId: string, previewText: string) => { + queryClient.setQueriesData( + { queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'text') }, + previewText + ) + + const activeFiles = queryClient.getQueryData>( + workspaceFilesKeys.list(workspaceId, 'active') + ) + const fileKey = activeFiles?.find((file) => file.id === fileId)?.key + if (fileKey) { + queryClient.setQueryData( + [...workspaceFilesKeys.content(workspaceId, fileId, 'text'), fileKey], + previewText + ) + } + }, + [queryClient, workspaceId] + ) + + const { + previewSession, + previewSessionsById, + activePreviewSessionId, + hydratePreviewSessions, + upsertPreviewSession, + completePreviewSession, + removePreviewSession, + resetPreviewSessions, + } = useFilePreviewSessions() + const previewSessionRef = useRef(previewSession) + previewSessionRef.current = previewSession + const previewSessionsRef = useRef(previewSessionsById) + previewSessionsRef.current = previewSessionsById + const activePreviewSessionIdRef = useRef(activePreviewSessionId) + activePreviewSessionIdRef.current = activePreviewSessionId + const latestPreviewTargetToolCallIdRef = useRef(null) + const previewSessionsStateRef = useRef({ + activeSessionId: activePreviewSessionId, + sessions: previewSessionsById, + }) + previewSessionsStateRef.current = { + activeSessionId: activePreviewSessionId, + sessions: previewSessionsById, + } + + const syncPreviewSessionRefs = useCallback((nextState: FilePreviewSessionsState) => { + previewSessionsStateRef.current = nextState + previewSessionsRef.current = nextState.sessions + activePreviewSessionIdRef.current = nextState.activeSessionId + previewSessionRef.current = + nextState.activeSessionId !== null + ? (nextState.sessions[nextState.activeSessionId] ?? null) + : null + }, []) + + const applyPreviewSessionUpdate = useCallback( + (session: FilePreviewSession, options?: { activate?: boolean }) => { + const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { + type: 'upsert', + session, + ...(options?.activate === false ? { activate: false } : {}), + }) + syncPreviewSessionRefs(nextState) + upsertPreviewSession(session, options) + return nextState + }, + [syncPreviewSessionRefs, upsertPreviewSession] + ) + + const applyCompletedPreviewSession = useCallback( + (session: FilePreviewSession) => { + const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { + type: 'complete', + session, + }) + syncPreviewSessionRefs(nextState) + completePreviewSession(session) + return nextState + }, + [completePreviewSession, syncPreviewSessionRefs] + ) + + const reconcileTerminalPreviewSessions = useCallback(() => { + const completedAt = new Date().toISOString() + const completedSessions = buildCompletedPreviewSessions( + previewSessionsStateRef.current.sessions, + completedAt + ) + + for (const session of completedSessions) { + applyCompletedPreviewSession(session) + } + }, [applyCompletedPreviewSession]) + + const removePreviewSessionImmediate = useCallback( + (sessionId: string) => { + const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { + type: 'remove', + sessionId, + }) + syncPreviewSessionRefs(nextState) + removePreviewSession(sessionId) + return nextState + }, + [removePreviewSession, syncPreviewSessionRefs] + ) + + const resetEphemeralPreviewState = useCallback( + (options?: { removeStreamingResource?: boolean }) => { + previewActivationOwnerRef.current.clear() + completedPreviewResourceHandoffRef.current.clear() + latestPreviewTargetToolCallIdRef.current = null + syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE) + resetPreviewSessions() + if (options?.removeStreamingResource) { + setResources((current) => current.filter((resource) => resource.id !== 'streaming-file')) + } + }, + [resetPreviewSessions, setResources, syncPreviewSessionRefs] + ) + + const promoteFileResource = useCallback( + (fileId: string, title: string) => { + setResources((current) => { + const withoutStreaming = current.filter((resource) => resource.id !== 'streaming-file') + if ( + withoutStreaming.some((resource) => resource.type === 'file' && resource.id === fileId) + ) { + return withoutStreaming + } + return [...withoutStreaming, { type: 'file', id: fileId, title }] + }) + }, + [setResources] + ) + + const syncPreviewResourceChrome = useCallback( + (session: FilePreviewSession, options?: { activate?: boolean }) => { + if (session.targetKind === 'new_file') { + setResources((current) => { + const existing = current.find((resource) => resource.id === 'streaming-file') + if (existing) { + return current.map((resource) => + resource.id === 'streaming-file' + ? { ...resource, title: session.fileName || 'Writing file...' } + : resource + ) + } + return [ + ...current, + { type: 'file', id: 'streaming-file', title: session.fileName || 'Writing file...' }, + ] + }) + setActiveResourceId('streaming-file') + return + } + + if (session.fileId && hasRenderableFilePreviewContent(session)) { + promoteFileResource(session.fileId, session.fileName || 'File') + if (options?.activate !== false) { + setActiveResourceId(session.fileId) + } + } + }, + [promoteFileResource, setActiveResourceId, setResources] + ) + + const seedPreviewSessions = useCallback( + (sessions: FilePreviewSession[]) => { + if (sessions.length === 0) { + return + } + + const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { + type: 'hydrate', + sessions, + }) + syncPreviewSessionRefs(nextState) + hydratePreviewSessions(sessions) + const active = + nextState.activeSessionId !== null + ? (nextState.sessions[nextState.activeSessionId] ?? null) + : null + if (active) { + syncPreviewResourceChrome(active, { + activate: active.targetKind === 'new_file' || shouldAutoActivatePreviewSession(active), + }) + } + }, + [ + hydratePreviewSessions, + shouldAutoActivatePreviewSession, + syncPreviewResourceChrome, + syncPreviewSessionRefs, + ] + ) + + const onPreviewPhase = useCallback( + (payload: SyntheticFilePreviewPayload, streamId: string | undefined) => { + const id = payload.toolCallId + const prevSession = previewSessionsRef.current[id] + const nextSession = deriveFilePreviewSession( + prevSession, + payload, + streamId, + new Date().toISOString() + ) + + if (payload.previewPhase === 'file_preview_start') { + latestPreviewTargetToolCallIdRef.current = id + applyPreviewSessionUpdate(nextSession) + return + } + + if (payload.previewPhase === 'file_preview_target') { + latestPreviewTargetToolCallIdRef.current = id + rememberPreviewActivationOwner(nextSession) + const nextState = applyPreviewSessionUpdate(nextSession) + const activePreview = + nextState.activeSessionId !== null + ? (nextState.sessions[nextState.activeSessionId] ?? null) + : null + if (activePreview?.id === nextSession.id) { + syncPreviewResourceChrome(activePreview, { + activate: + activePreview.targetKind === 'new_file' || + shouldAutoActivatePreviewSession(activePreview), + }) + } + return + } + + if (payload.previewPhase === 'file_preview_edit_meta') { + applyPreviewSessionUpdate(nextSession) + return + } + + if (payload.previewPhase === 'file_preview_content') { + applyPreviewSessionUpdate(nextSession) + if (!prevSession || !hasRenderableFilePreviewContent(prevSession)) { + syncPreviewResourceChrome(nextSession, { + activate: + nextSession.targetKind === 'new_file' || + shouldAutoActivatePreviewSession(nextSession), + }) + } + return + } + + const resultData = asPayloadRecord(payload.output) + const outputData = asPayloadRecord(resultData?.data) ?? resultData + const wasRenderableBeforeComplete = + prevSession !== undefined && hasRenderableFilePreviewContent(prevSession) + const nextState = applyCompletedPreviewSession(nextSession) + const fileId = nextSession.fileId + + if (fileId && resultData?.success === true && outputData?.id === fileId) { + const fileName = (outputData.name as string) ?? nextSession.fileName ?? 'File' + promoteFileResource(fileId, fileName) + const completedExt = fileName.includes('.') + ? (fileName.split('.').pop()?.toLowerCase() ?? '') + : '' + const isCompiledDocPreview = ['docx', 'pptx', 'pdf', 'xlsx'].includes(completedExt) + const shouldActivateOnComplete = + (isCompiledDocPreview || + (!wasRenderableBeforeComplete && hasRenderableFilePreviewContent(nextSession))) && + shouldAutoActivatePreviewSession(nextSession) + if (shouldActivateOnComplete) { + setActiveResourceId(fileId) + } + completedPreviewResourceHandoffRef.current.set(fileId, { + sessionId: nextSession.id, + suppressActivation: !shouldActivateOnComplete, + }) + if (hasRenderableFilePreviewContent(nextSession)) { + seedCompletedPreviewContentCache(fileId, nextSession.previewText) + } + invalidateResourceQueries(queryClient, workspaceId, 'file', fileId) + } else { + const activePreview = + nextState.activeSessionId !== null + ? (nextState.sessions[nextState.activeSessionId] ?? null) + : null + if (activePreview) { + syncPreviewResourceChrome(activePreview, { + activate: + activePreview.targetKind === 'new_file' || + shouldAutoActivatePreviewSession(activePreview), + }) + } + } + }, + [ + applyCompletedPreviewSession, + applyPreviewSessionUpdate, + promoteFileResource, + queryClient, + rememberPreviewActivationOwner, + seedCompletedPreviewContentCache, + setActiveResourceId, + shouldAutoActivatePreviewSession, + syncPreviewResourceChrome, + workspaceId, + ] + ) + + return { + previewSession, + previewSessionRef, + previewSessionsRef, + activePreviewSessionIdRef, + latestPreviewTargetToolCallIdRef, + previewActivationOwnerRef, + completedPreviewResourceHandoffRef, + shouldAutoActivatePreviewSession, + applyPreviewSessionUpdate, + removePreviewSessionImmediate, + reconcileTerminalPreviewSessions, + resetEphemeralPreviewState, + promoteFileResource, + seedPreviewSessions, + onPreviewPhase, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx rename to apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx index d5b65086436..b71d819e8fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx @@ -7,9 +7,10 @@ import { buildCompletedPreviewSessions, hasRenderableFilePreviewContent, INITIAL_FILE_PREVIEW_SESSIONS_STATE, + pickActiveSessionId, reduceFilePreviewSessions, shouldReplaceSession, -} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions' +} from '@/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions' function createSession( overrides: Partial & Pick @@ -275,6 +276,7 @@ describe('reduceFilePreviewSessions', () => { }) expect(afterEmptyUpsert.activeSessionId).toBe('preview-1') + expect(pickActiveSessionId(afterEmptyUpsert.sessions, null)).toBe('preview-2') const afterContent = reduceFilePreviewSessions(afterEmptyUpsert, { type: 'upsert', diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions.ts rename to apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts new file mode 100644 index 00000000000..834abbf108c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { StreamLoopContext } from './stream-context' + +const handlers = vi.hoisted(() => ({ + handleSessionEvent: vi.fn(), + handleTextEvent: vi.fn(), + handleToolEvent: vi.fn(), + handleResourceEvent: vi.fn(), + handleRunEvent: vi.fn(), + handleSpanEvent: vi.fn(), + handleErrorEvent: vi.fn(), + handleCompleteEvent: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event', () => ({ + handleSessionEvent: handlers.handleSessionEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event', () => ({ + handleTextEvent: handlers.handleTextEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event', () => ({ + handleToolEvent: handlers.handleToolEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event', () => ({ + handleResourceEvent: handlers.handleResourceEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event', () => ({ + handleRunEvent: handlers.handleRunEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event', () => ({ + handleSpanEvent: handlers.handleSpanEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event', () => ({ + handleErrorEvent: handlers.handleErrorEvent, +})) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event', () => ({ + handleCompleteEvent: handlers.handleCompleteEvent, +})) + +import { dispatchStreamEvent } from './dispatch-stream-event' + +function makeCtx(): StreamLoopContext { + return { + state: {} as StreamLoopContext['state'], + deps: {} as StreamLoopContext['deps'], + ops: { + resolveScopedSubagent: vi.fn(() => 'sub-agent'), + } as unknown as StreamLoopContext['ops'], + } +} + +function event(type: string, scope?: Record): PersistedStreamEventEnvelope { + return { + type, + payload: {}, + scope, + seq: 1, + stream: { streamId: 's' }, + ts: '2026-01-01T00:00:00Z', + v: 1, + } as unknown as PersistedStreamEventEnvelope +} + +describe('dispatchStreamEvent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes every event type to exactly its handler', () => { + const ctx = makeCtx() + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.session)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.text)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.tool)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.resource)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.run)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.span)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.error)) + dispatchStreamEvent(ctx, event(MothershipStreamV1EventType.complete)) + + expect(handlers.handleSessionEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleTextEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleToolEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleResourceEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleRunEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleSpanEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleErrorEvent).toHaveBeenCalledTimes(1) + expect(handlers.handleCompleteEvent).toHaveBeenCalledTimes(1) + }) + + it('computes and passes per-event scope to scoped handlers', () => { + const ctx = makeCtx() + dispatchStreamEvent( + ctx, + event(MothershipStreamV1EventType.text, { spanId: 'span-9', agentId: 'agent-x' }) + ) + expect(ctx.ops.resolveScopedSubagent).toHaveBeenCalledWith('agent-x', undefined, 'span-9') + const call = handlers.handleTextEvent.mock.calls[0] + expect(call[0]).toBe(ctx) + const scope = call[2] + expect(scope.scopedSpanId).toBe('span-9') + expect(scope.scopedAgentId).toBe('agent-x') + expect(scope.scopedSubagent).toBe('sub-agent') + expect(scope.spanIdentity).toEqual({ spanId: 'span-9' }) + }) + + it('invokes ctx-only handlers (session/run/complete) without a scope argument', () => { + const ctx = makeCtx() + const sessionEvent = event(MothershipStreamV1EventType.session) + dispatchStreamEvent(ctx, sessionEvent) + expect(handlers.handleSessionEvent).toHaveBeenCalledWith(ctx, sessionEvent) + expect(handlers.handleSessionEvent.mock.calls[0]).toHaveLength(2) + }) + + it('ignores unknown event types without throwing', () => { + const ctx = makeCtx() + expect(() => dispatchStreamEvent(ctx, event('totally-unknown'))).not.toThrow() + expect(handlers.handleSessionEvent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts new file mode 100644 index 00000000000..dace38d8e90 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts @@ -0,0 +1,82 @@ +import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { handleCompleteEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event' +import { handleErrorEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event' +import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' +import { handleRunEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event' +import { handleSessionEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event' +import { handleSpanEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event' +import { handleTextEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event' +import { handleToolEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event' +import type { + StreamEventScope, + StreamLoopContext, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' + +function computeEventScope( + ctx: StreamLoopContext, + parsed: PersistedStreamEventEnvelope +): StreamEventScope { + const scopedParentToolCallId = + typeof parsed.scope?.parentToolCallId === 'string' ? parsed.scope.parentToolCallId : undefined + const scopedAgentId = typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined + const scopedSpanId = typeof parsed.scope?.spanId === 'string' ? parsed.scope.spanId : undefined + const scopedParentSpanId = + typeof parsed.scope?.parentSpanId === 'string' ? parsed.scope.parentSpanId : undefined + const scopedSubagent = ctx.ops.resolveScopedSubagent( + scopedAgentId, + scopedParentToolCallId, + scopedSpanId + ) + const spanIdentity: { spanId?: string; parentSpanId?: string } = { + ...(scopedSpanId ? { spanId: scopedSpanId } : {}), + ...(scopedParentSpanId ? { parentSpanId: scopedParentSpanId } : {}), + } + return { + scopedSubagent, + scopedParentToolCallId, + scopedAgentId, + scopedSpanId, + scopedParentSpanId, + spanIdentity, + } +} + +/** + * Routes a parsed stream event to its handler. Per-event subagent/span scope is + * resolved once here and passed to the handlers that nest blocks by it. The + * caller's transport loop owns staleness, cursor dedup, and `streamId`/ + * `streamRequestId` updates; this function only mutates the supplied context. + */ +export function dispatchStreamEvent( + ctx: StreamLoopContext, + parsed: PersistedStreamEventEnvelope +): void { + const scope = computeEventScope(ctx, parsed) + switch (parsed.type) { + case MothershipStreamV1EventType.session: + handleSessionEvent(ctx, parsed) + break + case MothershipStreamV1EventType.text: + handleTextEvent(ctx, parsed, scope) + break + case MothershipStreamV1EventType.tool: + handleToolEvent(ctx, parsed, scope) + break + case MothershipStreamV1EventType.resource: + handleResourceEvent(ctx, parsed) + break + case MothershipStreamV1EventType.run: + handleRunEvent(ctx, parsed) + break + case MothershipStreamV1EventType.span: + handleSpanEvent(ctx, parsed, scope) + break + case MothershipStreamV1EventType.error: + handleErrorEvent(ctx, parsed, scope) + break + case MothershipStreamV1EventType.complete: + handleCompleteEvent(ctx, parsed) + break + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts new file mode 100644 index 00000000000..2c6254ac7c6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts @@ -0,0 +1,25 @@ +import { MothershipStreamV1CompletionStatus } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { + asPayloadRecord, + finalizeResidualToolCalls, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' + +type CompleteEvent = Extract + +export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEvent): void { + const { state, ops } = ctx + state.sawCompleteEvent = true + ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) + const completeResponse = asPayloadRecord(parsed.payload.response) + if (completeResponse === undefined || !('async_pause' in completeResponse)) { + finalizeResidualToolCalls( + state.blocks, + parsed.payload.status === MothershipStreamV1CompletionStatus.cancelled + ? 'cancelled' + : 'complete' + ) + ops.flush() + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts new file mode 100644 index 00000000000..6c1d00f42bf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts @@ -0,0 +1,23 @@ +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { + StreamEventScope, + StreamLoopContext, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' + +type ErrorEvent = Extract + +export function handleErrorEvent( + ctx: StreamLoopContext, + parsed: ErrorEvent, + scope: StreamEventScope +): void { + const { state, ops, deps } = ctx + state.sawStreamError = true + deps.setError(parsed.payload.message || parsed.payload.error || 'An error occurred') + ops.appendInlineErrorTag( + ops.buildInlineErrorTag(parsed.payload), + scope.scopedSubagent, + ops.resolveParentForSubagentBlock(scope.scopedSubagent, scope.scopedParentToolCallId), + typeof parsed.ts === 'string' ? parsed.ts : undefined + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts new file mode 100644 index 00000000000..b94384a8b34 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -0,0 +1,134 @@ +import { + type MothershipStreamV1EventType, + MothershipStreamV1ResourceOp, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { FilePreviewSession } from '@/lib/copilot/request/session' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { + hasRenderableFilePreviewContent, + shouldReplaceSession, +} from '@/app/workspace/[workspaceId]/home/hooks/preview' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +type ResourceEvent = Extract< + PersistedStreamEventEnvelope, + { type: typeof MothershipStreamV1EventType.resource } +> + +/** + * Applies a streamed resource upsert/remove to the mothership resource list, + * reconciling it with any in-flight or just-completed file-preview handoff so a + * generated file is not activated out from under the user while its preview is + * still streaming. Workflow resources are mirrored into the workflow registry. + */ +export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEvent): void { + const { + workspaceId, + queryClient, + addResource, + removeResource, + setResources, + setActiveResourceId, + resourcesRef, + activeResourceIdRef, + previewSessionsRef, + completedPreviewResourceHandoffRef, + previewActivationOwnerRef, + shouldAutoActivatePreviewSession, + ensureWorkflowInRegistry, + onResourceEventRef, + } = ctx.deps + const onResourceEvent = onResourceEventRef.current + const payload = parsed.payload + const resource = payload.resource + + if (payload.op === MothershipStreamV1ResourceOp.remove) { + removeResource(resource.type as MothershipResourceType, resource.id) + invalidateResourceQueries( + queryClient, + workspaceId, + resource.type as MothershipResourceType, + resource.id + ) + onResourceEvent?.() + return + } + + const nextResource = { + type: resource.type as MothershipResourceType, + id: resource.id, + title: typeof resource.title === 'string' ? resource.title : resource.id, + } + const completedPreviewHandoff = + nextResource.type === 'file' + ? completedPreviewResourceHandoffRef.current.get(nextResource.id) + : undefined + const matchingPreviewSessions = + nextResource.type === 'file' + ? Object.values(previewSessionsRef.current).filter( + (session) => session.fileId === nextResource.id + ) + : [] + const latestPreviewForResource = ( + sessions: FilePreviewSession[] + ): FilePreviewSession | undefined => + sessions.reduce( + (latest, session) => (shouldReplaceSession(latest, session) ? session : latest), + undefined + ) + const latestActivePreviewForResource = latestPreviewForResource( + matchingPreviewSessions.filter((session) => session.status !== 'complete') + ) + const previewForResource = + latestActivePreviewForResource ?? latestPreviewForResource(matchingPreviewSessions) + const isCompletedPreviewHandoffCurrent = + completedPreviewHandoff !== undefined && + (!latestActivePreviewForResource || + latestActivePreviewForResource.id === completedPreviewHandoff.sessionId) + if (completedPreviewHandoff && !isCompletedPreviewHandoffCurrent) { + completedPreviewResourceHandoffRef.current.delete(nextResource.id) + previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) + } + const shouldSuppressFileResourceActivation = + (isCompletedPreviewHandoffCurrent && completedPreviewHandoff?.suppressActivation === true) || + (previewForResource !== undefined && + previewForResource.status !== 'complete' && + (!hasRenderableFilePreviewContent(previewForResource) || + !shouldAutoActivatePreviewSession(previewForResource))) + const wasAdded = shouldSuppressFileResourceActivation + ? !resourcesRef.current.some((r) => r.type === nextResource.type && r.id === nextResource.id) + : addResource(nextResource) + if (shouldSuppressFileResourceActivation && wasAdded) { + setResources((current) => + current.some((r) => r.type === nextResource.type && r.id === nextResource.id) + ? current + : [...current, nextResource] + ) + } + if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) { + completedPreviewResourceHandoffRef.current.delete(nextResource.id) + previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) + } + invalidateResourceQueries(queryClient, workspaceId, nextResource.type, nextResource.id) + + if ( + !shouldSuppressFileResourceActivation && + !wasAdded && + activeResourceIdRef.current !== nextResource.id + ) { + setActiveResourceId(nextResource.id) + } + onResourceEvent?.() + + if (nextResource.type === 'workflow') { + const wasRegistered = ensureWorkflowInRegistry(nextResource.id, nextResource.title, workspaceId) + if (wasAdded && wasRegistered) { + useWorkflowRegistry.getState().setActiveWorkflow(nextResource.id) + } else { + useWorkflowRegistry.getState().loadWorkflowState(nextResource.id) + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts new file mode 100644 index 00000000000..f8c0d2df81d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts @@ -0,0 +1,55 @@ +import { MothershipStreamV1RunKind } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' + +type RunEvent = Extract + +export function handleRunEvent(ctx: StreamLoopContext, parsed: RunEvent): void { + const { state, ops } = ctx + const payload = parsed.payload + + if (payload.kind === MothershipStreamV1RunKind.compaction_start) { + const compactionId = `compaction_${Date.now()}` + state.activeCompactionId = compactionId + ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) + state.toolMap.set(compactionId, state.blocks.length) + state.blocks.push({ + type: 'tool_call', + toolCall: { + id: compactionId, + name: 'context_compaction', + status: 'executing', + displayTitle: 'Compacting context...', + }, + timestamp: Date.now(), + }) + ops.flush() + return + } + + if (payload.kind === MothershipStreamV1RunKind.compaction_done) { + const compactionId = state.activeCompactionId || `compaction_${Date.now()}` + state.activeCompactionId = undefined + const idx = state.toolMap.get(compactionId) + if (idx !== undefined && state.blocks[idx]?.toolCall) { + state.blocks[idx].toolCall!.status = 'success' + state.blocks[idx].toolCall!.displayTitle = 'Compacted context' + ops.stampBlockEnd(state.blocks[idx]) + } else { + state.toolMap.set(compactionId, state.blocks.length) + const endNow = Date.now() + state.blocks.push({ + type: 'tool_call', + toolCall: { + id: compactionId, + name: 'context_compaction', + status: 'success', + displayTitle: 'Compacted context', + }, + timestamp: endNow, + endedAt: endNow, + }) + } + ops.flush() + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts new file mode 100644 index 00000000000..b9484105903 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts @@ -0,0 +1,69 @@ +import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' + +type SessionEvent = Extract + +export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent): void { + const { deps } = ctx + const payload = parsed.payload + const payloadChatId = + payload.kind === MothershipStreamV1SessionKind.chat + ? payload.chatId + : typeof parsed.stream?.chatId === 'string' + ? parsed.stream.chatId + : undefined + + if (payload.kind === MothershipStreamV1SessionKind.chat && payloadChatId) { + const isNewChat = !deps.chatIdRef.current + deps.chatIdRef.current = payloadChatId + const selected = deps.selectedChatIdRef.current + if (selected == null) { + if (isNewChat) { + deps.setResolvedChatId(payloadChatId) + } + } else if (payloadChatId === selected) { + deps.setResolvedChatId(payloadChatId) + } + deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) }) + if (isNewChat) { + const userMsg = deps.pendingUserMsgRef.current + const activeStreamId = deps.streamIdRef.current + if (userMsg && activeStreamId) { + const assistantMessage = deps.buildAssistantSnapshotMessage({ + id: + deps.activeTurnRef.current?.assistantMessageId ?? + getLiveAssistantMessageId(activeStreamId), + content: deps.streamingContentRef.current, + contentBlocks: deps.streamingBlocksRef.current, + }) + const seededMessages = [userMsg, assistantMessage] + deps.queryClient.setQueryData( + mothershipChatKeys.detail(payloadChatId), + { + id: payloadChatId, + title: null, + messages: seededMessages, + activeStreamId, + resources: deps.resourcesRef.current, + } + ) + } + deps.setPendingMessages([]) + if (!deps.workflowIdRef.current) { + window.history.replaceState( + null, + '', + `/workspace/${deps.workspaceId}/chat/${payloadChatId}` + ) + } + } + } + + if (payload.kind === MothershipStreamV1SessionKind.title) { + deps.queryClient.invalidateQueries({ queryKey: mothershipChatKeys.list(deps.workspaceId) }) + deps.onTitleUpdateRef.current?.() + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts new file mode 100644 index 00000000000..49db0877be5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts @@ -0,0 +1,149 @@ +import { + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { + StreamEventScope, + StreamLoopContext, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { + asPayloadRecord, + FILE_SUBAGENT_ID, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' + +type SpanEvent = Extract + +export function handleSpanEvent( + ctx: StreamLoopContext, + parsed: SpanEvent, + scope: StreamEventScope +): void { + const { state, ops, deps } = ctx + const { scopedParentToolCallId, scopedAgentId, scopedSpanId, spanIdentity } = scope + const payload = parsed.payload + if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) { + return + } + const spanData = asPayloadRecord(payload.data) + const parentToolCallIdFromData = + typeof spanData?.tool_call_id === 'string' + ? spanData.tool_call_id + : typeof spanData?.toolCallId === 'string' + ? spanData.toolCallId + : undefined + const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData + const isPendingPause = spanData?.pending === true + const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId + + if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) { + const existingOpenForSpan = scopedSpanId + ? state.blocks.some( + (b) => b.type === 'subagent' && b.spanId === scopedSpanId && b.endedAt === undefined + ) + : false + const isSameActiveSubagent = + existingOpenForSpan || + (!scopedSpanId && + state.activeSubagent === name && + Boolean(state.activeSubagentParentToolCallId) && + parentToolCallId === state.activeSubagentParentToolCallId) + if (scopedSpanId) { + state.subagentBySpanId.set(scopedSpanId, name) + } + if (parentToolCallId) { + state.subagentByParentToolCallId.set(parentToolCallId, name) + } + state.activeSubagent = name + state.activeSubagentParentToolCallId = parentToolCallId + if (!isSameActiveSubagent) { + ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) + state.blocks.push({ + type: 'subagent', + content: name, + ...(parentToolCallId ? { parentToolCallId } : {}), + ...spanIdentity, + timestamp: Date.now(), + }) + } + if (name === FILE_SUBAGENT_ID && !isSameActiveSubagent) { + deps.applyPreviewSessionUpdate({ + schemaVersion: 1, + id: parentToolCallId || 'file-preview', + streamId: deps.streamIdRef.current ?? '', + toolCallId: parentToolCallId || 'file-preview', + status: 'pending', + fileName: '', + previewText: '', + previewVersion: 0, + updatedAt: new Date().toISOString(), + }) + } + ops.flush() + return + } + + if (payload.event === MothershipStreamV1SpanLifecycleEvent.end) { + if (isPendingPause) { + return + } + if (scopedSpanId) { + state.subagentBySpanId.delete(scopedSpanId) + } + if (parentToolCallId) { + state.subagentByParentToolCallId.delete(parentToolCallId) + } + if ( + deps.previewSessionRef.current && + (!deps.activePreviewSessionIdRef.current || + deps.previewSessionRef.current.status === 'complete') + ) { + const lastFileResource = deps.resourcesRef.current.find( + (r) => r.type === 'file' && r.id !== 'streaming-file' + ) + deps.setResources((rs) => rs.filter((r) => r.id !== 'streaming-file')) + if (lastFileResource) { + deps.setActiveResourceId(lastFileResource.id) + } + } + if ( + !parentToolCallId || + parentToolCallId === state.activeSubagentParentToolCallId || + name === state.activeSubagent + ) { + state.activeSubagent = undefined + state.activeSubagentParentToolCallId = undefined + } + const endNow = Date.now() + if (scopedSpanId) { + for (let i = state.blocks.length - 1; i >= 0; i--) { + const b = state.blocks[i] + if (b.type === 'subagent' && b.spanId === scopedSpanId && b.endedAt === undefined) { + b.endedAt = endNow + break + } + } + } else if (name) { + for (let i = state.blocks.length - 1; i >= 0; i--) { + const b = state.blocks[i] + if ( + b.type === 'subagent' && + b.content === name && + b.endedAt === undefined && + (!parentToolCallId || b.parentToolCallId === parentToolCallId) + ) { + b.endedAt = endNow + break + } + } + } + ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) + state.blocks.push({ + type: 'subagent_end', + ...(parentToolCallId ? { parentToolCallId } : {}), + ...spanIdentity, + timestamp: endNow, + }) + ops.flush() + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts new file mode 100644 index 00000000000..766c0aa1fe9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts @@ -0,0 +1,51 @@ +import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { + StreamEventScope, + StreamLoopContext, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' + +type TextEvent = Extract + +export function handleTextEvent( + ctx: StreamLoopContext, + parsed: TextEvent, + scope: StreamEventScope +): void { + const { state, ops, deps } = ctx + const { scopedSubagent, scopedParentToolCallId, spanIdentity } = scope + + const chunk = parsed.payload.text + if (!chunk) return + + const eventTs = typeof parsed.ts === 'string' ? parsed.ts : undefined + + if (parsed.payload.channel === MothershipStreamV1TextChannel.thinking) { + const scopedParentForBlock = ops.resolveParentForSubagentBlock( + scopedSubagent, + scopedParentToolCallId + ) + const tb = ops.ensureThinkingBlock(scopedSubagent, scopedParentForBlock, eventTs, spanIdentity) + tb.content = (tb.content ?? '') + chunk + ops.flushText() + return + } + + const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main' + const needsBoundaryNewline = + state.lastContentSource !== null && + state.lastContentSource !== contentSource && + state.runningText.length > 0 && + !state.runningText.endsWith('\n') + const scopedParentForBlock = ops.resolveParentForSubagentBlock( + scopedSubagent, + scopedParentToolCallId + ) + const tb = ops.ensureTextBlock(scopedSubagent, scopedParentForBlock, eventTs, spanIdentity) + const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk + tb.content = (tb.content ?? '') + normalizedChunk + state.runningText += normalizedChunk + state.lastContentSource = contentSource + deps.streamingContentRef.current = state.runningText + ops.flushText() +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts new file mode 100644 index 00000000000..0dd762aa3a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/copilot/resources/extraction', () => ({ + isResourceToolName: vi.fn(() => false), + extractResourcesFromToolResult: vi.fn(() => []), +})) +vi.mock('@/lib/copilot/tools/client/hidden-tools', () => ({ + isToolHiddenInUi: vi.fn(() => false), +})) +vi.mock('@/lib/copilot/tools/workflow-tools', () => ({ + isWorkflowToolName: vi.fn(() => false), +})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry', + () => ({ invalidateResourceQueries: vi.fn() }) +) + +import { handleToolEvent } from './handle-tool-event' +import { createStreamLoopContext, type StreamEventScope } from './stream-context' +import { makeStreamLoopDeps } from './stream-test-helpers' + +const SCOPE: StreamEventScope = { + scopedSubagent: undefined, + scopedParentToolCallId: undefined, + scopedAgentId: undefined, + scopedSpanId: undefined, + scopedParentSpanId: undefined, + spanIdentity: {}, +} + +type ToolEvent = Parameters[1] + +function toolCall(id: string, name = 'my_tool'): ToolEvent { + return { + type: 'tool', + v: 1, + seq: 1, + ts: '2026-01-01T00:00:00Z', + stream: { streamId: 's' }, + payload: { phase: 'call', executor: 'go', mode: 'sync', toolCallId: id, toolName: name }, + } as unknown as ToolEvent +} + +function toolResult(id: string, success: boolean, name = 'my_tool'): ToolEvent { + return { + type: 'tool', + v: 1, + seq: 2, + ts: '2026-01-01T00:00:01Z', + stream: { streamId: 's' }, + payload: { + phase: 'result', + executor: 'go', + mode: 'sync', + toolCallId: id, + toolName: name, + success, + status: success ? 'success' : 'error', + }, + } as unknown as ToolEvent +} + +describe('handleToolEvent', () => { + it('adds an executing tool_call block on a new call and resolves it on the result', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + handleToolEvent(ctx, toolCall('tc-1'), SCOPE) + expect(ctx.state.blocks).toHaveLength(1) + expect(ctx.state.blocks[0].toolCall?.id).toBe('tc-1') + expect(ctx.state.blocks[0].toolCall?.status).toBe('executing') + + handleToolEvent(ctx, toolResult('tc-1', true), SCOPE) + expect(ctx.state.blocks[0].toolCall?.status).toBe('success') + expect(ctx.state.blocks[0].endedAt).toBeTypeOf('number') + }) + + it('buffers a result that arrives before its call, then applies it when the call lands', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + handleToolEvent(ctx, toolResult('tc-2', true), SCOPE) + expect(ctx.state.blocks).toHaveLength(0) + expect(ctx.state.pendingToolResults.has('tc-2')).toBe(true) + + handleToolEvent(ctx, toolCall('tc-2'), SCOPE) + expect(ctx.state.blocks).toHaveLength(1) + expect(ctx.state.blocks[0].toolCall?.status).toBe('success') + expect(ctx.state.pendingToolResults.has('tc-2')).toBe(false) + }) + + it('marks an unsuccessful result as error', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + handleToolEvent(ctx, toolCall('tc-3'), SCOPE) + handleToolEvent(ctx, toolResult('tc-3', false), SCOPE) + expect(ctx.state.blocks[0].toolCall?.status).toBe('error') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts new file mode 100644 index 00000000000..20e36e0b145 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -0,0 +1,296 @@ +import { + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, + MothershipStreamV1ToolStatus, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { + extractResourcesFromToolResult, + isResourceToolName, +} from '@/lib/copilot/resources/extraction' +import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' +import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import type { + StreamEventScope, + StreamLoopContext, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import { + asPayloadRecord, + DEPLOY_TOOL_NAMES, + extractResourceFromReadResult, + FILE_SUBAGENT_ID, + FOLDER_TOOL_NAMES, + getToolUI, + isTerminalToolCallStatus, + resolveLiveToolStatus, + resolveStreamingToolDisplayTitle, + resolveToolDisplayTitle, + type ToolResultPhasePayload, + WORKFLOW_MUTATION_TOOL_NAMES, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' +import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { deploymentKeys } from '@/hooks/queries/deployments' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { workflowKeys } from '@/hooks/queries/workflows' + +type ToolEvent = Extract + +function applyToolResult( + ctx: StreamLoopContext, + idx: number, + id: string, + payload: ToolResultPhasePayload +): void { + const { state, ops, deps } = ctx + const tc = state.blocks[idx].toolCall! + const outputObj = asPayloadRecord(payload.output) + const isCancelled = + outputObj?.reason === 'user_cancelled' || + outputObj?.cancelledByUser === true || + payload.status === MothershipStreamV1ToolOutcome.cancelled + const status = isCancelled ? ToolCallStatus.cancelled : resolveLiveToolStatus(payload) + const isSuccess = status === ToolCallStatus.success + + if (status === ToolCallStatus.cancelled) { + tc.status = ToolCallStatus.cancelled + tc.displayTitle = 'Stopped by user' + } else { + tc.status = status + } + tc.streamingArgs = undefined + tc.result = { + success: isSuccess, + output: payload.output, + error: typeof payload.error === 'string' ? payload.error : undefined, + } + ops.stampBlockEnd(state.blocks[idx]) + ops.flush() + + if (tc.name === ReadTool.id && tc.status === 'success') { + const readArgs = state.toolArgsMap.get(id) + const resource = extractResourceFromReadResult( + typeof readArgs?.path === 'string' ? readArgs.path : undefined, + tc.result.output + ) + if (resource && deps.addResource(resource)) { + deps.onResourceEventRef.current?.() + } + } + + if (DEPLOY_TOOL_NAMES.has(tc.name) && tc.status === 'success') { + const output = tc.result?.output as Record | undefined + const deployedWorkflowId = (output?.workflowId as string) ?? undefined + if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') { + deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.info(deployedWorkflowId) }) + deps.queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(deployedWorkflowId) }) + deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) }) + } + } + + if (FOLDER_TOOL_NAMES.has(tc.name) && tc.status === 'success') { + deps.queryClient.invalidateQueries({ queryKey: folderKeys.list(deps.workspaceId) }) + } + if (WORKFLOW_MUTATION_TOOL_NAMES.has(tc.name) && tc.status === 'success') { + deps.queryClient.invalidateQueries({ queryKey: workflowKeys.list(deps.workspaceId) }) + } + + const extractedResources = + tc.status === 'success' && isResourceToolName(tc.name) + ? extractResourcesFromToolResult( + tc.name, + state.toolArgsMap.get(id) as Record | undefined, + tc.result?.output + ) + : [] + + for (const resource of extractedResources) { + invalidateResourceQueries(deps.queryClient, deps.workspaceId, resource.type, resource.id) + } + + if ((tc.name === 'edit_content' || tc.name === WorkspaceFile.id) && tc.status === 'success') { + const editOutput = tc.result?.output as Record | undefined + const editData = + editOutput && typeof editOutput.data === 'object' && editOutput.data !== null + ? (editOutput.data as Record) + : undefined + const editedFileId = + (typeof editData?.id === 'string' ? editData.id : undefined) ?? + deps.previewSessionRef.current?.fileId + if (editedFileId) { + const editedFileName = + (typeof editData?.name === 'string' ? editData.name : undefined) ?? + deps.previewSessionRef.current?.fileName ?? + 'File' + deps.promoteFileResource(editedFileId, editedFileName) + if ( + deps.activeResourceIdRef.current === null || + deps.activeResourceIdRef.current === 'streaming-file' || + deps.activeResourceIdRef.current === editedFileId + ) { + deps.setActiveResourceId(editedFileId) + } + invalidateResourceQueries(deps.queryClient, deps.workspaceId, 'file', editedFileId) + } + } + + deps.onToolResultRef.current?.(tc.name, tc.status === 'success', tc.result?.output) + + const workspaceFileOperation = + tc.name === WorkspaceFile.id && typeof tc.params?.operation === 'string' + ? tc.params.operation + : undefined + const shouldKeepWorkspacePreviewOpen = + tc.name === WorkspaceFile.id && + (workspaceFileOperation === 'append' || + workspaceFileOperation === 'update' || + workspaceFileOperation === 'patch') + + if ( + (tc.name === WorkspaceFile.id || tc.name === 'edit_content') && + !shouldKeepWorkspacePreviewOpen + ) { + if (tc.name === WorkspaceFile.id) { + deps.removePreviewSessionImmediate(id) + } + const fileResource = extractedResources.find((r) => r.type === 'file') + if (fileResource) { + deps.promoteFileResource(fileResource.id, fileResource.title) + deps.setActiveResourceId(fileResource.id) + invalidateResourceQueries(deps.queryClient, deps.workspaceId, 'file', fileResource.id) + } else if (tc.calledBy !== FILE_SUBAGENT_ID) { + deps.setResources((rs) => rs.filter((r) => r.id !== 'streaming-file')) + } + } +} + +export function handleToolEvent( + ctx: StreamLoopContext, + parsed: ToolEvent, + scope: StreamEventScope +): void { + const { state, ops, deps } = ctx + const { scopedSubagent, scopedParentToolCallId, spanIdentity } = scope + const payload = parsed.payload + const id = payload.toolCallId + + if ('previewPhase' in payload) { + deps.onPreviewPhase(payload, parsed.stream?.streamId) + return + } + + if (payload.phase === MothershipStreamV1ToolPhase.args_delta) { + const delta = payload.argumentsDelta + if (!delta) return + + const idx = state.toolMap.get(id) + if (idx !== undefined && state.blocks[idx].toolCall) { + const tc = state.blocks[idx].toolCall! + tc.streamingArgs = (tc.streamingArgs ?? '') + delta + const displayTitle = resolveStreamingToolDisplayTitle(tc.name, tc.streamingArgs) + if (displayTitle) tc.displayTitle = displayTitle + + ops.flush() + } + return + } + + if (payload.phase === MothershipStreamV1ToolPhase.result) { + const idx = state.toolMap.get(id) + if (idx === undefined || !state.blocks[idx].toolCall) { + state.pendingToolResults.set(id, payload) + return + } + applyToolResult(ctx, idx, id, payload) + return + } + + const name = payload.toolName + const isPartial = + payload.partial === true || payload.status === MothershipStreamV1ToolStatus.generating + if (isToolHiddenInUi(name)) { + return + } + const ui = getToolUI(payload.ui) + if (ui?.hidden) return + let displayTitle = ui?.title + const args = payload.arguments as Record | undefined + + displayTitle = resolveToolDisplayTitle(name, args) ?? displayTitle + + if (name === 'edit_content') { + const parentToolCallId = deps.latestPreviewTargetToolCallIdRef.current + const parentIdx = parentToolCallId !== null ? state.toolMap.get(parentToolCallId) : undefined + const parentToolCall = parentIdx !== undefined ? state.blocks[parentIdx].toolCall : undefined + const parentPreviewSession = + parentToolCallId !== null ? deps.previewSessionsRef.current[parentToolCallId] : undefined + const canReuseParentRow = + parentToolCall !== undefined && + (!isTerminalToolCallStatus(parentToolCall.status) || + (parentToolCall.status === ToolCallStatus.success && + parentPreviewSession !== undefined && + parentPreviewSession.status !== 'complete')) + if (parentIdx !== undefined && parentToolCall && canReuseParentRow) { + state.toolMap.set(id, parentIdx) + parentToolCall.status = 'executing' + parentToolCall.result = undefined + ops.flush() + return + } + } + + const existingToolCall = state.toolMap.has(id) + ? state.blocks[state.toolMap.get(id)!]?.toolCall + : undefined + const isNewToolCall = !existingToolCall + if (isNewToolCall) { + ops.stampBlockEnd(state.blocks[state.blocks.length - 1]) + state.toolMap.set(id, state.blocks.length) + const parentToolCallIdForBlock = ops.resolveParentForSubagentBlock( + scopedSubagent, + scopedParentToolCallId + ) + state.blocks.push({ + type: 'tool_call', + toolCall: { + id, + name, + status: 'executing', + displayTitle, + params: args, + calledBy: scopedSubagent, + }, + ...(parentToolCallIdForBlock ? { parentToolCallId: parentToolCallIdForBlock } : {}), + ...spanIdentity, + timestamp: Date.now(), + }) + if (name === ReadTool.id || isResourceToolName(name)) { + if (args) state.toolArgsMap.set(id, args) + } + const pendingResult = state.pendingToolResults.get(id) + if (pendingResult !== undefined) { + state.pendingToolResults.delete(id) + applyToolResult(ctx, state.toolMap.get(id)!, id, pendingResult) + } + } else { + const idx = state.toolMap.get(id)! + const tc = state.blocks[idx].toolCall + if (tc) { + tc.name = name + if (displayTitle) tc.displayTitle = displayTitle + if (args) tc.params = args + } + } + ops.flush() + + if (isWorkflowToolName(name) && !isPartial) { + const shouldStartWorkflowTool = + !deps.options.suppressedWorkflowToolStartIds?.has(id) && + (isNewToolCall || + (existingToolCall?.status === ToolCallStatus.executing && !existingToolCall.result)) + if (shouldStartWorkflowTool) { + deps.startClientWorkflowTool(id, name, args ?? {}) + } + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts new file mode 100644 index 00000000000..5be3244a128 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/index.ts @@ -0,0 +1,11 @@ +export { dispatchStreamEvent } from './dispatch-stream-event' +export { + type ActiveTurn, + createStreamLoopContext, + type StreamEventScope, + type StreamLoopContext, + type StreamLoopDeps, + type StreamLoopOptions, + type StreamLoopState, +} from './stream-context' +export { finalizeResidualToolCalls, isRecord } from './stream-helpers' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts new file mode 100644 index 00000000000..4d616426047 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts @@ -0,0 +1,250 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import type { MothershipStreamV1ErrorPayload } from '@/lib/copilot/generated/mothership-stream-v1' +import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types' +import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { createStreamLoopContext } from './stream-context' +import { makeStreamLoopDeps, ref } from './stream-test-helpers' + +describe('createStreamLoopContext', () => { + describe('isStale', () => { + it('is stale when the generation no longer matches expectedGen', () => { + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2) }) + ) + expect(ctx.ops.isStale()).toBe(true) + }) + + it('is stale when shouldContinue returns false', () => { + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + expectedGen: 1, + streamGenRef: ref(1), + options: { shouldContinue: () => false }, + }) + ) + expect(ctx.ops.isStale()).toBe(true) + }) + + it('is not stale when the generation matches and shouldContinue is true', () => { + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + expectedGen: 1, + streamGenRef: ref(1), + options: { shouldContinue: () => true }, + }) + ) + expect(ctx.ops.isStale()).toBe(false) + }) + + it('is not stale when expectedGen is undefined (no generation guard)', () => { + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ expectedGen: undefined, streamGenRef: ref(5) }) + ) + expect(ctx.ops.isStale()).toBe(false) + }) + }) + + describe('fresh (non-preserve) initialization', () => { + it('resets the shared streaming refs when the stream is live', () => { + const streamingContentRef = ref('leftover') + const streamingBlocksRef = ref([{ type: 'text', content: 'old' }]) + createStreamLoopContext( + makeStreamLoopDeps({ + expectedGen: 1, + streamGenRef: ref(1), + streamingContentRef, + streamingBlocksRef, + }) + ) + expect(streamingContentRef.current).toBe('') + expect(streamingBlocksRef.current).toEqual([]) + }) + + it('does NOT reset the shared refs when already stale (parity with the original ordering)', () => { + const streamingContentRef = ref('live-content') + const streamingBlocksRef = ref([{ type: 'text', content: 'live' }]) + // expectedGen !== streamGen => stale at construction time + createStreamLoopContext( + makeStreamLoopDeps({ + expectedGen: 1, + streamGenRef: ref(2), + streamingContentRef, + streamingBlocksRef, + }) + ) + expect(streamingContentRef.current).toBe('live-content') + expect(streamingBlocksRef.current).toEqual([{ type: 'text', content: 'live' }]) + }) + }) + + describe('preserveExistingState reconnect hydration', () => { + it('rebuilds blocks, toolMap, toolArgsMap, subagentBySpanId and recovers the active subagent', () => { + const blocks: ContentBlock[] = [ + { type: 'text', content: 'hi' }, + { + type: 'tool_call', + toolCall: { + id: 'tc-1', + name: 'read', + status: ToolCallStatus.success, + params: { path: '/a' }, + }, + }, + { type: 'subagent', content: 'file', spanId: 'span-1', parentToolCallId: 'tc-1' }, + ] + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + options: { preserveExistingState: true }, + streamingBlocksRef: ref(blocks), + streamingContentRef: ref('hi'), + }) + ) + expect(ctx.state.blocks).toHaveLength(3) + expect(ctx.state.runningText).toBe('hi') + expect(ctx.state.toolMap.get('tc-1')).toBe(1) + expect(ctx.state.toolArgsMap.get('tc-1')).toEqual({ path: '/a' }) + expect(ctx.state.subagentBySpanId.get('span-1')).toBe('file') + expect(ctx.state.activeSubagent).toBe('file') + expect(ctx.state.activeSubagentParentToolCallId).toBe('tc-1') + }) + + it('stops recovering the active subagent at a subagent_end marker', () => { + const blocks: ContentBlock[] = [ + { type: 'subagent', content: 'file', spanId: 'span-1' }, + { type: 'subagent_end', spanId: 'span-1' }, + ] + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + options: { preserveExistingState: true }, + streamingBlocksRef: ref(blocks), + }) + ) + expect(ctx.state.activeSubagent).toBeUndefined() + }) + + it('does not clear the shared refs on a preserve-state stream', () => { + const streamingContentRef = ref('keep') + const streamingBlocksRef = ref([{ type: 'text', content: 'keep' }]) + createStreamLoopContext( + makeStreamLoopDeps({ + options: { preserveExistingState: true }, + streamingContentRef, + streamingBlocksRef, + }) + ) + expect(streamingContentRef.current).toBe('keep') + }) + }) + + describe('flush', () => { + it('no-ops when the stream is stale', () => { + const setPendingMessages = vi.fn() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2), setPendingMessages }) + ) + ctx.state.blocks.push({ type: 'text', content: 'x' }) + ctx.ops.flush() + expect(setPendingMessages).not.toHaveBeenCalled() + }) + + it('writes a pending-message snapshot when there is no chatId', () => { + const setPendingMessages = vi.fn() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ chatIdRef: ref(undefined), setPendingMessages }) + ) + ctx.state.runningText = 'hello' + ctx.state.blocks.push({ type: 'text', content: 'hello' }) + ctx.ops.flush() + expect(setPendingMessages).toHaveBeenCalledTimes(1) + const updater = setPendingMessages.mock.calls[0][0] as (prev: ChatMessage[]) => ChatMessage[] + const result = updater([]) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ id: 'assistant-1', role: 'assistant', content: 'hello' }) + }) + + it('routes to mothership chat history when a chatId is present', () => { + const upsertMothershipChatHistory = vi.fn() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ + chatIdRef: ref('chat-1'), + upsertMothershipChatHistory, + }) + ) + ctx.state.runningText = 'hi' + ctx.ops.flush() + expect(upsertMothershipChatHistory).toHaveBeenCalledWith('chat-1', expect.any(Function)) + }) + }) + + describe('flushText (node falls through to flush synchronously)', () => { + it('no-ops when stale', () => { + const setPendingMessages = vi.fn() + const ctx = createStreamLoopContext( + makeStreamLoopDeps({ expectedGen: 1, streamGenRef: ref(2), setPendingMessages }) + ) + ctx.ops.flushText() + expect(setPendingMessages).not.toHaveBeenCalled() + }) + }) + + describe('block builders', () => { + it('ensureTextBlock coalesces consecutive same-scope text blocks', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + const a = ctx.ops.ensureTextBlock(undefined, undefined, undefined, {}) + const b = ctx.ops.ensureTextBlock(undefined, undefined, undefined, {}) + expect(a).toBe(b) + expect(ctx.state.blocks).toHaveLength(1) + }) + + it('ensureTextBlock starts a new block on a subagent-scope change and stamps the prior end', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + const main = ctx.ops.ensureTextBlock(undefined, undefined, undefined, {}) + const sub = ctx.ops.ensureTextBlock('file', undefined, undefined, { spanId: 's1' }) + expect(sub).not.toBe(main) + expect(ctx.state.blocks).toHaveLength(2) + expect(main.endedAt).toBeTypeOf('number') + expect(sub.spanId).toBe('s1') + expect(sub.subagent).toBe('file') + }) + + it('ensureThinkingBlock uses subagent_thinking under a subagent', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + const tb = ctx.ops.ensureThinkingBlock('file', 'tc', undefined, {}) + expect(tb.type).toBe('subagent_thinking') + }) + + it('toEventMs falls back to a finite now on an invalid timestamp', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + const ms = ctx.ops.toEventMs('not-a-date') + expect(Number.isFinite(ms)).toBe(true) + }) + + it('resolveScopedSubagent prefers agentId, then spanId, then parentToolCallId, then active', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + ctx.state.subagentBySpanId.set('s1', 'spanAgent') + ctx.state.subagentByParentToolCallId.set('p1', 'parentAgent') + ctx.state.activeSubagent = 'activeAgent' + expect(ctx.ops.resolveScopedSubagent('explicit', 'p1', 's1')).toBe('explicit') + expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', 's1')).toBe('spanAgent') + expect(ctx.ops.resolveScopedSubagent(undefined, 'p1', undefined)).toBe('parentAgent') + expect(ctx.ops.resolveScopedSubagent(undefined, undefined, undefined)).toBe('activeAgent') + }) + + it('buildInlineErrorTag includes the message, code and provider', () => { + const ctx = createStreamLoopContext(makeStreamLoopDeps()) + const tag = ctx.ops.buildInlineErrorTag({ + message: 'boom', + code: 'E1', + provider: 'openai', + } as unknown as MothershipStreamV1ErrorPayload) + expect(tag).toContain('mothership-error') + expect(tag).toContain('boom') + expect(tag).toContain('E1') + expect(tag).toContain('openai') + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts new file mode 100644 index 00000000000..07d998bfe20 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -0,0 +1,452 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { QueryClient } from '@tanstack/react-query' +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction' +import { captureRevealedSimKeys } from '@/lib/copilot/chat/sim-key-redaction' +import type { MothershipStreamV1ErrorPayload } from '@/lib/copilot/generated/mothership-stream-v1' +import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' +import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import type { ToolResultPhasePayload } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' +import type { + ChatMessage, + ContentBlock, + MothershipResource, + MothershipResourceType, +} from '@/app/workspace/[workspaceId]/home/types' +import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats' + +export type ActiveTurn = { + userMessageId: string + assistantMessageId: string + optimisticUserMessage: ChatMessage + optimisticAssistantMessage: ChatMessage +} + +export interface StreamLoopOptions { + preserveExistingState?: boolean + suppressedWorkflowToolStartIds?: ReadonlySet + targetChatId?: string + shouldContinue?: () => boolean +} + +export interface StreamLoopState { + blocks: ContentBlock[] + toolMap: Map + toolArgsMap: Map> + subagentByParentToolCallId: Map + subagentBySpanId: Map + pendingToolResults: Map + runningText: string + lastContentSource: 'main' | 'subagent' | null + streamRequestId: string | undefined + activeSubagent: string | undefined + activeSubagentParentToolCallId: string | undefined + activeCompactionId: string | undefined + sawStreamError: boolean + sawCompleteEvent: boolean + scheduledTextFlushFrame: number | null +} + +export interface StreamEventScope { + scopedSubagent: string | undefined + scopedParentToolCallId: string | undefined + scopedAgentId: string | undefined + scopedSpanId: string | undefined + scopedParentSpanId: string | undefined + spanIdentity: { spanId?: string; parentSpanId?: string } +} + +type SpanIdentity = { spanId?: string; parentSpanId?: string } + +export interface StreamLoopDeps { + workspaceId: string + queryClient: QueryClient + assistantId: string + expectedGen: number | undefined + options: StreamLoopOptions + + setError: Dispatch> + setPendingMessages: Dispatch> + setResolvedChatId: Dispatch> + setResources: Dispatch> + setActiveResourceId: Dispatch> + + addResource: (resource: MothershipResource) => boolean + removeResource: (resourceType: MothershipResourceType, resourceId: string) => void + startClientWorkflowTool: (id: string, name: string, args: Record) => void + upsertMothershipChatHistory: ( + chatId: string, + updater: (current: MothershipChatHistory) => MothershipChatHistory + ) => void + ensureWorkflowInRegistry: (resourceId: string, title: string, workspaceId: string) => boolean + + onPreviewPhase: (payload: SyntheticFilePreviewPayload, streamId: string | undefined) => void + applyPreviewSessionUpdate: ( + session: FilePreviewSession, + options?: { activate?: boolean } + ) => unknown + removePreviewSessionImmediate: (sessionId: string) => unknown + promoteFileResource: (fileId: string, title: string) => void + shouldAutoActivatePreviewSession: (session: FilePreviewSession) => boolean + + buildAssistantSnapshotMessage: (params: { + id: string + content: string + contentBlocks: ContentBlock[] + requestId?: string + }) => PersistedMessage + hasTerminalPersistedAssistantForStream: ( + messages: PersistedMessage[], + streamId: string, + liveAssistantId: string + ) => boolean + reconcileLiveAssistantTurn: (params: { + messages: PersistedMessage[] + streamId: string + liveAssistant: PersistedMessage + activeStreamId: string | null + }) => PersistedMessage[] + + streamGenRef: MutableRefObject + streamingBlocksRef: MutableRefObject + streamingContentRef: MutableRefObject + chatIdRef: MutableRefObject + selectedChatIdRef: MutableRefObject + streamIdRef: MutableRefObject + revealedSimKeysRef: MutableRefObject + pendingUserMsgRef: MutableRefObject + activeTurnRef: MutableRefObject + resourcesRef: MutableRefObject + workflowIdRef: MutableRefObject + activeResourceIdRef: MutableRefObject + onTitleUpdateRef: MutableRefObject<(() => void) | undefined> + onToolResultRef: MutableRefObject< + ((toolName: string, success: boolean, result: unknown) => void) | undefined + > + onResourceEventRef: MutableRefObject<(() => void) | undefined> + previewSessionRef: MutableRefObject + previewSessionsRef: MutableRefObject> + latestPreviewTargetToolCallIdRef: MutableRefObject + activePreviewSessionIdRef: MutableRefObject + completedPreviewResourceHandoffRef: MutableRefObject< + Map + > + previewActivationOwnerRef: MutableRefObject> +} + +export interface StreamLoopOps { + isStale: () => boolean + toEventMs: (ts: string | undefined) => number + stampBlockEnd: (block: ContentBlock | undefined, ts?: string) => void + ensureTextBlock: ( + subagentName: string | undefined, + parentToolCallId: string | undefined, + ts?: string, + identity?: SpanIdentity + ) => ContentBlock + ensureThinkingBlock: ( + subagentName: string | undefined, + parentToolCallId: string | undefined, + ts?: string, + identity?: SpanIdentity + ) => ContentBlock + resolveScopedSubagent: ( + agentId: string | undefined, + parentToolCallId: string | undefined, + spanId?: string + ) => string | undefined + resolveParentForSubagentBlock: ( + subagent: string | undefined, + scopedParent: string | undefined + ) => string | undefined + appendInlineErrorTag: ( + tag: string, + subagentName?: string, + parentToolCallId?: string, + ts?: string + ) => void + buildInlineErrorTag: (payload: MothershipStreamV1ErrorPayload) => string + flush: () => void + flushText: () => void +} + +export interface StreamLoopContext { + state: StreamLoopState + ops: StreamLoopOps + deps: StreamLoopDeps +} + +/** + * Builds the per-stream context for `processSSEStream`: the mutable accumulation + * state, the bound operations the event handlers share (block builders, `flush`, + * staleness), and the injected hook dependencies. A fresh context is created per + * stream invocation so overlapping/superseded streams never cross-write state; + * `isStale` carries the exact generation + `shouldContinue` guard, and the + * `preserveExistingState` reconnect path rehydrates blocks, the tool index, and + * the subagent indexes from the supplied refs. + */ +export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext { + const preserveState = deps.options.preserveExistingState === true + + const state: StreamLoopState = { + blocks: preserveState ? [...deps.streamingBlocksRef.current] : [], + toolMap: new Map(), + toolArgsMap: new Map>(), + subagentByParentToolCallId: new Map(), + subagentBySpanId: new Map(), + pendingToolResults: new Map(), + runningText: preserveState ? deps.streamingContentRef.current || '' : '', + lastContentSource: null, + streamRequestId: undefined, + activeSubagent: undefined, + activeSubagentParentToolCallId: undefined, + activeCompactionId: undefined, + sawStreamError: false, + sawCompleteEvent: false, + scheduledTextFlushFrame: null, + } + + const isStale = () => + (deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) || + deps.options.shouldContinue?.() === false + + if (preserveState) { + for (let i = 0; i < state.blocks.length; i++) { + const tc = state.blocks[i].toolCall + if (tc) { + state.toolMap.set(tc.id, i) + if (tc.params) state.toolArgsMap.set(tc.id, tc.params) + } + } + for (const block of state.blocks) { + if (block.type === 'subagent' && block.spanId && block.content) { + state.subagentBySpanId.set(block.spanId, block.content) + } + } + for (let i = state.blocks.length - 1; i >= 0; i--) { + if (state.blocks[i].type === 'subagent' && state.blocks[i].content) { + state.activeSubagent = state.blocks[i].content + state.activeSubagentParentToolCallId = state.blocks[i].parentToolCallId + break + } + if (state.blocks[i].type === 'subagent_end') { + break + } + } + } else if (!isStale()) { + deps.streamingContentRef.current = '' + deps.streamingBlocksRef.current = [] + } + + const toEventMs = (ts: string | undefined): number => { + if (ts) { + const parsed = Date.parse(ts) + if (Number.isFinite(parsed)) return parsed + } + return Date.now() + } + + const stampBlockEnd = (block: ContentBlock | undefined, ts?: string) => { + if (block && block.endedAt === undefined) block.endedAt = toEventMs(ts) + } + + const ensureTextBlock = ( + subagentName: string | undefined, + parentToolCallId: string | undefined, + ts?: string, + identity?: SpanIdentity + ): ContentBlock => { + const last = state.blocks[state.blocks.length - 1] + if ( + last?.type === 'text' && + last.subagent === subagentName && + last.parentToolCallId === parentToolCallId && + last.spanId === identity?.spanId + ) { + return last + } + stampBlockEnd(last, ts) + const b: ContentBlock = { type: 'text', content: '', timestamp: toEventMs(ts) } + if (subagentName) b.subagent = subagentName + if (parentToolCallId) b.parentToolCallId = parentToolCallId + if (identity?.spanId) b.spanId = identity.spanId + if (identity?.parentSpanId) b.parentSpanId = identity.parentSpanId + state.blocks.push(b) + return b + } + + const ensureThinkingBlock = ( + subagentName: string | undefined, + parentToolCallId: string | undefined, + ts?: string, + identity?: SpanIdentity + ): ContentBlock => { + const targetType = subagentName ? 'subagent_thinking' : 'thinking' + const last = state.blocks[state.blocks.length - 1] + if ( + last?.type === targetType && + last.subagent === subagentName && + last.parentToolCallId === parentToolCallId && + last.spanId === identity?.spanId + ) { + return last + } + stampBlockEnd(last, ts) + const b: ContentBlock = { type: targetType, content: '', timestamp: toEventMs(ts) } + if (subagentName) b.subagent = subagentName + if (parentToolCallId) b.parentToolCallId = parentToolCallId + if (identity?.spanId) b.spanId = identity.spanId + if (identity?.parentSpanId) b.parentSpanId = identity.parentSpanId + state.blocks.push(b) + return b + } + + const resolveScopedSubagent = ( + agentId: string | undefined, + parentToolCallId: string | undefined, + spanId?: string + ): string | undefined => { + if (agentId) return agentId + if (spanId) { + const scoped = state.subagentBySpanId.get(spanId) + if (scoped) return scoped + } + if (parentToolCallId) { + const scoped = state.subagentByParentToolCallId.get(parentToolCallId) + if (scoped) return scoped + } + return state.activeSubagent + } + + const resolveParentForSubagentBlock = ( + subagent: string | undefined, + scopedParent: string | undefined + ): string | undefined => { + if (!subagent) return undefined + if (scopedParent) return scopedParent + if (state.activeSubagent === subagent) return state.activeSubagentParentToolCallId + for (const [parent, name] of state.subagentByParentToolCallId) { + if (name === subagent) return parent + } + return undefined + } + + const flush = () => { + if (isStale()) return + deps.streamingBlocksRef.current = [...state.blocks] + captureRevealedSimKeys( + deps.revealedSimKeysRef.current, + [deps.assistantId, state.streamRequestId], + state.runningText + ) + const activeChatId = deps.options.targetChatId ?? deps.chatIdRef.current + if (!activeChatId) { + const snapshot: Partial = { + content: state.runningText, + contentBlocks: [...state.blocks], + } + if (state.streamRequestId) snapshot.requestId = state.streamRequestId + deps.setPendingMessages((prev) => { + if (deps.expectedGen !== undefined && deps.streamGenRef.current !== deps.expectedGen) { + return prev + } + const idx = prev.findIndex((m) => m.id === deps.assistantId) + if (idx >= 0) { + return prev.map((m) => (m.id === deps.assistantId ? { ...m, ...snapshot } : m)) + } + return [ + ...prev, + { id: deps.assistantId, role: 'assistant' as const, content: '', ...snapshot }, + ] + }) + return + } + + const assistantMessage = deps.buildAssistantSnapshotMessage({ + id: deps.assistantId, + content: state.runningText, + contentBlocks: state.blocks, + ...(state.streamRequestId ? { requestId: state.streamRequestId } : {}), + }) + deps.upsertMothershipChatHistory(activeChatId, (current) => { + const streamId = deps.streamIdRef.current ?? current.activeStreamId ?? deps.assistantId + const terminalPersistedAssistantExists = + current.activeStreamId !== streamId && + deps.hasTerminalPersistedAssistantForStream(current.messages, streamId, assistantMessage.id) + const reconciledMessages = deps.reconcileLiveAssistantTurn({ + messages: current.messages, + streamId, + liveAssistant: assistantMessage, + activeStreamId: current.activeStreamId, + }) + const skippedTerminalLiveWrite = reconciledMessages === current.messages + return { + ...current, + messages: reconciledMessages, + activeStreamId: + skippedTerminalLiveWrite || terminalPersistedAssistantExists + ? current.activeStreamId + : (deps.streamIdRef.current ?? current.activeStreamId), + } + }) + } + + const flushText = () => { + if (isStale()) return + if (state.scheduledTextFlushFrame !== null) return + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + flush() + return + } + state.scheduledTextFlushFrame = window.requestAnimationFrame(() => { + state.scheduledTextFlushFrame = null + flush() + }) + } + + const appendInlineErrorTag = ( + tag: string, + subagentName?: string, + parentToolCallId?: string, + ts?: string + ) => { + if (state.runningText.includes(tag)) return + const tb = ensureTextBlock(subagentName, parentToolCallId, ts) + const prefix = state.runningText.length > 0 && !state.runningText.endsWith('\n') ? '\n' : '' + tb.content = `${tb.content ?? ''}${prefix}${tag}` + state.runningText += `${prefix}${tag}` + deps.streamingContentRef.current = state.runningText + flush() + } + + const buildInlineErrorTag = (payload: MothershipStreamV1ErrorPayload) => { + const message = + (typeof payload.displayMessage === 'string' ? payload.displayMessage : undefined) || + (typeof payload.message === 'string' ? payload.message : undefined) || + (typeof payload.error === 'string' ? payload.error : undefined) || + 'An unexpected error occurred' + const provider = typeof payload.provider === 'string' ? payload.provider : undefined + const code = typeof payload.code === 'string' ? payload.code : undefined + return `${JSON.stringify({ + message, + ...(code ? { code } : {}), + ...(provider ? { provider } : {}), + })}` + } + + const ops: StreamLoopOps = { + isStale, + toEventMs, + stampBlockEnd, + ensureTextBlock, + ensureThinkingBlock, + resolveScopedSubagent, + resolveParentForSubagentBlock, + appendInlineErrorTag, + buildInlineErrorTag, + flush, + flushText, + } + + return { state, ops, deps } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts new file mode 100644 index 00000000000..437d7c73a0e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -0,0 +1,528 @@ +import { createLogger } from '@sim/logger' +import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import type { MothershipStreamV1ToolUI } from '@/lib/copilot/generated/mothership-stream-v1' +import { + CrawlWebsite, + CreateFolder, + DeleteFolder, + DeleteWorkflow, + DeployApi, + DeployChat, + DeployMcp, + FunctionExecute, + GetPageContents, + Glob, + Grep, + ManageCredential, + ManageCredentialOperation, + ManageCustomTool, + ManageCustomToolOperation, + ManageJob, + ManageJobOperation, + ManageMcpTool, + ManageMcpToolOperation, + ManageSkill, + ManageSkillOperation, + MoveFolder, + MoveWorkflow, + QueryLogs, + Redeploy, + RenameWorkflow, + RunFromBlock, + RunWorkflow, + RunWorkflowUntilBlock, + ScrapePage, + SearchOnline, + WorkspaceFile, + WorkspaceFileOperation, +} from '@/lib/copilot/generated/tool-catalog-v1' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { getWorkflowById } from '@/hooks/queries/utils/workflow-cache' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const logger = createLogger('StreamHelpers') + +export const FILE_SUBAGENT_ID = 'file' + +export const DEPLOY_TOOL_NAMES: Set = new Set([ + DeployApi.id, + DeployChat.id, + DeployMcp.id, + Redeploy.id, +]) + +export const FOLDER_TOOL_NAMES: Set = new Set([ + CreateFolder.id, + DeleteFolder.id, + MoveFolder.id, +]) + +export const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ + MoveWorkflow.id, + RenameWorkflow.id, + DeleteWorkflow.id, +]) + +export type StreamPayload = Record + +export type StreamToolUI = { + hidden?: boolean + title?: string + clientExecutable?: boolean +} + +export type ToolResultPhasePayload = { + output?: unknown + status?: string + error?: unknown + success?: boolean +} + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function asPayloadRecord(value: unknown): StreamPayload | undefined { + return isRecord(value) ? value : undefined +} + +export function getToolUI(ui?: MothershipStreamV1ToolUI): StreamToolUI | undefined { + if (!ui) { + return undefined + } + + const title = + typeof ui.title === 'string' + ? ui.title + : typeof ui.phaseLabel === 'string' + ? ui.phaseLabel + : undefined + + return { + ...(typeof ui.hidden === 'boolean' ? { hidden: ui.hidden } : {}), + ...(title ? { title } : {}), + ...(typeof ui.clientExecutable === 'boolean' ? { clientExecutable: ui.clientExecutable } : {}), + } +} + +export function finalizeResidualToolCalls( + blocks: ContentBlock[], + turnTerminal: 'complete' | 'cancelled' | 'error' +): void { + const endedAt = Date.now() + for (const block of blocks) { + const tc = block.toolCall + if (!tc || tc.status !== ToolCallStatus.executing) continue + if (turnTerminal === 'cancelled') { + tc.status = ToolCallStatus.cancelled + tc.displayTitle = 'Stopped by user' + } else if (turnTerminal === 'error') { + tc.status = ToolCallStatus.error + } else { + tc.status = ToolCallStatus.interrupted + logger.warn('Tool call unresolved at turn completion', { + toolCallId: tc.id, + toolName: tc.name, + }) + } + if (block.endedAt === undefined) { + block.endedAt = endedAt + } + } +} + +export function isTerminalToolCallStatus(status: ToolCallStatus): boolean { + return ( + status === ToolCallStatus.success || + status === ToolCallStatus.error || + status === ToolCallStatus.cancelled || + status === ToolCallStatus.skipped || + status === ToolCallStatus.rejected || + status === ToolCallStatus.interrupted + ) +} + +export function resolveLiveToolStatus( + payload: Partial<{ + status: string + success: boolean + output: unknown + }> +): ToolCallStatus { + return resolveStreamToolOutcome(payload) as ToolCallStatus +} + +function resolveLeafWorkflowPathSegment(segments: string[]): string | undefined { + const lastSegment = segments[segments.length - 1] + if (!lastSegment) return undefined + if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) { + return segments[segments.length - 2] + } + return lastSegment +} + +export function extractResourceFromReadResult( + path: string | undefined, + output: unknown +): MothershipResource | null { + if (!path) return null + + const segments = path + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] + if (!resourceType || !segments[1]) return null + + const obj = output && typeof output === 'object' ? (output as Record) : undefined + if (!obj) return null + + let id = obj.id as string | undefined + let name = obj.name as string | undefined + + if (!id && typeof obj.content === 'string') { + try { + const parsed = JSON.parse(obj.content) + id = parsed?.id as string | undefined + name = parsed?.name as string | undefined + } catch {} + } + + const fallbackTitle = + resourceType === 'workflow' + ? resolveLeafWorkflowPathSegment(segments) + : segments[1] || segments[segments.length - 1] + + if (!id) return null + return { type: resourceType, id, title: name || fallbackTitle || id } +} + +function stringParam(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function stringArrayParam(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) +} + +function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined { + const id = stringParam(workflowId) + if (!id) return undefined + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return undefined + return getWorkflowById(workspaceId, id)?.name +} + +function resolveBlockNameForDisplay(blockId: unknown): string | undefined { + const id = stringParam(blockId) + if (!id) return undefined + return useWorkflowStore.getState().blocks[id]?.name +} + +function resolveWorkspaceFileDisplayTitle( + operation: unknown, + title: unknown, + targetFileName?: unknown +): string | undefined { + const chunkTitle = stringParam(title) + const fileName = stringParam(targetFileName) + let verb = 'Writing' + + switch (operation) { + case WorkspaceFileOperation.append: + verb = 'Adding' + break + case WorkspaceFileOperation.patch: + verb = 'Editing' + break + case WorkspaceFileOperation.update: + verb = 'Writing' + break + } + + if (chunkTitle) return `${verb} ${chunkTitle}` + if (fileName) return `${verb} ${fileName}` + return undefined +} + +function resolveOperationDisplayTitle( + operation: unknown, + labels: Partial>, + fallback: string +): string { + const label = typeof operation === 'string' ? labels[operation] : undefined + return label ?? fallback +} + +function functionExecuteTitle(title: string | undefined): string { + return title ?? 'Running code' +} + +export function resolveToolDisplayTitle( + name: string, + args?: Record +): string | undefined { + if (!args) return undefined + + if (name === FunctionExecute.id) { + return functionExecuteTitle(stringParam(args.title)) + } + + if (name === WorkspaceFile.id) { + const target = asPayloadRecord(args.target) + return resolveWorkspaceFileDisplayTitle(args.operation, args.title, target?.fileName) + } + + if (name === SearchOnline.id) { + const toolTitle = stringParam(args.toolTitle) + return toolTitle ? `Searching online for ${toolTitle}` : 'Searching online' + } + + if (name === Grep.id) { + const toolTitle = stringParam(args.toolTitle) + return toolTitle ? `Searching for ${toolTitle}` : 'Searching' + } + + if (name === Glob.id) { + const toolTitle = stringParam(args.toolTitle) + return toolTitle ? `Finding ${toolTitle}` : 'Finding files' + } + + if (name === ScrapePage.id) { + const url = stringParam(args.url) + return url ? `Scraping ${url}` : 'Scraping page' + } + + if (name === CrawlWebsite.id) { + const url = stringParam(args.url) + return url ? `Crawling ${url}` : 'Crawling website' + } + + if (name === GetPageContents.id) { + const urls = stringArrayParam(args.urls) + if (urls.length === 1) return `Getting ${urls[0]}` + if (urls.length > 1) return `Getting ${urls.length} pages` + return 'Getting page contents' + } + + if (name === ManageCustomTool.id) { + return resolveOperationDisplayTitle( + args.operation, + { + [ManageCustomToolOperation.add]: 'Creating custom tool', + [ManageCustomToolOperation.edit]: 'Updating custom tool', + [ManageCustomToolOperation.delete]: 'Deleting custom tool', + [ManageCustomToolOperation.list]: 'Listing custom tools', + }, + 'Custom tool action' + ) + } + + if (name === ManageMcpTool.id) { + return resolveOperationDisplayTitle( + args.operation, + { + [ManageMcpToolOperation.add]: 'Creating MCP server', + [ManageMcpToolOperation.edit]: 'Updating MCP server', + [ManageMcpToolOperation.delete]: 'Deleting MCP server', + [ManageMcpToolOperation.list]: 'Listing MCP servers', + }, + 'MCP server action' + ) + } + + if (name === ManageSkill.id) { + return resolveOperationDisplayTitle( + args.operation, + { + [ManageSkillOperation.add]: 'Creating skill', + [ManageSkillOperation.edit]: 'Updating skill', + [ManageSkillOperation.delete]: 'Deleting skill', + [ManageSkillOperation.list]: 'Listing skills', + }, + 'Skill action' + ) + } + + if (name === ManageJob.id) { + return resolveOperationDisplayTitle( + args.operation, + { + [ManageJobOperation.create]: 'Creating job', + [ManageJobOperation.get]: 'Getting job', + [ManageJobOperation.update]: 'Updating job', + [ManageJobOperation.delete]: 'Deleting job', + [ManageJobOperation.list]: 'Listing jobs', + }, + 'Job action' + ) + } + + if (name === ManageCredential.id) { + return resolveOperationDisplayTitle( + args.operation, + { + [ManageCredentialOperation.rename]: 'Renaming credential', + [ManageCredentialOperation.delete]: 'Deleting credential', + }, + 'Credential action' + ) + } + + if (name === RunWorkflow.id) { + const workflowName = resolveWorkflowNameForDisplay(args.workflowId) + return workflowName ? `Running ${workflowName}` : 'Running workflow' + } + + if (name === RunFromBlock.id) { + const workflowName = resolveWorkflowNameForDisplay(args.workflowId) + const blockName = resolveBlockNameForDisplay(args.startBlockId) + if (workflowName && blockName) return `Running ${workflowName} from ${blockName}` + if (workflowName) return `Running ${workflowName}` + if (blockName) return `Running from ${blockName}` + return 'Running workflow' + } + + if (name === RunWorkflowUntilBlock.id) { + const workflowName = resolveWorkflowNameForDisplay(args.workflowId) + const blockName = resolveBlockNameForDisplay(args.stopAfterBlockId) + if (workflowName && blockName) return `Running ${workflowName} until ${blockName}` + if (workflowName) return `Running ${workflowName}` + if (blockName) return `Running until ${blockName}` + return 'Running workflow' + } + + if (name === QueryLogs.id) { + const workflowName = + resolveWorkflowNameForDisplay(args.workflowId) ?? stringParam(args.workflowName) + return workflowName ? `Querying logs for ${workflowName}` : undefined + } + + return undefined +} + +function decodeStreamingString(value: string): string { + return value + .replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)) + ) + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') +} + +function matchStreamingStringArg(streamingArgs: string, key: string): string | undefined { + const match = streamingArgs.match(new RegExp(`"${key}"\\s*:\\s*"([^"]*)"`, 'm')) + return match?.[1] ? decodeStreamingString(match[1]) : undefined +} + +export function resolveStreamingToolDisplayTitle( + name: string, + streamingArgs: string +): string | undefined { + if (name === FunctionExecute.id) { + return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title')) + } + + if (name === WorkspaceFile.id) { + return resolveWorkspaceFileDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + matchStreamingStringArg(streamingArgs, 'title'), + matchStreamingStringArg(streamingArgs, 'fileName') + ) + } + + if (name === SearchOnline.id) { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Searching online for ${toolTitle}` : undefined + } + + if (name === Grep.id) { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Searching for ${toolTitle}` : undefined + } + + if (name === Glob.id) { + const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') + return toolTitle ? `Finding ${toolTitle}` : undefined + } + + if (name === ScrapePage.id) { + const url = matchStreamingStringArg(streamingArgs, 'url') + return url ? `Scraping ${url}` : undefined + } + + if (name === CrawlWebsite.id) { + const url = matchStreamingStringArg(streamingArgs, 'url') + return url ? `Crawling ${url}` : undefined + } + + if (name === ManageCustomTool.id) { + return resolveOperationDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + { + [ManageCustomToolOperation.add]: 'Creating custom tool', + [ManageCustomToolOperation.edit]: 'Updating custom tool', + [ManageCustomToolOperation.delete]: 'Deleting custom tool', + [ManageCustomToolOperation.list]: 'Listing custom tools', + }, + 'Custom tool action' + ) + } + + if (name === ManageMcpTool.id) { + return resolveOperationDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + { + [ManageMcpToolOperation.add]: 'Creating MCP server', + [ManageMcpToolOperation.edit]: 'Updating MCP server', + [ManageMcpToolOperation.delete]: 'Deleting MCP server', + [ManageMcpToolOperation.list]: 'Listing MCP servers', + }, + 'MCP server action' + ) + } + + if (name === ManageSkill.id) { + return resolveOperationDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + { + [ManageSkillOperation.add]: 'Creating skill', + [ManageSkillOperation.edit]: 'Updating skill', + [ManageSkillOperation.delete]: 'Deleting skill', + [ManageSkillOperation.list]: 'Listing skills', + }, + 'Skill action' + ) + } + + if (name === ManageJob.id) { + return resolveOperationDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + { + [ManageJobOperation.create]: 'Creating job', + [ManageJobOperation.get]: 'Getting job', + [ManageJobOperation.update]: 'Updating job', + [ManageJobOperation.delete]: 'Deleting job', + [ManageJobOperation.list]: 'Listing jobs', + }, + 'Job action' + ) + } + + if (name === ManageCredential.id) { + return resolveOperationDisplayTitle( + matchStreamingStringArg(streamingArgs, 'operation'), + { + [ManageCredentialOperation.rename]: 'Renaming credential', + [ManageCredentialOperation.delete]: 'Deleting credential', + }, + 'Credential action' + ) + } + + return undefined +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts new file mode 100644 index 00000000000..c7f5b4a50eb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts @@ -0,0 +1,88 @@ +import type { MutableRefObject } from 'react' +import type { QueryClient } from '@tanstack/react-query' +import { vi } from 'vitest' +import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction' +import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import type { + ActiveTurn, + StreamLoopDeps, +} from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' +import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' + +/** Minimal {@link MutableRefObject} factory for stream-loop unit fixtures. */ +export function ref(current: T): MutableRefObject { + return { current } +} + +/** + * Builds a fully-stubbed {@link StreamLoopDeps} for stream-loop unit tests. + * Every function is a `vi.fn` and every ref is seeded with an empty value; + * tests override only the fields relevant to the behavior under test. + */ +export function makeStreamLoopDeps(overrides: Partial = {}): StreamLoopDeps { + return { + workspaceId: 'ws-1', + queryClient: { + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + // double-cast-allowed: minimal QueryClient stub for stream-loop unit fixtures + } as unknown as QueryClient, + assistantId: 'assistant-1', + expectedGen: 1, + options: {}, + setError: vi.fn(), + setPendingMessages: vi.fn(), + setResolvedChatId: vi.fn(), + setResources: vi.fn(), + setActiveResourceId: vi.fn(), + addResource: vi.fn(() => true), + removeResource: vi.fn(), + startClientWorkflowTool: vi.fn(), + upsertMothershipChatHistory: vi.fn(), + ensureWorkflowInRegistry: vi.fn(() => false), + onPreviewPhase: vi.fn(), + applyPreviewSessionUpdate: vi.fn(), + removePreviewSessionImmediate: vi.fn(), + promoteFileResource: vi.fn(), + shouldAutoActivatePreviewSession: vi.fn(() => true), + buildAssistantSnapshotMessage: vi.fn(({ id, content, contentBlocks, requestId }) => ({ + id, + role: 'assistant', + content, + contentBlocks, + ...(requestId ? { requestId } : {}), + // double-cast-allowed: vi.fn wrapper loses the exact snapshot-builder signature in this test fixture + })) as unknown as StreamLoopDeps['buildAssistantSnapshotMessage'], + hasTerminalPersistedAssistantForStream: vi.fn(() => false), + reconcileLiveAssistantTurn: vi.fn( + (params: { messages: PersistedMessage[] }) => params.messages + ), + streamGenRef: ref(1), + streamingBlocksRef: ref([]), + streamingContentRef: ref(''), + chatIdRef: ref(undefined), + selectedChatIdRef: ref(undefined), + streamIdRef: ref(undefined), + revealedSimKeysRef: ref(new Map()), + pendingUserMsgRef: ref(null), + activeTurnRef: ref(null), + resourcesRef: ref([]), + workflowIdRef: ref(undefined), + activeResourceIdRef: ref(null), + onTitleUpdateRef: ref<(() => void) | undefined>(undefined), + onToolResultRef: ref< + ((toolName: string, success: boolean, result: unknown) => void) | undefined + >(undefined), + onResourceEventRef: ref<(() => void) | undefined>(undefined), + previewSessionRef: ref(null), + previewSessionsRef: ref>({}), + latestPreviewTargetToolCallIdRef: ref(null), + activePreviewSessionIdRef: ref(null), + completedPreviewResourceHandoffRef: ref< + Map + >(new Map()), + previewActivationOwnerRef: ref>(new Map()), + ...overrides, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index e2ca729c64c..53b75ebe0f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -21,65 +21,20 @@ import type { } from '@/lib/copilot/chat/persisted-message' import { normalizeMessage, withBlockTiming } from '@/lib/copilot/chat/persisted-message' import { - captureRevealedSimKeys, type RevealedSimKeysByMessage, restoreRevealedSimKeysForMessage, } from '@/lib/copilot/chat/sim-key-redaction' -import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' import { MOTHERSHIP_CHAT_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants' -import type { - MothershipStreamV1ErrorPayload, - MothershipStreamV1ToolUI, -} from '@/lib/copilot/generated/mothership-stream-v1' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, - MothershipStreamV1ResourceOp, - MothershipStreamV1RunKind, MothershipStreamV1SessionKind, MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, MothershipStreamV1TextChannel, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, - MothershipStreamV1ToolStatus, } from '@/lib/copilot/generated/mothership-stream-v1' -import { - CrawlWebsite, - CreateFolder, - DeleteFolder, - DeleteWorkflow, - DeployApi, - DeployChat, - DeployMcp, - GetPageContents, - GetWorkflowLogs, - Glob, - Grep, - ManageCredential, - ManageCredentialOperation, - ManageCustomTool, - ManageCustomToolOperation, - ManageJob, - ManageJobOperation, - ManageMcpTool, - ManageMcpToolOperation, - ManageSkill, - ManageSkillOperation, - MoveFolder, - MoveWorkflow, - Read as ReadTool, - Redeploy, - RenameWorkflow, - RunFromBlock, - RunWorkflow, - RunWorkflowUntilBlock, - ScrapePage, - SearchOnline, - ToolSearchToolRegex, - WorkspaceFile, - WorkspaceFileOperation, -} from '@/lib/copilot/generated/tool-catalog-v1' import { type ParseStreamEventEnvelopeFailure, parsePersistedStreamEventEnvelope, @@ -90,12 +45,6 @@ import { isFilePreviewSession, } from '@/lib/copilot/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' -import { - extractResourcesFromToolResult, - isResourceToolName, -} from '@/lib/copilot/resources/extraction' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { bindRunToolToExecution, cancelRunToolExecution, @@ -106,17 +55,13 @@ import { import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' +import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' import { - buildCompletedPreviewSessions, - type FilePreviewSessionsState, - hasRenderableFilePreviewContent, - INITIAL_FILE_PREVIEW_SESSIONS_STATE, - reduceFilePreviewSessions, - shouldReplaceSession, - useFilePreviewSessions, -} from '@/app/workspace/[workspaceId]/home/hooks/use-file-preview-sessions' -import { deploymentKeys } from '@/hooks/queries/deployments' + createStreamLoopContext, + dispatchStreamEvent, + finalizeResidualToolCalls, + isRecord, +} from '@/app/workspace/[workspaceId]/home/hooks/stream' import { fetchMothershipChatHistory, type MothershipChatHistory, @@ -124,12 +69,10 @@ import { useMothershipChatHistory, } from '@/hooks/queries/mothership-chats' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' -import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' import { workflowKeys } from '@/hooks/queries/workflows' -import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' @@ -141,7 +84,6 @@ import type { ChatContext } from '@/stores/panel' import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { ChatMessage, ContentBlock, @@ -152,9 +94,6 @@ import type { QueuedMessage, ToolCallInfo, } from '../types' -import { ToolCallStatus } from '../types' - -const FILE_SUBAGENT_ID = 'file' export interface UseChatReturn { messages: ChatMessage[] @@ -186,20 +125,6 @@ export interface UseChatReturn { getCurrentRequestId: () => string | undefined } -const DEPLOY_TOOL_NAMES: Set = new Set([ - DeployApi.id, - DeployChat.id, - DeployMcp.id, - Redeploy.id, -]) - -const FOLDER_TOOL_NAMES: Set = new Set([CreateFolder.id, DeleteFolder.id, MoveFolder.id]) - -const WORKFLOW_MUTATION_TOOL_NAMES: Set = new Set([ - MoveWorkflow.id, - RenameWorkflow.id, - DeleteWorkflow.id, -]) const RECONNECT_TAIL_ERROR = 'Live reconnect failed before the stream finished. The latest response may be incomplete.' const MAX_RECONNECT_ATTEMPTS = 10 @@ -222,8 +147,6 @@ const EMPTY_MESSAGE_QUEUE: QueuedMothershipMessage[] = [] const logger = createLogger('useChat') -type StreamPayload = Record - type QueueDispatchAction = { type: 'send_head'; epoch: number } type QueueDispatchActionInput = { type: 'send_head' } @@ -369,10 +292,16 @@ function isChatContext(value: unknown): value is ChatContext { return typeof value.fileId === 'string' case 'folder': return typeof value.folderId === 'string' + case 'filefolder': + return typeof value.fileFolderId === 'string' case 'docs': return true case 'slash_command': return typeof value.command === 'string' + case 'integration': + return typeof value.blockType === 'string' + case 'skill': + return typeof value.skillId === 'string' default: return false } @@ -521,320 +450,6 @@ function clearQueuedSendHandoffClaim(expectedId?: string, expectedOwnerId?: stri window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) } -function stringParam(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value.trim() : undefined -} - -function stringArrayParam(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) -} - -function resolveWorkflowNameForDisplay(workflowId: unknown): string | undefined { - const id = stringParam(workflowId) - if (!id) return undefined - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!workspaceId) return undefined - return getWorkflowById(workspaceId, id)?.name -} - -function resolveBlockNameForDisplay(blockId: unknown): string | undefined { - const id = stringParam(blockId) - if (!id) return undefined - return useWorkflowStore.getState().blocks[id]?.name -} - -function resolveWorkspaceFileDisplayTitle( - operation: unknown, - title: unknown, - targetFileName?: unknown -): string | undefined { - const chunkTitle = stringParam(title) - const fileName = stringParam(targetFileName) - let verb = 'Writing' - - switch (operation) { - case WorkspaceFileOperation.append: - verb = 'Adding' - break - case WorkspaceFileOperation.patch: - verb = 'Editing' - break - case WorkspaceFileOperation.update: - verb = 'Writing' - break - } - - if (chunkTitle) return `${verb} ${chunkTitle}` - if (fileName) return `${verb} ${fileName}` - return undefined -} - -function resolveOperationDisplayTitle( - operation: unknown, - labels: Partial>, - fallback: string -): string { - const label = typeof operation === 'string' ? labels[operation] : undefined - return label ?? fallback -} - -function resolveToolDisplayTitle(name: string, args?: Record): string | undefined { - if (!args) return undefined - - if (name === WorkspaceFile.id) { - const target = asPayloadRecord(args.target) - return resolveWorkspaceFileDisplayTitle(args.operation, args.title, target?.fileName) - } - - if (name === SearchOnline.id) { - const toolTitle = stringParam(args.toolTitle) - return toolTitle ? `Searching online for ${toolTitle}` : 'Searching online' - } - - if (name === Grep.id) { - const toolTitle = stringParam(args.toolTitle) - return toolTitle ? `Searching for ${toolTitle}` : 'Searching' - } - - if (name === Glob.id) { - const toolTitle = stringParam(args.toolTitle) - return toolTitle ? `Finding ${toolTitle}` : 'Finding files' - } - - if (name === ScrapePage.id) { - const url = stringParam(args.url) - return url ? `Scraping ${url}` : 'Scraping page' - } - - if (name === CrawlWebsite.id) { - const url = stringParam(args.url) - return url ? `Crawling ${url}` : 'Crawling website' - } - - if (name === GetPageContents.id) { - const urls = stringArrayParam(args.urls) - if (urls.length === 1) return `Getting ${urls[0]}` - if (urls.length > 1) return `Getting ${urls.length} pages` - return 'Getting page contents' - } - - if (name === ManageCustomTool.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageCustomToolOperation.add]: 'Creating custom tool', - [ManageCustomToolOperation.edit]: 'Updating custom tool', - [ManageCustomToolOperation.delete]: 'Deleting custom tool', - [ManageCustomToolOperation.list]: 'Listing custom tools', - }, - 'Custom tool action' - ) - } - - if (name === ManageMcpTool.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageMcpToolOperation.add]: 'Creating MCP server', - [ManageMcpToolOperation.edit]: 'Updating MCP server', - [ManageMcpToolOperation.delete]: 'Deleting MCP server', - [ManageMcpToolOperation.list]: 'Listing MCP servers', - }, - 'MCP server action' - ) - } - - if (name === ManageSkill.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageSkillOperation.add]: 'Creating skill', - [ManageSkillOperation.edit]: 'Updating skill', - [ManageSkillOperation.delete]: 'Deleting skill', - [ManageSkillOperation.list]: 'Listing skills', - }, - 'Skill action' - ) - } - - if (name === ManageJob.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageJobOperation.create]: 'Creating job', - [ManageJobOperation.get]: 'Getting job', - [ManageJobOperation.update]: 'Updating job', - [ManageJobOperation.delete]: 'Deleting job', - [ManageJobOperation.list]: 'Listing jobs', - }, - 'Job action' - ) - } - - if (name === ManageCredential.id) { - return resolveOperationDisplayTitle( - args.operation, - { - [ManageCredentialOperation.rename]: 'Renaming credential', - [ManageCredentialOperation.delete]: 'Deleting credential', - }, - 'Credential action' - ) - } - - if (name === RunWorkflow.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) - return workflowName ? `Running ${workflowName}` : 'Running workflow' - } - - if (name === RunFromBlock.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) - const blockName = resolveBlockNameForDisplay(args.startBlockId) - if (workflowName && blockName) return `Running ${workflowName} from ${blockName}` - if (workflowName) return `Running ${workflowName}` - if (blockName) return `Running from ${blockName}` - return 'Running workflow' - } - - if (name === RunWorkflowUntilBlock.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) - const blockName = resolveBlockNameForDisplay(args.stopAfterBlockId) - if (workflowName && blockName) return `Running ${workflowName} until ${blockName}` - if (workflowName) return `Running ${workflowName}` - if (blockName) return `Running until ${blockName}` - return 'Running workflow' - } - - if (name === GetWorkflowLogs.id) { - const workflowName = resolveWorkflowNameForDisplay(args.workflowId) - return workflowName ? `Getting logs for ${workflowName}` : 'Getting logs' - } - - return undefined -} - -function decodeStreamingString(value: string): string { - return value - .replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) => - String.fromCharCode(Number.parseInt(hex, 16)) - ) - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\') -} - -function matchStreamingStringArg(streamingArgs: string, key: string): string | undefined { - const match = streamingArgs.match(new RegExp(`"${key}"\\s*:\\s*"([^"]*)"`, 'm')) - return match?.[1] ? decodeStreamingString(match[1]) : undefined -} - -function resolveStreamingToolDisplayTitle(name: string, streamingArgs: string): string | undefined { - if (name === WorkspaceFile.id) { - return resolveWorkspaceFileDisplayTitle( - matchStreamingStringArg(streamingArgs, 'operation'), - matchStreamingStringArg(streamingArgs, 'title'), - matchStreamingStringArg(streamingArgs, 'fileName') - ) - } - - if (name === SearchOnline.id) { - const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') - return toolTitle ? `Searching online for ${toolTitle}` : undefined - } - - if (name === Grep.id) { - const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') - return toolTitle ? `Searching for ${toolTitle}` : undefined - } - - if (name === Glob.id) { - const toolTitle = matchStreamingStringArg(streamingArgs, 'toolTitle') - return toolTitle ? `Finding ${toolTitle}` : undefined - } - - if (name === ScrapePage.id) { - const url = matchStreamingStringArg(streamingArgs, 'url') - return url ? `Scraping ${url}` : undefined - } - - if (name === CrawlWebsite.id) { - const url = matchStreamingStringArg(streamingArgs, 'url') - return url ? `Crawling ${url}` : undefined - } - - if (name === ManageCustomTool.id) { - return resolveOperationDisplayTitle( - matchStreamingStringArg(streamingArgs, 'operation'), - { - [ManageCustomToolOperation.add]: 'Creating custom tool', - [ManageCustomToolOperation.edit]: 'Updating custom tool', - [ManageCustomToolOperation.delete]: 'Deleting custom tool', - [ManageCustomToolOperation.list]: 'Listing custom tools', - }, - 'Custom tool action' - ) - } - - if (name === ManageMcpTool.id) { - return resolveOperationDisplayTitle( - matchStreamingStringArg(streamingArgs, 'operation'), - { - [ManageMcpToolOperation.add]: 'Creating MCP server', - [ManageMcpToolOperation.edit]: 'Updating MCP server', - [ManageMcpToolOperation.delete]: 'Deleting MCP server', - [ManageMcpToolOperation.list]: 'Listing MCP servers', - }, - 'MCP server action' - ) - } - - if (name === ManageSkill.id) { - return resolveOperationDisplayTitle( - matchStreamingStringArg(streamingArgs, 'operation'), - { - [ManageSkillOperation.add]: 'Creating skill', - [ManageSkillOperation.edit]: 'Updating skill', - [ManageSkillOperation.delete]: 'Deleting skill', - [ManageSkillOperation.list]: 'Listing skills', - }, - 'Skill action' - ) - } - - if (name === ManageJob.id) { - return resolveOperationDisplayTitle( - matchStreamingStringArg(streamingArgs, 'operation'), - { - [ManageJobOperation.create]: 'Creating job', - [ManageJobOperation.get]: 'Getting job', - [ManageJobOperation.update]: 'Updating job', - [ManageJobOperation.delete]: 'Deleting job', - [ManageJobOperation.list]: 'Listing jobs', - }, - 'Job action' - ) - } - - if (name === ManageCredential.id) { - return resolveOperationDisplayTitle( - matchStreamingStringArg(streamingArgs, 'operation'), - { - [ManageCredentialOperation.rename]: 'Renaming credential', - [ManageCredentialOperation.delete]: 'Deleting credential', - }, - 'Credential action' - ) - } - - return undefined -} - -type StreamToolUI = { - hidden?: boolean - title?: string - clientExecutable?: boolean -} - type StreamBatchResponse = { success: boolean events: StreamBatchEvent[] @@ -843,10 +458,6 @@ type StreamBatchResponse = { chatId?: string } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.' class StreamSchemaValidationError extends Error { @@ -950,6 +561,13 @@ function toRawPersistedContentBlock(block: ContentBlock): Record -): ToolCallStatus { - return resolveStreamToolOutcome(payload) as ToolCallStatus -} - /** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean { const workflows = getWorkflows(workspaceId) @@ -1390,53 +975,6 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId return true } -function extractResourceFromReadResult( - path: string | undefined, - output: unknown -): MothershipResource | null { - if (!path) return null - - const segments = path - .split('/') - .map((segment) => segment.trim()) - .filter(Boolean) - const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] - if (!resourceType || !segments[1]) return null - - const obj = output && typeof output === 'object' ? (output as Record) : undefined - if (!obj) return null - - let id = obj.id as string | undefined - let name = obj.name as string | undefined - - if (!id && typeof obj.content === 'string') { - try { - const parsed = JSON.parse(obj.content) - id = parsed?.id as string | undefined - name = parsed?.name as string | undefined - } catch { - // content is not JSON - } - } - - const fallbackTitle = - resourceType === 'workflow' - ? resolveLeafWorkflowPathSegment(segments) - : segments[1] || segments[segments.length - 1] - - if (!id) return null - return { type: resourceType, id, title: name || fallbackTitle || id } -} - -function resolveLeafWorkflowPathSegment(segments: string[]): string | undefined { - const lastSegment = segments[segments.length - 1] - if (!lastSegment) return undefined - if (/\.[^/.]+$/.test(lastSegment) && segments.length > 1) { - return segments[segments.length - 2] - } - return lastSegment -} - export interface UseChatOptions { onResourceEvent?: () => void apiPath?: string @@ -1557,52 +1095,28 @@ export function useChat( const activeResourceIdRef = useRef(effectiveActiveResourceId) activeResourceIdRef.current = effectiveActiveResourceId - const previewActivationOwnerRef = useRef>(new Map()) - const completedPreviewResourceHandoffRef = useRef< - Map - >(new Map()) - - const rememberPreviewActivationOwner = useCallback((session: FilePreviewSession) => { - if (!session.fileId || previewActivationOwnerRef.current.has(session.id)) { - return - } - previewActivationOwnerRef.current.set(session.id, activeResourceIdRef.current) - }, []) - - const shouldAutoActivatePreviewSession = useCallback((session: FilePreviewSession) => { - if (!session.fileId) { - return false - } - const currentActiveResourceId = activeResourceIdRef.current - const activationOwnerId = previewActivationOwnerRef.current.get(session.id) - return ( - currentActiveResourceId === null || - currentActiveResourceId === session.fileId || - currentActiveResourceId === 'streaming-file' || - currentActiveResourceId === activationOwnerId - ) - }, []) - - const seedCompletedPreviewContentCache = useCallback( - (fileId: string, previewText: string) => { - queryClient.setQueriesData( - { queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'text') }, - previewText - ) - - const activeFiles = queryClient.getQueryData>( - workspaceFilesKeys.list(workspaceId, 'active') - ) - const fileKey = activeFiles?.find((file) => file.id === fileId)?.key - if (fileKey) { - queryClient.setQueryData( - [...workspaceFilesKeys.content(workspaceId, fileId, 'text'), fileKey], - previewText - ) - } - }, - [queryClient, workspaceId] - ) + const { + previewSession, + previewSessionRef, + previewSessionsRef, + activePreviewSessionIdRef, + latestPreviewTargetToolCallIdRef, + previewActivationOwnerRef, + completedPreviewResourceHandoffRef, + shouldAutoActivatePreviewSession, + applyPreviewSessionUpdate, + removePreviewSessionImmediate, + reconcileTerminalPreviewSessions, + resetEphemeralPreviewState, + promoteFileResource, + seedPreviewSessions, + onPreviewPhase, + } = useFilePreviewController({ + workspaceId, + setResources, + setActiveResourceId, + activeResourceIdRef, + }) const upsertChatHistory = useCallback( (chatId: string, updater: (current: MothershipChatHistory) => MothershipChatHistory) => { @@ -1623,93 +1137,6 @@ export function useChat( [queryClient] ) - const { - previewSession, - previewSessionsById, - activePreviewSessionId, - hydratePreviewSessions, - upsertPreviewSession, - completePreviewSession, - removePreviewSession, - resetPreviewSessions, - } = useFilePreviewSessions() - const previewSessionRef = useRef(previewSession) - previewSessionRef.current = previewSession - const previewSessionsRef = useRef(previewSessionsById) - previewSessionsRef.current = previewSessionsById - const activePreviewSessionIdRef = useRef(activePreviewSessionId) - activePreviewSessionIdRef.current = activePreviewSessionId - const previewSessionsStateRef = useRef({ - activeSessionId: activePreviewSessionId, - sessions: previewSessionsById, - }) - previewSessionsStateRef.current = { - activeSessionId: activePreviewSessionId, - sessions: previewSessionsById, - } - - const syncPreviewSessionRefs = useCallback((nextState: FilePreviewSessionsState) => { - previewSessionsStateRef.current = nextState - previewSessionsRef.current = nextState.sessions - activePreviewSessionIdRef.current = nextState.activeSessionId - previewSessionRef.current = - nextState.activeSessionId !== null - ? (nextState.sessions[nextState.activeSessionId] ?? null) - : null - }, []) - - const applyPreviewSessionUpdate = useCallback( - (session: FilePreviewSession, options?: { activate?: boolean }) => { - const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { - type: 'upsert', - session, - ...(options?.activate === false ? { activate: false } : {}), - }) - syncPreviewSessionRefs(nextState) - upsertPreviewSession(session, options) - return nextState - }, - [syncPreviewSessionRefs, upsertPreviewSession] - ) - - const applyCompletedPreviewSession = useCallback( - (session: FilePreviewSession) => { - const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { - type: 'complete', - session, - }) - syncPreviewSessionRefs(nextState) - completePreviewSession(session) - return nextState - }, - [completePreviewSession, syncPreviewSessionRefs] - ) - - const reconcileTerminalPreviewSessions = useCallback(() => { - const completedAt = new Date().toISOString() - const completedSessions = buildCompletedPreviewSessions( - previewSessionsStateRef.current.sessions, - completedAt - ) - - for (const session of completedSessions) { - applyCompletedPreviewSession(session) - } - }, [applyCompletedPreviewSession]) - - const removePreviewSessionImmediate = useCallback( - (sessionId: string) => { - const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { - type: 'remove', - sessionId, - }) - syncPreviewSessionRefs(nextState) - removePreviewSession(sessionId) - return nextState - }, - [removePreviewSession, syncPreviewSessionRefs] - ) - // Sentinel used while no `chatId` is resolved; `adoptResolvedChatId` // migrates this bucket onto the real chatId on first send. Rotated on // home reset so a new pending chat starts with an empty bucket. @@ -1773,84 +1200,6 @@ export function useChat( ) const recoveringQueuedSendHandoffRef = useRef(null) - const resetEphemeralPreviewState = useCallback( - (options?: { removeStreamingResource?: boolean }) => { - previewActivationOwnerRef.current.clear() - completedPreviewResourceHandoffRef.current.clear() - syncPreviewSessionRefs(INITIAL_FILE_PREVIEW_SESSIONS_STATE) - resetPreviewSessions() - if (options?.removeStreamingResource) { - setResources((current) => current.filter((resource) => resource.id !== 'streaming-file')) - } - }, - [resetPreviewSessions, syncPreviewSessionRefs] - ) - - const syncPreviewResourceChrome = useCallback( - (session: FilePreviewSession, options?: { activate?: boolean }) => { - if (session.targetKind === 'new_file') { - setResources((current) => { - const existing = current.find((resource) => resource.id === 'streaming-file') - if (existing) { - return current.map((resource) => - resource.id === 'streaming-file' - ? { ...resource, title: session.fileName || 'Writing file...' } - : resource - ) - } - return [ - ...current, - { - type: 'file', - id: 'streaming-file', - title: session.fileName || 'Writing file...', - }, - ] - }) - setActiveResourceId('streaming-file') - return - } - - if (session.fileId && hasRenderableFilePreviewContent(session)) { - setResources((current) => current.filter((resource) => resource.id !== 'streaming-file')) - if (options?.activate !== false) { - setActiveResourceId(session.fileId) - } - } - }, - [] - ) - - const seedPreviewSessions = useCallback( - (sessions: FilePreviewSession[]) => { - if (sessions.length === 0) { - return - } - - const nextState = reduceFilePreviewSessions(previewSessionsStateRef.current, { - type: 'hydrate', - sessions, - }) - syncPreviewSessionRefs(nextState) - hydratePreviewSessions(sessions) - const active = - nextState.activeSessionId !== null - ? (nextState.sessions[nextState.activeSessionId] ?? null) - : null - if (active) { - syncPreviewResourceChrome(active, { - activate: active.targetKind === 'new_file' || shouldAutoActivatePreviewSession(active), - }) - } - }, - [ - hydratePreviewSessions, - shouldAutoActivatePreviewSession, - syncPreviewResourceChrome, - syncPreviewSessionRefs, - ] - ) - const abortControllerRef = useRef(null) const streamReaderRef = useRef | null>(null) const chatIdRef = useRef(initialChatId) @@ -2553,254 +1902,70 @@ export function useChat( } ) => { const decoder = new TextDecoder() - const isReaderStale = () => - (expectedGen !== undefined && streamGenRef.current !== expectedGen) || - options?.shouldContinue?.() === false - if (isReaderStale()) { + const ctx = createStreamLoopContext({ + workspaceId, + queryClient, + assistantId, + expectedGen, + options: options ?? {}, + setError, + setPendingMessages, + setResolvedChatId, + setResources, + setActiveResourceId, + addResource, + removeResource, + startClientWorkflowTool, + upsertMothershipChatHistory: upsertChatHistory, + ensureWorkflowInRegistry, + onPreviewPhase, + applyPreviewSessionUpdate, + removePreviewSessionImmediate, + promoteFileResource, + shouldAutoActivatePreviewSession, + buildAssistantSnapshotMessage, + hasTerminalPersistedAssistantForStream, + reconcileLiveAssistantTurn, + streamGenRef, + streamingBlocksRef, + streamingContentRef, + chatIdRef, + selectedChatIdRef, + streamIdRef, + revealedSimKeysRef, + pendingUserMsgRef, + activeTurnRef, + resourcesRef, + workflowIdRef, + activeResourceIdRef, + onTitleUpdateRef, + onToolResultRef, + onResourceEventRef, + previewSessionRef, + previewSessionsRef, + latestPreviewTargetToolCallIdRef, + activePreviewSessionIdRef, + completedPreviewResourceHandoffRef, + previewActivationOwnerRef, + }) + const { state, ops } = ctx + if (ops.isStale()) { void reader.cancel().catch(() => {}) return { sawStreamError: false, sawComplete: false } } streamReaderRef.current = reader let buffer = '' - const preserveState = options?.preserveExistingState === true - const blocks: ContentBlock[] = preserveState ? [...streamingBlocksRef.current] : [] - const toolMap = new Map() - const toolArgsMap = new Map>() - - if (preserveState) { - for (let i = 0; i < blocks.length; i++) { - const tc = blocks[i].toolCall - if (tc) { - toolMap.set(tc.id, i) - if (tc.params) toolArgsMap.set(tc.id, tc.params) - } - } - } - - let activeSubagent: string | undefined - let activeSubagentParentToolCallId: string | undefined - let activeCompactionId: string | undefined - const subagentByParentToolCallId = new Map() - - if (preserveState) { - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i].type === 'subagent' && blocks[i].content) { - activeSubagent = blocks[i].content - activeSubagentParentToolCallId = blocks[i].parentToolCallId - break - } - if (blocks[i].type === 'subagent_end') { - break - } - } - } - - let runningText = preserveState ? streamingContentRef.current || '' : '' - let lastContentSource: 'main' | 'subagent' | null = null - let streamRequestId: string | undefined - - if (!preserveState) { - streamingContentRef.current = '' - streamingBlocksRef.current = [] - } - - const toEventMs = (ts: string | undefined): number => { - if (ts) { - const parsed = Date.parse(ts) - if (Number.isFinite(parsed)) return parsed - } - return Date.now() - } - - const stampBlockEnd = (block: ContentBlock | undefined, ts?: string) => { - if (block && block.endedAt === undefined) block.endedAt = toEventMs(ts) - } - - const ensureTextBlock = ( - subagentName: string | undefined, - parentToolCallId: string | undefined, - ts?: string - ): ContentBlock => { - const last = blocks[blocks.length - 1] - if ( - last?.type === 'text' && - last.subagent === subagentName && - last.parentToolCallId === parentToolCallId - ) { - return last - } - stampBlockEnd(last, ts) - const b: ContentBlock = { type: 'text', content: '', timestamp: toEventMs(ts) } - if (subagentName) b.subagent = subagentName - if (parentToolCallId) b.parentToolCallId = parentToolCallId - blocks.push(b) - return b - } - - const ensureThinkingBlock = ( - subagentName: string | undefined, - parentToolCallId: string | undefined, - ts?: string - ): ContentBlock => { - const targetType = subagentName ? 'subagent_thinking' : 'thinking' - const last = blocks[blocks.length - 1] - if ( - last?.type === targetType && - last.subagent === subagentName && - last.parentToolCallId === parentToolCallId - ) { - return last - } - stampBlockEnd(last, ts) - const b: ContentBlock = { type: targetType, content: '', timestamp: toEventMs(ts) } - if (subagentName) b.subagent = subagentName - if (parentToolCallId) b.parentToolCallId = parentToolCallId - blocks.push(b) - return b - } - - const resolveScopedSubagent = ( - agentId: string | undefined, - parentToolCallId: string | undefined - ): string | undefined => { - if (agentId) return agentId - if (parentToolCallId) { - const scoped = subagentByParentToolCallId.get(parentToolCallId) - if (scoped) return scoped - } - return activeSubagent - } - - const resolveParentForSubagentBlock = ( - subagent: string | undefined, - scopedParent: string | undefined - ): string | undefined => { - if (!subagent) return undefined - if (scopedParent) return scopedParent - if (activeSubagent === subagent) return activeSubagentParentToolCallId - for (const [parent, name] of subagentByParentToolCallId) { - if (name === subagent) return parent - } - return undefined - } - - const appendInlineErrorTag = ( - tag: string, - subagentName?: string, - parentToolCallId?: string, - ts?: string - ) => { - if (runningText.includes(tag)) return - const tb = ensureTextBlock(subagentName, parentToolCallId, ts) - const prefix = runningText.length > 0 && !runningText.endsWith('\n') ? '\n' : '' - tb.content = `${tb.content ?? ''}${prefix}${tag}` - runningText += `${prefix}${tag}` - streamingContentRef.current = runningText - flush() - } - - const buildInlineErrorTag = (payload: MothershipStreamV1ErrorPayload) => { - const message = - (typeof payload.displayMessage === 'string' ? payload.displayMessage : undefined) || - (typeof payload.message === 'string' ? payload.message : undefined) || - (typeof payload.error === 'string' ? payload.error : undefined) || - 'An unexpected error occurred' - const provider = typeof payload.provider === 'string' ? payload.provider : undefined - const code = typeof payload.code === 'string' ? payload.code : undefined - return `${JSON.stringify({ - message, - ...(code ? { code } : {}), - ...(provider ? { provider } : {}), - })}` - } - - const isStale = isReaderStale - let sawStreamError = false - let sawCompleteEvent = false - let scheduledTextFlushFrame: number | null = null - - const flush = () => { - if (isStale()) return - streamingBlocksRef.current = [...blocks] - captureRevealedSimKeys( - revealedSimKeysRef.current, - [assistantId, streamRequestId], - runningText - ) - const activeChatId = options?.targetChatId ?? chatIdRef.current - if (!activeChatId) { - const snapshot: Partial = { - content: runningText, - contentBlocks: [...blocks], - } - if (streamRequestId) snapshot.requestId = streamRequestId - setPendingMessages((prev) => { - if (expectedGen !== undefined && streamGenRef.current !== expectedGen) return prev - const idx = prev.findIndex((m) => m.id === assistantId) - if (idx >= 0) { - return prev.map((m) => (m.id === assistantId ? { ...m, ...snapshot } : m)) - } - return [ - ...prev, - { id: assistantId, role: 'assistant' as const, content: '', ...snapshot }, - ] - }) - return - } - - const assistantMessage = buildAssistantSnapshotMessage({ - id: assistantId, - content: runningText, - contentBlocks: blocks, - ...(streamRequestId ? { requestId: streamRequestId } : {}), - }) - upsertChatHistory(activeChatId, (current) => { - const streamId = streamIdRef.current ?? current.activeStreamId ?? assistantId - const terminalPersistedAssistantExists = - current.activeStreamId !== streamId && - hasTerminalPersistedAssistantForStream(current.messages, streamId, assistantMessage.id) - const reconciledMessages = reconcileLiveAssistantTurn({ - messages: current.messages, - streamId, - liveAssistant: assistantMessage, - activeStreamId: current.activeStreamId, - }) - const skippedTerminalLiveWrite = reconciledMessages === current.messages - return { - ...current, - messages: reconciledMessages, - activeStreamId: - skippedTerminalLiveWrite || terminalPersistedAssistantExists - ? current.activeStreamId - : (streamIdRef.current ?? current.activeStreamId), - } - }) - } - - const flushText = () => { - if (isStale()) return - if (scheduledTextFlushFrame !== null) return - if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { - flush() - return - } - scheduledTextFlushFrame = window.requestAnimationFrame(() => { - scheduledTextFlushFrame = null - flush() - }) - } - try { const pendingLines: string[] = [] while (true) { if (pendingLines.length === 0) { // Don't read another chunk after `complete` has drained. - if (sawCompleteEvent) break + if (state.sawCompleteEvent) break const { done, value } = await reader.read() if (done) break - if (isStale()) continue + if (ops.isStale()) continue buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') @@ -2815,7 +1980,7 @@ export function useChat( if (line === undefined) { continue } - if (isStale()) { + if (ops.isStale()) { pendingLines.length = 0 continue } @@ -2835,10 +2000,10 @@ export function useChat( } const parsed = parsedResult.event - if (parsed.trace?.requestId && parsed.trace.requestId !== streamRequestId) { - streamRequestId = parsed.trace.requestId - streamRequestIdRef.current = streamRequestId - flush() + if (parsed.trace?.requestId && parsed.trace.requestId !== state.streamRequestId) { + state.streamRequestId = parsed.trace.requestId + streamRequestIdRef.current = state.streamRequestId + ops.flush() } if (parsed.stream?.streamId) { streamIdRef.current = parsed.stream.streamId @@ -2854,808 +2019,36 @@ export function useChat( } logger.debug('SSE event received', parsed) - const scopedParentToolCallId = - typeof parsed.scope?.parentToolCallId === 'string' - ? parsed.scope.parentToolCallId - : undefined - const scopedAgentId = - typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined - const scopedSubagent = resolveScopedSubagent(scopedAgentId, scopedParentToolCallId) - switch (parsed.type) { - case MothershipStreamV1EventType.session: { - const payload = parsed.payload - const payloadChatId = - payload.kind === MothershipStreamV1SessionKind.chat - ? payload.chatId - : typeof parsed.stream?.chatId === 'string' - ? parsed.stream.chatId - : undefined - if (payload.kind === MothershipStreamV1SessionKind.chat && payloadChatId) { - const isNewChat = !chatIdRef.current - chatIdRef.current = payloadChatId - const selected = selectedChatIdRef.current - if (selected == null) { - if (isNewChat) { - setResolvedChatId(payloadChatId) - } - } else if (payloadChatId === selected) { - setResolvedChatId(payloadChatId) - } - queryClient.invalidateQueries({ - queryKey: mothershipChatKeys.list(workspaceId), - }) - if (isNewChat) { - const userMsg = pendingUserMsgRef.current - const activeStreamId = streamIdRef.current - if (userMsg && activeStreamId) { - const assistantMessage = buildAssistantSnapshotMessage({ - id: - activeTurnRef.current?.assistantMessageId ?? - getLiveAssistantMessageId(activeStreamId), - content: streamingContentRef.current, - contentBlocks: streamingBlocksRef.current, - }) - const seededMessages = [userMsg, assistantMessage] - queryClient.setQueryData( - mothershipChatKeys.detail(payloadChatId), - { - id: payloadChatId, - title: null, - messages: seededMessages, - activeStreamId, - resources: resourcesRef.current, - } - ) - } - setPendingMessages([]) - if (!workflowIdRef.current) { - window.history.replaceState( - null, - '', - `/workspace/${workspaceId}/chat/${payloadChatId}` - ) - } - } - } - if (payload.kind === MothershipStreamV1SessionKind.title) { - queryClient.invalidateQueries({ - queryKey: mothershipChatKeys.list(workspaceId), - }) - onTitleUpdateRef.current?.() - } - break - } - case MothershipStreamV1EventType.text: { - const chunk = parsed.payload.text - if (chunk) { - const eventTs = typeof parsed.ts === 'string' ? parsed.ts : undefined - if (parsed.payload.channel === MothershipStreamV1TextChannel.thinking) { - const scopedParentForBlock = resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) - const tb = ensureThinkingBlock(scopedSubagent, scopedParentForBlock, eventTs) - tb.content = (tb.content ?? '') + chunk - flushText() - break - } - const contentSource: 'main' | 'subagent' = scopedSubagent ? 'subagent' : 'main' - const needsBoundaryNewline = - lastContentSource !== null && - lastContentSource !== contentSource && - runningText.length > 0 && - !runningText.endsWith('\n') - const scopedParentForBlock = resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) - const tb = ensureTextBlock(scopedSubagent, scopedParentForBlock, eventTs) - const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk - tb.content = (tb.content ?? '') + normalizedChunk - runningText += normalizedChunk - lastContentSource = contentSource - streamingContentRef.current = runningText - flushText() - } - break - } - case MothershipStreamV1EventType.tool: { - const payload = parsed.payload - const id = payload.toolCallId - - if ('previewPhase' in payload) { - const prevSession = previewSessionsRef.current[id] - const target = - payload.previewPhase === 'file_preview_target' ? payload.target : undefined - const targetKind = - 'targetKind' in payload && - (payload.targetKind === 'new_file' || payload.targetKind === 'file_id') - ? payload.targetKind - : target?.kind === 'new_file' || target?.kind === 'file_id' - ? target.kind - : prevSession?.targetKind - const fileId = - 'fileId' in payload && typeof payload.fileId === 'string' - ? payload.fileId - : typeof target?.fileId === 'string' - ? target.fileId - : prevSession?.fileId - const fileName = - 'fileName' in payload && typeof payload.fileName === 'string' - ? payload.fileName - : typeof target?.fileName === 'string' - ? target.fileName - : (prevSession?.fileName ?? '') - const operation = - 'operation' in payload && typeof payload.operation === 'string' - ? payload.operation - : prevSession?.operation - const edit = - ('edit' in payload ? asPayloadRecord(payload.edit) : undefined) ?? - prevSession?.edit - const streamId = parsed.stream?.streamId ?? prevSession?.streamId ?? '' - const nextPreviewVersion = - 'previewVersion' in payload && - typeof payload.previewVersion === 'number' && - Number.isFinite(payload.previewVersion) - ? payload.previewVersion - : (prevSession?.previewVersion ?? 0) + 1 - const baseSession: FilePreviewSession = { - schemaVersion: 1, - id, - streamId, - toolCallId: id, - status: prevSession?.status ?? 'pending', - fileName, - ...(fileId ? { fileId } : {}), - ...(targetKind ? { targetKind } : {}), - ...(operation ? { operation } : {}), - ...(edit ? { edit } : {}), - previewText: prevSession?.previewText ?? '', - previewVersion: prevSession?.previewVersion ?? 0, - updatedAt: prevSession?.updatedAt ?? new Date().toISOString(), - ...(prevSession?.completedAt ? { completedAt: prevSession.completedAt } : {}), - } - - if (payload.previewPhase === 'file_preview_start') { - const nextSession: FilePreviewSession = { - ...baseSession, - status: 'pending', - updatedAt: new Date().toISOString(), - } - applyPreviewSessionUpdate(nextSession) - break - } - - if (payload.previewPhase === 'file_preview_target') { - const nextSession: FilePreviewSession = { - ...baseSession, - updatedAt: new Date().toISOString(), - } - rememberPreviewActivationOwner(nextSession) - const nextState = applyPreviewSessionUpdate(nextSession) - const activePreview = - nextState.activeSessionId !== null - ? (nextState.sessions[nextState.activeSessionId] ?? null) - : null - if (activePreview?.id === nextSession.id) { - syncPreviewResourceChrome(activePreview, { - activate: - activePreview.targetKind === 'new_file' || - shouldAutoActivatePreviewSession(activePreview), - }) - } - break - } - - if (payload.previewPhase === 'file_preview_edit_meta') { - const nextSession: FilePreviewSession = { - ...baseSession, - status: prevSession?.status ?? 'pending', - updatedAt: new Date().toISOString(), - } - applyPreviewSessionUpdate(nextSession) - break - } - - if (payload.previewPhase === 'file_preview_content') { - const content = payload.content - const contentMode = payload.contentMode - const nextPreviewText = - contentMode === 'delta' ? (prevSession?.previewText ?? '') + content : content - const nextSession: FilePreviewSession = { - ...baseSession, - status: 'streaming', - previewText: nextPreviewText, - previewVersion: nextPreviewVersion, - updatedAt: new Date().toISOString(), - } - applyPreviewSessionUpdate(nextSession) - if (!prevSession || !hasRenderableFilePreviewContent(prevSession)) { - syncPreviewResourceChrome(nextSession, { - activate: - nextSession.targetKind === 'new_file' || - shouldAutoActivatePreviewSession(nextSession), - }) - } - const previewToolIdx = toolMap.get(id) - if (previewToolIdx !== undefined && blocks[previewToolIdx].toolCall) { - blocks[previewToolIdx].toolCall!.status = 'executing' - } - break - } - - if (payload.previewPhase === 'file_preview_complete') { - const resultData = asPayloadRecord(payload.output) - const outputData = asPayloadRecord(resultData?.data) ?? resultData - const completedAt = new Date().toISOString() - const wasRenderableBeforeComplete = - prevSession !== undefined && hasRenderableFilePreviewContent(prevSession) - const nextSession: FilePreviewSession = { - ...baseSession, - status: 'complete', - previewVersion: payload.previewVersion ?? prevSession?.previewVersion ?? 0, - updatedAt: completedAt, - completedAt, - } - const nextState = applyCompletedPreviewSession(nextSession) - - if (fileId && resultData?.success === true && outputData?.id === fileId) { - const fileName = (outputData.name as string) ?? nextSession.fileName ?? 'File' - const fileResource = { type: 'file' as const, id: fileId, title: fileName } - setResources((rs) => { - const without = rs.filter((r) => r.id !== 'streaming-file') - if (without.some((r) => r.type === 'file' && r.id === fileResource.id)) { - return without - } - return [...without, fileResource] - }) - const shouldActivateOnComplete = - !wasRenderableBeforeComplete && - hasRenderableFilePreviewContent(nextSession) && - shouldAutoActivatePreviewSession(nextSession) - if (shouldActivateOnComplete) { - setActiveResourceId(fileId) - } - completedPreviewResourceHandoffRef.current.set(fileId, { - sessionId: nextSession.id, - suppressActivation: !shouldActivateOnComplete, - }) - if (hasRenderableFilePreviewContent(nextSession)) { - seedCompletedPreviewContentCache(fileId, nextSession.previewText) - } - invalidateResourceQueries(queryClient, workspaceId, 'file', fileId) - } else { - const activePreview = - nextState.activeSessionId !== null - ? (nextState.sessions[nextState.activeSessionId] ?? null) - : null - if (activePreview) { - syncPreviewResourceChrome(activePreview, { - activate: - activePreview.targetKind === 'new_file' || - shouldAutoActivatePreviewSession(activePreview), - }) - } - } - break - } - } - - if (payload.phase === MothershipStreamV1ToolPhase.args_delta) { - const delta = payload.argumentsDelta - if (!delta) break - - const idx = toolMap.get(id) - if (idx !== undefined && blocks[idx].toolCall) { - const tc = blocks[idx].toolCall! - tc.streamingArgs = (tc.streamingArgs ?? '') + delta - const displayTitle = resolveStreamingToolDisplayTitle(tc.name, tc.streamingArgs) - if (displayTitle) tc.displayTitle = displayTitle - - flush() - } - break - } - - if (payload.phase === MothershipStreamV1ToolPhase.result) { - const idx = toolMap.get(id) - if (idx === undefined || !blocks[idx].toolCall) { - break - } - const tc = blocks[idx].toolCall! - const outputObj = asPayloadRecord(payload.output) - const isCancelled = - outputObj?.reason === 'user_cancelled' || - outputObj?.cancelledByUser === true || - payload.status === MothershipStreamV1ToolOutcome.cancelled - const status = isCancelled - ? ToolCallStatus.cancelled - : resolveLiveToolStatus(payload) - const isSuccess = status === ToolCallStatus.success - - if (status === ToolCallStatus.cancelled) { - tc.status = ToolCallStatus.cancelled - tc.displayTitle = 'Stopped by user' - } else { - tc.status = status - } - tc.streamingArgs = undefined - tc.result = { - success: isSuccess, - output: payload.output, - error: typeof payload.error === 'string' ? payload.error : undefined, - } - stampBlockEnd(blocks[idx]) - flush() - - if (tc.name === ReadTool.id && tc.status === 'success') { - const readArgs = toolArgsMap.get(id) - const resource = extractResourceFromReadResult( - typeof readArgs?.path === 'string' ? readArgs.path : undefined, - tc.result.output - ) - if (resource && addResource(resource)) { - onResourceEventRef.current?.() - } - } - - if (DEPLOY_TOOL_NAMES.has(tc.name) && tc.status === 'success') { - const output = tc.result?.output as Record | undefined - const deployedWorkflowId = (output?.workflowId as string) ?? undefined - if (deployedWorkflowId && typeof output?.isDeployed === 'boolean') { - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(deployedWorkflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(deployedWorkflowId), - }) - queryClient.invalidateQueries({ - queryKey: workflowKeys.list(workspaceId), - }) - } - } - - if (FOLDER_TOOL_NAMES.has(tc.name) && tc.status === 'success') { - queryClient.invalidateQueries({ - queryKey: folderKeys.list(workspaceId), - }) - } - if (WORKFLOW_MUTATION_TOOL_NAMES.has(tc.name) && tc.status === 'success') { - queryClient.invalidateQueries({ - queryKey: workflowKeys.list(workspaceId), - }) - } - - const extractedResources = - tc.status === 'success' && isResourceToolName(tc.name) - ? extractResourcesFromToolResult( - tc.name, - toolArgsMap.get(id) as Record | undefined, - tc.result?.output - ) - : [] - - for (const resource of extractedResources) { - invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) - } - - onToolResultRef.current?.(tc.name, tc.status === 'success', tc.result?.output) - - const workspaceFileOperation = - tc.name === WorkspaceFile.id && typeof tc.params?.operation === 'string' - ? tc.params.operation - : undefined - const shouldKeepWorkspacePreviewOpen = - tc.name === WorkspaceFile.id && - (workspaceFileOperation === 'append' || - workspaceFileOperation === 'update' || - workspaceFileOperation === 'patch') - - if ( - (tc.name === WorkspaceFile.id || tc.name === 'edit_content') && - !shouldKeepWorkspacePreviewOpen - ) { - if (tc.name === WorkspaceFile.id) { - removePreviewSessionImmediate(id) - } - const fileResource = extractedResources.find((r) => r.type === 'file') - if (fileResource) { - setResources((rs) => { - const without = rs.filter((r) => r.id !== 'streaming-file') - if (without.some((r) => r.type === 'file' && r.id === fileResource.id)) { - return without - } - return [...without, fileResource] - }) - setActiveResourceId(fileResource.id) - invalidateResourceQueries(queryClient, workspaceId, 'file', fileResource.id) - } else if (tc.calledBy !== FILE_SUBAGENT_ID) { - setResources((rs) => rs.filter((r) => r.id !== 'streaming-file')) - } - } - break - } - - const name = payload.toolName - const isPartial = - payload.partial === true || - payload.status === MothershipStreamV1ToolStatus.generating - if (name === ToolSearchToolRegex.id || isToolHiddenInUi(name)) { - break - } - const ui = getToolUI(payload.ui) - if (ui?.hidden) break - let displayTitle = ui?.title - const args = payload.arguments as Record | undefined - - displayTitle = resolveToolDisplayTitle(name, args) ?? displayTitle - - if (name === 'edit_content') { - const parentToolCallId = - activePreviewSessionIdRef.current ?? previewSessionRef.current?.toolCallId - const parentIdx = - parentToolCallId !== null && parentToolCallId !== undefined - ? toolMap.get(parentToolCallId) - : undefined - if (parentIdx !== undefined && blocks[parentIdx].toolCall) { - toolMap.set(id, parentIdx) - const tc = blocks[parentIdx].toolCall! - tc.status = 'executing' - tc.result = undefined - flush() - break - } - } - - const existingToolCall = toolMap.has(id) - ? blocks[toolMap.get(id)!]?.toolCall - : undefined - const isNewToolCall = !existingToolCall - if (isNewToolCall) { - stampBlockEnd(blocks[blocks.length - 1]) - toolMap.set(id, blocks.length) - const parentToolCallIdForBlock = resolveParentForSubagentBlock( - scopedSubagent, - scopedParentToolCallId - ) - blocks.push({ - type: 'tool_call', - toolCall: { - id, - name, - status: 'executing', - displayTitle, - params: args, - calledBy: scopedSubagent, - }, - ...(parentToolCallIdForBlock - ? { parentToolCallId: parentToolCallIdForBlock } - : {}), - timestamp: Date.now(), - }) - if (name === ReadTool.id || isResourceToolName(name)) { - if (args) toolArgsMap.set(id, args) - } - } else { - const idx = toolMap.get(id)! - const tc = blocks[idx].toolCall - if (tc) { - tc.name = name - if (displayTitle) tc.displayTitle = displayTitle - if (args) tc.params = args - } - } - flush() - - if (isWorkflowToolName(name) && !isPartial) { - const shouldStartWorkflowTool = - !options?.suppressedWorkflowToolStartIds?.has(id) && - (isNewToolCall || - (existingToolCall?.status === ToolCallStatus.executing && - !existingToolCall.result)) - if (shouldStartWorkflowTool) { - startClientWorkflowTool(id, name, args ?? {}) - } - } - break - } - case MothershipStreamV1EventType.resource: { - const payload = parsed.payload - const resource = payload.resource - - if (payload.op === MothershipStreamV1ResourceOp.remove) { - removeResource(resource.type as MothershipResourceType, resource.id) - invalidateResourceQueries( - queryClient, - workspaceId, - resource.type as MothershipResourceType, - resource.id - ) - onResourceEventRef.current?.() - break - } - - const nextResource = { - type: resource.type as MothershipResourceType, - id: resource.id, - title: typeof resource.title === 'string' ? resource.title : resource.id, - } - const completedPreviewHandoff = - nextResource.type === 'file' - ? completedPreviewResourceHandoffRef.current.get(nextResource.id) - : undefined - const matchingPreviewSessions = - nextResource.type === 'file' - ? Object.values(previewSessionsRef.current).filter( - (session) => session.fileId === nextResource.id - ) - : [] - const latestPreviewForResource = ( - sessions: FilePreviewSession[] - ): FilePreviewSession | undefined => - sessions.reduce( - (latest, session) => (shouldReplaceSession(latest, session) ? session : latest), - undefined - ) - const latestActivePreviewForResource = latestPreviewForResource( - matchingPreviewSessions.filter((session) => session.status !== 'complete') - ) - const previewForResource = - latestActivePreviewForResource ?? latestPreviewForResource(matchingPreviewSessions) - const isCompletedPreviewHandoffCurrent = - completedPreviewHandoff !== undefined && - (!latestActivePreviewForResource || - latestActivePreviewForResource.id === completedPreviewHandoff.sessionId) - if (completedPreviewHandoff && !isCompletedPreviewHandoffCurrent) { - completedPreviewResourceHandoffRef.current.delete(nextResource.id) - previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) - } - const shouldSuppressFileResourceActivation = - (isCompletedPreviewHandoffCurrent && - completedPreviewHandoff?.suppressActivation === true) || - (previewForResource !== undefined && - previewForResource.status !== 'complete' && - (!hasRenderableFilePreviewContent(previewForResource) || - !shouldAutoActivatePreviewSession(previewForResource))) - const wasAdded = shouldSuppressFileResourceActivation - ? !resourcesRef.current.some( - (r) => r.type === nextResource.type && r.id === nextResource.id - ) - : addResource(nextResource) - if (shouldSuppressFileResourceActivation && wasAdded) { - setResources((current) => - current.some((r) => r.type === nextResource.type && r.id === nextResource.id) - ? current - : [...current, nextResource] - ) - } - if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) { - completedPreviewResourceHandoffRef.current.delete(nextResource.id) - previewActivationOwnerRef.current.delete(completedPreviewHandoff.sessionId) - } - invalidateResourceQueries( - queryClient, - workspaceId, - nextResource.type, - nextResource.id - ) - - if ( - !shouldSuppressFileResourceActivation && - !wasAdded && - activeResourceIdRef.current !== nextResource.id - ) { - setActiveResourceId(nextResource.id) - } - onResourceEventRef.current?.() - - if (nextResource.type === 'workflow') { - const wasRegistered = ensureWorkflowInRegistry( - nextResource.id, - nextResource.title, - workspaceId - ) - if (wasAdded && wasRegistered) { - useWorkflowRegistry.getState().setActiveWorkflow(nextResource.id) - } else { - useWorkflowRegistry.getState().loadWorkflowState(nextResource.id) - } - } - break - } - case MothershipStreamV1EventType.run: { - const payload = parsed.payload - if (payload.kind === MothershipStreamV1RunKind.compaction_start) { - const compactionId = `compaction_${Date.now()}` - activeCompactionId = compactionId - stampBlockEnd(blocks[blocks.length - 1]) - toolMap.set(compactionId, blocks.length) - blocks.push({ - type: 'tool_call', - toolCall: { - id: compactionId, - name: 'context_compaction', - status: 'executing', - displayTitle: 'Compacting context...', - }, - timestamp: Date.now(), - }) - flush() - } else if (payload.kind === MothershipStreamV1RunKind.compaction_done) { - const compactionId = activeCompactionId || `compaction_${Date.now()}` - activeCompactionId = undefined - const idx = toolMap.get(compactionId) - if (idx !== undefined && blocks[idx]?.toolCall) { - blocks[idx].toolCall!.status = 'success' - blocks[idx].toolCall!.displayTitle = 'Compacted context' - stampBlockEnd(blocks[idx]) - } else { - toolMap.set(compactionId, blocks.length) - const endNow = Date.now() - blocks.push({ - type: 'tool_call', - toolCall: { - id: compactionId, - name: 'context_compaction', - status: 'success', - displayTitle: 'Compacted context', - }, - timestamp: endNow, - endedAt: endNow, - }) - } - flush() - } - break - } - case MothershipStreamV1EventType.span: { - const payload = parsed.payload - if (payload.kind !== MothershipStreamV1SpanPayloadKind.subagent) { - break - } - const spanData = asPayloadRecord(payload.data) - const parentToolCallIdFromData = - typeof spanData?.tool_call_id === 'string' - ? spanData.tool_call_id - : typeof spanData?.toolCallId === 'string' - ? spanData.toolCallId - : undefined - const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData - const isPendingPause = spanData?.pending === true - const name = typeof payload.agent === 'string' ? payload.agent : scopedAgentId - if (payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) { - const isSameActiveSubagent = - activeSubagent === name && - activeSubagentParentToolCallId && - parentToolCallId === activeSubagentParentToolCallId - if (parentToolCallId) { - subagentByParentToolCallId.set(parentToolCallId, name) - } - activeSubagent = name - activeSubagentParentToolCallId = parentToolCallId - if (!isSameActiveSubagent) { - stampBlockEnd(blocks[blocks.length - 1]) - blocks.push({ - type: 'subagent', - content: name, - ...(parentToolCallId ? { parentToolCallId } : {}), - timestamp: Date.now(), - }) - } - if (name === FILE_SUBAGENT_ID && !isSameActiveSubagent) { - applyPreviewSessionUpdate({ - schemaVersion: 1, - id: parentToolCallId || 'file-preview', - streamId: streamIdRef.current ?? '', - toolCallId: parentToolCallId || 'file-preview', - status: 'pending', - fileName: '', - previewText: '', - previewVersion: 0, - updatedAt: new Date().toISOString(), - }) - } - flush() - } else if (payload.event === MothershipStreamV1SpanLifecycleEvent.end) { - if (isPendingPause) { - break - } - if (parentToolCallId) { - subagentByParentToolCallId.delete(parentToolCallId) - } - if ( - previewSessionRef.current && - (!activePreviewSessionIdRef.current || - previewSessionRef.current.status === 'complete') - ) { - const lastFileResource = resourcesRef.current.find( - (r) => r.type === 'file' && r.id !== 'streaming-file' - ) - setResources((rs) => rs.filter((r) => r.id !== 'streaming-file')) - if (lastFileResource) { - setActiveResourceId(lastFileResource.id) - } - } - if ( - !parentToolCallId || - parentToolCallId === activeSubagentParentToolCallId || - name === activeSubagent - ) { - activeSubagent = undefined - activeSubagentParentToolCallId = undefined - } - const endNow = Date.now() - if (name) { - for (let i = blocks.length - 1; i >= 0; i--) { - const b = blocks[i] - if ( - b.type === 'subagent' && - b.content === name && - b.endedAt === undefined && - (!parentToolCallId || b.parentToolCallId === parentToolCallId) - ) { - b.endedAt = endNow - break - } - } - } - stampBlockEnd(blocks[blocks.length - 1]) - blocks.push({ - type: 'subagent_end', - ...(parentToolCallId ? { parentToolCallId } : {}), - timestamp: endNow, - }) - flush() - } - break - } - case MothershipStreamV1EventType.error: { - sawStreamError = true - setError(parsed.payload.message || parsed.payload.error || 'An error occurred') - appendInlineErrorTag( - buildInlineErrorTag(parsed.payload), - scopedSubagent, - resolveParentForSubagentBlock(scopedSubagent, scopedParentToolCallId), - typeof parsed.ts === 'string' ? parsed.ts : undefined - ) - break - } - case MothershipStreamV1EventType.complete: { - sawCompleteEvent = true - stampBlockEnd(blocks[blocks.length - 1]) - // `complete` is the end-of-turn marker; drain whatever - // else arrived in the same TCP chunk (trailing text, - // followups, run metadata) before stopping. Do NOT - // await another read — events after `complete` would - // be a server bug. - continue - } - } + dispatchStreamEvent(ctx, parsed) } } finally { - if (scheduledTextFlushFrame !== null) { - cancelAnimationFrame(scheduledTextFlushFrame) - scheduledTextFlushFrame = null - flush() + if (state.sawStreamError && !state.sawCompleteEvent) { + finalizeResidualToolCalls(state.blocks, 'error') + ops.flush() + } + if (state.scheduledTextFlushFrame !== null) { + cancelAnimationFrame(state.scheduledTextFlushFrame) + state.scheduledTextFlushFrame = null + ops.flush() } if (streamReaderRef.current === reader) { streamReaderRef.current = null } } - return { sawStreamError, sawComplete: sawCompleteEvent } + return { sawStreamError: state.sawStreamError, sawComplete: state.sawCompleteEvent } }, [ workspaceId, - router, queryClient, - upsertChatHistory, addResource, removeResource, + startClientWorkflowTool, + upsertChatHistory, + onPreviewPhase, applyPreviewSessionUpdate, - applyCompletedPreviewSession, removePreviewSessionImmediate, - syncPreviewResourceChrome, + promoteFileResource, + shouldAutoActivatePreviewSession, ] ) processSSEStreamRef.current = processSSEStream @@ -5318,20 +3711,12 @@ export function useChat( if (!hasExecutingTool && !hasOpenBlock) { return msg } - const updatedBlocks = (msg.contentBlocks ?? []).map((block) => { - const stamped = block.endedAt === undefined ? { ...block, endedAt: stopNow } : block - if (stamped.toolCall?.status !== 'executing') { - return stamped - } - return { - ...stamped, - toolCall: { - ...stamped.toolCall, - status: 'cancelled' as const, - displayTitle: 'Stopped by user', - }, - } - }) + const updatedBlocks: ContentBlock[] = (msg.contentBlocks ?? []).map((block) => ({ + ...block, + ...(block.endedAt === undefined ? { endedAt: stopNow } : {}), + ...(block.toolCall ? { toolCall: { ...block.toolCall } } : {}), + })) + finalizeResidualToolCalls(updatedBlocks, 'cancelled') updatedBlocks.push({ type: 'stopped' as const }) return { ...msg, contentBlocks: updatedBlocks } }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 7acfb139548..30307e4ca31 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -2,10 +2,13 @@ import { Agent, Auth, CreateWorkflow, - Debug, Deploy, EditWorkflow, + Ffmpeg, FunctionExecute, + GenerateAudio, + GenerateImage, + GenerateVideo, GetPageContents, Glob, Grep, @@ -14,6 +17,7 @@ import { KnowledgeBase, ManageMcpTool, ManageSkill, + Media, OpenResource, Read as ReadTool, Research, @@ -63,6 +67,7 @@ export const ToolCallStatus = { cancelled: 'cancelled', skipped: 'skipped', rejected: 'rejected', + interrupted: 'interrupted', } as const export type ToolCallStatus = (typeof ToolCallStatus)[keyof typeof ToolCallStatus] @@ -134,6 +139,15 @@ export interface ContentBlock { timestamp?: number endedAt?: number parentToolCallId?: string + /** + * Deterministic agent-run identity. `spanId` is the stable per-invocation id + * of the subagent that produced this block; `parentSpanId` links it to the + * run that invoked it (empty/"main" for top-level). These are the primary + * nesting keys used to build the agent tree; `parentToolCallId` is retained + * for tool linkage and legacy back-compat. + */ + spanId?: string + parentSpanId?: string } export interface ChatMessageAttachment { @@ -181,6 +195,7 @@ export const SUBAGENT_LABELS: Record = { agent: 'Tools Agent', job: 'Job Agent', file: 'File Agent', + media: 'Media Agent', } as const interface ToolTitleMetadata { @@ -209,7 +224,6 @@ export const TOOL_UI_METADATA: Record = { [CreateWorkflow.id]: { title: 'Creating workflow' }, [EditWorkflow.id]: { title: 'Editing workflow' }, [Workflow.id]: { title: 'Workflow Agent' }, - [Debug.id]: { title: 'Debug Agent' }, [RUN_SUBAGENT_ID]: { title: 'Run Agent' }, [Deploy.id]: { title: 'Deploy Agent' }, [Auth.id]: { title: 'Auth Agent' }, @@ -221,5 +235,10 @@ export const TOOL_UI_METADATA: Record = { custom_tool: { title: 'Creating tool' }, [Research.id]: { title: 'Research Agent' }, [OpenResource.id]: { title: 'Opening resource' }, + [Media.id]: { title: 'Media Agent' }, + [GenerateImage.id]: { title: 'Generating image' }, + [GenerateVideo.id]: { title: 'Generating video' }, + [GenerateAudio.id]: { title: 'Generating audio' }, + [Ffmpeg.id]: { title: 'Processing media' }, context_compaction: { title: 'Compacted context' }, } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts index 09ead88ac8d..16548e2d3c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/utils.ts @@ -60,7 +60,8 @@ export function getBlockIconAndColor( if (mcpBlock) return { icon: mcpBlock.icon, bgColor: mcpBlock.bgColor } } const normalized = normalizeToolId(toolName) - if (normalized === 'load_skill') return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } + if (normalized === 'load_skill' || normalized === 'load_user_skill') + return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' } const toolBlock = getBlockByToolName(normalized) if (toolBlock) return { icon: toolBlock.icon, bgColor: toolBlock.bgColor } } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 27205e91dfe..d7e74848d25 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -2,20 +2,9 @@ import { useCallback, useMemo, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' -import { WrenchIcon } from 'lucide-react' import { useParams } from 'next/navigation' -import { - Badge, - Button, - ChipInput, - ChipSelect, - type ComboboxOptionGroup, - Label, - Search, - Switch, -} from '@/components/emcn' -import { AgentSkillsIcon, McpIcon } from '@/components/icons' -import type { MothershipEnvironment, MothershipSettings } from '@/lib/api/contracts' +import { Badge, Button, ChipInput, ChipSelect, Label, Search, Switch } from '@/components/emcn' +import type { MothershipEnvironment } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { @@ -25,14 +14,7 @@ import { useSetUserRole, useUnbanUser, } from '@/hooks/queries/admin-users' -import { useCustomTools } from '@/hooks/queries/custom-tools' import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings' -import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp' -import { - useMothershipSettings, - useUpdateMothershipSettings, -} from '@/hooks/queries/mothership-settings' -import { useSkills } from '@/hooks/queries/skills' import { useImportWorkflow } from '@/hooks/queries/workflows' const PAGE_SIZE = 20 as const @@ -44,15 +26,6 @@ const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] { value: 'prod', label: 'Prod' }, ] -function defaultMothershipSettings(workspaceId: string): MothershipSettings { - return { - workspaceId, - mcpTools: [], - customTools: [], - skills: [], - } -} - export function Admin() { const params = useParams() const workspaceId = params?.workspaceId as string @@ -61,17 +34,6 @@ export function Admin() { const { data: settings } = useGeneralSettings() const updateSetting = useUpdateGeneralSetting() const importWorkflow = useImportWorkflow() - const adminMothershipWorkspaceId = settings?.superUserModeEnabled ? workspaceId : '' - const { data: mothershipSettings } = useMothershipSettings(adminMothershipWorkspaceId) - const updateMothershipSettings = useUpdateMothershipSettings() - const { data: mcpTools = [], isLoading: mcpToolsLoading } = useMcpToolsQuery( - adminMothershipWorkspaceId - ) - const { data: mcpServers = [] } = useMcpServers(adminMothershipWorkspaceId) - const { data: customTools = [], isLoading: customToolsLoading } = useCustomTools( - adminMothershipWorkspaceId - ) - const { data: skills = [], isLoading: skillsLoading } = useSkills(adminMothershipWorkspaceId) const setUserRole = useSetUserRole() const banUser = useBanUser() @@ -103,20 +65,6 @@ export function Admin() { [usersData?.total] ) const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset]) - const currentMothershipSettings = mothershipSettings ?? defaultMothershipSettings(workspaceId) - const selectedMothershipToolValues = useMemo( - () => [ - ...currentMothershipSettings.mcpTools.map((tool) => `mcp:${tool.serverId}:${tool.toolName}`), - ...currentMothershipSettings.customTools.map((tool) => `custom:${tool.customToolId}`), - ...currentMothershipSettings.skills.map((s) => `skill:${s.skillId}`), - ], - [ - currentMothershipSettings.customTools, - currentMothershipSettings.mcpTools, - currentMothershipSettings.skills, - ] - ) - const selectedMothershipToolCount = selectedMothershipToolValues.length const handleSuperUserModeToggle = async (checked: boolean) => { if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) { @@ -136,131 +84,6 @@ export function Admin() { [settings?.mothershipEnvironment, updateSetting] ) - const saveMothershipSettings = useCallback( - (next: Partial>) => { - updateMothershipSettings.mutate({ - ...currentMothershipSettings, - ...next, - workspaceId, - }) - }, - [currentMothershipSettings, updateMothershipSettings, workspaceId] - ) - - const connectedServerIds = useMemo( - () => - new Set( - mcpServers - .filter((server) => server.connectionStatus === 'connected') - .map((server) => server.id) - ), - [mcpServers] - ) - - const mothershipToolOptions = useMemo(() => { - const groups: ComboboxOptionGroup[] = [] - const refs = new Map< - string, - | { - type: 'mcp' - serverId: string - serverName?: string - toolName: string - title?: string - } - | { type: 'custom'; customToolId: string; title?: string } - | { type: 'skill'; skillId: string; name?: string } - >() - - const availableMcpTools = mcpTools.filter((tool) => connectedServerIds.has(tool.serverId)) - if (availableMcpTools.length > 0) { - groups.push({ - section: 'MCP Tools', - items: availableMcpTools.map((tool) => { - const value = `mcp:${tool.serverId}:${tool.name}` - refs.set(value, { - type: 'mcp', - serverId: tool.serverId, - serverName: tool.serverName, - toolName: tool.name, - title: tool.name, - }) - return { - label: `${tool.serverName}: ${tool.name}`, - value, - icon: McpIcon, - } - }), - }) - } - - if (customTools.length > 0) { - groups.push({ - section: 'Custom Tools', - items: customTools.map((tool) => { - const value = `custom:${tool.id}` - refs.set(value, { type: 'custom', customToolId: tool.id, title: tool.title }) - return { - label: tool.title, - value, - icon: WrenchIcon, - } - }), - }) - } - - if (skills.length > 0) { - groups.push({ - section: 'Skills', - items: skills.map((skill) => { - const value = `skill:${skill.id}` - refs.set(value, { type: 'skill', skillId: skill.id, name: skill.name }) - return { - label: skill.name, - value, - icon: AgentSkillsIcon, - } - }), - }) - } - - return { groups, refs } - }, [connectedServerIds, customTools, mcpTools, skills]) - - const handleMothershipToolSelectionChange = useCallback( - (values: string[]) => { - const mcpTools: MothershipSettings['mcpTools'] = [] - const customTools: MothershipSettings['customTools'] = [] - const skills: MothershipSettings['skills'] = [] - - for (const value of values) { - const ref = mothershipToolOptions.refs.get(value) - if (!ref) continue - if (ref.type === 'mcp') { - mcpTools.push({ - serverId: ref.serverId, - serverName: ref.serverName, - toolName: ref.toolName, - title: ref.title, - }) - } else if (ref.type === 'custom') { - customTools.push({ - customToolId: ref.customToolId, - title: ref.title, - }) - } else { - skills.push({ - skillId: ref.skillId, - name: ref.name, - }) - } - } - - saveMothershipSettings({ mcpTools, customTools, skills }) - }, - [mothershipToolOptions.refs, saveMothershipSettings] - ) - const handleImport = () => { if (!workflowId.trim()) return importWorkflow.mutate( @@ -353,41 +176,6 @@ export function Admin() { options={MOTHERSHIP_ENV_OPTIONS} />
- -
-
- -

- Select workspace MCP tools, custom tools, and skills that Mothership can use. -

-
- 0 - ? `${selectedMothershipToolCount} selected` - : undefined - } - placeholder={ - mcpToolsLoading || customToolsLoading || skillsLoading - ? 'Loading...' - : 'Select' - } - searchPlaceholder='Search tools and skills...' - disabled={ - updateMothershipSettings.isPending || - mcpToolsLoading || - customToolsLoading || - skillsLoading - } - searchable - /> -
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 6bd6c6a139c..691296e3fdd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -467,7 +467,7 @@ export function Billing() {
- Allow usage to go past usage limit + Allow usage to go past included usage { if (currentLimit == null || syncedRef.current === currentLimit) return - const isClean = draft === '' || draft === String(syncedRef.current) + // Display in credits; the prop is dollars. Integer credits round-trip exactly + // through creditsToDollars/dollarsToCredits, so the value never drifts. The + // on-demand "uncapped" sentinel renders as a blank field (No Usage Limit + // placeholder) rather than a meaningless giant credit number. + const lastSyncedDraft = + syncedRef.current == null || syncedRef.current >= ON_DEMAND_UNLIMITED + ? '' + : String(dollarsToCredits(syncedRef.current)) + const isClean = draft === '' || draft === lastSyncedDraft syncedRef.current = currentLimit - if (isClean) setDraft(String(currentLimit)) + if (isClean) { + setDraft(currentLimit >= ON_DEMAND_UNLIMITED ? '' : String(dollarsToCredits(currentLimit))) + } }, [currentLimit, draft]) useEffect(() => { if (!canEdit) return const currentLimit = currentLimitRef.current if (currentLimit == null || debouncedDraft.trim() === '') return - const parsed = Number.parseFloat(debouncedDraft) - if (Number.isNaN(parsed)) { + const parsedCredits = Number.parseFloat(debouncedDraft) + if (Number.isNaN(parsedCredits)) { toast.error('Usage limit must be a number') return } - if (parsed === currentLimit) return - if (parsed < minimumLimit) { - toast.error(`Usage limit must be at least ${minimumLimit}`) + if (parsedCredits === dollarsToCredits(currentLimit)) return + const minimumCredits = dollarsToCredits(minimumLimit) + if (parsedCredits < minimumCredits) { + toast.error(`Usage limit must be at least ${minimumCredits.toLocaleString()} credits`) return } + // Store dollars; the input is credits. Convert once at the boundary. + const limitDollars = creditsToDollars(parsedCredits) const onError = (error: unknown) => { toast.error("Couldn't update usage limit", { description: getErrorMessage(error, 'Please try again in a moment.'), @@ -88,11 +103,11 @@ export function UsageLimitField({ }) return } - saveOrgLimit({ organizationId, limit: parsed }, { onError }) + saveOrgLimit({ organizationId, limit: limitDollars }, { onError }) return } - saveUserLimit({ limit: parsed }, { onError }) + saveUserLimit({ limit: limitDollars }, { onError }) }, [debouncedDraft, minimumLimit, canEdit, context, organizationId, saveOrgLimit, saveUserLimit]) return ( @@ -101,18 +116,24 @@ export function UsageLimitField({ headerAccessory={ { - "Max usage to consume per month, in USD. By default, it's your plan's limit, but you can set it beyond." + "Max usage to consume per month, set in credits — Sim's usage unit (1,000 credits = $5). By default, it's your plan's included usage, but you can set it beyond." } } > setDraft(e.target.value)} - placeholder={currentLimit != null ? String(currentLimit) : 'Enter monthly usage limit'} + placeholder={ + currentLimit == null + ? 'Enter monthly usage limit' + : currentLimit >= ON_DEMAND_UNLIMITED + ? 'No Usage Limit' + : String(dollarsToCredits(currentLimit)) + } disabled={!canEdit} inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx new file mode 100644 index 00000000000..5842ed5e1cc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -0,0 +1,335 @@ +'use client' + +import { useMemo, useState } from 'react' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { Eye, EyeOff, Search } from 'lucide-react' +import { + Button, + Chip, + ChipConfirmModal, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { + CHIP_FIELD_INPUT, + CHIP_FIELD_SHELL, +} from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field' +import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' + +const logger = createLogger('BYOKKeyManager') + +export interface BYOKManagerProvider { + id: string + name: string + icon: React.ComponentType<{ className?: string }> + description: string + placeholder: string +} + +/** + * Optional provider grouping. Each provider id should belong to exactly one + * section; rows keep their {@link BYOKKeyManagerProps.providers} order within a + * group. When omitted, providers render as a single flat list. + */ +export interface BYOKProviderSection { + label: string + ids: string[] +} + +interface BYOKKeyManagerProps { + /** Providers to render, in display order. */ + providers: BYOKManagerProvider[] + /** Provider ids that currently have a stored key. */ + configuredProviderIds: Set + isLoading: boolean + /** Persist a key. Throw to surface an error in the modal. */ + onSave: (providerId: string, apiKey: string) => Promise + /** Remove a key. */ + onDelete: (providerId: string) => Promise + isSaving?: boolean + isDeleting?: boolean + /** Labeled provider groups. When omitted, renders a single flat list. */ + sections?: BYOKProviderSection[] + /** Optional subtitle shown above the provider list. */ + description?: string + /** Show the provider search box (hidden when there are only a couple). */ + showSearch?: boolean +} + +/** + * Shared BYOK key list + add/update/delete modals. Used by both the workspace + * BYOK settings page and the enterprise mothership BYOK tab so the two stay + * visually identical; only the provider set and the backing store differ. + * + * Renders content only (search, provider sections, modals) — the caller owns + * the page chrome (background, scroll container, and `max-w` centering). + */ +export function BYOKKeyManager({ + providers, + configuredProviderIds, + isLoading, + onSave, + onDelete, + isSaving = false, + isDeleting = false, + sections, + description, + showSearch = true, +}: BYOKKeyManagerProps) { + const [searchTerm, setSearchTerm] = useState('') + const [editingProvider, setEditingProvider] = useState(null) + const [apiKeyInput, setApiKeyInput] = useState('') + const [showApiKey, setShowApiKey] = useState(false) + const [error, setError] = useState(null) + const [deleteConfirmProvider, setDeleteConfirmProvider] = useState(null) + + const filteredProviders = useMemo(() => { + if (!searchTerm.trim()) return providers + const searchLower = searchTerm.toLowerCase() + return providers.filter( + (p) => + p.name.toLowerCase().includes(searchLower) || + p.description.toLowerCase().includes(searchLower) + ) + }, [searchTerm, providers]) + + const filteredIds = useMemo( + () => new Set(filteredProviders.map((p) => p.id)), + [filteredProviders] + ) + + const showNoResults = searchTerm.trim() !== '' && filteredProviders.length === 0 + const editingMeta = providers.find((p) => p.id === editingProvider) + const deleteMeta = providers.find((p) => p.id === deleteConfirmProvider) + + const openEditModal = (providerId: string) => { + setEditingProvider(providerId) + setApiKeyInput('') + setShowApiKey(false) + setError(null) + } + + const closeEditModal = () => { + setEditingProvider(null) + setApiKeyInput('') + setShowApiKey(false) + setError(null) + } + + const handleSave = async () => { + if (!editingProvider || !apiKeyInput.trim()) return + + setError(null) + try { + await onSave(editingProvider, apiKeyInput.trim()) + closeEditModal() + } catch (err) { + setError(getErrorMessage(err, 'Failed to save API key')) + logger.error('Failed to save BYOK key', { error: err }) + } + } + + const handleDelete = async () => { + if (!deleteConfirmProvider) return + + try { + await onDelete(deleteConfirmProvider) + setDeleteConfirmProvider(null) + } catch (err) { + logger.error('Failed to delete BYOK key', { error: err }) + } + } + + const renderRow = (provider: BYOKManagerProvider) => { + const hasKey = configuredProviderIds.has(provider.id) + const Icon = provider.icon + + return ( +
+
+
+ +
+
+ {provider.name} + + {provider.description} + +
+
+ + {hasKey ? ( +
+ openEditModal(provider.id)}>Update + setDeleteConfirmProvider(provider.id)}>Delete +
+ ) : ( + openEditModal(provider.id)}> + Add Key + + )} +
+ ) + } + + return ( + <> +
+ {showSearch && ( +
+ + setSearchTerm(e.target.value)} + disabled={isLoading} + className={cn(CHIP_FIELD_INPUT, 'disabled:cursor-not-allowed disabled:opacity-60')} + /> +
+ )} + + {description &&

{description}

} + + {isLoading ? ( +
+ {providers.map((p) => ( + + ))} +
+ ) : showNoResults ? ( +
+ No providers found matching "{searchTerm}" +
+ ) : sections ? ( +
+ {sections.map((section) => { + const rows = providers.filter( + (p) => section.ids.includes(p.id) && filteredIds.has(p.id) + ) + if (rows.length === 0) return null + + return ( + +
{rows.map(renderRow)}
+
+ ) + })} +
+ ) : ( +
{filteredProviders.map(renderRow)}
+ )} +
+ + { + if (!open) closeEditModal() + }} + srTitle='Add/Update API Key' + > + + {editingMeta && ( + <> + {configuredProviderIds.has(editingMeta.id) ? 'Update' : 'Add'} {editingMeta.name} API + Key + + )} + + +

+ This key will be used for all {editingMeta?.name} requests in this workspace. Your key + is encrypted and stored securely. +

+ + {/* Hidden decoy fields to prevent browser autofill */} + +
+ { + setApiKeyInput(e.target.value) + if (error) setError(null) + }} + placeholder={editingMeta?.placeholder} + className={CHIP_FIELD_INPUT} + name='byok_api_key' + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + +
+
+ {error} +
+ +
+ + { + if (!open) setDeleteConfirmProvider(null) + }} + srTitle='Delete API Key' + title='Delete API Key' + description={ + <> + Are you sure you want to delete the{' '} + {deleteMeta?.name} API + key?{' '} + + This workspace will revert to using platform hosted keys. + {' '} + This action cannot be undone. + + } + confirm={{ + label: 'Delete', + onClick: handleDelete, + pending: isDeleting, + pendingLabel: 'Deleting...', + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx new file mode 100644 index 00000000000..7a69eb52bf3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from '@/components/emcn' + +/** + * Skeleton component for BYOK provider key items. + */ +export function BYOKKeySkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 36129e63c15..3b68a529fe4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -1,23 +1,7 @@ 'use client' -import { useMemo, useState } from 'react' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { Eye, EyeOff } from 'lucide-react' +import { useMemo } from 'react' import { useParams } from 'next/navigation' -import { - Button, - Chip, - ChipConfirmModal, - ChipInput, - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - Search, -} from '@/components/emcn' import { AnthropicIcon, BasetenIcon, @@ -46,24 +30,15 @@ import { WizaIcon, ZeroBounceIcon, } from '@/components/icons' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { - type BYOKKey, - useBYOKKeys, - useDeleteBYOKKey, - useUpsertBYOKKey, -} from '@/hooks/queries/byok-keys' + BYOKKeyManager, + type BYOKManagerProvider, + type BYOKProviderSection, +} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager' +import { useBYOKKeys, useDeleteBYOKKey, useUpsertBYOKKey } from '@/hooks/queries/byok-keys' import type { BYOKProviderId } from '@/tools/types' -const logger = createLogger('BYOKSettings') - -const PROVIDERS: { - id: BYOKProviderId - name: string - icon: React.ComponentType<{ className?: string }> - description: string - placeholder: string -}[] = [ +const PROVIDERS: (BYOKManagerProvider & { id: BYOKProviderId })[] = [ { id: 'openai', name: 'OpenAI', @@ -253,7 +228,7 @@ const PROVIDERS: { * {@link PROVIDERS} belongs to exactly one section; rows keep their * {@link PROVIDERS} order within each group. */ -const PROVIDER_SECTIONS: { label: string; ids: BYOKProviderId[] }[] = [ +const PROVIDER_SECTIONS: BYOKProviderSection[] = [ { label: 'Models', ids: [ @@ -306,271 +281,35 @@ export function BYOK() { const upsertKey = useUpsertBYOKKey() const deleteKey = useDeleteBYOKKey() - const [searchTerm, setSearchTerm] = useState('') - const [editingProvider, setEditingProvider] = useState(null) - const [apiKeyInput, setApiKeyInput] = useState('') - const [showApiKey, setShowApiKey] = useState(false) - const [error, setError] = useState(null) - - const [deleteConfirmProvider, setDeleteConfirmProvider] = useState(null) - - const filteredProviders = useMemo(() => { - if (!searchTerm.trim()) return PROVIDERS - const searchLower = searchTerm.toLowerCase() - return PROVIDERS.filter( - (p) => - p.name.toLowerCase().includes(searchLower) || - p.description.toLowerCase().includes(searchLower) - ) - }, [searchTerm]) - - const filteredIds = useMemo( - () => new Set(filteredProviders.map((p) => p.id)), - [filteredProviders] - ) - - const showNoResults = searchTerm.trim() && filteredProviders.length === 0 - - const getKeyForProvider = (providerId: BYOKProviderId): BYOKKey | undefined => { - return keys.find((k) => k.providerId === providerId) - } - - const handleSave = async () => { - if (!editingProvider || !apiKeyInput.trim()) return - - setError(null) - try { - await upsertKey.mutateAsync({ - workspaceId, - providerId: editingProvider, - apiKey: apiKeyInput.trim(), - }) - setEditingProvider(null) - setApiKeyInput('') - setShowApiKey(false) - } catch (err) { - const message = getErrorMessage(err, 'Failed to save API key') - setError(message) - logger.error('Failed to save BYOK key', { error: err }) - } - } - - const handleDelete = async () => { - if (!deleteConfirmProvider) return - - try { - await deleteKey.mutateAsync({ - workspaceId, - providerId: deleteConfirmProvider, - }) - setDeleteConfirmProvider(null) - } catch (err) { - logger.error('Failed to delete BYOK key', { error: err }) - } - } - - const openEditModal = (providerId: BYOKProviderId) => { - setEditingProvider(providerId) - setApiKeyInput('') - setShowApiKey(false) - setError(null) - } + const configuredProviderIds = useMemo(() => new Set(keys.map((k) => k.providerId)), [keys]) return (
-
- setSearchTerm(e.target.value)} - disabled={isLoading} +
+ { + await upsertKey.mutateAsync({ + workspaceId, + providerId: providerId as BYOKProviderId, + apiKey, + }) + }} + onDelete={async (providerId) => { + await deleteKey.mutateAsync({ + workspaceId, + providerId: providerId as BYOKProviderId, + }) + }} /> - - {isLoading ? null : showNoResults ? ( -
- No providers found matching "{searchTerm}" -
- ) : ( -
- {PROVIDER_SECTIONS.map((section) => { - const rows = PROVIDERS.filter( - (p) => section.ids.includes(p.id) && filteredIds.has(p.id) - ) - if (rows.length === 0) return null - - return ( - -
- {rows.map((provider) => { - const existingKey = getKeyForProvider(provider.id) - const Icon = provider.icon - - return ( -
-
-
- -
-
- - {provider.name} - - - {provider.description} - -
-
- - {existingKey ? ( -
- openEditModal(provider.id)}>Update - setDeleteConfirmProvider(provider.id)}> - Delete - -
- ) : ( - openEditModal(provider.id)}> - Add Key - - )} -
- ) - })} -
-
- ) - })} -
- )}
- - { - if (!open) { - setEditingProvider(null) - setApiKeyInput('') - setShowApiKey(false) - setError(null) - } - }} - srTitle='Add/Update API Key' - > - { - setEditingProvider(null) - setApiKeyInput('') - setShowApiKey(false) - setError(null) - }} - > - {editingProvider && ( - <> - {getKeyForProvider(editingProvider) ? 'Update' : 'Add'}{' '} - {PROVIDERS.find((p) => p.id === editingProvider)?.name} API Key - - )} - - -

- This key will be used for all {PROVIDERS.find((p) => p.id === editingProvider)?.name}{' '} - requests in this workspace. Your key is encrypted and stored securely. -

- - {/* Hidden decoy fields to prevent browser autofill */} - - { - setApiKeyInput(e.target.value) - if (error) setError(null) - }} - placeholder={PROVIDERS.find((p) => p.id === editingProvider)?.placeholder} - name='byok_api_key' - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - data-lpignore='true' - data-form-type='other' - endAdornment={ - - } - /> - - {error} -
- { - setEditingProvider(null) - setApiKeyInput('') - setShowApiKey(false) - setError(null) - }} - cancelDisabled={upsertKey.isPending} - primaryAction={{ - label: upsertKey.isPending ? 'Saving...' : 'Save', - onClick: handleSave, - disabled: !apiKeyInput.trim() || upsertKey.isPending, - }} - /> -
- - { - if (!open) setDeleteConfirmProvider(null) - }} - srTitle='Delete API Key' - title='Delete API Key' - description={ - <> - Are you sure you want to delete the{' '} - - {PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name} - {' '} - API key?{' '} - - This workspace will revert to using platform hosted keys. - {' '} - This action cannot be undone. - - } - confirm={{ - label: 'Delete', - onClick: handleDelete, - pending: deleteKey.isPending, - pendingLabel: 'Deleting...', - }} - />
) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index 3cc39c2a2f0..2bb4d33ffdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -1,25 +1,49 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { Badge, Button, Input as EmcnInput, Label } from '@/components/emcn' +import { useParams } from 'next/navigation' +import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn' +import { AnthropicIcon, OpenAIIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' import { + BYOKKeyManager, + type BYOKManagerProvider, +} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager' +import { + type MothershipByokKey, type MothershipEnv, + useDeleteMothershipByok, useGenerateLicense, - useMothershipEnterpriseStats, + useMothershipByokKeys, useMothershipLicenses, useMothershipRequests, - useMothershipTrace, useMothershipUserBreakdown, + useUpsertMothershipByok, } from '@/hooks/queries/mothership-admin' -type Tab = 'overview' | 'licenses' | 'enterprise' | 'traces' +const ENTERPRISE_BYOK_PROVIDERS: BYOKManagerProvider[] = [ + { + id: 'openai', + name: 'OpenAI', + icon: OpenAIIcon, + description: 'Enterprise mothership LLM calls', + placeholder: 'sk-...', + }, + { + id: 'anthropic', + name: 'Anthropic', + icon: AnthropicIcon, + description: 'Enterprise mothership LLM calls', + placeholder: 'sk-ant-...', + }, +] + +type Tab = 'overview' | 'licenses' | 'byok' const TABS: { id: Tab; label: string }[] = [ { id: 'overview', label: 'Overview' }, { id: 'licenses', label: 'Licenses' }, - { id: 'enterprise', label: 'Enterprise' }, - { id: 'traces', label: 'Traces' }, + { id: 'byok', label: 'BYOK' }, ] const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [ @@ -143,20 +167,47 @@ export function Mothership() { )} {activeTab === 'licenses' && } - {activeTab === 'enterprise' && ( - - )} - {activeTab === 'traces' && } + {activeTab === 'byok' && }
) } +/* ─── BYOK Tab ─── */ + +function ByokTab() { + const params = useParams() + const workspaceId = (params?.workspaceId as string) || '' + + const { data, isLoading } = useMothershipByokKeys(workspaceId) + const upsert = useUpsertMothershipByok() + const del = useDeleteMothershipByok() + + const configuredProviderIds = useMemo( + () => new Set(((data?.keys as MothershipByokKey[]) ?? []).map((k) => k.provider)), + [data] + ) + + return ( + { + await upsert.mutateAsync({ workspaceId, provider, apiKey }) + }} + onDelete={async (provider) => { + await del.mutateAsync({ workspaceId, provider }) + }} + /> + ) +} + /* ─── Overview Tab ─── */ function OverviewTab({ @@ -221,6 +272,13 @@ function OverviewTab({ {/* User breakdown */} User Breakdown + {breakdownLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} {breakdown?.users && (
@@ -261,6 +319,13 @@ function OverviewTab({ {/* Recent requests */} Recent Requests ({requests?.count ?? '…'}) + {requestsLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} {requests?.requests && (
@@ -407,6 +472,14 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { All Licenses + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + {data?.licenses && (
@@ -451,381 +524,6 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) { ) } -/* ─── Enterprise Tab ─── */ - -function EnterpriseTab({ - environment, - start, - end, -}: { - environment: MothershipEnv - start: string - end: string -}) { - const [customerType, setCustomerType] = useState('') - const [searchInput, setSearchInput] = useState('') - const { data, isLoading, error } = useMothershipEnterpriseStats( - environment, - customerType, - start, - end - ) - - const handleSearch = () => { - setCustomerType(searchInput.trim()) - } - - return ( -
-
- setSearchInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder='Enter customer type (e.g. enterprise name)...' - /> - -
- - {error &&

{error.message}

} - - {data && ( - <> -
- - - - -
- - {data.top_models && ( - <> - - Top Models -
- {data.top_models.map((m: { model: string; count: number }) => ( - - {m.model} ({m.count}) - - ))} -
- - )} - - {data.users && ( - <> - - User Breakdown -
-
- User ID - Requests - Cost - Last Request -
- {data.users.map( - (u: { - user_id: string - request_count: number - total_cost: number - last_request: string - }) => ( -
- - {u.user_id} - - - {u.request_count} - - - {formatCost(u.total_cost)} - - - {formatDate(u.last_request)} - -
- ) - )} -
- - )} - - )} -
- ) -} - -/* ─── Traces Tab ─── */ - -function TracesTab({ environment }: { environment: MothershipEnv }) { - const [requestIdInput, setRequestIdInput] = useState('') - const [activeRequestId, setActiveRequestId] = useState('') - const { data: trace, isLoading, error } = useMothershipTrace(environment, activeRequestId) - - const handleLookup = () => { - setActiveRequestId(requestIdInput.trim()) - } - - return ( -
-
- setRequestIdInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleLookup()} - placeholder='Paste a request ID (sim_request_id)...' - className='font-mono text-[13px]' - /> - -
- - {error &&

{error.message}

} - - {trace && } -
- ) -} - -/* ─── Trace Detail ─── */ - -interface TraceSpan { - name: string - kind?: string - startMs: number - endMs?: number - durationMs?: number - status: string - parentName?: string - source?: string - attributes?: Record -} - -interface TraceData { - id: string - simRequestId: string - goTraceId: string - streamId?: string - chatId?: string - userId?: string - startMs: number - endMs: number - durationMs: number - outcome: string - spans: TraceSpan[] - model?: string - provider?: string - mode?: string - source?: string - message?: string - inputTokens?: number - outputTokens?: number - cacheReadTokens?: number - cacheWriteTokens?: number - rawTotalCost?: number - billedTotalCost?: number - toolCallCount?: number - error?: boolean - aborted?: boolean - errorMsg?: string -} - -function TraceDetail({ trace }: { trace: TraceData }) { - const rootSpans = trace.spans.filter((s) => !s.parentName) - const childMap = new Map() - for (const span of trace.spans) { - if (span.parentName) { - const existing = childMap.get(span.parentName) || [] - existing.push(span) - childMap.set(span.parentName, existing) - } - } - - return ( -
- {/* Trace metadata */} -
- - - - - {trace.outcome} - - - - - - - - {trace.userId && } - {trace.chatId && } - - - {trace.toolCallCount != null && trace.toolCallCount > 0 && ( - - )} - {trace.message && ( -
- -
- )} - {trace.errorMsg && ( -
- - {trace.errorMsg} - -
- )} -
- - {/* Span tree */} - Spans ({trace.spans.length}) -
- {rootSpans - .sort((a, b) => a.startMs - b.startMs) - .map((span) => ( - - ))} -
-
- ) -} - -function SpanNode({ - span, - childMap, - traceStartMs, - traceDurationMs, - depth, -}: { - span: TraceSpan - childMap: Map - traceStartMs: number - traceDurationMs: number - depth: number -}) { - const [expanded, setExpanded] = useState(depth < 2) - const children = childMap.get(span.name) || [] - const hasChildren = children.length > 0 - const durationMs = span.durationMs ?? (span.endMs ? span.endMs - span.startMs : 0) - const offsetPct = - traceDurationMs > 0 ? ((span.startMs - traceStartMs) / traceDurationMs) * 100 : 0 - const widthPct = traceDurationMs > 0 ? (durationMs / traceDurationMs) * 100 : 0 - - const statusColor = - span.status === 'ok' - ? 'bg-emerald-500/70' - : span.status === 'error' - ? 'bg-red-500/70' - : span.status === 'cancelled' - ? 'bg-yellow-500/70' - : 'bg-[var(--text-tertiary)]' - - const attrs = span.attributes || {} - const attrEntries = Object.entries(attrs).filter( - ([, v]) => v !== null && v !== undefined && v !== '' - ) - - return ( -
- - - {expanded && attrEntries.length > 0 && ( -
- {attrEntries.map(([key, val]) => ( -
- {key}: - - {typeof val === 'object' ? JSON.stringify(val) : String(val)} - -
- ))} -
- )} - - {expanded && - children - .sort((a, b) => a.startMs - b.startMs) - .map((child) => ( - - ))} -
- ) -} - /* ─── Shared components ─── */ function StatCard({ @@ -840,37 +538,11 @@ function StatCard({ return (

{label}

- {loading ? null : ( + {loading ? ( + + ) : (

{value ?? '—'}

)}
) } - -function MetaRow({ - label, - value, - mono, - children, -}: { - label: string - value?: string - mono?: boolean - children?: React.ReactNode -}) { - return ( -
- {label} - {children || ( - - {value} - - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts index 024b3258752..6a4f3bae67e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/index.ts @@ -1,3 +1,4 @@ +export { ManageCreditsModal } from './manage-credits-modal' export { NoOrganizationView } from './no-organization-view' export { OrganizationInviteModal } from './organization-invite-modal' export { OrganizationMemberLists } from './organization-member-lists' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/index.ts new file mode 100644 index 00000000000..accec25dcd3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/index.ts @@ -0,0 +1 @@ +export { ManageCreditsModal, type ManageCreditsTarget } from './manage-credits-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx new file mode 100644 index 00000000000..437fde263f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal/manage-credits-modal.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { getErrorMessage } from '@sim/utils/errors' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + Info, +} from '@/components/emcn' +import { CopyableValueField } from '@/app/workspace/[workspaceId]/components/credential-detail/components/copyable-value-field' +import { + useOrganizationMemberUsageLimit, + useUpdateOrganizationMemberUsageLimit, +} from '@/hooks/queries/organization' + +export interface ManageCreditsTarget { + userId: string + name: string + email: string +} + +interface ManageCreditsModalProps { + open: boolean + onOpenChange: (open: boolean) => void + organizationId: string + member: ManageCreditsTarget | null +} + +/** + * Modal for viewing a member's credits used in the organization's workspaces and + * setting their per-member credit limit. "Credits used" is a read-only chip; + * "Credit limit" is editable (blank = no limit). Hosted-only feature — surfaced + * only from the Organization tab, which already requires hosted + Team plan. + */ +export function ManageCreditsModal({ + open, + onOpenChange, + organizationId, + member, +}: ManageCreditsModalProps) { + const userId = member?.userId + const { data, isLoading } = useOrganizationMemberUsageLimit(organizationId, userId, open) + const updateLimit = useUpdateOrganizationMemberUsageLimit() + + const [draft, setDraft] = useState('') + const [error, setError] = useState(null) + // Seed the draft from server data only until the admin starts typing, so a + // background refetch (window focus, post-save invalidation) can't clobber an + // in-progress edit. Reset when the modal closes. + const hasEditedRef = useRef(false) + + useEffect(() => { + if (!open) { + hasEditedRef.current = false + return + } + if (data && !hasEditedRef.current) { + setDraft(data.creditLimit === null ? '' : String(data.creditLimit)) + setError(null) + } + }, [open, data]) + + const trimmed = draft.trim() + const parsedLimit = trimmed === '' ? null : Number(trimmed) + const isValid = + trimmed === '' || (parsedLimit !== null && Number.isInteger(parsedLimit) && parsedLimit >= 0) + const currentLimit = data?.creditLimit ?? null + const isDirty = parsedLimit !== currentLimit + const isSaving = updateLimit.isPending + + const creditsUsed = data ? data.creditsUsed.toLocaleString() : '—' + + const handleSave = () => { + if (!userId) return + if (!isValid) { + setError('Enter a whole number of credits, or leave blank for no limit.') + return + } + setError(null) + updateLimit.mutate( + { orgId: organizationId, userId, creditLimit: parsedLimit }, + { + onSuccess: () => onOpenChange(false), + onError: (err) => setError(getErrorMessage(err, 'Failed to update credit limit')), + } + ) + } + + return ( + + onOpenChange(false)}> + {member ? `Manage credits — ${member.name || member.email}` : 'Manage credits'} + + + + + + + Credit limit + + { + "Set in credits — Sim's usage unit (1,000 credits = $5). Caps this member's usage across this organization's workspaces each billing period." + } + + + } + value={draft} + onChange={(value) => { + hasEditedRef.current = true + setDraft(value) + }} + placeholder='No limit' + hint='Leave blank for no limit.' + disabled={isLoading || isSaving} + /> + {error} + + onOpenChange(false)} + cancelDisabled={isSaving} + primaryAction={{ + label: isSaving ? 'Saving…' : 'Save', + onClick: handleSave, + disabled: !isValid || !isDirty || isSaving || isLoading, + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index d7c6f03fd0d..1ea8749da87 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -25,6 +25,10 @@ import { MemberRow, MemberSection, } from '@/app/workspace/[workspaceId]/settings/components/member-list' +import { + ManageCreditsModal, + type ManageCreditsTarget, +} from '@/app/workspace/[workspaceId]/settings/components/team-management/components/manage-credits-modal' import { useUpdateWorkspacePermissions } from '@/hooks/queries/invitations' import { useCancelInvitation, @@ -84,6 +88,7 @@ export function OrganizationMemberLists({ onTransferOwnership, }: OrganizationMemberListsProps) { const [query, setQuery] = useState('') + const [creditsTarget, setCreditsTarget] = useState(null) const updateMemberRole = useUpdateOrganizationMemberRole() const updateInvitation = useUpdateInvitation() @@ -161,6 +166,19 @@ export function OrganizationMemberLists({ copyToClipboard(member.email)}> Copy email + {!isOwner && ( + + setCreditsTarget({ + userId: member.userId, + name: member.name, + email: member.email, + }) + } + > + Manage Credits + + )} {canRemove && ( ) })} + + { + if (!open) setCreditsTarget(null) + }} + organizationId={organizationId} + member={creditsTarget} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx index 68b66577b20..aa4906f358c 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/skill-modal/skill-modal.tsx @@ -138,6 +138,7 @@ export function SkillModal({ ) const isEditing = !!initialValues + const readOnly = !!initialValues?.readOnly const showFooter = activeTab === 'create' || isEditing return ( @@ -222,19 +223,21 @@ export function SkillModal({ {showFooter && ( onOpenChange(false)} + cancelDisabled={readOnly} secondaryAction={ isEditing && onDelete ? { label: 'Delete', onClick: () => onDelete(initialValues.id), variant: 'destructive', + disabled: readOnly, } : undefined } primaryAction={{ label: saving ? 'Saving...' : isEditing ? 'Update' : 'Create', onClick: handleSave, - disabled: saving || !hasChanges, + disabled: readOnly || saving || !hasChanges, }} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/fixtures/sample-skills.ts b/apps/sim/app/workspace/[workspaceId]/skills/fixtures/sample-skills.ts deleted file mode 100644 index 8946fb98d76..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/skills/fixtures/sample-skills.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SkillDefinition } from '@/hooks/queries/skills' - -/** - * Flip to `true` to seed the Skills page with the sample entries below - * so every workspace has a populated list out of the box. - */ -export const PREVIEW_SKILLS_WITH_SAMPLES = true - -/** - * Four out-of-the-box sample skills shown alongside any real skills the - * workspace has created. Each entry covers a distinct everyday agent task. - */ -export function getSampleSkills(workspaceId: string): SkillDefinition[] { - const now = new Date().toISOString() - const base = { - workspaceId, - userId: null, - createdAt: now, - updatedAt: now, - } - return [ - { - ...base, - id: 'sample-skill-summarize-thread', - name: 'summarize-thread', - description: 'Condense a long Slack or email thread into the key decisions and action items.', - content: - '# Summarize Thread\n\nGiven a Slack or email thread, produce:\n- A 1-sentence TL;DR\n- Key decisions made\n- Action items with owners\n- Open questions', - }, - { - ...base, - id: 'sample-skill-triage-inbox', - name: 'triage-inbox', - description: - 'Sort incoming messages by urgency and draft replies for the highest priority items.', - content: - '# Triage Inbox\n\nFor each unread message:\n1. Classify as Urgent / Today / This week / FYI\n2. For Urgent + Today, draft a reply\n3. Suggest a calendar block if a meeting is needed', - }, - { - ...base, - id: 'sample-skill-write-changelog', - name: 'write-changelog', - description: 'Turn a list of merged pull requests into a customer-facing changelog entry.', - content: - '# Write Changelog\n\nGiven a list of merged PRs, write a changelog entry:\n- Group by area (Features, Improvements, Fixes)\n- Plain-English titles\n- One-line descriptions, no jargon', - }, - { - ...base, - id: 'sample-skill-prep-standup', - name: 'prep-standup', - description: - 'Pull yesterday’s commits, tickets, and meetings into a ready-to-paste standup update.', - content: - '# Prep Standup\n\nProduce a 3-section update:\n- Yesterday: commits, closed tickets, completed meetings\n- Today: in-progress work, scheduled meetings\n- Blockers: anything waiting on someone else', - }, - ] -} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 5d3f14330f4..5158d33e5f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' @@ -10,10 +10,6 @@ import { SkillTile } from '@/app/workspace/[workspaceId]/components' import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' import { SkillModal } from '@/app/workspace/[workspaceId]/skills/components/skill-modal' -import { - getSampleSkills, - PREVIEW_SKILLS_WITH_SAMPLES, -} from '@/app/workspace/[workspaceId]/skills/fixtures/sample-skills' import type { SkillDefinition } from '@/hooks/queries/skills' import { useDeleteSkill, useSkills } from '@/hooks/queries/skills' @@ -76,12 +72,7 @@ export function Skills() { const [skillToDelete, setSkillToDelete] = useState<{ id: string; name: string } | null>(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const allSkills = useMemo(() => { - if (!PREVIEW_SKILLS_WITH_SAMPLES) return skills - return [...getSampleSkills(workspaceId), ...skills] - }, [skills, workspaceId]) - - const filteredSkills = allSkills.filter((s) => { + const filteredSkills = skills.filter((s) => { if (!searchTerm.trim()) return true const searchLower = searchTerm.toLowerCase() return ( @@ -91,7 +82,7 @@ export function Skills() { }) const handleDeleteClick = (skillId: string) => { - const s = allSkills.find((sk) => sk.id === skillId) + const s = skills.find((sk) => sk.id === skillId) if (!s) return setSkillToDelete({ id: skillId, name: s.name }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx index 6bd0f114661..149de3ee089 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -14,7 +14,7 @@ import { Textarea, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' -import { generateToolInputSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' +import { generateParameterSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' @@ -70,20 +70,6 @@ function haveSameParameterDescriptions( return aKeys.every((key) => a[key] === b[key]) } -/** - * Generate JSON Schema from input format with optional descriptions - */ -function generateParameterSchema( - inputFormat: NormalizedField[], - descriptions: Record -): Record { - const fieldsWithDescriptions = inputFormat.map((field) => ({ - ...field, - description: descriptions[field.name]?.trim() || undefined, - })) - return { ...generateToolInputSchema(fieldsWithDescriptions) } -} - /** * Component to query tools for a single server and report back via callback. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts index aac565e0872..239a70fa233 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts @@ -1,11 +1,15 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' +import { toast } from '@/components/emcn' import { requestRaw } from '@/lib/api/client' +import { isApiClientError } from '@/lib/api/client/errors' import { wandGenerateStreamContract } from '@/lib/api/contracts' import { readSSEStream } from '@/lib/core/utils/sse' import type { GenerationType } from '@/blocks/types' import { subscriptionKeys } from '@/hooks/queries/subscription' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useWand') @@ -94,6 +98,8 @@ export function useWand({ onGenerationComplete, }: UseWandProps) { const queryClient = useQueryClient() + const { navigateToSettings } = useSettingsNavigation() + const { workspaceId } = useParams<{ workspaceId: string }>() const workflowId = useWorkflowRegistry((state) => state.hydration.workflowId) const [isLoading, setIsLoading] = useState(false) const [isPromptVisible, setIsPromptVisible] = useState(false) @@ -189,6 +195,7 @@ export function useWand({ history: wandConfig?.maintainHistory ? conversationHistory : [], generationType: wandConfig?.generationType, workflowId: workflowId ?? undefined, + workspaceId: workspaceId ?? undefined, wandContext: contextParams?.tableId ? { tableId: contextParams.tableId } : undefined, }, signal: abortControllerRef.current.signal, @@ -237,6 +244,21 @@ export function useWand({ } catch (error: any) { if (error.name === 'AbortError') { logger.debug('Wand generation cancelled') + } else if (isApiClientError(error) && error.status === 402) { + // A per-member cap is only raisable by an org admin, so skip the Upgrade + // affordance the member can't act on. + const isMemberLimit = (error.body as { scope?: string } | null)?.scope === 'member' + toast.error( + error.message || 'Usage limit reached', + isMemberLimit + ? undefined + : { + action: { + label: 'Upgrade', + onClick: () => navigateToSettings({ section: 'billing' }), + }, + } + ) } else { logger.error('Wand generation failed', { error }) setError(error.message || 'Generation failed') @@ -257,6 +279,8 @@ export function useWand({ queryClient, contextParams?.tableId, workflowId, + workspaceId, + navigateToSettings, ] ) diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 4728cc97ff4..af9ee9befb8 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -12,6 +12,7 @@ import { } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' import { useParams } from 'next/navigation' import type { Socket } from 'socket.io-client' import { getSocketUrl } from '@/lib/core/utils/urls' @@ -30,6 +31,11 @@ const logger = createLogger('SocketContext') const TAB_SESSION_ID_KEY = 'sim_tab_session_id' +/** Bounded auto-retry budget for auth-class connect failures before going terminal. */ +const MAX_AUTH_RETRY_ATTEMPTS = 5 +const AUTH_RETRY_BASE_MS = 1000 +const AUTH_RETRY_MAX_MS = 30000 + function getTabSessionId(): string { if (typeof window === 'undefined') return '' @@ -162,6 +168,8 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const explicitWorkflowIdRef = useRef(explicitWorkflowId) const joinControllerRef = useRef(new SocketJoinController()) const joinRetryTimeoutRef = useRef | null>(null) + const authRetryAttemptsRef = useRef(0) + const authRetryTimeoutRef = useRef | null>(null) const params = useParams() const urlWorkflowId = params?.workflowId as string | undefined @@ -213,6 +221,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) { } }, []) + const clearAuthRetryTimeout = useCallback(() => { + if (authRetryTimeoutRef.current !== null) { + clearTimeout(authRetryTimeoutRef.current) + authRetryTimeoutRef.current = null + } + }, []) + const resetVisibleWorkflowState = useCallback((workflowId?: string | null) => { if (workflowId) { useOperationQueueStore.getState().cancelOperationsForWorkflow(workflowId) @@ -326,11 +341,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { useEffect(() => { if (!user?.id) return - if (authFailed) { - logger.info('Socket initialization skipped - auth failed, waiting for retry') - return - } - if (initializedRef.current || socket || isConnecting) { logger.info('Socket already exists or is connecting, skipping initialization') return @@ -376,6 +386,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketInstance.on('connect', () => { setIsConnected(true) setIsConnecting(false) + setIsReconnecting(false) + authRetryAttemptsRef.current = 0 + clearAuthRetryTimeout() setCurrentSocketId(socketInstance.id ?? null) logger.info('Socket connected successfully', { socketId: socketInstance.id, @@ -404,26 +417,45 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketInstance.on('connect_error', (error: Error) => { setIsConnecting(false) - logger.error('Socket connection error:', { message: error.message }) - // Check if this is an authentication failure - const isAuthError = - error.message?.includes('Token validation failed') || - error.message?.includes('Authentication failed') || - error.message?.includes('Authentication required') + if (socketInstance.active) { + // Temporary failure (timeout, network): Socket.IO retries natively with + // built-in backoff, re-invoking the auth callback for a fresh token. + logger.warn('Socket connection error, will auto-reconnect', { + message: error.message, + }) + setIsReconnecting(true) + return + } - if (isAuthError) { - logger.warn( - 'Authentication failed - stopping reconnection attempts. User may need to refresh/re-login.' + // active === false: denied by server middleware (always auth — it is the + // realtime server's only middleware). Socket.IO never retries denials, so + // schedule a manual connect() on the same instance with bounded backoff; + // the auth callback mints a fresh token on each attempt. + if (authRetryAttemptsRef.current < MAX_AUTH_RETRY_ATTEMPTS) { + authRetryAttemptsRef.current += 1 + const delayMs = backoffWithJitter(authRetryAttemptsRef.current, null, { + baseMs: AUTH_RETRY_BASE_MS, + maxMs: AUTH_RETRY_MAX_MS, + }) + setIsReconnecting(true) + logger.warn('Socket connection denied, retrying with a fresh token', { + message: error.message, + attempt: authRetryAttemptsRef.current, + delayMs: Math.round(delayMs), + }) + clearAuthRetryTimeout() + authRetryTimeoutRef.current = setTimeout(() => { + authRetryTimeoutRef.current = null + socketInstance.connect() + }, delayMs) + } else { + logger.error( + 'Socket connection denied after max retries - stopping. User may need to refresh/re-login.', + { message: error.message } ) - socketInstance.disconnect() - setSocket(null) setAuthFailed(true) setIsReconnecting(false) - initializedRef.current = false - } else if (socketInstance.active) { - // Temporary failure, will auto-reconnect - setIsReconnecting(true) } }) @@ -445,7 +477,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) { }) socketInstance.io.on('reconnect_error', (error: Error) => { - logger.error('Socket reconnection error:', { message: error.message }) + logger.warn('Socket reconnection attempt failed, will retry', { + message: error.message, + }) }) socketInstance.io.on('reconnect_failed', () => { @@ -722,6 +756,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { return () => { clearJoinRetryTimeout() + clearAuthRetryTimeout() positionUpdateTimeouts.current.forEach((timeoutId) => { clearTimeout(timeoutId) }) @@ -735,6 +770,33 @@ export function SocketProvider({ children, user }: SocketProviderProps) { socketRef.current = null } } + }, [user?.id]) + + /** + * Recover from a terminal auth failure without a full page reload. When the tab + * regains focus or the network returns, reset the retry budget and reconnect the + * existing socket — the auth callback re-mints a fresh token natively. + */ + useEffect(() => { + if (!user?.id || !authFailed) return + + const recoverFromAuthFailure = () => { + if (document.visibilityState !== 'visible') return + logger.info('Window focus/online detected, retrying socket connection after auth failure') + authRetryAttemptsRef.current = 0 + setAuthFailed(false) + socketRef.current?.connect() + } + + window.addEventListener('focus', recoverFromAuthFailure) + window.addEventListener('online', recoverFromAuthFailure) + document.addEventListener('visibilitychange', recoverFromAuthFailure) + + return () => { + window.removeEventListener('focus', recoverFromAuthFailure) + window.removeEventListener('online', recoverFromAuthFailure) + document.removeEventListener('visibilitychange', recoverFromAuthFailure) + } }, [user?.id, authFailed]) const hydrationPhase = useWorkflowRegistryStore((s) => s.hydration.phase) @@ -779,9 +841,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) { return } logger.info('Retrying socket connection after auth failure') + authRetryAttemptsRef.current = 0 setAuthFailed(false) - // initializedRef.current was already reset in connect_error handler - // Effect will re-run and attempt connection + socketRef.current?.connect() }, [authFailed]) const emitWorkflowOperation = useCallback( diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index 0bce880e776..44c87461d8c 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -7,6 +7,7 @@ import { generateId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' import { task } from '@trigger.dev/sdk' import { eq } from 'drizzle-orm' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure' import { createTimeoutAbortController } from '@/lib/core/execution-limits' import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter' @@ -22,6 +23,7 @@ import type { WorkflowGroup, } from '@/lib/table/types' import type { WorkflowGroupCellPayload } from '@/lib/table/workflow-columns' +import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' export type { WorkflowGroupCellPayload } @@ -228,6 +230,60 @@ async function runWorkflowAndWriteTerminal( if (cancelledBeforeRun(row.executions?.[groupId])) return 'error' + // Resolve the billing/enforcement actor: the user who triggered the run, + // else the workspace billed account (automated/auto-fire). Enrichment must + // never run unattributed — if neither resolves, fail the cell rather than + // running free and ungated. + const enrichmentActorUserId = + payload.triggeredByUserId ?? (await getWorkspaceBilledAccountUserId(workspaceId)) + if (!enrichmentActorUserId) { + logger.error( + `No billing actor for enrichment — failing cell (table=${tableId} row=${rowId} group=${groupId})` + ) + await writeState({ + status: 'error', + executionId, + jobId: null, + workflowId: statusId, + error: 'Unable to resolve a billing account for this enrichment.', + }) + return 'error' + } + + // Gate per-member + pooled usage before incurring hosted-key cost. Mirrors + // the workflow-group gate below: clear the cell's pre-stamp and signal the + // client to upgrade rather than marking the cell errored. + const usage = await checkActorUsageLimits(enrichmentActorUserId, workspaceId) + if (usage.isExceeded) { + logger.warn( + `Usage limit reached — halting enrichment (table=${tableId} row=${rowId} group=${groupId})` + ) + const { updateRow } = await import('@/lib/table/service') + await updateRow( + { tableId, rowId, data: {}, workspaceId, executionsPatch: { [groupId]: null } }, + table, + requestId + ).catch((err) => + logger.warn(`Failed to clear cell pre-stamp on usage limit`, { + error: toError(err).message, + }) + ) + let shouldEmit = true + if (dispatchId) { + const { completeDispatchIfActive } = await import('@/lib/table/dispatcher') + shouldEmit = await completeDispatchIfActive(dispatchId) + } + if (shouldEmit) { + await appendTableEvent({ + kind: 'usageLimitReached', + tableId, + ...(dispatchId ? { dispatchId } : {}), + message: usage.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.', + }) + } + return 'blocked' + } + const pickedUp = await markWorkflowGroupPickedUp(cellCtx, { workflowId: statusId, jobId: null, @@ -311,13 +367,14 @@ async function runWorkflowAndWriteTerminal( return 'error' } - // Bill the table owner for any hosted-key cost the providers incurred. - // Billing failures must not error an otherwise-successful cell. - if (cost > 0 && table.createdBy) { + // Bill the run's actor (triggerer, else workspace billed account) for any + // hosted-key cost the providers incurred. Billing failures must not error + // an otherwise-successful cell. + if (cost > 0) { try { const { recordUsage } = await import('@/lib/billing/core/usage-log') await recordUsage({ - userId: table.createdBy, + userId: enrichmentActorUserId, workspaceId, executionId, entries: [ @@ -439,13 +496,18 @@ async function runWorkflowAndWriteTerminal( // retry doesn't re-run the (stable) billing/usage/subscription lookups. // Failures are surfaced via cell state / SSE / dispatch halt, so suppress // preprocessing's own execution-log writes. + // Attribute the run to the member who triggered it (manual run, row edit, or + // auto-fire from their own write) so the gate + cost land on their per-member + // meter — mirroring the enrichment branch. Falls back to the workspace billed + // account for genuinely actor-less runs. const preprocess = await preprocessExecution({ workflowId, executionId, requestId, workspaceId, workflowRecord, - userId: workflowRecord.userId, + userId: payload.triggeredByUserId ?? workflowRecord.userId, + useAuthenticatedUserAsActor: Boolean(payload.triggeredByUserId), triggerType: 'workflow', checkDeployment: false, checkRateLimit: false, diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index c29b50cd9cd..f8828e8c907 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -109,11 +109,11 @@ describe.concurrent('Blocks Module', () => { expect(block?.tools.config?.tool({ operation: 'file_get' })).toBe('file_get') }) - it('should expose v4 with read and fetch routed to the expected tools', () => { + it('should keep v4 read and fetch routed to the expected tools', () => { const block = getBlock('file_v4') expect(block).toBeDefined() - expect(block?.hideFromToolbar).toBe(false) + expect(block?.hideFromToolbar).toBe(true) expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ 'file_read', 'file_fetch', @@ -160,6 +160,70 @@ describe.concurrent('Blocks Module', () => { workspaceId: 'workspace-1', }) }) + + it('should expose v5 with read (files only) and a get content operation', () => { + const block = getBlock('file_v5') + + expect(block).toBeDefined() + expect(block?.hideFromToolbar).toBe(false) + expect(block?.subBlocks[0].options?.map((option) => option.id)).toEqual([ + 'file_read', + 'file_get_content', + 'file_fetch', + 'file_write', + 'file_append', + ]) + expect(block?.subBlocks.find((subBlock) => subBlock.id === 'readFile')?.multiple).toBe(true) + expect(block?.tools.config?.tool({ operation: 'file_read' })).toBe('file_read') + expect(block?.tools.config?.tool({ operation: 'file_get_content' })).toBe('file_get_content') + expect(block?.tools.config?.tool({ operation: 'file_fetch' })).toBe('file_fetch') + + // Read exposes files only; the redundant single-file output is gone + expect(block?.outputs.files).toBeDefined() + expect(block?.outputs.file).toBeUndefined() + // Get content exposes a contents array + expect(block?.outputs.contents).toBeDefined() + + // Get content resolves canonical IDs + expect( + block?.tools.config?.params?.({ + operation: 'file_get_content', + getContentInput: '["file-1","file-2"]', + _context: { workspaceId: 'workspace-1' }, + }) + ).toEqual({ + fileId: ['file-1', 'file-2'], + workspaceId: 'workspace-1', + }) + + // Get content resolves selected file objects + expect( + block?.tools.config?.params?.({ + operation: 'file_get_content', + getContentInput: [ + { + key: 'workspace/workspace-1/example.md', + name: 'example.md', + path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', + size: 123, + type: 'text/markdown', + }, + ], + _context: { workspaceId: 'workspace-1' }, + }) + ).toEqual({ + fileInput: [ + { + key: 'workspace/workspace-1/example.md', + name: 'example.md', + path: '/api/files/serve/workspace%2Fworkspace-1%2Fexample.md?context=workspace', + size: 123, + type: 'text/markdown', + }, + ], + workspaceId: 'workspace-1', + }) + }) }) describe('Agent block', () => { diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 7ca9a7a6fbc..7edc44b5429 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -550,7 +550,7 @@ Return ONLY the JSON array.`, conversationId: { type: 'string', description: - 'Specific conversation ID to retrieve memories from (when memoryType is conversation_id)', + 'Specific conversation ID to retrieve memories from (used when memoryType is conversation, sliding_window, or sliding_window_tokens)', }, slidingWindowSize: { type: 'string', diff --git a/apps/sim/blocks/blocks/file.test.ts b/apps/sim/blocks/blocks/file.test.ts index 4778c1faa26..30e0e7d977e 100644 --- a/apps/sim/blocks/blocks/file.test.ts +++ b/apps/sim/blocks/blocks/file.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { FileV4Block } from '@/blocks/blocks/file' +import { FileV4Block, FileV5Block } from '@/blocks/blocks/file' describe('FileV4Block', () => { const buildParams = FileV4Block.tools.config.params @@ -50,3 +50,71 @@ describe('FileV4Block', () => { ).toThrow('File URL must use http or https') }) }) + +describe('FileV5Block', () => { + const buildParams = FileV5Block.tools.config.params + + it('maps each operation directly to its tool', () => { + expect(FileV5Block.tools.config.tool({ operation: 'file_read' })).toBe('file_read') + expect(FileV5Block.tools.config.tool({ operation: 'file_get_content' })).toBe( + 'file_get_content' + ) + expect(FileV5Block.tools.config.tool({ operation: 'file_fetch' })).toBe('file_fetch') + expect(FileV5Block.tools.config.tool({ operation: 'file_write' })).toBe('file_write') + expect(FileV5Block.tools.config.tool({ operation: 'file_append' })).toBe('file_append') + }) + + it('read returns only the files output (no redundant file)', () => { + expect(FileV5Block.outputs.files).toBeDefined() + expect(FileV5Block.outputs.contents).toBeDefined() + expect(FileV5Block.outputs.file).toBeUndefined() + }) + + it('resolves canonical IDs for get content', () => { + expect( + buildParams({ + operation: 'file_get_content', + getContentInput: '["file-1","file-2"]', + _context: { workspaceId: 'workspace-1' }, + }) + ).toEqual({ + fileId: ['file-1', 'file-2'], + workspaceId: 'workspace-1', + }) + }) + + it('resolves selected file objects for get content', () => { + expect( + buildParams({ + operation: 'file_get_content', + getContentInput: [ + { + key: 'workspace/workspace-1/notes.md', + name: 'notes.md', + path: '/api/files/serve/workspace%2Fworkspace-1%2Fnotes.md?context=workspace', + size: 10, + type: 'text/markdown', + }, + ], + _context: { workspaceId: 'workspace-1' }, + }) + ).toEqual({ + fileInput: [ + { + key: 'workspace/workspace-1/notes.md', + name: 'notes.md', + path: '/api/files/serve/workspace%2Fworkspace-1%2Fnotes.md?context=workspace', + size: 10, + type: 'text/markdown', + }, + ], + workspaceId: 'workspace-1', + }) + }) + + it('throws when no file is provided for get content', () => { + expect(() => buildParams({ operation: 'file_get_content' })).toThrow( + 'File is required for get content' + ) + }) +}) diff --git a/apps/sim/blocks/blocks/file.ts b/apps/sim/blocks/blocks/file.ts index d976f8b29ed..d9dba608a26 100644 --- a/apps/sim/blocks/blocks/file.ts +++ b/apps/sim/blocks/blocks/file.ts @@ -563,11 +563,11 @@ const parseReadFileIds = (input: unknown): string | string[] | null => { export const FileV4Block: BlockConfig = { ...FileV3Block, type: 'file_v4', - name: 'File', + name: 'File (Legacy)', description: 'Read, fetch, write, and append files', longDescription: 'Read workspace files by picker or canonical ID, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.', - hideFromToolbar: false, + hideFromToolbar: true, bestPractices: ` - Use Read when you need an existing workspace file object by picker selection or canonical file ID. - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. @@ -817,3 +817,307 @@ export const FileV4Block: BlockConfig = { }, }, } + +export const FileV5Block: BlockConfig = { + ...FileV4Block, + type: 'file_v5', + name: 'File', + description: 'Read, get content, fetch, write, and append files', + longDescription: + 'Read workspace file objects, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, or append content to existing files.', + hideFromToolbar: false, + bestPractices: ` + - Read returns workspace file objects in the "files" output and does NOT include their text. Use it to pick files or pass file references downstream (e.g. as attachments). + - Get Content is how you read file text. It accepts file objects or canonical file IDs and returns a "contents" array with one extracted text string per file (PDF, DOCX, CSV, etc. are parsed automatically). + - To read the text of files produced by another block, chain into Get Content: set its file input to the upstream file output, e.g. , , or . Never assume Read (or any file-object output) already contains the text. + - Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way. + - Use Fetch for external file URLs. Add headers for authenticated downloads, for example Slack private file URLs require an Authorization Bearer token. + - Use Write to create a new workspace file and Append to add content to an existing one. + `, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown' as SubBlockType, + options: [ + { label: 'Read', id: 'file_read' }, + { label: 'Get Content', id: 'file_get_content' }, + { label: 'Fetch', id: 'file_fetch' }, + { label: 'Write', id: 'file_write' }, + { label: 'Append', id: 'file_append' }, + ], + value: () => 'file_read', + }, + { + id: 'readFile', + title: 'Files', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'readFileInput', + acceptedTypes: '*', + placeholder: 'Select workspace files', + multiple: true, + mode: 'basic', + condition: { field: 'operation', value: 'file_read' }, + required: { field: 'operation', value: 'file_read' }, + }, + { + id: 'readFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'readFileInput', + placeholder: 'Workspace file ID or JSON array of IDs', + mode: 'advanced', + condition: { field: 'operation', value: 'file_read' }, + required: { field: 'operation', value: 'file_read' }, + }, + { + id: 'getContentFile', + title: 'Files', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'getContentInput', + acceptedTypes: '*', + placeholder: 'Select workspace files', + multiple: true, + mode: 'basic', + condition: { field: 'operation', value: 'file_get_content' }, + required: { field: 'operation', value: 'file_get_content' }, + }, + { + id: 'getContentFileId', + title: 'File ID', + type: 'short-input' as SubBlockType, + canonicalParamId: 'getContentInput', + placeholder: 'Workspace file ID or JSON array of IDs', + mode: 'advanced', + condition: { field: 'operation', value: 'file_get_content' }, + required: { field: 'operation', value: 'file_get_content' }, + }, + { + id: 'fileUrl', + title: 'File URL', + type: 'short-input' as SubBlockType, + placeholder: 'https://example.com/document.pdf', + condition: { field: 'operation', value: 'file_fetch' }, + required: { field: 'operation', value: 'file_fetch' }, + }, + { + id: 'headers', + title: 'Headers', + type: 'table' as SubBlockType, + columns: ['Key', 'Value'], + description: + 'Custom headers for fetching the file URL, such as Authorization: Bearer .', + condition: { field: 'operation', value: 'file_fetch' }, + }, + { + id: 'fileName', + title: 'File Name', + type: 'short-input' as SubBlockType, + placeholder: 'File name (e.g., data.csv)', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'content', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'File content to write...', + condition: { field: 'operation', value: 'file_write' }, + required: { field: 'operation', value: 'file_write' }, + }, + { + id: 'contentType', + title: 'Content Type', + type: 'short-input' as SubBlockType, + placeholder: 'text/plain (auto-detected from extension)', + condition: { field: 'operation', value: 'file_write' }, + mode: 'advanced', + }, + { + id: 'appendFile', + title: 'File', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'appendFileInput', + acceptedTypes: '.txt,.md,.json,.csv,.xml,.html,.htm,.yaml,.yml,.log,.rtf', + placeholder: 'Select or upload a workspace file', + mode: 'basic', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + }, + { + id: 'appendFileName', + title: 'File', + type: 'short-input' as SubBlockType, + canonicalParamId: 'appendFileInput', + placeholder: 'File name (e.g., notes.md)', + mode: 'advanced', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + }, + { + id: 'appendContent', + title: 'Content', + type: 'long-input' as SubBlockType, + placeholder: 'Content to append...', + condition: { field: 'operation', value: 'file_append' }, + required: { field: 'operation', value: 'file_append' }, + }, + ], + tools: { + access: ['file_read', 'file_get_content', 'file_fetch', 'file_write', 'file_append'], + config: { + tool: (params) => params.operation || 'file_read', + params: (params) => { + const operation = params.operation || 'file_read' + + if (operation === 'file_write') { + return { + fileName: params.fileName, + content: params.content, + contentType: params.contentType, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_append') { + const appendInput = params.appendFileInput + if (!appendInput) { + throw new Error('File is required for append') + } + + let fileName: string + if (typeof appendInput === 'string') { + fileName = appendInput.trim() + } else { + const normalized = normalizeFileInput(appendInput, { single: true }) + const file = normalized as Record | null + fileName = (file?.name as string) ?? '' + } + + if (!fileName) { + throw new Error('Could not determine file name') + } + + return { + fileName, + content: params.appendContent, + workspaceId: params._context?.workspaceId, + } + } + + if (operation === 'file_fetch') { + const fileUrl = resolveHttpFileUrl(params.fileUrl) + + return { + filePath: fileUrl, + fileType: params.fileType || 'auto', + headers: params.headers, + workspaceId: params._context?.workspaceId, + workflowId: params._context?.workflowId, + executionId: params._context?.executionId, + } + } + + if (operation === 'file_get_content') { + const getContentInput = params.getContentInput + if (!getContentInput) { + throw new Error('File is required for get content') + } + + const fileIds = parseReadFileIds(getContentInput) + if (fileIds) { + return { + fileId: fileIds, + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(getContentInput) + if (!normalized || normalized.length === 0) { + throw new Error('File is required for get content') + } + + return { + fileInput: normalized, + workspaceId: params._context?.workspaceId, + } + } + + const readInput = params.readFileInput + if (!readInput) { + throw new Error('File is required for read') + } + + const fileIds = parseReadFileIds(readInput) + if (fileIds) { + return { + fileId: fileIds, + workspaceId: params._context?.workspaceId, + } + } + + const normalized = normalizeFileInput(readInput) + if (!normalized || normalized.length === 0) { + throw new Error('File is required for read') + } + + return { + fileInput: normalized, + workspaceId: params._context?.workspaceId, + } + }, + }, + }, + inputs: { + operation: { + type: 'string', + description: 'Operation to perform (read, get content, fetch, write, or append)', + }, + readFileInput: { + type: 'json', + description: 'Selected workspace file or canonical file ID for read', + }, + getContentInput: { + type: 'json', + description: 'Selected workspace file or canonical file ID to extract content from', + }, + fileUrl: { type: 'string', description: 'External file URL for fetch' }, + headers: { type: 'json', description: 'Request headers for fetch' }, + fileType: { type: 'string', description: 'File type for fetch' }, + fileName: { type: 'string', description: 'Name for a new file (write)' }, + content: { type: 'string', description: 'File content to write' }, + contentType: { type: 'string', description: 'MIME content type for write' }, + appendFileInput: { type: 'json', description: 'File to append to' }, + appendContent: { type: 'string', description: 'Content to append to file' }, + }, + outputs: { + files: { + type: 'file[]', + description: 'Workspace file objects (read) or fetched file objects (fetch)', + }, + contents: { + type: 'array', + description: 'Array of file text contents, one entry per file (get content)', + }, + combinedContent: { + type: 'string', + description: 'All fetched file contents merged into a single text string (fetch)', + }, + id: { + type: 'string', + description: 'File ID (write and append)', + }, + name: { + type: 'string', + description: 'File name (write and append)', + }, + size: { + type: 'number', + description: 'File size in bytes (write and append)', + }, + url: { + type: 'string', + description: 'URL to access the file (write and append)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 4d0d5bf1443..53e346953d9 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -60,7 +60,7 @@ import { EvernoteBlock, EvernoteBlockMeta } from '@/blocks/blocks/evernote' import { ExaBlock, ExaBlockMeta } from '@/blocks/blocks/exa' import { ExtendBlock, ExtendBlockMeta, ExtendV2Block } from '@/blocks/blocks/extend' import { FathomBlock, FathomBlockMeta } from '@/blocks/blocks/fathom' -import { FileBlock, FileV2Block, FileV3Block, FileV4Block } from '@/blocks/blocks/file' +import { FileBlock, FileV2Block, FileV3Block, FileV4Block, FileV5Block } from '@/blocks/blocks/file' import { FindymailBlock, FindymailBlockMeta } from '@/blocks/blocks/findymail' import { FirecrawlBlock, FirecrawlBlockMeta } from '@/blocks/blocks/firecrawl' import { @@ -384,6 +384,7 @@ const BLOCK_REGISTRY: Record = { file_v2: FileV2Block, file_v3: FileV3Block, file_v4: FileV4Block, + file_v5: FileV5Block, findymail: FindymailBlock, zerobounce: ZeroBounceBlock, neverbounce: NeverBounceBlock, diff --git a/apps/sim/executor/handlers/agent/skills-resolver.test.ts b/apps/sim/executor/handlers/agent/skills-resolver.test.ts new file mode 100644 index 00000000000..fdb2cb18c2b --- /dev/null +++ b/apps/sim/executor/handlers/agent/skills-resolver.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { limitMock } = vi.hoisted(() => ({ limitMock: vi.fn() })) + +vi.mock('@sim/db', () => ({ + db: { select: () => ({ from: () => ({ where: () => ({ limit: limitMock }) }) }) }, + skill: { workspaceId: 'workspaceId', name: 'name', content: 'content' }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) +vi.mock('drizzle-orm', () => ({ + and: vi.fn(() => ({})), + eq: vi.fn(() => ({})), + inArray: vi.fn(() => ({})), +})) + +import { resolveSkillContent } from './skills-resolver' + +// resolveSkillContent is the shared resolver invoked when the mothership calls +// load_user_skill (and when a workflow agent block calls load_skill). +describe('resolveSkillContent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null without a skill name or workspace', async () => { + expect(await resolveSkillContent('', 'ws-1')).toBeNull() + expect(await resolveSkillContent('x', '')).toBeNull() + }) + + it('resolves builtin skills without touching the database', async () => { + const content = await resolveSkillContent('research', 'ws-1') + expect(content).toBeTruthy() + expect(limitMock).not.toHaveBeenCalled() + }) + + it('resolves a workspace user skill by name', async () => { + limitMock.mockResolvedValue([{ content: '# Playbook', name: 'posthog-playbook' }]) + expect(await resolveSkillContent('posthog-playbook', 'ws-1')).toBe('# Playbook') + }) + + it('returns null when the user skill is not found', async () => { + limitMock.mockResolvedValue([]) + expect(await resolveSkillContent('missing', 'ws-1')).toBeNull() + }) +}) diff --git a/apps/sim/executor/handlers/agent/skills-resolver.ts b/apps/sim/executor/handlers/agent/skills-resolver.ts index c39968b454b..b5a145562ee 100644 --- a/apps/sim/executor/handlers/agent/skills-resolver.ts +++ b/apps/sim/executor/handlers/agent/skills-resolver.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { skill } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' +import { getBuiltinSkillById, getBuiltinSkillByName } from '@/lib/workflows/skills/builtin-skills' import type { SkillInput } from '@/executor/handlers/agent/types' const logger = createLogger('SkillsResolver') @@ -29,18 +30,29 @@ export async function resolveSkillMetadata( ): Promise { if (!skillInputs.length || !workspaceId) return [] - const skillIds = skillInputs.map((s) => s.skillId) + const metadata: SkillMetadata[] = [] + const dbSkillIds: string[] = [] + for (const input of skillInputs) { + const builtin = getBuiltinSkillById(input.skillId) + if (builtin) { + metadata.push({ name: builtin.name, description: builtin.description }) + } else { + dbSkillIds.push(input.skillId) + } + } + + if (dbSkillIds.length === 0) return metadata try { const rows = await db .select({ name: skill.name, description: skill.description }) .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))) + .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, dbSkillIds))) - return rows + return [...metadata, ...rows] } catch (error) { - logger.error('Failed to resolve skill metadata', { error, skillIds, workspaceId }) - return [] + logger.error('Failed to resolve skill metadata', { error, dbSkillIds, workspaceId }) + return metadata } } @@ -54,6 +66,9 @@ export async function resolveSkillContent( ): Promise { if (!skillName || !workspaceId) return null + const builtin = getBuiltinSkillByName(skillName) + if (builtin) return builtin.content + try { const rows = await db .select({ content: skill.content, name: skill.name }) @@ -79,6 +94,9 @@ export async function resolveSkillContentById( ): Promise<{ name: string; content: string } | null> { if (!skillId || !workspaceId) return null + const builtin = getBuiltinSkillById(skillId) + if (builtin) return { name: builtin.name, content: builtin.content } + try { const rows = await db .select({ content: skill.content, name: skill.name }) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index a646ce6b4e5..2aec8abc939 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -302,6 +302,59 @@ describe('MothershipBlockHandler', () => { }) }) + it('preserves failed tool calls as output metadata without throwing', async () => { + mockGenerateId.mockReturnValueOnce('chat-uuid') + mockGenerateId.mockReturnValueOnce('message-uuid') + mockGenerateId.mockReturnValueOnce('request-uuid') + + fetchMock.mockResolvedValue( + createNdjsonResponse([ + { + type: 'final', + data: { + content: 'The lookup failed, so I could not use that result.', + model: 'mothership', + conversationId: 'chat-uuid', + tokens: { total: 7 }, + toolCalls: [ + { + name: 'lookup_customer', + status: 'error', + params: { email: 'missing@example.com' }, + result: { success: false, error: 'Customer not found' }, + error: 'Customer not found', + durationMs: 42, + }, + ], + }, + }, + ]) + ) + + const result = await handler.execute(context, block, { prompt: 'Hello from workflow' }) + + expect(result).toEqual({ + content: 'The lookup failed, so I could not use that result.', + model: 'mothership', + conversationId: 'chat-uuid', + tokens: { total: 7 }, + toolCalls: { + list: [ + expect.objectContaining({ + name: 'lookup_customer', + status: 'error', + arguments: { email: 'missing@example.com' }, + result: { success: false, error: 'Customer not found' }, + error: 'Customer not found', + duration: 42, + }), + ], + count: 1, + }, + cost: undefined, + }) + }) + it('surfaces mothership execute stream errors', async () => { mockGenerateId.mockReturnValueOnce('chat-uuid') mockGenerateId.mockReturnValueOnce('message-uuid') diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 60e2873f0ae..df66b4fc0d2 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -63,11 +63,9 @@ function formatMothershipBlockOutput( ): NormalizedBlockOutput { const formattedList = (result.toolCalls || []).map((tc: Record) => ({ name: typeof tc.name === 'string' ? tc.name : String(tc.name ?? ''), - arguments: (tc.params && typeof tc.params === 'object' ? tc.params : {}) as Record< - string, - unknown - >, - result: tc.result as any, + ...(typeof tc.status === 'string' ? { status: tc.status } : {}), + arguments: (tc.arguments || tc.params || tc.input || {}) as Record, + result: (tc.result ?? tc.output) as any, error: typeof tc.error === 'string' ? tc.error : undefined, duration: typeof tc.durationMs === 'number' ? tc.durationMs : 0, })) diff --git a/apps/sim/hooks/queries/mothership-admin.ts b/apps/sim/hooks/queries/mothership-admin.ts index 544313477cb..54fa8ada160 100644 --- a/apps/sim/hooks/queries/mothership-admin.ts +++ b/apps/sim/hooks/queries/mothership-admin.ts @@ -1,4 +1,4 @@ -import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query' +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' export type MothershipEnv = 'default' | 'dev' | 'staging' | 'prod' @@ -52,6 +52,24 @@ async function mothershipGet( return res.json() } +// Enterprise BYOK does NOT use the cross-env admin proxy. It talks to the +// workspace's own copilot (SIM_AGENT_API_URL — local in dev, prod copilot in +// prod) via a dedicated same-origin route that authenticates with the hosted +// internal key. So it always targets the copilot the mothership actually runs +// on, never a deployed dev/staging URL. +const BYOK_BASE = '/api/copilot/byok' + +async function byokFetch(url: string, init?: RequestInit) { + // boundary-raw-fetch: thin same-origin proxy to copilot; response shape is the + // upstream copilot JSON. + const res = await fetch(url, init) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.message || err.error || `Request failed (${res.status})`) + } + return res.json() +} + export const mothershipKeys = { all: ['mothership-admin'] as const, requests: (env: MothershipEnv, start: string, end: string, userId?: string) => @@ -61,10 +79,58 @@ export const mothershipKeys = { licenses: (env: MothershipEnv) => [...mothershipKeys.all, 'licenses', env] as const, licenseDetails: (env: MothershipEnv, id?: string, name?: string) => [...mothershipKeys.all, 'license-details', env, id, name] as const, - enterpriseStats: (env: MothershipEnv, customerType: string, start: string, end: string) => - [...mothershipKeys.all, 'enterprise-stats', env, customerType, start, end] as const, - trace: (env: MothershipEnv, requestId: string) => - [...mothershipKeys.all, 'trace', env, requestId] as const, + byok: (workspaceId: string) => [...mothershipKeys.all, 'byok', workspaceId] as const, +} + +export interface MothershipByokKey { + provider: string + keyLastFour: string + createdBy: string + createdAt: string + updatedAt: string +} + +/** List the enterprise BYOK keys stored for a workspace (metadata only). */ +export function useMothershipByokKeys(workspaceId: string) { + return useQuery({ + queryKey: mothershipKeys.byok(workspaceId), + queryFn: ({ signal }) => + byokFetch(`${BYOK_BASE}?workspaceId=${encodeURIComponent(workspaceId)}`, { signal }), + enabled: !!workspaceId, + staleTime: 30 * 1000, + }) +} + +/** Store (or replace) a workspace's enterprise BYOK key for a provider. */ +export function useUpsertMothershipByok() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: { workspaceId: string; provider: string; apiKey: string }) => + byokFetch(BYOK_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }), + onSuccess: (_data, params) => + queryClient.invalidateQueries({ queryKey: mothershipKeys.byok(params.workspaceId) }), + }) +} + +/** Delete a workspace's enterprise BYOK key for a provider. */ +export function useDeleteMothershipByok() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: { workspaceId: string; provider: string }) => + byokFetch( + `${BYOK_BASE}?${new URLSearchParams({ + workspaceId: params.workspaceId, + provider: params.provider, + }).toString()}`, + { method: 'DELETE' } + ), + onSuccess: (_data, params) => + queryClient.invalidateQueries({ queryKey: mothershipKeys.byok(params.workspaceId) }), + }) } export function useMothershipRequests( @@ -138,28 +204,3 @@ export function useGenerateLicense(environment: MothershipEnv) { mothershipPost('licenses/generate', environment, params), }) } - -export function useMothershipEnterpriseStats( - environment: MothershipEnv, - customerType: string, - start: string, - end: string -) { - return useQuery({ - queryKey: mothershipKeys.enterpriseStats(environment, customerType, start, end), - queryFn: ({ signal }) => - mothershipPost('enterprise-stats', environment, { customerType, start, end }, signal), - enabled: !!customerType && !!start && !!end, - staleTime: 60 * 1000, - placeholderData: keepPreviousData, - }) -} - -export function useMothershipTrace(environment: MothershipEnv, requestId: string) { - return useQuery({ - queryKey: mothershipKeys.trace(environment, requestId), - queryFn: ({ signal }) => mothershipGet('traces', environment, { requestId }, signal), - enabled: !!requestId, - staleTime: 60 * 1000, - }) -} diff --git a/apps/sim/hooks/queries/mothership-settings.ts b/apps/sim/hooks/queries/mothership-settings.ts deleted file mode 100644 index 6dbde4d4cdc..00000000000 --- a/apps/sim/hooks/queries/mothership-settings.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { requestJson } from '@/lib/api/client/request' -import { - getMothershipSettingsContract, - type MothershipSettings, - updateMothershipSettingsContract, -} from '@/lib/api/contracts/mothership-settings' - -export const mothershipSettingsKeys = { - all: ['mothership-settings'] as const, - detail: (workspaceId: string) => [...mothershipSettingsKeys.all, workspaceId] as const, -} - -async function fetchMothershipSettings( - workspaceId: string, - signal?: AbortSignal -): Promise { - const { data } = await requestJson(getMothershipSettingsContract, { - query: { workspaceId }, - signal, - }) - return data -} - -export function useMothershipSettings(workspaceId: string) { - return useQuery({ - queryKey: mothershipSettingsKeys.detail(workspaceId), - queryFn: ({ signal }) => fetchMothershipSettings(workspaceId, signal), - enabled: !!workspaceId, - staleTime: 60 * 1000, - }) -} - -export function useUpdateMothershipSettings() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async (settings: MothershipSettings) => { - const { data } = await requestJson(updateMothershipSettingsContract, { - body: { - workspaceId: settings.workspaceId, - mcpTools: settings.mcpTools, - customTools: settings.customTools, - skills: settings.skills, - }, - }) - return data - }, - onMutate: async (settings) => { - await queryClient.cancelQueries({ - queryKey: mothershipSettingsKeys.detail(settings.workspaceId), - }) - - const previous = queryClient.getQueryData( - mothershipSettingsKeys.detail(settings.workspaceId) - ) - - queryClient.setQueryData(mothershipSettingsKeys.detail(settings.workspaceId), settings) - return { previous } - }, - onError: (_error, settings, context) => { - if (context?.previous) { - queryClient.setQueryData( - mothershipSettingsKeys.detail(settings.workspaceId), - context.previous - ) - } - }, - onSettled: (_data, _error, settings) => { - queryClient.invalidateQueries({ - queryKey: mothershipSettingsKeys.detail(settings.workspaceId), - }) - }, - }) -} diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index e95372eedc5..ac02eeacccb 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -16,10 +16,14 @@ import { } from '@/lib/api/contracts/invitations' import { createOrganizationContract, + getMyMemberCreditsContract, + getOrganizationMemberUsageLimitContract, getOrganizationRosterContract, inviteOrganizationMembersContract, listOrganizationMembersContract, + type MyMemberCreditsData, type OrganizationMembersResponse, + type OrganizationMemberUsageLimitData, type OrganizationRoster, type RosterMember, type RosterPendingInvitation, @@ -28,6 +32,7 @@ import { transferOwnershipContract, updateOrganizationContract, updateOrganizationMemberRoleContract, + updateOrganizationMemberUsageLimitContract, updateOrganizationUsageLimitContract, } from '@/lib/api/contracts/organization' import { @@ -101,7 +106,11 @@ export const organizationKeys = { billing: (id: string) => [...organizationKeys.detail(id), 'billing'] as const, members: (id: string) => [...organizationKeys.detail(id), 'members'] as const, memberUsage: (id: string) => [...organizationKeys.detail(id), 'member-usage'] as const, + memberUsageLimit: (id: string, userId: string) => + [...organizationKeys.detail(id), 'member-usage-limit', userId] as const, roster: (id: string) => [...organizationKeys.detail(id), 'roster'] as const, + myMemberCredits: (workspaceId: string) => + [...organizationKeys.all, 'my-member-credits', workspaceId] as const, } export type { OrganizationRoster, RosterMember, RosterPendingInvitation, RosterWorkspaceAccess } @@ -485,6 +494,81 @@ export function useUpdateOrganizationMemberRole() { }) } +async function fetchOrganizationMemberUsageLimit( + orgId: string, + userId: string, + signal?: AbortSignal +): Promise { + const response = await requestJson(getOrganizationMemberUsageLimitContract, { + params: { id: orgId, memberId: userId }, + signal, + }) + return response.data +} + +/** + * Hook to fetch a single member's per-org credit usage + cap (values in credits). + * Lazily enabled so it only fires while the Manage Credits modal is open. + */ +export function useOrganizationMemberUsageLimit(orgId?: string, userId?: string, enabled = true) { + return useQuery({ + queryKey: organizationKeys.memberUsageLimit(orgId ?? '', userId ?? ''), + queryFn: ({ signal }) => + fetchOrganizationMemberUsageLimit(orgId as string, userId as string, signal), + enabled: Boolean(orgId) && Boolean(userId) && enabled, + staleTime: 30 * 1000, + }) +} + +interface UpdateMemberUsageLimitParams { + orgId: string + userId: string + creditLimit: ContractBodyInput['creditLimit'] +} + +export function useUpdateOrganizationMemberUsageLimit() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ orgId, userId, creditLimit }: UpdateMemberUsageLimitParams) => { + return requestJson(updateOrganizationMemberUsageLimitContract, { + params: { id: orgId, memberId: userId }, + body: { creditLimit }, + }) + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.memberUsageLimit(variables.orgId, variables.userId), + }) + }, + }) +} + +async function fetchMyMemberCredits( + workspaceId: string, + signal?: AbortSignal +): Promise { + const response = await requestJson(getMyMemberCreditsContract, { + query: { workspaceId }, + signal, + }) + return response.data +} + +/** + * The caller's OWN per-member credit usage + cap for a workspace's organization. + * `creditLimit` is null when no per-member cap applies (non-hosted, non-org + * workspace, or no cap set) — callers then fall back to the plan-level view. + */ +export function useMyMemberCredits(workspaceId?: string) { + return useQuery({ + queryKey: organizationKeys.myMemberCredits(workspaceId ?? ''), + queryFn: ({ signal }) => fetchMyMemberCredits(workspaceId as string, signal), + enabled: Boolean(workspaceId), + staleTime: 30 * 1000, + }) +} + type TransferOwnershipParams = { orgId: string } & ContractBodyInput diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 492c77286ec..6d97754ba42 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from '@/components/emcn' import { ApiClientError, isApiClientError } from '@/lib/api/client/errors' @@ -151,10 +152,42 @@ export function useWorkspaceFileContent( }) } -async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Promise { - const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&t=${Date.now()}` +/** + * Thrown when the serve route returns 409 — a generated document (pptx/docx/pdf/ + * xlsx) whose source is still being written/compiled. Distinct from a real fetch + * failure so the binary query can keep retrying (and the preview keeps showing + * its loading state) until the compiled artifact is ready. + */ +export class DocNotReadyError extends Error { + constructor() { + super('Document is still being generated') + this.name = 'DocNotReadyError' + } +} + +/** + * Fetch compiled/binary file content via the serve URL. + * + * A `version` (the file record's `updatedAt`) makes the URL content-immutable: the + * serve route marks versioned responses `immutable`, so the browser HTTP cache + * resolves re-opens and focus refetches with no round trip. Generated docs are + * edited in place (same storage key), so an unversioned caller cannot assume + * immutability and instead busts + bypasses the cache to always read fresh. A 409 + * means a generated doc is still compiling — surfaced as {@link DocNotReadyError} + * so the query keeps polling. + */ +async function fetchWorkspaceFileBinary( + key: string, + version: string | number | undefined, + signal?: AbortSignal +): Promise { + const cacheParam = + version != null ? `v=${encodeURIComponent(String(version))}` : `t=${Date.now()}` + const serveUrl = `/api/files/serve/${encodeURIComponent(key)}?context=workspace&${cacheParam}` + const init: RequestInit = version != null ? { signal } : { signal, cache: 'no-store' } // boundary-raw-fetch: binary download consumed as ArrayBuffer - const response = await fetch(serveUrl, { signal, cache: 'no-store' }) + const response = await fetch(serveUrl, init) + if (response.status === 409) throw new DocNotReadyError() if (!response.ok) throw new Error('Failed to fetch file content') return response.arrayBuffer() } @@ -163,14 +196,48 @@ async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Prom * Hook to fetch workspace file content as binary (ArrayBuffer). * `key` (the storage object key) is forwarded into the query key factory so that a new * storage key (e.g. after a file is re-uploaded) correctly busts the cache. + * + * `options.version` is a content version (the record's `updatedAt`) folded into the + * query key. Generated docs are edited IN PLACE — `edit_content` keeps the SAME + * storage key — so without a version the cache is never busted and the open + * preview keeps showing the stale binary after a regenerate. Versioning the key + * makes the preview refetch whenever the file's content changes (and on first + * open, keyed to the current content rather than a stale cached entry). */ -export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: string) { +export function useWorkspaceFileBinary( + workspaceId: string, + fileId: string, + key: string, + options?: { enabled?: boolean; version?: string | number } +) { return useQuery({ - queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), - queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal), - enabled: !!workspaceId && !!fileId && !!key, + queryKey: + options?.version != null + ? [...workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), options.version] + : workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), + queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, options?.version, signal), + // Callers gate this on a readiness signal (e.g. the file has committed + // content) so we don't 409-poll the serve route for a generated doc whose + // compiled artifact hasn't been written yet — the doc is fetched once, when + // it's actually ready, instead of hammering the serve URL through generation. + enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true), staleTime: 30 * 1000, refetchOnWindowFocus: 'always', + placeholderData: keepPreviousData, + // While a generated doc is still compiling, serve returns 409. Poll (stay in + // the loading state) until the artifact is ready instead of surfacing an + // error. The artifact is written before the source commits, so a fresh serve + // normally hits immediately; this only bridges S3 read-after-write lag and the + // brief mid-generation window. Poll on a short jittered backoff (~0.6s rising + // to ~2.5s, ~30s budget) so the common case recovers fast without hammering the + // serve URL on the long tail. SSE content invalidation also re-fetches when the + // file actually updates. + retry: (failureCount, error) => + error instanceof DocNotReadyError ? failureCount < 14 : failureCount < 2, + retryDelay: (failureCount, error) => + error instanceof DocNotReadyError + ? backoffWithJitter(failureCount, null, { baseMs: 600, maxMs: 2500 }) + : Math.min(1000 * 2 ** failureCount, 5000), }) } diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts new file mode 100644 index 00000000000..5ad10a63421 --- /dev/null +++ b/apps/sim/hooks/use-smooth-text.ts @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * Per-frame reveal speed is proportional to how far behind the display is, so a + * large burst drains quickly while a trickle reveals gently. `target / DIVISOR` + * gives an ease-out feel; the clamps keep it from stalling or jumping. + */ +const REVEAL_DIVISOR = 6 +const MIN_STEP = 1 +const MAX_STEP = 400 + +/** + * Content already longer than this at mount is assumed to be an in-progress + * resume (or restored history), so it is shown immediately rather than replayed + * from the first character. + */ +const RESUME_SKIP_THRESHOLD = 60 + +interface SmoothTextOptions { + /** + * When a content update is not a continuation of the previous string (the new + * value does not start with the old one — e.g. an in-place patch/rewrite + * rather than an append), show it in full immediately instead of re-revealing + * a prefix. Keeps diff/patch previews correct while still pacing ordinary + * append streams. Defaults to `false`, which keeps the original + * pull-back-on-shrink behavior used by the chat. + */ + snapOnNonAppend?: boolean +} + +/** + * Paces a growing string so it reveals at a steady cadence regardless of how + * bursty the upstream stream is — the client-side analogue of the AI SDK's + * `smoothStream`. Returns the portion of `content` that should be displayed now. + * + * While `isStreaming` is false the full string is returned unchanged (history + * and completed turns never animate). When streaming ends mid-reveal the + * remaining tail is shown immediately so nothing is left hidden. + */ +export function useSmoothText( + content: string, + isStreaming: boolean, + options?: SmoothTextOptions +): string { + const snapOnNonAppend = options?.snapOnNonAppend ?? false + + const [revealed, setRevealed] = useState(() => + isStreaming && content.length <= RESUME_SKIP_THRESHOLD ? 0 : content.length + ) + + const contentRef = useRef(content) + const streamingRef = useRef(isStreaming) + const revealedRef = useRef(revealed) + const frameRef = useRef(null) + const prevContentRef = useRef(content) + + // A non-append rewrite (e.g. a patch replacing earlier text) must be shown in + // full at once — re-revealing a prefix of rewritten content would look like + // the document is retyping itself. Adjust during render so the slice below + // never flashes a stale prefix. + let effectiveRevealed = revealed + if ( + snapOnNonAppend && + content !== prevContentRef.current && + !content.startsWith(prevContentRef.current) && + revealed < content.length + ) { + effectiveRevealed = content.length + revealedRef.current = content.length + setRevealed(content.length) + } + prevContentRef.current = content + + contentRef.current = content + streamingRef.current = isStreaming + + // Key the reveal loop to streaming + remaining backlog, NOT to `content`: + // `content` changes on every streamed chunk, and re-subscribing an rAF + setState + // loop on each change is the "a dependency changes on every render" pattern that + // trips React's max-update-depth guard. The running tick reads the latest content + // from `contentRef`, so new chunks are absorbed without per-chunk teardown; + // `hasBacklog` only flips when the reveal falls behind or catches up. + if (!isStreaming && effectiveRevealed !== content.length) { + effectiveRevealed = content.length + revealedRef.current = content.length + } + + const hasBacklog = effectiveRevealed < content.length + + useEffect(() => { + if (!isStreaming) { + revealedRef.current = contentRef.current.length + setRevealed(contentRef.current.length) + return + } + + const tick = () => { + const target = contentRef.current.length + // Upstream sanitization can rewrite earlier text and shrink the string; + // pull the cursor back to the new end so regrowth stays paced rather than + // jumping past it. + if (revealedRef.current > target) { + revealedRef.current = target + setRevealed(target) + } + const current = revealedRef.current + + if (!streamingRef.current) { + revealedRef.current = target + setRevealed(target) + frameRef.current = null + return + } + if (current >= target) { + frameRef.current = null + return + } + + const backlog = target - current + const step = Math.min(MAX_STEP, Math.max(MIN_STEP, Math.ceil(backlog / REVEAL_DIVISOR))) + const next = current + step + revealedRef.current = next + setRevealed(next) + frameRef.current = window.requestAnimationFrame(tick) + } + + if (hasBacklog && frameRef.current === null) { + frameRef.current = window.requestAnimationFrame(tick) + } + + return () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + } + }, [isStreaming, hasBacklog]) + + // Content can shrink when upstream sanitization rewrites earlier text; never + // hand back a slice index past the current end. + if (effectiveRevealed >= content.length) return content + return content.slice(0, effectiveRevealed) +} diff --git a/apps/sim/hooks/use-speech-to-text.ts b/apps/sim/hooks/use-speech-to-text.ts index e1500b47296..b57296bc658 100644 --- a/apps/sim/hooks/use-speech-to-text.ts +++ b/apps/sim/hooks/use-speech-to-text.ts @@ -20,8 +20,14 @@ export type PermissionState = 'prompt' | 'granted' | 'denied' interface UseSpeechToTextProps { onTranscript: (text: string) => void - onUsageLimitExceeded?: () => void + /** + * Called on a 402 from the token endpoint, with the server's limit message and + * whether it was a per-member cap (which only an org admin can raise). + */ + onUsageLimitExceeded?: (message?: string, isMemberLimit?: boolean) => void language?: string + /** Attributes the voice-input cost to this workspace for per-member usage. */ + workspaceId?: string } interface UseSpeechToTextReturn { @@ -36,6 +42,7 @@ export function useSpeechToText({ onTranscript, onUsageLimitExceeded, language, + workspaceId, }: UseSpeechToTextProps): UseSpeechToTextReturn { const [isListening, setIsListening] = useState(false) const [isSupported, setIsSupported] = useState(false) @@ -44,6 +51,7 @@ export function useSpeechToText({ const onTranscriptRef = useRef(onTranscript) const onUsageLimitExceededRef = useRef(onUsageLimitExceeded) const languageRef = useRef(language) + const workspaceIdRef = useRef(workspaceId) const mountedRef = useRef(true) const startingRef = useRef(false) @@ -62,6 +70,7 @@ export function useSpeechToText({ onTranscriptRef.current = onTranscript onUsageLimitExceededRef.current = onUsageLimitExceeded languageRef.current = language + workspaceIdRef.current = workspaceId useEffect(() => { const browserOk = @@ -166,10 +175,13 @@ export function useSpeechToText({ try { let tokenData: Awaited>> try { - tokenData = await requestJson(speechTokenContract, { body: {} }) + tokenData = await requestJson(speechTokenContract, { + body: workspaceIdRef.current ? { workspaceId: workspaceIdRef.current } : {}, + }) } catch (err) { if (isApiClientError(err) && err.status === 402) { - onUsageLimitExceededRef.current?.() + const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member' + onUsageLimitExceededRef.current?.(err.message, isMemberLimit) return false } throw err instanceof Error ? err : new Error('Failed to get speech token') diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 3970246cffb..36711774924 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -57,13 +57,6 @@ export const createWorkflowCopilotChatBodySchema = z.object({ }) export type CreateWorkflowCopilotChatBody = z.input -export const copilotStatsBodySchema = z.object({ - messageId: z.string(), - diffCreated: z.boolean(), - diffAccepted: z.boolean(), -}) -export type CopilotStatsBody = z.input - export const copilotTrainingExampleBodySchema = z.object({ json: z.string().min(1, 'JSON string is required'), title: z.string().min(1, 'Title is required'), @@ -93,16 +86,6 @@ export const renameCopilotChatBodySchema = z.object({ }) export type RenameCopilotChatBody = z.input -export const copilotToolPreferenceBodySchema = z.object({ - toolId: z.string().min(1), -}) -export type CopilotToolPreferenceBody = z.input - -export const copilotToolPreferenceQuerySchema = z.object({ - toolId: z.string().min(1), -}) -export type CopilotToolPreferenceQuery = z.input - const copilotResourceTypeSchema = z.enum([ 'table', 'file', @@ -280,6 +263,13 @@ export type UpdateCopilotMessagesBody = z.input @@ -472,13 +462,6 @@ export const copilotCredentialsContract = defineRouteContract({ }, }) -export const copilotStatsContract = defineRouteContract({ - method: 'POST', - path: '/api/copilot/stats', - body: copilotStatsBodySchema, - response: { mode: 'json', schema: successFlagSchema }, -}) - export const validateCopilotApiKeyContract = defineRouteContract({ method: 'POST', path: '/api/copilot/api-keys/validate', @@ -486,6 +469,79 @@ export const validateCopilotApiKeyContract = defineRouteContract({ response: { mode: 'empty' }, }) +export const validateCopilotByokBodySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + userId: z.string().min(1, 'userId is required'), +}) +export type ValidateCopilotByokBody = z.input + +/** + * Server-to-server entitlement gate called by the mothership (Go) before it + * uses a workspace's own provider key. Empty 200/401/403 responses signal the + * outcome; the Go caller fails closed to hosted keys on anything but a 200. + */ +export const validateCopilotByokContract = defineRouteContract({ + method: 'POST', + path: '/api/copilot/byok/validate', + body: validateCopilotByokBodySchema, + response: { mode: 'empty' }, +}) + +export const listCopilotByokKeysQuerySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), +}) +export type ListCopilotByokKeysQuery = z.input + +export const upsertCopilotByokKeyBodySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + provider: z.string().min(1, 'provider is required'), + apiKey: z.string().min(1, 'apiKey is required'), +}) +export type UpsertCopilotByokKeyBody = z.input + +export const deleteCopilotByokKeyQuerySchema = z.object({ + workspaceId: z.string().min(1, 'workspaceId is required'), + provider: z.string().min(1, 'provider is required'), +}) +export type DeleteCopilotByokKeyQuery = z.input + +/** + * Superuser-gated proxies to the copilot's `/api/admin/byok` endpoints. The + * responses are owned by the copilot service and forwarded verbatim. + */ +export const listCopilotByokKeysContract = defineRouteContract({ + method: 'GET', + path: '/api/copilot/byok', + query: listCopilotByokKeysQuerySchema, + response: { + mode: 'json', + // untyped-response: forwards the copilot /api/admin/byok response unchanged; shape is owned by the copilot service + schema: z.unknown(), + }, +}) + +export const upsertCopilotByokKeyContract = defineRouteContract({ + method: 'POST', + path: '/api/copilot/byok', + body: upsertCopilotByokKeyBodySchema, + response: { + mode: 'json', + // untyped-response: forwards the copilot /api/admin/byok response unchanged; shape is owned by the copilot service + schema: z.unknown(), + }, +}) + +export const deleteCopilotByokKeyContract = defineRouteContract({ + method: 'DELETE', + path: '/api/copilot/byok', + query: deleteCopilotByokKeyQuerySchema, + response: { + mode: 'json', + // untyped-response: forwards the copilot /api/admin/byok response unchanged; shape is owned by the copilot service + schema: z.unknown(), + }, +}) + export const createWorkflowCopilotChatContract = defineRouteContract({ method: 'POST', path: '/api/copilot/chats', @@ -629,32 +685,6 @@ export const renameCopilotChatContract = defineRouteContract({ response: { mode: 'json', schema: successFlagSchema }, }) -export const addCopilotAutoAllowedToolContract = defineRouteContract({ - method: 'POST', - path: '/api/copilot/auto-allowed-tools', - body: copilotToolPreferenceBodySchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - autoAllowedTools: z.array(z.string()), - }), - }, -}) - -export const removeCopilotAutoAllowedToolContract = defineRouteContract({ - method: 'DELETE', - path: '/api/copilot/auto-allowed-tools', - query: copilotToolPreferenceQuerySchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - autoAllowedTools: z.array(z.string()), - }), - }, -}) - export const revertCopilotCheckpointContract = defineRouteContract({ method: 'POST', path: '/api/copilot/checkpoints/revert', diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 7ea571b23d7..db667c50aa4 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -56,6 +56,8 @@ const wandGenerateBodySchema = z.object({ stream: z.boolean().optional().default(false), history: z.array(chatMessageSchema).optional().default([]), workflowId: z.string().optional(), + /** Falls back here for per-member usage attribution when no workflowId is sent. */ + workspaceId: z.string().optional(), generationType: z.string().optional(), wandContext: unknownRecordSchema.optional().default({}), }) @@ -81,6 +83,38 @@ export const wandGenerateStreamContract = defineRouteContract({ }, }) +const functionFileInputSchema = z + .object({ + path: z.string().min(1, 'Input file path is required'), + sandboxPath: z.string().optional(), + }) + .strict() + +const functionDirectoryInputSchema = z + .object({ + path: z.string().min(1, 'Input directory path is required'), + sandboxPath: z.string().optional(), + }) + .strict() + +const functionTableInputSchema = z + .object({ + path: z.string().optional(), + tableId: z.string().optional(), + sandboxPath: z.string().optional(), + }) + .strict() + +const functionOutputFileSchema = z + .object({ + path: z.string().min(1, 'Output file path is required'), + mode: z.enum(['create', 'overwrite']).default('create'), + sandboxPath: z.string().optional(), + format: z.enum(['json', 'csv', 'txt', 'md', 'html']).optional(), + mimeType: z.string().optional(), + }) + .strict() + export const functionExecuteContract = defineRouteContract({ method: 'POST', path: '/api/function/execute', @@ -90,11 +124,27 @@ export const functionExecuteContract = defineRouteContract({ params: unknownRecordSchema.optional().default({}), timeout: z.coerce.number().int().positive().optional(), language: z.string().optional().default(DEFAULT_CODE_LANGUAGE), + title: z.string().optional(), outputPath: z.string().optional(), outputFormat: z.string().optional(), outputTable: z.string().optional(), outputMimeType: z.string().optional(), outputSandboxPath: z.string().optional(), + overwriteFileId: z.string().optional(), + inputs: z + .object({ + files: z.array(functionFileInputSchema).optional(), + directories: z.array(functionDirectoryInputSchema).optional(), + tables: z.array(functionTableInputSchema).optional(), + }) + .strict() + .optional(), + outputs: z + .object({ + files: z.array(functionOutputFileSchema).optional(), + }) + .strict() + .optional(), envVars: z.record(z.string(), z.string()).optional().default({}), blockData: unknownRecordSchema.optional().default({}), blockNameMapping: z.record(z.string(), z.string()).optional().default({}), diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 0a83f1c61e7..0001d2b51b9 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -17,7 +17,6 @@ export * from './folders' export * from './hotspots' export * from './inbox' export * from './media' -export * from './mothership-settings' export * from './notifications' export * from './permission-groups' export * from './primitives' diff --git a/apps/sim/lib/api/contracts/media/speech.ts b/apps/sim/lib/api/contracts/media/speech.ts index eb691bd3485..f01e47b213a 100644 --- a/apps/sim/lib/api/contracts/media/speech.ts +++ b/apps/sim/lib/api/contracts/media/speech.ts @@ -4,6 +4,8 @@ import { defineRouteContract } from '@/lib/api/contracts/types' export const speechTokenBodySchema = z .object({ chatId: z.string().optional(), + /** Editor/workspace voice: the workspace the session user is recording in. */ + workspaceId: z.string().optional(), }) .passthrough() diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 2c65c824c2c..2a5a9e95484 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -78,6 +78,12 @@ export const mothershipExecuteBodySchema = z.object({ fileAttachments: z.array(mothershipExecuteFileAttachmentSchema).optional(), workflowId: z.string().optional(), executionId: z.string().optional(), + userMetadata: z + .object({ + name: z.string().optional(), + timezone: z.string().optional(), + }) + .optional(), }) export type MothershipExecuteBody = z.input diff --git a/apps/sim/lib/api/contracts/mothership-settings.ts b/apps/sim/lib/api/contracts/mothership-settings.ts deleted file mode 100644 index 9af57ec9ae0..00000000000 --- a/apps/sim/lib/api/contracts/mothership-settings.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' - -const dateStringSchema = z.preprocess( - (value) => (value instanceof Date ? value.toISOString() : value), - z.string() -) - -export const mothershipMcpToolRefSchema = z.object({ - serverId: z.string().min(1), - serverName: z.string().optional(), - toolName: z.string().min(1), - title: z.string().optional(), -}) - -export const mothershipCustomToolRefSchema = z.object({ - customToolId: z.string().min(1), - title: z.string().optional(), -}) - -export const mothershipSkillRefSchema = z.object({ - skillId: z.string().min(1), - name: z.string().optional(), -}) - -export const mothershipSettingsSchema = z.object({ - workspaceId: z.string().min(1), - mcpTools: z.array(mothershipMcpToolRefSchema).default([]), - customTools: z.array(mothershipCustomToolRefSchema).default([]), - skills: z.array(mothershipSkillRefSchema).default([]), - createdAt: dateStringSchema.optional(), - updatedAt: dateStringSchema.optional(), -}) - -export type MothershipMcpToolRef = z.output -export type MothershipCustomToolRef = z.output -export type MothershipSkillRef = z.output -export type MothershipSettings = z.output - -export const getMothershipSettingsQuerySchema = z.object({ - workspaceId: z.string().min(1), -}) - -export const updateMothershipSettingsBodySchema = z.object({ - workspaceId: z.string().min(1), - mcpTools: z.array(mothershipMcpToolRefSchema).default([]), - customTools: z.array(mothershipCustomToolRefSchema).default([]), - skills: z.array(mothershipSkillRefSchema).default([]), -}) - -export const getMothershipSettingsContract = defineRouteContract({ - method: 'GET', - path: '/api/mothership/settings', - query: getMothershipSettingsQuerySchema, - response: { - mode: 'json', - schema: z.object({ - data: mothershipSettingsSchema, - }), - }, -}) - -export const updateMothershipSettingsContract = defineRouteContract({ - method: 'PUT', - path: '/api/mothership/settings', - body: updateMothershipSettingsBodySchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - data: mothershipSettingsSchema, - }), - }, -}) diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index a445632ca99..28606fcc722 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { organizationBillingDataSchema } from '@/lib/api/contracts/subscription' import { defineRouteContract } from '@/lib/api/contracts/types' import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' @@ -363,6 +364,82 @@ export const removeOrganizationMemberContract = defineRouteContract({ }, }) +/** Per-member credit usage + cap for the Manage Credits modal (values in credits). */ +export const organizationMemberUsageLimitDataSchema = z.object({ + creditsUsed: z.number(), + creditLimit: z.number().nullable(), +}) + +export const getOrganizationMemberUsageLimitContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/members/[memberId]/usage-limit', + params: organizationMemberParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.boolean(), + data: organizationMemberUsageLimitDataSchema, + }), + }, +}) + +export const updateOrganizationMemberUsageLimitBodySchema = z.object({ + /** New cap in credits; `null` clears the per-member cap. */ + creditLimit: z + .number() + .int('Credit limit must be a whole number of credits') + .min(0, 'Credit limit cannot be negative') + .nullable(), +}) + +export const updateOrganizationMemberUsageLimitContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/members/[memberId]/usage-limit', + params: organizationMemberParamsSchema, + body: updateOrganizationMemberUsageLimitBodySchema, + response: { + mode: 'json', + schema: successResponseSchema.extend({ + data: z + .object({ + creditLimit: z.number().nullable(), + }) + .optional(), + }), + }, +}) + +/** + * Self-service per-member usage for the chat-home credits chip. Values are in + * DOLLARS (the DB unit) so the client's `formatCredits` performs the single + * dollars→credits conversion — returning credits here would double-convert. + * `limitDollars` is null when no per-member cap applies (non-hosted, the + * workspace isn't org-owned, or no cap is set), so the chip falls back to the + * plan-level credits view. + */ +export const myMemberCreditsDataSchema = z.object({ + usedDollars: z.number(), + limitDollars: z.number().nullable(), +}) +export type MyMemberCreditsData = z.infer + +/** + * Own-data-only (no admin gate, unlike the admin route above) and workspace- + * scoped, so the chat-home chip can resolve the acting member's own remaining. + */ +export const getMyMemberCreditsContract = defineRouteContract({ + method: 'GET', + path: '/api/billing/member-credits', + query: z.object({ workspaceId: workspaceIdSchema }), + response: { + mode: 'json', + schema: z.object({ + success: z.boolean(), + data: myMemberCreditsDataSchema, + }), + }, +}) + export const transferOwnershipContract = defineRouteContract({ method: 'POST', path: '/api/organizations/[id]/transfer-ownership', @@ -511,3 +588,6 @@ export type RosterWorkspaceAccess = z.infer export type RosterMember = z.infer export type RosterPendingInvitation = z.infer export type OrganizationMembersResponse = z.infer +export type OrganizationMemberUsageLimitData = z.infer< + typeof organizationMemberUsageLimitDataSchema +> diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 352156ab7f8..950acf782e1 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -10,6 +10,8 @@ export const skillSchema = z.object({ content: z.string(), createdAt: z.string(), updatedAt: z.string(), + /** True for built-in template skills, which are read-only and not stored in the DB. */ + readOnly: z.boolean().optional(), }) export type Skill = z.output diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index e4a27726227..57de6d75b29 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -460,6 +460,8 @@ export const fileServeParamsSchema = z.object({ export const fileServeQuerySchema = z.object({ raw: z.string().nullish(), + /** Content version (the file record's `updatedAt`). Present => the URL is content-immutable and may be cached indefinitely by the browser. */ + v: z.string().nullish(), }) export const fileViewParamsSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index 45dccee1c46..c2c3e3af402 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -20,6 +20,14 @@ export const billingUpdateCostBodySchema = z.object({ .enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block']) .default('copilot'), idempotencyKey: z.string().min(1).optional(), + /** + * Originating workspace. Stamped onto `usage_log.workspaceId` so mothership/ + * copilot cost is attributable to org-owned workspaces (per-member usage). + * Required: the Go mothership always resolves a workspace for a billed request, + * so a missing value is a bug to surface (fail loud) rather than silently drop + * the cost from the per-member meter. + */ + workspaceId: z.string().min(1), }) export type BillingUpdateCostBody = z.input diff --git a/apps/sim/lib/api/contracts/tools/file.ts b/apps/sim/lib/api/contracts/tools/file.ts index 0d52f507d11..1a151342e3b 100644 --- a/apps/sim/lib/api/contracts/tools/file.ts +++ b/apps/sim/lib/api/contracts/tools/file.ts @@ -53,12 +53,24 @@ export const fileManageReadBodySchema = z message: 'Either fileId or fileInput is required for read operation', }) +export const fileManageContentBodySchema = z + .object({ + operation: z.literal('content'), + workspaceId: z.string().min(1).optional(), + fileId: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(), + fileInput: z.unknown().optional(), + }) + .refine((data) => data.fileId !== undefined || data.fileInput !== undefined, { + message: 'Either fileId or fileInput is required for content operation', + }) + export const fileManageBodySchema = z.union([ fileManageWriteBodySchema, fileManageAppendBodySchema, fileManageGetBodySchema, fileManageMoveBodySchema, fileManageReadBodySchema, + fileManageContentBodySchema, ]) export const fileManageContract = defineRouteContract({ diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts index df04e7d872d..a94ee42011a 100644 --- a/apps/sim/lib/auth/internal.ts +++ b/apps/sim/lib/auth/internal.ts @@ -8,7 +8,12 @@ import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('CronAuth') const getJwtSecret = () => { - const secret = new TextEncoder().encode(env.INTERNAL_API_SECRET) + // Prefer a dedicated JWT signing key so the internal-JWT trust domain is + // separable from the raw INTERNAL_API_SECRET shared-bearer secret: leaking one + // shouldn't grant the other (raw secret => call internal endpoints; JWT key => + // mint tokens for arbitrary userIds). Falls back to INTERNAL_API_SECRET when + // unset so existing deployments keep working until the key is rotated in. + const secret = new TextEncoder().encode(env.INTERNAL_JWT_SECRET || env.INTERNAL_API_SECRET) return secret } diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts new file mode 100644 index 00000000000..d78d984d9ad --- /dev/null +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -0,0 +1,129 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFlags, mockDbLimit, mockGetOrgMemberUsageLimit, mockGetOrgMemberWorkspaceUsage } = + vi.hoisted(() => ({ + mockFlags: { isHosted: true, isBillingEnabled: true }, + mockDbLimit: vi.fn(), + mockGetOrgMemberUsageLimit: vi.fn(), + mockGetOrgMemberWorkspaceUsage: vi.fn(), + })) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + get isHosted() { + return mockFlags.isHosted + }, + get isBillingEnabled() { + return mockFlags.isBillingEnabled + }, +})) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: mockDbLimit, + }), + }), + }), + }, +})) + +vi.mock('@/lib/billing/organizations/member-limits', () => ({ + getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit, + getOrgMemberWorkspaceUsage: mockGetOrgMemberWorkspaceUsage, +})) + +// core/usage pulls in the email-rendering chain at import; stub the two symbols +// usage-monitor imports from it so the module loads in a node test env. +vi.mock('@/lib/billing/core/usage', () => ({ + getPooledOrgCurrentPeriodCost: vi.fn(), + getUserUsageLimit: vi.fn(), +})) + +import { checkOrgMemberUsageLimit } from '@/lib/billing/calculations/usage-monitor' + +describe('checkOrgMemberUsageLimit', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFlags.isHosted = true + mockFlags.isBillingEnabled = true + mockDbLimit.mockResolvedValue([{ organizationId: 'org-1' }]) + mockGetOrgMemberUsageLimit.mockResolvedValue(2) + mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1) + }) + + it('no-ops when not hosted', async () => { + mockFlags.isHosted = false + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + expect(mockDbLimit).not.toHaveBeenCalled() + expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled() + }) + + it('no-ops when billing is disabled', async () => { + mockFlags.isBillingEnabled = false + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + }) + + it('no-ops when workspaceId is empty', async () => { + const result = await checkOrgMemberUsageLimit('user-1', '') + expect(result.isExceeded).toBe(false) + expect(mockDbLimit).not.toHaveBeenCalled() + }) + + it('no-ops when the workspace is not org-owned', async () => { + mockDbLimit.mockResolvedValue([{ organizationId: null }]) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + expect(mockGetOrgMemberUsageLimit).not.toHaveBeenCalled() + }) + + it('no-ops when the member has no cap set', async () => { + mockGetOrgMemberUsageLimit.mockResolvedValue(null) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + expect(mockGetOrgMemberWorkspaceUsage).not.toHaveBeenCalled() + }) + + it('does not block when usage is below the cap', async () => { + mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1) + mockGetOrgMemberUsageLimit.mockResolvedValue(2) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + expect(result.currentUsage).toBe(1) + expect(result.limit).toBe(2) + expect(result.message).toBeUndefined() + }) + + it('blocks when usage meets the cap (>=)', async () => { + mockGetOrgMemberWorkspaceUsage.mockResolvedValue(2) + mockGetOrgMemberUsageLimit.mockResolvedValue(2) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(true) + expect(result.message).toBeTruthy() + }) + + it('blocks all usage when the cap is 0', async () => { + mockGetOrgMemberUsageLimit.mockResolvedValue(0) + mockGetOrgMemberWorkspaceUsage.mockResolvedValue(0) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(true) + }) + + it('fails open when an unexpected error occurs', async () => { + mockDbLimit.mockRejectedValue(new Error('db down')) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + }) + + it('fails open when org-workspace usage cannot be computed (unexpected error)', async () => { + mockGetOrgMemberWorkspaceUsage.mockRejectedValue(new Error('db unavailable')) + const result = await checkOrgMemberUsageLimit('user-1', 'ws-1') + expect(result.isExceeded).toBe(false) + }) +}) diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index b26154f83fd..df6e7d7fda3 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { member, userStats } from '@sim/db/schema' +import { member, userStats, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' @@ -10,14 +10,19 @@ import { } from '@/lib/billing/core/plan' import { getPooledOrgCurrentPeriodCost, getUserUsageLimit } from '@/lib/billing/core/usage' import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { computeDailyRefreshConsumed, getOrgMemberRefreshBounds, } from '@/lib/billing/credits/daily-refresh' +import { + getOrgMemberUsageLimit, + getOrgMemberWorkspaceUsage, +} from '@/lib/billing/organizations/member-limits' import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' -import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { isBillingEnabled, isHosted } from '@/lib/core/config/feature-flags' const logger = createLogger('UsageMonitor') @@ -283,6 +288,68 @@ async function checkAndNotifyUsage(userId: string): Promise { } } +/** + * Whether an account (or its organization owner) is billing-blocked — frozen for + * a dispute or flagged for a payment issue. Independent of usage limits, so it can + * gate paths that are otherwise exempt from metered caps (e.g. BYOK wand): a + * frozen account is locked out everywhere. + */ +export async function checkBillingBlocked( + userId: string +): Promise<{ blocked: boolean; message?: string }> { + const stats = await db + .select({ blocked: userStats.billingBlocked, blockedReason: userStats.billingBlockedReason }) + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) + + if (stats.length > 0 && stats[0].blocked) { + return { + blocked: true, + message: + stats[0].blockedReason === 'dispute' + ? 'Account frozen. Please contact support to resolve this issue.' + : 'Billing issue detected. Please update your payment method to continue.', + } + } + + const memberships = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + + for (const m of memberships) { + const owners = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner'))) + .limit(1) + + if (owners.length > 0) { + const ownerStats = await db + .select({ + blocked: userStats.billingBlocked, + blockedReason: userStats.billingBlockedReason, + }) + .from(userStats) + .where(eq(userStats.userId, owners[0].userId)) + .limit(1) + + if (ownerStats.length > 0 && ownerStats[0].blocked) { + return { + blocked: true, + message: + ownerStats[0].blockedReason === 'dispute' + ? 'Organization account frozen. Please contact support to resolve this issue.' + : 'Organization billing issue. Please contact your organization owner.', + } + } + } + } + + return { blocked: false } +} + /** * Server-side function to check if a user has exceeded their usage limits * For use in API routes, webhooks, and scheduled executions @@ -311,65 +378,16 @@ export async function checkServerSideUsageLimits( logger.info('Server-side checking usage limits for user', { userId }) const stats = await db - .select({ - blocked: userStats.billingBlocked, - blockedReason: userStats.billingBlockedReason, - current: userStats.currentPeriodCost, - }) + .select({ current: userStats.currentPeriodCost }) .from(userStats) .where(eq(userStats.userId, userId)) .limit(1) const currentUsage = stats.length > 0 ? toNumber(toDecimal(stats[0].current)) : 0 - if (stats.length > 0 && stats[0].blocked) { - const message = - stats[0].blockedReason === 'dispute' - ? 'Account frozen. Please contact support to resolve this issue.' - : 'Billing issue detected. Please update your payment method to continue.' - return { - isExceeded: true, - currentUsage, - limit: 0, - message, - } - } - - const memberships = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, userId)) - - for (const m of memberships) { - const owners = await db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner'))) - .limit(1) - - if (owners.length > 0) { - const ownerStats = await db - .select({ - blocked: userStats.billingBlocked, - blockedReason: userStats.billingBlockedReason, - }) - .from(userStats) - .where(eq(userStats.userId, owners[0].userId)) - .limit(1) - - if (ownerStats.length > 0 && ownerStats[0].blocked) { - const message = - ownerStats[0].blockedReason === 'dispute' - ? 'Organization account frozen. Please contact support to resolve this issue.' - : 'Organization billing issue. Please contact your organization owner.' - return { - isExceeded: true, - currentUsage, - limit: 0, - message, - } - } - } + const blocked = await checkBillingBlocked(userId) + if (blocked.blocked) { + return { isExceeded: true, currentUsage, limit: 0, message: blocked.message } } const usageData = await checkUsageStatus(userId, preloadedSubscription) @@ -409,3 +427,97 @@ export async function checkServerSideUsageLimits( } } } + +/** + * Per-member usage cap for the organization that owns the given workspace. + * + * Hosted-only and independent of the pooled org limit + * ({@link checkServerSideUsageLimits}). No-ops (isExceeded:false) when the + * feature is off (`!isHosted` / `!isBillingEnabled`), the workspace is not + * org-owned, or the actor has no per-member cap. Otherwise compares the actor's + * current-period usage inside the org's workspaces against their cap + * (`usage >= limit` blocks). + * + * Fails open on unexpected error: this is a secondary, additive gate, so a + * transient fault must not block execution that the primary pooled/personal + * check already allowed. + */ +export async function checkOrgMemberUsageLimit( + userId: string, + workspaceId: string +): Promise<{ + isExceeded: boolean + currentUsage: number + limit: number | null + message?: string +}> { + try { + if (!isHosted || !isBillingEnabled || !workspaceId) { + return { isExceeded: false, currentUsage: 0, limit: null } + } + + const [workspaceRow] = await db + .select({ organizationId: workspace.organizationId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + const organizationId = workspaceRow?.organizationId + if (!organizationId) { + return { isExceeded: false, currentUsage: 0, limit: null } + } + + const limit = await getOrgMemberUsageLimit(organizationId, userId) + if (limit === null) { + return { isExceeded: false, currentUsage: 0, limit: null } + } + + const usage = await getOrgMemberWorkspaceUsage(organizationId, userId) + const isExceeded = usage >= limit + + return { + isExceeded, + currentUsage: usage, + limit, + message: isExceeded + ? `Member credit limit exceeded: ${dollarsToCredits(usage).toLocaleString()} of ${dollarsToCredits(limit).toLocaleString()} credits used for this organization's workspaces. Ask an organization admin to raise your credit limit to continue.` + : undefined, + } + } catch (error) { + logger.error('Error checking per-member org usage limit', { + error: toError(error).message, + userId, + workspaceId, + }) + return { isExceeded: false, currentUsage: 0, limit: null } + } +} + +/** + * Unified usage gate for an actor: the pooled/personal cap first + * ({@link checkServerSideUsageLimits}), then the per-member org-workspace cap + * ({@link checkOrgMemberUsageLimit}) when a workspace is in scope. Returns the + * first exceeded result so every billable surface (workflow exec, copilot, + * voice, wand, enrichment, KB indexing) can gate on a single + * `{ isExceeded, message }`. `scope` distinguishes a pooled cap from a per-member + * cap so clients can hide an "Upgrade" affordance a capped member can't act on (a + * per-member cap is only raisable by an org admin). + */ +export async function checkActorUsageLimits( + userId: string, + workspaceId?: string | null +): Promise<{ isExceeded: boolean; message?: string; scope?: 'pooled' | 'member' }> { + const pooled = await checkServerSideUsageLimits(userId) + if (pooled.isExceeded) { + return { isExceeded: true, message: pooled.message, scope: 'pooled' } + } + + if (workspaceId) { + const member = await checkOrgMemberUsageLimit(userId, workspaceId) + if (member.isExceeded) { + return { isExceeded: true, message: member.message, scope: 'member' } + } + } + + return { isExceeded: false } +} diff --git a/apps/sim/lib/billing/core/usage-log.test.ts b/apps/sim/lib/billing/core/usage-log.test.ts index 7077abcfed2..66a61757447 100644 --- a/apps/sim/lib/billing/core/usage-log.test.ts +++ b/apps/sim/lib/billing/core/usage-log.test.ts @@ -10,6 +10,8 @@ const { mockOnConflictDoNothing, mockReturning, mockValues, + mockTransaction, + mockUpdate, } = vi.hoisted(() => ({ mockGetHighestPrioritySubscription: vi.fn(), mockInsert: vi.fn(), @@ -17,11 +19,14 @@ const { mockOnConflictDoNothing: vi.fn(), mockReturning: vi.fn(), mockValues: vi.fn(), + mockTransaction: vi.fn(), + mockUpdate: vi.fn(), })) vi.mock('@sim/db', () => ({ db: { insert: mockInsert, + transaction: mockTransaction, }, })) @@ -58,7 +63,12 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true, })) -import { recordUsage } from '@/lib/billing/core/usage-log' +import { + CUMULATIVE_COST_EPSILON, + recordCumulativeUsage, + recordUsage, + resolveCumulativeTopUp, +} from '@/lib/billing/core/usage-log' describe('recordUsage', () => { beforeEach(() => { @@ -129,3 +139,118 @@ describe('recordUsage', () => { }) }) }) + +describe('resolveCumulativeTopUp', () => { + it('bills the full amount on the first flush (nothing recorded yet)', () => { + expect(resolveCumulativeTopUp(0, 0.3474447)).toEqual({ + shouldBill: true, + delta: 0.3474447, + newTotal: 0.3474447, + }) + }) + + it('bills only the delta when the cumulative grows (recovered request)', () => { + const result = resolveCumulativeTopUp(0.3474447, 0.4662453) + expect(result.shouldBill).toBe(true) + expect(result.newTotal).toBe(0.4662453) + expect(result.delta).toBeCloseTo(0.1188006, 9) + }) + + it('is a no-op when the cumulative is unchanged (abort-race duplicate)', () => { + expect(resolveCumulativeTopUp(0.4662453, 0.4662453)).toEqual({ + shouldBill: false, + delta: 0, + newTotal: 0.4662453, + }) + }) + + it('is a no-op when an out-of-order flush carries a lower cumulative', () => { + expect(resolveCumulativeTopUp(0.4662453, 0.3)).toMatchObject({ shouldBill: false, delta: 0 }) + }) + + it('ignores sub-epsilon increases from decimal round-trips', () => { + expect( + resolveCumulativeTopUp(0.4662453, 0.4662453 + CUMULATIVE_COST_EPSILON / 2) + ).toMatchObject({ shouldBill: false }) + }) +}) + +describe('recordCumulativeUsage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReturning.mockResolvedValue([{ cost: '0.3474447' }]) + mockOnConflictDoNothing.mockReturnValue({ returning: mockReturning }) + mockValues.mockReturnValue({ onConflictDoNothing: mockOnConflictDoNothing }) + mockInsert.mockReturnValue({ values: mockValues }) + mockGetHighestPrioritySubscription.mockResolvedValue({ + periodEnd: new Date('2026-06-01T00:00:00.000Z'), + periodStart: new Date('2026-05-01T00:00:00.000Z'), + referenceId: 'org-1', + }) + mockIsOrgScopedSubscription.mockReturnValue(true) + }) + + const setupTx = (existingRow: { id: string; cost: string } | null) => { + const limit = vi.fn().mockResolvedValue(existingRow ? [existingRow] : []) + const where = vi.fn().mockReturnValue({ limit }) + const from = vi.fn().mockReturnValue({ where }) + const select = vi.fn().mockReturnValue({ from }) + const updateWhere = vi.fn().mockResolvedValue(undefined) + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }) + mockUpdate.mockReturnValue({ set: updateSet }) + const tx = { + execute: vi.fn().mockResolvedValue(undefined), + select, + update: mockUpdate, + insert: mockInsert, // recordUsage(tx) reuses the shared insert chain + } + mockTransaction.mockImplementation(async (fn: (t: typeof tx) => unknown) => fn(tx)) + return { tx, select, updateSet } + } + + it('inserts the full cumulative on the first flush', async () => { + setupTx(null) + const result = await recordCumulativeUsage({ + userId: 'user-1', + workspaceId: 'ws-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.3474447, + eventKey: 'update-cost:msg-1-billing', + metadata: { inputTokens: 100, outputTokens: 5 }, + }) + expect(result).toEqual({ billed: true, delta: 0.3474447, total: 0.3474447 }) + expect(mockInsert).toHaveBeenCalledTimes(1) + expect(mockUpdate).not.toHaveBeenCalled() + }) + + it('tops up to the higher cumulative and bills only the delta', async () => { + const { updateSet } = setupTx({ id: 'row-1', cost: '0.3474447' }) + const result = await recordCumulativeUsage({ + userId: 'user-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.4662453, + eventKey: 'update-cost:msg-1-billing', + }) + expect(result.billed).toBe(true) + expect(result.total).toBe(0.4662453) + expect(result.delta).toBeCloseTo(0.1188006, 9) + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ cost: '0.4662453' })) + expect(mockInsert).not.toHaveBeenCalled() + }) + + it('does not bill when the cumulative is not higher than recorded', async () => { + const { updateSet } = setupTx({ id: 'row-1', cost: '0.4662453' }) + const result = await recordCumulativeUsage({ + userId: 'user-1', + source: 'workspace-chat', + model: 'claude-opus-4.8', + cost: 0.4662453, + eventKey: 'update-cost:msg-1-billing', + }) + expect(result).toEqual({ billed: false, delta: 0, total: 0.4662453 }) + expect(updateSet).not.toHaveBeenCalled() + expect(mockInsert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index ad1657a6db6..083ee7de9a6 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -1,10 +1,10 @@ import { createHash } from 'node:crypto' import { db } from '@sim/db' -import { usageLog } from '@sim/db/schema' +import { usageLog, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, desc, eq, gte, inArray, lte, sql } from 'drizzle-orm' +import { and, desc, eq, gte, inArray, lt, lte, sql } from 'drizzle-orm' import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' @@ -222,6 +222,41 @@ export async function getBillingPeriodUsageCostByUser( return new Map(rows.map((row) => [row.userId, Number.parseFloat(row.cost ?? '0')])) } +/** + * A single user's usage_log cost inside an organization's own workspaces within + * a wall-clock window (by `created_at`). + * + * Unlike {@link getBillingPeriodUsageCostByUser} (which filters by the attributed + * billing entity and the stored billing-period columns), this joins `workspace` + * on `organization_id` and filters by `user_id` + a `created_at` range. That + * captures the member's consumption inside org-owned workspaces regardless of + * which billing entity the row was attributed to — required for per-member + * org-workspace usage, including external members whose runs bill to their own + * personal entity and mothership/copilot cost attributed to the using user. + * Scoped to one user so it uses the `(user_id, created_at)` index rather than + * scanning the whole org's period on the execution hot path. + */ +export async function getOrgWorkspaceUsageCostForUser( + organizationId: string, + userId: string, + window: { start: Date; end: Date } +): Promise { + const [row] = await db + .select({ cost: sql`COALESCE(SUM(${usageLog.cost}), 0)` }) + .from(usageLog) + .innerJoin(workspace, eq(workspace.id, usageLog.workspaceId)) + .where( + and( + eq(usageLog.userId, userId), + eq(workspace.organizationId, organizationId), + gte(usageLog.createdAt, window.start), + lt(usageLog.createdAt, window.end) + ) + ) + + return Number.parseFloat(row?.cost ?? '0') +} + /** * Records usage as append-only billing events. * @@ -319,6 +354,125 @@ export async function recordUsage(params: RecordUsageParams): Promise { }) } +/** + * Floating-point tolerance for cumulative cost comparison. Costs are dollars; + * a sub-microcent difference is treated as "no change" so a DB round-trip + * (decimal string -> float) can't manufacture a spurious top-up. + */ +export const CUMULATIVE_COST_EPSILON = 1e-9 + +/** + * Decide whether an incoming CUMULATIVE cost for a request should bill, given + * what has already been recorded for it. + * + * Billing is a monotonic top-up: only a strictly-higher cumulative bills, and + * it bills just the delta above what's recorded; a same-or-lower cumulative is + * a no-op. This is the core invariant that makes repeated flushes of a single + * request converge to the true total exactly once — a partial mid-loop flush + * (e.g. after a provider error), the recovered terminal flush, and abort-race + * duplicates all reconcile to the maximum cumulative with no under- or + * over-billing, independent of arrival order. + */ +export function resolveCumulativeTopUp( + recordedCost: number, + incomingCost: number +): { shouldBill: boolean; delta: number; newTotal: number } { + if (incomingCost <= recordedCost + CUMULATIVE_COST_EPSILON) { + return { shouldBill: false, delta: 0, newTotal: recordedCost } + } + return { shouldBill: true, delta: incomingCost - recordedCost, newTotal: incomingCost } +} + +export interface RecordCumulativeUsageParams { + userId: string + workspaceId?: string + source: UsageLogSource + /** Model name, stored as the row description. */ + model: string + /** The request's CUMULATIVE cost so far (not a per-leg delta). */ + cost: number + /** Stable per-request key; the single ledger row is keyed on this. */ + eventKey: string + metadata?: UsageLogMetadata +} + +export interface RecordCumulativeUsageResult { + /** True when a new (delta) charge was recorded for this flush. */ + billed: boolean + /** Amount newly charged by this flush (0 on a duplicate/lower flush). */ + delta: number + /** The request's recorded cumulative cost after this flush. */ + total: number +} + +/** + * Record a request's CUMULATIVE cost idempotently with monotonic top-up. + * + * Keeps exactly ONE usage_log row per `eventKey` holding the MAX cumulative + * cost ever submitted for the request, billing only the incremental delta on + * each flush. A per-key transactional advisory lock serializes concurrent + * flushes so the read-then-write — including the first insert — is race-free + * (no two flushes can both believe they are first and clobber each other). + * + * Because every leg flushes its cumulative and this converges to the max, + * there is no under-billing if the request recovers after a partial flush, no + * over-billing from duplicate/abort-race flushes, and no lost billing if the + * process dies between legs — each leg's cost is durably recorded as it lands. + */ +export async function recordCumulativeUsage( + params: RecordCumulativeUsageParams +): Promise { + const { userId, workspaceId, source, model, cost, eventKey, metadata } = params + + return db.transaction(async (tx) => { + // Serialize all flushes for this request (lock auto-releases at tx end). + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${eventKey}))`) + + const [existing] = await tx + .select({ id: usageLog.id, cost: usageLog.cost }) + .from(usageLog) + .where(eq(usageLog.eventKey, eventKey)) + .limit(1) + + const recorded = existing ? Number.parseFloat(existing.cost) : 0 + const { shouldBill, delta, newTotal } = resolveCumulativeTopUp(recorded, cost) + + if (!shouldBill) { + return { billed: false, delta: 0, total: recorded } + } + + if (existing) { + // Top up the single row to the new (higher) cumulative; the + // period total is SUM(usage_log.cost), so this lifts it by the delta. + await tx + .update(usageLog) + .set({ cost: newTotal.toString(), metadata: metadata ?? null }) + .where(eq(usageLog.id, existing.id)) + } else { + // First flush for this request: insert the canonical row (recordUsage + // resolves billing entity/period). Runs in the same tx + advisory lock. + await recordUsage({ + userId, + workspaceId, + tx, + entries: [ + { + category: 'model', + source, + description: model, + cost: newTotal, + eventKey, + sourceReference: eventKey, + ...(metadata ? { metadata } : {}), + }, + ], + }) + } + + return { billed: true, delta, total: newTotal } + }) +} + /** * Options for querying usage logs */ diff --git a/apps/sim/lib/billing/credits/conversion.test.ts b/apps/sim/lib/billing/credits/conversion.test.ts index 7d4a45a3733..22cd898d8c9 100644 --- a/apps/sim/lib/billing/credits/conversion.test.ts +++ b/apps/sim/lib/billing/credits/conversion.test.ts @@ -4,10 +4,24 @@ import { describe, expect, it } from 'vitest' import { apportionCredits, + creditsToDollars, dollarsToCredits, formatCreditCost, } from '@/lib/billing/credits/conversion' +describe('creditsToDollars', () => { + it('converts credits to dollars at 200 credits per dollar', () => { + expect(creditsToDollars(200)).toBe(1) + expect(creditsToDollars(0)).toBe(0) + expect(creditsToDollars(1)).toBe(0.005) + }) + + it('round-trips with dollarsToCredits for whole-credit values', () => { + expect(dollarsToCredits(creditsToDollars(2000))).toBe(2000) + expect(dollarsToCredits(creditsToDollars(1))).toBe(1) + }) +}) + describe('formatCreditCost', () => { it('renders multiplier-inclusive dollars as a single-rounded credit label', () => { expect(formatCreditCost(0.005)).toBe('1 credit') diff --git a/apps/sim/lib/billing/credits/conversion.ts b/apps/sim/lib/billing/credits/conversion.ts index 74a77b8fdaa..e11c716282c 100644 --- a/apps/sim/lib/billing/credits/conversion.ts +++ b/apps/sim/lib/billing/credits/conversion.ts @@ -12,6 +12,15 @@ export function dollarsToCredits(dollars: number): number { return Math.round(dollars * CREDIT_MULTIPLIER) } +/** + * Convert a credit amount (the API/UI unit) into stored dollars. + * Inverse of {@link dollarsToCredits}; use at write boundaries that accept + * credit-denominated input (e.g. per-member usage limits). + */ +export function creditsToDollars(credits: number): number { + return credits / CREDIT_MULTIPLIER +} + /** * Single source of truth for rendering a dollar cost as a credit label. * diff --git a/apps/sim/lib/billing/organizations/member-limits.test.ts b/apps/sim/lib/billing/organizations/member-limits.test.ts new file mode 100644 index 00000000000..491282450b2 --- /dev/null +++ b/apps/sim/lib/billing/organizations/member-limits.test.ts @@ -0,0 +1,161 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDbState, + mockInsert, + mockInsertValues, + mockOnConflictDoUpdate, + mockDelete, + mockDeleteWhere, + mockGetOrganizationSubscription, + mockGetOrgWorkspaceUsageCostForUser, +} = vi.hoisted(() => ({ + mockDbState: { selectResults: [] as unknown[] }, + mockInsert: vi.fn(), + mockInsertValues: vi.fn(), + mockOnConflictDoUpdate: vi.fn(), + mockDelete: vi.fn(), + mockDeleteWhere: vi.fn(), + mockGetOrganizationSubscription: vi.fn(), + mockGetOrgWorkspaceUsageCostForUser: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: vi.fn(() => { + const chain: Record = {} + chain.from = vi.fn(() => chain) + chain.where = vi.fn(() => chain) + chain.limit = vi.fn(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) + chain.then = (cb: (rows: unknown) => unknown) => + Promise.resolve(cb(mockDbState.selectResults.shift() ?? [])) + return chain + }), + insert: mockInsert, + delete: mockDelete, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + organizationMemberUsageLimit: { + id: 'oml.id', + organizationId: 'oml.organizationId', + userId: 'oml.userId', + usageLimit: 'oml.usageLimit', + setBy: 'oml.setBy', + createdAt: 'oml.createdAt', + updatedAt: 'oml.updatedAt', + }, + workspace: { id: 'workspace.id', organizationId: 'workspace.organizationId' }, +})) + +vi.mock('@/lib/billing/core/billing', () => ({ + getOrganizationSubscription: mockGetOrganizationSubscription, +})) + +vi.mock('@/lib/billing/core/usage-log', () => ({ + getOrgWorkspaceUsageCostForUser: mockGetOrgWorkspaceUsageCostForUser, +})) + +import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' +import { + getOrgMemberUsageLimit, + getOrgMemberWorkspaceUsage, + setOrgMemberUsageLimit, +} from '@/lib/billing/organizations/member-limits' + +beforeEach(() => { + vi.clearAllMocks() + mockDbState.selectResults = [] + mockInsert.mockReturnValue({ values: mockInsertValues }) + mockInsertValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }) + mockOnConflictDoUpdate.mockResolvedValue(undefined) + mockDelete.mockReturnValue({ where: mockDeleteWhere }) + mockDeleteWhere.mockResolvedValue(undefined) +}) + +describe('getOrgMemberUsageLimit', () => { + it('returns null when no row exists', async () => { + mockDbState.selectResults = [[]] + await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBeNull() + }) + + it('returns the stored dollar limit as a number', async () => { + mockDbState.selectResults = [[{ usageLimit: '2' }]] + await expect(getOrgMemberUsageLimit('org-1', 'user-2')).resolves.toBe(2) + }) +}) + +describe('setOrgMemberUsageLimit', () => { + it('upserts when given a dollar amount', async () => { + await setOrgMemberUsageLimit('org-1', 'user-2', 2, 'admin-1') + expect(mockInsert).toHaveBeenCalledTimes(1) + expect(mockDelete).not.toHaveBeenCalled() + const values = mockInsertValues.mock.calls[0][0] + expect(values).toMatchObject({ + organizationId: 'org-1', + userId: 'user-2', + usageLimit: '2', + setBy: 'admin-1', + }) + expect(mockOnConflictDoUpdate).toHaveBeenCalledTimes(1) + }) + + it('deletes the row when limit is null', async () => { + await setOrgMemberUsageLimit('org-1', 'user-2', null, 'admin-1') + expect(mockDelete).toHaveBeenCalledTimes(1) + expect(mockDeleteWhere).toHaveBeenCalledTimes(1) + expect(mockInsert).not.toHaveBeenCalled() + }) +}) + +describe('getOrgMemberWorkspaceUsage', () => { + it("returns the member's usage within the org subscription window", async () => { + const periodStart = new Date('2026-06-01T00:00:00.000Z') + const periodEnd = new Date('2026-07-01T00:00:00.000Z') + mockGetOrganizationSubscription.mockResolvedValue({ periodStart, periodEnd }) + mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(5) + + const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2') + + expect(result).toBe(5) + expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith('org-1', 'user-2', { + start: periodStart, + end: periodEnd, + }) + }) + + it('falls back to the all-time window when the org has no subscription', async () => { + mockGetOrganizationSubscription.mockResolvedValue(null) + mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(7) + + const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2') + + expect(result).toBe(7) + expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith( + 'org-1', + 'user-2', + defaultBillingPeriod() + ) + }) + + it('falls back to the all-time window when the subscription is missing periodEnd', async () => { + mockGetOrganizationSubscription.mockResolvedValue({ + periodStart: new Date('2026-06-01T00:00:00.000Z'), + periodEnd: null, + }) + mockGetOrgWorkspaceUsageCostForUser.mockResolvedValue(3) + + const result = await getOrgMemberWorkspaceUsage('org-1', 'user-2') + + expect(result).toBe(3) + expect(mockGetOrgWorkspaceUsageCostForUser).toHaveBeenCalledWith( + 'org-1', + 'user-2', + defaultBillingPeriod() + ) + }) +}) diff --git a/apps/sim/lib/billing/organizations/member-limits.ts b/apps/sim/lib/billing/organizations/member-limits.ts new file mode 100644 index 00000000000..c249aa94d9d --- /dev/null +++ b/apps/sim/lib/billing/organizations/member-limits.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { organizationMemberUsageLimit } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { getOrganizationSubscription } from '@/lib/billing/core/billing' +import { defaultBillingPeriod } from '@/lib/billing/core/billing-period' +import { getOrgWorkspaceUsageCostForUser } from '@/lib/billing/core/usage-log' +import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' + +const logger = createLogger('OrgMemberLimits') + +/** + * Read a member's per-organization usage limit (dollars). Returns `null` when no + * cap is set for the `(organization, user)` pair — meaning only the pooled org + * limit applies. Independent of `user_stats.current_usage_limit` (the user's + * personal subscription cap), so it covers external members without clobbering + * their personal limit. + */ +export async function getOrgMemberUsageLimit( + organizationId: string, + userId: string +): Promise { + const rows = await db + .select({ usageLimit: organizationMemberUsageLimit.usageLimit }) + .from(organizationMemberUsageLimit) + .where( + and( + eq(organizationMemberUsageLimit.organizationId, organizationId), + eq(organizationMemberUsageLimit.userId, userId) + ) + ) + .limit(1) + + if (rows.length === 0) return null + return toNumber(toDecimal(rows[0].usageLimit)) +} + +/** + * Upsert (or clear) a member's per-organization usage limit. Passing `null` for + * `limitDollars` deletes the row, removing the per-member cap. The target need + * not be an organization `member` row, so external members are supported. + */ +export async function setOrgMemberUsageLimit( + organizationId: string, + userId: string, + limitDollars: number | null, + setBy?: string +): Promise { + if (limitDollars === null) { + await db + .delete(organizationMemberUsageLimit) + .where( + and( + eq(organizationMemberUsageLimit.organizationId, organizationId), + eq(organizationMemberUsageLimit.userId, userId) + ) + ) + logger.info('Cleared per-member usage limit', { organizationId, userId, setBy }) + return + } + + await db + .insert(organizationMemberUsageLimit) + .values({ + id: generateId(), + organizationId, + userId, + usageLimit: limitDollars.toString(), + setBy: setBy ?? null, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [organizationMemberUsageLimit.organizationId, organizationMemberUsageLimit.userId], + set: { + usageLimit: limitDollars.toString(), + setBy: setBy ?? null, + updatedAt: new Date(), + }, + }) + + logger.info('Set per-member usage limit', { organizationId, userId, limitDollars, setBy }) +} + +/** + * Compute a member's current-period usage (dollars) inside the organization's + * own workspaces. + * + * Sums `usage_log` by `created_at` within the org subscription window across the + * org's workspaces, scoped to the given user (a single indexed aggregation — see + * {@link getOrgWorkspaceUsageCostForUser}). Filtering by workspace (not billing + * entity) is what captures external members and mothership/copilot cost. Raw + * usage — daily-refresh credits are a pooled concept and intentionally not + * deducted here. + * + * When the org has no resolvable subscription window, falls back to the open + * (all-time) window, matching how the rest of the billing layer resolves a + * missing period (e.g. {@link deriveBillingContext}, the pooled-org and user + * usage paths). A hosted org with a per-member cap normally has a period, so + * this fallback is an edge case; using the shared convention keeps this path + * consistent with every other usage read rather than special-casing it. + */ +export async function getOrgMemberWorkspaceUsage( + organizationId: string, + userId: string +): Promise { + const subscription = await getOrganizationSubscription(organizationId) + const billingPeriod = + subscription?.periodStart && subscription.periodEnd + ? { start: subscription.periodStart, end: subscription.periodEnd } + : defaultBillingPeriod() + + return getOrgWorkspaceUsageCostForUser(organizationId, userId, billingPeriod) +} diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts index b0e38557021..7b2dffa3783 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -25,6 +25,9 @@ const STATE_TO_STATUS: Record = { [MothershipStreamV1ToolOutcome.cancelled]: ToolCallStatus.cancelled, [MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.rejected, [MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.skipped, + aborted: ToolCallStatus.cancelled, + failed: ToolCallStatus.error, + interrupted: ToolCallStatus.interrupted, pending: ToolCallStatus.executing, executing: ToolCallStatus.executing, } @@ -51,6 +54,12 @@ function toDisplayBlock(block: PersistedContentBlock): ContentBlock | undefined if (block.parentToolCallId && displayed.parentToolCallId === undefined) { displayed.parentToolCallId = block.parentToolCallId } + if (block.spanId && displayed.spanId === undefined) { + displayed.spanId = block.spanId + } + if (block.parentSpanId && displayed.parentSpanId === undefined) { + displayed.parentSpanId = block.parentSpanId + } return withBlockTiming(displayed, block) } diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/copilot/chat/effective-transcript.ts index a7975900fca..78514fd9f0c 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/copilot/chat/effective-transcript.ts @@ -78,6 +78,8 @@ function appendTextBlock( options: { lane?: 'subagent' parentToolCallId?: string + spanId?: string + parentSpanId?: string } ): void { if (!content) return @@ -85,7 +87,8 @@ function appendTextBlock( if ( last?.type === MothershipStreamV1EventType.text && last.lane === options.lane && - last.parentToolCallId === options.parentToolCallId + last.parentToolCallId === options.parentToolCallId && + last.spanId === options.spanId ) { last.content = `${typeof last.content === 'string' ? last.content : ''}${content}` return @@ -95,6 +98,8 @@ function appendTextBlock( type: MothershipStreamV1EventType.text, ...(options.lane ? { lane: options.lane } : {}), ...(options.parentToolCallId ? { parentToolCallId: options.parentToolCallId } : {}), + ...(options.spanId ? { spanId: options.spanId } : {}), + ...(options.parentSpanId ? { parentSpanId: options.parentSpanId } : {}), content, }) } @@ -108,6 +113,7 @@ function buildLiveAssistantMessage(params: { const blocks: RawPersistedBlock[] = [] const toolIndexById = new Map() const subagentByParentToolCallId = new Map() + const subagentBySpanId = new Map() let activeSubagent: string | undefined let activeSubagentParentToolCallId: string | undefined let activeCompactionId: string | undefined @@ -118,9 +124,14 @@ function buildLiveAssistantMessage(params: { const resolveScopedSubagent = ( agentId: string | undefined, - parentToolCallId: string | undefined + parentToolCallId: string | undefined, + spanId?: string ): string | undefined => { if (agentId) return agentId + if (spanId) { + const scoped = subagentBySpanId.get(spanId) + if (scoped) return scoped + } if (parentToolCallId) { const scoped = subagentByParentToolCallId.get(parentToolCallId) if (scoped) return scoped @@ -146,6 +157,8 @@ function buildLiveAssistantMessage(params: { toolName: string calledBy?: string parentToolCallId?: string + spanId?: string + parentSpanId?: string displayTitle?: string params?: Record result?: { success: boolean; output?: unknown; error?: string } @@ -176,6 +189,8 @@ function buildLiveAssistantMessage(params: { : {}), } if (input.parentToolCallId) existing.parentToolCallId = input.parentToolCallId + if (input.spanId) existing.spanId = input.spanId + if (input.parentSpanId) existing.parentSpanId = input.parentSpanId return existing } @@ -198,6 +213,8 @@ function buildLiveAssistantMessage(params: { : {}), }, ...(input.parentToolCallId ? { parentToolCallId: input.parentToolCallId } : {}), + ...(input.spanId ? { spanId: input.spanId } : {}), + ...(input.parentSpanId ? { parentSpanId: input.parentSpanId } : {}), } toolIndexById.set(input.toolCallId, blocks.length) blocks.push(nextBlock) @@ -214,7 +231,18 @@ function buildLiveAssistantMessage(params: { typeof parsed.scope?.parentToolCallId === 'string' ? parsed.scope.parentToolCallId : undefined const scopedAgentId = typeof parsed.scope?.agentId === 'string' ? parsed.scope.agentId : undefined - const scopedSubagent = resolveScopedSubagent(scopedAgentId, scopedParentToolCallId) + const scopedSpanId = typeof parsed.scope?.spanId === 'string' ? parsed.scope.spanId : undefined + const scopedParentSpanId = + typeof parsed.scope?.parentSpanId === 'string' ? parsed.scope.parentSpanId : undefined + const scopedSubagent = resolveScopedSubagent( + scopedAgentId, + scopedParentToolCallId, + scopedSpanId + ) + const spanIdentity: { spanId?: string; parentSpanId?: string } = { + ...(scopedSpanId ? { spanId: scopedSpanId } : {}), + ...(scopedParentSpanId ? { parentSpanId: scopedParentSpanId } : {}), + } switch (parsed.type) { case MothershipStreamV1EventType.session: { @@ -245,6 +273,7 @@ function buildLiveAssistantMessage(params: { appendTextBlock(blocks, normalizedChunk, { ...(scopedSubagent ? { lane: 'subagent' as const } : {}), ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), + ...spanIdentity, }) runningText += normalizedChunk lastContentSource = contentSource @@ -271,6 +300,7 @@ function buildLiveAssistantMessage(params: { toolName: payload.toolName, calledBy: scopedSubagent, ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), + ...spanIdentity, state: resolveStreamToolOutcome(payload), result: { success: payload.success, @@ -286,6 +316,7 @@ function buildLiveAssistantMessage(params: { toolName: payload.toolName, calledBy: scopedSubagent, ...(parentForBlock ? { parentToolCallId: parentForBlock } : {}), + ...spanIdentity, displayTitle, params: isRecord(payload.arguments) ? payload.arguments : undefined, state: typeof payload.status === 'string' ? payload.status : 'executing', @@ -307,6 +338,9 @@ function buildLiveAssistantMessage(params: { const parentToolCallId = scopedParentToolCallId ?? parentToolCallIdFromData const name = typeof parsed.payload.agent === 'string' ? parsed.payload.agent : scopedAgentId if (parsed.payload.event === MothershipStreamV1SpanLifecycleEvent.start && name) { + if (scopedSpanId) { + subagentBySpanId.set(scopedSpanId, name) + } if (parentToolCallId) { subagentByParentToolCallId.set(parentToolCallId, name) } @@ -318,6 +352,7 @@ function buildLiveAssistantMessage(params: { lifecycle: MothershipStreamV1SpanLifecycleEvent.start, content: name, ...(parentToolCallId ? { parentToolCallId } : {}), + ...spanIdentity, }) continue } @@ -326,6 +361,9 @@ function buildLiveAssistantMessage(params: { if (spanData?.pending === true) { continue } + if (scopedSpanId) { + subagentBySpanId.delete(scopedSpanId) + } if (parentToolCallId) { subagentByParentToolCallId.delete(parentToolCallId) } @@ -342,6 +380,7 @@ function buildLiveAssistantMessage(params: { kind: MothershipStreamV1SpanPayloadKind.subagent, lifecycle: MothershipStreamV1SpanLifecycleEvent.end, ...(parentToolCallId ? { parentToolCallId } : {}), + ...spanIdentity, }) } continue @@ -381,6 +420,7 @@ function buildLiveAssistantMessage(params: { appendTextBlock(blocks, content, { ...(scopedSubagent ? { lane: 'subagent' as const } : {}), ...(errorParent ? { parentToolCallId: errorParent } : {}), + ...spanIdentity, }) runningText += content continue diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts index 691be015c9d..a1f92a08195 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -53,11 +53,46 @@ vi.mock('@/tools/utils', () => ({ stripVersionSuffix: vi.fn((toolId: string) => toolId), })) +vi.mock('@/lib/copilot/integration-tools', () => ({ + getExposedIntegrationTools: vi.fn(() => [ + { + toolId: 'gmail_send', + config: { id: 'gmail_send', name: 'Gmail Send', description: 'Send emails using Gmail' }, + service: 'gmail', + operation: 'send', + }, + { + toolId: 'brandfetch_search', + config: { + id: 'brandfetch_search', + name: 'Brandfetch Search', + description: 'Search for brands by company name', + }, + service: 'brandfetch', + operation: 'search', + }, + { + toolId: 'run_workflow', + config: { + id: 'run_workflow', + name: 'Run Workflow', + description: 'Run a workflow from the client', + }, + service: 'run', + operation: 'workflow', + }, + ]), +})) + vi.mock('@/tools/params', () => ({ createUserToolSchema: mockCreateUserToolSchema, })) -import { buildIntegrationToolSchemas, clearIntegrationToolSchemaCacheForTests } from './payload' +import { + buildCopilotRequestPayload, + buildIntegrationToolSchemas, + clearIntegrationToolSchemaCacheForTests, +} from './payload' describe('buildIntegrationToolSchemas', () => { beforeEach(() => { @@ -136,3 +171,60 @@ describe('buildIntegrationToolSchemas', () => { expect(second[0].input_schema).not.toHaveProperty('mutated') }) }) + +describe('buildCopilotRequestPayload', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes workspaceContext through to the Go request payload', async () => { + const payload = await buildCopilotRequestPayload( + { + message: 'debug workspace', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: 'claude-opus-4-8', + workspaceId: 'ws-1', + workspaceContext: 'workspace inventory', + }, + { selectedModel: 'claude-opus-4-8' } + ) + + expect(payload).toEqual( + expect.objectContaining({ + workspaceId: 'ws-1', + workspaceContext: 'workspace inventory', + }) + ) + }) + + it('passes user metadata through to the Go request payload', async () => { + const payload = await buildCopilotRequestPayload( + { + message: 'what time is it', + userId: 'user-1', + userMessageId: 'msg-1', + mode: 'agent', + model: 'claude-opus-4-8', + workspaceId: 'ws-1', + userTimezone: 'America/Los_Angeles', + userMetadata: { + name: 'Sid', + timezone: 'America/Los_Angeles', + }, + }, + { selectedModel: 'claude-opus-4-8' } + ) + + expect(payload).toEqual( + expect.objectContaining({ + userTimezone: 'America/Los_Angeles', + userMetadata: { + name: 'Sid', + timezone: 'America/Los_Angeles', + }, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index 1d0f542b92a..31da4396bc1 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -3,13 +3,14 @@ import { toError } from '@sim/utils/errors' import { LRUCache } from 'lru-cache' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isPaid } from '@/lib/billing/plan-helpers' +import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' import { getToolEntry } from '@/lib/copilot/tool-executor/router' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' -import { isHosted } from '@/lib/core/config/feature-flags' -import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' +import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/feature-flags' +import { buildUserSkillTool } from '@/lib/mothership/skills' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { tools } from '@/tools/registry' -import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' +import { stripVersionSuffix } from '@/tools/utils' const logger = createLogger('CopilotChatPayload') const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000 @@ -25,7 +26,7 @@ interface BuildPayloadParams { mode: string model: string provider?: string - contexts?: Array<{ type: string; content: string }> + contexts?: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: Array<{ id: string; key: string; size: number; [key: string]: unknown }> commands?: string[] chatId?: string @@ -34,6 +35,10 @@ interface BuildPayloadParams { workspaceContext?: string userPermission?: string userTimezone?: string + userMetadata?: { + name?: string + timezone?: string + } includeMothershipTools?: boolean } @@ -44,6 +49,8 @@ export interface ToolSchema { defer_loading?: boolean executeLocally?: boolean params?: Record + /** Canonical integration service/folder (e.g. "slack"), for server-side grouping. */ + service?: string oauth?: { required: boolean; provider: string } } @@ -133,7 +140,6 @@ async function buildIntegrationToolSchemasUncached( const integrationTools: ToolSchema[] = [] try { const { createUserToolSchema } = await import('@/tools/params') - const latestTools = getLatestVersionTools(tools) let shouldAppendEmailTagline = false try { @@ -177,11 +183,10 @@ async function buildIntegrationToolSchemasUncached( } } - for (const [toolId, toolConfig] of Object.entries(latestTools)) { + for (const { toolId, config: toolConfig, service } of getExposedIntegrationTools()) { try { - const strippedName = stripVersionSuffix(toolId) if (allowedIntegrations && toolIdToBlockType) { - const owningBlock = toolIdToBlockType.get(strippedName) + const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId)) if (owningBlock && !allowedIntegrations.has(owningBlock)) { continue } @@ -189,12 +194,13 @@ async function buildIntegrationToolSchemasUncached( const userSchema = createUserToolSchema(toolConfig, { surface: options.schemaSurface, }) - const catalogEntry = getToolEntry(strippedName) + const catalogEntry = getToolEntry(toolId) integrationTools.push({ - name: strippedName, + name: toolId, + service, description: getCopilotToolDescription(toolConfig, { isHosted, - fallbackName: strippedName, + fallbackName: toolId, appendEmailTagline: shouldAppendEmailTagline, }), input_schema: { ...userSchema }, @@ -264,7 +270,7 @@ export async function buildCopilotRequestPayload( const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode // Track uploaded files in the DB and build context tags instead of base64 inlining - const uploadContexts: Array<{ type: string; content: string }> = [] + const uploadContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = [] if (chatId && params.workspaceId && fileAttachments && fileAttachments.length > 0) { for (const f of fileAttachments) { const filename = (f.filename ?? f.name ?? 'file') as string @@ -279,9 +285,18 @@ export async function buildCopilotRequestPayload( mediaType, f.size ) + // Encode the read path per the percent-encoded VFS convention (matches + // files/ and the uploads glob output). The materialize_file `fileName` + // arg stays the raw display name — the upload resolver accepts both. + let encodedUploadName = displayName + try { + encodedUploadName = encodeVfsSegment(displayName) + } catch { + encodedUploadName = displayName + } const lines = [ `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`, - `Read with: read("uploads/${displayName}")`, + `Read with: read("uploads/${encodedUploadName}")`, `To save permanently: materialize_file(fileName: "${displayName}")`, ] if (displayName.endsWith('.json')) { @@ -306,9 +321,7 @@ export async function buildCopilotRequestPayload( const allContexts = [...(contexts ?? []), ...uploadContexts] let integrationTools: ToolSchema[] = [] - let mothershipTools: ToolSchema[] = [] - let workspaceContext = params.workspaceContext - + const mothershipTools: ToolSchema[] = [] const payloadLogger = logger.withMetadata({ messageId: userMessageId }) if (effectiveMode === 'build') { @@ -320,26 +333,16 @@ export async function buildCopilotRequestPayload( ) if (params.includeMothershipTools && params.workspaceId) { + // Expose all workspace user-created skills via the single load_user_skill + // tool. Available to every user; content is fetched sim-side when the + // model calls it. try { - const runtimeTools = await buildMothershipToolsForRequest({ - workspaceId: params.workspaceId, - userId, - }) - mothershipTools = runtimeTools.tools - if (runtimeTools.catalogContext) { - workspaceContext = [workspaceContext, runtimeTools.catalogContext] - .filter(Boolean) - .join('\n\n') - } + const userSkillTool = await buildUserSkillTool(params.workspaceId) + if (userSkillTool) mothershipTools.push(userSkillTool) } catch (error) { - logger.warn( - userMessageId - ? `Failed to build Mothership tools [messageId:${userMessageId}]` - : 'Failed to build Mothership tools', - { - error: toError(error).message, - } - ) + logger.warn('Failed to build load_user_skill tool', { + error: toError(error).message, + }) } } } @@ -361,9 +364,15 @@ export async function buildCopilotRequestPayload( ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(commands && commands.length > 0 ? { commands } : {}), - ...(workspaceContext ? { workspaceContext } : {}), + ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), ...(params.userPermission ? { userPermission: params.userPermission } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), + ...(params.userMetadata && (params.userMetadata.name || params.userMetadata.timezone) + ? { userMetadata: params.userMetadata } + : {}), + // Tell the copilot file subagent which document toolchain to write. Emitted + // only in Python mode so the JS path sends no new field (Go defaults to js). + ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), isHosted, } } diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts index 1d491d95a1d..15a5ef1ef63 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -20,7 +20,7 @@ import type { OrchestratorResult, } from '@/lib/copilot/request/types' -export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome +export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome | 'interrupted' interface PersistedToolCall { id: string @@ -47,6 +47,8 @@ export interface PersistedContentBlock { timestamp?: number endedAt?: number parentToolCallId?: string + spanId?: string + parentSpanId?: string } export interface PersistedFileAttachment { @@ -142,9 +144,21 @@ function withBlockParent(target: T, src: { parentToolCallId?: string }): T { return target } +/** + * Carry deterministic span identity (spanId / parentSpanId) across a block + * mapping so the nesting tree survives the persist → normalize → display round + * trip. Shared by both the write and read paths. + */ +function withBlockSpan(target: T, src: { spanId?: string; parentSpanId?: string }): T { + const writable = target as { spanId?: string; parentSpanId?: string } + if (src.spanId) writable.spanId = src.spanId + if (src.parentSpanId) writable.parentSpanId = src.parentSpanId + return target +} + function mapContentBlock(block: ContentBlock): PersistedContentBlock { const persisted = mapContentBlockBody(block) - return withBlockParent(withBlockTiming(persisted, block), block) + return withBlockSpan(withBlockParent(withBlockTiming(persisted, block), block), block) } function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { @@ -348,6 +362,8 @@ interface RawBlock { timestamp?: number endedAt?: number parentToolCallId?: string + spanId?: string + parentSpanId?: string toolCall?: { id?: string name?: string @@ -377,6 +393,9 @@ const OUTCOME_NORMALIZATION: Record = { [MothershipStreamV1ToolOutcome.cancelled]: MothershipStreamV1ToolOutcome.cancelled, [MothershipStreamV1ToolOutcome.skipped]: MothershipStreamV1ToolOutcome.skipped, [MothershipStreamV1ToolOutcome.rejected]: MothershipStreamV1ToolOutcome.rejected, + aborted: MothershipStreamV1ToolOutcome.cancelled, + failed: MothershipStreamV1ToolOutcome.error, + interrupted: 'interrupted', pending: 'pending', executing: 'executing', } @@ -525,6 +544,12 @@ function normalizeBlock(block: RawBlock): PersistedContentBlock { if (block.parentToolCallId && result.parentToolCallId === undefined) { result.parentToolCallId = block.parentToolCallId } + if (block.spanId && result.spanId === undefined) { + result.spanId = block.spanId + } + if (block.parentSpanId && result.parentSpanId === undefined) { + result.parentSpanId = block.parentSpanId + } return result } diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/copilot/chat/post.test.ts index e9f4ba41e30..118f6fee775 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/copilot/chat/post.test.ts @@ -13,7 +13,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const resolveWorkflowIdForUser = workflowsUtilsMockFns.mockResolveWorkflowIdForUser -const getWorkflowById = workflowsUtilsMockFns.mockGetWorkflowById const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions const { @@ -138,9 +137,9 @@ describe('handleUnifiedChatPost', () => { resolveWorkflowIdForUser.mockResolvedValue({ status: 'resolved', workflowId: 'wf-1', + workspaceId: 'ws-1', workflowName: 'Workflow One', }) - getWorkflowById.mockResolvedValue({ workspaceId: 'ws-1' }) getUserEntityPermissions.mockResolvedValue('write') getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' }) generateWorkspaceContext.mockResolvedValue('workspace context') @@ -179,8 +178,17 @@ describe('handleUnifiedChatPost', () => { ) expect(response.status).toBe(200) + expect(generateWorkspaceContext).toHaveBeenCalledWith('ws-1', 'user-1') + expect(buildCopilotRequestPayload).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-opus-4-8', + workspaceContext: 'workspace context', + }), + { selectedModel: 'claude-opus-4-8' } + ) expect(createSSEStream).toHaveBeenCalledWith( expect.objectContaining({ + titleModel: 'claude-opus-4-8', workspaceId: 'ws-1', orchestrateOptions: expect.objectContaining({ workflowId: 'wf-1', @@ -218,6 +226,7 @@ describe('handleUnifiedChatPost', () => { ) expect(createSSEStream).toHaveBeenCalledWith( expect.objectContaining({ + titleModel: 'claude-opus-4-8', workspaceId: 'ws-1', orchestrateOptions: expect.objectContaining({ workspaceId: 'ws-1', @@ -233,6 +242,31 @@ describe('handleUnifiedChatPost', () => { ) }) + it('accepts tagged skill contexts and forwards them to context resolution', async () => { + const response = await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Hello', + workspaceId: 'ws-1', + createNewChat: true, + contexts: [{ kind: 'skill', skillId: 'sk-1', label: 'my-skill' }], + }), + }) + ) + + expect(response.status).toBe(200) + expect(processContextsServer).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ kind: 'skill', skillId: 'sk-1', label: 'my-skill' }), + ]), + 'user-1', + 'Hello', + 'ws-1', + expect.anything() + ) + }) + it('persists cancelled partial responses from the server lifecycle', async () => { await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { @@ -275,6 +309,79 @@ describe('handleUnifiedChatPost', () => { ) }) + it('persists partial responses when the server lifecycle throws (onError)', async () => { + await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Hello', + workspaceId: 'ws-1', + createNewChat: true, + }), + }) + ) + + const streamArgs = createSSEStream.mock.calls[0]?.[0] + const onError = streamArgs?.orchestrateOptions?.onError + expect(onError).toBeTypeOf('function') + + await onError(new Error('bedrock overloaded'), { + success: false, + cancelled: false, + content: 'partial answer', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + requestId: 'request-1', + }) + + expect(finalizeAssistantTurn).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'chat-1', + userMessageId: expect.any(String), + streamMarkerPolicy: 'active-or-cleared', + assistantMessage: expect.objectContaining({ + role: 'assistant', + content: 'partial answer', + }), + }) + ) + }) + + it('clears the stream marker without an assistant message when nothing streamed before the throw', async () => { + await handleUnifiedChatPost( + new NextRequest('http://localhost/api/copilot/chat', { + method: 'POST', + body: JSON.stringify({ + message: 'Hello', + workspaceId: 'ws-1', + createNewChat: true, + }), + }) + ) + + const streamArgs = createSSEStream.mock.calls[0]?.[0] + const onError = streamArgs?.orchestrateOptions?.onError + expect(onError).toBeTypeOf('function') + + await onError(new Error('immediate failure'), { + success: false, + cancelled: false, + content: '', + contentBlocks: [], + toolCalls: [], + chatId: 'chat-1', + requestId: 'request-1', + }) + + const lastCall = finalizeAssistantTurn.mock.calls.at(-1)?.[0] + expect(lastCall).toMatchObject({ + chatId: 'chat-1', + streamMarkerPolicy: 'active-or-cleared', + }) + expect(lastCall?.assistantMessage).toBeUndefined() + }) + it('republishes completed status when cancelled lifecycle persistence already ran', async () => { await handleUnifiedChatPost( new NextRequest('http://localhost/api/copilot/chat', { diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index f3b8e4a9259..5465b25437a 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -44,7 +44,7 @@ import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request import { persistChatResources } from '@/lib/copilot/resources/persistence' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' +import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { getUserEntityPermissions, isWorkspaceAccessDeniedError, @@ -54,7 +54,7 @@ import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 const logger = createLogger('UnifiedChatAPI') -const DEFAULT_MODEL = 'claude-opus-4-6' +const DEFAULT_MODEL = 'claude-opus-4-8' const FileAttachmentSchema = z.object({ id: z.string(), @@ -109,6 +109,7 @@ const ChatContextSchema = z.object({ 'folder', 'filefolder', 'integration', + 'skill', ]), label: z.string(), chatId: z.string().optional(), @@ -121,6 +122,7 @@ const ChatContextSchema = z.object({ fileId: z.string().optional(), folderId: z.string().optional(), fileFolderId: z.string().optional(), + skillId: z.string().optional(), }) const ChatMessageSchema = z.object({ @@ -150,6 +152,7 @@ type UnifiedChatBranch = workflowId: string workflowName?: string workspaceId?: string + effectiveModel: string selectedModel: string mode: UnifiedChatRequest['mode'] provider?: string @@ -162,10 +165,11 @@ type UnifiedChatBranch = userId: string userMessageId: string chatId?: string - contexts: Array<{ type: string; content: string }> + contexts: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string userTimezone?: string + userMetadata?: { name?: string; timezone?: string } workflowId: string workflowName?: string workspaceId?: string @@ -174,6 +178,7 @@ type UnifiedChatBranch = commands?: string[] prefetch?: boolean implicitFeedback?: string + workspaceContext?: string }) => Promise> buildExecutionContext: (params: { userId: string @@ -185,6 +190,7 @@ type UnifiedChatBranch = | { kind: 'workspace' workspaceId: string + effectiveModel: string goRoute: '/api/mothership' titleModel: string titleProvider?: undefined @@ -194,10 +200,11 @@ type UnifiedChatBranch = userId: string userMessageId: string chatId?: string - contexts: Array<{ type: string; content: string }> + contexts: Array<{ type: string; content: string; tag?: string; path?: string }> fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string userTimezone?: string + userMetadata?: { name?: string; timezone?: string } workspaceContext?: string }) => Promise> buildExecutionContext: (params: { @@ -229,10 +236,10 @@ async function resolveAgentContexts(params: { workspaceId?: string chatId?: string requestId: string -}): Promise> { +}): Promise> { const { contexts, resourceAttachments, userId, message, workspaceId, chatId, requestId } = params - let agentContexts: Array<{ type: string; content: string }> = [] + let agentContexts: Array<{ type: string; content: string; tag?: string; path?: string }> = [] if (Array.isArray(contexts) && contexts.length > 0) { try { @@ -461,12 +468,19 @@ function buildOnComplete(params: { return } + // On a non-success terminal (e.g. a transient provider error like + // "overloaded"), persist whatever streamed before the failure — same as + // the cancelled path — instead of dropping the partial assistant output. + const assistantMessage = buildPersistedAssistantMessage(result, requestId) + const hasPartial = + !!assistantMessage.content?.trim() || (assistantMessage.contentBlocks?.length ?? 0) > 0 await finalizeAssistantTurn({ chatId, userMessageId, - ...(result.success - ? { assistantMessage: buildPersistedAssistantMessage(result, requestId) } - : {}), + ...(result.success || hasPartial ? { assistantMessage } : {}), + // Match the cancelled path so the partial still persists if onError + // raced ahead and already cleared the stream marker. + ...(result.success ? {} : { streamMarkerPolicy: 'active-or-cleared' as const }), }) if (notifyWorkspaceStatus && workspaceId) { @@ -495,11 +509,25 @@ function buildOnError(params: { }) { const { chatId, userMessageId, requestId, workspaceId, notifyWorkspaceStatus } = params - return async () => { + return async (_error: Error, result?: OrchestratorResult) => { if (!chatId) return try { - await finalizeAssistantTurn({ chatId, userMessageId }) + // Persist whatever streamed before a thrown backend error, mirroring the + // cancelled / non-success completion path, so the partial assistant turn + // (text + tool calls + subagent work) survives the refetch instead of the + // chat collapsing to an empty assistant row. + const assistantMessage = result + ? buildPersistedAssistantMessage(result, requestId) + : undefined + const hasPartial = + !!assistantMessage?.content?.trim() || (assistantMessage?.contentBlocks?.length ?? 0) > 0 + await finalizeAssistantTurn({ + chatId, + userMessageId, + ...(hasPartial ? { assistantMessage } : {}), + streamMarkerPolicy: 'active-or-cleared', + }) if (notifyWorkspaceStatus && workspaceId) { chatPubSub?.publishStatusChanged({ @@ -549,13 +577,7 @@ async function resolveBranch(params: { } const resolvedWorkflowId = resolved.workflowId - let resolvedWorkspaceId: string | undefined - try { - const workflow = await getWorkflowById(resolvedWorkflowId) - resolvedWorkspaceId = workflow?.workspaceId ?? requestedWorkspaceId - } catch { - resolvedWorkspaceId = requestedWorkspaceId - } + const resolvedWorkspaceId = resolved.workspaceId const selectedModel = model || DEFAULT_MODEL return { @@ -563,6 +585,7 @@ async function resolveBranch(params: { workflowId: resolvedWorkflowId, workflowName: resolved.workflowName, workspaceId: resolvedWorkspaceId, + effectiveModel: selectedModel, selectedModel, mode: mode ?? 'agent', provider, @@ -588,8 +611,10 @@ async function resolveBranch(params: { chatId: payloadParams.chatId, prefetch: payloadParams.prefetch, implicitFeedback: payloadParams.implicitFeedback, + workspaceContext: payloadParams.workspaceContext, userPermission: payloadParams.userPermission, userTimezone: payloadParams.userTimezone, + userMetadata: payloadParams.userMetadata, }, { selectedModel } ), @@ -629,6 +654,7 @@ async function resolveBranch(params: { return { kind: 'workspace', workspaceId: requestedWorkspaceId, + effectiveModel: DEFAULT_MODEL, goRoute: '/api/mothership', titleModel: DEFAULT_MODEL, notifyWorkspaceStatus: true, @@ -647,6 +673,7 @@ async function resolveBranch(params: { workspaceContext: payloadParams.workspaceContext, userPermission: payloadParams.userPermission, userTimezone: payloadParams.userTimezone, + userMetadata: payloadParams.userMetadata, includeMothershipTools: true, }, { selectedModel: '' } @@ -689,8 +716,14 @@ export async function handleUnifiedChatPost(req: NextRequest) { } const authenticatedUserId = session.user.id const authenticatedUserEmail = session.user.email + const authenticatedUserName = + typeof session.user.name === 'string' ? session.user.name : undefined const body = ChatMessageSchema.parse(await req.json()) + const userMetadata = { + ...(authenticatedUserName ? { name: authenticatedUserName } : {}), + ...(body.userTimezone ? { timezone: body.userTimezone } : {}), + } const normalizedContexts = normalizeContexts(body.contexts) ?? [] userMessageId = body.userMessageId || generateId() @@ -830,7 +863,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { activeOtelRoot.setRequestShape({ branchKind: branch.kind, mode: body.mode, - model: body.model, + model: branch.effectiveModel, provider: body.provider, createNewChat: body.createNewChat, prefetch: body.prefetch, @@ -856,15 +889,14 @@ export async function handleUnifiedChatPost(req: NextRequest) { // opens". Previously these ran bare under the root and inflated the // apparent "gap" before the model call. Each promise is its own // span; they run concurrently under Promise.all below. - const workspaceContextPromise = - branch.kind === 'workspace' - ? withCopilotSpan( - TraceSpan.CopilotChatBuildWorkspaceContext, - { [TraceAttr.WorkspaceId]: branch.workspaceId }, - () => generateWorkspaceContext(branch.workspaceId, authenticatedUserId), - activeOtelRoot.context - ) - : Promise.resolve(undefined) + const workspaceContextPromise = workspaceId + ? withCopilotSpan( + TraceSpan.CopilotChatBuildWorkspaceContext, + { [TraceAttr.WorkspaceId]: workspaceId }, + () => generateWorkspaceContext(workspaceId, authenticatedUserId), + activeOtelRoot.context + ) + : Promise.resolve(undefined) const agentContextsPromise = withCopilotSpan( TraceSpan.CopilotChatResolveAgentContexts, { @@ -940,6 +972,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, userTimezone: body.userTimezone, + userMetadata, workflowId: branch.workflowId, workflowName: branch.workflowName, workspaceId: branch.workspaceId, @@ -948,6 +981,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { commands: body.commands, prefetch: body.prefetch, implicitFeedback: body.implicitFeedback, + workspaceContext, }) : branch.buildPayload({ message: body.message, @@ -958,6 +992,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { fileAttachments: body.fileAttachments, userPermission: userPermission ?? undefined, userTimezone: body.userTimezone, + userMetadata, workspaceContext, }), activeOtelRoot.context @@ -988,7 +1023,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { orchestrateOptions: { userId: authenticatedUserId, ...(branch.kind === 'workflow' ? { workflowId: branch.workflowId } : {}), - ...(branch.kind === 'workspace' ? { workspaceId: branch.workspaceId } : {}), + ...(workspaceId ? { workspaceId } : {}), chatId: actualChatId, executionId, runId, diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts new file mode 100644 index 00000000000..c55f0e83a90 --- /dev/null +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatContext } from '@/stores/panel' + +const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) + +vi.mock('@sim/db', () => ({ db: {} })) +vi.mock('@sim/db/schema', () => ({ document: {}, knowledgeBase: {} })) +vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) + +import { processContextsServer } from './process-contents' + +describe('processContextsServer - skill contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged skill to full content + encoded VFS path', async () => { + getSkillById.mockResolvedValue({ + id: 'sk-1', + name: 'My Skill — PostHog', + description: 'desc', + content: '# My Skill\n\nDo the thing.', + }) + + const result = await processContextsServer( + [{ kind: 'skill', skillId: 'sk-1', label: 'My Skill — PostHog' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(getSkillById).toHaveBeenCalledWith({ skillId: 'sk-1', workspaceId: 'ws-1' }) + expect(result).toEqual([ + { + type: 'skill', + tag: '@My Skill — PostHog', + content: '# My Skill\n\nDo the thing.', + path: 'agent/skills/My%20Skill%20%E2%80%94%20PostHog.json', + }, + ]) + }) + + it('drops a skill that does not resolve (unknown or cross-workspace)', async () => { + getSkillById.mockResolvedValue(null) + + const result = await processContextsServer( + [{ kind: 'skill', skillId: 'missing', label: 'x' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) + + it('drops a skill when no workspace is in scope', async () => { + const result = await processContextsServer( + [{ kind: 'skill', skillId: 'sk-1', label: 'x' } as ChatContext], + 'user-1', + 'hello', + undefined + ) + + expect(getSkillById).not.toHaveBeenCalled() + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 58863ad7d22..076d77f5707 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { document, knowledgeBase } from '@sim/db/schema' +import { knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, @@ -7,17 +7,22 @@ import { } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { - serializeFileMeta, - serializeTableMeta, - serializeWorkflowMeta, -} from '@/lib/copilot/vfs/serializers' + buildVfsFolderPathMap, + canonicalBlockVfsPath, + canonicalKnowledgeBaseVfsDir, + canonicalTableVfsPath, + canonicalWorkflowVfsDir, + canonicalWorkspaceFilePath, + encodeVfsPathSegments, + encodeVfsSegment, +} from '@/lib/copilot/vfs/path-utils' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { getTableById } from '@/lib/table/service' +import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' +import { getSkillById } from '@/lib/workflows/skills/operations' +import { listFolders } from '@/lib/workflows/utils' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' -import { isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' import type { ChatContext } from '@/stores/panel' @@ -36,11 +41,20 @@ type AgentContextType = | 'folder' | 'filefolder' | 'active_resource' + | 'skill' interface AgentContext { type: AgentContextType tag: string content: string + /** + * Canonical, URL-encoded VFS path for the tagged resource (e.g. + * `agent/skills/My%20Skill.json`). Tagged resources are sent as path + * pointers so the model reads them on demand via VFS tools instead of the + * full body bloating the request. Skills are the exception: they carry both + * `path` and the full `content` so the skill is autoloaded. + */ + path?: string } const logger = createLogger('ProcessContents') @@ -56,6 +70,13 @@ export async function processContextsServer( if (!Array.isArray(contexts) || contexts.length === 0) return [] const tasks = contexts.map(async (ctx) => { try { + if (ctx.kind === 'skill' && ctx.skillId && currentWorkspaceId) { + return await processSkillFromDb( + ctx.skillId, + currentWorkspaceId, + ctx.label ? `@${ctx.label}` : '@' + ) + } if (ctx.kind === 'past_chat' && ctx.chatId) { return await processPastChatFromDb( ctx.chatId, @@ -110,17 +131,32 @@ export async function processContextsServer( if (ctx.kind === 'table' && ctx.tableId && currentWorkspaceId) { const result = await resolveTableResource(ctx.tableId, currentWorkspaceId) if (!result) return null - return { type: 'table', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content } + return { + type: 'table', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, + } } if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) { const result = await resolveFileResource(ctx.fileId, currentWorkspaceId) if (!result) return null - return { type: 'file', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content } + return { + type: 'file', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, + } } if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) { const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId) if (!result) return null - return { type: 'folder', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content } + return { + type: 'folder', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, + } } if (ctx.kind === 'filefolder' && ctx.fileFolderId && currentWorkspaceId) { const result = await resolveFileFolderResource(ctx.fileFolderId, currentWorkspaceId) @@ -129,6 +165,7 @@ export async function processContextsServer( type: 'filefolder', tag: ctx.label ? `@${ctx.label}` : '@', content: result.content, + path: result.path, } } if (ctx.kind === 'docs') { @@ -154,7 +191,10 @@ export async function processContextsServer( }) const results = await Promise.all(tasks) const filtered = results.filter( - (r): r is AgentContext => !!r && typeof r.content === 'string' && r.content.trim().length > 0 + (r): r is AgentContext => + !!r && + ((typeof r.content === 'string' && r.content.trim().length > 0) || + (typeof r.path === 'string' && r.path.length > 0)) ) logger.info('Processed contexts (server)', { totalRequested: contexts.length, @@ -211,6 +251,25 @@ function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | un return result } +async function processSkillFromDb( + skillId: string, + workspaceId: string, + tag: string +): Promise { + try { + const s = await getSkillById({ skillId, workspaceId }) + if (!s) return null + // Skills are autoloaded: carry the full SKILL.md body so the Go side can + // inject it into the dynamic system message for the turn. The path lets the + // model re-read the canonical VFS file if it needs to. + const path = `agent/skills/${encodeVfsSegment(s.name)}.json` + return { type: 'skill', tag, content: s.content, path } + } catch (error) { + logger.error('Error processing skill context (db)', { skillId, error }) + return null + } +} + async function processPastChatFromDb( chatId: string, userId: string, @@ -264,13 +323,33 @@ async function processPastChatFromDb( } } +/** + * Resolve a workflow folder id to its canonical, per-segment-encoded VFS folder + * path. Returns null for root-level workflows or when the folder can't be + * resolved. Uses the shared {@link buildVfsFolderPathMap} so the pointer path + * matches what the workspace VFS serves. + */ +async function resolveWorkflowFolderPath( + workspaceId: string | null | undefined, + folderId: string | null | undefined +): Promise { + if (!folderId || !workspaceId) return null + try { + const folders = await listFolders(workspaceId) + return buildVfsFolderPathMap(folders).get(folderId) ?? null + } catch (error) { + logger.warn('Failed to resolve workflow folder path', { workspaceId, folderId, error }) + return null + } +} + async function processWorkflowFromDb( workflowId: string, userId: string | undefined, tag: string, kind: 'workflow' | 'current_workflow' = 'workflow', currentWorkspaceId?: string, - chatId?: string + _chatId?: string ): Promise { try { let workflowRecord: Awaited> = null @@ -293,50 +372,18 @@ async function processWorkflowFromDb( if (!workflowRecord) { workflowRecord = await getActiveWorkflowRecord(workflowId) } - - if (kind === 'workflow') { - if (!workflowRecord) return null - const content = serializeWorkflowMeta({ - id: workflowRecord.id, - name: workflowRecord.name, - description: workflowRecord.description, - isDeployed: workflowRecord.isDeployed, - deployedAt: workflowRecord.deployedAt, - runCount: workflowRecord.runCount, - lastRunAt: workflowRecord.lastRunAt, - createdAt: workflowRecord.createdAt, - updatedAt: workflowRecord.updatedAt, - }) - return { type: kind, tag, content } - } - - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - logger.warn('No normalized workflow data found', { workflowId }) - return null - } - - const workflowState = { - blocks: normalized.blocks || {}, - edges: normalized.edges || [], - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - } - const sanitizedState = sanitizeForCopilot(workflowState) - const content = JSON.stringify( - { - workflowId, - workflowName: workflowRecord?.name || undefined, - state: sanitizedState, - }, - null, - 2 + if (!workflowRecord) return null + + // Emit a VFS-path pointer instead of the full (potentially huge) workflow + // state/meta. `current_workflow` points at the live state; a plain + // `workflow` mention points at the lighter metadata file. + const folderPath = await resolveWorkflowFolderPath( + workflowRecord.workspaceId ?? currentWorkspaceId, + workflowRecord.folderId ) - logger.info('Processed sanitized workflow context', { - workflowId, - blocks: Object.keys(sanitizedState.blocks || {}).length, - }) - return { type: kind, tag, content } + const dir = canonicalWorkflowVfsDir({ name: workflowRecord.name, folderPath }) + const path = kind === 'current_workflow' ? `${dir}/state.json` : `${dir}/meta.json` + return { type: kind, tag, content: '', path } } catch (error) { logger.error('Error processing workflow context', { workflowId, error }) return null @@ -411,7 +458,6 @@ async function processKnowledgeFromDb( .select({ id: knowledgeBase.id, name: knowledgeBase.name, - updatedAt: knowledgeBase.updatedAt, }) .from(knowledgeBase) .where(and(...conditions)) @@ -419,30 +465,12 @@ async function processKnowledgeFromDb( const kb = kbRows?.[0] if (!kb) return null - // Load up to 20 recent doc filenames - const docRows = await db - .select({ filename: document.filename }) - .from(document) - .where( - and( - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(20) - - const sampleDocuments = docRows.map((d: any) => d.filename).filter(Boolean) - // We don't have total via this quick select; fallback to sample count - const summary = { - id: kb.id, - name: kb.name, - docCount: sampleDocuments.length, - sampleDocuments, + return { + type: 'knowledge', + tag, + content: '', + path: `${canonicalKnowledgeBaseVfsDir(kb.name)}/meta.json`, } - const content = JSON.stringify(summary) - return { type: 'knowledge', tag, content } } catch (error) { logger.error('Error processing knowledge context (db)', { knowledgeBaseId, error }) return null @@ -466,60 +494,11 @@ async function processBlockMetadata( } const { registry: blockRegistry } = await import('@/blocks/registry') - const { tools: toolsRegistry } = await import('@/tools/registry') - const SPECIAL_BLOCKS_METADATA: Record = {} - - let metadata: any = {} - if ((SPECIAL_BLOCKS_METADATA as any)[blockId]) { - metadata = { ...(SPECIAL_BLOCKS_METADATA as any)[blockId] } - metadata.tools = metadata.tools?.access || [] - } else { - const blockConfig: any = (blockRegistry as any)[blockId] - if (!blockConfig) { - return null - } - metadata = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.description || '', - longDescription: blockConfig.longDescription, - category: blockConfig.category, - bgColor: blockConfig.bgColor, - inputs: blockConfig.inputs || {}, - outputs: blockConfig.outputs - ? Object.fromEntries( - Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : {}, - tools: blockConfig.tools?.access || [], - hideFromToolbar: blockConfig.hideFromToolbar, - } - if (blockConfig.subBlocks && Array.isArray(blockConfig.subBlocks)) { - metadata.subBlocks = (blockConfig.subBlocks as any[]).map((sb: any) => ({ - id: sb.id, - name: sb.name, - type: sb.type, - description: sb.description, - default: sb.default, - options: Array.isArray(sb.options) ? sb.options : [], - })) - } else { - metadata.subBlocks = [] - } - } - - if (Array.isArray(metadata.tools) && metadata.tools.length > 0) { - metadata.toolDetails = {} - for (const toolId of metadata.tools) { - const tool = (toolsRegistry as any)[toolId] - if (tool) { - metadata.toolDetails[toolId] = { name: tool.name, description: tool.description } - } - } + if (!(blockRegistry as any)[blockId]) { + return null } - const content = JSON.stringify({ metadata }) - return { type: 'blocks', tag, content } + return { type: 'blocks', tag, content: '', path: canonicalBlockVfsPath(blockId) } } catch (error) { logger.error('Error processing block metadata', { blockId, error }) return null @@ -534,6 +513,7 @@ async function processWorkflowBlockFromDb( currentWorkspaceId?: string ): Promise { try { + let workflowRecord: Awaited> = null if (userId) { const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, @@ -546,20 +526,28 @@ async function processWorkflowBlockFromDb( if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) { return null } + workflowRecord = authorization.workflow ?? null } - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) return null - const block = (normalized.blocks as any)[blockId] - if (!block) return null - const tag = label ? `@${label} in Workflow` : `@${block.name || blockId} in Workflow` + if (!workflowRecord) { + workflowRecord = await getActiveWorkflowRecord(workflowId) + } + if (!workflowRecord) return null - const contentObj = { - workflowId, - block: block, + const folderPath = await resolveWorkflowFolderPath( + workflowRecord.workspaceId ?? currentWorkspaceId, + workflowRecord.folderId + ) + const dir = canonicalWorkflowVfsDir({ name: workflowRecord.name, folderPath }) + const tag = label ? `@${label} in Workflow` : `@${blockId} in Workflow` + // Point at the workflow state; the block id tells the model which node to + // look up inside state.json without inlining the full block definition. + return { + type: 'workflow_block', + tag, + content: `Block id: ${blockId}`, + path: `${dir}/state.json`, } - const content = JSON.stringify(contentObj) - return { type: 'workflow_block', tag, content } } catch (error) { logger.error('Error processing workflow_block context', { workflowId, blockId, error }) return null @@ -674,7 +662,12 @@ export async function resolveActiveResourceContext( chatId ) if (!ctx) return null - return { type: 'active_resource', tag: '@active_resource', content: ctx.content } + return { + type: 'active_resource', + tag: '@active_resource', + content: ctx.content, + path: ctx.path, + } } case 'knowledgebase': { const ctx = await processKnowledgeFromDb( @@ -684,7 +677,12 @@ export async function resolveActiveResourceContext( workspaceId ) if (!ctx) return null - return { type: 'active_resource', tag: '@active_resource', content: ctx.content } + return { + type: 'active_resource', + tag: '@active_resource', + content: ctx.content, + path: ctx.path, + } } case 'table': { return await resolveTableResource(resourceId, workspaceId) @@ -716,7 +714,8 @@ async function resolveTableResource( return { type: 'active_resource', tag: '@active_resource', - content: serializeTableMeta(table), + content: '', + path: canonicalTableVfsPath(table.name), } } @@ -729,13 +728,8 @@ async function resolveFileResource( return { type: 'active_resource', tag: '@active_resource', - content: serializeFileMeta({ - id: record.id, - name: record.name, - contentType: record.type, - size: record.size, - uploadedAt: record.uploadedAt, - }), + content: '', + path: canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }), } } @@ -744,38 +738,15 @@ async function resolveFileFolderResource( workspaceId: string ): Promise { try { - const { workspaceFileFolder, workspaceFiles } = await import('@sim/db/schema') - const [folder] = await db - .select({ id: workspaceFileFolder.id, name: workspaceFileFolder.name }) - .from(workspaceFileFolder) - .where( - and( - eq(workspaceFileFolder.id, folderId), - eq(workspaceFileFolder.workspaceId, workspaceId), - isNull(workspaceFileFolder.deletedAt) - ) - ) - .limit(1) - if (!folder) return null - - const files = await db - .select({ - name: workspaceFiles.originalName, - type: workspaceFiles.contentType, - }) - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.folderId, folderId), - eq(workspaceFiles.workspaceId, workspaceId), - isNull(workspaceFiles.deletedAt) - ) - ) - - const fileList = files.map((f) => `- ${f.name}${f.type ? ` (${f.type})` : ''}`).join('\n') - const content = `File Folder: ${folder.name} (id: ${folder.id})\nFiles:\n${fileList || '(empty)'}` - - return { type: 'active_resource', tag: '@active_resource', content } + const rawPath = await getWorkspaceFileFolderPath(workspaceId, folderId) + if (!rawPath) return null + const encoded = encodeVfsPathSegments(rawPath.split('/').filter(Boolean)) + return { + type: 'active_resource', + tag: '@active_resource', + content: '', + path: `files/${encoded}`, + } } catch (error) { logger.error('Failed to resolve file folder resource', { folderId, error }) return null @@ -786,26 +757,12 @@ async function resolveFolderResource( folderId: string, workspaceId: string ): Promise { - try { - const { workflowFolder, workflow } = await import('@sim/db/schema') - const [folder] = await db - .select({ id: workflowFolder.id, name: workflowFolder.name }) - .from(workflowFolder) - .where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId))) - .limit(1) - if (!folder) return null - - const workflows = await db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) - - const workflowList = workflows.map((w) => `- ${w.name} (id: ${w.id})`).join('\n') - const content = `Folder: ${folder.name} (id: ${folder.id})\nWorkflows:\n${workflowList || '(empty)'}` - - return { type: 'active_resource', tag: '@active_resource', content } - } catch (error) { - logger.error('Failed to resolve folder resource', { folderId, error }) - return null + const folderPath = await resolveWorkflowFolderPath(workspaceId, folderId) + if (!folderPath) return null + return { + type: 'active_resource', + tag: '@active_resource', + content: '', + path: `workflows/${folderPath}`, } } diff --git a/apps/sim/lib/copilot/chat/stream-tool-outcome.ts b/apps/sim/lib/copilot/chat/stream-tool-outcome.ts index 863c47f98ee..88faa018467 100644 --- a/apps/sim/lib/copilot/chat/stream-tool-outcome.ts +++ b/apps/sim/lib/copilot/chat/stream-tool-outcome.ts @@ -38,6 +38,10 @@ export function resolveStreamToolOutcome({ case MothershipStreamV1ToolOutcome.skipped: case MothershipStreamV1ToolOutcome.rejected: return status + case 'aborted': + return MothershipStreamV1ToolOutcome.cancelled + case 'failed': + return MothershipStreamV1ToolOutcome.error default: return success === true ? MothershipStreamV1ToolOutcome.success diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts new file mode 100644 index 00000000000..0c7850da2d3 --- /dev/null +++ b/apps/sim/lib/copilot/chat/workspace-context.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => ({ db: {} })) +vi.mock('@sim/db/schema', () => ({ + knowledgeBase: {}, + knowledgeConnector: {}, + mcpServers: {}, + userTableDefinitions: {}, + userTableRows: {}, + workflow: {}, + workflowFolder: {}, + workflowSchedule: {}, +})) + +import { canonicalWorkflowVfsDir } from '@/lib/copilot/vfs/path-utils' +import { buildWorkspaceMd, type WorkspaceMdData } from './workspace-context' + +function baseData(overrides: Partial = {}): WorkspaceMdData { + return { + workspace: { id: 'ws-1', name: 'WS', ownerId: 'u-1' }, + members: [], + workflows: [], + knowledgeBases: [], + tables: [], + files: [], + oauthIntegrations: [], + envVariables: [], + ...overrides, + } +} + +describe('buildWorkspaceMd - workflow VFS state paths', () => { + // `workflows[].folderPath` arrives ALREADY per-segment percent-encoded (it is + // the value from buildVfsFolderPathMap / resolveFolderPath that also builds the + // stored VFS keys). The advertised path must not re-encode it. + it('emits a single-encoded state path for a folder name with a space', () => { + const md = buildWorkspaceMd( + baseData({ + workflows: [ + { id: 'wf-1', name: 'The Elder', isDeployed: false, folderPath: 'The%20Elder' }, + ], + }) + ) + + expect(md).toContain('workflows/The%20Elder/The%20Elder/state.json') + // The exact double-encoding regression: `%20` -> `%2520`. + expect(md).not.toContain('The%2520Elder') + }) + + it('matches the canonical VFS dir helper the materializer/pointers use', () => { + const folderPath = 'My%20Folder/Sub%20Folder' + const md = buildWorkspaceMd( + baseData({ + workflows: [{ id: 'wf-1', name: 'My Flow', isDeployed: false, folderPath }], + }) + ) + + const expected = `${canonicalWorkflowVfsDir({ name: 'My Flow', folderPath })}/state.json` + expect(expected).toBe('workflows/My%20Folder/Sub%20Folder/My%20Flow/state.json') + expect(md).toContain(expected) + }) + + it('advertises canonical encoded VFS paths for root-level workflows', () => { + const md = buildWorkspaceMd( + baseData({ + workflows: [{ id: 'wf-1', name: 'Root Flow', isDeployed: false, folderPath: null }], + }) + ) + + expect(md).toContain('VFS dir: `workflows/Root%20Flow`') + expect(md).toContain('VFS state path: `workflows/Root%20Flow/state.json`') + }) +}) + +describe('buildWorkspaceMd - connected integrations / credentials', () => { + it('lists each connected account with its credentialId and never leaks tokens', () => { + const md = buildWorkspaceMd( + baseData({ + oauthIntegrations: [ + { + id: 'cred-abc', + providerId: 'google-email', + displayName: 'alice@example.com', + role: 'admin', + }, + { id: 'cred-def', providerId: 'slack', displayName: 'Workspace Bot', role: 'member' }, + ], + }) + ) + + // credentialId must be present so the superagent can pass it without reading credentials.json. + expect(md).toContain('credentialId: `cred-abc`') + expect(md).toContain('credentialId: `cred-def`') + expect(md).toContain('google-email') + expect(md).toContain('slack') + + // No OAuth secrets/tokens may ever appear in the workspace context. + for (const secret of [ + 'accessToken', + 'refreshToken', + 'idToken', + 'clientSecret', + 'access_token', + 'refresh_token', + ]) { + expect(md).not.toContain(secret) + } + }) + + it('renders (none) when no integrations are connected', () => { + const md = buildWorkspaceMd(baseData({ oauthIntegrations: [] })) + expect(md).toContain('## Connected Integrations\n(none)') + }) +}) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index 36b63fbd5f4..09c9a02c51d 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -13,6 +13,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, count, eq, inArray, isNull } from 'drizzle-orm' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' @@ -58,7 +59,12 @@ export interface WorkspaceMdData { }> tables: Array<{ id: string; name: string; description?: string | null; rowCount: number }> files: Array<{ id: string; name: string; type: string; size: number; folderPath?: string | null }> - oauthIntegrations: Array<{ providerId: string }> + oauthIntegrations: Array<{ + id: string + providerId: string + displayName?: string | null + role?: string | null + }> envVariables: string[] tasks?: Array<{ id: string; title: string; updatedAt: Date }> customTools?: Array<{ id: string; name: string }> @@ -75,23 +81,6 @@ export interface WorkspaceMdData { }> } -function normalizeFolderPathForVfs(folderPath?: string | null): string | null { - if (!folderPath) return null - const segments = folderPath - .split('/') - .map((segment) => normalizeVfsSegment(segment)) - .filter(Boolean) - return segments.length > 0 ? segments.join('/') : null -} - -function buildWorkflowStatePath(workflowName: string, folderPath?: string | null): string { - const normalizedFolderPath = normalizeFolderPathForVfs(folderPath) - const normalizedWorkflowName = normalizeVfsSegment(workflowName) - return normalizedFolderPath - ? `workflows/${normalizedFolderPath}/${normalizedWorkflowName}/state.json` - : `workflows/${normalizedWorkflowName}/state.json` -} - /** * Pure formatting: build WORKSPACE.md content from pre-fetched data. * No DB access — callers are responsible for providing the data. @@ -129,25 +118,21 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { const formatWf = (wf: (typeof data.workflows)[0], indent: string) => { const parts = [`${indent}- **${wf.name}** (${wf.id})`] + const workflowDir = canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }) + parts.push(`${indent} VFS dir: \`${workflowDir}\``) + parts.push(`${indent} VFS state path: \`${workflowDir}/state.json\``) if (wf.description) parts.push(`${indent} ${wf.description}`) const flags: string[] = [] if (wf.isDeployed) flags.push('deployed') if (wf.lastRunAt) flags.push(`last run: ${wf.lastRunAt.toISOString().split('T')[0]}`) if (flags.length > 0) parts[0] += ` — ${flags.join(', ')}` - if (wf.folderPath) { - parts.push( - `${indent} VFS state path: \`${buildWorkflowStatePath(wf.name, wf.folderPath)}\`` - ) - } return parts.join('\n') } const lines: string[] = [] - if (data.workflows.some((workflow) => workflow.folderPath)) { - lines.push( - 'Use the canonical VFS state path shown under nested workflows. Do not infer nested workflow paths from the leaf workflow name alone.' - ) - } + lines.push( + 'Use the canonical VFS dir/state path shown under each workflow. Paths are percent-encoded per segment; copy them verbatim and do not infer paths from display names.' + ) for (const wf of rootWorkflows) { lines.push(formatWf(wf, '')) } @@ -200,15 +185,21 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { rootFiles.push(f) } } - const lines: string[] = [] + const fileLine = (f: (typeof data.files)[0], indent: string) => { + const vfsPath = canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) + return `${indent}- **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)} — \`${vfsPath}\`` + } + const lines: string[] = [ + 'Read or edit a file by the exact VFS path shown in backticks below — copy it verbatim (it is already percent-encoded) and append `/content` to read the contents. Do not retype the display name or re-encode the path.', + ] for (const f of rootFiles) { - lines.push(`- **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)}`) + lines.push(fileLine(f, '')) } const sortedFolders = [...folderFiles.entries()].sort((a, b) => a[0].localeCompare(b[0])) for (const [folder, folderFileList] of sortedFolders) { lines.push(`- 📁 **${folder}/**`) for (const f of folderFileList) { - lines.push(` - **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)}`) + lines.push(fileLine(f, ' ')) } } sections.push(`## Files (${data.files.length})\n${lines.join('\n')}`) @@ -217,12 +208,16 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { } if (data.oauthIntegrations.length > 0) { - const providers = [...new Set(data.oauthIntegrations.map((c) => c.providerId))] - const lines = providers.map((p) => { - const services = PROVIDER_SERVICES[p] - return services ? `- ${p} (${services.join(', ')})` : `- ${p}` + const lines = data.oauthIntegrations.map((c) => { + const services = PROVIDER_SERVICES[c.providerId] + const svc = services ? ` (${services.join(', ')})` : '' + const who = c.displayName ? ` — ${c.displayName}` : '' + const role = c.role ? `, ${c.role}` : '' + return `- ${c.providerId}${svc}${who}${role} — credentialId: \`${c.id}\`` }) - sections.push(`## Connected Integrations\n${lines.join('\n')}`) + sections.push( + `## Connected Integrations\nPass these credentialId values directly on OAuth tool calls — no need to read environment/credentials.json for them.\n${lines.join('\n')}` + ) } else { sections.push('## Connected Integrations\n(none)') } @@ -247,7 +242,11 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { if (data.skills && data.skills.length > 0) { const lines = data.skills.map((s) => `- **${s.name}** (${s.id}) — ${s.description}`) - sections.push(`## Skills (${data.skills.length})\n${lines.join('\n')}`) + sections.push( + `## Skills (${data.skills.length})\n` + + 'To use a skill, call the load_user_skill tool with its name to load the full instructions, then follow them. The descriptions below only say when each skill applies — they are not the instructions.\n' + + lines.join('\n') + ) } if (data.jobs && data.jobs.length > 0) { @@ -267,10 +266,15 @@ export function buildWorkspaceMd(data: WorkspaceMdData): string { return sections.join('\n\n') } +export function buildWorkspaceContextMd(data: WorkspaceMdData): string { + return ['# Workspace Context', '', buildWorkspaceMd(data)].join('\n\n') +} + /** * Generate WORKSPACE.md content from actual database state. - * Auto-injected into the system prompt and served as a top-level VFS file. - * The LLM never writes it directly. + * Served as a top-level VFS file. The Go system prompt keeps only stable + * discovery rules; the LLM reads dynamic workspace state from VFS files. + * The LLM never writes this file directly. */ export async function generateWorkspaceContext( workspaceId: string, @@ -358,7 +362,7 @@ export async function generateWorkspaceContext( .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))), - listSkills({ workspaceId }), + listSkills({ workspaceId, includeBuiltins: false }), db .select({ @@ -452,7 +456,12 @@ export async function generateWorkspaceContext( size: f.size, folderPath: f.folderPath ?? null, })), - oauthIntegrations: credentials.map((c) => ({ providerId: c.providerId })), + oauthIntegrations: credentials.map((c) => ({ + id: c.id, + providerId: c.providerId, + displayName: c.displayName, + role: c.role, + })), envVariables: [], customTools: customTools.map((t) => ({ id: t.id, name: t.title })), mcpServers: mcpServerRows, diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index ff621d199ab..4dd1935ce0c 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -25,8 +25,6 @@ export const MOTHERSHIP_CHAT_API_PATH = '/api/mothership/chat' /** POST — confirm or reject a tool call. */ export const COPILOT_CONFIRM_API_PATH = '/api/copilot/confirm' -/** POST — forward diff-accepted/rejected stats to the copilot backend. */ -export const COPILOT_STATS_API_PATH = '/api/copilot/stats' /** Maximum entries in the in-memory SSE tool-event dedup cache. */ export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000 diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts new file mode 100644 index 00000000000..88e8344e560 --- /dev/null +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts @@ -0,0 +1,1434 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from copilot/contracts/mothership-stream-v1.schema.json +// + +export type JsonSchema = unknown + +export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { + $defs: { + MothershipStreamV1AdditionalPropertiesMap: { + additionalProperties: true, + type: 'object', + }, + MothershipStreamV1AsyncToolRecordStatus: { + enum: ['pending', 'running', 'completed', 'failed', 'cancelled', 'delivered'], + type: 'string', + }, + MothershipStreamV1CheckpointPauseEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CheckpointPausePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CheckpointPauseFrame: { + additionalProperties: false, + properties: { + parentToolCallId: { + type: 'string', + }, + parentToolName: { + type: 'string', + }, + pendingToolIds: { + items: { + type: 'string', + }, + type: 'array', + }, + }, + required: ['parentToolCallId', 'parentToolName', 'pendingToolIds'], + type: 'object', + }, + MothershipStreamV1CheckpointPausePayload: { + additionalProperties: false, + properties: { + checkpointId: { + type: 'string', + }, + executionId: { + type: 'string', + }, + frames: { + items: { + $ref: '#/$defs/MothershipStreamV1CheckpointPauseFrame', + }, + type: 'array', + }, + kind: { + enum: ['checkpoint_pause'], + type: 'string', + }, + pendingToolCallIds: { + items: { + type: 'string', + }, + type: 'array', + }, + runId: { + type: 'string', + }, + }, + required: ['kind', 'checkpointId', 'runId', 'executionId', 'pendingToolCallIds'], + type: 'object', + }, + MothershipStreamV1CompactionDoneData: { + additionalProperties: false, + properties: { + summary_chars: { + type: 'integer', + }, + }, + required: ['summary_chars'], + type: 'object', + }, + MothershipStreamV1CompactionDoneEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CompactionDonePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CompactionDonePayload: { + additionalProperties: false, + properties: { + data: { + $ref: '#/$defs/MothershipStreamV1CompactionDoneData', + }, + kind: { + enum: ['compaction_done'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1CompactionStartEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CompactionStartPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CompactionStartPayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['compaction_start'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1CompleteEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1CompletePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['complete'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1CompletePayload: { + additionalProperties: false, + properties: { + cost: { + $ref: '#/$defs/MothershipStreamV1CostData', + }, + reason: { + type: 'string', + }, + response: true, + status: { + $ref: '#/$defs/MothershipStreamV1CompletionStatus', + }, + usage: { + $ref: '#/$defs/MothershipStreamV1UsageData', + }, + }, + required: ['status'], + type: 'object', + }, + MothershipStreamV1CompletionStatus: { + enum: ['complete', 'error', 'cancelled'], + type: 'string', + }, + MothershipStreamV1CostData: { + additionalProperties: false, + properties: { + input: { + type: 'number', + }, + output: { + type: 'number', + }, + total: { + type: 'number', + }, + }, + type: 'object', + }, + MothershipStreamV1ErrorEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ErrorPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['error'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ErrorPayload: { + additionalProperties: false, + properties: { + code: { + type: 'string', + }, + data: true, + displayMessage: { + type: 'string', + }, + error: { + type: 'string', + }, + message: { + type: 'string', + }, + provider: { + type: 'string', + }, + }, + required: ['message'], + type: 'object', + }, + MothershipStreamV1EventEnvelopeCommon: { + additionalProperties: false, + properties: { + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream'], + type: 'object', + }, + MothershipStreamV1EventType: { + enum: ['session', 'text', 'tool', 'span', 'resource', 'run', 'error', 'complete'], + type: 'string', + }, + MothershipStreamV1ResourceDescriptor: { + additionalProperties: false, + properties: { + id: { + type: 'string', + }, + title: { + type: 'string', + }, + type: { + type: 'string', + }, + }, + required: ['type', 'id'], + type: 'object', + }, + MothershipStreamV1ResourceOp: { + enum: ['upsert', 'remove'], + type: 'string', + }, + MothershipStreamV1ResourceRemoveEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ResourceRemovePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['resource'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ResourceRemovePayload: { + additionalProperties: false, + properties: { + op: { + enum: ['remove'], + type: 'string', + }, + resource: { + $ref: '#/$defs/MothershipStreamV1ResourceDescriptor', + }, + }, + required: ['op', 'resource'], + type: 'object', + }, + MothershipStreamV1ResourceUpsertEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ResourceUpsertPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['resource'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ResourceUpsertPayload: { + additionalProperties: false, + properties: { + op: { + enum: ['upsert'], + type: 'string', + }, + resource: { + $ref: '#/$defs/MothershipStreamV1ResourceDescriptor', + }, + }, + required: ['op', 'resource'], + type: 'object', + }, + MothershipStreamV1ResumeRequest: { + additionalProperties: false, + properties: { + checkpointId: { + type: 'string', + }, + results: { + items: { + $ref: '#/$defs/MothershipStreamV1ResumeToolResult', + }, + type: 'array', + }, + streamId: { + type: 'string', + }, + userId: { + type: 'string', + }, + }, + required: ['streamId', 'checkpointId', 'userId', 'results'], + type: 'object', + }, + MothershipStreamV1ResumeToolResult: { + additionalProperties: false, + properties: { + error: { + type: 'string', + }, + output: true, + success: { + type: 'boolean', + }, + toolCallId: { + type: 'string', + }, + }, + required: ['toolCallId', 'success'], + type: 'object', + }, + MothershipStreamV1RunKind: { + enum: ['checkpoint_pause', 'resumed', 'compaction_start', 'compaction_done'], + type: 'string', + }, + MothershipStreamV1RunResumedEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1RunResumedPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['run'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1RunResumedPayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['resumed'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SessionChatEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionChatPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionChatPayload: { + additionalProperties: false, + properties: { + chatId: { + type: 'string', + }, + kind: { + enum: ['chat'], + type: 'string', + }, + }, + required: ['kind', 'chatId'], + type: 'object', + }, + MothershipStreamV1SessionKind: { + enum: ['trace', 'chat', 'title', 'start'], + type: 'string', + }, + MothershipStreamV1SessionStartData: { + additionalProperties: false, + properties: { + responseId: { + type: 'string', + }, + }, + type: 'object', + }, + MothershipStreamV1SessionStartEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionStartPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionStartPayload: { + additionalProperties: false, + properties: { + data: { + $ref: '#/$defs/MothershipStreamV1SessionStartData', + }, + kind: { + enum: ['start'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SessionTitleEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionTitlePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionTitlePayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['title'], + type: 'string', + }, + title: { + type: 'string', + }, + }, + required: ['kind', 'title'], + type: 'object', + }, + MothershipStreamV1SessionTraceEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SessionTracePayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['session'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SessionTracePayload: { + additionalProperties: false, + properties: { + kind: { + enum: ['trace'], + type: 'string', + }, + requestId: { + type: 'string', + }, + spanId: { + type: 'string', + }, + }, + required: ['kind', 'requestId'], + type: 'object', + }, + MothershipStreamV1SpanKind: { + enum: ['subagent'], + type: 'string', + }, + MothershipStreamV1SpanLifecycleEvent: { + enum: ['start', 'end'], + type: 'string', + }, + MothershipStreamV1SpanPayloadKind: { + enum: ['subagent', 'structured_result', 'subagent_result'], + type: 'string', + }, + MothershipStreamV1StreamCursor: { + additionalProperties: false, + properties: { + cursor: { + type: 'string', + }, + seq: { + type: 'integer', + }, + streamId: { + type: 'string', + }, + }, + required: ['streamId', 'cursor', 'seq'], + type: 'object', + }, + MothershipStreamV1StreamRef: { + additionalProperties: false, + properties: { + chatId: { + type: 'string', + }, + cursor: { + type: 'string', + }, + streamId: { + type: 'string', + }, + }, + required: ['streamId'], + type: 'object', + }, + MothershipStreamV1StreamScope: { + additionalProperties: false, + properties: { + agentId: { + type: 'string', + }, + lane: { + enum: ['subagent'], + type: 'string', + }, + parentSpanId: { + type: 'string', + }, + parentToolCallId: { + type: 'string', + }, + spanId: { + type: 'string', + }, + }, + required: ['lane'], + type: 'object', + }, + MothershipStreamV1StructuredResultSpanEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1StructuredResultSpanPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1StructuredResultSpanPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + kind: { + enum: ['structured_result'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SubagentResultSpanEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SubagentResultSpanPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SubagentResultSpanPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + kind: { + enum: ['subagent_result'], + type: 'string', + }, + }, + required: ['kind'], + type: 'object', + }, + MothershipStreamV1SubagentSpanEndEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SubagentSpanEndPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SubagentSpanEndPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + event: { + enum: ['end'], + type: 'string', + }, + kind: { + enum: ['subagent'], + type: 'string', + }, + }, + required: ['kind', 'event'], + type: 'object', + }, + MothershipStreamV1SubagentSpanStartEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1SubagentSpanStartPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['span'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1SubagentSpanStartPayload: { + additionalProperties: false, + properties: { + agent: { + type: 'string', + }, + data: true, + event: { + enum: ['start'], + type: 'string', + }, + kind: { + enum: ['subagent'], + type: 'string', + }, + }, + required: ['kind', 'event'], + type: 'object', + }, + MothershipStreamV1TextChannel: { + enum: ['assistant', 'thinking'], + type: 'string', + }, + MothershipStreamV1TextEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1TextPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['text'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1TextPayload: { + additionalProperties: false, + properties: { + channel: { + $ref: '#/$defs/MothershipStreamV1TextChannel', + }, + text: { + type: 'string', + }, + }, + required: ['channel', 'text'], + type: 'object', + }, + MothershipStreamV1ToolArgsDeltaEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ToolArgsDeltaPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['tool'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ToolArgsDeltaPayload: { + additionalProperties: false, + properties: { + argumentsDelta: { + type: 'string', + }, + executor: { + $ref: '#/$defs/MothershipStreamV1ToolExecutor', + }, + mode: { + $ref: '#/$defs/MothershipStreamV1ToolMode', + }, + phase: { + enum: ['args_delta'], + type: 'string', + }, + toolCallId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + }, + required: ['toolCallId', 'toolName', 'argumentsDelta', 'executor', 'mode', 'phase'], + type: 'object', + }, + MothershipStreamV1ToolCallDescriptor: { + additionalProperties: false, + properties: { + arguments: { + $ref: '#/$defs/MothershipStreamV1AdditionalPropertiesMap', + }, + executor: { + $ref: '#/$defs/MothershipStreamV1ToolExecutor', + }, + mode: { + $ref: '#/$defs/MothershipStreamV1ToolMode', + }, + partial: { + type: 'boolean', + }, + phase: { + enum: ['call'], + type: 'string', + }, + requiresConfirmation: { + type: 'boolean', + }, + status: { + $ref: '#/$defs/MothershipStreamV1ToolStatus', + }, + toolCallId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + ui: { + $ref: '#/$defs/MothershipStreamV1ToolUI', + }, + }, + required: ['toolCallId', 'toolName', 'executor', 'mode', 'phase'], + type: 'object', + }, + MothershipStreamV1ToolCallEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ToolCallDescriptor', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['tool'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ToolExecutor: { + enum: ['go', 'sim', 'client'], + type: 'string', + }, + MothershipStreamV1ToolMode: { + enum: ['sync', 'async'], + type: 'string', + }, + MothershipStreamV1ToolOutcome: { + enum: ['success', 'error', 'cancelled', 'skipped', 'rejected'], + type: 'string', + }, + MothershipStreamV1ToolPhase: { + enum: ['call', 'args_delta', 'result'], + type: 'string', + }, + MothershipStreamV1ToolResultEventEnvelope: { + additionalProperties: false, + properties: { + payload: { + $ref: '#/$defs/MothershipStreamV1ToolResultPayload', + }, + scope: { + $ref: '#/$defs/MothershipStreamV1StreamScope', + }, + seq: { + type: 'integer', + }, + stream: { + $ref: '#/$defs/MothershipStreamV1StreamRef', + }, + trace: { + $ref: '#/$defs/MothershipStreamV1Trace', + }, + ts: { + type: 'string', + }, + type: { + enum: ['tool'], + type: 'string', + }, + v: { + enum: [1], + type: 'integer', + }, + }, + required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], + type: 'object', + }, + MothershipStreamV1ToolResultPayload: { + additionalProperties: false, + properties: { + error: { + type: 'string', + }, + executor: { + $ref: '#/$defs/MothershipStreamV1ToolExecutor', + }, + mode: { + $ref: '#/$defs/MothershipStreamV1ToolMode', + }, + output: true, + phase: { + enum: ['result'], + type: 'string', + }, + status: { + $ref: '#/$defs/MothershipStreamV1ToolStatus', + }, + success: { + type: 'boolean', + }, + toolCallId: { + type: 'string', + }, + toolName: { + type: 'string', + }, + }, + required: ['toolCallId', 'toolName', 'executor', 'mode', 'phase', 'success'], + type: 'object', + }, + MothershipStreamV1ToolStatus: { + enum: ['generating', 'executing', 'success', 'error', 'cancelled', 'skipped', 'rejected'], + type: 'string', + }, + MothershipStreamV1ToolUI: { + additionalProperties: false, + properties: { + clientExecutable: { + type: 'boolean', + }, + hidden: { + type: 'boolean', + }, + icon: { + type: 'string', + }, + internal: { + type: 'boolean', + }, + phaseLabel: { + type: 'string', + }, + requiresConfirmation: { + type: 'boolean', + }, + title: { + type: 'string', + }, + }, + type: 'object', + }, + MothershipStreamV1Trace: { + additionalProperties: false, + properties: { + goTraceId: { + description: + 'OTel trace ID from the first Go ingress. May differ from requestId when Sim assigns the canonical request identity.', + type: 'string', + }, + requestId: { + type: 'string', + }, + spanId: { + type: 'string', + }, + }, + required: ['requestId'], + type: 'object', + }, + MothershipStreamV1UsageData: { + additionalProperties: false, + properties: { + cache_creation_input_tokens: { + type: 'integer', + }, + cache_read_input_tokens: { + type: 'integer', + }, + input_tokens: { + type: 'integer', + }, + model: { + type: 'string', + }, + output_tokens: { + type: 'integer', + }, + total_tokens: { + type: 'integer', + }, + }, + type: 'object', + }, + }, + $id: 'mothership-stream-v1.schema.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + description: 'Shared execution-oriented mothership stream contract from Go to Sim.', + oneOf: [ + { + $ref: '#/$defs/MothershipStreamV1SessionStartEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SessionChatEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SessionTitleEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SessionTraceEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1TextEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ToolCallEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ToolArgsDeltaEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ToolResultEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SubagentSpanStartEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SubagentSpanEndEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1StructuredResultSpanEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1SubagentResultSpanEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ResourceUpsertEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ResourceRemoveEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CheckpointPauseEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1RunResumedEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CompactionStartEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CompactionDoneEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1ErrorEventEnvelope', + }, + { + $ref: '#/$defs/MothershipStreamV1CompleteEventEnvelope', + }, + ], + title: 'MothershipStreamV1EventEnvelope', +} diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts index ef7f2e065fb..d7194f13757 100644 --- a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -58,7 +58,9 @@ export interface MothershipStreamV1SessionStartData { export interface MothershipStreamV1StreamScope { agentId?: string lane: 'subagent' + parentSpanId?: string parentToolCallId?: string + spanId?: string } export interface MothershipStreamV1StreamRef { chatId?: string diff --git a/apps/sim/lib/copilot/generated/request-trace-v1.ts b/apps/sim/lib/copilot/generated/request-trace-v1.ts index 8b9e3870f11..80ebb12dadc 100644 --- a/apps/sim/lib/copilot/generated/request-trace-v1.ts +++ b/apps/sim/lib/copilot/generated/request-trace-v1.ts @@ -71,7 +71,11 @@ export interface MothershipStreamV1AdditionalPropertiesMap { * via the `definition` "RequestTraceV1UsageSummary". */ export interface RequestTraceV1UsageSummary { + cacheAttemptedRequests?: number + cacheHitRequests?: number cacheReadTokens?: number + cacheSavingsRate?: number + cacheWriteRequests?: number cacheWriteTokens?: number inputTokens?: number outputTokens?: number @@ -80,7 +84,7 @@ export interface RequestTraceV1UsageSummary { * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema * via the `definition` "RequestTraceV1MergedTrace". */ -interface RequestTraceV1MergedTrace { +export interface RequestTraceV1MergedTrace { chatId?: string cost?: RequestTraceV1CostSummary durationMs: number @@ -99,7 +103,7 @@ interface RequestTraceV1MergedTrace { * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema * via the `definition` "RequestTraceV1SimReport". */ -interface RequestTraceV1SimReport1 { +export interface RequestTraceV1SimReport1 { chatId?: string cost?: RequestTraceV1CostSummary durationMs: number diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 520481878fb..b3132e7a11b 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3,6 +3,7 @@ // export interface ToolCatalogEntry { + capabilities?: unknown clientExecutable?: boolean hidden?: boolean id: @@ -10,15 +11,12 @@ export interface ToolCatalogEntry { | 'auth' | 'check_deployment_status' | 'complete_job' - | 'context_write' | 'crawl_website' | 'create_file' | 'create_file_folder' | 'create_folder' - | 'create_job' | 'create_workflow' | 'create_workspace_mcp_server' - | 'debug' | 'delete_file' | 'delete_file_folder' | 'delete_folder' @@ -28,24 +26,26 @@ export interface ToolCatalogEntry { | 'deploy_api' | 'deploy_chat' | 'deploy_mcp' + | 'diff_workflows' | 'download_to_workspace_file' | 'edit_content' | 'edit_workflow' + | 'ffmpeg' | 'file' | 'function_execute' | 'generate_api_key' + | 'generate_audio' | 'generate_image' - | 'generate_visualization' + | 'generate_video' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' - | 'get_deployment_version' - | 'get_execution_summary' + | 'get_deployment_log' | 'get_job_logs' | 'get_page_contents' | 'get_platform_actions' | 'get_workflow_data' - | 'get_workflow_logs' + | 'get_workflow_run_options' | 'glob' | 'grep' | 'job' @@ -53,14 +53,18 @@ export interface ToolCatalogEntry { | 'knowledge_base' | 'list_file_folders' | 'list_folders' + | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' + | 'load_deployment' + | 'load_integration_tool' | 'manage_credential' | 'manage_custom_tool' | 'manage_job' | 'manage_mcp_tool' | 'manage_skill' | 'materialize_file' + | 'media' | 'move_file' | 'move_file_folder' | 'move_folder' @@ -68,6 +72,8 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'promote_to_live' + | 'query_logs' | 'read' | 'redeploy' | 'rename_file' @@ -76,7 +82,6 @@ export interface ToolCatalogEntry { | 'research' | 'respond' | 'restore_resource' - | 'revert_to_version' | 'run' | 'run_block' | 'run_from_block' @@ -92,7 +97,8 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'superagent' | 'table' - | 'tool_search_tool_regex' + | 'touch_plan' + | 'update_deployment_version' | 'update_job_history' | 'update_workspace_mcp_server' | 'user_memory' @@ -106,15 +112,12 @@ export interface ToolCatalogEntry { | 'auth' | 'check_deployment_status' | 'complete_job' - | 'context_write' | 'crawl_website' | 'create_file' | 'create_file_folder' | 'create_folder' - | 'create_job' | 'create_workflow' | 'create_workspace_mcp_server' - | 'debug' | 'delete_file' | 'delete_file_folder' | 'delete_folder' @@ -124,24 +127,26 @@ export interface ToolCatalogEntry { | 'deploy_api' | 'deploy_chat' | 'deploy_mcp' + | 'diff_workflows' | 'download_to_workspace_file' | 'edit_content' | 'edit_workflow' + | 'ffmpeg' | 'file' | 'function_execute' | 'generate_api_key' + | 'generate_audio' | 'generate_image' - | 'generate_visualization' + | 'generate_video' | 'get_block_outputs' | 'get_block_upstream_references' | 'get_deployed_workflow_state' - | 'get_deployment_version' - | 'get_execution_summary' + | 'get_deployment_log' | 'get_job_logs' | 'get_page_contents' | 'get_platform_actions' | 'get_workflow_data' - | 'get_workflow_logs' + | 'get_workflow_run_options' | 'glob' | 'grep' | 'job' @@ -149,14 +154,18 @@ export interface ToolCatalogEntry { | 'knowledge_base' | 'list_file_folders' | 'list_folders' + | 'list_integration_tools' | 'list_user_workspaces' | 'list_workspace_mcp_servers' + | 'load_deployment' + | 'load_integration_tool' | 'manage_credential' | 'manage_custom_tool' | 'manage_job' | 'manage_mcp_tool' | 'manage_skill' | 'materialize_file' + | 'media' | 'move_file' | 'move_file_folder' | 'move_folder' @@ -164,6 +173,8 @@ export interface ToolCatalogEntry { | 'oauth_get_auth_link' | 'oauth_request_access' | 'open_resource' + | 'promote_to_live' + | 'query_logs' | 'read' | 'redeploy' | 'rename_file' @@ -172,7 +183,6 @@ export interface ToolCatalogEntry { | 'research' | 'respond' | 'restore_resource' - | 'revert_to_version' | 'run' | 'run_block' | 'run_from_block' @@ -188,7 +198,8 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'superagent' | 'table' - | 'tool_search_tool_regex' + | 'touch_plan' + | 'update_deployment_version' | 'update_job_history' | 'update_workspace_mcp_server' | 'user_memory' @@ -203,11 +214,11 @@ export interface ToolCatalogEntry { subagentId?: | 'agent' | 'auth' - | 'debug' | 'deploy' | 'file' | 'job' | 'knowledge' + | 'media' | 'research' | 'run' | 'superagent' @@ -278,24 +289,6 @@ export const CompleteJob: ToolCatalogEntry = { }, } -export const ContextWrite: ToolCatalogEntry = { - id: 'context_write', - name: 'context_write', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: 'Full content to write to the file (replaces existing content)', - }, - file_path: { type: 'string', description: "Path of the file to write (e.g. 'SESSION.md')" }, - }, - required: ['file_path', 'content'], - }, -} - export const CrawlWebsite: ToolCatalogEntry = { id: 'crawl_website', name: 'crawl_website', @@ -338,21 +331,55 @@ export const CreateFile: ToolCatalogEntry = { fileName: { type: 'string', description: - 'Workspace filename or slash-separated file path including extension, e.g. "main.py", "report.md", or "Reports/2026/report.md".', + 'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, }, - required: ['fileName'], }, resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Contains id (the fileId) and name.' }, + data: { + type: 'object', + description: + 'Contains id (internal file ID), name, and vfsPath. Use vfsPath for follow-up file tools.', + }, message: { type: 'string', description: 'Human-readable outcome.' }, success: { type: 'boolean', description: 'Whether the file was created.' }, }, required: ['success', 'message'], }, requiredPermission: 'write', + capabilities: ['file_output'], } export const CreateFileFolder: ToolCatalogEntry = { @@ -363,14 +390,17 @@ export const CreateFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - name: { type: 'string', description: 'Folder name.' }, - parentId: { type: 'string', description: 'Optional parent file-folder ID.' }, + path: { + type: 'string', + description: + 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', + }, workspaceId: { type: 'string', description: 'Optional workspace ID. Defaults to the current workspace.', }, }, - required: ['name'], + required: ['path'], }, requiredPermission: 'write', } @@ -392,60 +422,6 @@ export const CreateFolder: ToolCatalogEntry = { requiredPermission: 'write', } -export const CreateJob: ToolCatalogEntry = { - id: 'create_job', - name: 'create_job', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - cron: { - type: 'string', - description: - "Cron expression for recurring jobs (e.g., '*/5 * * * *' for every 5 minutes, '0 9 * * *' for daily at 9 AM). Omit for one-time jobs.", - }, - lifecycle: { - type: 'string', - description: - "'persistent' (default) or 'until_complete'. Until_complete jobs stop when complete_job is called after the success condition is met.", - enum: ['persistent', 'until_complete'], - }, - maxRuns: { - type: 'integer', - description: - 'Maximum number of executions before the job auto-completes. Safety limit to prevent runaway polling.', - }, - prompt: { - type: 'string', - description: - 'The prompt to execute when the job fires. This is sent to the Mothership as a user message.', - }, - successCondition: { - type: 'string', - description: - "What must happen for the job to be considered complete. Used with until_complete lifecycle (e.g., 'John has replied to the partnership email').", - }, - time: { - type: 'string', - description: - "ISO 8601 datetime for one-time execution or as the start time for a cron schedule (e.g., '2026-03-06T09:00:00'). Include timezone offset or use the timezone parameter.", - }, - timezone: { - type: 'string', - description: - "IANA timezone for the schedule (e.g., 'America/New_York', 'Europe/London'). Defaults to UTC.", - }, - title: { - type: 'string', - description: - "A short, descriptive title for the job (e.g., 'Email Poller', 'Daily Report'). Used as the display name.", - }, - }, - required: ['title', 'prompt'], - }, -} - export const CreateWorkflow: ToolCatalogEntry = { id: 'create_workflow', name: 'create_workflow', @@ -495,31 +471,6 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const Debug: ToolCatalogEntry = { - id: 'debug', - name: 'debug', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - context: { - description: - 'Pre-gathered context: workflow state JSON, block schemas, error logs. The debug agent will skip re-reading anything included here.', - type: 'string', - }, - request: { - description: - 'What to debug. Include error messages, block IDs, and any context about the failure.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - subagentId: 'debug', - internal: true, -} - export const DeleteFile: ToolCatalogEntry = { id: 'delete_file', name: 'delete_file', @@ -528,13 +479,14 @@ export const DeleteFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileIds: { + paths: { type: 'array', - description: 'Canonical workspace file IDs of the files to delete.', + description: + 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', items: { type: 'string' }, }, }, - required: ['fileIds'], + required: ['paths'], }, resultSchema: { type: 'object', @@ -555,13 +507,13 @@ export const DeleteFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - folderIds: { + paths: { type: 'array', - description: 'The workspace file-folder IDs to delete.', + description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', items: { type: 'string' }, }, }, - required: ['folderIds'], + required: ['paths'], }, requiresConfirmation: true, requiredPermission: 'write', @@ -632,7 +584,7 @@ export const Deploy: ToolCatalogEntry = { properties: { request: { description: - 'Detailed deployment instructions. Include deployment type (api/chat) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', + 'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', type: 'string', }, }, @@ -657,6 +609,16 @@ export const DeployApi: ToolCatalogEntry = { enum: ['deploy', 'undeploy'], default: 'deploy', }, + versionDescription: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a concise (1-3 sentence) description of what changed in this deployment version, e.g. "Adds Slack failure alert and retries on the HTTP block". If unsure what changed, call diff_workflows(ref1: "live", ref2: "draft") first. Ignored for undeploy.', + }, + versionName: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a short human-readable name/label for this deployment version (shown in the deployment history), e.g. "v2 pricing" or "Add Slack alerts". Ignored for undeploy.', + }, workflowId: { type: 'string', description: 'Workflow ID to deploy (required in workspace context)', @@ -739,7 +701,10 @@ export const DeployChat: ToolCatalogEntry = { enum: ['public', 'password', 'email', 'sso'], default: 'public', }, - description: { type: 'string', description: 'Optional description for the chat' }, + description: { + type: 'string', + description: 'Optional chat-facing description shown on the chat page', + }, identifier: { type: 'string', description: 'URL slug for the chat (lowercase letters, numbers, hyphens only)', @@ -753,7 +718,8 @@ export const DeployChat: ToolCatalogEntry = { blockId: { type: 'string', description: 'The block UUID' }, path: { type: 'string', - description: "The output path (e.g. 'response', 'response.content')", + description: + 'The output path (e.g. `content` for an agent; structured fields are top-level paths). Call get_block_outputs for real paths.', }, }, required: ['blockId', 'path'], @@ -761,6 +727,16 @@ export const DeployChat: ToolCatalogEntry = { }, password: { type: 'string', description: 'Password for password-protected chats' }, title: { type: 'string', description: 'Display title for the chat interface' }, + versionDescription: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a concise (1-3 sentence) description of what changed in this deployment version (distinct from the chat-facing description). If unsure what changed, call diff_workflows(ref1: "live", ref2: "draft") first. Ignored for undeploy.', + }, + versionName: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a short human-readable name/label for this deployment version (distinct from the chat title; shown in deployment history). Ignored for undeploy.', + }, welcomeMessage: { type: 'string', description: 'Welcome message shown to users' }, workflowId: { type: 'string', @@ -939,6 +915,33 @@ export const DeployMcp: ToolCatalogEntry = { requiredPermission: 'admin', } +export const DiffWorkflows: ToolCatalogEntry = { + id: 'diff_workflows', + name: 'diff_workflows', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + ref1: { + type: 'string', + description: + 'Base side (string): a version number (e.g. "3"), "live" (active deployment), or "draft" (current editor state).', + }, + ref2: { + type: 'string', + description: + 'Target side (string): a version number (e.g. "4"), "live" (active deployment), or "draft" (current editor state).', + }, + workflowId: { + type: 'string', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['ref1', 'ref2'], + }, +} + export const DownloadToWorkspaceFile: ToolCatalogEntry = { id: 'download_to_workspace_file', name: 'download_to_workspace_file', @@ -950,7 +953,37 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { fileName: { type: 'string', description: - 'Optional workspace file name to save as. If omitted, the name is inferred from the response or URL.', + 'Backward-compatible workspace file name. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, url: { type: 'string', @@ -961,6 +994,7 @@ export const DownloadToWorkspaceFile: ToolCatalogEntry = { required: ['url'], }, requiredPermission: 'write', + capabilities: ['file_output'], } export const EditContent: ToolCatalogEntry = { @@ -1022,10 +1056,10 @@ export const EditWorkflow: ToolCatalogEntry = { params: { type: 'object', description: - 'Parameters for the operation. \nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": "claude-sonnet-4-6"}}\nFor delete: {} (empty object)', + 'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": ""}}\nFor delete: omit params entirely (none needed)', }, }, - required: ['operation_type', 'block_id', 'params'], + required: ['operation_type', 'block_id'], }, }, workflowId: { @@ -1039,79 +1073,323 @@ export const EditWorkflow: ToolCatalogEntry = { requiredPermission: 'write', } -export const File: ToolCatalogEntry = { - id: 'file', - name: 'file', - route: 'subagent', - mode: 'async', - parameters: { type: 'object' }, - subagentId: 'file', - internal: true, -} - -export const FunctionExecute: ToolCatalogEntry = { - id: 'function_execute', - name: 'function_execute', +export const Ffmpeg: ToolCatalogEntry = { + id: 'ffmpeg', + name: 'ffmpeg', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - code: { + aspectRatio: { type: 'string', - description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + description: 'Target aspect ratio for scale_pad, e.g. 9:16, 16:9, 1:1.', }, - inputFiles: { - type: 'array', - description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}. Example: ["wf_123"]', - items: { type: 'string' }, + end: { type: 'number', description: 'End time in seconds (trim).' }, + format: { + type: 'string', + description: 'Target format/extension for convert (e.g. mp4, mp3, wav, gif).', }, - inputTables: { - type: 'array', + height: { type: 'number', description: 'Target height in pixels (scale_pad).' }, + inputs: { + type: 'object', description: - 'Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Example: ["tbl_abc123"]', - items: { type: 'string' }, + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, }, - language: { - type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], + loopToVideo: { + type: 'boolean', + description: 'For overlay_audio, loop or trim the audio to match the video length.', }, - outputFormat: { - type: 'string', - description: - 'Format for outputPath. Determines how the code result is serialized. If omitted, inferred from outputPath file extension.', - enum: ['json', 'csv', 'txt', 'md', 'html'], + musicVolume: { + type: 'number', + description: 'Volume multiplier for the background music track in mix_audio (e.g. 0.3).', }, - outputMimeType: { + operation: { type: 'string', - description: - 'MIME type for outputSandboxPath export. Required for binary files: image/png, image/jpeg, application/pdf, etc. Omit for text files.', + description: 'The FFmpeg operation to run.', + enum: [ + 'overlay_audio', + 'mix_audio', + 'concat', + 'trim', + 'scale_pad', + 'overlay_image', + 'add_text', + 'fade', + 'extract_audio', + 'convert', + 'thumbnail', + 'probe', + ], }, - outputPath: { - type: 'string', + outputs: { + type: 'object', description: - 'Pipe output directly to a NEW workspace file instead of returning in context. ALWAYS use this instead of a separate workspace_file write call. Use a root path like "files/result.json" — nested output paths are not supported.', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - outputSandboxPath: { + position: { type: 'string', - description: - 'Path to a file created inside the sandbox that should be exported to the workspace. Use together with outputPath.', + description: 'Placement for add_text / overlay_image.', + enum: ['top', 'center', 'bottom', 'top-left', 'top-right', 'bottom-left', 'bottom-right'], }, - outputTable: { - type: 'string', - description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + start: { type: 'number', description: 'Start time in seconds (trim, thumbnail, fade).' }, + text: { type: 'string', description: 'Text to burn in for add_text.' }, + volume: { + type: 'number', + description: 'Volume multiplier for the primary track (mix_audio / overlay_audio).', }, + width: { type: 'number', description: 'Target width in pixels (scale_pad).' }, }, - required: ['code'], + required: ['operation', 'inputs'], }, requiredPermission: 'write', + capabilities: ['file_input', 'file_output'], } -export const GenerateApiKey: ToolCatalogEntry = { +export const File: ToolCatalogEntry = { + id: 'file', + name: 'file', + route: 'subagent', + mode: 'async', + parameters: { type: 'object' }, + subagentId: 'file', + internal: true, +} + +export const FunctionExecute: ToolCatalogEntry = { + id: 'function_execute', + name: 'function_execute', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + outputTable: { + type: 'string', + description: + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', + }, + }, + required: ['code'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], +} + +export const GenerateApiKey: ToolCatalogEntry = { id: 'generate_api_key', name: 'generate_api_key', route: 'sim', @@ -1134,6 +1412,156 @@ export const GenerateApiKey: ToolCatalogEntry = { requiredPermission: 'admin', } +export const GenerateAudio: ToolCatalogEntry = { + id: 'generate_audio', + name: 'generate_audio', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + duration: { + type: 'number', + description: + 'Approximate duration in seconds for sfx (and music models that support it). MiniMax music ignores this — fit music to a video with the ffmpeg tool instead.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + instrumental: { + type: 'boolean', + description: + 'For music: true = instrumental, no vocals (default); false = a song with vocals.', + }, + lyrics: { + type: 'string', + description: + 'For music with vocals: the lyrics to sing (optional; supports [Verse]/[Chorus] tags). Setting this implies instrumental=false.', + }, + model: { + type: 'string', + description: + 'Optional model override for the selected type (e.g. fal-ai/elevenlabs/tts/eleven-v3 for speech).', + }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, + }, + prompt: { + type: 'string', + description: + 'For speech: the text to speak (may include expressive tags). For music/sfx: a description of the audio to generate.', + }, + type: { + type: 'string', + description: 'Kind of audio to generate. Defaults to speech.', + enum: ['speech', 'music', 'sfx'], + }, + voice: { type: 'string', description: 'Optional voice name or id for speech.' }, + }, + required: ['prompt'], + }, + requiredPermission: 'write', + capabilities: ['file_input', 'file_output', 'generated_media'], +} + export const GenerateImage: ToolCatalogEntry = { id: 'generate_image', name: 'generate_image', @@ -1147,72 +1575,288 @@ export const GenerateImage: ToolCatalogEntry = { description: 'Aspect ratio for the generated image.', enum: ['1:1', '16:9', '9:16', '4:3', '3:4'], }, - fileName: { - type: 'string', + inputs: { + type: 'object', description: - 'Output file name. Defaults to "generated-image.png". New generated images currently create root workspace files, so pass a plain file name, not a nested path.', + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, }, - overwriteFileId: { - type: 'string', + outputs: { + type: 'object', description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated image so the existing chat resource stays current instead of creating a duplicate like "image (1).png". The file ID is returned by previous generate_image or generate_visualization calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, prompt: { type: 'string', description: - 'Detailed text description of the image to generate, or editing instructions when used with editFileId.', - }, - referenceFileIds: { - type: 'array', - description: - 'File IDs of workspace images to include as context for the generation. All images are sent alongside the prompt. Use for: editing a single image (1 file), compositing multiple images together (2+ files), style transfer, face swapping, etc. Order matters — list the primary/base image first. When revising an existing image in place, pair the primary file ID here with overwriteFileId set to that same ID.', - items: { type: 'string' }, + 'Detailed text description of the image to generate, or editing instructions when editing the image(s) passed in `inputs.files`.', }, }, required: ['prompt'], }, requiredPermission: 'write', + capabilities: ['file_input', 'file_output', 'generated_media'], } -export const GenerateVisualization: ToolCatalogEntry = { - id: 'generate_visualization', - name: 'generate_visualization', +export const GenerateVideo: ToolCatalogEntry = { + id: 'generate_video', + name: 'generate_video', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - code: { + aspectRatio: { type: 'string', + description: 'Aspect ratio for the video (model-dependent).', + enum: ['16:9', '9:16', '1:1'], + }, + duration: { + type: 'number', + description: 'Clip duration in seconds (model-dependent; e.g. 4, 6, 8).', + }, + generateAudio: { + type: 'boolean', description: - "Python code that generates a visualization using matplotlib. MUST call plt.savefig('/home/user/output.png', dpi=150, bbox_inches='tight') to produce output.", + "Toggle Veo's native audio (dialogue/SFX/ambience/music generated from the prompt). Default true. Set false when you will add your own voiceover/music via the ffmpeg tool.", }, - fileName: { + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { type: 'string', description: 'Canonical VFS table path when available.' }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { type: 'string', description: 'Workspace table ID.' }, + }, + }, + }, + }, + }, + model: { type: 'string', description: - 'Output file name. Defaults to "chart.png". New visualization outputs currently create root workspace files, so pass a plain file name, not a nested path.', + "Optional model override, keyed to the video's goal: veo-3.1-lite (prototype/quick test, cheapest), veo-3.1-fast (reasonable draft — default, good video), veo-3.1 Standard (final cut / premium quality). Stay on Veo unless the user explicitly asks for another model; seedance-2.0 for >8s narrative, kling-v3-pro for specific looks.", + enum: [ + 'veo-3.1', + 'veo-3.1-fast', + 'veo-3.1-lite', + 'seedance-2.0', + 'seedance-2.0-fast', + 'kling-v3-pro', + 'minimax-hailuo-2.3-pro', + 'wan-2.2-a14b-turbo', + 'ltx-2.3', + ], }, - inputFiles: { - type: 'array', + negativePrompt: { + type: 'string', description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}.', - items: { type: 'string' }, + 'Things to exclude from the video/audio (Veo models), e.g. "no background music" to keep dialogue but drop Veo\'s invented music before overlaying your own track.', }, - inputTables: { - type: 'array', + outputs: { + type: 'object', description: - "Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Read with pandas: pd.read_csv('/home/user/tables/tbl_xxx.csv')", - items: { type: 'string' }, + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - overwriteFileId: { + prompt: { type: 'string', description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated chart so the existing chat resource stays current instead of creating a duplicate like "chart (1).png". The file ID is returned by previous generate_visualization or generate_image calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', + 'Detailed description of the video to generate (scene, action, camera movement, style).', + }, + promptOptimizer: { + type: 'boolean', + description: 'Enable prompt optimization for MiniMax models (default true).', + }, + resolution: { + type: 'string', + description: 'Video resolution (model-dependent), e.g. 720p or 1080p.', + enum: ['720p', '1080p', '4k'], }, }, - required: ['code'], + required: ['prompt'], }, requiredPermission: 'write', + capabilities: ['file_input', 'file_output', 'generated_media'], } export const GetBlockOutputs: ToolCatalogEntry = { @@ -1276,46 +1920,19 @@ export const GetDeployedWorkflowState: ToolCatalogEntry = { }, } -export const GetDeploymentVersion: ToolCatalogEntry = { - id: 'get_deployment_version', - name: 'get_deployment_version', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - version: { type: 'number', description: 'The deployment version number' }, - workflowId: { type: 'string', description: 'The workflow ID' }, - }, - required: ['workflowId', 'version'], - }, -} - -export const GetExecutionSummary: ToolCatalogEntry = { - id: 'get_execution_summary', - name: 'get_execution_summary', +export const GetDeploymentLog: ToolCatalogEntry = { + id: 'get_deployment_log', + name: 'get_deployment_log', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - limit: { - type: 'number', - description: 'Max number of executions to return (default: 10, max: 20).', - }, - status: { - type: 'string', - description: "Filter by status: 'success', 'error', or 'all' (default: 'all').", - enum: ['success', 'error', 'all'], - }, workflowId: { type: 'string', - description: - 'Optional workflow ID. If omitted, returns executions across all workflows in the workspace.', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', }, - workspaceId: { type: 'string', description: 'Workspace ID to scope executions to.' }, }, - required: ['workspaceId'], }, } @@ -1396,21 +2013,14 @@ export const GetWorkflowData: ToolCatalogEntry = { }, } -export const GetWorkflowLogs: ToolCatalogEntry = { - id: 'get_workflow_logs', - name: 'get_workflow_logs', +export const GetWorkflowRunOptions: ToolCatalogEntry = { + id: 'get_workflow_run_options', + name: 'get_workflow_run_options', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - executionId: { - type: 'string', - description: - 'Optional execution ID to get logs for a specific execution. Use with get_execution_summary to find execution IDs first.', - }, - includeDetails: { type: 'boolean', description: 'Include detailed info' }, - limit: { type: 'number', description: 'Max number of entries (hard limit: 3)' }, workflowId: { type: 'string', description: 'Optional workflow ID. If not provided, uses the current workflow in context.', @@ -1474,9 +2084,13 @@ export const Grep: ToolCatalogEntry = { path: { type: 'string', description: - "Optional path prefix to scope the search (e.g. 'workflows/', 'environment/', 'internal/', 'components/blocks/').", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + }, + pattern: { + type: 'string', + description: + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, - pattern: { type: 'string', description: 'Regex pattern to search for in file contents.' }, toolTitle: { type: 'string', description: @@ -1594,10 +2208,10 @@ export const KnowledgeBase: ToolCatalogEntry = { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', }, - fileIds: { + filePaths: { type: 'array', description: - 'Canonical workspace file IDs to add as documents (for add_file). Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', items: { type: 'string' }, }, filename: { @@ -1627,7 +2241,7 @@ export const KnowledgeBase: ToolCatalogEntry = { syncIntervalMinutes: { type: 'number', description: - 'Sync interval in minutes: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', + 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', default: 1440, }, tagDefinitionId: { @@ -1652,7 +2266,8 @@ export const KnowledgeBase: ToolCatalogEntry = { }, workspaceId: { type: 'string', - description: "Workspace ID (required for 'create', optional filter for 'list')", + description: + "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", }, }, }, @@ -1680,7 +2295,7 @@ export const KnowledgeBase: ToolCatalogEntry = { ], }, }, - required: ['operation', 'args'], + required: ['operation'], }, resultSchema: { type: 'object', @@ -1724,28 +2339,89 @@ export const ListFolders: ToolCatalogEntry = { }, } -export const ListUserWorkspaces: ToolCatalogEntry = { - id: 'list_user_workspaces', - name: 'list_user_workspaces', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, -} - -export const ListWorkspaceMcpServers: ToolCatalogEntry = { - id: 'list_workspace_mcp_servers', - name: 'list_workspace_mcp_servers', +export const ListIntegrationTools: ToolCatalogEntry = { + id: 'list_integration_tools', + name: 'list_integration_tools', + route: 'sim', + mode: 'async', + parameters: { + properties: { + integration: { + description: + 'The integration service name — the folder under components/integrations/ (e.g. "slack", "gmail", "google_sheets"). Returns every operation\'s id, name, and description for that service.', + type: 'string', + }, + }, + required: ['integration'], + type: 'object', + }, +} + +export const ListUserWorkspaces: ToolCatalogEntry = { + id: 'list_user_workspaces', + name: 'list_user_workspaces', + route: 'sim', + mode: 'async', + parameters: { type: 'object', properties: {} }, +} + +export const ListWorkspaceMcpServers: ToolCatalogEntry = { + id: 'list_workspace_mcp_servers', + name: 'list_workspace_mcp_servers', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + workspaceId: { + type: 'string', + description: + 'Workspace ID. Required when no current workspace context is available, such as headless MCP calls.', + }, + }, + }, +} + +export const LoadDeployment: ToolCatalogEntry = { + id: 'load_deployment', + name: 'load_deployment', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + version: { + type: 'string', + description: + 'A string: a deployment version number (e.g. "5"), or "live" for the active deployment. (Unlike promote_to_live, which takes a numeric version, "live" is accepted here.)', + }, + workflowId: { + type: 'string', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const LoadIntegrationTool: ToolCatalogEntry = { + id: 'load_integration_tool', + name: 'load_integration_tool', route: 'sim', mode: 'async', parameters: { - type: 'object', properties: { - workspaceId: { - type: 'string', + tool_ids: { description: - 'Workspace ID. Required when no current workspace context is available, such as headless MCP calls.', + 'Exact integration tool ids to load before calling them, e.g. ["gmail_send_v2"]. Copy the "id" field verbatim from components/integrations/{service}/{operation}.json (including any version suffix).', + items: { type: 'string' }, + type: 'array', }, }, + required: ['tool_ids'], + type: 'object', }, } @@ -1791,7 +2467,8 @@ export const ManageCustomTool: ToolCatalogEntry = { }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'", + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -1831,7 +2508,7 @@ export const ManageCustomTool: ToolCatalogEntry = { toolId: { type: 'string', description: - "The ID of the custom tool (required for edit). Must be the exact toolId from the get_workflow_data custom tool response - do not guess or construct it. DO NOT PROVIDE THE TOOL ID IF THE OPERATION IS 'ADD'.", + "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", }, toolIds: { type: 'array', @@ -1842,6 +2519,7 @@ export const ManageCustomTool: ToolCatalogEntry = { required: ['operation'], }, requiresConfirmation: true, + requiredPermission: 'write', } export const ManageJob: ToolCatalogEntry = { @@ -1857,7 +2535,11 @@ export const ManageJob: ToolCatalogEntry = { description: 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', properties: { - cron: { type: 'string', description: 'Cron expression for recurring jobs' }, + cron: { + type: 'string', + description: + "Cron expression for a recurring job (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + }, jobId: { type: 'string', description: 'Job ID (required for get, update)' }, jobIds: { type: 'array', @@ -1868,13 +2550,18 @@ export const ManageJob: ToolCatalogEntry = { type: 'string', description: "'persistent' (default) or 'until_complete'. Until_complete jobs stop when complete_job is called.", + enum: ['persistent', 'until_complete'], }, maxRuns: { type: 'integer', description: 'Max executions before auto-completing. Safety limit.', }, prompt: { type: 'string', description: 'The prompt to execute when the job fires' }, - status: { type: 'string', description: 'Job status: active, paused' }, + status: { + type: 'string', + description: 'Job status: active, paused', + enum: ['active', 'paused'], + }, successCondition: { type: 'string', description: @@ -1882,7 +2569,8 @@ export const ManageJob: ToolCatalogEntry = { }, time: { type: 'string', - description: 'ISO 8601 datetime for one-time jobs or cron start time', + description: + "ISO 8601 datetime. One-time job -> set time and omit cron. May also anchor a recurring cron job's first-fire time.", }, timezone: { type: 'string', @@ -1896,7 +2584,8 @@ export const ManageJob: ToolCatalogEntry = { }, operation: { type: 'string', - description: 'The operation to perform: create, list, get, update, delete', + description: + 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', enum: ['create', 'list', 'get', 'update', 'delete'], }, }, @@ -1940,13 +2629,14 @@ export const ManageMcpTool: ToolCatalogEntry = { }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'", + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { type: 'string', description: - "Required for edit and delete. The database ID of the MCP server. DO NOT PROVIDE if operation is 'add' or 'list'.", + "The MCP server's id — the `id` field inside the VFS file agent/mcp-servers/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", }, }, required: ['operation'], @@ -1978,13 +2668,14 @@ export const ManageSkill: ToolCatalogEntry = { }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'", + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { type: 'string', description: - "The ID of the skill (required for edit/delete). Must be the exact ID from the VFS or list. DO NOT PROVIDE if operation is 'add' or 'list'.", + "The skill's id — the `id` field inside the VFS file agent/skills/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", }, }, required: ['operation'], @@ -2007,29 +2698,29 @@ export const MaterializeFile: ToolCatalogEntry = { 'The names of the uploaded files to materialize (e.g. ["report.pdf", "data.csv"])', items: { type: 'string' }, }, - knowledgeBaseId: { - type: 'string', - description: - 'ID of an existing knowledge base to add the file to (only used with operation "knowledge_base"). If omitted, a new KB is created.', - }, operation: { type: 'string', description: - 'What to do with the file. "save" promotes it to files/. "import" imports a workflow JSON. "table" converts CSV/TSV/JSON to a table. "knowledge_base" saves and adds to a KB. Defaults to "save".', - enum: ['save', 'import', 'table', 'knowledge_base'], + 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. Defaults to "save".', + enum: ['save', 'import'], default: 'save', }, - tableName: { - type: 'string', - description: - 'Custom name for the table (only used with operation "table"). Defaults to the file name without extension.', - }, }, required: ['fileNames'], }, requiredPermission: 'write', } +export const Media: ToolCatalogEntry = { + id: 'media', + name: 'media', + route: 'subagent', + mode: 'async', + parameters: { type: 'object' }, + subagentId: 'media', + internal: true, +} + export const MoveFile: ToolCatalogEntry = { id: 'move_file', name: 'move_file', @@ -2038,17 +2729,18 @@ export const MoveFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileIds: { + destinationPath: { + type: 'string', + description: + 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', + }, + paths: { type: 'array', - description: 'Canonical workspace file IDs to move.', + description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', items: { type: 'string' }, }, - folderId: { - type: 'string', - description: 'Target file-folder ID. Omit or pass empty string to move to workspace root.', - }, }, - required: ['fileIds'], + required: ['paths'], }, requiredPermission: 'write', } @@ -2061,14 +2753,17 @@ export const MoveFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - folderId: { type: 'string', description: 'The workspace file-folder ID to move.' }, - parentId: { + destinationPath: { type: 'string', description: - 'Target parent file-folder ID. Omit or pass empty string to move to workspace root.', + 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', + }, + path: { + type: 'string', + description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', }, }, - required: ['folderId'], + required: ['path'], }, requiredPermission: 'write', } @@ -2127,7 +2822,7 @@ export const OauthGetAuthLink: ToolCatalogEntry = { providerName: { type: 'string', description: - "The name of the OAuth provider to connect (e.g., 'Slack', 'Gmail', 'Google Calendar', 'GitHub')", + "The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`); the service display name or providerId resolves case-insensitively/fuzzily, so avoid bare base providers like `google`.", }, }, required: ['providerName'], @@ -2145,7 +2840,7 @@ export const OauthRequestAccess: ToolCatalogEntry = { providerName: { type: 'string', description: - "The name of the OAuth provider to connect (e.g., 'Slack', 'Gmail', 'Google Calendar')", + "The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`); the service display name or providerId resolves case-insensitively/fuzzily, so avoid bare base providers like `google`.", }, }, required: ['providerName'], @@ -2163,14 +2858,16 @@ export const OpenResource: ToolCatalogEntry = { properties: { resources: { type: 'array', - description: 'Array of resources to open. Each item must have type and id.', + description: + 'Array of resources to open. Each item must have type and either id or, for files, path.', items: { type: 'object', properties: { - id: { + id: { type: 'string', description: 'Canonical resource ID for non-file resources.' }, + path: { type: 'string', description: - 'Canonical resource ID. For type "file" this must be a UUID from the workspace file meta.json "id" field—never a VFS path or display name.', + 'Encoded VFS path for type "file" (percent-encoded per segment, e.g. "files/Reports/Q4%20Report.pdf"). Copy it verbatim from glob/read/workspace context output — do not decode it to a display name or re-encode it.', }, type: { type: 'string', @@ -2178,7 +2875,7 @@ export const OpenResource: ToolCatalogEntry = { enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, - required: ['type', 'id'], + required: ['type'], }, }, }, @@ -2186,6 +2883,135 @@ export const OpenResource: ToolCatalogEntry = { }, } +export const PromoteToLive: ToolCatalogEntry = { + id: 'promote_to_live', + name: 'promote_to_live', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + version: { + type: 'number', + description: + 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + }, + workflowId: { + type: 'string', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const QueryLogs: ToolCatalogEntry = { + id: 'query_logs', + name: 'query_logs', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: "Optional (view='full'): only return this block's span subtree.", + }, + blockName: { + type: 'string', + description: "Optional (view='full'): only return spans for this block name.", + }, + costOperator: { + type: 'string', + description: "Filter (view='list'): comparison operator for cost.", + enum: ['=', '>', '<', '>=', '<=', '!='], + }, + costValue: { + type: 'number', + description: "Filter (view='list'): cost threshold paired with costOperator.", + }, + cursor: { + type: 'string', + description: "Pagination cursor (view='list') from a prior response's nextCursor.", + }, + durationOperator: { + type: 'string', + description: "Filter (view='list'): comparison operator for duration (ms).", + enum: ['=', '>', '<', '>=', '<=', '!='], + }, + durationValue: { + type: 'number', + description: "Filter (view='list'): duration threshold (ms) paired with durationOperator.", + }, + endDate: { type: 'string', description: "Filter (view='list'): ISO end of the time range." }, + executionId: { + type: 'string', + description: + "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + }, + folderIds: { + type: 'string', + description: "Filter (view='list'): comma-separated folder IDs (descendants included).", + }, + folderName: { + type: 'string', + description: "Filter (view='list'): substring match on folder name.", + }, + level: { + type: 'string', + description: + "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.", + }, + limit: { type: 'number', description: "Max results (view='list'), 1-200 (default 100)." }, + pattern: { + type: 'string', + description: + "Optional separate parameter (not a 'view' value): with view 'overview' or 'full', greps the execution's trace spans (requires executionId), returning matching spans with snippets instead of the full log.", + }, + search: { + type: 'string', + description: "Filter (view='list'): substring match on executionId.", + }, + sortBy: { + type: 'string', + description: "Sort field (view='list').", + enum: ['date', 'duration', 'cost', 'status'], + }, + sortOrder: { + type: 'string', + description: "Sort order (view='list').", + enum: ['asc', 'desc'], + }, + startDate: { + type: 'string', + description: "Filter (view='list'): ISO start of the time range.", + }, + triggers: { + type: 'string', + description: "Filter (view='list'): comma-separated trigger types.", + }, + view: { + type: 'string', + description: + "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).", + enum: ['list', 'overview', 'full'], + }, + workflowIds: { + type: 'string', + description: "Filter (view='list'): comma-separated workflow IDs.", + }, + workflowName: { + type: 'string', + description: "Filter (view='list'): substring match on workflow name.", + }, + workspaceId: { type: 'string', description: 'Workspace ID to scope to.' }, + }, + required: ['view'], + }, +} + export const Read: ToolCatalogEntry = { id: 'read', name: 'read', @@ -2204,7 +3030,7 @@ export const Read: ToolCatalogEntry = { path: { type: 'string', description: - "Path to the file to read (e.g. 'workflows/My Workflow/state.json' or 'workflows/Projects/Q1/My Workflow/state.json').", + "Path to the VFS resource to read (e.g. 'workflows/My%20Workflow/state.json', 'files/Q4%20Report.pdf/content' for file bytes/parsed text, or 'uploads/data.csv' for a chat upload). Copy paths verbatim from glob/grep/read output.", }, }, required: ['path'], @@ -2219,11 +3045,22 @@ export const Redeploy: ToolCatalogEntry = { parameters: { type: 'object', properties: { + versionDescription: { + type: 'string', + description: + 'REQUIRED: a concise (1-3 sentence) description of what changed in this deployment version. If unsure what changed, call diff_workflows(ref1: "live", ref2: "draft") first.', + }, + versionName: { + type: 'string', + description: + 'REQUIRED: a short human-readable name/label for this deployment version, shown in deployment history.', + }, workflowId: { type: 'string', description: 'Workflow ID to redeploy (required in workspace context)', }, }, + required: ['versionDescription', 'versionName'], }, resultSchema: { type: 'object', @@ -2284,14 +3121,17 @@ export const RenameFile: ToolCatalogEntry = { parameters: { type: 'object', properties: { - fileId: { type: 'string', description: 'Canonical workspace file ID of the file to rename.' }, newName: { type: 'string', description: 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', }, + path: { + type: 'string', + description: 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', + }, }, - required: ['fileId', 'newName'], + required: ['path', 'newName'], }, resultSchema: { type: 'object', @@ -2313,10 +3153,13 @@ export const RenameFileFolder: ToolCatalogEntry = { parameters: { type: 'object', properties: { - folderId: { type: 'string', description: 'The workspace file-folder ID to rename.' }, name: { type: 'string', description: 'New folder name.' }, + path: { + type: 'string', + description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', + }, }, - required: ['folderId', 'name'], + required: ['path', 'name'], }, requiredPermission: 'write', } @@ -2395,23 +3238,6 @@ export const RestoreResource: ToolCatalogEntry = { requiredPermission: 'admin', } -export const RevertToVersion: ToolCatalogEntry = { - id: 'revert_to_version', - name: 'revert_to_version', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - version: { type: 'number', description: 'The deployment version number to revert to' }, - workflowId: { type: 'string', description: 'The workflow ID' }, - }, - required: ['workflowId', 'version'], - }, - requiresConfirmation: true, - requiredPermission: 'admin', -} - export const Run: ToolCatalogEntry = { id: 'run', name: 'run', @@ -2510,16 +3336,26 @@ export const RunWorkflow: ToolCatalogEntry = { parameters: { type: 'object', properties: { + inputFromExecutionId: { + type: 'string', + description: + 'Reuse the recorded input from a past execution of this workflow (from query_logs) instead of supplying workflow_input — handy for replaying a run without retyping inputs. The reused input is re-validated against the trigger. Mutually exclusive with workflow_input and useMockPayload.', + }, triggerBlockId: { type: 'string', description: - 'Optional trigger block ID when the workflow has multiple entrypoints and you need to target a specific one.', + 'Trigger block ID to run from (from get_workflow_run_options). Required when the workflow has multiple entrypoints.', }, useDeployedState: { type: 'boolean', description: 'When true, runs the deployed version instead of the live draft. Default: false (draft).', }, + useMockPayload: { + type: 'boolean', + description: + "When true, run with the trigger's generated mock payload instead of workflow_input. Prefer building your own workflow_input; use this only when you can't.", + }, workflowId: { type: 'string', description: @@ -2527,10 +3363,10 @@ export const RunWorkflow: ToolCatalogEntry = { }, workflow_input: { type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', + description: + "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, - required: ['workflow_input'], }, clientExecutable: true, requiresConfirmation: true, @@ -2544,6 +3380,11 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { parameters: { type: 'object', properties: { + inputFromExecutionId: { + type: 'string', + description: + 'Reuse the recorded input from a past execution of this workflow (from query_logs) instead of supplying workflow_input. The reused input is re-validated against the trigger. Mutually exclusive with workflow_input and useMockPayload.', + }, stopAfterBlockId: { type: 'string', description: 'The block ID to stop after. Execution halts once this block completes.', @@ -2551,13 +3392,18 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { triggerBlockId: { type: 'string', description: - 'Optional trigger block ID when the workflow has multiple entrypoints and you need to target a specific one.', + 'Trigger block ID to run from (from get_workflow_run_options). Required when the workflow has multiple entrypoints.', }, useDeployedState: { type: 'boolean', description: 'When true, runs the deployed version instead of the live draft. Default: false (draft).', }, + useMockPayload: { + type: 'boolean', + description: + "When true, run with the trigger's generated mock payload instead of workflow_input. Prefer building your own workflow_input; use this only when you can't.", + }, workflowId: { type: 'string', description: @@ -2565,7 +3411,8 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { }, workflow_input: { type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', + description: + "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, required: ['stopAfterBlockId'], @@ -2648,7 +3495,6 @@ export const SearchOnline: ToolCatalogEntry = { 'news', 'tweet', 'github', - 'paper', 'company', 'research paper', 'linkedin profile', @@ -2662,7 +3508,7 @@ export const SearchOnline: ToolCatalogEntry = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "pricing changes" or "Slack webhook docs", not a full sentence like "Searching online for pricing changes".', + "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", }, }, required: ['query', 'toolTitle'], @@ -2770,12 +3616,19 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { items: { type: 'object', properties: { - name: { type: 'string' }, + name: { type: 'string', description: 'Variable name.' }, operation: { type: 'string', enum: ['add', 'delete', 'edit'] }, - type: { type: 'string', enum: ['plain', 'number', 'boolean', 'array', 'object'] }, - value: { type: 'string' }, + type: { + type: 'string', + description: 'Variable type. Required for add/edit; ignored for delete.', + enum: ['plain', 'number', 'boolean', 'array', 'object'], + }, + value: { + type: 'string', + description: 'Variable value. Required for add/edit; ignored for delete.', + }, }, - required: ['operation', 'name', 'type', 'value'], + required: ['operation', 'name'], }, }, workflowId: { @@ -2823,29 +3676,84 @@ export const Table: ToolCatalogEntry = { internal: true, } -export const ToolSearchToolRegex: ToolCatalogEntry = { - id: 'tool_search_tool_regex', - name: 'tool_search_tool_regex', +export const TouchPlan: ToolCatalogEntry = { + id: 'touch_plan', + name: 'touch_plan', route: 'sim', mode: 'async', parameters: { + type: 'object', properties: { - case_insensitive: { - description: 'Whether the regex should be case-insensitive (default true).', - type: 'boolean', + name: { + type: 'string', + description: + 'Plan file name or relative path under .plans, e.g. "implementation.md" or "phase-1/implementation.md". If no extension is supplied, ".md" is appended.', }, - max_results: { - description: 'Maximum number of tools to return (optional).', - type: 'integer', + scope: { + type: 'string', + description: + 'Plan scope. Use "workspace" for root .plans/** main-agent plans. Use "workflow" for workflows/{workflow}/.plans/** subplans. If omitted with workflowPath, workflow scope is assumed; otherwise workspace scope is assumed.', + enum: ['workspace', 'workflow'], }, - pattern: { - description: 'Regular expression to match tool names or descriptions.', + title: { + type: 'string', + description: 'Optional short user-visible label for the plan creation.', + }, + workflowPath: { type: 'string', + description: + 'Required for scope "workflow". Canonical workflow VFS path, e.g. "workflows/My%20Workflow" or "workflows/Folder/My%20Workflow". Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it. Do not use workflow IDs.', + }, + }, + required: ['name'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Contains id, name, scope, vfsPath, backingVfsPath, and workflowId for workflow plans. Use vfsPath for follow-up workspace_file calls.', }, + message: { type: 'string', description: 'Human-readable outcome.' }, + success: { type: 'boolean', description: 'Whether the plan file was created.' }, }, - required: ['pattern'], + required: ['success', 'message'], + }, + requiredPermission: 'write', + capabilities: ['file_output'], +} + +export const UpdateDeploymentVersion: ToolCatalogEntry = { + id: 'update_deployment_version', + name: 'update_deployment_version', + route: 'sim', + mode: 'async', + parameters: { type: 'object', + properties: { + description: { + type: 'string', + description: 'New description for the deployment version. Provide name and/or description.', + }, + name: { + type: 'string', + description: 'New name/label for the deployment version. Provide name and/or description.', + }, + version: { + type: 'number', + description: + 'The numeric deployment version number to update (use get_deployment_log to find it).', + }, + workflowId: { + type: 'string', + description: 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], }, + requiresConfirmation: true, + requiredPermission: 'write', } export const UpdateJobHistory: ToolCatalogEntry = { @@ -2900,7 +3808,8 @@ export const UserMemory: ToolCatalogEntry = { }, correct_value: { type: 'string', - description: "The correct value to replace the wrong one (for 'correct' operation)", + description: + "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", }, key: { type: 'string', @@ -2987,15 +3896,10 @@ export const UserTable: ToolCatalogEntry = { description: "Enrichment registry ID for add_enrichment. Discover the available IDs (and each one's inputs/outputs) via list_enrichments first — don't hardcode. Examples: work-email, phone-number, company-domain, company-info.", }, - fileId: { - type: 'string', - description: - 'Canonical workspace file ID for create_from_file/import_file. Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', - }, filePath: { type: 'string', description: - 'Legacy workspace file reference for create_from_file/import_file. Prefer fileId.', + 'Canonical workspace file VFS path for create_from_file/import_file, e.g. files/{path}/{name}.', }, filter: { type: 'object', @@ -3075,7 +3979,11 @@ export const UserTable: ToolCatalogEntry = { description: "Table name (required for 'create'). Also the optional display name for add_enrichment — defaults to the enrichment's registry name when omitted.", }, - newName: { type: 'string', description: 'New column name (required for rename_column)' }, + newName: { + type: 'string', + description: + 'New name. Required for rename_column (new column name) and for rename (new table name).', + }, newType: { type: 'string', description: @@ -3221,6 +4129,7 @@ export const UserTable: ToolCatalogEntry = { 'get', 'get_schema', 'delete', + 'rename', 'insert_row', 'batch_insert_rows', 'get_row', @@ -3287,21 +4196,17 @@ export const WorkspaceFile: ToolCatalogEntry = { }, target: { type: 'object', - description: 'Explicit file target. Use kind=file_id + fileId for existing files.', + description: 'Explicit file target. Use kind=path + path for existing files.', properties: { - fileId: { + kind: { type: 'string', - description: 'Canonical existing workspace file ID. Required when target.kind=file_id.', + description: 'How the file target is identified.', + enum: ['path'], }, - fileName: { + path: { type: 'string', description: - 'Plain workspace filename including extension, e.g. "main.py" or "report.docx". Required when target.kind=new_file.', - }, - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['new_file', 'file_id'], + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', }, }, required: ['kind'], @@ -3380,10 +4285,6 @@ export const WorkspaceFile: ToolCatalogEntry = { }, }, }, - newName: { - type: 'string', - description: 'New file name for rename. Must be a plain workspace filename like "main.py".', - }, }, required: ['operation', 'target', 'title'], }, @@ -3403,6 +4304,38 @@ export const WorkspaceFile: ToolCatalogEntry = { requiredPermission: 'write', } +export const FfmpegOperation = { + overlayAudio: 'overlay_audio', + mixAudio: 'mix_audio', + concat: 'concat', + trim: 'trim', + scalePad: 'scale_pad', + overlayImage: 'overlay_image', + addText: 'add_text', + fade: 'fade', + extractAudio: 'extract_audio', + convert: 'convert', + thumbnail: 'thumbnail', + probe: 'probe', +} as const + +export type FfmpegOperation = (typeof FfmpegOperation)[keyof typeof FfmpegOperation] + +export const FfmpegOperationValues = [ + FfmpegOperation.overlayAudio, + FfmpegOperation.mixAudio, + FfmpegOperation.concat, + FfmpegOperation.trim, + FfmpegOperation.scalePad, + FfmpegOperation.overlayImage, + FfmpegOperation.addText, + FfmpegOperation.fade, + FfmpegOperation.extractAudio, + FfmpegOperation.convert, + FfmpegOperation.thumbnail, + FfmpegOperation.probe, +] as const + export const KnowledgeBaseOperation = { create: 'create', get: 'get', @@ -3530,8 +4463,6 @@ export const ManageSkillOperationValues = [ export const MaterializeFileOperation = { save: 'save', import: 'import', - table: 'table', - knowledgeBase: 'knowledge_base', } as const export type MaterializeFileOperation = @@ -3540,8 +4471,6 @@ export type MaterializeFileOperation = export const MaterializeFileOperationValues = [ MaterializeFileOperation.save, MaterializeFileOperation.import, - MaterializeFileOperation.table, - MaterializeFileOperation.knowledgeBase, ] as const export const UserMemoryOperation = { @@ -3569,6 +4498,7 @@ export const UserTableOperation = { get: 'get', getSchema: 'get_schema', delete: 'delete', + rename: 'rename', insertRow: 'insert_row', batchInsertRows: 'batch_insert_rows', getRow: 'get_row', @@ -3604,6 +4534,7 @@ export const UserTableOperationValues = [ UserTableOperation.get, UserTableOperation.getSchema, UserTableOperation.delete, + UserTableOperation.rename, UserTableOperation.insertRow, UserTableOperation.batchInsertRows, UserTableOperation.getRow, @@ -3650,15 +4581,12 @@ export const TOOL_CATALOG: Record = { [Auth.id]: Auth, [CheckDeploymentStatus.id]: CheckDeploymentStatus, [CompleteJob.id]: CompleteJob, - [ContextWrite.id]: ContextWrite, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, [CreateFileFolder.id]: CreateFileFolder, [CreateFolder.id]: CreateFolder, - [CreateJob.id]: CreateJob, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, - [Debug.id]: Debug, [DeleteFile.id]: DeleteFile, [DeleteFileFolder.id]: DeleteFileFolder, [DeleteFolder.id]: DeleteFolder, @@ -3668,24 +4596,26 @@ export const TOOL_CATALOG: Record = { [DeployApi.id]: DeployApi, [DeployChat.id]: DeployChat, [DeployMcp.id]: DeployMcp, + [DiffWorkflows.id]: DiffWorkflows, [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile, [EditContent.id]: EditContent, [EditWorkflow.id]: EditWorkflow, + [Ffmpeg.id]: Ffmpeg, [File.id]: File, [FunctionExecute.id]: FunctionExecute, [GenerateApiKey.id]: GenerateApiKey, + [GenerateAudio.id]: GenerateAudio, [GenerateImage.id]: GenerateImage, - [GenerateVisualization.id]: GenerateVisualization, + [GenerateVideo.id]: GenerateVideo, [GetBlockOutputs.id]: GetBlockOutputs, [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, - [GetDeploymentVersion.id]: GetDeploymentVersion, - [GetExecutionSummary.id]: GetExecutionSummary, + [GetDeploymentLog.id]: GetDeploymentLog, [GetJobLogs.id]: GetJobLogs, [GetPageContents.id]: GetPageContents, [GetPlatformActions.id]: GetPlatformActions, [GetWorkflowData.id]: GetWorkflowData, - [GetWorkflowLogs.id]: GetWorkflowLogs, + [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, [Grep.id]: Grep, [Job.id]: Job, @@ -3693,14 +4623,18 @@ export const TOOL_CATALOG: Record = { [KnowledgeBase.id]: KnowledgeBase, [ListFileFolders.id]: ListFileFolders, [ListFolders.id]: ListFolders, + [ListIntegrationTools.id]: ListIntegrationTools, [ListUserWorkspaces.id]: ListUserWorkspaces, [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, + [LoadDeployment.id]: LoadDeployment, + [LoadIntegrationTool.id]: LoadIntegrationTool, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, [ManageJob.id]: ManageJob, [ManageMcpTool.id]: ManageMcpTool, [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, + [Media.id]: Media, [MoveFile.id]: MoveFile, [MoveFileFolder.id]: MoveFileFolder, [MoveFolder.id]: MoveFolder, @@ -3708,6 +4642,8 @@ export const TOOL_CATALOG: Record = { [OauthGetAuthLink.id]: OauthGetAuthLink, [OauthRequestAccess.id]: OauthRequestAccess, [OpenResource.id]: OpenResource, + [PromoteToLive.id]: PromoteToLive, + [QueryLogs.id]: QueryLogs, [Read.id]: Read, [Redeploy.id]: Redeploy, [RenameFile.id]: RenameFile, @@ -3716,7 +4652,6 @@ export const TOOL_CATALOG: Record = { [Research.id]: Research, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, - [RevertToVersion.id]: RevertToVersion, [Run.id]: Run, [RunBlock.id]: RunBlock, [RunFromBlock.id]: RunFromBlock, @@ -3732,7 +4667,8 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [Superagent.id]: Superagent, [Table.id]: Table, - [ToolSearchToolRegex.id]: ToolSearchToolRegex, + [TouchPlan.id]: TouchPlan, + [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, [UpdateJobHistory.id]: UpdateJobHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserMemory.id]: UserMemory, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index faf11409c4e..69d42ded7fb 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -61,23 +61,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - context_write: { - parameters: { - type: 'object', - properties: { - content: { - type: 'string', - description: 'Full content to write to the file (replaces existing content)', - }, - file_path: { - type: 'string', - description: "Path of the file to write (e.g. 'SESSION.md')", - }, - }, - required: ['file_path', 'content'], - }, - resultSchema: undefined, - }, crawl_website: { parameters: { type: 'object', @@ -125,17 +108,47 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { fileName: { type: 'string', description: - 'Workspace filename or slash-separated file path including extension, e.g. "main.py", "report.md", or "Reports/2026/report.md".', + 'Backward-compatible workspace filename. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, }, - required: ['fileName'], }, resultSchema: { type: 'object', properties: { data: { type: 'object', - description: 'Contains id (the fileId) and name.', + description: + 'Contains id (internal file ID), name, and vfsPath. Use vfsPath for follow-up file tools.', }, message: { type: 'string', @@ -153,20 +166,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - name: { - type: 'string', - description: 'Folder name.', - }, - parentId: { + path: { type: 'string', - description: 'Optional parent file-folder ID.', + description: + 'Canonical folder VFS path to create, e.g. "files/Images" or "files/Reports/2026".', }, workspaceId: { type: 'string', description: 'Optional workspace ID. Defaults to the current workspace.', }, }, - required: ['name'], + required: ['path'], }, resultSchema: undefined, }, @@ -191,56 +201,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - create_job: { - parameters: { - type: 'object', - properties: { - cron: { - type: 'string', - description: - "Cron expression for recurring jobs (e.g., '*/5 * * * *' for every 5 minutes, '0 9 * * *' for daily at 9 AM). Omit for one-time jobs.", - }, - lifecycle: { - type: 'string', - description: - "'persistent' (default) or 'until_complete'. Until_complete jobs stop when complete_job is called after the success condition is met.", - enum: ['persistent', 'until_complete'], - }, - maxRuns: { - type: 'integer', - description: - 'Maximum number of executions before the job auto-completes. Safety limit to prevent runaway polling.', - }, - prompt: { - type: 'string', - description: - 'The prompt to execute when the job fires. This is sent to the Mothership as a user message.', - }, - successCondition: { - type: 'string', - description: - "What must happen for the job to be considered complete. Used with until_complete lifecycle (e.g., 'John has replied to the partnership email').", - }, - time: { - type: 'string', - description: - "ISO 8601 datetime for one-time execution or as the start time for a cron schedule (e.g., '2026-03-06T09:00:00'). Include timezone offset or use the timezone parameter.", - }, - timezone: { - type: 'string', - description: - "IANA timezone for the schedule (e.g., 'America/New_York', 'Europe/London'). Defaults to UTC.", - }, - title: { - type: 'string', - description: - "A short, descriptive title for the job (e.g., 'Email Poller', 'Daily Report'). Used as the display name.", - }, - }, - required: ['title', 'prompt'], - }, - resultSchema: undefined, - }, create_workflow: { parameters: { type: 'object', @@ -299,38 +259,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - debug: { - parameters: { - properties: { - context: { - description: - 'Pre-gathered context: workflow state JSON, block schemas, error logs. The debug agent will skip re-reading anything included here.', - type: 'string', - }, - request: { - description: - 'What to debug. Include error messages, block IDs, and any context about the failure.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - resultSchema: undefined, - }, delete_file: { parameters: { type: 'object', properties: { - fileIds: { + paths: { type: 'array', - description: 'Canonical workspace file IDs of the files to delete.', + description: + 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', items: { type: 'string', }, }, }, - required: ['fileIds'], + required: ['paths'], }, resultSchema: { type: 'object', @@ -351,15 +293,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderIds: { + paths: { type: 'array', - description: 'The workspace file-folder IDs to delete.', + description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', items: { type: 'string', }, }, }, - required: ['folderIds'], + required: ['paths'], }, resultSchema: undefined, }, @@ -413,7 +355,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { request: { description: - 'Detailed deployment instructions. Include deployment type (api/chat) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', + 'Detailed deployment instructions. Include deployment type (api/chat/mcp) and ALL user-specified options: identifier, title, description, authType, password, allowedEmails, welcomeMessage, outputConfigs (block outputs to display).', type: 'string', }, }, @@ -432,6 +374,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enum: ['deploy', 'undeploy'], default: 'deploy', }, + versionDescription: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a concise (1-3 sentence) description of what changed in this deployment version, e.g. "Adds Slack failure alert and retries on the HTTP block". If unsure what changed, call diff_workflows(ref1: "live", ref2: "draft") first. Ignored for undeploy.', + }, + versionName: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a short human-readable name/label for this deployment version (shown in the deployment history), e.g. "v2 pricing" or "Add Slack alerts". Ignored for undeploy.', + }, workflowId: { type: 'string', description: 'Workflow ID to deploy (required in workspace context)', @@ -521,7 +473,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, description: { type: 'string', - description: 'Optional description for the chat', + description: 'Optional chat-facing description shown on the chat page', }, identifier: { type: 'string', @@ -539,7 +491,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, path: { type: 'string', - description: "The output path (e.g. 'response', 'response.content')", + description: + 'The output path (e.g. `content` for an agent; structured fields are top-level paths). Call get_block_outputs for real paths.', }, }, required: ['blockId', 'path'], @@ -553,6 +506,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: 'Display title for the chat interface', }, + versionDescription: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a concise (1-3 sentence) description of what changed in this deployment version (distinct from the chat-facing description). If unsure what changed, call diff_workflows(ref1: "live", ref2: "draft") first. Ignored for undeploy.', + }, + versionName: { + type: 'string', + description: + 'REQUIRED when action is "deploy": a short human-readable name/label for this deployment version (distinct from the chat title; shown in deployment history). Ignored for undeploy.', + }, welcomeMessage: { type: 'string', description: 'Welcome message shown to users', @@ -760,6 +723,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['deploymentType', 'deploymentStatus'], }, }, + diff_workflows: { + parameters: { + type: 'object', + properties: { + ref1: { + type: 'string', + description: + 'Base side (string): a version number (e.g. "3"), "live" (active deployment), or "draft" (current editor state).', + }, + ref2: { + type: 'string', + description: + 'Target side (string): a version number (e.g. "4"), "live" (active deployment), or "draft" (current editor state).', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['ref1', 'ref2'], + }, + resultSchema: undefined, + }, download_to_workspace_file: { parameters: { type: 'object', @@ -767,7 +754,37 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { fileName: { type: 'string', description: - 'Optional workspace file name to save as. If omitted, the name is inferred from the response or URL.', + 'Backward-compatible workspace file name. Prefer outputs.files[0].path for new calls.', + }, + outputs: { + type: 'object', + description: 'Workspace file output declarations using canonical VFS paths.', + properties: { + files: { + type: 'array', + description: + 'Files to create or overwrite. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: 'Canonical destination VFS path, e.g. "files/Reports/result.csv".', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, url: { type: 'string', @@ -834,10 +851,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { params: { type: 'object', description: - 'Parameters for the operation. \nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": "claude-sonnet-4-6"}}\nFor delete: {} (empty object)', + 'Parameters for the operation (optional).\nFor edit: {"inputs": {"temperature": 0.5}} NOT {"subBlocks": {"temperature": {"value": 0.5}}}\nFor add: {"type": "agent", "name": "My Agent", "inputs": {"model": ""}}\nFor delete: omit params entirely (none needed)', }, }, - required: ['operation_type', 'block_id', 'params'], + required: ['operation_type', 'block_id'], }, }, workflowId: { @@ -850,70 +867,330 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - file: { - parameters: { - type: 'object', - }, - resultSchema: undefined, - }, - function_execute: { + ffmpeg: { parameters: { type: 'object', properties: { - code: { + aspectRatio: { type: 'string', - description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + description: 'Target aspect ratio for scale_pad, e.g. 9:16, 16:9, 1:1.', }, - inputFiles: { - type: 'array', - description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}. Example: ["wf_123"]', - items: { - type: 'string', - }, + end: { + type: 'number', + description: 'End time in seconds (trim).', }, - inputTables: { - type: 'array', + format: { + type: 'string', + description: 'Target format/extension for convert (e.g. mp4, mp3, wav, gif).', + }, + height: { + type: 'number', + description: 'Target height in pixels (scale_pad).', + }, + inputs: { + type: 'object', description: - 'Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Example: ["tbl_abc123"]', - items: { - type: 'string', + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, }, }, - language: { - type: 'string', - description: 'Execution language.', - enum: ['javascript', 'python', 'shell'], + loopToVideo: { + type: 'boolean', + description: 'For overlay_audio, loop or trim the audio to match the video length.', }, - outputFormat: { - type: 'string', - description: - 'Format for outputPath. Determines how the code result is serialized. If omitted, inferred from outputPath file extension.', - enum: ['json', 'csv', 'txt', 'md', 'html'], + musicVolume: { + type: 'number', + description: 'Volume multiplier for the background music track in mix_audio (e.g. 0.3).', }, - outputMimeType: { + operation: { type: 'string', - description: - 'MIME type for outputSandboxPath export. Required for binary files: image/png, image/jpeg, application/pdf, etc. Omit for text files.', + description: 'The FFmpeg operation to run.', + enum: [ + 'overlay_audio', + 'mix_audio', + 'concat', + 'trim', + 'scale_pad', + 'overlay_image', + 'add_text', + 'fade', + 'extract_audio', + 'convert', + 'thumbnail', + 'probe', + ], }, - outputPath: { - type: 'string', + outputs: { + type: 'object', description: - 'Pipe output directly to a NEW workspace file instead of returning in context. ALWAYS use this instead of a separate workspace_file write call. Use a root path like "files/result.json" — nested output paths are not supported.', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, - outputSandboxPath: { + position: { type: 'string', - description: - 'Path to a file created inside the sandbox that should be exported to the workspace. Use together with outputPath.', + description: 'Placement for add_text / overlay_image.', + enum: ['top', 'center', 'bottom', 'top-left', 'top-right', 'bottom-left', 'bottom-right'], }, - outputTable: { + start: { + type: 'number', + description: 'Start time in seconds (trim, thumbnail, fade).', + }, + text: { type: 'string', - description: - 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + description: 'Text to burn in for add_text.', }, - }, - required: ['code'], + volume: { + type: 'number', + description: 'Volume multiplier for the primary track (mix_audio / overlay_audio).', + }, + width: { + type: 'number', + description: 'Target width in pixels (scale_pad).', + }, + }, + required: ['operation', 'inputs'], + }, + resultSchema: undefined, + }, + file: { + parameters: { + type: 'object', + }, + resultSchema: undefined, + }, + function_execute: { + parameters: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + }, + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + language: { + type: 'string', + description: 'Execution language.', + enum: ['javascript', 'python', 'shell'], + }, + outputTable: { + type: 'string', + description: + 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', + }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, + }, + title: { + type: 'string', + description: + 'Short user-visible label for this execution, e.g. "Clean customer CSV", "Revenue chart", or "Query GitHub issues".', + }, + }, + required: ['code'], }, resultSchema: undefined, }, @@ -935,80 +1212,453 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - generate_image: { + generate_audio: { parameters: { type: 'object', properties: { - aspectRatio: { - type: 'string', - description: 'Aspect ratio for the generated image.', - enum: ['1:1', '16:9', '9:16', '4:3', '3:4'], + duration: { + type: 'number', + description: + 'Approximate duration in seconds for sfx (and music models that support it). MiniMax music ignores this — fit music to a video with the ffmpeg tool instead.', }, - fileName: { + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + instrumental: { + type: 'boolean', + description: + 'For music: true = instrumental, no vocals (default); false = a song with vocals.', + }, + lyrics: { type: 'string', description: - 'Output file name. Defaults to "generated-image.png". New generated images currently create root workspace files, so pass a plain file name, not a nested path.', + 'For music with vocals: the lyrics to sing (optional; supports [Verse]/[Chorus] tags). Setting this implies instrumental=false.', }, - overwriteFileId: { + model: { type: 'string', description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated image so the existing chat resource stays current instead of creating a duplicate like "image (1).png". The file ID is returned by previous generate_image or generate_visualization calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', + 'Optional model override for the selected type (e.g. fal-ai/elevenlabs/tts/eleven-v3 for speech).', + }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, }, prompt: { type: 'string', description: - 'Detailed text description of the image to generate, or editing instructions when used with editFileId.', + 'For speech: the text to speak (may include expressive tags). For music/sfx: a description of the audio to generate.', }, - referenceFileIds: { - type: 'array', + type: { + type: 'string', + description: 'Kind of audio to generate. Defaults to speech.', + enum: ['speech', 'music', 'sfx'], + }, + voice: { + type: 'string', + description: 'Optional voice name or id for speech.', + }, + }, + required: ['prompt'], + }, + resultSchema: undefined, + }, + generate_image: { + parameters: { + type: 'object', + properties: { + aspectRatio: { + type: 'string', + description: 'Aspect ratio for the generated image.', + enum: ['1:1', '16:9', '9:16', '4:3', '3:4'], + }, + inputs: { + type: 'object', description: - 'File IDs of workspace images to include as context for the generation. All images are sent alongside the prompt. Use for: editing a single image (1 file), compositing multiple images together (2+ files), style transfer, face swapping, etc. Order matters — list the primary/base image first. When revising an existing image in place, pair the primary file ID here with overwriteFileId set to that same ID.', - items: { - type: 'string', + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, }, }, + outputs: { + type: 'object', + description: + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, + }, + }, + prompt: { + type: 'string', + description: + 'Detailed text description of the image to generate, or editing instructions when editing the image(s) passed in `inputs.files`.', + }, }, required: ['prompt'], }, resultSchema: undefined, }, - generate_visualization: { + generate_video: { parameters: { type: 'object', properties: { - code: { + aspectRatio: { type: 'string', + description: 'Aspect ratio for the video (model-dependent).', + enum: ['16:9', '9:16', '1:1'], + }, + duration: { + type: 'number', + description: 'Clip duration in seconds (model-dependent; e.g. 4, 6, 8).', + }, + generateAudio: { + type: 'boolean', description: - "Python code that generates a visualization using matplotlib. MUST call plt.savefig('/home/user/output.png', dpi=150, bbox_inches='tight') to produce output.", + "Toggle Veo's native audio (dialogue/SFX/ambience/music generated from the prompt). Default true. Set false when you will add your own voiceover/music via the ffmpeg tool.", }, - fileName: { + inputs: { + type: 'object', + description: + 'Workspace resources to mount into the sandbox. Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it.', + properties: { + directories: { + type: 'array', + description: + 'Workspace folders to mount recursively into the sandbox, including nested files and empty folders.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS folder path, e.g. "files/Reports" or "workflows/My%20Workflow/.plans". By default this mounts at "/home/user/{path}". Workflow alias directories mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full sandbox directory path override. Omit to mount at /home/user/{path}.', + }, + }, + required: ['path'], + }, + }, + files: { + type: 'array', + description: 'Workspace files to mount into the sandbox.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Canonical VFS file path, e.g. "files/Reports/sales.csv" or "workflows/My%20Workflow/changelog.md". By default this mounts at "/home/user/{path}". Workflow alias paths mount under "/home/user/workflows/...".', + }, + sandboxPath: { + type: 'string', + description: + 'Full sandbox path to mount at, e.g. /home/user/inputs/data.csv. STRONGLY RECOMMENDED whenever the file name has spaces or special characters: the default mount path is the percent-ENCODED canonical path (e.g. /home/user/files/Q4%20Sales%20(Final).csv), which code using the human-readable name will not find. Set a simple sandboxPath and read exactly that.', + }, + }, + required: ['path'], + }, + }, + tables: { + type: 'array', + description: 'Workspace tables to mount as CSV files.', + items: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Canonical VFS table path when available.', + }, + sandboxPath: { + type: 'string', + description: 'Optional full sandbox path for the mounted CSV.', + }, + tableId: { + type: 'string', + description: 'Workspace table ID.', + }, + }, + }, + }, + }, + }, + model: { type: 'string', description: - 'Output file name. Defaults to "chart.png". New visualization outputs currently create root workspace files, so pass a plain file name, not a nested path.', + "Optional model override, keyed to the video's goal: veo-3.1-lite (prototype/quick test, cheapest), veo-3.1-fast (reasonable draft — default, good video), veo-3.1 Standard (final cut / premium quality). Stay on Veo unless the user explicitly asks for another model; seedance-2.0 for >8s narrative, kling-v3-pro for specific looks.", + enum: [ + 'veo-3.1', + 'veo-3.1-fast', + 'veo-3.1-lite', + 'seedance-2.0', + 'seedance-2.0-fast', + 'kling-v3-pro', + 'minimax-hailuo-2.3-pro', + 'wan-2.2-a14b-turbo', + 'ltx-2.3', + ], }, - inputFiles: { - type: 'array', + negativePrompt: { + type: 'string', description: - 'Canonical workspace file IDs to mount in the sandbox. Discover IDs via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json"). Mounted path: /home/user/files/{fileId}/{originalName}.', - items: { - type: 'string', - }, + 'Things to exclude from the video/audio (Veo models), e.g. "no background music" to keep dialogue but drop Veo\'s invented music before overlaying your own track.', }, - inputTables: { - type: 'array', + outputs: { + type: 'object', description: - "Table IDs to mount as CSV files in the sandbox. Each table appears at /home/user/tables/{tableId}.csv with a header row. Read with pandas: pd.read_csv('/home/user/tables/tbl_xxx.csv')", - items: { - type: 'string', + 'Workspace files to create or overwrite from returned code results or sandbox-created files.', + properties: { + files: { + type: 'array', + description: 'File outputs. Parent folders must already exist for create mode.', + items: { + type: 'object', + properties: { + format: { + type: 'string', + description: 'Optional serialization format for returned values.', + enum: ['json', 'csv', 'txt', 'md', 'html'], + }, + mimeType: { + type: 'string', + description: 'Optional MIME type override when inference is not enough.', + }, + mode: { + type: 'string', + description: 'Create a new file or overwrite an existing file at path.', + enum: ['create', 'overwrite'], + }, + path: { + type: 'string', + description: + 'Canonical destination VFS path, e.g. "files/Reports/chart.png", "workflows/My%20Workflow/changelog.md", or "workflows/My%20Workflow/.plans/plan.md".', + }, + sandboxPath: { + type: 'string', + description: + 'Optional full path to a file created inside the sandbox. Omit to save the code return value.', + }, + }, + required: ['path', 'mode'], + }, + }, }, }, - overwriteFileId: { + prompt: { type: 'string', description: - 'If provided, overwrites the existing workspace file with this ID instead of creating a new file. Use this when the user asks to update, refine, or redo a previously generated chart so the existing chat resource stays current instead of creating a duplicate like "chart (1).png". The file ID is returned by previous generate_visualization or generate_image calls (fileId field), or can be found via read("files/by-id/{fileId}/meta.json").', + 'Detailed description of the video to generate (scene, action, camera movement, style).', + }, + promptOptimizer: { + type: 'boolean', + description: 'Enable prompt optimization for MiniMax models (default true).', + }, + resolution: { + type: 'string', + description: 'Video resolution (model-dependent), e.g. 720p or 1080p.', + enum: ['720p', '1080p', '4k'], }, }, - required: ['code'], + required: ['prompt'], }, resultSchema: undefined, }, @@ -1068,47 +1718,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_deployment_version: { + get_deployment_log: { parameters: { type: 'object', properties: { - version: { - type: 'number', - description: 'The deployment version number', - }, - workflowId: { - type: 'string', - description: 'The workflow ID', - }, - }, - required: ['workflowId', 'version'], - }, - resultSchema: undefined, - }, - get_execution_summary: { - parameters: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Max number of executions to return (default: 10, max: 20).', - }, - status: { - type: 'string', - description: "Filter by status: 'success', 'error', or 'all' (default: 'all').", - enum: ['success', 'error', 'all'], - }, workflowId: { type: 'string', description: - 'Optional workflow ID. If omitted, returns executions across all workflows in the workspace.', - }, - workspaceId: { - type: 'string', - description: 'Workspace ID to scope executions to.', + 'Optional workflow ID. If not provided, uses the current workflow in context.', }, }, - required: ['workspaceId'], }, resultSchema: undefined, }, @@ -1191,23 +1810,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_workflow_logs: { + get_workflow_run_options: { parameters: { type: 'object', properties: { - executionId: { - type: 'string', - description: - 'Optional execution ID to get logs for a specific execution. Use with get_execution_summary to find execution IDs first.', - }, - includeDetails: { - type: 'boolean', - description: 'Include detailed info', - }, - limit: { - type: 'number', - description: 'Max number of entries (hard limit: 3)', - }, workflowId: { type: 'string', description: @@ -1267,11 +1873,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - "Optional path prefix to scope the search (e.g. 'workflows/', 'environment/', 'internal/', 'components/blocks/').", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", }, pattern: { type: 'string', - description: 'Regex pattern to search for in file contents.', + description: + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', @@ -1387,10 +1994,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', }, - fileIds: { + filePaths: { type: 'array', description: - 'Canonical workspace file IDs to add as documents (for add_file). Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', + 'Canonical workspace file VFS paths to add as documents (for add_file), e.g. ["files/Docs/handbook.pdf"].', items: { type: 'string', }, @@ -1427,7 +2034,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { syncIntervalMinutes: { type: 'number', description: - 'Sync interval in minutes: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', + 'Sync interval in minutes. Accepted values: 60 (hourly), 360 (6h), 1440 (daily), 10080 (weekly), 0 (manual only). Default: 1440', default: 1440, }, tagDefinitionId: { @@ -1452,7 +2059,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, workspaceId: { type: 'string', - description: "Workspace ID (required for 'create', optional filter for 'list')", + description: + "Workspace ID. Required for 'create' when there is no workspace in context; otherwise the current workspace context is used.", }, }, }, @@ -1480,7 +2088,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { ], }, }, - required: ['operation', 'args'], + required: ['operation'], }, resultSchema: { type: 'object', @@ -1519,29 +2127,79 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { workspaceId: { type: 'string', - description: 'Optional workspace ID to list folders for.', + description: 'Optional workspace ID to list folders for.', + }, + }, + }, + resultSchema: undefined, + }, + list_integration_tools: { + parameters: { + properties: { + integration: { + description: + 'The integration service name — the folder under components/integrations/ (e.g. "slack", "gmail", "google_sheets"). Returns every operation\'s id, name, and description for that service.', + type: 'string', + }, + }, + required: ['integration'], + type: 'object', + }, + resultSchema: undefined, + }, + list_user_workspaces: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + list_workspace_mcp_servers: { + parameters: { + type: 'object', + properties: { + workspaceId: { + type: 'string', + description: + 'Workspace ID. Required when no current workspace context is available, such as headless MCP calls.', + }, + }, + }, + resultSchema: undefined, + }, + load_deployment: { + parameters: { + type: 'object', + properties: { + version: { + type: 'string', + description: + 'A string: a deployment version number (e.g. "5"), or "live" for the active deployment. (Unlike promote_to_live, which takes a numeric version, "live" is accepted here.)', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID. If not provided, uses the current workflow in context.', }, }, + required: ['version'], }, resultSchema: undefined, }, - list_user_workspaces: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, - list_workspace_mcp_servers: { + load_integration_tool: { parameters: { - type: 'object', properties: { - workspaceId: { - type: 'string', + tool_ids: { description: - 'Workspace ID. Required when no current workspace context is available, such as headless MCP calls.', + 'Exact integration tool ids to load before calling them, e.g. ["gmail_send_v2"]. Copy the "id" field verbatim from components/integrations/{service}/{operation}.json (including any version suffix).', + items: { + type: 'string', + }, + type: 'array', }, }, + required: ['tool_ids'], + type: 'object', }, resultSchema: undefined, }, @@ -1585,7 +2243,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'", + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -1639,7 +2298,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolId: { type: 'string', description: - "The ID of the custom tool (required for edit). Must be the exact toolId from the get_workflow_data custom tool response - do not guess or construct it. DO NOT PROVIDE THE TOOL ID IF THE OPERATION IS 'ADD'.", + "The ID of the custom tool. Get it from the `list` operation or the `id` field inside the tool's VFS file (agent/custom-tools/{name}.json — the filename is the display name, not the id); get_workflow_data also returns it where that tool is available. Do not guess or construct it. Required for edit and delete; omit for add and list.", }, toolIds: { type: 'array', @@ -1664,7 +2323,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { cron: { type: 'string', - description: 'Cron expression for recurring jobs', + description: + "Cron expression for a recurring job (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", }, jobId: { type: 'string', @@ -1681,6 +2341,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: "'persistent' (default) or 'until_complete'. Until_complete jobs stop when complete_job is called.", + enum: ['persistent', 'until_complete'], }, maxRuns: { type: 'integer', @@ -1693,6 +2354,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { status: { type: 'string', description: 'Job status: active, paused', + enum: ['active', 'paused'], }, successCondition: { type: 'string', @@ -1701,7 +2363,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, time: { type: 'string', - description: 'ISO 8601 datetime for one-time jobs or cron start time', + description: + "ISO 8601 datetime. One-time job -> set time and omit cron. May also anchor a recurring cron job's first-fire time.", }, timezone: { type: 'string', @@ -1715,7 +2378,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, operation: { type: 'string', - description: 'The operation to perform: create, list, get, update, delete', + description: + 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', enum: ['create', 'list', 'get', 'update', 'delete'], }, }, @@ -1761,13 +2425,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'", + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { type: 'string', description: - "Required for edit and delete. The database ID of the MCP server. DO NOT PROVIDE if operation is 'add' or 'list'.", + "The MCP server's id — the `id` field inside the VFS file agent/mcp-servers/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", }, }, required: ['operation'], @@ -1793,13 +2458,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'", + description: + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_job uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { type: 'string', description: - "The ID of the skill (required for edit/delete). Must be the exact ID from the VFS or list. DO NOT PROVIDE if operation is 'add' or 'list'.", + "The skill's id — the `id` field inside the VFS file agent/skills/{name}.json (the {name} filename is the display name, not the id). Required for edit and delete; omit for add and list.", }, }, required: ['operation'], @@ -1818,46 +2484,42 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, - knowledgeBaseId: { - type: 'string', - description: - 'ID of an existing knowledge base to add the file to (only used with operation "knowledge_base"). If omitted, a new KB is created.', - }, operation: { type: 'string', description: - 'What to do with the file. "save" promotes it to files/. "import" imports a workflow JSON. "table" converts CSV/TSV/JSON to a table. "knowledge_base" saves and adds to a KB. Defaults to "save".', - enum: ['save', 'import', 'table', 'knowledge_base'], + 'What to do with the file. "save" promotes it to a permanent files/ path. "import" imports a workflow JSON as a workspace workflow. Defaults to "save".', + enum: ['save', 'import'], default: 'save', }, - tableName: { - type: 'string', - description: - 'Custom name for the table (only used with operation "table"). Defaults to the file name without extension.', - }, }, required: ['fileNames'], }, resultSchema: undefined, }, + media: { + parameters: { + type: 'object', + }, + resultSchema: undefined, + }, move_file: { parameters: { type: 'object', properties: { - fileIds: { + destinationPath: { + type: 'string', + description: + 'Canonical target folder path, e.g. "files/Images". Omit or pass "files" for root.', + }, + paths: { type: 'array', - description: 'Canonical workspace file IDs to move.', + description: 'Canonical workspace file VFS paths to move, e.g. ["files/photo.png"].', items: { type: 'string', }, }, - folderId: { - type: 'string', - description: - 'Target file-folder ID. Omit or pass empty string to move to workspace root.', - }, }, - required: ['fileIds'], + required: ['paths'], }, resultSchema: undefined, }, @@ -1865,17 +2527,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderId: { + destinationPath: { type: 'string', - description: 'The workspace file-folder ID to move.', + description: + 'Canonical target parent folder path, e.g. "files/Archive". Omit or pass "files" for root.', }, - parentId: { + path: { type: 'string', - description: - 'Target parent file-folder ID. Omit or pass empty string to move to workspace root.', + description: 'Canonical folder VFS path to move, e.g. "files/Reports/2026".', }, }, - required: ['folderId'], + required: ['path'], }, resultSchema: undefined, }, @@ -1924,7 +2586,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { providerName: { type: 'string', description: - "The name of the OAuth provider to connect (e.g., 'Slack', 'Gmail', 'Google Calendar', 'GitHub')", + "The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`); the service display name or providerId resolves case-insensitively/fuzzily, so avoid bare base providers like `google`.", }, }, required: ['providerName'], @@ -1938,7 +2600,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { providerName: { type: 'string', description: - "The name of the OAuth provider to connect (e.g., 'Slack', 'Gmail', 'Google Calendar')", + "The OAuth provider to connect. Pass the integration's provider value (e.g. `google-email`, `slack`); the service display name or providerId resolves case-insensitively/fuzzily, so avoid bare base providers like `google`.", }, }, required: ['providerName'], @@ -1951,14 +2613,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { resources: { type: 'array', - description: 'Array of resources to open. Each item must have type and id.', + description: + 'Array of resources to open. Each item must have type and either id or, for files, path.', items: { type: 'object', properties: { id: { + type: 'string', + description: 'Canonical resource ID for non-file resources.', + }, + path: { type: 'string', description: - 'Canonical resource ID. For type "file" this must be a UUID from the workspace file meta.json "id" field—never a VFS path or display name.', + 'Encoded VFS path for type "file" (percent-encoded per segment, e.g. "files/Reports/Q4%20Report.pdf"). Copy it verbatim from glob/read/workspace context output — do not decode it to a display name or re-encode it.', }, type: { type: 'string', @@ -1966,7 +2633,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, - required: ['type', 'id'], + required: ['type'], }, }, }, @@ -1974,6 +2641,136 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + promote_to_live: { + parameters: { + type: 'object', + properties: { + version: { + type: 'number', + description: + 'The numeric deployment version number to promote to live (e.g. 5). "live" is not accepted here — pass the version number (use load_deployment to change the draft).', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], + }, + resultSchema: undefined, + }, + query_logs: { + parameters: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: "Optional (view='full'): only return this block's span subtree.", + }, + blockName: { + type: 'string', + description: "Optional (view='full'): only return spans for this block name.", + }, + costOperator: { + type: 'string', + description: "Filter (view='list'): comparison operator for cost.", + enum: ['=', '>', '<', '>=', '<=', '!='], + }, + costValue: { + type: 'number', + description: "Filter (view='list'): cost threshold paired with costOperator.", + }, + cursor: { + type: 'string', + description: "Pagination cursor (view='list') from a prior response's nextCursor.", + }, + durationOperator: { + type: 'string', + description: "Filter (view='list'): comparison operator for duration (ms).", + enum: ['=', '>', '<', '>=', '<=', '!='], + }, + durationValue: { + type: 'number', + description: + "Filter (view='list'): duration threshold (ms) paired with durationOperator.", + }, + endDate: { + type: 'string', + description: "Filter (view='list'): ISO end of the time range.", + }, + executionId: { + type: 'string', + description: + "Required for 'overview'/'full': the execution to read. For 'list', an optional exact-match filter.", + }, + folderIds: { + type: 'string', + description: "Filter (view='list'): comma-separated folder IDs (descendants included).", + }, + folderName: { + type: 'string', + description: "Filter (view='list'): substring match on folder name.", + }, + level: { + type: 'string', + description: + "Filter (view='list'): comma-separated levels: error, info, running, pending. Default all.", + }, + limit: { + type: 'number', + description: "Max results (view='list'), 1-200 (default 100).", + }, + pattern: { + type: 'string', + description: + "Optional separate parameter (not a 'view' value): with view 'overview' or 'full', greps the execution's trace spans (requires executionId), returning matching spans with snippets instead of the full log.", + }, + search: { + type: 'string', + description: "Filter (view='list'): substring match on executionId.", + }, + sortBy: { + type: 'string', + description: "Sort field (view='list').", + enum: ['date', 'duration', 'cost', 'status'], + }, + sortOrder: { + type: 'string', + description: "Sort order (view='list').", + enum: ['asc', 'desc'], + }, + startDate: { + type: 'string', + description: "Filter (view='list'): ISO start of the time range.", + }, + triggers: { + type: 'string', + description: "Filter (view='list'): comma-separated trigger types.", + }, + view: { + type: 'string', + description: + "Disclosure level: 'list' (summaries), 'overview' (one execution's trace tree, no I/O), or 'full' (one execution's trace spans with I/O).", + enum: ['list', 'overview', 'full'], + }, + workflowIds: { + type: 'string', + description: "Filter (view='list'): comma-separated workflow IDs.", + }, + workflowName: { + type: 'string', + description: "Filter (view='list'): substring match on workflow name.", + }, + workspaceId: { + type: 'string', + description: 'Workspace ID to scope to.', + }, + }, + required: ['view'], + }, + resultSchema: undefined, + }, read: { parameters: { type: 'object', @@ -1994,7 +2791,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - "Path to the file to read (e.g. 'workflows/My Workflow/state.json' or 'workflows/Projects/Q1/My Workflow/state.json').", + "Path to the VFS resource to read (e.g. 'workflows/My%20Workflow/state.json', 'files/Q4%20Report.pdf/content' for file bytes/parsed text, or 'uploads/data.csv' for a chat upload). Copy paths verbatim from glob/grep/read output.", }, }, required: ['path'], @@ -2005,11 +2802,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + versionDescription: { + type: 'string', + description: + 'REQUIRED: a concise (1-3 sentence) description of what changed in this deployment version. If unsure what changed, call diff_workflows(ref1: "live", ref2: "draft") first.', + }, + versionName: { + type: 'string', + description: + 'REQUIRED: a short human-readable name/label for this deployment version, shown in deployment history.', + }, workflowId: { type: 'string', description: 'Workflow ID to redeploy (required in workspace context)', }, }, + required: ['versionDescription', 'versionName'], }, resultSchema: { type: 'object', @@ -2073,17 +2881,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - fileId: { - type: 'string', - description: 'Canonical workspace file ID of the file to rename.', - }, newName: { type: 'string', description: 'New filename including extension, e.g. "draft_v2.md". Use move_file to move files between folders.', }, + path: { + type: 'string', + description: + 'Canonical workspace file VFS path to rename, e.g. "files/Reports/draft.md".', + }, }, - required: ['fileId', 'newName'], + required: ['path', 'newName'], }, resultSchema: { type: 'object', @@ -2108,16 +2917,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - folderId: { - type: 'string', - description: 'The workspace file-folder ID to rename.', - }, name: { type: 'string', description: 'New folder name.', }, + path: { + type: 'string', + description: 'Canonical folder VFS path to rename, e.g. "files/Reports/Old".', + }, }, - required: ['folderId', 'name'], + required: ['path', 'name'], }, resultSchema: undefined, }, @@ -2192,23 +3001,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - revert_to_version: { - parameters: { - type: 'object', - properties: { - version: { - type: 'number', - description: 'The deployment version number to revert to', - }, - workflowId: { - type: 'string', - description: 'The workflow ID', - }, - }, - required: ['workflowId', 'version'], - }, - resultSchema: undefined, - }, run: { parameters: { properties: { @@ -2294,16 +3086,26 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + inputFromExecutionId: { + type: 'string', + description: + 'Reuse the recorded input from a past execution of this workflow (from query_logs) instead of supplying workflow_input — handy for replaying a run without retyping inputs. The reused input is re-validated against the trigger. Mutually exclusive with workflow_input and useMockPayload.', + }, triggerBlockId: { type: 'string', description: - 'Optional trigger block ID when the workflow has multiple entrypoints and you need to target a specific one.', + 'Trigger block ID to run from (from get_workflow_run_options). Required when the workflow has multiple entrypoints.', }, useDeployedState: { type: 'boolean', description: 'When true, runs the deployed version instead of the live draft. Default: false (draft).', }, + useMockPayload: { + type: 'boolean', + description: + "When true, run with the trigger's generated mock payload instead of workflow_input. Prefer building your own workflow_input; use this only when you can't.", + }, workflowId: { type: 'string', description: @@ -2311,10 +3113,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, workflow_input: { type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', + description: + "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, - required: ['workflow_input'], }, resultSchema: undefined, }, @@ -2322,6 +3124,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + inputFromExecutionId: { + type: 'string', + description: + 'Reuse the recorded input from a past execution of this workflow (from query_logs) instead of supplying workflow_input. The reused input is re-validated against the trigger. Mutually exclusive with workflow_input and useMockPayload.', + }, stopAfterBlockId: { type: 'string', description: 'The block ID to stop after. Execution halts once this block completes.', @@ -2329,13 +3136,18 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { triggerBlockId: { type: 'string', description: - 'Optional trigger block ID when the workflow has multiple entrypoints and you need to target a specific one.', + 'Trigger block ID to run from (from get_workflow_run_options). Required when the workflow has multiple entrypoints.', }, useDeployedState: { type: 'boolean', description: 'When true, runs the deployed version instead of the live draft. Default: false (draft).', }, + useMockPayload: { + type: 'boolean', + description: + "When true, run with the trigger's generated mock payload instead of workflow_input. Prefer building your own workflow_input; use this only when you can't.", + }, workflowId: { type: 'string', description: @@ -2343,7 +3155,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, workflow_input: { type: 'object', - description: 'JSON object with key-value mappings where each key is an input field name', + description: + "JSON object matching the target trigger's inputSchema (from get_workflow_run_options). For external/webhook triggers this is the event payload; for API/Input triggers it is the form fields.", }, }, required: ['stopAfterBlockId'], @@ -2420,7 +3233,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'news', 'tweet', 'github', - 'paper', 'company', 'research paper', 'linkedin profile', @@ -2443,7 +3255,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "pricing changes" or "Slack webhook docs", not a full sentence like "Searching online for pricing changes".', + "Required short UI label fragment (e.g. 'Slack integrations'), not a full sentence.", }, }, required: ['query', 'toolTitle'], @@ -2540,6 +3352,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { name: { type: 'string', + description: 'Variable name.', }, operation: { type: 'string', @@ -2547,13 +3360,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, type: { type: 'string', + description: 'Variable type. Required for add/edit; ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', + description: 'Variable value. Required for add/edit; ignored for delete.', }, }, - required: ['operation', 'name', 'type', 'value'], + required: ['operation', 'name'], }, }, workflowId: { @@ -2593,24 +3408,79 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - tool_search_tool_regex: { + touch_plan: { parameters: { + type: 'object', properties: { - case_insensitive: { - description: 'Whether the regex should be case-insensitive (default true).', - type: 'boolean', + name: { + type: 'string', + description: + 'Plan file name or relative path under .plans, e.g. "implementation.md" or "phase-1/implementation.md". If no extension is supplied, ".md" is appended.', }, - max_results: { - description: 'Maximum number of tools to return (optional).', - type: 'integer', + scope: { + type: 'string', + description: + 'Plan scope. Use "workspace" for root .plans/** main-agent plans. Use "workflow" for workflows/{workflow}/.plans/** subplans. If omitted with workflowPath, workflow scope is assumed; otherwise workspace scope is assumed.', + enum: ['workspace', 'workflow'], }, - pattern: { - description: 'Regular expression to match tool names or descriptions.', + title: { + type: 'string', + description: 'Optional short user-visible label for the plan creation.', + }, + workflowPath: { + type: 'string', + description: + 'Required for scope "workflow". Canonical workflow VFS path, e.g. "workflows/My%20Workflow" or "workflows/Folder/My%20Workflow". Copy paths verbatim from glob/read/grep output — they are percent-encoded per segment (spaces are %20, an in-name slash is %2F; parentheses and dots stay literal). Both the encoded path and the plain name resolve, so copy the returned path exactly rather than retyping or decoding it. Do not use workflow IDs.', + }, + }, + required: ['name'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Contains id, name, scope, vfsPath, backingVfsPath, and workflowId for workflow plans. Use vfsPath for follow-up workspace_file calls.', + }, + message: { type: 'string', + description: 'Human-readable outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the plan file was created.', }, }, - required: ['pattern'], + required: ['success', 'message'], + }, + }, + update_deployment_version: { + parameters: { type: 'object', + properties: { + description: { + type: 'string', + description: + 'New description for the deployment version. Provide name and/or description.', + }, + name: { + type: 'string', + description: + 'New name/label for the deployment version. Provide name and/or description.', + }, + version: { + type: 'number', + description: + 'The numeric deployment version number to update (use get_deployment_log to find it).', + }, + workflowId: { + type: 'string', + description: + 'Optional workflow ID. If not provided, uses the current workflow in context.', + }, + }, + required: ['version'], }, resultSchema: undefined, }, @@ -2667,7 +3537,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, correct_value: { type: 'string', - description: "The correct value to replace the wrong one (for 'correct' operation)", + description: + "The correct value to replace the wrong one (for 'correct' operation). Requires `key` (the memory to replace).", }, key: { type: 'string', @@ -2765,15 +3636,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: "Enrichment registry ID for add_enrichment. Discover the available IDs (and each one's inputs/outputs) via list_enrichments first — don't hardcode. Examples: work-email, phone-number, company-domain, company-info.", }, - fileId: { - type: 'string', - description: - 'Canonical workspace file ID for create_from_file/import_file. Discover via read("files/{path}/{name}/meta.json") or glob("files/**/meta.json") / glob("files/by-id/*/meta.json").', - }, filePath: { type: 'string', description: - 'Legacy workspace file reference for create_from_file/import_file. Prefer fileId.', + 'Canonical workspace file VFS path for create_from_file/import_file, e.g. files/{path}/{name}.', }, filter: { type: 'object', @@ -2863,7 +3729,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, newName: { type: 'string', - description: 'New column name (required for rename_column)', + description: + 'New name. Required for rename_column (new column name) and for rename (new table name).', }, newType: { type: 'string', @@ -3022,6 +3889,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'get', 'get_schema', 'delete', + 'rename', 'insert_row', 'batch_insert_rows', 'get_row', @@ -3087,22 +3955,17 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, target: { type: 'object', - description: 'Explicit file target. Use kind=file_id + fileId for existing files.', + description: 'Explicit file target. Use kind=path + path for existing files.', properties: { - fileId: { + kind: { type: 'string', - description: - 'Canonical existing workspace file ID. Required when target.kind=file_id.', + description: 'How the file target is identified.', + enum: ['path'], }, - fileName: { + path: { type: 'string', description: - 'Plain workspace filename including extension, e.g. "main.py" or "report.docx". Required when target.kind=new_file.', - }, - kind: { - type: 'string', - description: 'How the file target is identified.', - enum: ['new_file', 'file_id'], + 'Canonical existing workspace file VFS path, e.g. "files/Reports/report.md". Required when target.kind=path.', }, }, required: ['kind'], @@ -3181,11 +4044,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - newName: { - type: 'string', - description: - 'New file name for rename. Must be a plain workspace filename like "main.py".', - }, }, required: ['operation', 'target', 'title'], }, diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 84bf88d82c4..d3f930bd45a 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -20,8 +20,15 @@ export const TraceAttr = { AbortFound: 'abort.found', AbortRedisResult: 'abort.redis_result', AnalyticsAborted: 'analytics.aborted', + AnalyticsBilledCacheReadCost: 'analytics.billed_cache_read_cost', + AnalyticsBilledCacheWriteCost: 'analytics.billed_cache_write_cost', + AnalyticsBilledInputCost: 'analytics.billed_input_cost', + AnalyticsBilledOutputCost: 'analytics.billed_output_cost', AnalyticsBilledTotalCost: 'analytics.billed_total_cost', + AnalyticsCacheAttemptedRequests: 'analytics.cache_attempted_requests', + AnalyticsCacheHitRequests: 'analytics.cache_hit_requests', AnalyticsCacheReadTokens: 'analytics.cache_read_tokens', + AnalyticsCacheWriteRequests: 'analytics.cache_write_requests', AnalyticsCacheWriteTokens: 'analytics.cache_write_tokens', AnalyticsCustomerType: 'analytics.customer_type', AnalyticsDurationMs: 'analytics.duration_ms', @@ -30,6 +37,11 @@ export const TraceAttr = { AnalyticsModel: 'analytics.model', AnalyticsOutputTokens: 'analytics.output_tokens', AnalyticsProvider: 'analytics.provider', + AnalyticsRawCacheReadCost: 'analytics.raw_cache_read_cost', + AnalyticsRawCacheWriteCost: 'analytics.raw_cache_write_cost', + AnalyticsRawInputCost: 'analytics.raw_input_cost', + AnalyticsRawOutputCost: 'analytics.raw_output_cost', + AnalyticsRawTotalCost: 'analytics.raw_total_cost', AnalyticsSource: 'analytics.source', AnalyticsToolCallCount: 'analytics.tool_call_count', ApiKeyId: 'api_key.id', @@ -174,8 +186,12 @@ export const TraceAttr = { CopilotOutputFileBytes: 'copilot.output_file.bytes', CopilotOutputFileFormat: 'copilot.output_file.format', CopilotOutputFileId: 'copilot.output_file.id', + CopilotOutputFileMimeType: 'copilot.output_file.mime_type', + CopilotOutputFileMode: 'copilot.output_file.mode', CopilotOutputFileName: 'copilot.output_file.name', CopilotOutputFileOutcome: 'copilot.output_file.outcome', + CopilotOutputFilePath: 'copilot.output_file.path', + CopilotOutputFileSandboxPath: 'copilot.output_file.sandbox_path', CopilotPendingStreamWaitMs: 'copilot.pending_stream.wait_ms', CopilotPrefetch: 'copilot.prefetch', CopilotPublisherClientDisconnected: 'copilot.publisher.client_disconnected', @@ -186,6 +202,7 @@ export const TraceAttr = { CopilotRecoveryRequestedAfterSeq: 'copilot.recovery.requested_after_seq', CopilotRequestCancelReason: 'copilot.request.cancel_reason', CopilotRequestOutcome: 'copilot.request.outcome', + CopilotRequestWillRetryOnStreamError: 'copilot.request.will_retry_on_stream_error', CopilotResourceAttachmentsCount: 'copilot.resource_attachments.count', CopilotResourcesAborted: 'copilot.resources.aborted', CopilotResourcesOp: 'copilot.resources.op', @@ -303,13 +320,17 @@ export const TraceAttr = { FunctionName: 'function.name', GenAiAgentId: 'gen_ai.agent.id', GenAiAgentName: 'gen_ai.agent.name', + GenAiCacheOutcome: 'gen_ai.cache.outcome', + GenAiCacheTokenType: 'gen_ai.cache.token.type', GenAiCostInput: 'gen_ai.cost.input', GenAiCostOutput: 'gen_ai.cost.output', GenAiCostTotal: 'gen_ai.cost.total', GenAiInputMessages: 'gen_ai.input.messages', GenAiOperationName: 'gen_ai.operation.name', GenAiOutputMessages: 'gen_ai.output.messages', + GenAiProviderName: 'gen_ai.provider.name', GenAiRequestAssistantMessages: 'gen_ai.request.assistant_messages', + GenAiRequestCacheableSystemBlocks: 'gen_ai.request.cacheable_system_blocks', GenAiRequestContentBlocks: 'gen_ai.request.content_blocks', GenAiRequestHasCacheControl: 'gen_ai.request.has_cache_control', GenAiRequestImageBlocks: 'gen_ai.request.image_blocks', @@ -317,12 +338,56 @@ export const TraceAttr = { GenAiRequestMaxMessageBlocks: 'gen_ai.request.max_message_blocks', GenAiRequestMessagesCount: 'gen_ai.request.messages.count', GenAiRequestModel: 'gen_ai.request.model', + GenAiRequestPromptCacheBreakpointCreated: 'gen_ai.request.prompt_cache.breakpoint.created', + GenAiRequestPromptCacheBreakpointDynamicWriteSuppressReason: + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppress_reason', + GenAiRequestPromptCacheBreakpointDynamicWriteSuppressed: + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppressed', + GenAiRequestPromptCacheBreakpointEligible: 'gen_ai.request.prompt_cache.breakpoint.eligible', + GenAiRequestPromptCacheBreakpointKind: 'gen_ai.request.prompt_cache.breakpoint.kind', + GenAiRequestPromptCacheBreakpointMinimumTokens: + 'gen_ai.request.prompt_cache.breakpoint.minimum_tokens', + GenAiRequestPromptCacheBreakpointPrefixTokensEstimated: + 'gen_ai.request.prompt_cache.breakpoint.prefix_tokens_estimated', + GenAiRequestPromptCacheBreakpointPreviousBoundaryTokens: + 'gen_ai.request.prompt_cache.breakpoint.previous_boundary_tokens', + GenAiRequestPromptCacheBreakpointSection: 'gen_ai.request.prompt_cache.breakpoint.section', + GenAiRequestPromptCacheBreakpointSkipReason: 'gen_ai.request.prompt_cache.breakpoint.skip_reason', + GenAiRequestPromptCacheBreakpointTargetIndex: + 'gen_ai.request.prompt_cache.breakpoint.target_index', + GenAiRequestPromptCacheBreakpointTargetRole: 'gen_ai.request.prompt_cache.breakpoint.target_role', + GenAiRequestPromptCacheBreakpointTargetType: 'gen_ai.request.prompt_cache.breakpoint.target_type', + GenAiRequestPromptCacheBreakpointThresholdTokens: + 'gen_ai.request.prompt_cache.breakpoint.threshold_tokens', + GenAiRequestPromptCacheBreakpointTtl: 'gen_ai.request.prompt_cache.breakpoint.ttl', + GenAiRequestPromptCacheBreakpointsCount: 'gen_ai.request.prompt_cache.breakpoints.count', + GenAiRequestPromptCacheBreakpointsCreated: 'gen_ai.request.prompt_cache.breakpoints.created', + GenAiRequestPromptCacheDynamicMinimumTokens: 'gen_ai.request.prompt_cache.dynamic.minimum_tokens', + GenAiRequestPromptCacheDynamicPrefixTokensEstimated: + 'gen_ai.request.prompt_cache.dynamic.prefix_tokens_estimated', + GenAiRequestPromptCacheDynamicPreviousBoundaryTokens: + 'gen_ai.request.prompt_cache.dynamic.previous_boundary_tokens', + GenAiRequestPromptCacheDynamicSkipReason: 'gen_ai.request.prompt_cache.dynamic.skip_reason', + GenAiRequestPromptCacheDynamicTargetType: 'gen_ai.request.prompt_cache.dynamic.target_type', + GenAiRequestPromptCacheDynamicThresholdTokens: + 'gen_ai.request.prompt_cache.dynamic.threshold_tokens', + GenAiRequestPromptCacheDynamicTtl: 'gen_ai.request.prompt_cache.dynamic.ttl', + GenAiRequestPromptCacheDynamicUserEligible: 'gen_ai.request.prompt_cache.dynamic_user_eligible', + GenAiRequestPromptCacheDynamicUserSkipReason: + 'gen_ai.request.prompt_cache.dynamic_user_skip_reason', + GenAiRequestPromptCacheStaticTtl: 'gen_ai.request.prompt_cache.static.ttl', + GenAiRequestPromptCacheStaticSystem: 'gen_ai.request.prompt_cache.static_system', + GenAiRequestPromptCacheStaticTools: 'gen_ai.request.prompt_cache.static_tools', + GenAiRequestRuntimeContextChars: 'gen_ai.request.runtime_context_chars', + GenAiRequestRuntimeContextMessages: 'gen_ai.request.runtime_context_messages', + GenAiRequestSystemBlocks: 'gen_ai.request.system_blocks', GenAiRequestSystemChars: 'gen_ai.request.system_chars', GenAiRequestTextBlocks: 'gen_ai.request.text_blocks', GenAiRequestToolResultBlocks: 'gen_ai.request.tool_result_blocks', GenAiRequestToolUseBlocks: 'gen_ai.request.tool_use_blocks', GenAiRequestToolsCount: 'gen_ai.request.tools.count', GenAiRequestUserMessages: 'gen_ai.request.user_messages', + GenAiResponseModel: 'gen_ai.response.model', GenAiStreamPhaseTextBytes: 'gen_ai.stream.phase.text.bytes', GenAiStreamPhaseTextChunks: 'gen_ai.stream.phase.text.chunks', GenAiStreamPhaseTextFirstMs: 'gen_ai.stream.phase.text.first_ms', @@ -336,8 +401,11 @@ export const TraceAttr = { GenAiStreamPhaseToolArgsFirstMs: 'gen_ai.stream.phase.tool_args.first_ms', GenAiStreamPhaseToolArgsMs: 'gen_ai.stream.phase.tool_args.ms', GenAiSystem: 'gen_ai.system', + GenAiTokenType: 'gen_ai.token.type', GenAiToolName: 'gen_ai.tool.name', + GenAiUsageCacheCreationInputTokens: 'gen_ai.usage.cache_creation.input_tokens', GenAiUsageCacheCreationTokens: 'gen_ai.usage.cache_creation_tokens', + GenAiUsageCacheReadInputTokens: 'gen_ai.usage.cache_read.input_tokens', GenAiUsageCacheReadTokens: 'gen_ai.usage.cache_read_tokens', GenAiUsageInputTokens: 'gen_ai.usage.input_tokens', GenAiUsageOutputTokens: 'gen_ai.usage.output_tokens', @@ -404,6 +472,10 @@ export const TraceAttr = { PrefsToolCount: 'prefs.tool_count', ProcessingChunkSize: 'processing.chunk_size', ProcessingRecipe: 'processing.recipe', + PromptCacheableBlocks: 'prompt.cacheable_blocks', + PromptSet: 'prompt.set', + PromptSystemBlocks: 'prompt.system_blocks', + PromptSystemChars: 'prompt.system_chars', ProviderId: 'provider.id', RateLimitAttempt: 'rate_limit.attempt', RateLimitCount: 'rate_limit.count', @@ -476,14 +548,17 @@ export const TraceAttr = { ToolAsyncWaiterPubsubDeliveries: 'tool.async_waiter.pubsub_deliveries', ToolAsyncWaiterResolution: 'tool.async_waiter.resolution', ToolCallId: 'tool.call_id', + ToolClientCount: 'tool.client.count', ToolClientExecutable: 'tool.client_executable', ToolCompletionReceived: 'tool.completion.received', ToolConfirmationStatus: 'tool.confirmation.status', + ToolDeferredCount: 'tool.deferred.count', ToolDurationMs: 'tool.duration_ms', ToolErrorKind: 'tool.error_kind', ToolExecutor: 'tool.executor', ToolExternalService: 'tool.external.service', ToolId: 'tool.id', + ToolLoadedCount: 'tool.loaded.count', ToolName: 'tool.name', ToolOutcome: 'tool.outcome', ToolOutcomeMessage: 'tool.outcome.message', @@ -498,9 +573,14 @@ export const TraceAttr = { ToolStoreStatus: 'tool.store_status', ToolSync: 'tool.sync', ToolTimeoutMs: 'tool.timeout_ms', + ToolVisibleCount: 'tool.visible.count', + ToolVisibleNames: 'tool.visible.names', TraceAborted: 'trace.aborted', TraceBilledTotalCost: 'trace.billed_total_cost', + TraceCacheAttemptedRequests: 'trace.cache_attempted_requests', + TraceCacheHitRequests: 'trace.cache_hit_requests', TraceCacheReadTokens: 'trace.cache_read_tokens', + TraceCacheWriteRequests: 'trace.cache_write_requests', TraceCacheWriteTokens: 'trace.cache_write_tokens', TraceDurationMs: 'trace.duration_ms', TraceError: 'trace.error', @@ -547,8 +627,15 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'abort.found', 'abort.redis_result', 'analytics.aborted', + 'analytics.billed_cache_read_cost', + 'analytics.billed_cache_write_cost', + 'analytics.billed_input_cost', + 'analytics.billed_output_cost', 'analytics.billed_total_cost', + 'analytics.cache_attempted_requests', + 'analytics.cache_hit_requests', 'analytics.cache_read_tokens', + 'analytics.cache_write_requests', 'analytics.cache_write_tokens', 'analytics.customer_type', 'analytics.duration_ms', @@ -557,6 +644,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'analytics.model', 'analytics.output_tokens', 'analytics.provider', + 'analytics.raw_cache_read_cost', + 'analytics.raw_cache_write_cost', + 'analytics.raw_input_cost', + 'analytics.raw_output_cost', + 'analytics.raw_total_cost', 'analytics.source', 'analytics.tool_call_count', 'api_key.id', @@ -701,8 +793,12 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.output_file.bytes', 'copilot.output_file.format', 'copilot.output_file.id', + 'copilot.output_file.mime_type', + 'copilot.output_file.mode', 'copilot.output_file.name', 'copilot.output_file.outcome', + 'copilot.output_file.path', + 'copilot.output_file.sandbox_path', 'copilot.pending_stream.wait_ms', 'copilot.prefetch', 'copilot.publisher.client_disconnected', @@ -713,6 +809,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.recovery.requested_after_seq', 'copilot.request.cancel_reason', 'copilot.request.outcome', + 'copilot.request.will_retry_on_stream_error', 'copilot.resource_attachments.count', 'copilot.resources.aborted', 'copilot.resources.op', @@ -830,13 +927,17 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'function.name', 'gen_ai.agent.id', 'gen_ai.agent.name', + 'gen_ai.cache.outcome', + 'gen_ai.cache.token.type', 'gen_ai.cost.input', 'gen_ai.cost.output', 'gen_ai.cost.total', 'gen_ai.input.messages', 'gen_ai.operation.name', 'gen_ai.output.messages', + 'gen_ai.provider.name', 'gen_ai.request.assistant_messages', + 'gen_ai.request.cacheable_system_blocks', 'gen_ai.request.content_blocks', 'gen_ai.request.has_cache_control', 'gen_ai.request.image_blocks', @@ -844,12 +945,45 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.request.max_message_blocks', 'gen_ai.request.messages.count', 'gen_ai.request.model', + 'gen_ai.request.prompt_cache.breakpoint.created', + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppress_reason', + 'gen_ai.request.prompt_cache.breakpoint.dynamic_write_suppressed', + 'gen_ai.request.prompt_cache.breakpoint.eligible', + 'gen_ai.request.prompt_cache.breakpoint.kind', + 'gen_ai.request.prompt_cache.breakpoint.minimum_tokens', + 'gen_ai.request.prompt_cache.breakpoint.prefix_tokens_estimated', + 'gen_ai.request.prompt_cache.breakpoint.previous_boundary_tokens', + 'gen_ai.request.prompt_cache.breakpoint.section', + 'gen_ai.request.prompt_cache.breakpoint.skip_reason', + 'gen_ai.request.prompt_cache.breakpoint.target_index', + 'gen_ai.request.prompt_cache.breakpoint.target_role', + 'gen_ai.request.prompt_cache.breakpoint.target_type', + 'gen_ai.request.prompt_cache.breakpoint.threshold_tokens', + 'gen_ai.request.prompt_cache.breakpoint.ttl', + 'gen_ai.request.prompt_cache.breakpoints.count', + 'gen_ai.request.prompt_cache.breakpoints.created', + 'gen_ai.request.prompt_cache.dynamic.minimum_tokens', + 'gen_ai.request.prompt_cache.dynamic.prefix_tokens_estimated', + 'gen_ai.request.prompt_cache.dynamic.previous_boundary_tokens', + 'gen_ai.request.prompt_cache.dynamic.skip_reason', + 'gen_ai.request.prompt_cache.dynamic.target_type', + 'gen_ai.request.prompt_cache.dynamic.threshold_tokens', + 'gen_ai.request.prompt_cache.dynamic.ttl', + 'gen_ai.request.prompt_cache.dynamic_user_eligible', + 'gen_ai.request.prompt_cache.dynamic_user_skip_reason', + 'gen_ai.request.prompt_cache.static.ttl', + 'gen_ai.request.prompt_cache.static_system', + 'gen_ai.request.prompt_cache.static_tools', + 'gen_ai.request.runtime_context_chars', + 'gen_ai.request.runtime_context_messages', + 'gen_ai.request.system_blocks', 'gen_ai.request.system_chars', 'gen_ai.request.text_blocks', 'gen_ai.request.tool_result_blocks', 'gen_ai.request.tool_use_blocks', 'gen_ai.request.tools.count', 'gen_ai.request.user_messages', + 'gen_ai.response.model', 'gen_ai.stream.phase.text.bytes', 'gen_ai.stream.phase.text.chunks', 'gen_ai.stream.phase.text.first_ms', @@ -863,8 +997,11 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'gen_ai.stream.phase.tool_args.first_ms', 'gen_ai.stream.phase.tool_args.ms', 'gen_ai.system', + 'gen_ai.token.type', 'gen_ai.tool.name', + 'gen_ai.usage.cache_creation.input_tokens', 'gen_ai.usage.cache_creation_tokens', + 'gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.cache_read_tokens', 'gen_ai.usage.input_tokens', 'gen_ai.usage.output_tokens', @@ -931,6 +1068,10 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'prefs.tool_count', 'processing.chunk_size', 'processing.recipe', + 'prompt.cacheable_blocks', + 'prompt.set', + 'prompt.system_blocks', + 'prompt.system_chars', 'provider.id', 'rate_limit.attempt', 'rate_limit.count', @@ -1003,14 +1144,17 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'tool.async_waiter.pubsub_deliveries', 'tool.async_waiter.resolution', 'tool.call_id', + 'tool.client.count', 'tool.client_executable', 'tool.completion.received', 'tool.confirmation.status', + 'tool.deferred.count', 'tool.duration_ms', 'tool.error_kind', 'tool.executor', 'tool.external.service', 'tool.id', + 'tool.loaded.count', 'tool.name', 'tool.outcome', 'tool.outcome.message', @@ -1025,9 +1169,14 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'tool.store_status', 'tool.sync', 'tool.timeout_ms', + 'tool.visible.count', + 'tool.visible.names', 'trace.aborted', 'trace.billed_total_cost', + 'trace.cache_attempted_requests', + 'trace.cache_hit_requests', 'trace.cache_read_tokens', + 'trace.cache_write_requests', 'trace.cache_write_tokens', 'trace.duration_ms', 'trace.error', diff --git a/apps/sim/lib/copilot/generated/trace-events-v1.ts b/apps/sim/lib/copilot/generated/trace-events-v1.ts index b5aa8f71b2c..345606eff40 100644 --- a/apps/sim/lib/copilot/generated/trace-events-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-events-v1.ts @@ -19,6 +19,7 @@ export const TraceEvent = { CopilotVfsParseFailed: 'copilot.vfs.parse_failed', CopilotVfsResizeAttempt: 'copilot.vfs.resize_attempt', CopilotVfsResizeAttemptFailed: 'copilot.vfs.resize_attempt_failed', + GenAiPromptCacheBreakpoint: 'gen_ai.prompt_cache.breakpoint', LlmInvokeSent: 'llm.invoke.sent', LlmStreamFirstChunk: 'llm.stream.first_chunk', LlmStreamOpened: 'llm.stream.opened', @@ -41,6 +42,7 @@ export const TraceEventValues: readonly TraceEventValue[] = [ 'copilot.vfs.parse_failed', 'copilot.vfs.resize_attempt', 'copilot.vfs.resize_attempt_failed', + 'gen_ai.prompt_cache.breakpoint', 'llm.invoke.sent', 'llm.stream.first_chunk', 'llm.stream.opened', diff --git a/apps/sim/lib/copilot/integration-tools.ts b/apps/sim/lib/copilot/integration-tools.ts new file mode 100644 index 00000000000..c1c93cc536e --- /dev/null +++ b/apps/sim/lib/copilot/integration-tools.ts @@ -0,0 +1,68 @@ +import { getAllBlocks } from '@/blocks/registry' +import { tools as toolRegistry } from '@/tools/registry' +import type { ToolConfig } from '@/tools/types' +import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' + +export interface ExposedIntegrationTool { + /** + * Full registry tool id — also the agent-callable id and the schema `id` + * field (e.g. gmail_read_v2). No stripping: discovery, the schema id, and the + * callable id are all this exact value, matching the block's tools.access. + */ + toolId: string + config: ToolConfig + /** Service directory name, e.g. "gmail". */ + service: string + /** Operation stem within the service (used for the VFS path filename), e.g. "read". */ + operation: string +} + +let cached: ExposedIntegrationTool[] | null = null + +/** + * Returns the canonical set of integration tools exposed to the copilot agent: + * the latest version of each operation owned by a visible (non-hideFromToolbar) + * block. + * + * This is the single source of truth shared by VFS discovery + * (components/integrations/**) and the deferred callable-tool payload, so the + * agent can call exactly what it can discover — no orphan callable tools, and no + * version drift between what the VFS shows and what is loadable. + */ +export function getExposedIntegrationTools(): ExposedIntegrationTool[] { + if (cached) return cached + + // Map the tool ids each visible block exposes (both the raw id and its + // version-stripped base name) to that block's service directory. + const toolToService = new Map() + for (const block of getAllBlocks()) { + if (block.hideFromToolbar) continue + if (!block.tools?.access) continue + const service = stripVersionSuffix(block.type) + for (const toolId of block.tools.access) { + toolToService.set(toolId, service) + toolToService.set(stripVersionSuffix(toolId), service) + } + } + + const exposed: ExposedIntegrationTool[] = [] + const seen = new Set() + for (const [toolId, config] of Object.entries(getLatestVersionTools(toolRegistry))) { + const baseName = stripVersionSuffix(toolId) + const service = toolToService.get(toolId) ?? toolToService.get(baseName) + if (!service) continue + if (seen.has(baseName)) continue + seen.add(baseName) + const prefix = `${service}_` + const operation = baseName.startsWith(prefix) ? baseName.slice(prefix.length) : baseName + exposed.push({ toolId, config, service, operation }) + } + + cached = exposed + return exposed +} + +/** Test-only: clears the memoized set so registry changes are picked up. */ +export function resetExposedIntegrationToolsCache(): void { + cached = null +} diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts index e6ad4807eee..9ee241ba183 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -12,6 +12,8 @@ export function createStreamingContext(overrides?: Partial): S runId: undefined, messageId: generateId(), accumulatedContent: '', + finalAssistantContent: '', + sawMainToolCall: false, contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts index d570954e9e9..1cbbd5a5103 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -16,6 +16,8 @@ function makeContext(): StreamingContext { runId: undefined, messageId: 'msg-1', accumulatedContent: '', + finalAssistantContent: '', + sawMainToolCall: false, contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index f9c63d13027..46c40413c01 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -26,6 +26,7 @@ import { buildFilePreviewText, loadWorkspaceFileTextForPreview, } from '@/lib/copilot/tools/server/files/file-preview' +import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('CopilotFilePreviewAdapter') @@ -64,6 +65,27 @@ function toPreviewTargetKind(kind: string | undefined): FilePreviewTargetKind | return kind === 'new_file' || kind === 'file_id' ? kind : undefined } +async function resolvePreviewTarget(args: { + workspaceId?: string + target: FileIntent['target'] +}): Promise { + if (args.target.kind !== 'path' || !args.workspaceId || !args.target.path) { + return args.target + } + + const file = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + if (!file) { + return args.target + } + + return { + kind: 'file_id', + fileId: file.id, + fileName: args.target.fileName ?? file.name, + path: args.target.path, + } +} + function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undefined { const args = asJsonRecord(value) if (!args) { @@ -79,6 +101,7 @@ function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undef const fileId = typeof target.fileId === 'string' ? target.fileId : undefined const fileName = typeof target.fileName === 'string' ? target.fileName : undefined + const path = typeof target.path === 'string' ? target.path : undefined const title = typeof args.title === 'string' ? args.title : undefined const contentType = typeof args.contentType === 'string' ? args.contentType : undefined const edit = asJsonRecord(args.edit) @@ -89,6 +112,7 @@ function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undef kind: targetKind, ...(fileId ? { fileId } : {}), ...(fileName ? { fileName } : {}), + ...(path ? { path } : {}), }, ...(title ? { title } : {}), ...(contentType ? { contentType } : {}), @@ -96,6 +120,41 @@ function parseWorkspaceFileArgs(value: unknown): ParsedWorkspaceFileArgs | undef } } +function extractWorkspaceFileResult(output: unknown): { fileId?: string; fileName?: string } { + const candidates: JsonRecord[] = [] + const root = asJsonRecord(output) + if (root) { + candidates.push(root) + const rootData = asJsonRecord(root.data) + if (rootData) candidates.push(rootData) + const rootOutput = asJsonRecord(root.output) + if (rootOutput) { + candidates.push(rootOutput) + const outputData = asJsonRecord(rootOutput.data) + if (outputData) candidates.push(outputData) + } + } + + for (const candidate of candidates) { + const fileId = + typeof candidate.id === 'string' + ? candidate.id + : typeof candidate.fileId === 'string' + ? candidate.fileId + : undefined + if (!fileId) continue + + const fileName = + typeof candidate.name === 'string' + ? candidate.name + : typeof candidate.fileName === 'string' + ? candidate.fileName + : undefined + return { fileId, fileName } + } + return {} +} + export function decodeJsonStringPrefix(input: string): string { let output = '' for (let i = 0; i < input.length; i++) { @@ -293,7 +352,11 @@ export async function processFilePreviewStreamEvent(input: { const toolCallId = streamEvent.payload.toolCallId const parsedArgs = parseWorkspaceFileArgs(streamEvent.payload.arguments) if (toolCallId && parsedArgs) { - const { operation, target, title, contentType, edit } = parsedArgs + const { operation, title, contentType, edit } = parsedArgs + const target = await resolvePreviewTarget({ + workspaceId: execContext.workspaceId, + target: parsedArgs.target, + }) const previewTargetKind = toPreviewTargetKind(target.kind) const { fileId, fileName } = target @@ -384,6 +447,75 @@ export async function processFilePreviewStreamEvent(input: { } } + if ( + isToolResultStreamEvent(streamEvent) && + streamEvent.payload.toolName === 'workspace_file' && + context.activeFileIntent && + isContentOperation(context.activeFileIntent.operation) + ) { + const result = extractWorkspaceFileResult(streamEvent.payload.output) + if (result.fileId && context.activeFileIntent.target.kind === 'path') { + context.activeFileIntent = { + ...context.activeFileIntent, + target: { + kind: 'file_id', + fileId: result.fileId, + fileName: result.fileName ?? context.activeFileIntent.target.fileName, + path: context.activeFileIntent.target.path, + }, + } + + let previewBaseContent: string | undefined + if ( + execContext.workspaceId && + (context.activeFileIntent.operation === 'append' || + context.activeFileIntent.operation === 'patch') + ) { + previewBaseContent = await loadWorkspaceFileTextForPreview( + execContext.workspaceId, + result.fileId + ) + } + + let session = buildPreviewSessionFromIntent(streamId, context.activeFileIntent) + if (previewBaseContent !== undefined) { + session = { ...session, baseContent: previewBaseContent } + } + filePreviewState.set(context.activeFileIntent.toolCallId, { + session, + lastEmittedPreviewText: '', + lastSnapshotAt: 0, + }) + await persistFilePreviewSession(session) + + await emitPreviewEvent(streamEvent, options, { + toolCallId: context.activeFileIntent.toolCallId, + toolName: 'workspace_file', + previewPhase: 'file_preview_start', + }) + await emitPreviewEvent(streamEvent, options, { + toolCallId: context.activeFileIntent.toolCallId, + toolName: 'workspace_file', + previewPhase: 'file_preview_target', + operation: context.activeFileIntent.operation, + target: { + kind: 'file_id', + fileId: result.fileId, + ...(result.fileName ? { fileName: result.fileName } : {}), + }, + ...(context.activeFileIntent.title ? { title: context.activeFileIntent.title } : {}), + }) + if (context.activeFileIntent.edit) { + await emitPreviewEvent(streamEvent, options, { + toolCallId: context.activeFileIntent.toolCallId, + toolName: 'workspace_file', + previewPhase: 'file_preview_edit_meta', + edit: context.activeFileIntent.edit, + }) + } + } + } + if ( isToolResultStreamEvent(streamEvent) && streamEvent.payload.toolName === 'workspace_file' && diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 8d08f755d8f..a152fad1b9d 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -18,6 +18,23 @@ vi.mock('@/lib/copilot/request/session', async () => { return { ...actual, hasAbortMarker: vi.fn().mockResolvedValue(false), + upsertFilePreviewSession: vi.fn(async (session) => session), + } +}) + +const resolveWorkspaceFileReferenceMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +})) + +vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { + const actual = await vi.importActual< + typeof import('@/lib/copilot/tools/server/files/file-preview') + >('@/lib/copilot/tools/server/files/file-preview') + return { + ...actual, + loadWorkspaceFileTextForPreview: vi.fn().mockResolvedValue(''), } }) @@ -71,6 +88,8 @@ function createStreamingContext(): StreamingContext { return { messageId: 'msg-1', accumulatedContent: '', + finalAssistantContent: '', + sawMainToolCall: false, contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), @@ -93,6 +112,8 @@ function createStreamingContext(): StreamingContext { describe('copilot go stream helpers', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) + resolveWorkspaceFileReferenceMock.mockReset() + resolveWorkspaceFileReferenceMock.mockResolvedValue(null) }) afterEach(() => { @@ -140,6 +161,270 @@ describe('copilot go stream helpers', () => { }) }) + it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-1', + name: 'notes.md', + }) + + const workspaceFileCall = createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'workspace-file-path-1', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + arguments: { + operation: 'update', + target: { kind: 'path', path: 'files/notes.md' }, + title: 'Update notes', + }, + }, + }) + const workspaceFileResult = createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'workspace-file-path-1', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: { + success: true, + data: { id: 'file-1', name: 'notes.md', operation: 'update' }, + }, + }, + }) + const editContentDelta = createEvent({ + streamId: 'stream-1', + cursor: '3', + seq: 3, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-path-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.args_delta, + argumentsDelta: '{"content":"hello world', + }, + }) + const editContentResult = createEvent({ + streamId: 'stream-1', + cursor: '4', + seq: 4, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-path-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: { + success: true, + data: { id: 'file-1', name: 'notes.md' }, + }, + }, + }) + const complete = createEvent({ + streamId: 'stream-1', + cursor: '5', + seq: 5, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { + status: MothershipStreamV1CompletionStatus.complete, + }, + }) + + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([ + workspaceFileCall, + workspaceFileResult, + editContentDelta, + editContentResult, + complete, + ]) + ) + + const onEvent = vi.fn() + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + messageId: 'msg-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + onEvent, + timeout: 1000, + }) + + const previewEvents = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => + event.type === MothershipStreamV1EventType.tool && 'previewPhase' in event.payload + ) + + expect(previewEvents.map((event) => event.payload.previewPhase)).toEqual([ + 'file_preview_start', + 'file_preview_target', + 'file_preview_content', + 'file_preview_complete', + ]) + expect(previewEvents[1].payload).toMatchObject({ + previewPhase: 'file_preview_target', + target: { kind: 'file_id', fileId: 'file-1', fileName: 'notes.md' }, + }) + expect(previewEvents[2].payload).toMatchObject({ + previewPhase: 'file_preview_content', + fileId: 'file-1', + targetKind: 'file_id', + content: 'hello world', + }) + expect(previewEvents[3].payload).toMatchObject({ + previewPhase: 'file_preview_complete', + fileId: 'file-1', + }) + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', 'files/notes.md') + }) + + it('resolves workflow alias paths to the backing file before streaming previews', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'changelog-file-1', + name: 'workflow-1.md', + }) + + const workspaceFileCall = createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'workspace-file-alias-1', + toolName: 'workspace_file', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + arguments: { + operation: 'append', + target: { kind: 'path', path: 'workflows/My%20Workflow/changelog.md' }, + title: 'Update changelog', + }, + }, + }) + const editContentDelta = createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-alias-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.args_delta, + argumentsDelta: '{"content":"\\n- Added a workflow step', + }, + }) + const editContentResult = createEvent({ + streamId: 'stream-1', + cursor: '3', + seq: 3, + requestId: 'req-1', + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'edit-content-alias-1', + toolName: 'edit_content', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + output: { + success: true, + data: { id: 'changelog-file-1', name: 'workflow-1.md' }, + }, + }, + }) + const complete = createEvent({ + streamId: 'stream-1', + cursor: '4', + seq: 4, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { + status: MothershipStreamV1CompletionStatus.complete, + }, + }) + + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([workspaceFileCall, editContentDelta, editContentResult, complete]) + ) + + const onEvent = vi.fn() + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + messageId: 'msg-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + onEvent, + timeout: 1000, + }) + + const previewEvents = onEvent.mock.calls + .map(([event]) => event) + .filter( + (event) => + event.type === MothershipStreamV1EventType.tool && 'previewPhase' in event.payload + ) + + expect(previewEvents.map((event) => event.payload.previewPhase)).toEqual([ + 'file_preview_start', + 'file_preview_target', + 'file_preview_content', + 'file_preview_complete', + ]) + expect(previewEvents[1].payload).toMatchObject({ + previewPhase: 'file_preview_target', + target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'workflow-1.md' }, + }) + expect(previewEvents[2].payload).toMatchObject({ + previewPhase: 'file_preview_content', + fileId: 'changelog-file-1', + targetKind: 'file_id', + content: '\n- Added a workflow step', + }) + expect(previewEvents[3].payload).toMatchObject({ + previewPhase: 'file_preview_complete', + fileId: 'changelog-file-1', + }) + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( + 'workspace-1', + 'workflows/My%20Workflow/changelog.md' + ) + }) + it('drops duplicate tool_result events before forwarding them', async () => { const toolResult = createEvent({ streamId: 'stream-1', @@ -532,4 +817,55 @@ describe('copilot go stream helpers', () => { }).goTraceId ).toBe('go-trace-1') }) + + it('records span identity on the subagent block from the scope', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + createSseResponse([ + createEvent({ + streamId: 'stream-1', + cursor: '1', + seq: 1, + requestId: 'req-1', + type: MothershipStreamV1EventType.span, + scope: { + lane: 'subagent', + agentId: 'deploy', + parentToolCallId: 'tc-deploy-inner', + spanId: 'S2', + parentSpanId: 'S1', + }, + payload: { + kind: 'subagent', + event: 'start', + agent: 'deploy', + data: { tool_call_id: 'tc-deploy-inner', nested: true }, + }, + }), + createEvent({ + streamId: 'stream-1', + cursor: '2', + seq: 2, + requestId: 'req-1', + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + }), + ]) + ) + + const context = createStreamingContext() + const execContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + + await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { + timeout: 1000, + }) + + const subagentBlock = context.contentBlocks.find((block) => block.type === 'subagent') + expect(subagentBlock).toBeDefined() + expect(subagentBlock?.spanId).toBe('S2') + expect(subagentBlock?.parentSpanId).toBe('S1') + expect(subagentBlock?.parentToolCallId).toBe('tc-deploy-inner') + }) }) diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts index 45539d74021..362da84bb08 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' import { - type MothershipStreamV1EventType, + MothershipStreamV1EventType, MothershipStreamV1SpanLifecycleEvent, } from '@/lib/copilot/generated/mothership-stream-v1' import { CopilotSseCloseReason } from '@/lib/copilot/generated/trace-attribute-values-v1' @@ -315,6 +315,30 @@ export async function runStreamLoop( counters.eventsByType[streamEvent.type as MothershipStreamV1EventType] += 1 } + // Surface the full error payload the moment it arrives on the wire. This + // is the single chokepoint every error event passes through (main AND + // subagent lanes), before subagent routing — which has no `error` + // handler — would otherwise swallow it. The client only renders + // `message`/`displayMessage`, so log `code`/`provider`/`data` (the raw + // upstream provider error) here to explain a client-side "Stream error". + if (streamEvent.type === MothershipStreamV1EventType.error) { + const errorPayload = streamEvent.payload + logger.error('Received error event from Go copilot stream', { + path: pathname, + lane: streamEvent.scope?.lane ?? 'main', + parentToolCallId: streamEvent.scope?.parentToolCallId, + agentId: streamEvent.scope?.agentId, + code: errorPayload.code, + provider: errorPayload.provider, + message: errorPayload.message, + error: errorPayload.error, + displayMessage: errorPayload.displayMessage, + data: errorPayload.data, + requestId: context.requestId, + messageId: context.messageId, + }) + } + if (shouldSkipToolCallEvent(streamEvent) || shouldSkipToolResultEvent(streamEvent)) { return } @@ -351,6 +375,11 @@ export async function runStreamLoop( if (isSubagentSpanStreamEvent(streamEvent)) { const spanData = parseSubagentSpanData(streamEvent.payload.data) const toolCallId = streamEvent.scope?.parentToolCallId || spanData?.toolCallId + // Deterministic nesting identity. spanId / parentSpanId are the + // primary keys; the toolCallId-keyed stack below is the legacy + // fallback for streams that predate span identity. + const spanId = streamEvent.scope?.spanId + const parentSpanId = streamEvent.scope?.parentSpanId const subagentName = streamEvent.payload.agent const spanEvt = streamEvent.payload.event const isPendingPause = spanData?.pending === true @@ -378,6 +407,8 @@ export async function runStreamLoop( type: 'subagent', content: subagentName, parentToolCallId: toolCallId, + ...(spanId ? { spanId } : {}), + ...(parentSpanId ? { parentSpanId } : {}), timestamp: Date.now(), }) } diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts index 40af3110c62..d55fe63bbaa 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -12,10 +12,16 @@ const { isSimExecuted, executeTool, ensureHandlersRegistered } = vi.hoisted(() = ensureHandlersRegistered: vi.fn(), })) -const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), - markAsyncToolRunning: vi.fn(), - completeAsyncToolCall: vi.fn(), +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall, markAsyncToolDelivered } = + vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), + markAsyncToolDelivered: vi.fn(), + })) + +const { waitForToolCompletion } = vi.hoisted(() => ({ + waitForToolCompletion: vi.fn(), })) vi.mock('@/lib/copilot/tool-executor', () => ({ @@ -34,16 +40,20 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ createRunCheckpoint: vi.fn(), getAsyncToolCall: vi.fn(), markAsyncToolStatus: vi.fn(), - markAsyncToolDelivered: vi.fn(), listAsyncToolCallsForRun: vi.fn(), getAsyncToolCalls: vi.fn(), claimCompletedAsyncToolCall: vi.fn(), releaseCompletedAsyncToolClaim: vi.fn(), upsertAsyncToolCall, markAsyncToolRunning, + markAsyncToolDelivered, completeAsyncToolCall, })) +vi.mock('@/lib/copilot/request/tools/client', () => ({ + waitForToolCompletion, +})) + import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1EventType, @@ -68,10 +78,14 @@ describe('sse-handlers tool lifecycle', () => { upsertAsyncToolCall.mockResolvedValue(null) markAsyncToolRunning.mockResolvedValue(null) completeAsyncToolCall.mockResolvedValue(null) + markAsyncToolDelivered.mockResolvedValue(null) + waitForToolCompletion.mockResolvedValue(null) context = { chatId: undefined, messageId: 'msg-1', accumulatedContent: '', + finalAssistantContent: '', + sawMainToolCall: false, trace: new TraceCollector(), contentBlocks: [], toolCalls: new Map(), @@ -93,6 +107,54 @@ describe('sse-handlers tool lifecycle', () => { } }) + it('keeps only the latest post-tool assistant text for headless final content', async () => { + await sseHandlers.text( + { + type: MothershipStreamV1EventType.text, + payload: { + channel: MothershipStreamV1TextChannel.assistant, + text: 'I will check that.', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-1', + toolName: ReadTool.id, + arguments: { path: 'foo.txt' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, autoExecuteTools: false } + ) + + await sseHandlers.text( + { + type: MothershipStreamV1EventType.text, + payload: { + channel: MothershipStreamV1TextChannel.assistant, + text: 'Final answer only.', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false } + ) + + expect(context.accumulatedContent).toBe('I will check that.Final answer only.') + expect(context.finalAssistantContent).toBe('Final answer only.') + }) + it('executes tool_call and emits tool_result', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) const onEvent = vi.fn() @@ -236,6 +298,51 @@ describe('sse-handlers tool lifecycle', () => { expect(updated?.result?.output).toBe('done') }) + it('marks background client workflow tools delivered after synthetic result emission', async () => { + waitForToolCompletion.mockResolvedValueOnce({ + status: 'background', + data: { detached: true }, + }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-background', + toolName: 'run_workflow', + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: true, timeout: 1000 } + ) + + await sleep(0) + await Promise.allSettled(context.pendingToolPromises.values()) + + expect(markAsyncToolDelivered).toHaveBeenCalledWith('tool-background') + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + toolCallId: 'tool-background', + phase: MothershipStreamV1ToolPhase.result, + status: MothershipStreamV1ToolOutcome.skipped, + success: true, + output: { detached: true }, + }), + }) + ) + expect(context.toolCalls.get('tool-background')?.status).toBe( + MothershipStreamV1ToolOutcome.skipped + ) + }) + it('does not add hidden tool calls to content blocks', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { skill: 'ok' } }) @@ -263,6 +370,95 @@ describe('sse-handlers tool lifecycle', () => { expect(context.toolCalls.get('tool-hidden')?.name).toBe('load_agent_skill') }) + it('does not add ui-hidden tool calls to content blocks', async () => { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-ui-hidden', + toolName: 'read', + arguments: { path: 'components/integrations/slack/README.md' }, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + ui: { hidden: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.contentBlocks).toEqual([]) + expect(context.toolCalls.get('tool-ui-hidden')?.name).toBe('read') + }) + + it('removes an existing content block when a later frame marks the tool hidden', async () => { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-hidden-after-partial', + toolName: 'read', + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + arguments: { path: 'components/integrations' }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + expect(context.contentBlocks).toHaveLength(1) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-hidden-after-partial', + toolName: 'read', + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + arguments: { path: 'components/integrations/slack/README.md' }, + ui: { hidden: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.contentBlocks).toEqual([]) + }) + + it('does not show pathless read or glob generating placeholders', async () => { + for (const toolName of ['read', 'glob'] as const) { + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: `${toolName}-generating`, + toolName, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + } + + expect(context.contentBlocks).toEqual([]) + expect(context.toolCalls.has('read-generating')).toBe(false) + expect(context.toolCalls.has('glob-generating')).toBe(false) + }) + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) context.subAgentParentToolCallId = 'parent-1' diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts index 1dc5b739f7d..9ad195f4977 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -5,6 +5,7 @@ import { flushSubagentThinkingBlock, flushThinkingBlock, getScopedParentToolCallId, + getScopedSpanIdentity, } from './types' export function handleTextEvent(scope: ToolScope): StreamHandler { @@ -21,6 +22,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { if (scope === 'subagent') { const parentToolCallId = getScopedParentToolCallId(event, context) if (!parentToolCallId) return + const spanIdentity = getScopedSpanIdentity(event) if (event.payload.channel === MothershipStreamV1TextChannel.thinking) { if ( context.currentSubagentThinkingBlock && @@ -33,6 +35,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { type: 'subagent_thinking', content: '', parentToolCallId, + ...spanIdentity, timestamp: Date.now(), } } @@ -47,7 +50,12 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { } context.subAgentContent[parentToolCallId] = (context.subAgentContent[parentToolCallId] || '') + chunk - addContentBlock(context, { type: 'subagent_text', content: chunk, parentToolCallId }) + addContentBlock(context, { + type: 'subagent_text', + content: chunk, + parentToolCallId, + ...spanIdentity, + }) return } @@ -68,6 +76,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { flushThinkingBlock(context) } context.accumulatedContent += chunk + context.finalAssistantContent += chunk addContentBlock(context, { type: 'text', content: chunk }) } } diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts index 1056ad0bee5..0811917fd1f 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +import { markAsyncToolDelivered, upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' import { MothershipStreamV1AsyncToolRecordStatus, @@ -39,6 +40,7 @@ import { flushSubagentThinkingBlock, flushThinkingBlock, getScopedParentToolCallId, + getScopedSpanIdentity, getToolCallUI, getToolResultErrorMessage, handleClientCompletion, @@ -50,7 +52,7 @@ const logger = createLogger('CopilotToolHandler') function applyToolDisplay( toolCall: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string } + ui: { title?: string; phaseLabel?: string; hidden?: boolean } ): void { if (!toolCall) return const displayTitle = ui.title || ui.phaseLabel @@ -149,7 +151,20 @@ export async function handleToolEvent( return } - await handleCallPhase(event.payload, context, execContext, options, parentToolCallId, scope) + if (!parentToolCallId) { + context.sawMainToolCall = true + context.finalAssistantContent = '' + } + + await handleCallPhase( + event.payload, + context, + execContext, + options, + parentToolCallId, + scope, + getScopedSpanIdentity(event) + ) } function handleResultPhase( @@ -224,7 +239,8 @@ async function handleCallPhase( execContext: ExecutionContext, options: OrchestratorOptions, parentToolCallId: string | undefined, - scope: ToolScope + scope: ToolScope, + spanIdentity: { spanId?: string; parentSpanId?: string } ): Promise { const { toolCallId, toolName } = data const args = data.arguments @@ -234,6 +250,8 @@ async function handleCallPhase( const isSubagent = scope === 'subagent' const ui = getToolCallUI(data) + if (isPartial && shouldDelayVfsPlaceholder(toolName, args)) return + if (isSubagent) { if (wasToolResultSeen(toolCallId) || existing?.endTime) { if (existing && !existing.name && toolName) existing.name = toolName @@ -254,7 +272,15 @@ async function handleCallPhase( } if (isSubagent) { - registerSubagentToolCall(context, toolCallId, toolName, args, parentToolCallId!, ui) + registerSubagentToolCall( + context, + toolCallId, + toolName, + args, + parentToolCallId!, + ui, + spanIdentity + ) } else { registerMainToolCall(context, toolCallId, toolName, args, existing, ui) } @@ -307,23 +333,41 @@ async function handleCallPhase( ) } +function shouldDelayVfsPlaceholder( + toolName: string, + args: Record | undefined +): boolean { + return (toolName === 'read' || toolName === 'glob') && !args +} + +function removeToolCallContentBlock(context: StreamingContext, toolCallId: string): void { + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const block = context.contentBlocks[i] + if (block.type === 'tool_call' && block.toolCall?.id === toolCallId) { + context.contentBlocks.splice(i, 1) + } + } +} + function registerSubagentToolCall( context: StreamingContext, toolCallId: string, toolName: string, args: Record | undefined, parentToolCallId: string, - ui: { title?: string; phaseLabel?: string } + ui: { title?: string; phaseLabel?: string; hidden?: boolean }, + spanIdentity: { spanId?: string; parentSpanId?: string } ): void { if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] } - const hideFromUi = isToolHiddenInUi(toolName) + const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true let toolCall = context.toolCalls.get(toolCallId) if (toolCall) { if (!toolCall.name && toolName) toolCall.name = toolName if (args && !toolCall.params) toolCall.params = args applyToolDisplay(toolCall, ui) + if (hideFromUi) removeToolCallContentBlock(context, toolCallId) } else { toolCall = { id: toolCallId, @@ -341,6 +385,7 @@ function registerSubagentToolCall( toolCall, calledBy: parentToolCall?.name, parentToolCallId, + ...spanIdentity, }) } } @@ -362,12 +407,16 @@ function registerMainToolCall( toolName: string, args: Record | undefined, existing: ToolCallState | undefined, - ui: { title?: string; phaseLabel?: string } + ui: { title?: string; phaseLabel?: string; hidden?: boolean } ): void { - const hideFromUi = isToolHiddenInUi(toolName) + const hideFromUi = isToolHiddenInUi(toolName) || ui.hidden === true if (existing) { if (args && !existing.params) existing.params = args applyToolDisplay(existing, ui) + if (hideFromUi) { + removeToolCallContentBlock(context, toolCallId) + return + } if ( !hideFromUi && !context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId) @@ -457,6 +506,15 @@ async function dispatchToolExecution( span.setAttribute(TraceAttr.ToolOutcome, completion.status) } handleClientCompletion(toolCall, toolCallId, completion) + if (completion?.status === ASYNC_TOOL_CONFIRMATION_STATUS.background) { + await markAsyncToolDelivered(toolCallId).catch((err) => { + logger.warn(`Failed to mark background ${scopeLabel}tool delivered`, { + toolCallId, + toolName, + error: toError(err).message, + }) + }) + } await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) return ( completion ?? { diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts index 4909410b8ae..bff45b9a97d 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -83,6 +83,23 @@ export function getScopedParentToolCallId( return event.scope?.parentToolCallId || context.subAgentParentToolCallId } +/** + * Extract the deterministic span identity from an event's scope. Returns an + * empty object for legacy events that predate span identity so callers can + * spread it safely and fall back to `parentToolCallId`-based grouping. + */ +export function getScopedSpanIdentity(event: StreamEvent): { + spanId?: string + parentSpanId?: string +} { + const spanId = event.scope?.spanId + const parentSpanId = event.scope?.parentSpanId + return { + ...(spanId ? { spanId } : {}), + ...(parentSpanId ? { parentSpanId } : {}), + } +} + export function registerPendingToolPromise( context: StreamingContext, toolCallId: string, diff --git a/apps/sim/lib/copilot/request/lifecycle/finalize.ts b/apps/sim/lib/copilot/request/lifecycle/finalize.ts index 56c34efaf1e..eca363d1226 100644 --- a/apps/sim/lib/copilot/request/lifecycle/finalize.ts +++ b/apps/sim/lib/copilot/request/lifecycle/finalize.ts @@ -115,23 +115,43 @@ async function handleError( result.errors?.[0] || 'An unexpected error occurred while processing the response.' + // Persist whatever was generated before the failure, exactly like an abort — + // a transient provider error (e.g. overloaded) shouldn't discard the partial + // assistant output the user already saw streaming. + const partialContent = result.content || undefined + const partialContentLen = result.content?.length ?? 0 + const toolCallCount = result.toolCalls?.length ?? 0 + if (publisher.clientDisconnected) { logger.info(`[${requestId}] Stream failed after client disconnect`, { error: errorMessage }) } - logger.error(`[${requestId}] Orchestration returned failure`, { error: errorMessage }) + logger.error(`[${requestId}] Orchestration returned failure`, { + error: errorMessage, + partialContentLen, + toolCallCount, + }) + // Surface the real error (Go already classifies provider errors like + // "overloaded" into a friendly displayMessage). Don't clobber it with a + // generic string. await publisher.publish({ type: MothershipStreamV1EventType.error, payload: { message: errorMessage, error: errorMessage, - data: { displayMessage: 'An unexpected error occurred while processing the response.' }, + displayMessage: errorMessage, + data: { displayMessage: errorMessage }, }, }) if (!publisher.sawComplete) { await publisher.publish({ type: MothershipStreamV1EventType.complete, - payload: { status: MothershipStreamV1CompletionStatus.error }, + payload: { + status: MothershipStreamV1CompletionStatus.error, + ...(partialContent ? { partialContent } : {}), + ...(partialContentLen ? { partialContentLen } : {}), + ...(toolCallCount ? { toolCallCount } : {}), + }, }) } await publisher.flush() diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts index 7af0bfd58d8..d31751c4ad2 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.test.ts @@ -6,7 +6,6 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' import type { OrchestratorResult } from '@/lib/copilot/request/types' const { runCopilotLifecycle } = vi.hoisted(() => ({ @@ -34,22 +33,13 @@ describe('runHeadlessCopilotLifecycle', () => { beforeEach(() => { trace.setGlobalTracerProvider(new BasicTracerProvider()) propagation.setGlobalPropagator(new W3CTraceContextPropagator()) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue( - new Response(null, { - status: 200, - }) - ) - ) }) afterEach(() => { vi.clearAllMocks() - vi.unstubAllGlobals() }) - it('reports a successful headless trace', async () => { + it('runs the lifecycle and returns its result', async () => { runCopilotLifecycle.mockResolvedValueOnce( createLifecycleResult({ usage: { prompt: 10, completion: 5 }, @@ -77,32 +67,13 @@ describe('runHeadlessCopilotLifecycle', () => { expect.objectContaining({ simRequestId: 'req-1', trace: expect.any(Object), + otelContext: expect.any(Object), chatId: 'chat-1', }) ) - - expect(fetch).toHaveBeenCalledTimes(1) - const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(url).toContain('/api/traces') - const body = JSON.parse(String(init.body)) - expect(body).toEqual( - expect.objectContaining({ - simRequestId: 'req-1', - outcome: RequestTraceV1Outcome.success, - chatId: 'chat-1', - usage: { - inputTokens: 10, - outputTokens: 5, - }, - cost: { - rawTotalCost: 3, - billedTotalCost: 3, - }, - }) - ) }) - it('reports an error trace when the lifecycle result is unsuccessful', async () => { + it('returns an unsuccessful result from the lifecycle', async () => { runCopilotLifecycle.mockResolvedValueOnce( createLifecycleResult({ success: false, @@ -125,9 +96,6 @@ describe('runHeadlessCopilotLifecycle', () => { ) expect(result.success).toBe(false) - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - const body = JSON.parse(String(init.body)) - expect(body.outcome).toBe(RequestTraceV1Outcome.error) }) it('prefers an explicit simRequestId over the payload messageId', async () => { @@ -154,13 +122,9 @@ describe('runHeadlessCopilotLifecycle', () => { simRequestId: 'workflow-request-id', }) ) - - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - const body = JSON.parse(String(init.body)) - expect(body.simRequestId).toBe('workflow-request-id') }) - it('passes an OTel context to the lifecycle and trace report', async () => { + it('threads a valid OTel context into the lifecycle', async () => { let lifecycleTraceparent = '' runCopilotLifecycle.mockImplementationOnce(async (_payload, options) => { const { traceHeaders } = await import('@/lib/copilot/request/go/propagation') @@ -183,18 +147,9 @@ describe('runHeadlessCopilotLifecycle', () => { ) expect(lifecycleTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/) - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - const headers = init.headers as Record - // The outbound trace report now runs inside its own OTel child span, so - // traceparent has the same trace-id as the lifecycle but a different - // span-id. Both must stay on the same trace. - const lifecycleTraceId = lifecycleTraceparent.split('-')[1] - expect(headers.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/) - expect(headers.traceparent.split('-')[1]).toBe(lifecycleTraceId) - expect(headers.traceparent.split('-')[2]).not.toBe(lifecycleTraceparent.split('-')[2]) }) - it('reports an error trace when the lifecycle throws', async () => { + it('rethrows when the lifecycle throws', async () => { runCopilotLifecycle.mockRejectedValueOnce(new Error('kaboom')) await expect( @@ -212,9 +167,5 @@ describe('runHeadlessCopilotLifecycle', () => { } ) ).rejects.toThrow('kaboom') - - const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - const body = JSON.parse(String(init.body)) - expect(body.outcome).toBe(RequestTraceV1Outcome.error) }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/copilot/request/lifecycle/headless.ts index 0ae8fc75f55..30bf8832fa5 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/copilot/request/lifecycle/headless.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { RequestTraceV1Outcome as RequestTraceOutcome } from '@/lib/copilot/generated/request-trace-v1' import { @@ -10,7 +9,7 @@ import { CopilotTransport } from '@/lib/copilot/generated/trace-attribute-values import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run' import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' import { withCopilotOtelContext } from '@/lib/copilot/request/otel' -import { reportTrace, TraceCollector } from '@/lib/copilot/request/trace' +import { TraceCollector } from '@/lib/copilot/request/trace' import type { OrchestratorResult } from '@/lib/copilot/request/types' const logger = createLogger('CopilotHeadlessLifecycle') @@ -74,33 +73,6 @@ export async function runHeadlessCopilotLifecycle( ? RequestTraceV1SpanStatus.cancelled : RequestTraceV1SpanStatus.error ) - - try { - // Best-effort extraction of the prompt from the untyped - // headless payload. Keeps parity with the streaming path - // where `message` is destructured directly. - const userMessage = - typeof requestPayload.message === 'string' ? requestPayload.message : undefined - await reportTrace( - trace.build({ - outcome, - simRequestId, - chatId: result?.chatId ?? options.chatId, - runId: options.runId, - executionId: options.executionId, - userMessage, - usage: result?.usage, - cost: result?.cost, - }), - otelContext - ) - } catch (error) { - logger.warn('Failed to report headless trace', { - simRequestId, - chatId: result?.chatId ?? options.chatId, - error: getErrorMessage(error), - }) - } } } ) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index 12008960528..95bd73c0c69 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -67,6 +67,7 @@ vi.mock('@/lib/core/config/env', () => ({ }, getEnv: vi.fn((key: string) => (key === 'NEXT_PUBLIC_APP_URL' ? 'http://localhost:3000' : '')), isTruthy: vi.fn((value: string | undefined) => value === 'true'), + isFalsy: vi.fn((value: string | undefined) => value === 'false'), })) vi.mock('@/lib/environment/utils', () => ({ @@ -85,6 +86,8 @@ vi.mock('@/lib/copilot/request/tools/executor', () => ({ executeToolAndReport: vi.fn(), })) +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { CopilotBackendError } from '@/lib/copilot/request/go/stream' import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' describe('runCopilotLifecycle', () => { @@ -223,6 +226,90 @@ describe('runCopilotLifecycle', () => { ) }) + it('uses the final post-tool assistant content for headless results', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'I will check that.Final answer only.' + context.finalAssistantContent = 'Final answer only.' + context.sawMainToolCall = true + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + interactive: false, + } + ) + + expect(result).toEqual( + expect.objectContaining({ + success: true, + content: 'Final answer only.', + }) + ) + }) + + it('does not fall back to pre-tool narration when headless final content is empty', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'I will check that.' + context.finalAssistantContent = '' + context.sawMainToolCall = true + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + interactive: false, + } + ) + + expect(result).toEqual( + expect.objectContaining({ + success: true, + content: '', + }) + ) + }) + it('propagates payload userPermission into the generated execution context', async () => { let capturedExecContext: ExecutionContext | undefined mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) @@ -255,4 +342,245 @@ describe('runCopilotLifecycle', () => { }) ) }) + + it('normalizes the initial request body with workspaceId from lifecycle options', async () => { + let requestBody: Record | undefined + mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({}) + mockRunStreamLoop.mockImplementationOnce( + async (_fetchUrl: string, fetchOptions: RequestInit): Promise => { + requestBody = JSON.parse(String(fetchOptions.body)) + } + ) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + } + ) + + expect(requestBody).toEqual( + expect.objectContaining({ + workspaceId: 'ws-1', + }) + ) + }) + + it('uses the lifecycle workspaceId for async tool resume requests', async () => { + const requestBodies: Record[] = [] + const fetchUrls: string[] = [] + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + mockRunStreamLoop.mockImplementationOnce( + async ( + fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + fetchUrls.push(fetchUrl) + requestBodies.push(JSON.parse(String(fetchOptions.body))) + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + mockRunStreamLoop.mockImplementationOnce( + async (fetchUrl: string, fetchOptions: RequestInit): Promise => { + fetchUrls.push(fetchUrl) + requestBodies.push(JSON.parse(String(fetchOptions.body))) + } + ) + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + workflowId: 'workflow-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume') + expect(requestBodies[1]).toEqual( + expect.objectContaining({ + checkpointId: 'ckpt-1', + userId: 'user-1', + workspaceId: 'ws-1', + }) + ) + }) + + it('finalizes as success when a resume fails with a retryable error then the retry succeeds', async () => { + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + // 1) Initial stream pauses on an async tool checkpoint with a resolved + // tool result, so the lifecycle transitions into a resume leg. + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + + // 2) First resume leg dies mid-stream like a transient provider error: + // it records an error AND throws a retryable 5xx. + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.errors.push( + 'Copilot backend stream ended before a terminal event on /api/tools/resume' + ) + throw new CopilotBackendError('backend stream ended before a terminal event', { + status: 503, + }) + } + ) + + // 3) Retry of the same resume leg succeeds cleanly. + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + _fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + context.accumulatedContent = 'Recovered final answer.' + context.finalAssistantContent = 'Recovered final answer.' + } + ) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + // Three legs ran (initial + failed resume + retried resume), and the + // recovered retry must NOT inherit the failed attempt's error. + expect(mockRunStreamLoop).toHaveBeenCalledTimes(3) + expect(result).toEqual( + expect.objectContaining({ + success: true, + cancelled: false, + errors: undefined, + }) + ) + }) + + it('marks resume legs willRetryOnStreamError except the final attempt', async () => { + const bodies: Record[] = [] + const executionContext: ExecutionContext = { + userId: 'user-1', + workflowId: '', + workspaceId: 'ws-1', + chatId: 'chat-1', + decryptedEnvVars: {}, + } + + // Initial leg pauses on a resolved async tool checkpoint → enters resume. + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + bodies.push(JSON.parse(String(fetchOptions.body))) + context.toolCalls.set('tool-1', { + id: 'tool-1', + name: 'read', + status: MothershipStreamV1ToolOutcome.success, + result: { success: true, output: { content: 'file contents' } }, + }) + context.awaitingAsyncContinuation = { + checkpointId: 'ckpt-1', + pendingToolCallIds: ['tool-1'], + } + } + ) + + // Three resume attempts, all failing with a retryable 5xx so the loop + // exhausts MAX_RESUME_ATTEMPTS (= 3) and gives up. + for (let i = 0; i < 3; i++) { + mockRunStreamLoop.mockImplementationOnce( + async ( + _fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext + ): Promise => { + bodies.push(JSON.parse(String(fetchOptions.body))) + context.errors.push('Copilot backend stream ended before a terminal event') + throw new CopilotBackendError('backend stream ended before a terminal event', { + status: 503, + }) + } + ) + } + + await runCopilotLifecycle( + { message: 'hello', messageId: 'stream-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'exec-1', + runId: 'run-1', + executionContext, + } + ) + + // Initial + 3 resume attempts. + expect(mockRunStreamLoop).toHaveBeenCalledTimes(4) + // Initial leg is never retried by this loop → no flag. + expect(bodies[0].willRetryOnStreamError).toBeUndefined() + // Resume attempts 0 and 1 will be retried on a stream error → flagged. + expect(bodies[1].willRetryOnStreamError).toBe(true) + expect(bodies[2].willRetryOnStreamError).toBe(true) + // Final attempt (2) is terminal → not flagged, so Go bills + surfaces it. + expect(bodies[3].willRetryOnStreamError).toBeUndefined() + }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index ff7f63e9ef4..bf72de725bf 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' +import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' import { SIM_AGENT_VERSION } from '@/lib/copilot/constants' import { @@ -43,6 +44,19 @@ const logger = createLogger('CopilotLifecycle') const MAX_RESUME_ATTEMPTS = 3 const RESUME_BACKOFF_MS = [250, 500, 1000] as const +function nonBlankString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function resultContent(context: StreamingContext, options: CopilotLifecycleOptions): string { + if (options.interactive === false && context.sawMainToolCall) { + return context.finalAssistantContent + } + return context.accumulatedContent +} + export interface CopilotLifecycleOptions extends OrchestratorOptions { userId: string workflowId?: string @@ -139,7 +153,7 @@ export async function runCopilotLifecycle( // branch here — if there are errors we never reach a // wasAborted-without-errors state. cancelled: context.wasAborted && context.errors.length === 0, - content: context.accumulatedContent, + content: resultContent(context, lifecycleOptions), contentBlocks: context.contentBlocks, toolCalls: buildToolCallSummaries(context), chatId: context.chatId, @@ -155,7 +169,18 @@ export async function runCopilotLifecycle( return result } catch (error) { const err = toError(error) - logger.error('Copilot orchestration failed', { error: err.message }) + // A CopilotBackendError carries the upstream HTTP status + body (e.g. a 5xx + // from /api/tools/resume when an oversized tool result — a rendered-doc + // image — is posted back). Log those so a client-side "Stream error" that + // originates from a thrown backend leg (vs an `error` SSE event) is + // explained, not just reduced to a message string. + logger.error('Copilot orchestration failed', { + error: err.message, + name: err.name, + ...(error instanceof CopilotBackendError + ? { backendStatus: error.status, backendBody: error.body?.slice(0, 2000) } + : {}), + }) // If the abort signal fired, this throw is a consequence of the // cancel (publisher.publish fails once the client disconnects, a // downstream Go read throws on ctx cancel, etc.) — NOT a real @@ -167,12 +192,18 @@ export async function runCopilotLifecycle( // Return `cancelled: true` so upstream classification stays // consistent with the success-path cancel result. const wasCancelled = lifecycleOptions.abortSignal?.aborted ?? false + // Preserve whatever streamed before the throw for both terminals. A thrown + // backend error (as opposed to an `error` SSE event that lets the loop finish + // normally) must still carry the partial assistant turn so onError can + // persist it — otherwise the post-error refetch replaces the rich live turn + // with an empty assistant row and the UI appears to wipe the message + + // subagent work. const result: OrchestratorResult = { success: false, cancelled: wasCancelled, - content: wasCancelled ? context.accumulatedContent : '', - contentBlocks: wasCancelled ? context.contentBlocks : [], - toolCalls: wasCancelled ? buildToolCallSummaries(context) : [], + content: context.accumulatedContent, + contentBlocks: context.contentBlocks, + toolCalls: buildToolCallSummaries(context), chatId: context.chatId, requestId: context.requestId, error: err.message, @@ -182,7 +213,7 @@ export async function runCopilotLifecycle( } if (!wasCancelled) { - await lifecycleOptions.onError?.(err) + await lifecycleOptions.onError?.(err, result) } else if (!onCompleteStarted && lifecycleOptions.onComplete) { try { await lifecycleOptions.onComplete(result) @@ -212,6 +243,21 @@ async function runCheckpointLoop( let resumeAttempt = 0 const callerOnEvent = options.onEvent const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId }) + const lifecycleWorkspaceId = nonBlankString(options.workspaceId) + + // Go's auth middleware re-validates every Sim -> Go request by reading + // workspaceId from the JSON body and forwarding it to Sim's validate route, + // where it is required for the per-member usage gate. Normalize the initial + // leg from the lifecycle option so callers that only set the option (not the + // raw payload) still send it on the first request. + if (lifecycleWorkspaceId && !nonBlankString(payload.workspaceId)) { + payload = { ...payload, workspaceId: lifecycleWorkspaceId } + } + + // Enterprise BYOK eligibility hint: set once on the initial mothership request + // so Go only attempts a BYOK lookup for entitled workspaces. This is only a + // gate — Go re-confirms entitlement authoritatively before using any key. + payload = await withByokEligibilityHint(payload, route, lifecycleWorkspaceId) for (;;) { context.streamComplete = false @@ -264,6 +310,26 @@ async function runCheckpointLoop( hasCheckpoint: !!context.awaitingAsyncContinuation, }) + // Snapshot recorded errors before this attempt. If the attempt fails with + // a retryable resume error, we roll back to this baseline before retrying + // so a subsequent successful retry doesn't inherit the failed attempt's + // errors (e.g. "backend stream ended before a terminal event") and get + // mis-finalized as `error`. + const errorsBeforeAttempt = context.errors.length + + // A resume leg that is not the last allowed attempt will be retried below + // on a retryable stream error. Tell Go so it treats a mid-flight provider + // error as non-terminal for the UI and suppresses the user-facing error tag + // that a recovered retry should not show. Billing is still flushed for + // every leg; /api/billing/update-cost records cumulative cost as a + // monotonic top-up, so the partial retry leg and the recovered terminal leg + // reconcile to the maximum cumulative total. Recomputed per attempt because + // the same payload is reused across retries. + const willRetryOnStreamError = isResume && resumeAttempt < MAX_RESUME_ATTEMPTS - 1 + const legPayload = willRetryOnStreamError + ? { ...payload, willRetryOnStreamError: true } + : payload + try { await runStreamLoop( `${mothershipBaseURL}${route}`, @@ -275,7 +341,7 @@ async function runCheckpointLoop( ...getMothershipSourceEnvHeaders(), 'X-Client-Version': SIM_AGENT_VERSION, }, - body: JSON.stringify(payload), + body: JSON.stringify(legPayload), }, context, execContext, @@ -301,6 +367,9 @@ async function runCheckpointLoop( isRetryableStreamError(streamError) && resumeAttempt < MAX_RESUME_ATTEMPTS - 1 ) { + // Discard errors recorded during this failed attempt; we're about to + // redo this leg and a clean retry must not finalize as `error`. + context.errors.length = errorsBeforeAttempt resumeAttempt++ const backoff = RESUME_BACKOFF_MS[resumeAttempt - 1] ?? 1000 logger.warn('Resume stream failed, retrying', { @@ -434,6 +503,8 @@ async function runCheckpointLoop( payload = { streamId: context.messageId, checkpointId: continuation.checkpointId, + userId: options.userId, + ...(lifecycleWorkspaceId ? { workspaceId: lifecycleWorkspaceId } : {}), results, } @@ -552,6 +623,35 @@ async function ensureHeadlessRunIdentity(input: { // Helpers // --------------------------------------------------------------------------- +/** + * Adds `enterpriseByokEligible: true` to the initial mothership payload when the + * workspace is on an enterprise plan. BYOK is mothership-only, so non-mothership + * routes (e.g. `/api/copilot`) are left untouched. Failures default to hosted. + */ +async function withByokEligibilityHint( + payload: Record, + route: string, + workspaceId?: string +): Promise> { + // The eligibility hint is server-authoritative: always overwrite any + // client-supplied value with a server-derived boolean so a client can never + // assert its own eligibility. (Copilot's ValidateBYOK is the final authority, + // but the hint must never originate from the client.) BYOK is mothership-only; + // everything else gets an explicit false. + let eligible = false + if (workspaceId && route.startsWith('/api/mothership')) { + try { + eligible = await isWorkspaceOnEnterprisePlan(workspaceId) + } catch (error) { + logger.warn('Failed to resolve BYOK eligibility; defaulting to hosted', { + workspaceId, + error: toError(error).message, + }) + } + } + return { ...payload, enterpriseByokEligible: eligible } +} + function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean { return !!(options.abortSignal?.aborted || context.wasAborted) } diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index 69682f0019f..22497ef3ad2 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -39,7 +39,7 @@ import { unregisterActiveStream, } from '@/lib/copilot/request/session' import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse' -import { reportTrace, TraceCollector } from '@/lib/copilot/request/trace' +import { TraceCollector } from '@/lib/copilot/request/trace' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' @@ -339,27 +339,6 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS await scheduleFilePreviewSessionCleanup(streamId) await cleanupAbortMarker(streamId) - const trace = collector.build({ - outcome, - simRequestId: requestId, - streamId, - chatId, - runId, - executionId, - // Pass the raw user prompt through so the Go-side trace - // ingest can stamp it onto the `request_traces.message` - // column at insert time. Avoids relying on the late - // `UpdateAnalytics` UPDATE (which silently misses many - // rows). - userMessage: message, - usage: lifecycleResult?.usage, - cost: lifecycleResult?.cost, - }) - reportTrace(trace, otelContext).catch((err) => { - logger.warn(`[${requestId}] Failed to report trace`, { - error: getErrorMessage(err), - }) - }) rootOutcome = outcome if (lifecycleResult?.usage) { activeOtelRoot.span.setAttributes({ @@ -466,6 +445,7 @@ function fireTitleGeneration(params: { model: titleModel, provider: titleProvider, userId, + workspaceId, otelContext, }) .then(async (title) => { @@ -497,9 +477,10 @@ export async function requestChatTitle(params: { model: string provider?: string userId?: string + workspaceId?: string otelContext?: Context }): Promise { - const { message, model, provider, userId, otelContext } = params + const { message, model, provider, userId, workspaceId, otelContext } = params if (!message || !model) return null const headers: Record = { @@ -520,6 +501,8 @@ export async function requestChatTitle(params: { message, model, ...(provider ? { provider } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(userId ? { userId } : {}), }), otelContext, spanName: 'sim → go /api/generate-chat-title', diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.ts b/apps/sim/lib/copilot/request/session/explicit-abort.ts index fcc209dc5c0..9dbae1ff8eb 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.ts +++ b/apps/sim/lib/copilot/request/session/explicit-abort.ts @@ -11,6 +11,7 @@ export async function requestExplicitStreamAbort(params: { streamId: string userId: string chatId?: string + workspaceId?: string timeoutMs?: number otelContext?: Context }): Promise { @@ -18,6 +19,7 @@ export async function requestExplicitStreamAbort(params: { streamId, userId, chatId, + workspaceId, timeoutMs = DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS, otelContext, } = params @@ -46,6 +48,7 @@ export async function requestExplicitStreamAbort(params: { messageId: streamId, userId, ...(chatId ? { chatId } : {}), + ...(workspaceId ? { workspaceId } : {}), }), otelContext, spanName: 'sim → go /api/streams/explicit-abort', diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/copilot/request/sse-utils.test.ts new file mode 100644 index 00000000000..585cee9d18c --- /dev/null +++ b/apps/sim/lib/copilot/request/sse-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session' +import type { StreamEvent } from '@/lib/copilot/request/types' +import { shouldSkipToolCallEvent } from './sse-utils' + +describe('shouldSkipToolCallEvent', () => { + it('skips pathless read and glob generating placeholders without marking the call seen', () => { + const readEvent = toolCallEvent('read-generating-placeholder', 'read', undefined, true) + const globEvent = toolCallEvent('glob-generating-placeholder', 'glob', undefined, true) + + expect(shouldSkipToolCallEvent(readEvent)).toBe(true) + expect(shouldSkipToolCallEvent(globEvent)).toBe(true) + + expect( + shouldSkipToolCallEvent( + toolCallEvent('read-generating-placeholder', 'read', { + path: 'components/integrations/slack/README.md', + }) + ) + ).toBe(false) + expect( + shouldSkipToolCallEvent( + toolCallEvent('glob-generating-placeholder', 'glob', { + pattern: 'components/blocks/*/README.md', + }) + ) + ).toBe(false) + }) + + it('keeps non-vfs generating placeholders visible', () => { + expect( + shouldSkipToolCallEvent( + toolCallEvent('search-generating-placeholder', 'search_online', undefined, true) + ) + ).toBe(false) + }) +}) + +function toolCallEvent( + toolCallId: string, + toolName: string, + args?: Record, + generating = false +): StreamEvent { + return { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor: MothershipStreamV1ToolExecutor.go, + mode: MothershipStreamV1ToolMode.sync, + phase: MothershipStreamV1ToolPhase.call, + ...(generating ? { status: TOOL_CALL_STATUS.generating } : {}), + ...(args ? { arguments: args } : {}), + }, + } satisfies StreamEvent +} diff --git a/apps/sim/lib/copilot/request/sse-utils.ts b/apps/sim/lib/copilot/request/sse-utils.ts index 7c403b2fd51..310c1b9eaa8 100644 --- a/apps/sim/lib/copilot/request/sse-utils.ts +++ b/apps/sim/lib/copilot/request/sse-utils.ts @@ -55,6 +55,7 @@ export function wasToolResultSeen(toolCallId: string): boolean { export function shouldSkipToolCallEvent(event: StreamEvent): boolean { if (!isToolCallStreamEvent(event)) return false + if (isPathlessVfsGeneratingEvent(event)) return true if (event.payload.status === TOOL_CALL_STATUS.generating) return false const toolCallId = getToolCallIdFromCallEvent(event) if (event.payload.partial === true) return false @@ -63,6 +64,12 @@ export function shouldSkipToolCallEvent(event: StreamEvent): boolean { return false } +function isPathlessVfsGeneratingEvent(event: ToolCallStreamEvent): boolean { + if (event.payload.status !== TOOL_CALL_STATUS.generating) return false + if (event.payload.toolName !== 'read' && event.payload.toolName !== 'glob') return false + return event.payload.arguments === undefined +} + export function shouldSkipToolResultEvent(event: StreamEvent): boolean { return isToolResultStreamEvent(event) && wasToolResultSeen(getToolCallIdFromResultEvent(event)) } diff --git a/apps/sim/lib/copilot/request/subagent.ts b/apps/sim/lib/copilot/request/subagent.ts deleted file mode 100644 index dd741d45c1c..00000000000 --- a/apps/sim/lib/copilot/request/subagent.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' -import { SIM_AGENT_VERSION } from '@/lib/copilot/constants' -import { - MothershipStreamV1EventType, - MothershipStreamV1SpanPayloadKind, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { createStreamingContext } from '@/lib/copilot/request/context/request-context' -import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' -import { runStreamLoop } from '@/lib/copilot/request/go/stream' -import { withCopilotSpan } from '@/lib/copilot/request/otel' -import type { - ExecutionContext, - OrchestratorOptions, - StreamEvent, - StreamingContext, - ToolCallSummary, -} from '@/lib/copilot/request/types' -import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' -import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/feature-flags' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { getWorkflowById } from '@/lib/workflows/utils' - -const logger = createLogger('CopilotSubagentOrchestrator') - -export interface SubagentOrchestratorOptions extends Omit { - userId: string - workflowId?: string - workspaceId?: string - userPermission?: string - simRequestId?: string - onComplete?: (result: SubagentOrchestratorResult) => void | Promise -} - -export interface SubagentOrchestratorResult { - success: boolean - content: string - toolCalls: ToolCallSummary[] - structuredResult?: { - type?: string - summary?: string - data?: unknown - success?: boolean - } - error?: string - errors?: string[] -} - -export async function orchestrateSubagentStream( - agentId: string, - requestPayload: Record, - options: SubagentOrchestratorOptions -): Promise { - return withCopilotSpan( - TraceSpan.CopilotSubagentExecute, - { - [TraceAttr.SubagentId]: agentId, - // Sim-side entrypoint = MCP / headless subagent call. No parent - // agent (the caller is an external client); treat as depth 2 and - // mark as NOT nested so it aggregates with Go-side direct-child - // subagent spans on dashboards. Grandchildren are stamped - // depth=3 + nested=true in - // `agents/nested.go:executeNestedAgent`. - [TraceAttr.SubagentDepth]: 2, - [TraceAttr.SubagentNested]: false, - [TraceAttr.SubagentParentAgentId]: 'mcp', - [TraceAttr.UserId]: options.userId, - ...(options.simRequestId ? { [TraceAttr.SimRequestId]: options.simRequestId } : {}), - ...(options.workflowId ? { [TraceAttr.WorkflowId]: options.workflowId } : {}), - ...(options.workspaceId ? { [TraceAttr.WorkspaceId]: options.workspaceId } : {}), - }, - async (otelSpan) => { - const result = await orchestrateSubagentStreamInner(agentId, requestPayload, options) - otelSpan.setAttributes({ - [TraceAttr.SubagentOutcomeSuccess]: result.success, - [TraceAttr.SubagentOutcomeToolCallCount]: result.toolCalls.length, - [TraceAttr.SubagentOutcomeContentBytes]: result.content?.length ?? 0, - ...(result.structuredResult?.type - ? { [TraceAttr.SubagentOutcomeStructuredType]: result.structuredResult.type } - : {}), - ...(result.error - ? { [TraceAttr.SubagentOutcomeError]: String(result.error).slice(0, 500) } - : {}), - }) - return result - } - ) -} - -async function orchestrateSubagentStreamInner( - agentId: string, - requestPayload: Record, - options: SubagentOrchestratorOptions -): Promise { - const { userId, workflowId, workspaceId, userPermission } = options - const chatId = - (typeof requestPayload.chatId === 'string' && requestPayload.chatId) || generateId() - const execContext = await buildExecutionContext(userId, workflowId, workspaceId, chatId) - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - let resolvedWorkflowName = - typeof requestPayload.workflowName === 'string' ? requestPayload.workflowName : undefined - let resolvedWorkspaceId = - execContext.workspaceId || - (typeof requestPayload.workspaceId === 'string' ? requestPayload.workspaceId : workspaceId) - - if (workflowId && (!resolvedWorkflowName || !resolvedWorkspaceId)) { - const workflow = await getWorkflowById(workflowId) - resolvedWorkflowName ||= workflow?.name || undefined - resolvedWorkspaceId ||= workflow?.workspaceId || undefined - } - - let resolvedWorkspaceContext = - typeof requestPayload.workspaceContext === 'string' - ? requestPayload.workspaceContext - : undefined - if (!resolvedWorkspaceContext && resolvedWorkspaceId) { - try { - resolvedWorkspaceContext = await generateWorkspaceContext(resolvedWorkspaceId, userId) - } catch (error) { - logger.warn('Failed to generate workspace context for subagent request', { - agentId, - workspaceId: resolvedWorkspaceId, - error: toError(error).message, - }) - } - } - - const msgId = requestPayload?.messageId - const context = createStreamingContext({ - chatId, - requestId: options.simRequestId, - messageId: typeof msgId === 'string' ? msgId : generateId(), - }) - - let structuredResult: SubagentOrchestratorResult['structuredResult'] - - try { - await runStreamLoop( - `${mothershipBaseURL}/api/subagent/${agentId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - ...getMothershipSourceEnvHeaders(), - 'X-Client-Version': SIM_AGENT_VERSION, - }, - body: JSON.stringify({ - ...requestPayload, - chatId, - userId, - stream: true, - ...(resolvedWorkflowName ? { workflowName: resolvedWorkflowName } : {}), - ...(resolvedWorkspaceId ? { workspaceId: resolvedWorkspaceId } : {}), - ...(resolvedWorkspaceContext ? { workspaceContext: resolvedWorkspaceContext } : {}), - isHosted, - ...(userPermission ? { userPermission } : {}), - }), - }, - context, - execContext, - { - ...options, - interactive: false, - onBeforeDispatch: (event: StreamEvent, ctx: StreamingContext) => { - if ( - event.type === MothershipStreamV1EventType.span && - (event.payload.kind === MothershipStreamV1SpanPayloadKind.structured_result || - event.payload.kind === MothershipStreamV1SpanPayloadKind.subagent_result) - ) { - structuredResult = normalizeStructuredResult(event.payload.data) - ctx.streamComplete = true - return true - } - - if (event.scope?.agentId === agentId && !ctx.subAgentParentToolCallId) { - return false - } - - return false - }, - } - ) - - const result: SubagentOrchestratorResult = { - success: context.errors.length === 0 && !context.wasAborted, - content: context.accumulatedContent, - toolCalls: buildToolCallSummaries(context), - structuredResult, - errors: context.errors.length ? context.errors : undefined, - } - await options.onComplete?.(result) - return result - } catch (error) { - const err = error instanceof Error ? error : new Error('Subagent orchestration failed') - logger.error('Subagent orchestration failed', { - error: err.message, - agentId, - }) - await options.onError?.(err) - return { - success: false, - content: context.accumulatedContent, - toolCalls: [], - error: err.message, - } - } -} - -function normalizeStructuredResult(data: unknown): SubagentOrchestratorResult['structuredResult'] { - if (!data || typeof data !== 'object') return undefined - const d = data as Record - return { - type: (d.result_type || d.type) as string | undefined, - summary: d.summary as string | undefined, - data: d.data ?? d, - success: d.success as boolean | undefined, - } -} - -async function buildExecutionContext( - userId: string, - workflowId?: string, - workspaceId?: string, - chatId?: string -): Promise { - if (workflowId) { - return prepareExecutionContext(userId, workflowId, chatId, { workspaceId }) - } - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) - return { - userId, - workflowId: workflowId || '', - workspaceId, - chatId, - decryptedEnvVars, - } -} diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index e72d6455f89..0d3cb404549 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { extractTabularData, + normalizeOutputWorkspaceFileName, serializeOutputForFile, unwrapFunctionExecuteOutput, } from '@/lib/copilot/request/tools/files' @@ -71,6 +72,21 @@ describe('serializeOutputForFile (json / txt / md)', () => { }) }) +describe('normalizeOutputWorkspaceFileName', () => { + it('derives the leaf file name from workflow alias output paths', () => { + expect(normalizeOutputWorkspaceFileName('workflows/My%20Workflow/changelog.md')).toBe( + 'changelog.md' + ) + expect( + normalizeOutputWorkspaceFileName('workflows/My%20Workflow/.plans/phase%201/implementation.md') + ).toBe('implementation.md') + }) + + it('still handles normal workspace file output paths', () => { + expect(normalizeOutputWorkspaceFileName('files/Reports/output.csv')).toBe('output.csv') + }) +}) + describe('extractTabularData', () => { it('extracts rows directly from an array input', () => { expect(extractTabularData([{ a: 1 }, { a: 2 }])).toEqual([{ a: 1 }, { a: 2 }]) diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 4ca8e11b408..98a52ae8ab8 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -7,13 +7,14 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' const logger = createLogger('CopilotToolResultFiles') export const OUTPUT_PATH_TOOLS: Set = new Set([FunctionExecute.id, UserTable.id]) -type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' +export type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' export const EXT_TO_FORMAT: Record = { '.json': 'json', @@ -111,17 +112,12 @@ export function convertRowsToCsv(rows: Record[]): string { } export function normalizeOutputWorkspaceFileName(outputPath: string): string { - const trimmed = outputPath.trim().replace(/^\/+/, '') - const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed - if (!withoutPrefix) { - throw new Error('outputPath must include a file name, e.g. "files/result.json"') + const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, '')) + const fileName = segments.at(-1) + if (!fileName) { + throw new Error('Output path must include a file name') } - if (withoutPrefix.includes('/')) { - throw new Error( - 'outputPath must target a flat workspace file, e.g. "files/result.json". Nested paths like "files/reports/result.json" are not supported.' - ) - } - return withoutPrefix + return fileName } export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat { @@ -145,6 +141,62 @@ export function serializeOutputForFile(output: unknown, format: OutputFormat): s return JSON.stringify(unwrapped, null, 2) } +export interface OutputFileDeclaration { + path: string + mode?: 'create' | 'overwrite' + format?: OutputFormat + mimeType?: string + sandboxPath?: string + formatPath?: string +} + +export function getOutputFileDeclarations( + params: Record | undefined +): OutputFileDeclaration[] { + const args = params?.args as Record | undefined + const outputs = + (params?.outputs as { files?: unknown[] } | undefined) ?? + (args?.outputs as { files?: unknown[] } | undefined) + + if (Array.isArray(outputs?.files)) { + return outputs.files.flatMap((item): OutputFileDeclaration[] => { + if (!item || typeof item !== 'object') return [] + const file = item as Record + if (typeof file.path !== 'string') return [] + return [ + { + path: file.path, + mode: file.mode === 'overwrite' ? 'overwrite' : 'create', + format: typeof file.format === 'string' ? (file.format as OutputFormat) : undefined, + mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined, + sandboxPath: typeof file.sandboxPath === 'string' ? file.sandboxPath : undefined, + }, + ] + }) + } + + const outputPath = + (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) + if (!outputPath) return [] + const overwriteFileId = + (params?.overwriteFileId as string | undefined) ?? (args?.overwriteFileId as string | undefined) + return [ + { + path: overwriteFileId || outputPath, + mode: overwriteFileId ? 'overwrite' : 'create', + formatPath: outputPath, + format: ((params?.outputFormat as string | undefined) ?? + (args?.outputFormat as string | undefined)) as OutputFormat | undefined, + mimeType: + (params?.outputMimeType as string | undefined) ?? + (args?.outputMimeType as string | undefined), + sandboxPath: + (params?.outputSandboxPath as string | undefined) ?? + (args?.outputSandboxPath as string | undefined), + }, + ] +} + export async function maybeWriteOutputToFile( toolName: string, params: Record | undefined, @@ -155,17 +207,26 @@ export async function maybeWriteOutputToFile( if (!OUTPUT_PATH_TOOLS.has(toolName)) return result if (!context.workspaceId || !context.userId) return result - const args = params?.args as Record | undefined - const outputPath = - (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) - if (!outputPath) return result - const outputSandboxPath = - (params?.outputSandboxPath as string | undefined) ?? - (args?.outputSandboxPath as string | undefined) - if (toolName === FunctionExecute.id && outputSandboxPath) return result + const outputFiles = getOutputFileDeclarations(params).filter((file) => !file.sandboxPath) + if (outputFiles.length === 0) return result - const explicitFormat = - (params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined) + const outputObject = + result.output && typeof result.output === 'object' && !Array.isArray(result.output) + ? (result.output as Record) + : undefined + const resultObject = + outputObject?.result && + typeof outputObject.result === 'object' && + !Array.isArray(outputObject.result) + ? (outputObject.result as Record) + : undefined + if (Array.isArray(resultObject?.files)) { + logger.warn('Skipping returned-value output write because sandbox export response is active', { + toolName, + outputCount: outputFiles.length, + }) + return result + } // Only span the actual write path (where we upload to storage). Fast // no-op returns above don't need a span — they'd just pad the trace @@ -178,58 +239,92 @@ export async function maybeWriteOutputToFile( }, async (span) => { try { - const fileName = normalizeOutputWorkspaceFileName(outputPath) - const format = resolveOutputFormat(fileName, explicitFormat) - span.setAttributes({ - [TraceAttr.CopilotOutputFileName]: fileName, - [TraceAttr.CopilotOutputFileFormat]: format, - }) - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const content = serializeOutputForFile(result.output, format) - const contentType = FORMAT_TO_CONTENT_TYPE[format] + const writtenFiles = [] + for (const outputFile of outputFiles) { + const fileName = normalizeOutputWorkspaceFileName( + outputFile.formatPath ?? outputFile.path + ) + const format = resolveOutputFormat(fileName, outputFile.format) + const content = serializeOutputForFile(result.output, format) + const contentType = outputFile.mimeType || FORMAT_TO_CONTENT_TYPE[format] + const buffer = Buffer.from(content, 'utf-8') - const buffer = Buffer.from(content, 'utf-8') - span.setAttribute(TraceAttr.CopilotOutputFileBytes, buffer.length) - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + + const written = await writeWorkspaceFileByPath({ + workspaceId: context.workspaceId!, + userId: context.userId!, + target: { + path: outputFile.path, + mode: outputFile.mode ?? 'create', + mimeType: outputFile.mimeType, + }, + buffer, + inferredMimeType: contentType, + }) + writtenFiles.push({ + ...written, + bytes: buffer.length, + format, + requestedPath: outputFile.path, + }) } - const uploaded = await uploadWorkspaceFile( - context.workspaceId!, - context.userId!, - buffer, - fileName, - contentType - ) + + const firstWritten = writtenFiles[0] span.setAttributes({ - [TraceAttr.CopilotOutputFileId]: uploaded.id, + [TraceAttr.CopilotOutputFileId]: firstWritten.id, + [TraceAttr.CopilotOutputFileName]: firstWritten.name, + [TraceAttr.CopilotOutputFileFormat]: firstWritten.format, + [TraceAttr.CopilotOutputFilePath]: firstWritten.vfsPath, + [TraceAttr.CopilotOutputFileMode]: firstWritten.mode, + [TraceAttr.CopilotOutputFileBytes]: firstWritten.bytes, [TraceAttr.CopilotOutputFileOutcome]: CopilotOutputFileOutcome.Uploaded, }) logger.info('Tool output written to file', { toolName, - fileName, - size: buffer.length, - fileId: uploaded.id, + outputCount: writtenFiles.length, + files: writtenFiles.map((file) => ({ + fileId: file.id, + vfsPath: file.vfsPath, + size: file.bytes, + })), }) return { success: true, output: { - message: `Output written to files/${fileName} (${buffer.length} bytes)`, - fileId: uploaded.id, - fileName, - size: buffer.length, - downloadUrl: uploaded.url, + message: + writtenFiles.length === 1 + ? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)` + : `Output written to ${writtenFiles.length} files`, + files: writtenFiles.map((file) => ({ + fileId: file.id, + fileName: file.name, + vfsPath: file.vfsPath, + size: file.bytes, + downloadUrl: file.downloadUrl, + })), + fileId: firstWritten.id, + fileName: firstWritten.name, + vfsPath: firstWritten.vfsPath, + size: firstWritten.bytes, + downloadUrl: firstWritten.downloadUrl, }, - resources: [{ type: 'file', id: uploaded.id, title: fileName }], + resources: writtenFiles.map((file) => ({ + type: 'file', + id: file.id, + title: file.name, + path: file.vfsPath, + })), } } catch (err) { const message = toError(err).message logger.warn('Failed to write tool output to file', { toolName, - outputPath, + outputPaths: outputFiles.map((file) => file.path), error: message, }) span.setAttribute(TraceAttr.CopilotOutputFileOutcome, CopilotOutputFileOutcome.Failed) diff --git a/apps/sim/lib/copilot/request/trace.ts b/apps/sim/lib/copilot/request/trace.ts index cb399959d7d..f2672468e52 100644 --- a/apps/sim/lib/copilot/request/trace.ts +++ b/apps/sim/lib/copilot/request/trace.ts @@ -1,6 +1,3 @@ -import type { Context } from '@opentelemetry/api' -import { createLogger } from '@sim/logger' -import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { type RequestTraceV1CostSummary, RequestTraceV1Outcome, @@ -10,10 +7,6 @@ import { RequestTraceV1SpanStatus, type RequestTraceV1UsageSummary, } from '@/lib/copilot/generated/request-trace-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { env } from '@/lib/core/config/env' - -const logger = createLogger('RequestTrace') export class TraceCollector { private readonly spans: RequestTraceV1Span[] = [] @@ -80,7 +73,16 @@ export class TraceCollector { // the moment it's first written instead of waiting on the late // analytics UPDATE. userMessage?: string - usage?: { prompt: number; completion: number } + usage?: { + prompt: number + completion: number + cacheAttemptedRequests?: number + cacheHitRequests?: number + cacheWriteRequests?: number + cacheReadTokens?: number + cacheWriteTokens?: number + cacheSavingsRate?: number + } cost?: { input: number; output: number; total: number } }): RequestTraceV1SimReport { const endMs = Date.now() @@ -88,6 +90,12 @@ export class TraceCollector { ? { inputTokens: params.usage.prompt, outputTokens: params.usage.completion, + cacheAttemptedRequests: params.usage.cacheAttemptedRequests ?? 0, + cacheHitRequests: params.usage.cacheHitRequests ?? 0, + cacheWriteRequests: params.usage.cacheWriteRequests ?? 0, + cacheReadTokens: params.usage.cacheReadTokens ?? 0, + cacheWriteTokens: params.usage.cacheWriteTokens ?? 0, + cacheSavingsRate: params.usage.cacheSavingsRate ?? 0, } : undefined @@ -117,35 +125,4 @@ export class TraceCollector { } } -export async function reportTrace( - trace: RequestTraceV1SimReport, - otelContext?: Context -): Promise { - const { fetchGo } = await import('@/lib/copilot/request/go/fetch') - const body = JSON.stringify(trace) - const response = await fetchGo(`${SIM_AGENT_API_URL}/api/traces`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body, - otelContext, - spanName: 'sim → go /api/traces', - operation: 'report_trace', - attributes: { - [TraceAttr.RequestId]: trace.simRequestId ?? '', - [TraceAttr.HttpRequestContentLength]: body.length, - [TraceAttr.CopilotTraceSpanCount]: trace.spans?.length ?? 0, - }, - }) - - if (!response.ok) { - logger.warn('Failed to report trace', { - status: response.status, - simRequestId: trace.simRequestId, - }) - } -} - export { RequestTraceV1Outcome, RequestTraceV1SpanStatus } diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/copilot/request/types.ts index 2b310e9d648..0898bdbe442 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -57,6 +57,14 @@ export interface ContentBlock { timestamp: number endedAt?: number parentToolCallId?: string + /** + * Deterministic agent-run identity. `spanId` is the stable per-invocation id + * of the subagent that produced the block; `parentSpanId` links it to the run + * that invoked it. These are the primary nesting keys; `parentToolCallId` is + * retained for tool linkage and legacy back-compat. + */ + spanId?: string + parentSpanId?: string } export interface StreamingContext { @@ -66,6 +74,8 @@ export interface StreamingContext { runId?: string messageId: string accumulatedContent: string + finalAssistantContent: string + sawMainToolCall: boolean contentBlocks: ContentBlock[] toolCalls: Map pendingToolPromises: Map> @@ -97,7 +107,7 @@ export interface StreamingContext { activeFileIntent?: { toolCallId: string operation: string - target: { kind: string; fileId?: string; fileName?: string } + target: { kind: string; fileId?: string; fileName?: string; path?: string } title?: string contentType?: string edit?: Record @@ -136,7 +146,7 @@ export interface OrchestratorOptions { timeout?: number onEvent?: (event: StreamEvent) => void | Promise onComplete?: (result: OrchestratorResult) => void | Promise - onError?: (error: Error) => void | Promise + onError?: (error: Error, result?: OrchestratorResult) => void | Promise abortSignal?: AbortSignal onAbortObserved?: (reason: string) => void interactive?: boolean diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index 7f2ac6ed33e..7244214f190 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -108,4 +108,37 @@ describe('extractResourcesFromToolResult', () => { expect(resources).toEqual([]) }) + + it.each([ + ['generate_video', 'ad-clip.mp4'], + ['generate_audio', 'voiceover.mp3'], + ['ffmpeg', 'final-ad.mp4'], + ])('auto-opens the generated file from %s results', (toolName, fileName) => { + const resources = extractResourcesFromToolResult( + toolName, + {}, + { + success: true, + message: `Saved at "files/${fileName}"`, + fileId: 'file_media_123', + fileName, + } + ) + + expect(resources).toEqual([{ type: 'file', id: 'file_media_123', title: fileName }]) + }) + + it('does not create a resource for ffmpeg probe (no file written)', () => { + const resources = extractResourcesFromToolResult( + 'ffmpeg', + { operation: 'probe' }, + { + success: true, + message: 'Probed media', + probe: { durationSeconds: 12.5, width: 1080, height: 1920 }, + } + ) + + expect(resources).toEqual([]) + }) }) diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index 6cba5a3bee0..20e77c91183 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -4,9 +4,11 @@ import { DeleteWorkflow, DownloadToWorkspaceFile, EditWorkflow, + Ffmpeg, FunctionExecute, + GenerateAudio, GenerateImage, - GenerateVisualization, + GenerateVideo, Knowledge, KnowledgeBase, UserTable, @@ -27,8 +29,10 @@ const RESOURCE_TOOL_NAMES: Set = new Set([ FunctionExecute.id, KnowledgeBase.id, Knowledge.id, - GenerateVisualization.id, GenerateImage.id, + GenerateVideo.id, + GenerateAudio.id, + Ffmpeg.id, ]) export function isResourceToolName(toolName: string): boolean { @@ -143,8 +147,11 @@ export function extractResourcesFromToolResult( } case DownloadToWorkspaceFile.id: - case GenerateVisualization.id: - case GenerateImage.id: { + case GenerateImage.id: + case GenerateVideo.id: + case GenerateAudio.id: + case Ffmpeg.id: { + // ffmpeg's probe op writes no file (no fileId) → no resource/auto-open. if (result.fileId) { return [ { diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 9529f980f2b..ff1703ebbf9 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -17,6 +17,7 @@ export interface MothershipResource { type: MothershipResourceType id: string title: string + path?: string } export function isEphemeralResource(resource: MothershipResource): boolean { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index 95433dabde2..789e5e16b1a 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -3,7 +3,6 @@ import { CheckDeploymentStatus, CompleteJob, CreateFolder, - CreateJob, CreateWorkflow, CreateWorkspaceMcpServer, DeleteFolder, @@ -12,19 +11,22 @@ import { DeployApi, DeployChat, DeployMcp, + DiffWorkflows, FunctionExecute, GenerateApiKey, GetBlockOutputs, GetBlockUpstreamReferences, GetDeployedWorkflowState, - GetDeploymentVersion, + GetDeploymentLog, GetPlatformActions, GetWorkflowData, + GetWorkflowRunOptions, Glob as GlobTool, Grep as GrepTool, ListFolders, ListUserWorkspaces, ListWorkspaceMcpServers, + LoadDeployment, ManageCredential, ManageCustomTool, ManageJob, @@ -36,17 +38,18 @@ import { OauthGetAuthLink, OauthRequestAccess, OpenResource, + PromoteToLive, Read as ReadTool, Redeploy, RenameWorkflow, RestoreResource, - RevertToVersion, RunBlock, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, SetBlockEnabled, SetGlobalWorkflowVariables, + UpdateDeploymentVersion, UpdateJobHistory, UpdateWorkspaceMcpServer, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -62,15 +65,17 @@ import { executeCheckDeploymentStatus, executeCreateWorkspaceMcpServer, executeDeleteWorkspaceMcpServer, - executeGetDeploymentVersion, + executeDiffWorkflows, + executeGetDeploymentLog, executeListWorkspaceMcpServers, - executeRevertToVersion, + executeLoadDeployment, + executePromoteToLive, + executeUpdateDeploymentVersion, executeUpdateWorkspaceMcpServer, } from '../tools/handlers/deployment/manage' import { executeFunctionExecute } from '../tools/handlers/function-execute' import { executeCompleteJob, - executeCreateJob, executeManageJob, executeUpdateJobHistory, } from '../tools/handlers/jobs' @@ -105,6 +110,7 @@ import { executeGetBlockUpstreamReferences, executeGetDeployedWorkflowState, executeGetWorkflowData, + executeGetWorkflowRunOptions, executeListFolders, executeListUserWorkspaces, } from '../tools/handlers/workflow/queries' @@ -134,6 +140,7 @@ function buildHandlerMap(): Record { [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), [ListFolders.id]: h(executeListFolders), [GetWorkflowData.id]: h(executeGetWorkflowData), + [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), [GetBlockOutputs.id]: h(executeGetBlockOutputs), [GetBlockUpstreamReferences.id]: h(executeGetBlockUpstreamReferences), [GetDeployedWorkflowState.id]: h(executeGetDeployedWorkflowState), @@ -162,10 +169,12 @@ function buildHandlerMap(): Record { [CreateWorkspaceMcpServer.id]: h(executeCreateWorkspaceMcpServer), [UpdateWorkspaceMcpServer.id]: h(executeUpdateWorkspaceMcpServer), [DeleteWorkspaceMcpServer.id]: h(executeDeleteWorkspaceMcpServer), - [GetDeploymentVersion.id]: h(executeGetDeploymentVersion), - [RevertToVersion.id]: h(executeRevertToVersion), + [GetDeploymentLog.id]: h(executeGetDeploymentLog), + [DiffWorkflows.id]: h(executeDiffWorkflows), + [LoadDeployment.id]: h(executeLoadDeployment), + [PromoteToLive.id]: h(executePromoteToLive), + [UpdateDeploymentVersion.id]: h(executeUpdateDeploymentVersion), - [CreateJob.id]: h(executeCreateJob), [ManageJob.id]: h(executeManageJob), [CompleteJob.id]: h(executeCompleteJob), [UpdateJobHistory.id]: h(executeUpdateJobHistory), diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts new file mode 100644 index 00000000000..627927df4cf --- /dev/null +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts @@ -0,0 +1,25 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { getHiddenToolNames, isToolHiddenInUi } from './hidden-tools' + +describe('isToolHiddenInUi', () => { + it('hides the internal loaders', () => { + expect(isToolHiddenInUi('load_custom_tool')).toBe(true) + expect(isToolHiddenInUi('load_integration_tool')).toBe(true) + // Retained for historical persisted messages even though it is no longer emitted. + expect(isToolHiddenInUi('load_agent_skill')).toBe(true) + }) + + it('does not hide user skill loads, ordinary tools, or undefined', () => { + // load_user_skill renders like the old per-skill loaders so the load is visible. + expect(isToolHiddenInUi('load_user_skill')).toBe(false) + expect(isToolHiddenInUi('read')).toBe(false) + expect(isToolHiddenInUi(undefined)).toBe(false) + }) + + it('exposes the hidden set', () => { + expect(getHiddenToolNames().has('load_custom_tool')).toBe(true) + }) +}) diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index f816e913bfc..5154ad2db1d 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -1,8 +1,7 @@ -const HIDDEN_TOOL_NAMES = new Set([ - 'tool_search_tool_regex', - 'load_agent_skill', - 'load_custom_tool', -]) +// load_agent_skill is retained for historical persisted messages; it is no +// longer emitted now that internal skills autoload. load_user_skill is NOT +// hidden — it renders like the old per-skill loaders so users see the skill load. +const HIDDEN_TOOL_NAMES = new Set(['load_agent_skill', 'load_custom_tool', 'load_integration_tool']) export function isToolHiddenInUi(toolName: string | undefined): boolean { return !!toolName && HIDDEN_TOOL_NAMES.has(toolName) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index a78e9324341..a8873d8bb4f 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -37,24 +37,75 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) - it('formats special workspace file reads as natural language', () => { + it('decodes percent-encoded VFS path segments for display', () => { expect( - resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { - path: 'files/haiku_collection_sim.pptx/compiled-check', + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'files/My%20Report.txt', })?.text - ).toBe('Attempted to read the final file check for haiku_collection_sim.pptx') + ).toBe('Reading My Report.txt') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'workflows/My%20Workflow/meta.json', + })?.text + ).toBe('Read My Workflow') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'files/caf%C3%A9.txt', + })?.text + ).toBe('Reading café.txt') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'files/Quarterly%20Report.docx/content', + })?.text + ).toBe('Read Quarterly Report.docx') + }) + + it('shows only the file name for file reads, dropping the folder path and content qualifier', () => { + // Bare file leaf inside a folder → just the file name (with extension). + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'files/Skills/Skill%20%E2%80%94%20PostHog%20Analytics.md', + })?.text + ).toBe('Read Skill — PostHog Analytics.md') + // Explicit content facet → no "the content of", folder dropped too. expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'files/by-id/87c18b84-2f83-43a4-bed8-8a86f7d42022/compiled-check', + path: 'files/Skills/Skill%20%E2%80%94%20PostHog%20Analytics.md/content', + })?.text + ).toBe('Read Skill — PostHog Analytics.md') + + // Non-content facets keep their descriptive label but still show only the name. + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'files/Reports/brief.docx/meta.json', + })?.text + ).toBe('Reading metadata for brief.docx') + }) + + it('falls back to the raw segment when it is not valid percent-encoding', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'files/100%done.txt', + })?.text + ).toBe('Reading 100%done.txt') + }) + + it('formats special workspace file reads as natural language', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'files/haiku_collection_sim.pptx/compiled-check', })?.text - ).toBe('Read the final file check for this file') + ).toBe('Attempted to read the final file check for haiku_collection_sim.pptx') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'files/by-id/625094cc-2f64-4de9-a39c-452cb8283bb1/content', + path: 'files/Reports/brief.docx/content', })?.text - ).toBe('Read the content of this file') + ).toBe('Read brief.docx') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index dafa45ecc6d..a8952f935a9 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -5,6 +5,7 @@ import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' +import { decodeVfsSegment } from '@/lib/copilot/vfs/path-utils' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' @@ -80,6 +81,20 @@ function formatReadingLabel(target: string | undefined, state: ClientToolCallSta } } +/** + * VFS paths store each segment percent-encoded (see {@link encodeVfsSegment}), so + * a read on "My Report.txt" arrives as "files/My%20Report.txt". Decode for + * display so the user sees the real file name. Falls back to the raw segment when + * it is not valid encoding (e.g. a literal "%" that was never encoded). + */ +function decodeVfsSegmentSafe(segment: string): string { + try { + return decodeVfsSegment(segment) + } catch { + return segment + } +} + function describeReadTarget(path: string | undefined): string | undefined { if (!path) return undefined @@ -87,6 +102,7 @@ function describeReadTarget(path: string | undefined): string | undefined { .split('/') .map((segment) => segment.trim()) .filter(Boolean) + .map(decodeVfsSegmentSafe) if (segments.length === 0) return undefined @@ -107,8 +123,13 @@ function describeReadTarget(path: string | undefined): string | undefined { return stripExtension(resourceName) } -const FILE_SPECIAL_READ_TARGET_PREFIXES: Record = { - content: 'the content of', +// A workspace file is addressed as a directory of facets in the VFS +// (files/{...path}/{name}/{facet}). `content` is the default facet — reading a +// file means reading its content — so it carries no qualifier, matching a bare +// `files/{...path}/{name}` read. The remaining facets are genuinely distinct, so +// they keep a descriptive label. +const FILE_FACET_LABELS: Record = { + content: '', 'meta.json': 'metadata for', style: 'style details for', 'compiled-check': 'the final file check for', @@ -116,21 +137,16 @@ const FILE_SPECIAL_READ_TARGET_PREFIXES: Record = { function describeFileReadTarget(segments: string[]): string { const lastSegment = segments[segments.length - 1] || '' - const specialPrefix = FILE_SPECIAL_READ_TARGET_PREFIXES[lastSegment] - if (specialPrefix) { - return `${specialPrefix} ${describeSpecialFilePathSubject(segments)}` + const facetLabel = FILE_FACET_LABELS[lastSegment] + // Treat the suffix as a facet only when a real file name precedes it; otherwise + // the leaf is the file itself (e.g. a file literally named "content"). + if (facetLabel !== undefined && segments.length > 2) { + const fileName = segments[segments.length - 2] + return facetLabel ? `${facetLabel} ${fileName}` : fileName } - - return segments.slice(1).join('/') || lastSegment -} - -function describeSpecialFilePathSubject(segments: string[]): string { - if (segments[1] === 'by-id') { - const namedRemainder = segments.slice(3, -1).join('/') - return namedRemainder || 'this file' - } - - return segments.slice(1, -1).join('/') || 'this file' + // Show just the file name, not the folder path — these are glanceable status + // lines, and the other resource types already render the leaf only. + return lastSegment } function getLeafResourceSegment(segments: string[]): string { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 0ef9330e810..48d5b085689 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -9,8 +9,8 @@ import { performDeleteWorkflowMcpTool, performUpdateWorkflowMcpTool, } from '@/lib/mcp/orchestration' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' -import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' +import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync' +import { generateParameterSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' import { performChatDeploy, performChatUndeploy, @@ -159,10 +159,30 @@ export async function executeDeployApi( } } + const versionDescription = params.versionDescription?.trim() + if (!versionDescription) { + return { + success: false, + error: + 'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).', + } + } + + const versionName = params.versionName?.trim() + if (!versionName) { + return { + success: false, + error: + 'versionName is required when deploying. Provide a short human-readable label for this deployment version.', + } + } + const result = await performFullDeploy({ workflowId, userId: context.userId, workflowName: workflowRecord.name || undefined, + versionDescription, + versionName, }) if (!result.success) { return { success: false, error: result.error || 'Failed to deploy workflow' } @@ -310,6 +330,24 @@ export async function executeDeployChat( return { success: false, error: 'Chat identifier and title are required' } } + const versionDescription = params.versionDescription?.trim() + if (!versionDescription) { + return { + success: false, + error: + 'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (distinct from the chat-facing description; call diff_workflows with ref1 "live" and ref2 "draft" if unsure).', + } + } + + const versionName = params.versionName?.trim() + if (!versionName) { + return { + success: false, + error: + 'versionName is required when deploying. Provide a short human-readable label for this deployment version (distinct from the chat title).', + } + } + const identifierPattern = /^[a-z0-9-]+$/ if (!identifierPattern.test(identifier)) { return { @@ -360,6 +398,8 @@ export async function executeDeployChat( identifier, title, description: resolvedDescription, + versionDescription, + versionName, customizations: { primaryColor: params.customizations?.primaryColor || @@ -566,10 +606,19 @@ export async function executeDeployMcp( params.toolDescription || workflowRecord.description || `Execute ${workflowRecord.name} workflow` - const parameterSchema = - params.parameterSchema && Object.keys(params.parameterSchema).length > 0 - ? params.parameterSchema - : await generateParameterSchemaForWorkflow(workflowId) + /** + * Build the parameter schema exactly as the deploy modal does: overlay the + * caller-supplied per-parameter descriptions onto the workflow's deployed + * input format, then generate the schema. Names/types/required come from the + * workflow's input trigger — this tool only sets descriptions. + */ + const inputFormat = await getDeployedWorkflowInputFormat(workflowId) + const parameterDescriptions = Object.fromEntries( + (params.parameterDescriptions ?? []) + .filter((entry) => entry && typeof entry.name === 'string' && entry.name.trim() !== '') + .map((entry) => [entry.name, entry.description ?? '']) + ) + const parameterSchema = generateParameterSchema(inputFormat, parameterDescriptions) const baseUrl = getBaseUrl() const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}` const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId) @@ -706,7 +755,7 @@ export async function executeDeployMcp( } export async function executeRedeploy( - params: { workflowId?: string }, + params: { workflowId?: string; versionDescription?: string; versionName?: string }, context: ExecutionContext ): Promise { try { @@ -714,9 +763,30 @@ export async function executeRedeploy( if (!workflowId) { return { success: false, error: 'workflowId is required' } } + const versionDescription = params.versionDescription?.trim() + if (!versionDescription) { + return { + success: false, + error: + 'versionDescription is required. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).', + } + } + const versionName = params.versionName?.trim() + if (!versionName) { + return { + success: false, + error: + 'versionName is required. Provide a short human-readable label for this deployment version.', + } + } await ensureWorkflowAccess(workflowId, context.userId, 'admin') - const result = await performFullDeploy({ workflowId, userId: context.userId }) + const result = await performFullDeploy({ + workflowId, + userId: context.userId, + versionDescription, + versionName, + }) if (!result.success) { return { success: false, error: result.error || 'Failed to redeploy workflow' } } diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts index 6af4a09b638..bfef16561ac 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts @@ -6,11 +6,18 @@ import { auditMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns } import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionContext } from '@/lib/copilot/request/types' -const { ensureWorkflowAccessMock } = vi.hoisted(() => ({ +const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), + checkNeedsRedeploymentMock: vi.fn(), })) const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion +const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion + +const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock } = vi.hoisted(() => ({ + resolveWorkflowStateRefMock: vi.fn(), + generateWorkflowDiffSummaryMock: vi.fn(), +})) vi.mock('@sim/db', () => ({ db: { @@ -53,24 +60,50 @@ vi.mock('../access', () => ({ vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -import { executeRevertToVersion } from './manage' +vi.mock('./state-refs', () => ({ + resolveWorkflowStateRef: resolveWorkflowStateRefMock, +})) + +vi.mock('@/lib/workflows/comparison', () => ({ + generateWorkflowDiffSummary: generateWorkflowDiffSummaryMock, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + checkNeedsRedeployment: checkNeedsRedeploymentMock, +})) + +import { db } from '@sim/db' +import { + executeCheckDeploymentStatus, + executeDiffWorkflows, + executeGetDeploymentLog, + executeLoadDeployment, + executePromoteToLive, +} from './manage' -describe('executeRevertToVersion', () => { +function selectChain(result: unknown[], resolveOnWhere = false) { + const chain = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => (resolveOnWhere ? Promise.resolve(result) : chain)), + orderBy: vi.fn(() => Promise.resolve(result)), + limit: vi.fn(() => Promise.resolve(result)), + } + return chain +} + +describe('executeLoadDeployment', () => { beforeEach(() => { vi.clearAllMocks() - vi.stubGlobal('fetch', vi.fn()) ensureWorkflowAccessMock.mockResolvedValue({ workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, }) }) - it('uses the shared revert helper instead of the HTTP route', async () => { - performRevertToVersionMock.mockResolvedValue({ - success: true, - lastSaved: 12345, - }) + it('loads a version into the draft via performRevertToVersion', async () => { + performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 12345 }) - const result = await executeRevertToVersion({ workflowId: 'wf-1', version: 7 }, { + const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) @@ -82,30 +115,248 @@ describe('executeRevertToVersion', () => { userId: 'user-1', workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, }) - expect(global.fetch).not.toHaveBeenCalled() expect(result).toEqual({ success: true, output: { - message: 'Reverted workflow to deployment version 7', + workflowId: 'wf-1', + message: 'Loaded version 7 into the workflow draft', lastSaved: 12345, }, }) }) + it('maps "live" to the active version', async () => { + performRevertToVersionMock.mockResolvedValue({ success: true, lastSaved: 1 }) + + await executeLoadDeployment({ workflowId: 'wf-1', version: 'live' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(performRevertToVersionMock).toHaveBeenCalledWith( + expect.objectContaining({ version: 'active' }) + ) + }) + + it('rejects "draft"', async () => { + const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 'draft' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result.success).toBe(false) + expect(performRevertToVersionMock).not.toHaveBeenCalled() + }) + it('returns shared helper failures directly', async () => { performRevertToVersionMock.mockResolvedValue({ success: false, error: 'Deployment version not found', }) - const result = await executeRevertToVersion({ workflowId: 'wf-1', version: 7 }, { + const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { userId: 'user-1', workflowId: 'wf-1', } as ExecutionContext) - expect(result).toEqual({ - success: false, - error: 'Deployment version not found', + expect(result).toEqual({ success: false, error: 'Deployment version not found' }) + }) +}) + +describe('executePromoteToLive', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, + }) + }) + + it('promotes a version via performActivateVersion', async () => { + performActivateVersionMock.mockResolvedValue({ + success: true, + deployedAt: new Date('2026-05-30T00:00:00.000Z'), + }) + + const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') + expect(performActivateVersionMock).toHaveBeenCalledWith({ + workflowId: 'wf-1', + version: 3, + userId: 'user-1', + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, + }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + workflowId: 'wf-1', + version: 3, + message: 'Promoted version 3 to live', + }) + }) + + it('rejects a non-numeric version like "live"', async () => { + const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result.success).toBe(false) + expect(performActivateVersionMock).not.toHaveBeenCalled() + }) +}) + +describe('executeGetDeploymentLog', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, + }) + }) + + it('returns versions from the deployment-version table', async () => { + const rows = [ + { + id: 'v2', + version: 2, + name: null, + description: null, + isActive: true, + createdAt: new Date('2026-05-30T00:00:00.000Z'), + createdBy: 'user-1', + }, + { + id: 'v1', + version: 1, + name: 'first', + description: 'initial', + isActive: false, + createdAt: new Date('2026-05-29T00:00:00.000Z'), + createdBy: null, + }, + ] + vi.mocked(db.select).mockReturnValueOnce(selectChain(rows) as never) + + const result = await executeGetDeploymentLog({ workflowId: 'wf-1' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + workflowId: 'wf-1', + count: 2, + versions: [ + { id: 'v2', version: 2, isActive: true }, + { id: 'v1', version: 1, name: 'first', description: 'initial', isActive: false }, + ], + }) + }) +}) + +describe('executeDiffWorkflows', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('diffs ref2 against ref1 and returns the structured summary', async () => { + resolveWorkflowStateRefMock + .mockResolvedValueOnce({ state: { base: true }, ref: '1', version: 1, isActive: false }) + .mockResolvedValueOnce({ state: { target: true }, ref: 'live', version: 2, isActive: true }) + + const summary = { + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + } + generateWorkflowDiffSummaryMock.mockReturnValue(summary) + + const result = await executeDiffWorkflows({ workflowId: 'wf-1', ref1: 1, ref2: 'live' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 1, 'user-1') + expect(resolveWorkflowStateRefMock).toHaveBeenCalledWith('wf-1', 'live', 'user-1') + // ref1 = base/previous, ref2 = target/current. + expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true }) + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + workflowId: 'wf-1', + ref1: { ref: '1', version: 1 }, + ref2: { ref: 'live', version: 2, isActive: true }, + diff: { hasChanges: false }, + }) + }) +}) + +describe('executeCheckDeploymentStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureWorkflowAccessMock.mockResolvedValue({ + workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, + }) + checkNeedsRedeploymentMock.mockResolvedValue(false) + }) + + it('uses the shared redeployment freshness helper for deployed APIs', async () => { + vi.mocked(db.select) + .mockReturnValueOnce( + selectChain([{ isDeployed: true, deployedAt: new Date('2026-05-28') }]) as never + ) + .mockReturnValueOnce(selectChain([]) as never) + .mockReturnValueOnce(selectChain([], true) as never) + checkNeedsRedeploymentMock.mockResolvedValueOnce(true) + + const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(checkNeedsRedeploymentMock).toHaveBeenCalledWith('wf-1') + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: true, + api: { + isDeployed: true, + needsRedeployment: true, + }, + }) + }) + + it('does not check redeployment freshness for undeployed APIs', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(selectChain([{ isDeployed: false, deployedAt: null }]) as never) + .mockReturnValueOnce(selectChain([]) as never) + .mockReturnValueOnce(selectChain([], true) as never) + + const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { + userId: 'user-1', + workflowId: 'wf-1', + } as ExecutionContext) + + expect(checkNeedsRedeploymentMock).not.toHaveBeenCalled() + expect(result.success).toBe(true) + expect(result.output).toMatchObject({ + isDeployed: false, + api: { + isDeployed: false, + needsRedeployment: false, + }, }) }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index bd84602a560..4c40ae6820f 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -7,22 +7,31 @@ import { workflowMcpTool, } from '@sim/db/schema' import { toError } from '@sim/utils/errors' -import { and, eq, inArray, isNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { performCreateWorkflowMcpServer, performDeleteWorkflowMcpServer, performUpdateWorkflowMcpServer, } from '@/lib/mcp/orchestration' -import { performRevertToVersion } from '@/lib/workflows/orchestration' +import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' +import { performActivateVersion, performRevertToVersion } from '@/lib/workflows/orchestration' +import { updateDeploymentVersionMetadata } from '@/lib/workflows/persistence/utils' +import { checkNeedsRedeployment } from '@/app/api/workflows/utils' import { ensureWorkflowAccess, ensureWorkspaceAccess } from '../access' import type { CheckDeploymentStatusParams, CreateWorkspaceMcpServerParams, DeleteWorkspaceMcpServerParams, + DiffWorkflowsParams, + GetDeploymentLogParams, ListWorkspaceMcpServersParams, + LoadDeploymentParams, + PromoteToLiveParams, + UpdateDeploymentVersionParams, UpdateWorkspaceMcpServerParams, } from '../param-types' +import { resolveWorkflowStateRef } from './state-refs' export async function executeCheckDeploymentStatus( params: CheckDeploymentStatusParams, @@ -60,12 +69,13 @@ export async function executeCheckDeploymentStatus( ]) const isApiDeployed = apiDeploy[0]?.isDeployed || false + const needsRedeployment = isApiDeployed ? await checkNeedsRedeployment(workflowId) : false const apiDetails = { isDeployed: isApiDeployed, deployedAt: apiDeploy[0]?.deployedAt || null, endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', - needsRedeployment: false, + needsRedeployment, } const isChatDeployed = !!chatDeploy[0] @@ -336,8 +346,8 @@ export async function executeDeleteWorkspaceMcpServer( } } -export async function executeGetDeploymentVersion( - params: { workflowId?: string; version?: number }, +export async function executeGetDeploymentLog( + params: GetDeploymentLogParams, context: ExecutionContext ): Promise { try { @@ -345,36 +355,124 @@ export async function executeGetDeploymentVersion( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const version = params.version - if (version === undefined || version === null) { - return { success: false, error: 'version is required' } - } - await ensureWorkflowAccess(workflowId, context.userId) - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) + const rows = await db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + createdBy: workflowDeploymentVersion.createdBy, + }) .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.version, version) - ) - ) - .limit(1) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + .orderBy(desc(workflowDeploymentVersion.version)) + + const versions = rows.map((r) => ({ + id: r.id, + version: r.version, + name: r.name ?? undefined, + description: r.description ?? undefined, + isActive: r.isActive, + createdAt: r.createdAt.toISOString(), + createdBy: r.createdBy ?? undefined, + })) - if (!row?.state) { - return { success: false, error: `Deployment version ${version} not found` } + return { success: true, output: { workflowId, count: versions.length, versions } } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +// Cap individual sub-block before/after values so a large diff can't blow the +// tool-result budget. Oversized values are replaced with an elision marker. +const MAX_DIFF_VALUE_BYTES = 2000 + +function guardDiffValue(value: unknown): unknown { + try { + const json = JSON.stringify(value) + if (json && json.length > MAX_DIFF_VALUE_BYTES) { + return { elided: true, bytes: json.length } + } + } catch { + return { elided: true, reason: 'unserializable' } + } + return value +} + +export async function executeDiffWorkflows( + params: DiffWorkflowsParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + if (params.ref1 === undefined || params.ref2 === undefined) { + return { success: false, error: 'ref1 and ref2 are required' } + } + + // resolveWorkflowStateRef enforces read access on the workflow. + const [side1, side2] = await Promise.all([ + resolveWorkflowStateRef(workflowId, params.ref1, context.userId), + resolveWorkflowStateRef(workflowId, params.ref2, context.userId), + ]) + + // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. + const summary = generateWorkflowDiffSummary(side2.state, side1.state) + const diff = { + ...summary, + modifiedBlocks: summary.modifiedBlocks.map((block) => ({ + ...block, + changes: block.changes.map((change) => ({ + field: change.field, + oldValue: guardDiffValue(change.oldValue), + newValue: guardDiffValue(change.newValue), + })), + })), } - return { success: true, output: { version, deployedState: row.state } } + return { + success: true, + output: { + workflowId, + ref1: { ref: side1.ref, version: side1.version, isActive: side1.isActive }, + ref2: { ref: side2.ref, version: side2.version, isActive: side2.isActive }, + diff, + }, + } } catch (error) { return { success: false, error: toError(error).message } } } -export async function executeRevertToVersion( - params: { workflowId?: string; version?: number }, +function resolveLoadVersion( + raw: number | string +): { ok: true; version: number | 'active' } | { ok: false; error: string } { + if (typeof raw === 'number' && Number.isFinite(raw)) return { ok: true, version: raw } + if (typeof raw === 'string') { + const t = raw.trim().toLowerCase() + if (t === 'live' || t === 'active') return { ok: true, version: 'active' } + if (t === 'draft' || t === 'current') { + return { + ok: false, + error: 'Cannot load "draft" — load_deployment restores a deployed version into the draft', + } + } + if (/^\d+$/.test(t)) return { ok: true, version: Number.parseInt(t, 10) } + } + return { + ok: false, + error: `Invalid version "${String(raw)}": expected a version number or "live"`, + } +} + +export async function executeLoadDeployment( + params: LoadDeploymentParams, context: ExecutionContext ): Promise { try { @@ -382,10 +480,13 @@ export async function executeRevertToVersion( if (!workflowId) { return { success: false, error: 'workflowId is required' } } - const version = params.version - if (version === undefined || version === null) { + if (params.version === undefined || params.version === null) { return { success: false, error: 'version is required' } } + const target = resolveLoadVersion(params.version) + if (!target.ok) { + return { success: false, error: target.error } + } const { workflow: workflowRecord } = await ensureWorkflowAccess( workflowId, @@ -394,19 +495,21 @@ export async function executeRevertToVersion( ) const result = await performRevertToVersion({ workflowId, - version, + version: target.version, userId: context.userId, workflow: workflowRecord as Record, }) if (!result.success) { - return { success: false, error: result.error || 'Failed to revert' } + return { success: false, error: result.error || 'Failed to load deployment' } } + const label = target.version === 'active' ? 'the live deployment' : `version ${target.version}` return { success: true, output: { - message: `Reverted workflow to deployment version ${version}`, + workflowId, + message: `Loaded ${label} into the workflow draft`, lastSaved: result.lastSaved, }, } @@ -414,3 +517,109 @@ export async function executeRevertToVersion( return { success: false, error: toError(error).message } } } + +function normalizePromoteVersion(raw: number | string): number | null { + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) return Number.parseInt(raw.trim(), 10) + return null +} + +export async function executePromoteToLive( + params: PromoteToLiveParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + if (params.version === undefined || params.version === null) { + return { success: false, error: 'version is required' } + } + const version = normalizePromoteVersion(params.version) + if (version === null) { + return { + success: false, + error: + 'version must be a deployment version number (use load_deployment to change the draft; "live" is already live)', + } + } + + const { workflow: workflowRecord } = await ensureWorkflowAccess( + workflowId, + context.userId, + 'admin' + ) + const result = await performActivateVersion({ + workflowId, + version, + userId: context.userId, + workflow: workflowRecord as Record, + }) + + if (!result.success) { + return { success: false, error: result.error || 'Failed to promote version' } + } + + return { + success: true, + output: { + workflowId, + version, + message: `Promoted version ${version} to live`, + deployedAt: result.deployedAt ? new Date(result.deployedAt).toISOString() : undefined, + warnings: result.warnings, + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + +export async function executeUpdateDeploymentVersion( + params: UpdateDeploymentVersionParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + if (params.version === undefined || params.version === null) { + return { success: false, error: 'version is required' } + } + const version = normalizePromoteVersion(params.version) + if (version === null) { + return { + success: false, + error: 'version must be a deployment version number (use get_deployment_log to find it)', + } + } + + const name = typeof params.name === 'string' ? params.name.trim() : undefined + const description = + typeof params.description === 'string' ? params.description.trim() : undefined + if (name === undefined && description === undefined) { + return { success: false, error: 'Provide a name and/or description to update' } + } + + await ensureWorkflowAccess(workflowId, context.userId, 'write') + + const updated = await updateDeploymentVersionMetadata({ + workflowId, + version, + ...(name !== undefined ? { name: name || null } : {}), + ...(description !== undefined ? { description: description || null } : {}), + }) + if (!updated) { + return { success: false, error: `Deployment version ${version} not found` } + } + + return { + success: true, + output: { workflowId, version, name: updated.name, description: updated.description }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts new file mode 100644 index 00000000000..8dde36ba6f0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts @@ -0,0 +1,95 @@ +import { db } from '@sim/db' +import { workflowDeploymentVersion } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { loadWorkflowDeploymentSnapshot } from '@/lib/workflows/persistence/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { ensureWorkflowAccess } from '../access' + +/** Canonical workflow-state selector: a deployment version number, the live + * (active) deployment, or the current draft. */ +export type WorkflowRef = number | 'live' | 'draft' + +export interface ResolvedWorkflowRef { + state: WorkflowState + /** Human-readable ref label: "live", "draft", or the version number as a string. */ + ref: string + version?: number + isActive?: boolean + createdAt?: string +} + +/** + * Parse a raw ref param into a canonical WorkflowRef. + * Accepts a version number, a numeric string, "live"/"active", or "draft"/"current". + * Throws on anything else. + */ +export function parseWorkflowRef(raw: unknown): WorkflowRef { + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + if (typeof raw === 'string') { + const trimmed = raw.trim().toLowerCase() + if (trimmed === 'live' || trimmed === 'active') return 'live' + if (trimmed === 'draft' || trimmed === 'current') return 'draft' + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10) + } + throw new Error(`Invalid ref "${String(raw)}": expected a version number, "live", or "draft"`) +} + +/** + * Resolve a (workflowId, ref) pair to a WorkflowState for diffing. Raw stored + * snapshots are used for version/live (matching checkNeedsRedeployment's baseline), + * and loadWorkflowDeploymentSnapshot is used for draft. Requires read access. + */ +export async function resolveWorkflowStateRef( + workflowId: string, + rawRef: unknown, + userId: string +): Promise { + const ref = parseWorkflowRef(rawRef) + await ensureWorkflowAccess(workflowId, userId, 'read') + + if (ref === 'draft') { + const state = await loadWorkflowDeploymentSnapshot(workflowId) + if (!state) { + throw new Error(`Workflow ${workflowId} has no draft state`) + } + return { state, ref: 'draft' } + } + + const whereClause = + ref === 'live' + ? and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + : and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.version, ref) + ) + + const [row] = await db + .select({ + version: workflowDeploymentVersion.version, + state: workflowDeploymentVersion.state, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + }) + .from(workflowDeploymentVersion) + .where(whereClause) + .limit(1) + + if (!row?.state) { + throw new Error( + ref === 'live' + ? `Workflow ${workflowId} has no active deployment` + : `Deployment version ${ref} not found for workflow ${workflowId}` + ) + } + + return { + state: row.state as WorkflowState, + ref: ref === 'live' ? 'live' : String(ref), + version: row.version, + isActive: row.isActive, + createdAt: row.createdAt?.toISOString(), + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 0968b3ffa86..3df97818c0e 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,5 +1,10 @@ import { createLogger } from '@sim/logger' -import { getTableById, queryRows } from '@/lib/table/service' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { getTableById, listTables, queryRows } from '@/lib/table/service' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer, findWorkspaceFileRecord, @@ -13,36 +18,100 @@ const logger = createLogger('CopilotFunctionExecute') const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_TOTAL_SIZE = 50 * 1024 * 1024 +const MAX_MOUNTED_FILES = 500 interface SandboxFile { path: string content: string + encoding?: 'base64' +} + +interface CanonicalFileInput { + path: string + sandboxPath?: string +} + +interface CanonicalDirectoryInput { + path: string + sandboxPath?: string +} + +interface CanonicalTableInput { + tableId?: string + path?: string + sandboxPath?: string +} + +function tableNameFromVfsPath(tableRef: string): string | null { + if (!tableRef.startsWith('tables/')) return null + const segments = decodeVfsPathSegments(tableRef) + const metaIndex = segments.lastIndexOf('meta.json') + return segments[metaIndex > 0 ? metaIndex - 1 : segments.length - 1] ?? null +} + +async function resolveTableRef( + tableRef: string, + tablePathLookup?: Map>[number]> +) { + if (!tableRef.startsWith('tables/')) { + return getTableById(tableRef) + } + + const tableName = tableNameFromVfsPath(tableRef) + if (!tableName) return null + return tablePathLookup?.get(tableName) ?? null } async function resolveInputFiles( workspaceId: string, inputFiles?: unknown[], - inputTables?: unknown[] + inputTables?: unknown[], + inputDirectories?: unknown[] ): Promise { const sandboxFiles: SandboxFile[] = [] let totalSize = 0 if (inputFiles?.length && workspaceId) { - const allFiles = await listWorkspaceFiles(workspaceId) + const allFiles = await listWorkspaceFiles(workspaceId, { + includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + }) for (const fileRef of inputFiles) { - if (typeof fileRef !== 'string') continue - const record = findWorkspaceFileRecord(allFiles, fileRef) - if (!record) { - logger.warn('Input file not found', { fileRef }) + const filePath = + typeof fileRef === 'string' + ? fileRef + : fileRef && typeof fileRef === 'object' + ? (fileRef as CanonicalFileInput).path + : undefined + if (!filePath) continue + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: filePath }) + if (!alias && isPlanAliasPath(filePath)) { + logger.warn('Unsupported plan alias input file path', { filePath }) continue } - if (record.size > MAX_FILE_SIZE) { - logger.warn('Input file exceeds size limit', { fileId: record.id, size: record.size }) + if (alias?.kind === 'plans_dir') { + logger.warn('Input file is a plan alias directory', { filePath }) continue } + const record = findWorkspaceFileRecord(allFiles, alias?.backingPath ?? filePath) + if (!record) { + if (filePath.startsWith('uploads/')) { + throw new Error( + `Cannot mount "${filePath}": uploads/ files are not mountable into the sandbox. Use materialize_file to save it to a files/... path first, then mount that canonical path.` + ) + } + throw new Error( + `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` + ) + } + if (record.size > MAX_FILE_SIZE) { + throw new Error( + `Input file "${filePath}" is ${Math.round(record.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.` + ) + } if (totalSize + record.size > MAX_TOTAL_SIZE) { - logger.warn('Total input size limit reached') - break + throw new Error( + `Mounting "${filePath}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller files.` + ) } const buffer = await fetchWorkspaceFileBuffer(record) totalSize += buffer.length @@ -50,27 +119,152 @@ async function resolveInputFiles( record.type || '' ) const content = isText ? buffer.toString('utf-8') : buffer.toString('base64') + const explicitSandboxPath = + typeof fileRef === 'object' && fileRef !== null + ? (fileRef as CanonicalFileInput).sandboxPath + : undefined sandboxFiles.push({ - path: getSandboxWorkspaceFilePath(record), + path: + explicitSandboxPath || + (alias ? workflowAliasSandboxPath(alias.aliasPath) : getSandboxWorkspaceFilePath(record)), content, encoding: isText ? undefined : 'base64', - } as SandboxFile) + }) + } + } + + if (inputDirectories?.length && workspaceId) { + const folders = await listWorkspaceFileFolders(workspaceId, { + includeReservedSystemFolders: isMothershipBetaFeaturesEnabled, + }) + const allFiles = await listWorkspaceFiles(workspaceId, { + folders, + includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + }) + for (const dirRef of inputDirectories) { + const dirPath = + typeof dirRef === 'string' + ? dirRef + : dirRef && typeof dirRef === 'object' + ? (dirRef as CanonicalDirectoryInput).path + : undefined + if (!dirPath) continue + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: dirPath }) + if (alias && alias.kind !== 'plans_dir') { + throw new Error(`Input directory is a plan alias file, not a directory: ${dirPath}`) + } + if (!alias && isPlanAliasPath(dirPath)) { + throw new Error(`Unsupported plan alias directory: ${dirPath}`) + } + const backingDirPath = alias?.backingPath ?? dirPath + const folderSegments = decodeVfsPathSegments(backingDirPath.replace(/^\/?files\/?/, '')) + const folderDisplayPath = folderSegments.join('/') + const folder = folders.find((candidate) => candidate.path === folderDisplayPath) + if (!folder) { + throw new Error(`Input directory not found: ${dirPath}`) + } + const mountRoot = + typeof dirRef === 'object' && + dirRef !== null && + (dirRef as CanonicalDirectoryInput).sandboxPath + ? (dirRef as CanonicalDirectoryInput).sandboxPath! + : alias + ? workflowAliasSandboxPath(alias.aliasPath) + : `/home/user/files/${encodeVfsPathSegments(folder.path.split('/'))}` + const descendants = allFiles.filter((file) => { + if (!file.folderPath) return false + return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) + }) + if (descendants.length > MAX_MOUNTED_FILES) { + throw new Error( + `Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.` + ) + } + logger.info('Mounting workspace directory for function_execute', { + vfsPath: dirPath, + sandboxPath: mountRoot, + fileCount: descendants.length, + }) + const childFolders = folders.filter( + (candidate) => + candidate.path !== folder.path && candidate.path.startsWith(`${folder.path}/`) + ) + if (descendants.length === 0 && childFolders.length === 0) { + sandboxFiles.push({ path: `${mountRoot}/.keep`, content: '' }) + continue + } + for (const childFolder of childFolders) { + const hasFiles = descendants.some((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === childFolder.path || + file.folderPath.startsWith(`${childFolder.path}/`) + ) + }) + if (!hasFiles) { + const relativeFolder = childFolder.path.slice(folder.path.length).replace(/^\/+/, '') + sandboxFiles.push({ path: `${mountRoot}/${relativeFolder}/.keep`, content: '' }) + } + } + for (const record of descendants) { + if (record.size > MAX_FILE_SIZE) { + throw new Error(`Input file exceeds size limit: ${record.name}`) + } + if (totalSize + record.size > MAX_TOTAL_SIZE) { + throw new Error('Total input size limit exceeded while mounting directory') + } + const buffer = await fetchWorkspaceFileBuffer(record) + totalSize += buffer.length + const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( + record.type || '' + ) + const relativeFolder = + record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' + const relativePath = alias + ? encodeVfsPathSegments( + [relativeFolder, record.name].filter(Boolean).join('/').split('/') + ) + : [relativeFolder, record.name].filter(Boolean).join('/') + sandboxFiles.push({ + path: `${mountRoot}/${relativePath}`, + content: isText ? buffer.toString('utf-8') : buffer.toString('base64'), + encoding: isText ? undefined : 'base64', + }) + } } } if (inputTables?.length) { - for (const tableId of inputTables) { - if (typeof tableId !== 'string') continue - const table = await getTableById(tableId) + const hasTablePathRefs = inputTables.some((tableRef) => { + const tableId = + typeof tableRef === 'string' + ? tableRef + : tableRef && typeof tableRef === 'object' + ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path + : undefined + return typeof tableId === 'string' && tableId.startsWith('tables/') + }) + const tablePathLookup = hasTablePathRefs + ? new Map((await listTables(workspaceId)).map((table) => [table.name, table])) + : undefined + for (const tableRef of inputTables) { + const tableId = + typeof tableRef === 'string' + ? tableRef + : tableRef && typeof tableRef === 'object' + ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path + : undefined + if (!tableId) continue + const table = await resolveTableRef(tableId, tablePathLookup) if (!table || table.workspaceId !== workspaceId) { - logger.warn('Input table not found', { tableId }) - continue + throw new Error( + `Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.` + ) } const rows = await queryRows(table, {}, 'copilot-fn-exec') - if (!rows.rows?.length) continue - const allKeys = new Set() - for (const row of rows.rows) { + const allKeys = new Set(table.schema.columns.map((column) => column.name)) + for (const row of rows.rows ?? []) { if (row.data && typeof row.data === 'object') { for (const key of Object.keys(row.data as Record)) { allKeys.add(key) @@ -79,7 +273,7 @@ async function resolveInputFiles( } const headers = Array.from(allKeys) const csvLines = [headers.join(',')] - for (const row of rows.rows) { + for (const row of rows.rows ?? []) { const data = (row.data || {}) as Record csvLines.push( headers @@ -94,8 +288,12 @@ async function resolveInputFiles( ) } const csvContent = csvLines.join('\n') + const sandboxPath = + typeof tableRef === 'object' && tableRef !== null + ? (tableRef as CanonicalTableInput).sandboxPath + : undefined sandboxFiles.push({ - path: `/home/user/tables/${tableId}.csv`, + path: sandboxPath || `/home/user/tables/${table.id}.csv`, content: csvContent, }) } @@ -118,11 +316,30 @@ export async function executeFunctionExecute( } if (context.workspaceId) { - const inputFiles = enrichedParams.inputFiles as unknown[] | undefined - const inputTables = enrichedParams.inputTables as unknown[] | undefined + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] - if (inputFiles?.length || inputTables?.length) { - const resolved = await resolveInputFiles(context.workspaceId, inputFiles, inputTables) + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories + ) if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] enrichedParams._sandboxFiles = [...existing, ...resolved] diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index 84184b0e357..24fe894c1b7 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -3,26 +3,8 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockFindUpload, - mockFetchBuffer, - mockParseFileRows, - mockInferSchema, - mockCoerceRows, - mockCreateTable, - mockBatchInsertRows, - mockDeleteTable, - mockGetLimits, -} = vi.hoisted(() => ({ +const { mockFindUpload } = vi.hoisted(() => ({ mockFindUpload: vi.fn(), - mockFetchBuffer: vi.fn(), - mockParseFileRows: vi.fn(), - mockInferSchema: vi.fn(), - mockCoerceRows: vi.fn(), - mockCreateTable: vi.fn(), - mockBatchInsertRows: vi.fn(), - mockDeleteTable: vi.fn(), - mockGetLimits: vi.fn(), })) vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ @@ -34,20 +16,11 @@ vi.mock('@/lib/uploads', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchBuffer, + fetchWorkspaceFileBuffer: vi.fn(), })) -vi.mock('@/lib/table', () => ({ - CSV_MAX_BATCH_SIZE: 1000, - TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 100 }, - parseFileRows: mockParseFileRows, - inferSchemaFromCsv: mockInferSchema, - coerceRowsForTable: mockCoerceRows, - createTable: mockCreateTable, - batchInsertRows: mockBatchInsertRows, - deleteTable: mockDeleteTable, - getWorkspaceTableLimits: mockGetLimits, - sanitizeName: (raw: string) => raw.replace(/[^a-zA-Z0-9_]/g, '_'), +vi.mock('@/lib/copilot/vfs/path-utils', () => ({ + canonicalWorkspaceFilePath: vi.fn(), })) vi.mock('@/lib/workflows/operations/import-export', () => ({ parseWorkflowJson: vi.fn() })) @@ -65,113 +38,30 @@ const context = { workflowId: 'wf-1', } as ExecutionContext -const uploadRow = { - id: 'file-1', - workspaceId: 'ws-1', - displayName: 'data.csv', - originalName: 'data.csv', - key: 'uploads/data.csv', - size: 123, - contentType: 'text/csv', - userId: 'user-1', - deletedAt: null, - uploadedAt: new Date(), - updatedAt: new Date(), -} - -describe('executeMaterializeFile - table operation', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFindUpload.mockResolvedValue(uploadRow) - mockFetchBuffer.mockResolvedValue(Buffer.from('name\nAlice')) - mockParseFileRows.mockResolvedValue({ headers: ['name'], rows: [{ name: 'Alice' }] }) - mockInferSchema.mockReturnValue({ - columns: [{ name: 'name', type: 'string' }], - headerToColumn: new Map([['name', 'name']]), - }) - mockCoerceRows.mockReturnValue([{ name: 'Alice' }]) - mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 }) - mockCreateTable.mockResolvedValue({ id: 'tbl_abc', name: 'data', schema: { columns: [] } }) - mockBatchInsertRows.mockResolvedValue([{ id: 'row-1' }]) - mockDeleteTable.mockResolvedValue(undefined) - }) - - it('creates a table and returns a table resource', async () => { - const result = await executeMaterializeFile( - { fileNames: ['data.csv'], operation: 'table' }, - context - ) - - expect(result.success).toBe(true) - expect(mockCreateTable).toHaveBeenCalledTimes(1) - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'data', - workspaceId: 'ws-1', - userId: 'user-1', - maxRows: 1_000_000, - maxTables: 50, - }), - expect.any(String) - ) - expect(result.resources).toEqual([{ type: 'table', id: 'tbl_abc', title: 'data' }]) - expect((result.output as { succeeded: string[] }).succeeded).toEqual(['data.csv']) - }) - - it('honors an explicit tableName', async () => { - await executeMaterializeFile( - { fileNames: ['data.csv'], operation: 'table', tableName: 'My Customers' }, - context - ) - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ name: 'My_Customers' }), - expect.any(String) - ) - }) - - it('deletes the table and fails when row insertion throws', async () => { - mockBatchInsertRows.mockRejectedValueOnce(new Error('insert exploded')) +describe('executeMaterializeFile - unsupported operation', () => { + beforeEach(() => vi.clearAllMocks()) + it('rejects the table operation and points to the table subagent', async () => { const result = await executeMaterializeFile( { fileNames: ['data.csv'], operation: 'table' }, context ) expect(result.success).toBe(false) - expect(mockDeleteTable).toHaveBeenCalledWith('tbl_abc', expect.any(String)) - expect((result.output as { failed: Array<{ error: string }> }).failed[0].error).toContain( - 'insert exploded' - ) - }) - - it('fails fast (no table created) when the upload is missing', async () => { - mockFindUpload.mockResolvedValue(null) - - const result = await executeMaterializeFile( - { fileNames: ['missing.csv'], operation: 'table' }, - context - ) - - expect(result.success).toBe(false) - expect(mockCreateTable).not.toHaveBeenCalled() - expect((result.output as { failed: Array<{ error: string }> }).failed[0].error).toContain( - 'Upload not found' - ) + expect(result.error).toContain('Unsupported materialize_file operation "table"') + expect(result.error).toContain('table subagent') + expect(mockFindUpload).not.toHaveBeenCalled() }) -}) - -describe('executeMaterializeFile - unsupported operation', () => { - beforeEach(() => vi.clearAllMocks()) - it('rejects an unimplemented operation instead of silently saving', async () => { + it('rejects the knowledge_base operation and points to the knowledge subagent', async () => { const result = await executeMaterializeFile( { fileNames: ['data.csv'], operation: 'knowledge_base' }, context ) expect(result.success).toBe(false) - expect(result.error).toContain('not implemented') + expect(result.error).toContain('Unsupported materialize_file operation "knowledge_base"') + expect(result.error).toContain('knowledge subagent') expect(mockFindUpload).not.toHaveBeenCalled() - expect(mockCreateTable).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 6783267b73e..a514f6f735c 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -7,19 +7,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' -import { - batchInsertRows, - CSV_MAX_BATCH_SIZE, - coerceRowsForTable, - createTable, - deleteTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - parseFileRows, - sanitizeName, - TABLE_LIMITS, - type TableSchema, -} from '@/lib/table' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getServePathPrefix } from '@/lib/uploads' import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' @@ -71,14 +59,21 @@ async function executeSave(fileName: string, chatId: string): Promise), rather than echoing the raw display name. + const canonicalPath = canonicalWorkspaceFilePath({ + folderPath: null, + name: updated.originalName, + }) + return { success: true, output: { - message: `File "${fileName}" materialized. It is now available at files/${fileName} and will persist independently of this chat.`, + message: `File "${updated.originalName}" materialized. It is now available at ${canonicalPath} and will persist independently of this chat.`, fileId: updated.id, - path: `files/${fileName}`, + path: canonicalPath, }, - resources: [{ type: 'file', id: updated.id, title: fileName }], + resources: [{ type: 'file', id: updated.id, title: updated.originalName }], } } @@ -192,89 +187,6 @@ async function executeImport( } } -async function executeTable( - fileName: string, - chatId: string, - workspaceId: string, - userId: string, - requestedTableName?: string -): Promise { - const row = await findMothershipUploadRowByChatAndName(chatId, fileName) - if (!row) { - return { - success: false, - error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`, - } - } - - const fileRecord = toFileRecord(row) - const buffer = await fetchWorkspaceFileBuffer(fileRecord) - const { headers, rows } = await parseFileRows(buffer, fileRecord.name, fileRecord.type) - if (rows.length === 0) { - return { success: false, error: `"${fileName}" contains no data rows.` } - } - - const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) - const baseName = requestedTableName?.trim() || fileName.replace(/\.[^.]+$/, '') - const tableName = sanitizeName(baseName, 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const schema: TableSchema = { columns } - const planLimits = await getWorkspaceTableLimits(workspaceId) - const requestId = generateId().slice(0, 8) - - const table = await createTable( - { - name: tableName, - description: `Imported from ${fileName}`, - schema, - workspaceId, - userId, - maxRows: planLimits.maxRowsPerTable, - maxTables: planLimits.maxTables, - }, - requestId - ) - - try { - // Coerce against the created table's schema so rows key by assigned ids. - const coerced = coerceRowsForTable(rows, table.schema, headerToColumn) - let inserted = 0 - for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) { - const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE) - const result = await batchInsertRows( - { tableId: table.id, rows: batch, workspaceId, userId }, - table, - generateId().slice(0, 8) - ) - inserted += result.length - } - - logger.info('Created table from upload', { - fileName, - tableId: table.id, - columns: columns.length, - rows: inserted, - chatId, - }) - - return { - success: true, - output: { - message: `File "${fileName}" imported as table "${table.name}" with ${columns.length} columns and ${inserted} rows.`, - tableId: table.id, - tableName: table.name, - rowCount: inserted, - }, - resources: [{ type: 'table', id: table.id, title: table.name }], - } - } catch (insertError) { - await deleteTable(table.id, requestId).catch(() => {}) - throw insertError - } -} - export async function executeMaterializeFile( params: Record, context: ExecutionContext @@ -296,16 +208,14 @@ export async function executeMaterializeFile( } const operation = (params.operation as string | undefined) || 'save' - - const supportedOperations = new Set(['save', 'import', 'table']) - if (!supportedOperations.has(operation)) { + // Only save/import are implemented. Reject anything else with guidance instead of + // silently falling back to save (table/knowledge_base are handled by their subagents). + if (operation !== 'save' && operation !== 'import') { return { success: false, - error: `materialize_file operation "${operation}" is not implemented. Supported operations: ${[...supportedOperations].join(', ')}.`, + error: `Unsupported materialize_file operation "${operation}". Use "save" or "import". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`, } } - - const requestedTableName = params.tableName as string | undefined const succeeded: string[] = [] const failed: Array<{ fileName: string; error: string }> = [] const resources: NonNullable = [] @@ -315,14 +225,6 @@ export async function executeMaterializeFile( let result: ToolCallResult if (operation === 'import') { result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId) - } else if (operation === 'table') { - result = await executeTable( - fileName, - context.chatId, - context.workspaceId, - context.userId, - requestedTableName - ) } else { result = await executeSave(fileName, context.chatId) } diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 582d52ef92d..b0c28e25b7a 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -13,6 +13,10 @@ export interface GetWorkflowDataParams { dataType?: string } +export interface GetWorkflowRunOptionsParams { + workflowId?: string +} + export interface GetBlockOutputsParams { workflowId?: string blockIds?: string[] @@ -48,6 +52,10 @@ export interface RunWorkflowParams { input?: unknown /** Optional trigger block ID when the workflow has multiple entrypoints and the caller wants a specific one. */ triggerBlockId?: string + /** When true, run with the resolved trigger's generated mock payload instead of workflow_input. */ + useMockPayload?: boolean + /** Reuse the recorded input from a past execution of this workflow instead of supplying workflow_input. */ + inputFromExecutionId?: string /** When true, runs the deployed version instead of the draft. Default: false (draft). */ useDeployedState?: boolean } @@ -58,6 +66,10 @@ export interface RunWorkflowUntilBlockParams { input?: unknown /** Optional trigger block ID when the workflow has multiple entrypoints and the caller wants a specific one. */ triggerBlockId?: string + /** When true, run with the resolved trigger's generated mock payload instead of workflow_input. */ + useMockPayload?: boolean + /** Reuse the recorded input from a past execution of this workflow instead of supplying workflow_input. */ + inputFromExecutionId?: string /** The block ID to stop after. Execution halts once this block completes. */ stopAfterBlockId: string /** When true, runs the deployed version instead of the draft. Default: false (draft). */ @@ -118,6 +130,10 @@ export interface SetBlockEnabledParams { export interface DeployApiParams { workflowId?: string action?: 'deploy' | 'undeploy' + /** Description of what changed in this deployment version. Required when action is 'deploy'. */ + versionDescription?: string + /** Short human-readable name/label for this deployment version. Required when action is 'deploy'. */ + versionName?: string } export interface DeployChatParams { @@ -126,6 +142,10 @@ export interface DeployChatParams { identifier?: string title?: string description?: string + /** Description of what changed in this deployment version (distinct from the chat-facing `description`). Required when action is 'deploy'. */ + versionDescription?: string + /** Short human-readable name/label for this deployment version. Required when action is 'deploy'. */ + versionName?: string welcomeMessage?: string customizations?: { primaryColor?: string @@ -148,13 +168,52 @@ export interface DeployMcpParams { toolName?: string toolDescription?: string serverId?: string - parameterSchema?: Record + /** + * Per-parameter descriptions as `[{ name, description }]`. Overlaid onto the + * workflow's input format before generating the tool schema — the same path + * the deploy modal uses. Parameter names/types/required come from the + * workflow's input trigger, not from this tool. + */ + parameterDescriptions?: Array<{ name: string; description: string }> } export interface CheckDeploymentStatusParams { workflowId?: string } +export interface UpdateDeploymentVersionParams { + workflowId?: string + version: number | string + /** New name/label for the version. Provide name and/or description. */ + name?: string + /** New description for the version. Provide name and/or description. */ + description?: string +} + +export interface GetDeploymentLogParams { + workflowId?: string +} + +export interface DiffWorkflowsParams { + workflowId?: string + /** Base/previous side: a version number, "live", or "draft". */ + ref1: number | string + /** Target/current side: a version number, "live", or "draft". */ + ref2: number | string +} + +export interface LoadDeploymentParams { + workflowId?: string + /** Version number to load, or "live" for the active deployment. */ + version: number | string +} + +export interface PromoteToLiveParams { + workflowId?: string + /** Version number to promote to live. */ + version: number +} + export interface ListWorkspaceMcpServersParams { workspaceId?: string } @@ -219,15 +278,18 @@ export type OpenResourceType = MothershipResourceType export interface OpenResourceItem { type?: OpenResourceType id?: string + path?: string } export interface OpenResourceParams { resources?: OpenResourceItem[] type?: OpenResourceType id?: string + path?: string } export interface ValidOpenResourceParams { type: OpenResourceType - id: string + id?: string + path?: string } diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index d573959f9db..af6abf456fc 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,8 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getWorkspaceFileMock } = vi.hoisted(() => ({ +const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({ getWorkspaceFileMock: vi.fn(), + resolveWorkspaceFileReferenceMock: vi.fn(), })) vi.mock('@sim/db', () => ({ @@ -16,6 +17,7 @@ vi.mock('@sim/db/schema', () => ({})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: getWorkspaceFileMock, + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, })) vi.mock('@/lib/workflows/utils', () => ({ @@ -45,6 +47,7 @@ describe('executeOpenResource', () => { getWorkspaceFileMock.mockResolvedValue({ id: 'wf_qL_cfff-FskMsXtOdm599', name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: null, }) const result = await executeOpenResource( @@ -63,6 +66,98 @@ describe('executeOpenResource', () => { type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599', title: 'MAC_Brand_Guidelines_May_2021 (1).docx', + path: 'files/MAC_Brand_Guidelines_May_2021%20(1).docx', + }, + ], + }) + }) + + it('opens workspace files by canonical VFS path', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: 'Docs', + }) + + const result = await executeOpenResource( + { + resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }], + }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( + 'workspace-1', + 'files/Docs/MAC_Brand_Guidelines.docx' + ) + expect(result).toMatchObject({ + success: true, + output: { opened: 1, errors: [] }, + resources: [ + { + type: 'file', + id: 'wf_qL_cfff-FskMsXtOdm599', + title: 'MAC_Brand_Guidelines_May_2021 (1).docx', + path: 'files/Docs/MAC_Brand_Guidelines_May_2021%20(1).docx', + }, + ], + }) + }) + + it('opens workflow alias file paths through workspace file reference resolution', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'wf_plan_file', + name: 'implementation.md', + folderPath: 'system/workflows/My Workflow/.plans', + }) + + const result = await executeOpenResource( + { + resources: [{ type: 'file', path: 'workflows/My%20Workflow/.plans/implementation.md' }], + }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( + 'workspace-1', + 'workflows/My%20Workflow/.plans/implementation.md' + ) + expect(result).toMatchObject({ + success: true, + resources: [ + { + type: 'file', + id: 'wf_plan_file', + title: 'implementation.md', + path: 'files/system/workflows/My%20Workflow/.plans/implementation.md', + }, + ], + }) + }) + + it('opens root plan alias file paths through workspace file reference resolution', async () => { + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'wf_root_plan', + name: 'root.md', + folderPath: 'system/.plans', + }) + + const result = await executeOpenResource( + { + resources: [{ type: 'file', path: '.plans/root.md' }], + }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', '.plans/root.md') + expect(result).toMatchObject({ + success: true, + resources: [ + { + type: 'file', + id: 'wf_root_plan', + title: 'root.md', + path: 'files/system/.plans/root.md', }, ], }) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 338f187de3e..885e57e30ce 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,9 +1,13 @@ import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + getWorkspaceFile, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' @@ -14,18 +18,30 @@ async function resolveResource( context: ExecutionContext ): Promise { const resourceType = item.type - let resourceId = item.id + let resourceId = item.id ?? '' let title: string = resourceType if (resourceType === 'file') { if (!context.workspaceId) return { error: 'Opening a workspace file requires workspace context.' } - const record = await getWorkspaceFile(context.workspaceId, item.id) - if (!record) return { error: `No workspace file with id "${item.id}".` } + const fileRef = item.path || item.id || '' + const record = item.path + ? await resolveWorkspaceFileReference(context.workspaceId, item.path) + : item.id + ? await getWorkspaceFile(context.workspaceId, item.id) + : null + if (!record) return { error: `No workspace file found for "${fileRef}".` } resourceId = record.id title = record.name + return { + type: resourceType, + id: resourceId, + title, + path: canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }), + } } if (resourceType === 'workflow') { + if (!item.id) return { error: 'workflow resources require `id`.' } const wf = await getWorkflowById(item.id) if (!wf) return { error: `No workflow with id "${item.id}".` } if (context.workspaceId && wf.workspaceId !== context.workspaceId) @@ -34,6 +50,7 @@ async function resolveResource( title = wf.name } if (resourceType === 'table') { + if (!item.id) return { error: 'table resources require `id`.' } const tbl = await getTableById(item.id) if (!tbl) return { error: `No table with id "${item.id}".` } if (context.workspaceId && tbl.workspaceId !== context.workspaceId) @@ -42,6 +59,7 @@ async function resolveResource( title = tbl.name } if (resourceType === 'knowledgebase') { + if (!item.id) return { error: 'knowledgebase resources require `id`.' } const kb = await getKnowledgeBaseById(item.id) if (!kb) return { error: `No knowledge base with id "${item.id}".` } if (context.workspaceId && kb.workspaceId !== context.workspaceId) @@ -50,6 +68,7 @@ async function resolveResource( title = kb.name } if (resourceType === 'log') { + if (!item.id) return { error: 'log resources require `id`.' } const logRecord = await getLogById(item.id) if (!logRecord) return { error: `No log with id "${item.id}".` } if (context.workspaceId && logRecord.workspaceId !== context.workspaceId) @@ -75,7 +94,10 @@ export async function executeOpenResource( const params = rawParams as OpenResourceParams const items: OpenResourceItem[] = - params.resources ?? (params.type && params.id ? [{ type: params.type, id: params.id }] : []) + params.resources ?? + (params.type && (params.id || params.path) + ? [{ type: params.type, id: params.id, path: params.path }] + : []) if (items.length === 0) { return { success: false, error: 'resources array is required' } @@ -114,8 +136,8 @@ function validateOpenResourceItem( if (!VALID_OPEN_RESOURCE_TYPES.has(item.type)) { return { success: false, error: `Invalid resource type: ${item.type}` } } - if (!item.id) { + if (!item.id && !(item.type === 'file' && item.path)) { return { success: false, error: `${item.type} resources require \`id\`` } } - return { success: true, params: { type: item.type, id: item.id } } + return { success: true, params: { type: item.type, id: item.id, path: item.path } } } diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts index d59afa50324..0e914229c8a 100644 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts +++ b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts @@ -4,12 +4,40 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, asc, desc, eq, isNull, or } from 'drizzle-orm' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { + type GrepCountEntry, + type GrepMatch, + type GrepOptions, + grepReadResult, + WorkspaceFileGrepError, +} from '@/lib/copilot/vfs/operations' +import { decodeVfsSegment, encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import { getServePathPrefix } from '@/lib/uploads' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('UploadFileReader') +/** + * Canonical comparison key for an upload's VFS name. Accepts both the raw display + * name and a percent-encoded segment (decode first — a no-op for raw names — + * then re-encode to the canonical `files/`-style form) so either spelling + * resolves the same row. Raw names containing a literal `%` cannot be decoded; + * fall back to encoding the raw name. + */ +function canonicalUploadKey(name: string): string { + let decoded = name + try { + decoded = decodeVfsSegment(name) + } catch { + decoded = name + } + try { + return encodeVfsSegment(decoded) + } catch { + return name.trim() + } +} + /** VFS-visible name. Coalesces to originalName for legacy rows that predate displayName. */ function vfsName(row: typeof workspaceFiles.$inferSelect): string { return row.displayName ?? row.originalName @@ -80,8 +108,8 @@ export async function findMothershipUploadRowByChatAndName( ) .orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id)) - const segmentKey = normalizeVfsSegment(fileName) - return allRows.find((r) => normalizeVfsSegment(vfsName(r)) === segmentKey) ?? null + const segmentKey = canonicalUploadKey(fileName) + return allRows.find((r) => canonicalUploadKey(vfsName(r)) === segmentKey) ?? null } /** @@ -133,3 +161,32 @@ export async function readChatUpload( return null } } + +/** + * Grep the content of a single chat upload (`uploads/`), mirroring + * {@link WorkspaceVFS.grepFile} for the chat-scoped uploads namespace. Resolves + * the upload by name (raw or percent-encoded), reads its text per file type, and + * greps it. Throws {@link WorkspaceFileGrepError} when the upload is missing or + * has no searchable text (image/binary/too-large) so the caller surfaces the + * message verbatim. + */ +export async function grepChatUpload( + filename: string, + chatId: string, + pattern: string, + options?: GrepOptions +): Promise { + const row = await findMothershipUploadRowByChatAndName(chatId, filename) + if (!row) { + throw new WorkspaceFileGrepError( + `Upload not found: "${filename}". Use glob("uploads/*") to list available uploads.` + ) + } + const record = toWorkspaceFileRecord(row) + const result = await readFileRecord(record) + if (!result) { + throw new WorkspaceFileGrepError(`Upload content not found for "${filename}".`) + } + const uploadsPath = `uploads/${canonicalUploadKey(record.name)}` + return grepReadResult(uploadsPath, result, pattern, uploadsPath, options) +} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 06126741ae2..72eea0cefb9 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -9,8 +9,10 @@ const { getOrMaterializeVFS } = vi.hoisted(() => ({ getOrMaterializeVFS: vi.fn(), })) -const { readChatUpload } = vi.hoisted(() => ({ +const { readChatUpload, listChatUploads, grepChatUpload } = vi.hoisted(() => ({ readChatUpload: vi.fn(), + listChatUploads: vi.fn(), + grepChatUpload: vi.fn(), })) vi.mock('@/lib/copilot/vfs', () => ({ @@ -18,22 +20,29 @@ vi.mock('@/lib/copilot/vfs', () => ({ })) vi.mock('./upload-file-reader', () => ({ readChatUpload, - listChatUploads: vi.fn(), + listChatUploads, + grepChatUpload, })) -import { executeVfsGrep, executeVfsRead } from './vfs' +import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { executeVfsGlob, executeVfsGrep, executeVfsRead } from './vfs' const OVERSIZED_INLINE_CONTENT = 'x'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) function makeVfs() { return { grep: vi.fn(), + grepFile: vi.fn(), + glob: vi.fn().mockReturnValue([]), read: vi.fn(), readFileContent: vi.fn(), suggestSimilar: vi.fn().mockReturnValue([]), } } +const GREP_CTX = { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } +const GREP_CTX_CHAT = { ...GREP_CTX, chatId: 'chat-1' } + describe('vfs handlers oversize policy', () => { beforeEach(() => { vi.clearAllMocks() @@ -80,7 +89,7 @@ describe('vfs handlers oversize policy', () => { getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/big.txt' }, + { path: 'files/big.txt/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) @@ -103,7 +112,7 @@ describe('vfs handlers oversize policy', () => { getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/chess.png' }, + { path: 'files/chess.png/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) @@ -111,6 +120,29 @@ describe('vfs handlers oversize policy', () => { expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('image') }) + it('passes through compiled file attachments even when oversized', async () => { + const vfs = makeVfs() + const largeBase64 = 'A'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) + vfs.readFileContent.mockResolvedValue({ + content: 'Compiled file: report.pdf (500000 bytes, application/pdf)', + totalLines: 1, + attachment: { + type: 'file', + name: 'report.pdf', + source: { type: 'base64', media_type: 'application/pdf', data: largeBase64 }, + }, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/reports/report.pdf/compiled' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('file') + }) + it('fails oversized image placeholder when image exceeds size limit', async () => { const vfs = makeVfs() vfs.readFileContent.mockResolvedValue({ @@ -120,11 +152,256 @@ describe('vfs handlers oversize policy', () => { getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/huge.png' }, + { path: 'files/huge.png/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) expect(result.success).toBe(false) expect(result.error).toContain('too large') }) + + it('reads canonical file leaf metadata without fetching dynamic content', async () => { + const vfs = makeVfs() + vfs.read.mockReturnValue({ + content: '{"id":"wf_123","vfsPath":"files/report.csv"}', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/report.csv' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect(vfs.readFileContent).not.toHaveBeenCalled() + expect(vfs.read).toHaveBeenCalledWith('files/report.csv', undefined, undefined) + }) + + it('uses dynamic file reads for canonical style paths', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ + content: '{"format":"docx"}', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/reports/brief.docx/style' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.docx/style') + expect(vfs.read).not.toHaveBeenCalled() + }) + + it('uses dynamic file reads for canonical compiled paths', async () => { + const vfs = makeVfs() + vfs.readFileContent.mockResolvedValue({ + content: 'Compiled file: brief.pdf (1000 bytes, application/pdf)', + totalLines: 1, + }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/reports/brief.pdf/compiled' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.pdf/compiled') + expect(vfs.read).not.toHaveBeenCalled() + }) +}) + +describe('vfs grep workspace-file routing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('routes a single workspace file leaf to grepFile (content search)', async () => { + const vfs = makeVfs() + vfs.grepFile.mockResolvedValue([{ path: 'files/report.csv', line: 2, content: 'revenue,100' }]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsGrep( + { pattern: 'revenue', path: 'files/report.csv', output_mode: 'content' }, + GREP_CTX + ) + + expect(result.success).toBe(true) + expect(vfs.grepFile).toHaveBeenCalledWith( + 'files/report.csv', + 'revenue', + expect.objectContaining({ outputMode: 'content', maxResults: 50 }) + ) + expect(vfs.grep).not.toHaveBeenCalled() + expect((result.output as { matches: unknown[] }).matches).toHaveLength(1) + }) + + it('routes a files//content path to grepFile', async () => { + const vfs = makeVfs() + vfs.grepFile.mockResolvedValue([]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGrep({ pattern: 'x', path: 'files/reports/brief.pdf/content' }, GREP_CTX) + + expect(vfs.grepFile).toHaveBeenCalledWith( + 'files/reports/brief.pdf/content', + 'x', + expect.any(Object) + ) + expect(vfs.grep).not.toHaveBeenCalled() + }) + + it('uses the VFS map grep for non-file paths', async () => { + const vfs = makeVfs() + vfs.grep.mockReturnValue([]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGrep({ pattern: 'slack', path: 'workflows/' }, GREP_CTX) + + expect(vfs.grep).toHaveBeenCalledWith('slack', 'workflows/', expect.any(Object)) + expect(vfs.grepFile).not.toHaveBeenCalled() + }) + + it('uses the VFS map grep when no path is given', async () => { + const vfs = makeVfs() + vfs.grep.mockReturnValue([]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGrep({ pattern: 'slack' }, GREP_CTX) + + expect(vfs.grep).toHaveBeenCalledWith('slack', undefined, expect.any(Object)) + expect(vfs.grepFile).not.toHaveBeenCalled() + }) + + it('surfaces a workspace-file grep scope error verbatim', async () => { + const vfs = makeVfs() + vfs.grepFile.mockRejectedValue( + new WorkspaceFileGrepError( + 'Grep over workspace file content must target a single workspace file (e.g. path: "files/report.csv"). "files/" is not a single workspace file.' + ) + ) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsGrep({ pattern: 'x', path: 'files/' }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('single workspace file') + }) +}) + +describe('vfs uploads are opt-in (like recently-deleted/)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not search uploads for an unscoped grep', async () => { + const vfs = makeVfs() + vfs.grep.mockReturnValue([]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGrep({ pattern: 'secret' }, GREP_CTX_CHAT) + + expect(grepChatUpload).not.toHaveBeenCalled() + expect(vfs.grep).toHaveBeenCalledWith('secret', undefined, expect.any(Object)) + }) + + it('does not search uploads for a files/ grep', async () => { + const vfs = makeVfs() + vfs.grepFile.mockResolvedValue([]) + getOrMaterializeVFS.mockResolvedValue(vfs) + + await executeVfsGrep({ pattern: 'secret', path: 'files/report.csv' }, GREP_CTX_CHAT) + + expect(grepChatUpload).not.toHaveBeenCalled() + }) + + it('routes an explicit uploads/ path to grepChatUpload', async () => { + grepChatUpload.mockResolvedValue([{ path: 'uploads/report.json', line: 1, content: 'hit' }]) + + const result = await executeVfsGrep( + { pattern: 'hit', path: 'uploads/report.json' }, + GREP_CTX_CHAT + ) + + expect(result.success).toBe(true) + expect(grepChatUpload).toHaveBeenCalledWith( + 'report.json', + 'chat-1', + 'hit', + expect.objectContaining({ maxResults: 50 }) + ) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('rejects a bare uploads/ folder grep (no cross-folder search)', async () => { + const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/' }, GREP_CTX_CHAT) + + expect(result.success).toBe(false) + expect(result.error).toContain('single upload') + expect(grepChatUpload).not.toHaveBeenCalled() + }) + + it('errors when grepping uploads without chat context', async () => { + const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json' }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('No chat context') + expect(grepChatUpload).not.toHaveBeenCalled() + }) + + it('surfaces an upload-not-found grep error verbatim', async () => { + grepChatUpload.mockRejectedValue( + new WorkspaceFileGrepError( + 'Upload not found: "ghost.json". Use glob("uploads/*") to list available uploads.' + ) + ) + + const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/ghost.json' }, GREP_CTX_CHAT) + + expect(result.success).toBe(false) + expect(result.error).toContain('Upload not found') + }) + + it('lists uploads only when scoped, with percent-encoded paths', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + listChatUploads.mockResolvedValue([{ name: 'My Report.json' }, { name: 'data.csv' }]) + + const scoped = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT) + expect((scoped.output as { files: string[] }).files).toEqual( + expect.arrayContaining(['uploads/My%20Report.json', 'uploads/data.csv']) + ) + + listChatUploads.mockClear() + const broad = await executeVfsGlob({ pattern: '**' }, GREP_CTX_CHAT) + expect(listChatUploads).not.toHaveBeenCalled() + expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json') + }) + + it('reads an upload directly, tolerating a spurious /content suffix', async () => { + const vfs = makeVfs() + getOrMaterializeVFS.mockResolvedValue(vfs) + readChatUpload.mockResolvedValue({ content: 'hello upload', totalLines: 1 }) + + const bare = await executeVfsRead({ path: 'uploads/report.csv' }, GREP_CTX_CHAT) + expect(bare.success).toBe(true) + expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1') + + // The model adds /content out of habit (from files/) — it must still resolve. + const withContent = await executeVfsRead({ path: 'uploads/report.csv/content' }, GREP_CTX_CHAT) + expect(withContent.success).toBe(true) + expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1') + }) + + it('tolerates a trailing /content on an uploads grep path', async () => { + grepChatUpload.mockResolvedValue([]) + + await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json/content' }, GREP_CTX_CHAT) + + expect(grepChatUpload).toHaveBeenCalledWith('report.json', 'chat-1', 'x', expect.any(Object)) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index ab87b264aa5..e41c2b34b82 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -3,10 +3,43 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' -import { listChatUploads, readChatUpload } from './upload-file-reader' +import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' +import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { grepChatUpload, listChatUploads, readChatUpload } from './upload-file-reader' const logger = createLogger('VfsTools') +/** + * Encode a chat-upload display name as a single canonical VFS path segment so + * `uploads/` paths follow the same percent-encoded convention as `files/`. + * Falls back to the raw name if the segment cannot be encoded (so a listing + * never fails wholesale over one odd name). + */ +function encodeUploadSegment(name: string): string { + try { + return encodeVfsSegment(name) + } catch { + return name + } +} + +/** + * True when a grep `path` targets the workspace files tree (`files/` or + * `recently-deleted/files/`). Such greps search a single file's content via + * {@link WorkspaceVFS.grepFile}; every other path searches the VFS map. + */ +function isWorkspaceFileGrepPath(path: string | undefined): path is string { + if (!path) return false + return /^(recently-deleted\/)?files(\/|$)/.test(path.replace(/^\/+/, '')) +} + +/** True when a grep `path` targets the chat-scoped uploads namespace. */ +function isChatUploadGrepPath(path: string | undefined): path is string { + if (!path) return false + return /^uploads(\/|$)/.test(path.replace(/^\/+/, '')) +} + function serializedResultSize(value: unknown): number { try { return JSON.stringify(value).length @@ -18,16 +51,19 @@ function serializedResultSize(value: unknown): number { function isOversizedReadPlaceholder(content: string): boolean { return ( content.startsWith('[File too large to display inline:') || - content.startsWith('[Image too large:') + content.startsWith('[Image too large:') || + content.startsWith('[Compiled artifact too large:') ) } -function hasImageAttachment(result: unknown): boolean { +function hasModelAttachment(result: unknown): boolean { if (!result || typeof result !== 'object') { return false } const attachment = (result as { attachment?: { type?: string } }).attachment - return attachment?.type === 'image' + return ( + attachment?.type === 'image' || attachment?.type === 'file' || attachment?.type === 'document' + ) } export async function executeVfsGrep( @@ -45,15 +81,49 @@ export async function executeVfsGrep( return { success: false, error: 'No workspace context available' } } + const rawPath = typeof params.path === 'string' ? params.path : undefined + try { - const vfs = await getOrMaterializeVFS(workspaceId, context.userId) - const result = vfs.grep(pattern, params.path as string | undefined, { + const grepOptions = { maxResults: (params.maxResults as number) ?? 50, outputMode: outputMode as 'content' | 'files_with_matches' | 'count', ignoreCase: (params.ignoreCase as boolean) ?? false, lineNumbers: (params.lineNumbers as boolean) ?? true, context: (params.context as number) ?? 0, - }) + } + + // Routing mirrors read/glob: + // - uploads/ -> grep one chat upload's content (chat-scoped) + // - files/ -> grep one workspace file's content (one file only) + // - everything else -> grep the in-memory VFS map (workflow JSON, metadata) + // Chat uploads are opt-in like recently-deleted/: they are never in the VFS + // map, so an unscoped grep can't touch them — only an explicit uploads/ + // path does, and only one upload at a time. + let result: GrepMatch[] | string[] | GrepCountEntry[] + if (isChatUploadGrepPath(rawPath)) { + if (!context.chatId) { + return { success: false, error: 'No chat context available for uploads/' } + } + // The upload is the first segment after uploads/; any trailing segment + // (e.g. a /content suffix) is ignored, mirroring the uploads read path. + const filename = rawPath + .replace(/^\/+/, '') + .replace(/^uploads\/?/, '') + .split('/')[0] + if (!filename) { + return { + success: false, + error: + 'Grep over chat uploads must target a single upload (e.g. path: "uploads/report.json"). Use glob("uploads/*") to list uploads.', + } + } + result = await grepChatUpload(filename, context.chatId, pattern, grepOptions) + } else { + const vfs = await getOrMaterializeVFS(workspaceId, context.userId) + result = isWorkspaceFileGrepPath(rawPath) + ? await vfs.grepFile(rawPath, pattern, grepOptions) + : vfs.grep(pattern, rawPath, grepOptions) + } const key = outputMode === 'files_with_matches' ? 'files' : outputMode === 'count' ? 'counts' : 'matches' const matchCount = Array.isArray(result) @@ -69,12 +139,22 @@ export async function executeVfsGrep( 'Grep result too large to return inline. Retry grep with a more specific pattern or narrower path, and reduce context or maxResults. Avoid catch-all greps because smaller searches save context window and make follow-up reads cheaper.', } } - logger.debug('vfs_grep result', { pattern, path: params.path, outputMode, matchCount }) + logger.debug('vfs_grep result', { pattern, path: rawPath, outputMode, matchCount }) return { success: true, output } } catch (err) { + // Expected single-file scoping / no-text / too-large conditions: surface the + // message verbatim instead of logging an internal failure. + if (err instanceof WorkspaceFileGrepError) { + logger.debug('vfs_grep workspace file rejected', { + pattern, + path: rawPath, + error: err.message, + }) + return { success: false, error: err.message } + } logger.error('vfs_grep failed', { pattern, - path: params.path, + path: rawPath, error: toError(err).message, }) return { success: false, error: getErrorMessage(err, 'vfs_grep failed') } @@ -101,7 +181,9 @@ export async function executeVfsGlob( if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { const uploads = await listChatUploads(context.chatId) - const uploadPaths = uploads.map((f) => `uploads/${f.name}`) + // Encode per segment so uploads/ paths match the files/ convention; the + // upload resolver accepts both the encoded path and the raw display name. + const uploadPaths = uploads.map((f) => `uploads/${encodeUploadSegment(f.name)}`) files = [...files, ...uploadPaths] } @@ -153,23 +235,26 @@ export async function executeVfsRead( } } - // Handle chat-scoped uploads via the uploads/ virtual prefix + // Handle chat-scoped uploads via the uploads/ virtual prefix. + // Uploads are flat and have no metadata/content split like files/ — the upload + // IS the first path segment after uploads/. Any trailing segment (e.g. a + // /content suffix added out of habit) is ignored so the read resolves either way. if (path.startsWith('uploads/')) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } } - const filename = path.slice('uploads/'.length) + const filename = path.slice('uploads/'.length).split('/')[0] const uploadResult = await readChatUpload(filename, context.chatId) if (uploadResult) { - const isImage = hasImageAttachment(uploadResult) + const isAttachment = hasModelAttachment(uploadResult) if ( - !isImage && + !isAttachment && (isOversizedReadPlaceholder(uploadResult.content) || serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS) ) { logger.warn('Upload read result too large', { path, - hasAttachment: isImage, + hasAttachment: isAttachment, contentLength: uploadResult.content.length, serializedSize: serializedResultSize(uploadResult), }) @@ -184,7 +269,7 @@ export async function executeVfsRead( logger.debug('vfs_read resolved chat upload', { path, totalLines: uploadResult.totalLines, - hasAttachment: isImage, + hasAttachment: isAttachment, offset, limit, }) @@ -198,20 +283,23 @@ export async function executeVfsRead( const vfs = await getOrMaterializeVFS(workspaceId, context.userId) - // For workspace file paths (files/ or recently-deleted/files/), try readFileContent - // first so images, PDFs, and documents get proper attachment/parsing handling rather - // than being served as raw VFS metadata text. - const fileContent = await vfs.readFileContent(path) + // Plain canonical file leaves are metadata resources. Dynamic file content + // and inspection paths use explicit suffixes like /content, /style, + // /compiled-check, or /compiled. + const shouldReadDynamicFileContent = + /^recently-deleted\/files\/.+\/content$/.test(path) || + /^files\/.+\/(?:content|style|compiled-check|compiled|render|extract)$/.test(path) + const fileContent = shouldReadDynamicFileContent ? await vfs.readFileContent(path) : null if (fileContent) { - const isImage = hasImageAttachment(fileContent) + const isAttachment = hasModelAttachment(fileContent) if ( - !isImage && + !isAttachment && (isOversizedReadPlaceholder(fileContent.content) || serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS) ) { logger.warn('File read result too large', { path, - hasAttachment: isImage, + hasAttachment: isAttachment, contentLength: fileContent.content.length, serializedSize: serializedResultSize(fileContent), }) @@ -226,7 +314,7 @@ export async function executeVfsRead( logger.debug('vfs_read resolved workspace file', { path, totalLines: fileContent.totalLines, - hasAttachment: isImage, + hasAttachment: isAttachment, offset, limit, }) @@ -247,7 +335,7 @@ export async function executeVfsRead( return { success: false, error: `File not found: ${path}.${hint}` } } if ( - !hasImageAttachment(result) && + !hasModelAttachment(result) && (isOversizedReadPlaceholder(result.content) || serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS) ) { @@ -270,31 +358,3 @@ export async function executeVfsRead( return { success: false, error: getErrorMessage(err, 'vfs_read failed') } } } - -async function executeVfsList( - params: Record, - context: ExecutionContext -): Promise { - const path = params.path as string | undefined - if (!path) { - return { success: false, error: "Missing required parameter 'path'" } - } - - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, error: 'No workspace context available' } - } - - try { - const vfs = await getOrMaterializeVFS(workspaceId, context.userId) - const entries = vfs.list(path) - logger.debug('vfs_list result', { path, entryCount: entries.length }) - return { success: true, output: { entries } } - } catch (err) { - logger.error('vfs_list failed', { - path, - error: toError(err).message, - }) - return { success: false, error: getErrorMessage(err, 'vfs_list failed') } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts index c02c93c834e..582c42c6a6b 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { createEnvMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -34,9 +35,7 @@ vi.mock('@/lib/api-key/orchestration', () => ({ performCreateWorkspaceApiKey: vi.fn(), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { INTERNAL_API_SECRET: 'secret' }, -})) +vi.mock('@/lib/core/config/env', () => createEnvMock({ INTERNAL_API_SECRET: 'secret' })) vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1', diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index 93b8a0899c2..115f8ff3590 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -3,6 +3,7 @@ import { db, workflow as workflowTable } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { eq } from 'drizzle-orm' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' @@ -11,6 +12,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { + getExecutionInputForWorkflow, getExecutionStateForWorkflow, getLatestExecutionStateWithExecutionId, } from '@/lib/workflows/executor/execution-state' @@ -23,10 +25,15 @@ import { performUpdateWorkflow, } from '@/lib/workflows/orchestration' import { + loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' +import { + resolveTriggerRunOptions, + validateTriggerInput, +} from '@/lib/workflows/triggers/run-options' import { listFolders, setWorkflowVariables, verifyFolderWorkspace } from '@/lib/workflows/utils' import type { SerializableExecutionState } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' @@ -123,6 +130,111 @@ function resolveRunTriggerBlockId(params: { triggerBlockId?: unknown }): string : undefined } +interface PreparedTriggerRun { + triggerBlockId: string + input: unknown +} + +/** + * Resolves which trigger a copilot run targets and validates the input against + * it. There are no fallbacks: an invalid trigger id, an ambiguous workflow, or + * input that doesn't match the trigger's schema returns an error string so the + * agent fixes it and retries. The resolved triggerBlockId is returned so the + * caller pins the executed entry to the validated one. + */ +async function resolveValidatedTriggerRun( + workflowId: string, + useDraftState: boolean, + params: { + triggerBlockId?: unknown + workflow_input?: unknown + input?: unknown + useMockPayload?: unknown + inputFromExecutionId?: unknown + } +): Promise { + const state = useDraftState + ? await loadWorkflowFromNormalizedTables(workflowId) + : await loadDeployedWorkflowState(workflowId) + + if (!state?.blocks) { + return { + error: `Workflow ${workflowId} has no ${useDraftState ? 'saved draft' : 'deployed'} state to run.`, + } + } + + const merged = mergeSubblockStateWithValues(state.blocks) + const options = resolveTriggerRunOptions(merged, state.edges) + + if (options.length === 0) { + return { + error: + 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.', + } + } + + const listTriggers = () => + options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') + + const requestedId = resolveRunTriggerBlockId(params) + let option = options[0] + if (requestedId) { + const match = options.find((o) => o.triggerBlockId === requestedId) + if (!match) { + return { + error: `triggerBlockId "${requestedId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.`, + } + } + option = match + } else if (options.length > 1) { + return { + error: `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.`, + } + } + + const providedInput = resolveRunWorkflowInput(params) + const hasProvidedInput = providedInput !== undefined + const useMock = params.useMockPayload === true + const fromExecutionId = + typeof params.inputFromExecutionId === 'string' && params.inputFromExecutionId.trim().length > 0 + ? params.inputFromExecutionId.trim() + : undefined + + const sourceCount = (hasProvidedInput ? 1 : 0) + (useMock ? 1 : 0) + (fromExecutionId ? 1 : 0) + if (sourceCount > 1) { + return { + error: + 'Provide only one input source: workflow_input, useMockPayload: true, or inputFromExecutionId.', + } + } + + // Mock payload is generated to match the trigger, so it bypasses validation. + if (useMock) { + return { triggerBlockId: option.triggerBlockId, input: option.mockPayload } + } + + let inputToValidate = providedInput + if (fromExecutionId) { + const past = await getExecutionInputForWorkflow(fromExecutionId, workflowId) + if (!past.found) { + return { + error: `No execution "${fromExecutionId}" found for this workflow to reuse input from.`, + } + } + if (past.input === undefined) { + return { error: `Execution "${fromExecutionId}" has no recorded input to reuse.` } + } + inputToValidate = past.input + } + + const validation = validateTriggerInput(option, inputToValidate) + if (!validation.ok) { + return { error: validation.error || 'workflow_input is invalid for the target trigger.' } + } + + return { triggerBlockId: option.triggerBlockId, input: inputToValidate } +} + function isBlockProtected(blockId: string, blocksById: Record): boolean { const block = blocksById[blockId] if (!block) return false @@ -354,6 +466,11 @@ export async function executeRunWorkflow( const useDraftState = !params.useDeployedState + const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) + if ('error' in prepared) { + return { success: false, error: prepared.error } + } + const result = await executeWorkflow( { id: workflowRecord.id, @@ -362,13 +479,13 @@ export async function executeRunWorkflow( variables: workflowRecord.variables || {}, }, generateRequestId(), - resolveRunWorkflowInput(params), + prepared.input, context.userId, { enabled: true, useDraftState, workflowTriggerType: 'copilot', - triggerBlockId: resolveRunTriggerBlockId(params), + triggerBlockId: prepared.triggerBlockId, } ) @@ -656,6 +773,11 @@ export async function executeRunWorkflowUntilBlock( const useDraftState = !params.useDeployedState + const prepared = await resolveValidatedTriggerRun(workflowId, useDraftState, params) + if ('error' in prepared) { + return { success: false, error: prepared.error } + } + const result = await executeWorkflow( { id: workflowRecord.id, @@ -664,14 +786,14 @@ export async function executeRunWorkflowUntilBlock( variables: workflowRecord.variables || {}, }, generateRequestId(), - resolveRunWorkflowInput(params), + prepared.input, context.userId, { enabled: true, useDraftState, stopAfterBlockId: params.stopAfterBlockId, workflowTriggerType: 'copilot', - triggerBlockId: resolveRunTriggerBlockId(params), + triggerBlockId: prepared.triggerBlockId, } ) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index e5139b6457a..41ea582105f 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,4 +1,5 @@ import { toError } from '@sim/utils/errors' +import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { mcpService } from '@/lib/mcp/service' @@ -11,6 +12,7 @@ import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' +import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getWorkflowById, listFolders } from '@/lib/workflows/utils' import { listUserWorkspaces } from '@/lib/workspaces/utils' @@ -23,6 +25,7 @@ import type { GetBlockUpstreamReferencesParams, GetDeployedWorkflowStateParams, GetWorkflowDataParams, + GetWorkflowRunOptionsParams, ListFoldersParams, } from '../param-types' @@ -64,6 +67,81 @@ export async function executeListFolders( } } +export async function executeGetWorkflowRunOptions( + params: GetWorkflowRunOptionsParams, + context: ExecutionContext +): Promise { + try { + const workflowId = params.workflowId || context.workflowId + if (!workflowId) { + return { success: false, error: 'workflowId is required' } + } + + await ensureWorkflowAccess(workflowId, context.userId) + + const normalized = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalized) { + return { success: false, error: `Workflow ${workflowId} has no saved state` } + } + + const merged = mergeSubblockStateWithValues(normalized.blocks) + const options = resolveTriggerRunOptions(merged, normalized.edges) + + if (options.length === 0) { + return { + success: true, + output: { + workflowId, + default: null, + triggers: [], + message: + 'No runnable trigger blocks found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.', + }, + } + } + + const guidanceFor = (kind: string): string => { + switch (kind) { + case 'fields': + return 'Build workflow_input matching inputSchema. Copy mockPayload only if you have no better values.' + case 'event_payload': + return 'Construct an event payload matching inputSchema, or run with useMockPayload: true if you cannot build one.' + case 'chat': + return 'Provide workflow_input shaped like { "input": "" }.' + default: + return 'No input required.' + } + } + + const triggers = options.map((option) => { + const pub = toPublicRunOption(option) + const callExample = + pub.inputKind === 'none' + ? { triggerBlockId: pub.triggerBlockId } + : { triggerBlockId: pub.triggerBlockId, workflow_input: pub.mockPayload } + return { ...pub, guidance: guidanceFor(pub.inputKind), callExample } + }) + + const defaultOption = options.find((option) => option.isDefault) + + return { + success: true, + output: { + workflowId, + default: defaultOption + ? { + triggerBlockId: defaultOption.triggerBlockId, + reason: `Highest-priority trigger (${defaultOption.blockName})`, + } + : null, + triggers, + }, + } + } catch (error) { + return { success: false, error: toError(error).message } + } +} + export async function executeGetWorkflowData( params: GetWorkflowDataParams, context: ExecutionContext diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index 043802fbb99..db8f9e73da8 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -25,7 +25,7 @@ export const searchDocumentationServerTool: BaseServerTool } args?: Record } @@ -32,6 +26,8 @@ interface CreateFileResult { id: string name: string contentType: string + vfsPath: string + backingVfsPath?: string } } @@ -50,48 +46,51 @@ export const createFileServerTool: BaseServerTool @@ -35,17 +40,32 @@ export const deleteFileServerTool: BaseServerTool ({ + executeInE2B: vi.fn(), + executeShellInE2B: vi.fn(), +})) +vi.mock('@/lib/execution/languages', () => ({ + CodeLanguage: { javascript: 'javascript', python: 'python' }, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: vi.fn(), + fetchWorkspaceFileBuffer: vi.fn(), +})) +vi.mock('./doc-compiled-store', () => ({ + loadCompiledDoc: vi.fn(), + storeCompiledDoc: vi.fn(), +})) + +import { collectReferencedFileIds } from './doc-compile' + +const ID = '550e8400-e29b-41d4-a716-446655440000' + +describe('collectReferencedFileIds', () => { + it('captures the id from getFileBase64(...) with single or double quotes', () => { + expect(collectReferencedFileIds(`await getFileBase64('${ID}')`)).toEqual(new Set([ID])) + expect(collectReferencedFileIds(`getFileBase64("abc_def-1")`)).toEqual(new Set(['abc_def-1'])) + }) + + it('captures the id from pptx addImage(slide, id, opts) (second arg)', () => { + const src = `await addImage(slide, '${ID}', { x: 1, y: 1, w: 2, h: 2 })` + expect(collectReferencedFileIds(src)).toEqual(new Set([ID])) + }) + + it('captures the id from docx addImage(id, opts) (first arg)', () => { + const src = `const img = await addImage('docx-img-1', { width: 200, height: 100 })` + expect(collectReferencedFileIds(src)).toEqual(new Set(['docx-img-1'])) + }) + + it('captures the id from pdf drawImage(page, id, opts) (second arg)', () => { + const src = `await drawImage(page, 'pdf-img-2', { x: 0, y: 0, width: 100, height: 100 })` + expect(collectReferencedFileIds(src)).toEqual(new Set(['pdf-img-2'])) + }) + + it('still supports the legacy /home/user/inputs/ path form', () => { + expect(collectReferencedFileIds(`fs.readFileSync('/home/user/inputs/legacy-1')`)).toEqual( + new Set(['legacy-1']) + ) + }) + + it('collects and dedupes ids across multiple call sites', () => { + const src = ` + await addImage(slide, 'logo-1', { x: 0, y: 0, w: 1, h: 1 }); + const uri = await getFileBase64('logo-1'); + await addImage(slide, 'crest-2', { x: 2, y: 0, w: 1, h: 1 }); + ` + expect(collectReferencedFileIds(src)).toEqual(new Set(['logo-1', 'crest-2'])) + }) + + it('does not match id-like strings outside the image helpers', () => { + const src = `slide.addText('order ${ID} shipped', { x: 1, y: 1, w: 8, h: 1 })` + expect(collectReferencedFileIds(src)).toEqual(new Set()) + }) + + it('does not match slide.addImage({ data }) — no fileId is present there', () => { + const src = `slide.addImage({ data: base64Data, x: 1, y: 1, w: 2, h: 2 })` + expect(collectReferencedFileIds(src)).toEqual(new Set()) + }) + + it('returns an empty set when there are no image references', () => { + expect(collectReferencedFileIds(`slide.addText('hello', { x: 1, y: 1 })`)).toEqual(new Set()) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts new file mode 100644 index 00000000000..920830f193b --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -0,0 +1,416 @@ +import { createLogger } from '@sim/logger' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b' +import { CodeLanguage } from '@/lib/execution/languages' +import { + fetchWorkspaceFileBuffer, + getWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { loadCompiledDoc, storeCompiledDoc } from './doc-compiled-store' + +const logger = createLogger('CopilotDocCompile') + +/** + * Thrown when the user-authored Python script itself fails (raised an exception + * or produced no output) — i.e. an error the agent should fix by editing the + * script. Infra failures (E2B sandbox create/timeout, S3) propagate as plain + * Errors so callers can return 5xx instead of telling the agent its script was + * wrong. + */ +export class DocCompileUserError extends Error { + constructor(message: string) { + super(message) + this.name = 'DocCompileUserError' + } +} + +const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +const PDF_MIME = 'application/pdf' + +// When the E2B doc sandbox is enabled, ALL four formats compile there: pptx/docx +// via Node (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python +// (reportlab/openpyxl). Source MIMEs for the node engines match the isolated-vm +// JS path; the python engines have distinct markers. +export const PPTXGENJS_SOURCE_MIME = 'text/x-pptxgenjs' +export const DOCXJS_SOURCE_MIME = 'text/x-docxjs' +export const PYTHON_PDF_SOURCE_MIME = 'text/x-python-pdf' +export const PYTHON_XLSX_SOURCE_MIME = 'text/x-python-xlsx' + +export type DocEngine = 'node' | 'python' + +export interface E2BDocFormat { + ext: 'pptx' | 'docx' | 'pdf' | 'xlsx' + engine: DocEngine + formatName: 'PPTX' | 'DOCX' | 'PDF' | 'XLSX' + contentType: string + sourceMime: string +} + +/** + * Resolves the E2B doc format + engine for a filename, or null for non-docs. + * pptx/docx → node, pdf/xlsx → python. Only meaningful when the E2B doc sandbox + * is enabled; callers gate on isE2BDocEnabled before using this. + */ +export function getE2BDocFormat(fileName: string): E2BDocFormat | null { + const l = fileName.toLowerCase() + if (l.endsWith('.pptx')) + return { + ext: 'pptx', + engine: 'node', + formatName: 'PPTX', + contentType: PPTX_MIME, + sourceMime: PPTXGENJS_SOURCE_MIME, + } + if (l.endsWith('.docx')) + return { + ext: 'docx', + engine: 'node', + formatName: 'DOCX', + contentType: DOCX_MIME, + sourceMime: DOCXJS_SOURCE_MIME, + } + if (l.endsWith('.pdf')) + return { + ext: 'pdf', + engine: 'python', + formatName: 'PDF', + contentType: PDF_MIME, + sourceMime: PYTHON_PDF_SOURCE_MIME, + } + // xlsx is gated behind the mothership beta flag (like plans/changelog): the + // skill + prompt are gated on the Go side, and this is the single Sim chokepoint + // that keeps the compile/serve/check/recalc paths off for xlsx when beta is off. + if (l.endsWith('.xlsx') && isMothershipBetaFeaturesEnabled) + return { + ext: 'xlsx', + engine: 'python', + formatName: 'XLSX', + contentType: XLSX_MIME, + sourceMime: PYTHON_XLSX_SOURCE_MIME, + } + return null +} + +// The skills reference workspace images by BARE file id through the injected +// helpers — `getFileBase64(id)`, `addImage(slide, id, ...)` (pptx), +// `addImage(id, ...)` (docx), `drawImage(page, id, ...)` (pdf) — never as a path. +// Capture the id from those call sites (skipping a leading slide/page argument), +// plus the legacy `/home/user/inputs/` path, so referenced files are staged +// before the script runs. Without this the sandbox `getFileBase64` throws +// "file not staged" and every workspace-image embed silently fails. +const INPUT_PATH_RE = /\/home\/user\/inputs\/([A-Za-z0-9_-]+)/g +const FILE_HELPER_RE = + /\b(?:getFileBase64|addImage|drawImage)\(\s*(?:[A-Za-z_$][\w$]*\s*,\s*)?['"]([A-Za-z0-9_-]+)['"]/g + +// The doc source is user/LLM-controlled, so bound how much it can pull into the +// sandbox: each `/home/user/inputs/` reference is only ~35 bytes, so the +// source-size cap alone does not bound staging. These caps prevent an +// authenticated member from forcing thousands of (or very large) workspace files +// to be downloaded and base64-held in-process per compile request. +const MAX_STAGED_INPUTS = 20 +const MAX_STAGED_FILE_BYTES = 25 * 1024 * 1024 +const MAX_STAGED_TOTAL_BYTES = 50 * 1024 * 1024 + +/** + * Collects the workspace file ids a doc source references — from the injected + * image-helper call sites and the legacy `/home/user/inputs/` path. Matching + * is scoped to the helper calls (not bare id-like strings in slide text), and the + * caller skips any id that does not resolve to a real file, so over-matching is + * harmless. + */ +export function collectReferencedFileIds(source: string): Set { + const ids = new Set() + for (const re of [INPUT_PATH_RE, FILE_HELPER_RE]) { + for (const match of source.matchAll(re)) { + if (match[1]) ids.add(match[1]) + } + } + return ids +} + +async function stageReferencedImages(source: string, workspaceId: string): Promise { + const ids = collectReferencedFileIds(source) + if (ids.size > MAX_STAGED_INPUTS) { + throw new Error( + `Too many referenced input files (${ids.size}); max ${MAX_STAGED_INPUTS}. Reference fewer files.` + ) + } + const files: SandboxFile[] = [] + let totalBytes = 0 + for (const fileId of ids) { + let record: Awaited> + try { + record = await getWorkspaceFile(workspaceId, fileId) + } catch (err) { + logger.warn('Failed to resolve referenced image for doc compile', { + workspaceId, + fileId, + error: err instanceof Error ? err.message : String(err), + }) + continue + } + if (!record) continue + if (typeof record.size === 'number' && record.size > MAX_STAGED_FILE_BYTES) { + logger.warn('Skipping oversized referenced image for doc compile', { + workspaceId, + fileId, + size: record.size, + }) + continue + } + if (totalBytes + (record.size ?? 0) > MAX_STAGED_TOTAL_BYTES) { + throw new Error( + `Referenced input files exceed the ${MAX_STAGED_TOTAL_BYTES} byte staging budget.` + ) + } + let buffer: Buffer + try { + buffer = await fetchWorkspaceFileBuffer(record) + } catch (err) { + logger.warn('Failed to stage referenced image for doc compile', { + workspaceId, + fileId, + error: err instanceof Error ? err.message : String(err), + }) + continue + } + // Enforce the per-file cap on actual bytes too: record.size can be null/stale, + // in which case the pre-fetch check above is skipped and a single oversized + // file would otherwise be fully base64-held in memory. + if (buffer.length > MAX_STAGED_FILE_BYTES) { + logger.warn('Skipping oversized referenced image for doc compile (post-fetch)', { + workspaceId, + fileId, + size: buffer.length, + }) + continue + } + // Budget check after the fetch (record.size may be unset/stale) — kept + // outside the catch above so it fails the compile rather than being skipped. + totalBytes += buffer.length + if (totalBytes > MAX_STAGED_TOTAL_BYTES) { + throw new Error( + `Referenced input files exceed the ${MAX_STAGED_TOTAL_BYTES} byte staging budget.` + ) + } + files.push({ + path: `/home/user/inputs/${fileId}`, + content: buffer.toString('base64'), + encoding: 'base64', + }) + } + return files +} + +const DOC_COMPILE_TIMEOUT_MS = 120_000 + +// Appended to xlsx compile scripts: LibreOffice recalculates formulas on +// load/convert and writes cached values, then we move the result back over +// output.xlsx so the binary read back has computed results (openpyxl alone omits +// them). Indented at column 0 so it concatenates cleanly after the user's script. +const XLSX_RECALC_SNIPPET = ` +import subprocess as __sim_sp, shutil as __sim_sh, os as __sim_os +# Best-effort: bake cached formula values via LibreOffice. If recalc fails +# (soffice crash/timeout/unsupported), keep the openpyxl workbook as-is — it's +# still a valid file (formulas just lack cached values). Never fail the user's +# compile over an infra recalc failure. +try: + __sim_os.makedirs("/home/user/__recalc", exist_ok=True) + __sim_sp.run( + ["soffice", "--headless", "--convert-to", "xlsx", "--outdir", "/home/user/__recalc", "/home/user/output.xlsx"], + check=True, timeout=120, capture_output=True, + ) + __sim_sh.move("/home/user/__recalc/output.xlsx", "/home/user/output.xlsx") +except Exception as __sim_recalc_err: + print("xlsx recalc skipped:", __sim_recalc_err) +`.trim() + +interface CompileArgs { + source: string + fileName: string + workspaceId: string +} + +/** + * Compiles a Python document script to its binary in the dedicated E2B doc + * sandbox. The script must save to /home/user/output.; we read that back. + * Throws with a human-readable message when the script errors or writes nothing. + * Internal — callers use compileDoc (load-or-build + store). + */ +async function compileDocViaE2BPython( + { source, workspaceId }: CompileArgs, + fmt: E2BDocFormat +): Promise { + const sandboxFiles = await stageReferencedImages(source, workspaceId) + const outputSandboxPath = `/home/user/output.${fmt.ext}` + + // openpyxl writes formula strings but no cached values, so a web viewer (SheetJS) + // renders formula cells blank. Recalculate in place with LibreOffice (it + // evaluates formulas on load and writes cached values on convert) so the stored + // artifact — and everything that serves it — shows computed results. pdf is + // unaffected. Runs only after the user's script succeeds. + const code = fmt.ext === 'xlsx' ? `${source}\n${XLSX_RECALC_SNIPPET}` : source + + const result = await executeInE2B({ + code, + language: CodeLanguage.Python, + timeoutMs: DOC_COMPILE_TIMEOUT_MS, + sandboxFiles, + outputSandboxPath, + sandboxKind: 'doc', + }) + + if (result.error) { + // The script raised — a user-code error the agent should fix. + throw new DocCompileUserError(result.error) + } + if (!result.exportedFileContent) { + throw new DocCompileUserError( + `${fmt.formatName} generation produced no output. The script must save to ${outputSandboxPath}.` + ) + } + return Buffer.from(result.exportedFileContent, 'base64') +} + +// ── Node engine (pptxgenjs / docx) ────────────────────────────────────────── +// Preambles replicate the isolated-vm bootstraps as node globals: the injected +// `pptx`/`docx` instances, geometry constants, and fileId-based image helpers +// (reading staged /home/user/inputs/ files). pptx also gets `iconImage` +// (react-icons → sharp → PNG), which only works here because the E2B sandbox is +// a full Linux VM. The agent's edit_content source runs inside an async IIFE so +// top-level await (addImage/iconImage) works; the finalizer writes the binary. +const PPTX_NODE_PREAMBLE = ` +const PptxGenJS = require('pptxgenjs'); +const fs = require('fs'); +globalThis.pptx = new PptxGenJS(); +globalThis.pptx.layout = 'LAYOUT_16x9'; +globalThis.SLIDE_W = 10; globalThis.SLIDE_H = 5.625; +globalThis.MARGIN = 0.5; globalThis.CONTENT_W = 9; globalThis.CONTENT_H = 3.8; +function __mime(b){ if(b.length>=2&&b[0]===0x89&&b[1]===0x50)return 'image/png'; if(b.length>=2&&b[0]===0xff&&b[1]===0xd8)return 'image/jpeg'; if(b.length>=3&&b[0]===0x47&&b[1]===0x49&&b[2]===0x46)return 'image/gif'; if(b.length>=12&&b.slice(0,4).toString('latin1')==='RIFF'&&b.slice(8,12).toString('latin1')==='WEBP')return 'image/webp'; return 'image/png'; } +globalThis.getFileBase64 = async function(fileId){ const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('getFileBase64: file not staged: '+fileId); const b=fs.readFileSync(p); return __mime(b)+';base64,'+b.toString('base64'); }; +globalThis.addImage = async function(slide, fileId, opts){ if(!opts||opts.x==null||opts.y==null||opts.w==null||opts.h==null) throw new Error('addImage: opts must include x, y, w, h'); const data=await globalThis.getFileBase64(fileId); slide.addImage(Object.assign({}, opts, { data })); }; +globalThis.iconImage = async function(IconComponent, color, size){ const React=require('react'); const RDS=require('react-dom/server'); const sharp=require('sharp'); const svg=RDS.renderToStaticMarkup(React.createElement(IconComponent,{color:color||'#000000',size:String(size||256)})); const png=await sharp(Buffer.from(svg)).png().toBuffer(); return 'image/png;base64,'+png.toString('base64'); }; +`.trim() + +const DOCX_NODE_PREAMBLE = ` +const docx = require('docx'); +const fs = require('fs'); +globalThis.docx = docx; +globalThis.__docxSections = []; +globalThis.__docxDocOptions = null; +globalThis.addSection = function(s){ globalThis.__docxSections.push(s); }; +globalThis.PAGE_W = 12240; globalThis.PAGE_H = 15840; globalThis.MARGIN = 1440; globalThis.CONTENT_W = 9360; +globalThis.getFileBase64 = async function(fileId){ const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('getFileBase64: file not staged: '+fileId); const b=fs.readFileSync(p); const m=(b[0]===0x89?'image/png':b[0]===0xff?'image/jpeg':b[0]===0x47?'image/gif':'image/png'); return 'data:'+m+';base64,'+b.toString('base64'); }; +globalThis.addImage = async function(fileId, opts){ if(!opts||opts.width==null||opts.height==null) throw new Error('addImage: opts must include width and height'); const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('addImage: file not staged: '+fileId); const b=fs.readFileSync(p); const ext=(b[0]===0x89?'png':b[0]===0xff?'jpg':b[0]===0x47?'gif':'png'); const { width, height, type:_t, data:_d, transformation:ut, ...rest } = opts; return new docx.ImageRun(Object.assign(rest, { data: b, type: ext, transformation: Object.assign({ width, height }, ut||{}) })); }; +`.trim() + +const PPTX_NODE_FINALIZE = `await globalThis.pptx.writeFile({ fileName: '/home/user/output.pptx' });` +const DOCX_NODE_FINALIZE = ` +let doc = globalThis.doc; +if (!doc && globalThis.__docxSections.length > 0) doc = new docx.Document(Object.assign({}, globalThis.__docxDocOptions || {}, { sections: globalThis.__docxSections })); +if (!doc) throw new Error('No document created. Use addSection({ children: [...] }) for chunked writes, or set globalThis.doc.'); +const __buf = await docx.Packer.toBuffer(doc); +fs.writeFileSync('/home/user/output.docx', __buf); +`.trim() + +/** + * Compiles a pptx/docx document by running the agent's pptxgenjs/docx source in + * the E2B doc sandbox via Node. Mirrors compileDocViaE2BPython for the JS + * engines. Throws DocCompileUserError on a script error. + */ +async function compileDocViaE2BNode( + { source, fileName, workspaceId }: CompileArgs, + ext: 'pptx' | 'docx' +): Promise { + const sandboxFiles = await stageReferencedImages(source, workspaceId) + const outputSandboxPath = `/home/user/output.${ext}` + const preamble = ext === 'pptx' ? PPTX_NODE_PREAMBLE : DOCX_NODE_PREAMBLE + const finalize = ext === 'pptx' ? PPTX_NODE_FINALIZE : DOCX_NODE_FINALIZE + + const script = `${preamble} +;(async () => { +${source} +${finalize} +})().then(() => console.log('__DOC_OK__')).catch((e) => { console.error('__DOC_ERR__' + (e && e.message ? e.message : String(e))); process.exit(1); }); +` + + const result = await executeShellInE2B({ + code: 'NODE_PATH=$(npm root -g) node /home/user/script.js', + envs: {}, + timeoutMs: DOC_COMPILE_TIMEOUT_MS, + sandboxKind: 'doc', + sandboxFiles: [ + ...sandboxFiles, + { + path: '/home/user/script.js', + content: Buffer.from(script, 'utf-8').toString('base64'), + encoding: 'base64', + }, + ], + outputSandboxPath, + }) + + // Success requires the script to reach the finalizer (__DOC_OK__) AND produce + // the output file — a script that writes then throws must not persist a + // partial/corrupt artifact (mirrors the Python path). + const out = `${result.stdout || ''}\n${result.error || ''}` + const errMatch = out.match(/__DOC_ERR__([\s\S]*)/) + if (out.includes('__DOC_OK__') && result.exportedFileContent) { + return Buffer.from(result.exportedFileContent, 'base64') + } + if (errMatch) { + // The script ran and threw — a user-code error the agent should fix. + throw new DocCompileUserError( + `${ext.toUpperCase()} generation failed: ${errMatch[1]?.trim() || 'unknown error'}` + ) + } + // No __DOC_OK__ and no __DOC_ERR__ → node never completed (sandbox died, command + // failure, or the output couldn't be read). That's a retriable system error, not + // the agent's code — surface it as a plain Error so callers don't tell the agent + // to "fix its code". + throw new Error( + `${ext.toUpperCase()} compile did not complete in the sandbox: ${result.error || 'no output produced'}` + ) +} + +/** + * Returns the compiled binary for a doc, building it once (via the right engine — + * Node for pptx/docx, Python for pdf/xlsx) if the source-hash artifact is not + * already in S3. Used by read paths (serve, render, compiled-check) so E2B runs + * at most once per distinct source. + */ +export async function compileDoc( + args: CompileArgs +): Promise<{ buffer: Buffer; contentType: string }> { + const { source, fileName, workspaceId } = args + const fmt = getE2BDocFormat(fileName) + if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) + + const existing = await loadCompiledDoc(workspaceId, source, fmt.ext) + if (existing) return { buffer: existing, contentType: fmt.contentType } + + const buffer = + fmt.engine === 'node' + ? await compileDocViaE2BNode({ source, fileName, workspaceId }, fmt.ext as 'pptx' | 'docx') + : await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt) + await storeCompiledDoc(workspaceId, source, fmt.ext, fmt.contentType, buffer) + return { buffer, contentType: fmt.contentType } +} + +/** + * Loads a compiled doc artifact by extension when present, without compiling. + * Used by the serve route, which has the source + ext but no file record — a hit + * means the file is a generated doc whose binary is already built. + */ +export async function loadCompiledDocByExt( + workspaceId: string, + source: string, + ext: string +): Promise<{ buffer: Buffer; contentType: string } | null> { + const fmt = getE2BDocFormat(`x.${ext}`) + if (!fmt) return null + const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext) + return buffer ? { buffer, contentType: fmt.contentType } : null +} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts new file mode 100644 index 00000000000..470cffef323 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts @@ -0,0 +1,70 @@ +import { createHash } from 'node:crypto' +import { createLogger } from '@sim/logger' +import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' + +const logger = createLogger('CopilotDocCompiledStore') + +/** + * Compiled-artifact store for Python-generated documents. + * + * The Python doc path keeps the SOURCE as the primary file (the agent reads and + * edits it exactly like the JS path). The compiled binary is stored as its own + * S3 object, content-addressed by (workspaceId, sha256(source), ext) — the hash + * is in the key, so when the source changes the key changes. Every read path + * (serve, preview, /compiled) loads the artifact for the current source hash and + * recompiles only when it is absent. No fileId in the key means any site with + * the source (e.g. the serve route) can find it. S3 is cheap; stale artifacts + * are inert. + */ +function compiledArtifactKey(workspaceId: string, source: string, ext: string): string { + const hash = createHash('sha256').update(source, 'utf-8').digest('hex') + return `copilot-doc-compiled/${workspaceId}/${hash}.${ext}` +} + +/** Loads the compiled binary for the current source, or null if not yet built. */ +export async function loadCompiledDoc( + workspaceId: string, + source: string, + ext: string +): Promise { + const key = compiledArtifactKey(workspaceId, source, ext) + try { + return await downloadFile({ key, context: 'copilot' }) + } catch { + return null + } +} + +/** + * Stores the compiled binary as the source's associated S3 artifact. + * + * Throws on failure (does not swallow): the serve route is load-only and cannot + * self-heal a missing artifact, so a silent store failure would make a write + * report success while leaving the document unrenderable. Propagating lets the + * write fail honestly so the caller (and the agent) can retry. + */ +export async function storeCompiledDoc( + workspaceId: string, + source: string, + ext: string, + contentType: string, + binary: Buffer +): Promise { + const key = compiledArtifactKey(workspaceId, source, ext) + try { + await uploadFile({ + file: binary, + fileName: `doc.${ext}`, + contentType, + context: 'copilot', + customKey: key, + preserveKey: true, + }) + } catch (err) { + logger.error('Failed to store compiled doc artifact', { + key, + error: err instanceof Error ? err.message : String(err), + }) + throw err instanceof Error ? err : new Error(String(err)) + } +} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-extract.ts b/apps/sim/lib/copilot/tools/server/files/doc-extract.ts new file mode 100644 index 00000000000..1674b7c7af7 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/doc-extract.ts @@ -0,0 +1,113 @@ +import { executeInE2B } from '@/lib/execution/e2b' +import { CodeLanguage } from '@/lib/execution/languages' + +const EXTRACT_TIMEOUT_MS = 120_000 +// Bound the text handed back to the agent so a huge document can't blow the +// context window; the agent gets a clear truncation marker if it hits the cap. +const MAX_EXTRACT_CHARS = 200_000 + +/** Binary document formats whose text/tables we can extract in the doc sandbox. */ +const EXTRACTABLE_EXTS = new Set(['pdf', 'pptx', 'docx', 'xlsx']) + +export function isExtractableDocExt(ext: string): boolean { + return EXTRACTABLE_EXTS.has(ext.toLowerCase()) +} + +export interface DocExtract { + text: string + truncated: boolean +} + +/** + * Extracts readable text (and tables) from an uploaded binary document inside the + * E2B doc sandbox so the agent can read/reason over files it cannot otherwise see + * as source: pdf via pdfplumber, pptx via python-pptx, docx via python-docx, xlsx + * via openpyxl. Read-only — never mutates the file. Throws on sandbox/infra + * failure or an unparseable document. + */ +export async function extractDocText(args: { binary: Buffer; ext: string }): Promise { + const ext = args.ext.toLowerCase() + if (!isExtractableDocExt(ext)) { + throw new Error(`Cannot extract text from .${ext} (supported: pdf, pptx, docx, xlsx)`) + } + + const script = ` +import json +ext = ${JSON.stringify(ext)} +inp = f"/home/user/input.{ext}" +out = [] + +if ext == "pdf": + import pdfplumber + with pdfplumber.open(inp) as pdf: + for i, page in enumerate(pdf.pages, 1): + out.append(f"--- Page {i} ---") + out.append(page.extract_text() or "") + for t in (page.extract_tables() or []): + out.append("[table] " + json.dumps(t, ensure_ascii=False)) +elif ext == "pptx": + from pptx import Presentation + prs = Presentation(inp) + for i, slide in enumerate(prs.slides, 1): + out.append(f"--- Slide {i} ---") + for shape in slide.shapes: + if shape.has_text_frame and shape.text_frame.text.strip(): + out.append(shape.text_frame.text) + if shape.has_table: + for row in shape.table.rows: + out.append(" | ".join(c.text for c in row.cells)) + nf = slide.notes_slide.notes_text_frame if slide.has_notes_slide else None + notes = nf.text if nf is not None else "" + if notes.strip(): + out.append("[notes] " + notes) +elif ext == "docx": + import docx + d = docx.Document(inp) + for p in d.paragraphs: + if p.text.strip(): + out.append(p.text) + for tbl in d.tables: + for row in tbl.rows: + out.append(" | ".join(c.text for c in row.cells)) +elif ext == "xlsx": + import openpyxl + wb = openpyxl.load_workbook(inp, data_only=True) + for ws in wb.worksheets: + out.append(f"--- Sheet {ws.title} ---") + # Cap rows so an inflated used-range can't blow up memory/output. + for ri, row in enumerate(ws.iter_rows(values_only=True)): + if ri >= 5000: + out.append("[... more rows truncated]") + break + out.append(",".join("" if v is None else str(v) for v in row)) + +# Bound the transferred text so a decompression bomb can't return gigabytes. +# Headroom over MAX_EXTRACT_CHARS so the TS-side truncation flag can still fire. +text = "\\n".join(out)[:${MAX_EXTRACT_CHARS + 20000}] +print("__SIM_RESULT__=" + json.dumps({"text": text})) +`.trim() + + const result = await executeInE2B({ + code: script, + language: CodeLanguage.Python, + timeoutMs: EXTRACT_TIMEOUT_MS, + sandboxKind: 'doc', + sandboxFiles: [ + { + path: `/home/user/input.${ext}`, + content: args.binary.toString('base64'), + encoding: 'base64', + }, + ], + }) + + if (result.error) { + throw new Error(`Document extraction failed: ${result.error}`) + } + const payload = result.result as { text?: string } | null + const full = payload?.text ?? '' + const truncated = full.length > MAX_EXTRACT_CHARS + // The caller (VFS read) owns the user-facing truncation note; just return the + // bounded text + the flag here. + return { text: full.slice(0, MAX_EXTRACT_CHARS), truncated } +} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts new file mode 100644 index 00000000000..dd8c43b3499 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts @@ -0,0 +1,122 @@ +import { createLogger } from '@sim/logger' +import { executeInE2B } from '@/lib/execution/e2b' +import { CodeLanguage } from '@/lib/execution/languages' +import { compileDoc, DocCompileUserError } from './doc-compile' + +const logger = createLogger('CopilotDocRecalc') + +const RECALC_TIMEOUT_MS = 150_000 +const MAX_REPORTED_ERRORS = 50 + +export interface XlsxCellError { + sheet: string + cell: string + error: string +} + +export interface XlsxRecalcResult { + ok: boolean + errors: XlsxCellError[] +} + +/** + * Scans an .xlsx workbook for spilled error values (#REF!/#DIV/0!/etc.) — the + * spreadsheet equivalent of the visual QA loop. + * + * Precondition: the binary must already be recalculated (cached values present). + * compileDoc bakes a LibreOffice recalc into every xlsx artifact, so the input + * here always has cached values — meaning we can read them with openpyxl + * (data_only) directly and skip a second LibreOffice cold-start. Throws on + * sandbox/infra failure. + */ +export async function recalcXlsx(args: { + binary: Buffer + workspaceId: string +}): Promise { + const script = ` +import json, openpyxl + +ERR = {"#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#N/A", "#NULL!", "#NUM!"} + +# Input is already recalculated by compileDoc, so cached values are present. +wb = openpyxl.load_workbook("/home/user/input.xlsx", data_only=True) +errors = [] +for ws in wb.worksheets: + for row in ws.iter_rows(): + for c in row: + if isinstance(c.value, str) and c.value.strip() in ERR: + errors.append({"sheet": ws.title, "cell": c.coordinate, "error": c.value.strip()}) + +print("__SIM_RESULT__=" + json.dumps({"ok": len(errors) == 0, "errors": errors[:${MAX_REPORTED_ERRORS}]})) +`.trim() + + const result = await executeInE2B({ + code: script, + language: CodeLanguage.Python, + timeoutMs: RECALC_TIMEOUT_MS, + sandboxKind: 'doc', + sandboxFiles: [ + { + path: '/home/user/input.xlsx', + content: args.binary.toString('base64'), + encoding: 'base64', + }, + ], + }) + + if (result.error) { + throw new Error(`Spreadsheet recalc failed: ${result.error}`) + } + const payload = result.result as XlsxRecalcResult | null + if (!payload || typeof payload.ok !== 'boolean') { + logger.warn('Recalc returned no structured result', { workspaceId: args.workspaceId }) + return { ok: true, errors: [] } + } + return { ok: payload.ok, errors: Array.isArray(payload.errors) ? payload.errors : [] } +} + +/** Single-line summary of the first few formula errors, for the compiled-check result. */ +export function formatXlsxErrors(errors: XlsxCellError[]): string { + return `${errors.length} formula error(s): ${errors + .slice(0, 5) + .map((e) => `${e.sheet}!${e.cell}=${e.error}`) + .join(', ')}` +} + +export interface CompiledCheckResult { + ok: boolean + error?: string + errors?: XlsxCellError[] +} + +/** + * Compiles a generated doc (and, for xlsx, recalc-scans its formulas) to verify + * it builds — the shared body behind the /compiled-check route and the VFS + * compiled-check read. Returns { ok: false } only for a DocCompileUserError (the + * agent's script is wrong); infra failures (E2B/S3) rethrow so callers surface a + * 5xx instead of telling the agent to fix its script. + */ +export async function runE2BCompiledCheck(args: { + source: string + fileName: string + workspaceId: string + ext: string +}): Promise { + try { + const compiled = await compileDoc({ + source: args.source, + fileName: args.fileName, + workspaceId: args.workspaceId, + }) + if (args.ext === 'xlsx') { + const recalc = await recalcXlsx({ binary: compiled.buffer, workspaceId: args.workspaceId }) + return recalc.ok + ? { ok: true } + : { ok: false, error: formatXlsxErrors(recalc.errors), errors: recalc.errors } + } + return { ok: true } + } catch (err) { + if (err instanceof DocCompileUserError) return { ok: false, error: err.message } + throw err + } +} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-render.ts b/apps/sim/lib/copilot/tools/server/files/doc-render.ts new file mode 100644 index 00000000000..70b3a2a2d97 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/doc-render.ts @@ -0,0 +1,109 @@ +import { executeInE2B } from '@/lib/execution/e2b' +import { CodeLanguage } from '@/lib/execution/languages' + +const RENDER_TIMEOUT_MS = 150_000 +// Bound the visual-QA cost: cap pages and rasterization DPI so the JPEGs the +// file agent inspects stay small enough for vision input. +const MAX_RENDER_PAGES = 20 +const RENDER_DPI = 110 + +/** Extensions LibreOffice can render to page images for the visual QA loop. */ +const RENDERABLE_EXTS = new Set(['pptx', 'docx', 'pdf']) + +export function isRenderableDocExt(ext: string): boolean { + return RENDERABLE_EXTS.has(ext.toLowerCase()) +} + +export interface DocRender { + /** A single contact-sheet grid JPEG of all pages, for the agent's visual QA. */ + grid: Buffer + pageCount: number +} + +/** + * Renders a compiled document binary to a single contact-sheet grid image inside + * the E2B doc sandbox (LibreOffice → PDF → poppler `pdftoppm` → Pillow tile). + * Works for any compiled binary regardless of which engine produced it + * (isolated-vm JS or E2B Python), so the visual QA loop covers pptx/docx/pdf + * uniformly. One grid image fits the VFS single-attachment read and gives the + * agent the whole deck/doc at a glance (mirrors Anthropic's thumbnail grid). + * + * Throws on a sandbox/infra failure or when the doc renders to zero pages. + */ +export async function renderDocToGrid(args: { + binary: Buffer + ext: string + workspaceId: string +}): Promise { + const ext = args.ext.toLowerCase() + if (!isRenderableDocExt(ext)) { + throw new Error(`Cannot render .${ext} to images (supported: pptx, docx, pdf)`) + } + + const script = ` +import subprocess, glob, base64, json +from PIL import Image + +ext = ${JSON.stringify(ext)} +inp = f"/home/user/input.{ext}" +pdf = inp if ext == "pdf" else "/home/user/input.pdf" + +if ext != "pdf": + subprocess.run( + ["soffice", "--headless", "--convert-to", "pdf", "--outdir", "/home/user", inp], + check=True, timeout=120, capture_output=True, + ) + +subprocess.run( + ["pdftoppm", "-jpeg", "-r", "${RENDER_DPI}", "-l", "${MAX_RENDER_PAGES}", pdf, "/home/user/page"], + check=True, timeout=120, capture_output=True, +) + +paths = sorted(glob.glob("/home/user/page*.jpg"))[:${MAX_RENDER_PAGES}] +imgs = [Image.open(p).convert("RGB") for p in paths] +n = len(imgs) +if n == 0: + print("__SIM_RESULT__=" + json.dumps({"grid": None, "pageCount": 0})) +else: + cols = 1 if n == 1 else (2 if n <= 6 else 3) + rows = (n + cols - 1) // cols + cell_w = max(i.width for i in imgs) + cell_h = max(i.height for i in imgs) + pad = 12 + grid = Image.new("RGB", (cols * cell_w + (cols + 1) * pad, rows * cell_h + (rows + 1) * pad), (240, 240, 240)) + for idx, im in enumerate(imgs): + r, c = divmod(idx, cols) + grid.paste(im, (pad + c * (cell_w + pad), pad + r * (cell_h + pad))) + # Cap the grid's longest edge so the JPEG stays a reasonable vision input. + max_edge = 2200 + if max(grid.size) > max_edge: + scale = max_edge / max(grid.size) + grid = grid.resize((int(grid.width * scale), int(grid.height * scale))) + grid.save("/home/user/grid.jpg", "JPEG", quality=80) + with open("/home/user/grid.jpg", "rb") as f: + print("__SIM_RESULT__=" + json.dumps({"grid": base64.b64encode(f.read()).decode(), "pageCount": n})) +`.trim() + + const result = await executeInE2B({ + code: script, + language: CodeLanguage.Python, + timeoutMs: RENDER_TIMEOUT_MS, + sandboxKind: 'doc', + sandboxFiles: [ + { + path: `/home/user/input.${ext}`, + content: args.binary.toString('base64'), + encoding: 'base64', + }, + ], + }) + + if (result.error) { + throw new Error(`Document render failed: ${result.error}`) + } + const payload = result.result as { grid?: string | null; pageCount?: number } | null + if (!payload?.grid) { + throw new Error('Document render produced no pages') + } + return { grid: Buffer.from(payload.grid, 'base64'), pageCount: payload.pageCount ?? 0 } +} diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index c9b8a5e4b61..d5ec0650171 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -8,8 +8,8 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getExtensionFromMimeType, getFileExtension, @@ -22,6 +22,19 @@ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB const DownloadToWorkspaceFileArgsSchema = z.object({ url: z.string().url(), fileName: z.string().min(1).optional(), + outputs: z + .object({ + files: z + .array( + z.object({ + path: z.string().min(1), + mode: z.enum(['create', 'overwrite']).optional(), + mimeType: z.string().optional(), + }) + ) + .optional(), + }) + .optional(), }) const DownloadToWorkspaceFileResultSchema = z.object({ @@ -29,6 +42,7 @@ const DownloadToWorkspaceFileResultSchema = z.object({ message: z.string(), fileId: z.string().optional(), fileName: z.string().optional(), + vfsPath: z.string().optional(), downloadUrl: z.string().optional(), }) @@ -161,7 +175,9 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< params.fileName, params.url ) + const outputFile = params.outputs?.files?.[0] const fileName = inferOutputFileName(params.fileName, response.headers, params.url, mimeType) + const outputPath = outputFile?.path ?? `files/${fileName}` assertServerToolNotAborted(context) @@ -173,28 +189,34 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } assertServerToolNotAborted(context) - const uploaded = await uploadWorkspaceFile( + const written = await writeWorkspaceFileByPath({ workspaceId, - context.userId, - fileBuffer, - fileName, - mimeType - ) + userId: context.userId, + target: { + path: outputPath, + mode: outputFile?.mode ?? 'create', + mimeType: outputFile?.mimeType, + }, + buffer: fileBuffer, + inferredMimeType: outputFile?.mimeType ?? mimeType, + }) logger.info('Downloaded remote file to workspace', { sourceUrl: params.url, - fileId: uploaded.id, - fileName: uploaded.name, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, mimeType, size: fileBuffer.length, }) return { success: true, - message: `Downloaded "${uploaded.name}" to workspace (${fileBuffer.length} bytes)`, - fileId: uploaded.id, - fileName: uploaded.name, - downloadUrl: uploaded.url, + message: `Downloaded "${written.name}" to ${written.vfsPath} (${fileBuffer.length} bytes)`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, } } catch (error) { const msg = getErrorMessage(error, 'Unknown error') diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index b668338120c..e272146045a 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -1,14 +1,15 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { runSandboxTask } from '@/lib/execution/sandbox/run-task' +import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getE2BDocFormat } from './doc-compile' import { consumeLatestFileIntent } from './file-intent-store' -import { getDocumentFormatInfo, inferContentType } from './workspace-file' +import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file' const logger = createLogger('EditContentServerTool') @@ -63,12 +64,20 @@ export const editContentServerTool: BaseServerTool 0) return arr + } + return values + .map((value) => stringValue(value)) + .filter((value): value is string => Boolean(value)) +} + +function decodeFileFolderPath(path: string): string[] | null { + const trimmed = path.trim().replace(/\/+$/, '') + if (!trimmed || trimmed === 'files') return null + const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed + const withoutMarker = withoutPrefix.endsWith('/.folder') + ? withoutPrefix.slice(0, -'/.folder'.length) + : withoutPrefix + const segments = decodeVfsPathSegments(withoutMarker).filter(Boolean) + return segments.length > 0 ? segments : null +} + +async function resolveFolderIdFromPath( + workspaceId: string, + path: string, + label = 'Folder' +): Promise { + const segments = decodeFileFolderPath(path) + if (!segments) throw new Error(`${label} path must identify a folder under files/`) + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (!folderId) throw new Error(`${label} not found at files/${segments.join('/')}`) + return folderId +} + +async function resolveOptionalFolderId( + workspaceId: string, + value: unknown +): Promise { + const raw = nullableStringValue(value) + if (raw === undefined) return undefined + if (raw === null) return null + const segments = decodeFileFolderPath(raw) + if (!segments) return null + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) + if (!folderId) throw new Error(`Target folder not found at files/${segments.join('/')}`) + return folderId +} + +async function resolveFileIdsFromPaths( + workspaceId: string, + paths: string[] +): Promise<{ + fileIds: string[] + failed: string[] +}> { + const fileIds: string[] = [] + const failed: string[] = [] + for (const path of paths) { + const file = await resolveWorkspaceFileReference(workspaceId, path) + if (!file) { + failed.push(path) + continue + } + fileIds.push(file.id) + } + return { fileIds, failed } +} + async function resolveWorkspaceId( params: WorkspaceScopedArgs, context: ServerToolContext | undefined, @@ -146,10 +226,33 @@ export const createFileFolderServerTool: BaseServerTool 1) { + const resolvedParentId = await findWorkspaceFileFolderIdByPath( + workspaceId, + pathSegments.slice(0, -1) + ) + if (!resolvedParentId) { + return { + success: false, + message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`, + } + } + parentId = resolvedParentId + } assertServerToolNotAborted(context) const result = await performCreateWorkspaceFileFolder({ @@ -193,9 +296,14 @@ export const renameFileFolderServerTool: BaseServerTool 0 + ? await Promise.all(paths.map((path) => resolveFolderIdFromPath(workspaceId, path))) + : (params.folderIds ?? + stringArrayValue(payload?.folderIds) ?? + [stringValue(params.folderId) || stringValue(payload?.folderId) || ''].filter(Boolean)) + if (folderIds.length === 0) return { success: false, message: 'paths is required' } assertServerToolNotAborted(context) const result = await performDeleteWorkspaceFileItems({ @@ -336,13 +458,29 @@ export const moveFileServerTool: BaseServerTool if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) + const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) + const resolvedByPath = + paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined + if (resolvedByPath?.failed.length) { + return { + success: false, + message: `Files not found: ${resolvedByPath.failed.join(', ')}`, + } + } const fileIds = + resolvedByPath?.fileIds ?? params.fileIds ?? stringArrayValue(payload?.fileIds) ?? [stringValue(params.fileId) || stringValue(payload?.fileId) || ''].filter(Boolean) - if (fileIds.length === 0) return { success: false, message: 'fileIds is required' } - - const folderId = nullableStringValue(params.folderId ?? payload?.folderId) ?? null + if (fileIds.length === 0) return { success: false, message: 'paths is required' } + + const folderId = + (await resolveOptionalFolderId( + workspaceId, + params.destinationPath ?? payload?.destinationPath + )) ?? + nullableStringValue(params.folderId ?? payload?.folderId) ?? + null assertServerToolNotAborted(context) const result = await performMoveWorkspaceFileItems({ diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index 1f8ac039f38..10bac242eca 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -6,14 +6,18 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + getWorkspaceFile, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performRenameWorkspaceFile } from '@/lib/workspace-files/orchestration' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') interface RenameFileArgs { - fileId: string + path?: string + fileId?: string newName: string args?: Record } @@ -40,18 +44,23 @@ export const renameFileServerTool: BaseServerTool ({ + ensureWorkspaceAccess: vi.fn(), + resolveWorkflowAliasForWorkspace: vi.fn(), + writeWorkspaceFileByPath: vi.fn(), +})) + +vi.mock('@/lib/copilot/tools/handlers/access', () => ({ + ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, +})) + +vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ + resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace, +})) + +vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ + writeWorkspaceFileByPath: mocks.writeWorkspaceFileByPath, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isMothershipBetaFeaturesEnabled: true, +})) + +import { touchPlanServerTool } from './touch-plan' + +describe('touch_plan server tool', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) + }) + + it('creates a workflow-local plan alias and returns backing metadata', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/implementation.md', + backingPath: 'files/.plans/wf_1/implementation.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'implementation.md', + }) + mocks.writeWorkspaceFileByPath.mockResolvedValue({ + id: 'file-plan', + name: 'implementation.md', + vfsPath: 'workflows/My%20Workflow/.plans/implementation.md', + backingVfsPath: 'files/.plans/wf_1/implementation.md', + }) + + const result = await touchPlanServerTool.execute( + { workflowPath: 'workflows/My Workflow', name: 'implementation' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(mocks.resolveWorkflowAliasForWorkspace).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + path: 'workflows/My%20Workflow/.plans/implementation.md', + }) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/implementation.md', + mode: 'create', + mimeType: 'text/markdown', + }, + buffer: Buffer.from('', 'utf-8'), + inferredMimeType: 'text/markdown', + }) + expect(result).toMatchObject({ + success: true, + data: { + id: 'file-plan', + scope: 'workflow', + vfsPath: 'workflows/My%20Workflow/.plans/implementation.md', + backingVfsPath: 'files/.plans/wf_1/implementation.md', + workflowId: 'wf_1', + }, + }) + }) + + it('creates a workspace root plan alias and returns backing metadata', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: '.plans/migration.md', + backingPath: 'files/.plans/workspace/migration.md', + backingFolderPath: 'files/.plans/workspace', + planRelativePath: 'migration.md', + }) + mocks.writeWorkspaceFileByPath.mockResolvedValue({ + id: 'file-root-plan', + name: 'migration.md', + vfsPath: '.plans/migration.md', + backingVfsPath: 'files/.plans/workspace/migration.md', + }) + + const result = await touchPlanServerTool.execute( + { scope: 'workspace', name: 'migration' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(mocks.resolveWorkflowAliasForWorkspace).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + path: '.plans/migration.md', + }) + expect(mocks.writeWorkspaceFileByPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: '.plans/migration.md', + mode: 'create', + mimeType: 'text/markdown', + }, + buffer: Buffer.from('', 'utf-8'), + inferredMimeType: 'text/markdown', + }) + expect(result).toMatchObject({ + success: true, + data: { + id: 'file-root-plan', + scope: 'workspace', + vfsPath: '.plans/migration.md', + backingVfsPath: 'files/.plans/workspace/migration.md', + }, + }) + }) + + it('rejects missing workflows before writing', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + + const result = await touchPlanServerTool.execute( + { workflowPath: 'workflows/Missing', name: 'implementation.md' }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toContain('Workflow not found') + expect(mocks.writeWorkspaceFileByPath).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/files/touch-plan.ts b/apps/sim/lib/copilot/tools/server/files/touch-plan.ts new file mode 100644 index 00000000000..168903236ba --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/touch-plan.ts @@ -0,0 +1,152 @@ +import { createLogger } from '@sim/logger' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { + canonicalizeVfsPath, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' + +const logger = createLogger('TouchPlanServerTool') +const TOUCH_PLAN_TOOL_ID = 'touch_plan' + +interface TouchPlanArgs { + scope?: 'workspace' | 'workflow' + workflowPath?: string + name: string + title?: string + args?: Record +} + +interface TouchPlanResult { + success: boolean + message: string + data?: { + id: string + name: string + vfsPath: string + backingVfsPath?: string + scope: 'workspace' | 'workflow' + workflowId?: string + } +} + +function normalizeWorkflowPath(path: string): string { + const trimmed = path.trim().replace(/^\/+|\/+$/g, '') + const withoutKnownLeaf = trimmed + .replace(/\/(meta|state|executions|deployment|versions|links)\.json$/, '') + .replace(/\/changelog\.md$/, '') + .replace(/\/\.plans$/, '') + + const canonical = canonicalizeVfsPath(withoutKnownLeaf) + if (!canonical.startsWith('workflows/')) { + throw new Error('workflowPath must be a canonical workflows/... VFS path') + } + return canonical +} + +function normalizePlanRelativePath(name: string): string { + const segments = decodeVfsPathSegments(name) + if (segments.length === 0) { + throw new Error('Plan name is required') + } + const leaf = segments.at(-1) ?? '' + const leafWithExtension = leaf.includes('.') ? leaf : `${leaf}.md` + return encodeVfsPathSegments([...segments.slice(0, -1), leafWithExtension]) +} + +export const touchPlanServerTool: BaseServerTool = { + name: TOUCH_PLAN_TOOL_ID, + async execute(params: TouchPlanArgs, context?: ServerToolContext): Promise { + if (!isMothershipBetaFeaturesEnabled) { + return { success: false, message: 'touch_plan is not available' } + } + if (!context?.userId) { + throw new Error('Authentication required') + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + + const nested = params.args + const nestedScope = nested?.scope as TouchPlanArgs['scope'] | undefined + const scope = + params.scope || + nestedScope || + (params.workflowPath || nested?.workflowPath ? 'workflow' : 'workspace') + const workflowPath = params.workflowPath || (nested?.workflowPath as string) || '' + const name = params.name || (nested?.name as string) || '' + if (!name) { + return { success: false, message: 'touch_plan requires name' } + } + if (scope !== 'workspace' && scope !== 'workflow') { + return { success: false, message: 'touch_plan scope must be "workspace" or "workflow"' } + } + if (scope === 'workflow' && !workflowPath) { + return { + success: false, + message: 'touch_plan with workflow scope requires workflowPath and name', + } + } + + const planRelativePath = normalizePlanRelativePath(name) + const aliasPath = + scope === 'workspace' + ? `.plans/${planRelativePath}` + : `${normalizeWorkflowPath(workflowPath)}/.plans/${planRelativePath}` + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: aliasPath }) + if (!alias || alias.kind !== 'plan_file') { + return { + success: false, + message: + scope === 'workflow' + ? `Workflow not found for plan path: ${aliasPath}` + : `Unsupported workspace plan path: ${aliasPath}`, + } + } + + assertServerToolNotAborted(context) + const result = await writeWorkspaceFileByPath({ + workspaceId, + userId: context.userId, + target: { + path: aliasPath, + mode: 'create', + mimeType: 'text/markdown', + }, + buffer: Buffer.from('', 'utf-8'), + inferredMimeType: 'text/markdown', + }) + + logger.info('Workflow plan touched via copilot', { + workspaceId, + workflowId: alias.scope === 'workflow' ? alias.workflowId : undefined, + scope: alias.scope, + vfsPath: result.vfsPath, + backingVfsPath: result.backingVfsPath, + userId: context.userId, + }) + + return { + success: true, + message: `${alias.scope === 'workspace' ? 'Workspace' : 'Workflow'} plan "${result.vfsPath}" created successfully`, + data: { + id: result.id, + name: result.name, + vfsPath: result.vfsPath, + backingVfsPath: result.backingVfsPath, + scope: alias.scope, + workflowId: alias.scope === 'workflow' ? alias.workflowId : undefined, + }, + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index a84c69e24a0..2ebc7319048 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -8,19 +8,32 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases' +import { isE2BDocEnabled } from '@/lib/core/config/feature-flags' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchWorkspaceFileBuffer as downloadWsFile, getWorkspaceFile, getWorkspaceFileByName, + resolveWorkspaceFileReference, uploadWorkspaceFile, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performDeleteWorkspaceFileItems, performRenameWorkspaceFile, } from '@/lib/workspace-files/orchestration' import type { SandboxTaskId } from '@/sandbox-tasks/registry' +import { + compileDoc, + DOCXJS_SOURCE_MIME, + DocCompileUserError, + getE2BDocFormat, + PPTXGENJS_SOURCE_MIME, +} from './doc-compile' import { storeFileIntent } from './file-intent-store' const logger = createLogger('WorkspaceFileServerTool') @@ -28,8 +41,9 @@ const logger = createLogger('WorkspaceFileServerTool') const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' const PDF_MIME = 'application/pdf' -const PPTX_SOURCE_MIME = 'text/x-pptxgenjs' -const DOCX_SOURCE_MIME = 'text/x-docxjs' +// Single source of the JS source MIMEs is doc-compile.ts; reuse to avoid drift. +const PPTX_SOURCE_MIME = PPTXGENJS_SOURCE_MIME +const DOCX_SOURCE_MIME = DOCXJS_SOURCE_MIME const PDF_SOURCE_MIME = 'text/x-pdflibjs' type WorkspaceFileOperation = 'create' | 'append' | 'update' | 'delete' | 'rename' | 'patch' @@ -45,6 +59,11 @@ type WorkspaceFileTarget = fileId: string fileName?: string } + | { + kind: 'path' + path: string + fileName?: string + } type WorkspaceFileEdit = | { @@ -162,6 +181,74 @@ export function getDocumentFormatInfo(fileName: string): DocumentFormatInfo { return { isDoc: false } } +export type CompileForWriteResult = + | { ok: true; sourceMime: string } + | { ok: false; message: string } + +/** + * Shared write-time doc handling for create + edit_content: validates and builds + * the document (E2B doc sandbox when enabled — Node pptx/docx, Python pdf/xlsx — + * else isolated-vm JS) and returns the source MIME to store, or a user-facing + * failure message. Non-doc files resolve to `fallbackMime`. Compilation happens + * here exactly once per write; the artifact is content-addressed so a read can + * later just load it. + */ +export async function compileDocForWrite(args: { + source: string + fileName: string + workspaceId: string + ownerKey: string + signal?: AbortSignal + fallbackMime: string +}): Promise { + const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args + const docInfo = getDocumentFormatInfo(fileName) + const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(fileName) : null + + if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) { + return { + ok: false, + message: isE2BDocEnabled + ? 'Excel (.xlsx) generation is currently behind a beta flag (MOTHERSHIP_BETA_FEATURES) and is not available.' + : 'Excel (.xlsx) generation requires the E2B document sandbox, which is not enabled in this environment.', + } + } + + if (e2bFmt) { + // compileDoc is load-or-build, so an identical re-write reuses the cached + // binary instead of re-running E2B. + try { + await compileDoc({ source, fileName, workspaceId }) + } catch (err) { + if (err instanceof DocCompileUserError) { + return { + ok: false, + message: `${e2bFmt.formatName} generation failed: ${err.message}. Fix the code and retry.`, + } + } + return { + ok: false, + message: `${e2bFmt.formatName} generation failed due to a system error: ${toError(err).message}. Retry shortly.`, + } + } + return { ok: true, sourceMime: e2bFmt.sourceMime } + } + + if (docInfo.isDoc) { + try { + await runSandboxTask(docInfo.taskId!, { code: source, workspaceId }, { ownerKey, signal }) + } catch (err) { + return { + ok: false, + message: `${docInfo.formatName} generation failed: ${toError(err).message}. Fix the code and retry.`, + } + } + return { ok: true, sourceMime: docInfo.sourceMime! } + } + + return { ok: true, sourceMime: fallbackMime } +} + export const workspaceFileServerTool: BaseServerTool = { name: WorkspaceFile.id, async execute( @@ -195,6 +282,57 @@ export const workspaceFileServerTool: BaseServerTool => { + if (!target || (target.kind !== 'path' && target.kind !== 'file_id')) { + return { error: `${operationName} requires target.kind=path with target.path` } + } + let fileRecord: WorkspaceFileRecord | null = null + let vfsPath: string | undefined + if (target.kind === 'path') { + const alias = await resolveWorkflowAliasForWorkspace({ + workspaceId: workspaceId!, + path: target.path, + }) + if (!alias && isPlanAliasPath(target.path)) { + return { error: `Unsupported plan alias path or missing workflow: ${target.path}` } + } + if (alias) { + if (alias.kind === 'plans_dir') { + return { error: `Plan alias directory is not a file: ${target.path}` } + } + fileRecord = await resolveWorkspaceFileReference(workspaceId!, alias.backingPath) + if (!fileRecord && alias.kind === 'changelog') { + await ensureWorkflowAliasBacking({ + workspaceId: workspaceId!, + userId: context.userId, + workflowId: alias.workflowId, + workflowName: alias.workflowName, + }) + fileRecord = await resolveWorkspaceFileReference(workspaceId!, alias.backingPath) + } + vfsPath = alias.aliasPath + } else { + fileRecord = await resolveWorkspaceFileReference(workspaceId!, target.path) + vfsPath = target.path + } + } else { + fileRecord = await getWorkspaceFile(workspaceId!, target.fileId) + } + if (!fileRecord) { + const ref = target.kind === 'path' ? target.path : target.fileId + return { error: `File not found: ${ref}` } + } + if (target.fileName && target.fileName !== fileRecord.name) { + return { + error: `Target mismatch: "${target.fileName}" does not match resolved file "${fileRecord.name}"`, + } + } + return { fileRecord, vfsPath } + } + if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } @@ -229,24 +367,18 @@ export const workspaceFileServerTool: BaseServerTool = { '3:4': '768x1024', } -function validateGeneratedWorkspaceFileName(fileName: string): string | null { - const trimmed = fileName.trim() - if (!trimmed) return 'File name cannot be empty' - if (trimmed.includes('/')) { - return 'Workspace files use a flat namespace. Use a plain file name like "generated-image.png", not a path like "images/generated-image.png".' - } - return null -} - interface GenerateImageArgs { prompt: string - referenceFileIds?: string[] + inputs?: { files?: Array<{ path: string }> } aspectRatio?: string - fileName?: string - overwriteFileId?: string + outputs?: { + files?: Array<{ + path: string + mode?: 'create' | 'overwrite' + mimeType?: string + }> + } } interface GenerateImageResult { @@ -51,6 +45,7 @@ interface GenerateImageResult { message: string fileId?: string fileName?: string + vfsPath?: string downloadUrl?: string _serviceCost?: { service: string; cost: number } } @@ -86,14 +81,14 @@ export const generateImageServerTool: BaseServerTool = [] - if (params.referenceFileIds?.length) { - for (const fileId of params.referenceFileIds) { + const referencePaths = params.inputs?.files?.map((file) => file.path) ?? [] + + if (referencePaths.length) { + for (const filePath of referencePaths) { try { - const fileRecord = await getWorkspaceFile(workspaceId, fileId) + const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) if (fileRecord) { - referenceRecords.push({ id: fileRecord.id, name: fileRecord.name }) const buffer = await fetchWorkspaceFileBuffer(fileRecord) const base64 = buffer.toString('base64') const mime = fileRecord.type || 'image/png' @@ -101,17 +96,17 @@ export const generateImageServerTool: BaseServerTool record.name === fileName)?.id - : undefined - const overwriteFileId = params.overwriteFileId ?? inferredOverwriteFileId - - if (inferredOverwriteFileId) { - logger.info('Inferring overwrite target from referenced file name', { - fileName, - overwriteFileId: inferredOverwriteFileId, - }) - } - - if (overwriteFileId) { - const existing = await getWorkspaceFile(workspaceId, overwriteFileId) - if (!existing) { - return { - success: false, - message: `File not found for overwrite: ${overwriteFileId}`, - } - } - assertServerToolNotAborted(context) - const updated = await updateWorkspaceFileContent( - workspaceId, - overwriteFileId, - context.userId, - imageBuffer, - mimeType - ) - logger.info('Generated image overwritten', { - fileId: updated.id, - fileName: updated.name, - size: imageBuffer.length, - mimeType, - }) - const pathPrefix = getServePathPrefix() - return { - success: true, - message: `Image ${params.referenceFileIds?.length ? 'edited' : 'generated'} and updated in "${updated.name}" (${imageBuffer.length} bytes)`, - fileId: updated.id, - fileName: updated.name, - downloadUrl: `${pathPrefix}${encodeURIComponent(updated.key)}?context=workspace`, - _serviceCost: { service: 'nano_banana_2', cost: NANO_BANANA_IMAGE_COST_USD }, - } - } + const mode = outputFile?.mode ?? 'create' assertServerToolNotAborted(context) - const uploaded = await uploadWorkspaceFile( + const written = await writeWorkspaceFileByPath({ workspaceId, - context.userId, - imageBuffer, - fileName, - mimeType - ) + userId: context.userId, + target: { + path: outputPath, + mode, + mimeType: outputFile?.mimeType, + }, + buffer: imageBuffer, + inferredMimeType: mimeType, + }) logger.info('Generated image saved', { - fileId: uploaded.id, - fileName: uploaded.name, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, size: imageBuffer.length, mimeType, }) return { success: true, - message: `Image ${params.referenceFileIds?.length ? 'edited' : 'generated'} and saved as "${uploaded.name}" (${imageBuffer.length} bytes)`, - fileId: uploaded.id, - fileName: uploaded.name, - downloadUrl: uploaded.url, + message: `Image ${referencePaths.length ? 'edited' : 'generated'} and ${written.mode === 'overwrite' ? 'updated' : 'saved'} at "${written.vfsPath}" (${imageBuffer.length} bytes)`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, _serviceCost: { service: 'nano_banana_2', cost: NANO_BANANA_IMAGE_COST_USD }, } } catch (error) { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 9aa79d7d569..cbdfd91a6e2 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -5,6 +5,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { generateInternalToken } from '@/lib/auth/internal' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -22,6 +23,7 @@ import { EMBEDDING_DIMENSIONS, generateSearchEmbedding, getConfiguredEmbeddingModel, + recordSearchEmbeddingUsage, } from '@/lib/knowledge/embeddings' import { createKnowledgeBase, @@ -222,11 +224,18 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const failedFiles: string[] = [] - for (const fileRef of fileIds) { + for (const fileRef of fileRefs) { const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileRef) if (!fileRecord) { failedFiles.push(fileRef) @@ -328,7 +348,8 @@ export const knowledgeBaseServerTool: BaseServerTool } + text?: string + position?: string + start?: number + end?: number + width?: number + height?: number + aspectRatio?: string + volume?: number + musicVolume?: number + loopToVideo?: boolean + format?: string + outputs?: { + files?: Array<{ path: string; mode?: 'create' | 'overwrite'; mimeType?: string }> + } +} + +interface FfmpegResult { + success: boolean + message: string + fileId?: string + fileName?: string + vfsPath?: string + downloadUrl?: string + probe?: unknown +} + +export const ffmpegServerTool: BaseServerTool = { + name: Ffmpeg.id, + + async execute(params: FfmpegArgs, context?: ServerToolContext): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + if (!VALID_OPERATIONS.includes(params.operation)) { + return { success: false, message: `Invalid operation "${params.operation}".` } + } + + const inputPaths = params.inputs?.files?.map((f) => f.path) ?? [] + if (inputPaths.length === 0) { + return { success: false, message: 'At least one input file is required in inputs.files' } + } + + try { + const mediaFiles: MediaFile[] = [] + for (const filePath of inputPaths) { + const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) + if (!fileRecord) { + return { success: false, message: `Input file not found: ${filePath}` } + } + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + mediaFiles.push({ + buffer, + mimeType: fileRecord.type || 'application/octet-stream', + name: fileRecord.name, + }) + } + + assertServerToolNotAborted(context) + const result = await runFfmpegOperation(params.operation, mediaFiles, { + text: params.text, + position: params.position, + start: params.start, + end: params.end, + width: params.width, + height: params.height, + aspectRatio: params.aspectRatio, + volume: params.volume, + musicVolume: params.musicVolume, + loopToVideo: params.loopToVideo, + format: params.format, + }) + + // probe reports metadata only — no file written. + if (params.operation === 'probe') { + return { + success: true, + message: `Probed ${mediaFiles[0]?.name ?? inputPaths[0]}: ${JSON.stringify(result.probe)}`, + probe: result.probe, + } + } + + if (!result.buffer || !result.ext) { + return { success: false, message: `ffmpeg ${params.operation} produced no output` } + } + + const outputFile = params.outputs?.files?.[0] + const outputPath = outputFile?.path || `files/ffmpeg-${params.operation}.${result.ext}` + const mode = outputFile?.mode ?? 'create' + + assertServerToolNotAborted(context) + const written = await writeWorkspaceFileByPath({ + workspaceId, + userId: context.userId, + target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, + buffer: result.buffer, + inferredMimeType: result.contentType || 'application/octet-stream', + }) + + logger.info('ffmpeg operation completed', { + operation: params.operation, + vfsPath: written.vfsPath, + size: result.buffer.length, + }) + + return { + success: true, + message: `${params.operation} completed and ${written.mode === 'overwrite' ? 'updated' : 'saved'} at "${written.vfsPath}" (${result.buffer.length} bytes)`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, + } + } catch (error) { + const msg = getErrorMessage(error, 'Unknown error') + logger.error('ffmpeg operation failed', { operation: params.operation, error: msg }) + return { success: false, message: `ffmpeg ${params.operation} failed: ${msg}` } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts new file mode 100644 index 00000000000..6d224686397 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -0,0 +1,152 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { GenerateAudio } from '@/lib/copilot/generated/tool-catalog-v1' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' +import { + fetchWorkspaceFileBuffer, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('GenerateAudioTool') + +const VALID_TYPES: AudioType[] = ['speech', 'music', 'sfx'] + +interface GenerateAudioArgs { + prompt: string + type?: string + model?: string + voice?: string + duration?: number + /** For music: explicit lyrics for a vocal track. */ + lyrics?: string + /** For music: true = instrumental (default), false = vocal track. */ + instrumental?: boolean + /** Optional reference voice sample (workspace audio file) for zero-shot voice cloning. */ + inputs?: { files?: Array<{ path: string }> } + outputs?: { + files?: Array<{ path: string; mode?: 'create' | 'overwrite'; mimeType?: string }> + } +} + +interface GenerateAudioResult { + success: boolean + message: string + fileId?: string + fileName?: string + vfsPath?: string + downloadUrl?: string + _serviceCost?: { service: string; cost: number } +} + +function audioExtFromContentType(contentType: string): string { + if (contentType.includes('wav')) return 'wav' + if (contentType.includes('mp4') || contentType.includes('m4a')) return 'm4a' + if (contentType.includes('ogg')) return 'ogg' + if (contentType.includes('flac')) return 'flac' + if (contentType.includes('aac')) return 'aac' + if (contentType.includes('opus')) return 'opus' + return 'mp3' +} + +export const generateAudioServerTool: BaseServerTool = { + name: GenerateAudio.id, + + async execute( + params: GenerateAudioArgs, + context?: ServerToolContext + ): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + if (!params.prompt) { + return { success: false, message: 'prompt is required' } + } + + const type = (params.type || 'speech') as AudioType + if (!VALID_TYPES.includes(type)) { + return { + success: false, + message: `Invalid type "${params.type}". Must be one of: ${VALID_TYPES.join(', ')}`, + } + } + + // Voice cloning: a reference sample clones that voice into the generated speech. + let voiceSampleDataUri: string | undefined + const samplePath = params.inputs?.files?.[0]?.path + if (samplePath) { + const sample = await resolveWorkspaceFileReference(workspaceId, samplePath) + if (!sample) { + return { success: false, message: `Voice sample not found: ${samplePath}` } + } + const sampleBuffer = await fetchWorkspaceFileBuffer(sample) + const sampleMime = sample.type || 'audio/mpeg' + voiceSampleDataUri = `data:${sampleMime};base64,${sampleBuffer.toString('base64')}` + } + + try { + logger.info('Generating audio', { + type, + model: params.model, + promptLength: params.prompt.length, + voiceClone: Boolean(voiceSampleDataUri), + }) + + const result = await generateFalAudio({ + prompt: params.prompt, + type, + model: params.model, + voice: params.voice, + duration: params.duration, + lyrics: params.lyrics, + instrumental: params.instrumental, + voiceSampleDataUri, + }) + + const outputFile = params.outputs?.files?.[0] + const ext = audioExtFromContentType(result.contentType) + const outputPath = outputFile?.path || `files/generated-audio.${ext}` + const mode = outputFile?.mode ?? 'create' + + assertServerToolNotAborted(context) + const written = await writeWorkspaceFileByPath({ + workspaceId, + userId: context.userId, + target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, + buffer: result.buffer, + inferredMimeType: result.contentType, + }) + + logger.info('Generated audio saved', { + fileId: written.id, + vfsPath: written.vfsPath, + size: result.buffer.length, + type, + model: result.model, + }) + + return { + success: true, + message: `${type === 'speech' ? 'Speech' : type === 'music' ? 'Music' : 'Sound effect'} generated and ${written.mode === 'overwrite' ? 'updated' : 'saved'} at "${written.vfsPath}" (${result.buffer.length} bytes, model ${result.model})`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, + _serviceCost: { service: 'falai_audio', cost: result.cost.costDollars }, + } + } catch (error) { + const msg = getErrorMessage(error, 'Unknown error') + logger.error('Audio generation failed', { error: msg }) + return { success: false, message: `Failed to generate audio: ${msg}` } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/media/generate-video.ts b/apps/sim/lib/copilot/tools/server/media/generate-video.ts new file mode 100644 index 00000000000..c607754f10f --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/media/generate-video.ts @@ -0,0 +1,127 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { GenerateVideo } from '@/lib/copilot/generated/tool-catalog-v1' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { generateFalVideo } from '@/lib/media/falai-video' +import { + fetchWorkspaceFileBuffer, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('GenerateVideoTool') + +interface GenerateVideoArgs { + prompt: string + model?: string + aspectRatio?: string + resolution?: string + duration?: number + generateAudio?: boolean + negativePrompt?: string + promptOptimizer?: boolean + inputs?: { files?: Array<{ path: string }> } + outputs?: { + files?: Array<{ path: string; mode?: 'create' | 'overwrite'; mimeType?: string }> + } +} + +interface GenerateVideoResult { + success: boolean + message: string + fileId?: string + fileName?: string + vfsPath?: string + downloadUrl?: string + _serviceCost?: { service: string; cost: number } +} + +export const generateVideoServerTool: BaseServerTool = { + name: GenerateVideo.id, + + async execute( + params: GenerateVideoArgs, + context?: ServerToolContext + ): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + if (!params.prompt) { + return { success: false, message: 'prompt is required' } + } + + try { + let imageDataUri: string | undefined + const refPath = params.inputs?.files?.[0]?.path + if (refPath) { + const fileRecord = await resolveWorkspaceFileReference(workspaceId, refPath) + if (!fileRecord) { + return { success: false, message: `Reference image not found: ${refPath}` } + } + const buffer = await fetchWorkspaceFileBuffer(fileRecord) + const mime = fileRecord.type || 'image/png' + imageDataUri = `data:${mime};base64,${buffer.toString('base64')}` + } + + logger.info('Generating video', { + model: params.model || 'veo-3.1-fast', + promptLength: params.prompt.length, + imageToVideo: Boolean(imageDataUri), + }) + + const result = await generateFalVideo({ + prompt: params.prompt, + model: params.model, + aspectRatio: params.aspectRatio, + resolution: params.resolution, + duration: params.duration, + generateAudio: params.generateAudio, + negativePrompt: params.negativePrompt, + promptOptimizer: params.promptOptimizer, + imageDataUri, + }) + + const outputFile = params.outputs?.files?.[0] + const outputPath = outputFile?.path || 'files/generated-video.mp4' + const mode = outputFile?.mode ?? 'create' + + assertServerToolNotAborted(context) + const written = await writeWorkspaceFileByPath({ + workspaceId, + userId: context.userId, + target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, + buffer: result.buffer, + inferredMimeType: result.contentType, + }) + + logger.info('Generated video saved', { + fileId: written.id, + vfsPath: written.vfsPath, + size: result.buffer.length, + model: result.model, + }) + + return { + success: true, + message: `Video generated and ${written.mode === 'overwrite' ? 'updated' : 'saved'} at "${written.vfsPath}" (${result.buffer.length} bytes, model ${result.model})`, + fileId: written.id, + fileName: written.name, + vfsPath: written.vfsPath, + downloadUrl: written.downloadUrl, + _serviceCost: { service: 'falai_video', cost: result.cost.costDollars }, + } + } catch (error) { + const msg = getErrorMessage(error, 'Unknown error') + logger.error('Video generation failed', { error: msg }) + return { success: false, message: `Failed to generate video: ${msg}` } + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 169a6398274..a151b25a511 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -6,8 +6,10 @@ import { DeleteFile, DeleteFileFolder, DownloadToWorkspaceFile, + Ffmpeg, + GenerateAudio, GenerateImage, - GenerateVisualization, + GenerateVideo, KnowledgeBase, ManageCredential, ManageCustomTool, @@ -17,6 +19,7 @@ import { MoveFileFolder, RenameFile, RenameFileFolder, + TouchPlan, UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' @@ -41,19 +44,22 @@ import { renameFileFolderServerTool, } from '@/lib/copilot/tools/server/files/file-folders' import { renameFileServerTool } from '@/lib/copilot/tools/server/files/rename-file' +import { touchPlanServerTool } from '@/lib/copilot/tools/server/files/touch-plan' import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file' import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' import { getJobLogsServerTool } from '@/lib/copilot/tools/server/jobs/get-job-logs' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' +import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' +import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' -import { generateVisualizationServerTool } from '@/lib/copilot/tools/server/visualization/generate-visualization' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' -import { getExecutionSummaryServerTool } from '@/lib/copilot/tools/server/workflow/get-execution-summary' -import { getWorkflowLogsServerTool } from '@/lib/copilot/tools/server/workflow/get-workflow-logs' +import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' export type ExecuteResponseSuccess = z.output @@ -106,6 +112,7 @@ const WRITE_ACTIONS: Record = { [WorkspaceFile.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], [editContentServerTool.name]: ['*'], [CreateFile.id]: ['*'], + [TouchPlan.id]: ['*'], [RenameFile.id]: ['*'], [DeleteFile.id]: ['*'], [MoveFile.id]: ['*'], @@ -114,8 +121,10 @@ const WRITE_ACTIONS: Record = { [MoveFileFolder.id]: ['*'], [DeleteFileFolder.id]: ['*'], [DownloadToWorkspaceFile.id]: ['*'], - [GenerateVisualization.id]: ['generate'], [GenerateImage.id]: ['generate'], + [GenerateVideo.id]: ['generate'], + [GenerateAudio.id]: ['generate'], + [Ffmpeg.id]: ['*'], } function isWritePermission(userPermission: string): boolean { @@ -131,12 +140,11 @@ function isWriteAction(toolName: string, action: string | undefined): boolean { } /** Registry of all server tools. Tools self-declare their validation schemas. */ -const serverToolRegistry: Record = { +const baseServerToolRegistry: Record = { [getBlocksMetadataServerTool.name]: getBlocksMetadataServerTool, [getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool, [editWorkflowServerTool.name]: editWorkflowServerTool, - [getExecutionSummaryServerTool.name]: getExecutionSummaryServerTool, - [getWorkflowLogsServerTool.name]: getWorkflowLogsServerTool, + [queryLogsServerTool.name]: queryLogsServerTool, [getJobLogsServerTool.name]: getJobLogsServerTool, [searchDocumentationServerTool.name]: searchDocumentationServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, @@ -147,6 +155,7 @@ const serverToolRegistry: Record = { [workspaceFileServerTool.name]: workspaceFileServerTool, [editContentServerTool.name]: editContentServerTool, [createFileServerTool.name]: createFileServerTool, + [touchPlanServerTool.name]: touchPlanServerTool, [renameFileServerTool.name]: renameFileServerTool, [deleteFileServerTool.name]: deleteFileServerTool, [moveFileServerTool.name]: moveFileServerTool, @@ -156,12 +165,23 @@ const serverToolRegistry: Record = { [moveFileFolderServerTool.name]: moveFileFolderServerTool, [deleteFileFolderServerTool.name]: deleteFileFolderServerTool, [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, - [generateVisualizationServerTool.name]: generateVisualizationServerTool, [generateImageServerTool.name]: generateImageServerTool, + [generateVideoServerTool.name]: generateVideoServerTool, + [generateAudioServerTool.name]: generateAudioServerTool, + [ffmpegServerTool.name]: ffmpegServerTool, +} + +function getServerToolRegistry(): Record { + if (isMothershipBetaFeaturesEnabled) { + return baseServerToolRegistry + } + const registry = { ...baseServerToolRegistry } + delete registry[touchPlanServerTool.name] + return registry } export function getRegisteredServerToolNames(): string[] { - return Object.keys(serverToolRegistry) + return Object.keys(getServerToolRegistry()) } export async function routeExecution( @@ -169,7 +189,7 @@ export async function routeExecution( payload: unknown, context?: ServerToolContext ): Promise { - const tool = serverToolRegistry[toolName] + const tool = getServerToolRegistry()[toolName] if (!tool) { throw new Error(`Unknown server tool: ${toolName}`) } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index cefdcd06fa5..07ca1ee82e1 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -99,7 +99,7 @@ async function resolveWorkspaceFile( const record = await resolveWorkspaceFileReference(workspaceId, fileReference) if (!record) { throw new Error( - `File not found: "${fileReference}". Use glob("files/by-id/*/meta.json") to list canonical file IDs.` + `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) } const buffer = await fetchWorkspaceFileBuffer(record) @@ -501,6 +501,7 @@ export const userTableServerTool: BaseServerTool rowId: args.rowId, data: rowDataNameToId(args.data, idByName), workspaceId, + actorUserId: context.userId, }, table, requestId @@ -572,6 +573,7 @@ export const userTableServerTool: BaseServerTool filter: filterNamesToIds(args.filter, idByName), data: rowDataNameToId(args.data, idByName), limit: args.limit, + actorUserId: context.userId, }, requestId ) @@ -673,6 +675,7 @@ export const userTableServerTool: BaseServerTool data: rowDataNameToId(u.data, idByName), })), workspaceId, + actorUserId: context.userId, }, table, requestId @@ -730,7 +733,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: - 'fileId is required for create_from_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.', + 'fileId or filePath is required for create_from_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").', } } if (!workspaceId) { @@ -837,7 +840,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: - 'fileId is required for import_file. Read files/{name}/meta.json or files/by-id/*/meta.json to get the canonical file ID.', + 'fileId or filePath is required for import_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").', } } if (!tableId) { @@ -1256,7 +1259,7 @@ export const userTableServerTool: BaseServerTool // can opt in by passing `autoRun: true`. const autoRun = args.autoRun === true const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun }, + { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, requestId ) return { @@ -1317,6 +1320,7 @@ export const userTableServerTool: BaseServerTool { tableId: args.tableId, groupId, + actorUserId: context.userId, workflowId: args.workflowId as string | undefined, name: args.name as string | undefined, dependencies: args.dependencies as WorkflowGroupDependencies | undefined, @@ -1378,7 +1382,14 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await addWorkflowGroupOutput( - { tableId: args.tableId, groupId, blockId, path, columnName }, + { + tableId: args.tableId, + groupId, + blockId, + path, + columnName, + actorUserId: context.userId, + }, requestId ) return { @@ -1462,6 +1473,7 @@ export const userTableServerTool: BaseServerTool mode: runMode, rowIds, requestId, + triggeredByUserId: context.userId, }) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { @@ -1618,7 +1630,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun }, + { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, requestId ) return { diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts deleted file mode 100644 index 629622a1cf3..00000000000 --- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { GenerateVisualization } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { executeInE2B, type SandboxFile } from '@/lib/execution/e2b' -import { CodeLanguage } from '@/lib/execution/languages' -import { getTableById, queryRows } from '@/lib/table/service' -import { getServePathPrefix } from '@/lib/uploads' -import { - fetchWorkspaceFileBuffer, - findWorkspaceFileRecord, - getSandboxWorkspaceFilePath, - getWorkspaceFile, - listWorkspaceFiles, - updateWorkspaceFileContent, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -const logger = createLogger('GenerateVisualizationTool') - -interface VisualizationArgs { - code: string - inputTables?: string[] - inputFiles?: string[] - fileName?: string - overwriteFileId?: string -} - -interface VisualizationResult { - success: boolean - message: string - fileId?: string - fileName?: string - downloadUrl?: string -} - -function csvEscapeValue(value: unknown): string { - if (value === null || value === undefined) return '' - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - const str = String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - -const TEXT_EXTENSIONS = new Set(['csv', 'json', 'txt', 'md', 'html', 'xml', 'tsv', 'yaml', 'yml']) -const MAX_FILE_SIZE = 10 * 1024 * 1024 -const MAX_TOTAL_SIZE = 50 * 1024 * 1024 - -function validateGeneratedWorkspaceFileName(fileName: string): string | null { - const trimmed = fileName.trim() - if (!trimmed) return 'File name cannot be empty' - if (trimmed.includes('/')) { - return 'Workspace files use a flat namespace. Use a plain file name like "chart.png", not a path like "charts/chart.png".' - } - return null -} - -async function collectSandboxFiles( - workspaceId: string, - inputFiles?: string[], - inputTables?: string[], - messageId?: string -): Promise { - const withMessageId = (message: string) => - messageId ? `${message} [messageId:${messageId}]` : message - const sandboxFiles: SandboxFile[] = [] - let totalSize = 0 - - if (inputFiles?.length) { - const allFiles = await listWorkspaceFiles(workspaceId) - for (const fileRef of inputFiles) { - const record = findWorkspaceFileRecord(allFiles, fileRef) - if (!record) { - logger.warn('Sandbox input file not found', { fileRef }) - continue - } - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - if (!TEXT_EXTENSIONS.has(ext)) { - logger.warn('Skipping non-text sandbox input file', { - fileId: record.id, - fileName: record.name, - ext, - }) - continue - } - if (record.size > MAX_FILE_SIZE) { - logger.warn('Sandbox input file exceeds size limit', { - fileId: record.id, - fileName: record.name, - size: record.size, - }) - continue - } - if (totalSize + record.size > MAX_TOTAL_SIZE) { - logger.warn('Sandbox input total size limit reached, skipping remaining files') - break - } - const buffer = await fetchWorkspaceFileBuffer(record) - totalSize += buffer.length - const textContent = buffer.toString('utf-8') - sandboxFiles.push({ - path: getSandboxWorkspaceFilePath(record), - content: textContent, - }) - sandboxFiles.push({ - path: `/home/user/${record.name}`, - content: textContent, - }) - } - } - - if (inputTables?.length) { - for (const tableId of inputTables) { - const table = await getTableById(tableId) - if (!table || table.workspaceId !== workspaceId) { - logger.warn('Sandbox input table not found', { tableId }) - continue - } - const { rows } = await queryRows(table, { limit: 10000 }, 'sandbox-input') - const schema = table.schema as { columns: Array<{ name: string; type?: string }> } - const cols = schema.columns.map((c) => c.name) - const typeComment = `# types: ${schema.columns.map((c) => `${c.name}=${c.type || 'string'}`).join(', ')}` - const csvLines = [typeComment, cols.join(',')] - for (const row of rows) { - csvLines.push( - cols.map((c) => csvEscapeValue((row.data as Record)[c])).join(',') - ) - } - const csvContent = csvLines.join('\n') - if (totalSize + csvContent.length > MAX_TOTAL_SIZE) { - logger.warn('Sandbox input total size limit reached, skipping remaining tables') - break - } - totalSize += csvContent.length - sandboxFiles.push({ path: `/home/user/tables/${tableId}.csv`, content: csvContent }) - } - } - - return sandboxFiles -} - -export const generateVisualizationServerTool: BaseServerTool< - VisualizationArgs, - VisualizationResult -> = { - name: GenerateVisualization.id, - - async execute( - params: VisualizationArgs, - context?: ServerToolContext - ): Promise { - const withMessageId = (message: string) => - context?.messageId ? `${message} [messageId:${context.messageId}]` : message - - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const { code } = params - if (!code) { - return { success: false, message: 'code is required' } - } - - try { - const sandboxFiles = await collectSandboxFiles( - workspaceId, - params.inputFiles, - params.inputTables, - context.messageId - ) - - const wrappedCode = [ - 'import matplotlib', - "matplotlib.use('Agg')", - 'import matplotlib.pyplot as plt', - '', - code, - '', - '# Auto-save if user did not explicitly call savefig', - 'import os as _os', - "if not _os.path.exists('/home/user/output.png'):", - ' if plt.get_fignums():', - " plt.savefig('/home/user/output.png', dpi=150, bbox_inches='tight')", - ' plt.close()', - ].join('\n') - - const result = await executeInE2B({ - code: wrappedCode, - language: CodeLanguage.Python, - timeoutMs: 60_000, - sandboxFiles, - }) - - if (result.error) { - return { success: false, message: `Python execution failed: ${result.error}` } - } - - let imageBase64: string | undefined - - if (result.images?.length) { - imageBase64 = result.images[0] - } - - if (!imageBase64) { - return { - success: false, - message: `Code ran but produced no image. Make sure your code creates a matplotlib figure and calls plt.savefig('/home/user/output.png'). Stdout: ${result.stdout?.slice(0, 500) || '(empty)'}`, - } - } - - const fileName = params.fileName || 'chart.png' - const fileNameValidationError = validateGeneratedWorkspaceFileName(fileName) - if (fileNameValidationError) { - return { success: false, message: fileNameValidationError } - } - const imageBuffer = Buffer.from(imageBase64, 'base64') - - if (params.overwriteFileId) { - const existing = await getWorkspaceFile(workspaceId, params.overwriteFileId) - if (!existing) { - return { - success: false, - message: `File not found for overwrite: ${params.overwriteFileId}`, - } - } - assertServerToolNotAborted(context) - const updated = await updateWorkspaceFileContent( - workspaceId, - params.overwriteFileId, - context.userId, - imageBuffer, - 'image/png' - ) - logger.info('Chart image overwritten', { - fileId: updated.id, - fileName: updated.name, - size: imageBuffer.length, - }) - const pathPrefix = getServePathPrefix() - return { - success: true, - message: `Chart updated in "${updated.name}" (${imageBuffer.length} bytes)`, - fileId: updated.id, - fileName: updated.name, - downloadUrl: `${pathPrefix}${encodeURIComponent(updated.key)}?context=workspace`, - } - } - - assertServerToolNotAborted(context) - const uploaded = await uploadWorkspaceFile( - workspaceId, - context.userId, - imageBuffer, - fileName, - 'image/png' - ) - - logger.info('Chart image saved', { - fileId: uploaded.id, - fileName: uploaded.name, - size: imageBuffer.length, - }) - - return { - success: true, - message: `Chart saved as "${uploaded.name}" (${imageBuffer.length} bytes)`, - fileId: uploaded.id, - fileName: uploaded.name, - downloadUrl: uploaded.url, - } - } catch (error) { - const msg = getErrorMessage(error, 'Unknown error') - logger.error('Visualization generation failed', { error: msg }) - return { success: false, message: `Failed to generate visualization: ${msg}` } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts index ef7d326a580..b042789c194 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts @@ -427,7 +427,28 @@ export function createValidatedEdge( skippedItems?: SkippedItem[] ): boolean { if (!modifiedState.blocks[targetBlockId]) { - logger.warn(`Target block "${targetBlockId}" not found. Edge skipped.`, { + // The target doesn't exist yet. It may be created by a later operation in + // this batch or by a future edit_workflow call. Record the connection as + // pending on the source block (persisted in block.data) so it is resolved + // automatically once the target appears, instead of being silently dropped. + const pendingSource = modifiedState.blocks[sourceBlockId] + if (pendingSource) { + if (!pendingSource.data) pendingSource.data = {} + if (!pendingSource.data.pendingConnections) pendingSource.data.pendingConnections = {} + const pending = pendingSource.data.pendingConnections as Record< + string, + Array<{ target: string; targetHandle: string }> + > + if (!pending[sourceHandle]) pending[sourceHandle] = [] + if ( + !pending[sourceHandle].some( + (p) => p.target === targetBlockId && p.targetHandle === targetHandle + ) + ) { + pending[sourceHandle].push({ target: targetBlockId, targetHandle }) + } + } + logger.warn(`Target block "${targetBlockId}" not found. Connection deferred until it exists.`, { sourceBlockId, targetBlockId, sourceHandle, @@ -436,7 +457,7 @@ export function createValidatedEdge( type: 'invalid_edge_target', operationType, blockId: sourceBlockId, - reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" skipped - target block does not exist`, + reason: `Edge from "${sourceBlockId}" to "${targetBlockId}" deferred until the target block "${targetBlockId}" exists - if it is created later (in this or a following edit) the engine wires this edge automatically; if you did not intend to create "${targetBlockId}", fix the target id.`, details: { sourceHandle, targetHandle, targetId: targetBlockId }, }) return false @@ -513,6 +534,17 @@ export function createValidatedEdge( // Use normalized handle if available (e.g., 'if' -> 'condition-{uuid}') const finalSourceHandle = sourceValidation.normalizedHandle || sourceHandle + // Avoid creating duplicate edges (e.g., when a pending connection resolves to + // the same edge a later operation already created). + const edgeExists = (modifiedState.edges || []).some( + (e: any) => + e.source === sourceBlockId && + e.sourceHandle === finalSourceHandle && + e.target === targetBlockId && + e.targetHandle === targetHandle + ) + if (edgeExists) return true + modifiedState.edges.push({ id: generateId(), source: sourceBlockId, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts index 8d4cfe002da..11405480bf4 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts @@ -3,7 +3,11 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateEdges } from '@/stores/workflows/workflow/edge-validation' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { addConnectionsAsEdges, normalizeBlockIdsInOperations } from './builders' +import { + addConnectionsAsEdges, + createValidatedEdge, + normalizeBlockIdsInOperations, +} from './builders' import { handleAddOperation, handleDeleteOperation, @@ -239,6 +243,13 @@ export function applyOperationsToWorkflowState( totalEdges: (modifiedState as any).edges?.length, }) } + + // Pass 3: resolve pending connections whose target block now exists. These are + // forward-reference connections that were recorded (on block.data) when their + // target didn't exist yet — possibly in an earlier edit_workflow call. Now + // that this batch's blocks are all created, create the edges that resolve. + resolvePendingConnections(modifiedState, skippedItems) + // Remove edges that cross scope boundaries. This runs after all operations // and deferred connections are applied so that every block has its final // parentId. Running it per-operation would incorrectly drop edges between @@ -280,6 +291,54 @@ export function applyOperationsToWorkflowState( return { state: modifiedState, validationErrors, skippedItems } } +/** + * Resolves pending forward-reference connections recorded on block.data. + * + * When a connection references a target block that does not exist yet, the edge + * is recorded as pending on the source block instead of being dropped. This runs + * after all blocks for the current batch exist (and picks up pending entries + * persisted from earlier edit_workflow calls), creating any edges whose target + * now exists and leaving still-unresolved entries pending. + */ +function resolvePendingConnections(modifiedState: any, skippedItems: SkippedItem[]): void { + const blocks = modifiedState.blocks || {} + for (const [sourceId, block] of Object.entries(blocks) as [string, any][]) { + const pending = block?.data?.pendingConnections as + | Record> + | undefined + if (!pending) continue + + for (const [handle, targets] of Object.entries(pending)) { + const stillPending: Array<{ target: string; targetHandle: string }> = [] + for (const { target, targetHandle } of targets) { + if (blocks[target]) { + createValidatedEdge( + modifiedState, + sourceId, + target, + handle, + targetHandle || 'target', + 'resolve_pending_connection', + logger, + skippedItems + ) + } else { + stillPending.push({ target, targetHandle }) + } + } + if (stillPending.length > 0) { + pending[handle] = stillPending + } else { + delete pending[handle] + } + } + + if (Object.keys(pending).length === 0) { + block.data.pendingConnections = undefined + } + } +} + /** * Removes edges that cross scope boundaries after all operations are applied. * An edge is invalid if: diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index 38e6e6558ea..44bb0ee4a8c 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -31,9 +31,20 @@ import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-ch import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' import { applyOperationsToWorkflowState } from './engine' -import { formatWorkflowLintMessage, hasWorkflowLintIssues, lintEditedWorkflowState } from './lint' -import type { EditWorkflowParams, ValidationError } from './types' -import { preValidateCredentialInputs, validateWorkflowSelectorIds } from './validation' +import { + collectWorkflowFieldIssues, + formatWorkflowLintMessage, + hasWorkflowLintIssues, + lintEditedWorkflowState, + type WorkflowLintReport, + type WorkflowLintUnresolvedReference, +} from './lint' +import { type EditWorkflowParams, isDeferredSkippedItem, type ValidationError } from './types' +import { + collectUnresolvedReferences, + preValidateCredentialInputs, + UNRESOLVABLE_AT_LINT_NOTE, +} from './validation' async function getCurrentWorkflowStateFromDb( workflowId: string @@ -149,14 +160,26 @@ export const editWorkflowServerTool: BaseServerTool // Add credential validation errors validationErrors.push(...credentialErrors) - // Validate selector IDs exist in the database + // Resolve credential/resource references against the workspace (Tier 2). + // Includes oauth-input credentials and only the active canonical member, so a + // credential "set in basic mode but unresolved in the dropdown" is caught. + let unresolvedReferences: WorkflowLintUnresolvedReference[] = [] if (context?.userId) { try { - const selectorErrors = await validateWorkflowSelectorIds(modifiedWorkflowState, { + unresolvedReferences = await collectUnresolvedReferences(modifiedWorkflowState, { userId: context.userId, workspaceId, }) - validationErrors.push(...selectorErrors) + // Back-compat: also surface unresolved references through the input-validation channel. + validationErrors.push( + ...unresolvedReferences.map((ref) => ({ + blockId: ref.blockId, + blockType: ref.blockType ?? 'unknown', + field: ref.field, + value: ref.value, + error: ref.reason, + })) + ) } catch (error) { logger.warn('Selector ID validation failed', { error: toError(error).message, @@ -226,9 +249,28 @@ export const editWorkflowServerTool: BaseServerTool ? validationErrors.map((e) => `Block "${e.blockId}" (${e.blockType}): ${e.error}`) : undefined - // Format skipped items for LLM feedback - const skippedMessages = - skippedItems.length > 0 ? skippedItems.map((item) => item.reason) : undefined + // Split engine skipped items into genuine failures vs benign, self-healing + // deferrals. A deferred forward-reference edge (invalid_edge_target) is NOT + // a failure: the engine wires it automatically once its target block exists + // (this call or a later one) via pendingConnections. Surfacing it through + // the same "skipped" failure channel as real skips makes a literal model + // thrash (re-issuing a self-healing op). Keep them in separate result fields + // and preserve item.type/details so the prompt can branch on a + // machine-readable category instead of pattern-matching prose. + const mapSkippedItem = (item: (typeof skippedItems)[number]) => ({ + type: item.type, + operationType: item.operationType, + blockId: item.blockId, + reason: item.reason, + ...(item.details && { details: item.details }), + }) + + const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item)) + const deferredItems = skippedItems.filter((item) => isDeferredSkippedItem(item)) + + const skippedDetails = + genuineSkippedItems.length > 0 ? genuineSkippedItems.map(mapSkippedItem) : undefined + const deferredDetails = deferredItems.length > 0 ? deferredItems.map(mapSkippedItem) : undefined // Persist the workflow state to the database const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState @@ -267,7 +309,16 @@ export const editWorkflowServerTool: BaseServerTool isDeployed: false, } - const workflowLint = lintEditedWorkflowState(workflowStateForDb as any) + // Aggregate lint report: graph (sources/sinks/orphans/ports) + Tier-1 config + // (required + canonical-mode) + Tier-2 resolution (credential/resource IDs). + const graphLint = lintEditedWorkflowState(workflowStateForDb as any) + const fieldIssues = collectWorkflowFieldIssues(workflowStateForDb.blocks as any) + const workflowLint: WorkflowLintReport = { + ...graphLint, + fieldIssues, + unresolvedReferences, + notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [], + } const workflowLintMessage = hasWorkflowLintIssues(workflowLint) ? formatWorkflowLintMessage(workflowLint) : undefined @@ -312,17 +363,19 @@ export const editWorkflowServerTool: BaseServerTool workflowId, workflowName: workflowName ?? 'Workflow', workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, - ...(workflowLintMessage && { - workflowLint, - workflowLintMessage, - }), + workflowLint, + ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { inputValidationErrors: inputErrors, inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`, }), - ...(skippedMessages && { - skippedItems: skippedMessages, - skippedItemsMessage: `${skippedItems.length} operation(s) were skipped due to invalid references. Details: ${skippedMessages.join('; ')}`, + ...(skippedDetails && { + skippedItems: skippedDetails, + skippedItemsMessage: `${skippedDetails.length} operation(s) were skipped (not applied) and need attention. Each item includes a machine-readable "type" (e.g. block_not_found, block_locked, duplicate_block_name, invalid_block_type, invalid_source_handle, invalid_target_handle, invalid_edge_scope). Details: ${skippedDetails.map((item) => item.reason).join('; ')}`, + }), + ...(deferredDetails && { + deferredConnections: deferredDetails, + deferredMessage: `${deferredDetails.length} edge(s) were deferred because their target block does not exist yet. This is NOT a failure and does NOT need fixing: the engine wires these edges automatically once the target block exists (in this edit or a later one). Do not re-issue them. Only act on a deferred edge if its target id was a typo or hallucination that you do not intend to create. Details: ${deferredDetails.map((item) => item.reason).join('; ')}`, }), ...(sanitizationWarnings && { sanitizationWarnings, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts index 2623ff40c1a..13b7345a0a9 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts @@ -14,7 +14,7 @@ function baseBlock(id: string, type: string, name: string, subBlocks: Record { - it('reports orphan blocks and empty condition/router ports', () => { + it('reports orphan blocks but allows unconnected condition/router branches', () => { const workflowState = { blocks: { start: baseBlock('start', 'starter', 'Start'), @@ -68,11 +68,7 @@ describe('lintEditedWorkflowState', () => { expect(lint.orphanBlocks).toEqual([ { blockId: 'function', blockName: 'Orphan Function', blockType: 'function' }, ]) - expect(lint.emptyOutgoingPorts.map((port) => `${port.blockName}.${port.label}`)).toEqual([ - 'Condition.else', - 'Router.route-0', - 'Router.route-1', - ]) + expect(lint.emptyOutgoingPorts).toEqual([]) expect(lint.invalidBranchPorts).toEqual([]) expect(hasWorkflowLintIssues(lint)).toBe(true) }) @@ -163,6 +159,8 @@ describe('lintEditedWorkflowState', () => { const lint = lintEditedWorkflowState(workflowState as any) expect(lint).toEqual({ + sources: [{ blockId: 'start', blockName: 'Start', blockType: 'starter' }], + sinks: [{ blockId: 'agent', blockName: 'Agent', blockType: 'agent' }], orphanBlocks: [], emptyOutgoingPorts: [], invalidBranchPorts: [], @@ -170,4 +168,153 @@ describe('lintEditedWorkflowState', () => { }) expect(hasWorkflowLintIssues(lint)).toBe(false) }) + + it('objectively reports multiple sources without turning disconnected islands into an issue', () => { + const workflowState = { + blocks: { + start: baseBlock('start', 'starter', 'Start'), + fetch: baseBlock('fetch', 'function', 'FetchCurrentFiles'), + normalize: baseBlock('normalize', 'function', 'NormalizeFiles'), + slack: { ...baseBlock('slack', 'slack', 'SlackTrigger'), triggerMode: true }, + filter: baseBlock('filter', 'condition', 'MessageFilter'), + }, + edges: [ + { + id: 'e1', + source: 'start', + sourceHandle: 'source', + target: 'fetch', + targetHandle: 'target', + }, + { + id: 'e2', + source: 'fetch', + sourceHandle: 'source', + target: 'normalize', + targetHandle: 'target', + }, + { + id: 'e3', + source: 'slack', + sourceHandle: 'source', + target: 'filter', + targetHandle: 'target', + }, + ], + } + + const lint = lintEditedWorkflowState(workflowState as any) + + expect(lint.sources.map((b) => b.blockId).sort()).toEqual(['slack', 'start']) + expect(lint.orphanBlocks).toEqual([]) + expect(lint.emptyOutgoingPorts).toEqual([]) + expect(hasWorkflowLintIssues(lint)).toBe(false) + }) + + it('reports sources and sinks (triggers are sources, terminals are sinks, notes excluded)', () => { + const workflowState = { + blocks: { + start: baseBlock('start', 'starter', 'Start'), + agent: baseBlock('agent', 'agent', 'Agent'), + end: baseBlock('end', 'function', 'End'), + note: baseBlock('note', 'note', 'Note'), + }, + edges: [ + { + id: 'e1', + source: 'start', + sourceHandle: 'source', + target: 'agent', + targetHandle: 'target', + }, + { + id: 'e2', + source: 'agent', + sourceHandle: 'source', + target: 'end', + targetHandle: 'target', + }, + ], + } + + const lint = lintEditedWorkflowState(workflowState as any) + + // 'start' has no incoming edge -> a source, even though it is NOT an orphan (trigger). + expect(lint.sources).toEqual([{ blockId: 'start', blockName: 'Start', blockType: 'starter' }]) + expect(lint.orphanBlocks).toEqual([]) + // 'end' has no outgoing edge -> a sink. + expect(lint.sinks).toEqual([{ blockId: 'end', blockName: 'End', blockType: 'function' }]) + // 'agent' has both in and out edges -> neither source nor sink. + expect(lint.sources.map((b) => b.blockId)).not.toContain('agent') + expect(lint.sinks.map((b) => b.blockId)).not.toContain('agent') + // 'note' is excluded from both even though it has no edges. + expect(lint.sources.map((b) => b.blockId)).not.toContain('note') + expect(lint.sinks.map((b) => b.blockId)).not.toContain('note') + }) + + it('warns when loop/parallel start ports are empty', () => { + const workflowState = { + blocks: { + start: baseBlock('start', 'starter', 'Start'), + loop: baseBlock('loop', 'loop', 'Loop'), + parallel: baseBlock('parallel', 'parallel', 'Parallel'), + }, + edges: [ + { + id: 'e1', + source: 'start', + sourceHandle: 'source', + target: 'loop', + targetHandle: 'target', + }, + { + id: 'e2', + source: 'loop', + sourceHandle: 'loop-end-source', + target: 'parallel', + targetHandle: 'target', + }, + ], + } + + const lint = lintEditedWorkflowState(workflowState as any) + + expect(lint.emptyOutgoingPorts.map((port) => `${port.blockName}.${port.handle}`)).toEqual([ + 'Loop.loop-start-source', + 'Parallel.parallel-start-source', + ]) + expect(hasWorkflowLintIssues(lint)).toBe(true) + }) + + it('treats loop/parallel start edges as internal so containers can still be sinks', () => { + const workflowState = { + blocks: { + start: baseBlock('start', 'starter', 'Start'), + loop: baseBlock('loop', 'loop', 'Loop'), + child: baseBlock('child', 'function', 'Loop Child'), + }, + edges: [ + { + id: 'e1', + source: 'start', + sourceHandle: 'source', + target: 'loop', + targetHandle: 'target', + }, + { + id: 'e2', + source: 'loop', + sourceHandle: 'loop-start-source', + target: 'child', + targetHandle: 'target', + }, + ], + } + + const lint = lintEditedWorkflowState(workflowState as any) + + expect(lint.emptyOutgoingPorts).toEqual([]) + expect(lint.sinks.map((block) => block.blockId).sort()).toEqual(['child', 'loop']) + expect(hasWorkflowLintIssues(lint)).toBe(false) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts index 24e2a561088..8b9789fa276 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts @@ -1,4 +1,10 @@ +import { getBlock } from '@/blocks' import { isTriggerBlockType } from '@/executor/constants' +import { + collectBlockFieldIssues, + extractBlockParams, + type InactiveModeValue, +} from '@/serializer/index' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { validateConditionHandle, validateRouterHandle } from './validation' @@ -6,6 +12,7 @@ type BlockState = { id?: string type?: string name?: string + triggerMode?: boolean subBlocks?: Record } @@ -40,12 +47,42 @@ export interface WorkflowLintInvalidConnectionTarget { } export interface WorkflowLintResult { + /** Every non-note block with no incoming edge (trigger blocks are naturally sources). */ + sources: WorkflowLintBlockRef[] + /** Every non-note block with no outgoing edge. */ + sinks: WorkflowLintBlockRef[] orphanBlocks: WorkflowLintBlockRef[] emptyOutgoingPorts: WorkflowLintEmptyOutgoingPort[] invalidBranchPorts: WorkflowLintInvalidBranchPort[] invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[] } +/** Tier-1 (sync, config) field issues for a single block. */ +export interface WorkflowLintFieldIssue extends WorkflowLintBlockRef { + /** Required fields that resolve empty in the active mode. */ + missingRequiredFields: string[] + /** Canonical pairs whose value is stranded on the inactive member (silently dropped). */ + inactiveModeValues: InactiveModeValue[] +} + +/** Tier-2 (async, DB) credential/resource reference that does not resolve to an accessible entity. */ +export interface WorkflowLintUnresolvedReference extends WorkflowLintBlockRef { + field: string + value: string | string[] + kind: 'credential' | 'resource' + reason: string +} + +/** + * Aggregate lint report: the graph lint plus the config (Tier 1) and resolution + * (Tier 2) checks. Returned in the edit_workflow result and written to lint.json. + */ +export interface WorkflowLintReport extends WorkflowLintResult { + fieldIssues: WorkflowLintFieldIssue[] + unresolvedReferences: WorkflowLintUnresolvedReference[] + notes: string[] +} + function blockRef(blockId: string, block: BlockState): WorkflowLintBlockRef { return { blockId, @@ -54,61 +91,28 @@ function blockRef(blockId: string, block: BlockState): WorkflowLintBlockRef { } } -function parseArrayValue(value: unknown): any[] { - if (Array.isArray(value)) return value - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) - return Array.isArray(parsed) ? parsed : [] - } catch { - return [] - } - } - return [] +function isWorkflowEntryBlock(block: BlockState) { + return Boolean(block.triggerMode) || isTriggerBlockType(block.type) } -function conditionPortLabel(title: string, elseIfIndex: number): string { - if (title === 'if') return 'if' - if (title === 'else') return 'else' - if (title === 'else if') return `else-if-${elseIfIndex}` - return title || `branch-${elseIfIndex}` -} - -function conditionPorts(block: BlockState) { - const conditions = parseArrayValue(block.subBlocks?.conditions?.value) - let elseIfIndex = 0 - - return conditions - .map((condition, index) => { - const title = String(condition?.title ?? '').toLowerCase() - const label = conditionPortLabel(title, elseIfIndex) - if (title === 'else if') elseIfIndex++ - - if (!condition?.id) return null - return { - handle: `condition-${condition.id}`, - label: label || `branch-${index}`, - value: block.subBlocks?.conditions?.value, - } - }) - .filter((port): port is { handle: string; label: string; value: unknown } => Boolean(port)) -} - -function routerPorts(block: BlockState) { - return parseArrayValue(block.subBlocks?.routes?.value) - .map((route, index) => { - if (!route?.id) return null - return { - handle: `router-${route.id}`, - label: `route-${index}`, - value: block.subBlocks?.routes?.value, - } - }) - .filter((port): port is { handle: string; label: string; value: unknown } => Boolean(port)) +function requiredSubflowStartPort(block: BlockState) { + if (block.type === 'loop') { + return { handle: 'loop-start-source', label: 'loop-start-source' } + } + if (block.type === 'parallel') { + return { handle: 'parallel-start-source', label: 'parallel-start-source' } + } + return null } -function shouldLintDynamicPorts(block: BlockState) { - return block.type === 'condition' || block.type === 'router_v2' +function countsAsExternalOutgoing(block: BlockState, sourceHandle?: string | null) { + if (block.type === 'loop') { + return sourceHandle !== 'loop-start-source' + } + if (block.type === 'parallel') { + return sourceHandle !== 'parallel-start-source' + } + return true } export function lintEditedWorkflowState(workflowState: Pick) { @@ -118,6 +122,7 @@ export function lintEditedWorkflowState(workflowState: Pick() + const outgoingEdgesBySource = new Set() const connectedDynamicHandles = new Map>() const invalidBranchPorts: WorkflowLintInvalidBranchPort[] = [] const invalidConnectionTargets: WorkflowLintInvalidConnectionTarget[] = [] @@ -142,53 +147,69 @@ export function lintEditedWorkflowState(workflowState: Pick() + handles.add(normalizedHandle) + connectedDynamicHandles.set(sourceBlockId, handles) continue } - const normalizedHandle = validation.normalizedHandle || sourceHandle const handles = connectedDynamicHandles.get(sourceBlockId) || new Set() - handles.add(normalizedHandle) + handles.add(sourceHandle) connectedDynamicHandles.set(sourceBlockId, handles) } const orphanBlocks = Object.entries(blocks) - .filter(([, block]) => block.type !== 'note' && !isTriggerBlockType(block.type)) + .filter(([, block]) => block.type !== 'note' && !isWorkflowEntryBlock(block)) .filter(([blockId]) => !incomingEdgesByTarget.has(blockId)) .map(([blockId, block]) => blockRef(blockId, block)) + // Structural descriptors (advisory, not "issues"): sources have no incoming + // edge (trigger blocks are naturally sources), sinks have no outgoing edge. + const sources = Object.entries(blocks) + .filter(([, block]) => block.type !== 'note') + .filter(([blockId]) => !incomingEdgesByTarget.has(blockId)) + .map(([blockId, block]) => blockRef(blockId, block)) + + const sinks = Object.entries(blocks) + .filter(([, block]) => block.type !== 'note') + .filter(([blockId]) => !outgoingEdgesBySource.has(blockId)) + .map(([blockId, block]) => blockRef(blockId, block)) + const emptyOutgoingPorts = Object.entries(blocks).flatMap(([blockId, block]) => { const handles = connectedDynamicHandles.get(blockId) || new Set() - const ports = - block.type === 'condition' - ? conditionPorts(block) - : block.type === 'router_v2' - ? routerPorts(block) - : [] + const requiredPort = requiredSubflowStartPort(block) + const ports = requiredPort ? [requiredPort] : [] return ports .filter((port) => !handles.has(port.handle)) @@ -200,6 +221,8 @@ export function lintEditedWorkflowState(workflowState: Pick | undefined +): WorkflowLintFieldIssue[] { + const results: WorkflowLintFieldIssue[] = [] + for (const [blockId, block] of Object.entries(blocks || {})) { + const type = (block as { type?: string })?.type + if (!type || type === 'note' || type === 'loop' || type === 'parallel') continue + const blockConfig = getBlock(type) + if (!blockConfig) continue + + let params: Record + try { + params = extractBlockParams(block as any) + } catch { + continue + } + + const { missingRequiredFields, inactiveModeValues } = collectBlockFieldIssues( + block as any, + blockConfig, + params + ) + if (missingRequiredFields.length > 0 || inactiveModeValues.length > 0) { + results.push({ + ...blockRef(blockId, block as BlockState), + missingRequiredFields, + inactiveModeValues, + }) + } + } + return results +} + +type WorkflowLintIssueView = WorkflowLintResult & { + fieldIssues?: WorkflowLintFieldIssue[] + unresolvedReferences?: WorkflowLintUnresolvedReference[] +} + +export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) { return ( lint.orphanBlocks.length > 0 || lint.emptyOutgoingPorts.length > 0 || lint.invalidBranchPorts.length > 0 || - lint.invalidConnectionTargets.length > 0 + lint.invalidConnectionTargets.length > 0 || + (lint.fieldIssues?.length ?? 0) > 0 || + (lint.unresolvedReferences?.length ?? 0) > 0 ) } -export function formatWorkflowLintMessage(lint: WorkflowLintResult) { +export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { const parts: string[] = [] if (lint.orphanBlocks.length > 0) { @@ -229,7 +298,7 @@ export function formatWorkflowLintMessage(lint: WorkflowLintResult) { if (lint.emptyOutgoingPorts.length > 0) { parts.push( - `Unconnected condition/router ports: ${lint.emptyOutgoingPorts + `Unconnected required subflow start ports: ${lint.emptyOutgoingPorts .map((port) => `"${port.blockName || port.blockId}".${port.label}`) .join(', ')}` ) @@ -251,5 +320,44 @@ export function formatWorkflowLintMessage(lint: WorkflowLintResult) { ) } - return `Workflow graph lint found issues. Fix these before continuing: ${parts.join('; ')}` + const fieldIssues = lint.fieldIssues ?? [] + const missing = fieldIssues.filter((issue) => issue.missingRequiredFields.length > 0) + if (missing.length > 0) { + parts.push( + `Blocks missing required fields: ${missing + .map( + (issue) => + `"${issue.blockName || issue.blockId}" (${issue.missingRequiredFields.join(', ')})` + ) + .join(', ')}` + ) + } + + const inactive = fieldIssues.filter((issue) => issue.inactiveModeValues.length > 0) + if (inactive.length > 0) { + parts.push( + `Values set on the inactive field mode (they will not resolve): ${inactive + .map( + (issue) => + `"${issue.blockName || issue.blockId}" (${issue.inactiveModeValues + .map( + (v) => + `${v.inactiveMemberId}: move the value to "${v.activeMemberId ?? v.canonicalId}"` + ) + .join('; ')})` + ) + .join(', ')}` + ) + } + + const unresolved = lint.unresolvedReferences ?? [] + if (unresolved.length > 0) { + parts.push( + `Credential/resource references that do not resolve: ${unresolved + .map((ref) => `"${ref.blockName || ref.blockId}".${ref.field} (${ref.reason})`) + .join(', ')}` + ) + } + + return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}` } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts index a29271f08c8..b2e27179d0b 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts @@ -438,3 +438,93 @@ describe('handleEditOperation nestedNodes merge', () => { expect(replacementBlock.data?.parentId).toBe('outer-loop') }) }) + +describe('forward-reference connections (pending resolution)', () => { + function makeMinimalWorkflow() { + return { + blocks: { + 'start-1': { + id: 'start-1', + type: 'function', + name: 'Start', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + data: {}, + }, + }, + edges: [] as any[], + loops: {}, + parallels: {}, + } + } + + // Valid UUIDs so block_ids are not normalized/remapped on add. + const BLOCK_A = '11111111-1111-4111-8111-111111111111' + const BLOCK_B = '22222222-2222-4222-8222-222222222222' + + it('defers a connection to a not-yet-created block and resolves it on a later apply', () => { + const workflow = makeMinimalWorkflow() + + // First apply: add block A connecting to block B, which does not exist yet. + const first = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'add', + block_id: BLOCK_A, + params: { + type: 'function', + name: 'Block A', + inputs: { code: 'return 1' }, + connections: { source: BLOCK_B }, + }, + }, + ]) + + // No edge created yet; the connection is recorded as pending on block A. + expect(first.state.edges.some((e: any) => e.target === BLOCK_B)).toBe(false) + expect(first.state.blocks[BLOCK_A].data.pendingConnections.source).toEqual([ + { target: BLOCK_B, targetHandle: 'target' }, + ]) + + // Second apply (simulating a later edit_workflow call): add block B. + const second = applyOperationsToWorkflowState(first.state, [ + { + operation_type: 'add', + block_id: BLOCK_B, + params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } }, + }, + ]) + + // The pending edge is now created and the pending record cleared. + const edge = second.state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B) + expect(edge).toBeDefined() + expect(second.state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined() + }) + + it('resolves a forward-reference connection within a single apply regardless of operation order', () => { + const workflow = makeMinimalWorkflow() + + const { state } = applyOperationsToWorkflowState(workflow, [ + { + operation_type: 'add', + block_id: BLOCK_A, + params: { + type: 'function', + name: 'Block A', + inputs: { code: 'return 1' }, + connections: { source: BLOCK_B }, + }, + }, + { + operation_type: 'add', + block_id: BLOCK_B, + params: { type: 'function', name: 'Block B', inputs: { code: 'return 2' } }, + }, + ]) + + const edge = state.edges.find((e: any) => e.source === BLOCK_A && e.target === BLOCK_B) + expect(edge).toBeDefined() + expect(state.blocks[BLOCK_A].data?.pendingConnections).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts index aaabf57cb64..8e7e33df487 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts @@ -659,6 +659,12 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon if (params?.connections) { modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id) + // Re-specifying connections fully replaces this block's outgoing edges, so + // drop any previously-recorded pending (forward-reference) connections too. + if (block.data?.pendingConnections) { + block.data.pendingConnections = undefined + } + deferredConnections.push({ blockId: block_id, connections: params.connections, @@ -866,10 +872,12 @@ export function handleInsertIntoSubflowOperation( } if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') { - logger.error('Subflow block has invalid type', { - subflowId, - type: subflowBlock.type, - block_id, + logSkippedItem(skippedItems, { + type: 'invalid_subflow_parent', + operationType: 'insert_into_subflow', + blockId: block_id, + reason: `Cannot insert block "${block_id}" into "${subflowId}" - the target is a "${subflowBlock.type}" block, not a loop or parallel container`, + details: { subflowId, subflowType: subflowBlock.type }, }) return } @@ -1014,6 +1022,13 @@ export function handleInsertIntoSubflowOperation( // Remove existing edges from this block first modifiedState.edges = modifiedState.edges.filter((edge: any) => edge.source !== block_id) + // Re-specifying connections fully replaces this block's outgoing edges, so + // drop any previously-recorded pending (forward-reference) connections too. + const connBlock = modifiedState.blocks[block_id] + if (connBlock?.data?.pendingConnections) { + connBlock.data.pendingConnections = undefined + } + // Add to deferred connections list deferredConnections.push({ blockId: block_id, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts index db9b773ec16..c12e16c4add 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts @@ -62,6 +62,25 @@ export interface SkippedItem { details?: Record } +/** + * Skipped-item types that represent benign, SELF-HEALING deferrals rather than + * failures. A deferred forward-reference edge (`invalid_edge_target`) is + * recorded as a pending connection and wired automatically once its target + * block exists -- possibly on a later edit_workflow call. It must be surfaced to + * the model as informational, NOT through the "skipped/failed operation" + * channel; otherwise a literal model re-issues the self-healing operation in a + * loop. See `createValidatedEdge` (builders.ts) and `resolvePendingConnections` + * (engine.ts). + */ +export const DEFERRED_SKIPPED_ITEM_TYPES: ReadonlySet = new Set([ + 'invalid_edge_target', +]) + +/** Whether a skipped item is a benign deferral (see DEFERRED_SKIPPED_ITEM_TYPES). */ +export function isDeferredSkippedItem(item: SkippedItem): boolean { + return DEFERRED_SKIPPED_ITEM_TYPES.has(item.type) +} + /** * Logs and records a skipped item */ diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index c447f190954..5493727f72d 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -48,6 +48,23 @@ const huggingfaceBlockConfig = { subBlocks: [{ id: 'model', type: 'short-input' }], } +const knowledgeBlockConfig = { + type: 'knowledge', + name: 'Knowledge', + outputs: {}, + subBlocks: [{ id: 'knowledgeBaseId', type: 'knowledge-base-selector' }], +} + +const canonicalCredBlockConfig = { + type: 'canonicalcred', + name: 'CanonicalCred', + outputs: {}, + subBlocks: [ + { id: 'credential', type: 'oauth-input', canonicalParamId: 'cred', mode: 'basic' }, + { id: 'manualCredential', type: 'short-input', canonicalParamId: 'cred', mode: 'advanced' }, + ], +} + vi.mock('@/blocks/registry', () => ({ getBlock: (type: string) => type === 'condition' @@ -60,7 +77,11 @@ vi.mock('@/blocks/registry', () => ({ ? agentBlockConfig : type === 'huggingface' ? huggingfaceBlockConfig - : undefined, + : type === 'knowledge' + ? knowledgeBlockConfig + : type === 'canonicalcred' + ? canonicalCredBlockConfig + : undefined, })) vi.mock('@/blocks/utils', () => ({ @@ -77,7 +98,12 @@ vi.mock('@/providers/utils', () => ({ getHostedModels: () => [], })) -import { preValidateCredentialInputs, validateInputsForBlock } from './validation' +import { + collectUnresolvedReferences, + preValidateCredentialInputs, + validateInputsForBlock, + validateWorkflowSelectorIds, +} from './validation' describe('validateInputsForBlock', () => { beforeEach(() => { @@ -277,3 +303,115 @@ describe('preValidateCredentialInputs', () => { expect(result.errors).toHaveLength(0) }) }) + +const CTX = { userId: 'user-1', workspaceId: 'workspace-1' } + +describe('validateWorkflowSelectorIds (credential inclusion)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + }) + + it('skips oauth-input by default (credentials pre-validated)', async () => { + const state = { + blocks: { b1: { type: 'slack', name: 'Slack', subBlocks: { credential: { value: 'bad' } } } }, + } + const errors = await validateWorkflowSelectorIds(state, CTX) + expect(errors).toHaveLength(0) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + }) + + it('validates oauth-input when includeCredentials is set', async () => { + mockValidateSelectorIds.mockResolvedValue({ + valid: [], + invalid: ['bad'], + warning: 'Accessible workspace credentials: Work [cred_ok]', + }) + const state = { + blocks: { b1: { type: 'slack', name: 'Slack', subBlocks: { credential: { value: 'bad' } } } }, + } + const errors = await validateWorkflowSelectorIds(state, CTX, { includeCredentials: true }) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('oauth-input', 'bad', CTX) + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toContain('oauth-input') + }) +}) + +describe('collectUnresolvedReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + }) + + it('flags a basic-mode credential that does not resolve (kind: credential)', async () => { + mockValidateSelectorIds.mockResolvedValue({ + valid: [], + invalid: ['bad-cred'], + warning: 'Accessible workspace credentials: Work [cred_ok]', + }) + const state = { + blocks: { + b1: { type: 'slack', name: 'Slack', subBlocks: { credential: { value: 'bad-cred' } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ + blockId: 'b1', + blockName: 'Slack', + field: 'credential', + kind: 'credential', + }) + expect(refs[0]?.reason).toContain('bad-cred') + expect(refs[0]?.reason).toContain('Accessible workspace credentials') + }) + + it('flags an unresolved knowledge base (kind: resource)', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_x'] }) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value: 'kb_x' } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ blockId: 'kb1', field: 'knowledgeBaseId', kind: 'resource' }) + }) + + it('only validates the active canonical member (inactive member is not flagged)', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['stranded'] }) + // No override + empty basic + filled advanced -> resolves to advanced mode. + // The active member is the (short-input) manual twin, so the inactive + // oauth-input basic member is never validated. + const state = { + blocks: { + c1: { + type: 'canonicalcred', + name: 'Cred', + subBlocks: { credential: { value: '' }, manualCredential: { value: 'stranded' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(refs).toHaveLength(0) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + }) + + it('validates the active basic credential member', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['good-but-missing'] }) + const state = { + blocks: { + c1: { + type: 'canonicalcred', + name: 'Cred', + data: { canonicalModes: { cred: 'basic' } }, + subBlocks: { credential: { value: 'good-but-missing' }, manualCredential: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('oauth-input', 'good-but-missing', CTX) + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ field: 'credential', kind: 'credential' }) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 94010df4479..bce551f32b6 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -1,6 +1,13 @@ import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { + buildCanonicalIndex, + buildSubBlockValues, + isCanonicalPair, + resolveCanonicalMode, +} from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getModelOptions } from '@/blocks/utils' @@ -714,24 +721,48 @@ export function isBlockTypeAllowed( } /** - * Validates selector IDs in the workflow state exist in the database - * Returns validation errors for any invalid selector IDs + * A credential/resource reference whose value does not resolve to an accessible + * workspace entity (the "set in basic mode but the dropdown shows nothing" case). + * Structurally compatible with the copilot WorkflowLintUnresolvedReference. */ -export async function validateWorkflowSelectorIds( - workflowState: any, - context: { userId: string; workspaceId?: string } -): Promise { - const logger = createLogger('EditWorkflowSelectorValidation') - const errors: ValidationError[] = [] +export interface UnresolvedSelectorReference { + blockId: string + blockName?: string + blockType?: string + field: string + value: string | string[] + kind: 'credential' | 'resource' + reason: string +} - // Collect all selector fields from all blocks - const selectorsToValidate: Array<{ - blockId: string - blockType: string - fieldName: string - selectorType: string - value: string | string[] - }> = [] +/** + * External selector IDs (Slack channels, Drive files, Jira projects, folders, + * MCP tools) are validated only at run time, so a clean Tier-2 result is not + * proof those references resolve. Surface this in the lint output. + */ +export const UNRESOLVABLE_AT_LINT_NOTE = + 'Credential/resource resolution covers oauth credentials, knowledge bases, documents, workflows, and MCP servers. External selector IDs (Slack channels, Drive files, Jira projects, folders, MCP tools) are validated only at run time.' + +interface SelectorFieldToValidate { + blockId: string + blockType: string + blockName?: string + fieldName: string + selectorType: string + value: string | string[] +} + +/** + * Walk a workflow state and collect selector/credential fields to validate. + * For canonical pairs only the ACTIVE member is collected (an intentionally-empty + * inactive member is never flagged). oauth-input credentials are included only + * when `options.includeCredentials` is set. + */ +function collectSelectorFields( + workflowState: any, + options: { includeCredentials?: boolean } = {} +): SelectorFieldToValidate[] { + const fields: SelectorFieldToValidate[] = [] for (const [blockId, block] of Object.entries(workflowState.blocks || {})) { const blockData = block as any @@ -741,13 +772,29 @@ export async function validateWorkflowSelectorIds( const blockConfig = getBlock(blockType) if (!blockConfig) continue - // Check each subBlock for selector types + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const allValues = buildSubBlockValues(blockData.subBlocks || {}) + const canonicalModeOverrides = blockData.data?.canonicalModes + for (const subBlockConfig of blockConfig.subBlocks) { if (!SELECTOR_TYPES.has(subBlockConfig.type)) continue - // Skip oauth-input - credentials are pre-validated before edit application - // This allows existing collaborator credentials to remain untouched - if (subBlockConfig.type === 'oauth-input') continue + // oauth-input credentials are only validated when explicitly requested + // (the edit path pre-validates them separately; the lint opts in). + if (subBlockConfig.type === 'oauth-input' && !options.includeCredentials) continue + + // For canonical pairs, only validate the active member's value so an + // intentionally-empty inactive member is never flagged. + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (group && isCanonicalPair(group)) { + const mode = resolveCanonicalMode(group, allValues, canonicalModeOverrides) + const isActiveMember = + mode === 'advanced' + ? group.advancedIds.includes(subBlockConfig.id) + : group.basicId === subBlockConfig.id + if (!isActiveMember) continue + } const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value if (!subBlockValue) continue @@ -761,9 +808,10 @@ export async function validateWorkflowSelectorIds( .filter(Boolean) } - selectorsToValidate.push({ + fields.push({ blockId, blockType, + blockName: blockData.name, fieldName: subBlockConfig.id, selectorType: subBlockConfig.type, value: values, @@ -771,6 +819,27 @@ export async function validateWorkflowSelectorIds( } } + return fields +} + +/** + * Validates selector IDs in the workflow state exist in the database. + * Returns validation errors for any invalid selector IDs. + * + * `options.includeCredentials` controls whether oauth-input credential fields + * are validated (the edit path defaults to skipping them since they are + * pre-validated; the lint opts in to close that gap). + */ +export async function validateWorkflowSelectorIds( + workflowState: any, + context: { userId: string; workspaceId?: string }, + options: { includeCredentials?: boolean } = {} +): Promise { + const logger = createLogger('EditWorkflowSelectorValidation') + const errors: ValidationError[] = [] + + const selectorsToValidate = collectSelectorFields(workflowState, options) + if (selectorsToValidate.length === 0) { return errors } @@ -814,6 +883,56 @@ export async function validateWorkflowSelectorIds( return errors } +/** + * Lint-facing Tier-2 resolution: validate every ACTIVE credential/resource + * member (including oauth-input) against the workspace and return the references + * that do not resolve to an accessible entity. This is the "set in basic mode + * but the dropdown shows nothing" check, using the same resolver the dropdown + * options come from. Best-effort: per-field resolution failures are skipped. + */ +export async function collectUnresolvedReferences( + workflowState: any, + context: { userId: string; workspaceId?: string } +): Promise { + const logger = createLogger('EditWorkflowResolutionLint') + const references: UnresolvedSelectorReference[] = [] + + const selectorsToValidate = collectSelectorFields(workflowState, { includeCredentials: true }) + if (selectorsToValidate.length === 0) { + return references + } + + for (const selector of selectorsToValidate) { + let result: Awaited> + try { + result = await validateSelectorIds(selector.selectorType, selector.value, context) + } catch (error) { + logger.warn('Selector resolution failed; skipping field', { + blockId: selector.blockId, + fieldName: selector.fieldName, + error: toError(error).message, + }) + continue + } + + if (result.invalid.length > 0) { + const kind = selector.selectorType === 'oauth-input' ? 'credential' : 'resource' + const warningInfo = result.warning ? `. ${result.warning}` : '' + references.push({ + blockId: selector.blockId, + blockType: selector.blockType, + blockName: selector.blockName, + field: selector.fieldName, + value: selector.value, + kind, + reason: `${selector.selectorType} ID(s) ${result.invalid.join(', ')} do not resolve to an accessible ${kind}${warningInfo}`, + }) + } + } + + return references +} + /** * Pre-validates credential and apiKey inputs in operations before they are applied. * - Validates oauth-input (credential) IDs are accessible to the user in the workflow workspace diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts b/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts deleted file mode 100644 index 10f18a192b9..00000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { db } from '@sim/db' -import { workflow, workflowExecutionLogs } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, desc, eq, type SQL } from 'drizzle-orm' -import { GetExecutionSummary } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('GetExecutionSummaryServerTool') - -interface GetExecutionSummaryArgs { - workspaceId: string - workflowId?: string - limit?: number - status?: 'success' | 'error' | 'all' -} - -interface ExecutionSummary { - executionId: string - workflowId: string | null - workflowName: string | null - status: string - trigger: string - startedAt: string - durationMs: number | null - cost: number | null - error: string | null -} - -function extractErrorMessage(executionData: any): string | null { - if (!executionData) return null - return ( - executionData?.errorDetails?.error || - executionData?.errorDetails?.message || - executionData?.finalOutput?.error || - executionData?.error || - null - ) -} - -export const getExecutionSummaryServerTool: BaseServerTool< - GetExecutionSummaryArgs, - ExecutionSummary[] -> = { - name: GetExecutionSummary.id, - async execute( - rawArgs: GetExecutionSummaryArgs, - context?: ServerToolContext - ): Promise { - const { workspaceId, workflowId, limit = 10, status = 'all' } = rawArgs || {} - - if (!workspaceId || typeof workspaceId !== 'string') { - throw new Error('workspaceId is required') - } - if (!context?.userId) { - throw new Error('Unauthorized access') - } - - const access = await checkWorkspaceAccess(workspaceId, context.userId) - if (!access.hasAccess) { - throw new Error('Unauthorized workspace access') - } - - const clampedLimit = Math.min(Math.max(1, limit), 20) - - logger.info('Fetching execution summary', { - workspaceId, - workflowId, - limit: clampedLimit, - status, - }) - - const conditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, workspaceId)] - - if (workflowId) { - conditions.push(eq(workflowExecutionLogs.workflowId, workflowId)) - } - - if (status === 'error') { - conditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (status === 'success') { - conditions.push(eq(workflowExecutionLogs.level, 'info')) - } - - const rows = await db - .select({ - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - workflowName: workflow.name, - status: workflowExecutionLogs.status, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - costTotal: workflowExecutionLogs.costTotal, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .where(and(...conditions)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(clampedLimit) - - const summaries: ExecutionSummary[] = await Promise.all( - rows.map(async (row) => { - // Only externalized rows need a fetch; error fields live in the heavy data. - const executionData = - row.level === 'error' - ? await materializeExecutionData(row.executionData as Record | null, { - workspaceId: row.workspaceId, - workflowId: row.workflowId, - executionId: row.executionId, - }) - : row.executionData - const errorMsg = row.level === 'error' ? extractErrorMessage(executionData) : null - - return { - executionId: row.executionId, - workflowId: row.workflowId, - workflowName: row.workflowName, - status: row.status, - trigger: row.trigger, - startedAt: row.startedAt.toISOString(), - durationMs: row.totalDurationMs ?? null, - cost: row.costTotal != null ? Number(row.costTotal) : null, - error: errorMsg - ? typeof errorMsg === 'string' - ? errorMsg - : JSON.stringify(errorMsg) - : null, - } - }) - ) - - logger.info('Execution summary prepared', { - count: summaries.length, - workspaceId, - }) - - return summaries - }, -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts deleted file mode 100644 index 471bc388057..00000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' -import { and, desc, eq } from 'drizzle-orm' -import { GetWorkflowLogs } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { materializeExecutionData } from '@/lib/logs/execution/trace-store' -import type { TraceSpan } from '@/lib/logs/types' - -const logger = createLogger('GetWorkflowLogsServerTool') - -interface GetWorkflowLogsArgs { - workflowId: string - executionId?: string - limit?: number - includeDetails?: boolean -} - -interface BlockExecution { - id: string - blockId: string - blockName: string - blockType: string - startedAt: string - endedAt: string - durationMs: number - status: 'success' | 'error' | 'skipped' - errorMessage?: string - inputData: Record - outputData: Record - cost?: { - total: number - input: number - output: number - model?: string - tokens?: { total: number; input: number; output: number } - } -} - -interface SimplifiedBlock { - id: string - name: string - startedAt: string - endedAt: string - durationMs: number - output: Record - error: string | undefined -} - -interface SimplifiedExecution { - id: string - executionId: string - status: string - startedAt: string - endedAt: string | null - durationMs: number | null - error?: string - blocks?: SimplifiedBlock[] -} - -/** Shape of the JSONB executionData column fields we access. */ -interface ExecutionData { - traceSpans?: TraceSpan[] - errorDetails?: { error?: unknown; message?: unknown } - finalOutput?: { error?: unknown } - error?: unknown -} - -function extractBlockExecutionsFromTraceSpans(traceSpans: TraceSpan[]): BlockExecution[] { - const blockExecutions: BlockExecution[] = [] - - function processSpan(span: TraceSpan) { - if (span.blockId) { - blockExecutions.push({ - id: span.id, - blockId: span.blockId, - blockName: span.name || '', - blockType: span.type, - startedAt: span.startTime, - endedAt: span.endTime, - durationMs: span.duration || 0, - status: span.status || 'success', - errorMessage: span.output?.error as string | undefined, - inputData: span.input || {}, - outputData: span.output || {}, - cost: span.cost - ? { - total: span.cost.total ?? 0, - input: span.cost.input ?? 0, - output: span.cost.output ?? 0, - } - : undefined, - }) - } - span.children?.forEach(processSpan) - } - - traceSpans.forEach(processSpan) - return blockExecutions -} - -export const getWorkflowLogsServerTool: BaseServerTool = - { - name: GetWorkflowLogs.id, - async execute( - rawArgs: GetWorkflowLogsArgs, - context?: { userId: string } - ): Promise { - const { - workflowId, - executionId, - limit = 2, - includeDetails = false, - } = rawArgs || ({} as GetWorkflowLogsArgs) - - if (!workflowId || typeof workflowId !== 'string') { - throw new Error('workflowId is required') - } - if (!context?.userId) { - throw new Error('Unauthorized workflow access') - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: context.userId, - action: 'read', - }) - if (!authorization.allowed) { - throw new Error(authorization.message || 'Unauthorized workflow access') - } - - logger.info('Fetching workflow logs', { - workflowId, - executionId, - limit, - includeDetails, - }) - - const conditions = [eq(workflowExecutionLogs.workflowId, workflowId)] - if (executionId) { - conditions.push(eq(workflowExecutionLogs.executionId, executionId)) - } - - const executionLogs = await db - .select({ - id: workflowExecutionLogs.id, - executionId: workflowExecutionLogs.executionId, - workflowId: workflowExecutionLogs.workflowId, - workspaceId: workflowExecutionLogs.workspaceId, - status: workflowExecutionLogs.status, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .where(and(...conditions)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - // Enforce the documented hard limit (the materialization fans out per row). - .limit(executionId ? 1 : Math.min(Math.max(1, limit), 3)) - - const simplifiedExecutions: SimplifiedExecution[] = await Promise.all( - executionLogs.map(async (log) => { - const executionData = (await materializeExecutionData( - log.executionData as Record | null, - { - workspaceId: log.workspaceId, - workflowId: log.workflowId, - executionId: log.executionId, - } - )) as ExecutionData - const traceSpans = executionData?.traceSpans ?? [] - const blockExecutions = includeDetails - ? extractBlockExecutionsFromTraceSpans(traceSpans) - : [] - - const simplifiedBlocks: SimplifiedBlock[] = blockExecutions.map((block) => ({ - id: block.blockId, - name: block.blockName, - startedAt: block.startedAt, - endedAt: block.endedAt, - durationMs: block.durationMs, - output: block.outputData, - error: block.status === 'error' ? block.errorMessage : undefined, - })) - - const rawError = - executionData?.errorDetails?.error || - executionData?.errorDetails?.message || - executionData?.finalOutput?.error || - executionData?.error || - null - const errorMessage = rawError - ? typeof rawError === 'string' - ? rawError - : JSON.stringify(rawError) - : undefined - - return { - id: log.id, - executionId: log.executionId, - status: log.status, - startedAt: log.startedAt.toISOString(), - endedAt: log.endedAt ? log.endedAt.toISOString() : null, - durationMs: log.totalDurationMs ?? null, - ...(errorMessage ? { error: errorMessage } : {}), - ...(simplifiedBlocks.length > 0 ? { blocks: simplifiedBlocks } : {}), - } - }) - ) - - const resultSize = JSON.stringify(simplifiedExecutions).length - logger.info('Workflow logs result prepared', { - executionCount: simplifiedExecutions.length, - resultSizeKB: Math.round(resultSize / 1024), - }) - - return simplifiedExecutions - }, - } diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts new file mode 100644 index 00000000000..6f195ac4a30 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { listLogsMock, fetchLogDetailMock, toOverviewMock, toFullMock, grepSpansMock } = vi.hoisted( + () => ({ + listLogsMock: vi.fn(), + fetchLogDetailMock: vi.fn(), + toOverviewMock: vi.fn(), + toFullMock: vi.fn(), + grepSpansMock: vi.fn(), + }) +) + +vi.mock('@/lib/logs/list-logs', () => ({ listLogs: listLogsMock })) +vi.mock('@/lib/logs/fetch-log-detail', () => ({ fetchLogDetail: fetchLogDetailMock })) +vi.mock('@/lib/logs/log-views', () => ({ + toOverview: toOverviewMock, + toFull: toFullMock, + grepSpans: grepSpansMock, +})) +vi.mock('@/lib/execution/payloads/large-execution-value', () => ({ + collectLargeValueExecutionIds: vi.fn(() => []), + collectLargeValueKeys: vi.fn(() => []), +})) + +import { queryLogsServerTool } from './query-logs' + +const ctx = { userId: 'user-1', workspaceId: 'ws-1' } + +function detail(overrides: Record = {}) { + return { + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'success', + trigger: 'manual', + cost: { total: 0.1 }, + executionData: { totalDuration: 1234, traceSpans: [{ id: 's1' }] }, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('queryLogsServerTool', () => { + it('list view delegates to listLogs with workspaceId and no view field', async () => { + listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null }) + + const result = await queryLogsServerTool.execute( + { view: 'list', sortBy: 'date', sortOrder: 'desc', limit: 100 } as any, + ctx + ) + + expect(listLogsMock).toHaveBeenCalledTimes(1) + const [params, userId] = listLogsMock.mock.calls[0] + expect(userId).toBe('user-1') + expect(params.workspaceId).toBe('ws-1') + expect(params).not.toHaveProperty('view') + expect(result).toEqual({ data: [{ id: 'log-1' }], nextCursor: null }) + }) + + it('overview view returns the projected span tree', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }]) + + const result: any = await queryLogsServerTool.execute( + { view: 'overview', executionId: 'exec-1' } as any, + ctx + ) + + expect(result.executionId).toBe('exec-1') + expect(result.durationMs).toBe(1234) + expect(result.spans).toEqual([{ id: 's1', name: 'A' }]) + expect(toFullMock).not.toHaveBeenCalled() + }) + + it('full view returns materialized spans', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + toFullMock.mockResolvedValue([{ id: 's1', input: { a: 1 } }]) + + const result: any = await queryLogsServerTool.execute( + { view: 'full', executionId: 'exec-1', blockId: 'blk-1' } as any, + ctx + ) + + expect(toFullMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + blockId: 'blk-1', + blockName: undefined, + }) + expect(result.spans).toEqual([{ id: 's1', input: { a: 1 } }]) + expect(result.truncated).toBe(false) + }) + + it('full view falls back to overview when the result is too large', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + const huge = 'x'.repeat(600 * 1024) + toFullMock.mockResolvedValue([{ id: 's1', output: huge }]) + toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }]) + + const result: any = await queryLogsServerTool.execute( + { view: 'full', executionId: 'exec-1' } as any, + ctx + ) + + expect(result.truncated).toBe(true) + expect(result.note).toContain('too large') + expect(result.spans).toEqual([{ id: 's1', name: 'A' }]) + }) + + it('pattern runs grepSpans and returns matches', async () => { + fetchLogDetailMock.mockResolvedValue(detail()) + grepSpansMock.mockResolvedValue({ + matches: [{ spanId: 's1', name: 'A', field: 'output', snippet: '…timeout…' }], + truncated: false, + }) + + const result: any = await queryLogsServerTool.execute( + { view: 'full', executionId: 'exec-1', pattern: 'timeout' } as any, + ctx + ) + + expect(grepSpansMock).toHaveBeenCalledTimes(1) + expect(result.pattern).toBe('timeout') + expect(result.matches).toHaveLength(1) + expect(toFullMock).not.toHaveBeenCalled() + }) + + it('returns not-found for an unknown executionId', async () => { + fetchLogDetailMock.mockResolvedValue(null) + const result: any = await queryLogsServerTool.execute( + { view: 'overview', executionId: 'missing' } as any, + ctx + ) + expect(result.ok).toBe(false) + expect(result.error).toContain('missing') + }) + + it('throws when unauthenticated', async () => { + await expect( + queryLogsServerTool.execute({ view: 'overview', executionId: 'exec-1' } as any, {} as any) + ).rejects.toThrow('Unauthorized') + }) + + it('rejects overview/full without executionId via inputSchema', () => { + const schema = queryLogsServerTool.inputSchema! + expect(schema.safeParse({ view: 'overview', workspaceId: 'ws-1' }).success).toBe(false) + expect(schema.safeParse({ view: 'full', workspaceId: 'ws-1' }).success).toBe(false) + expect( + schema.safeParse({ view: 'overview', workspaceId: 'ws-1', executionId: 'e1' }).success + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts new file mode 100644 index 00000000000..4e4af600e85 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -0,0 +1,199 @@ +import { createLogger } from '@sim/logger' +import { z } from 'zod' +import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +import { + collectLargeValueExecutionIds, + collectLargeValueKeys, +} from '@/lib/execution/payloads/large-execution-value' +import { fetchLogDetail } from '@/lib/logs/fetch-log-detail' +import { type ListLogsParams, listLogs } from '@/lib/logs/list-logs' +import { grepSpans, type LogViewContext, toFull, toOverview } from '@/lib/logs/log-views' +import type { TraceSpan } from '@/lib/logs/types' + +const logger = createLogger('QueryLogsServerTool') + +/** + * Max serialized size for a `full` view result before falling back to the + * compact overview. Keeps a single tool result inline-able. + */ +const MAX_FULL_RESULT_BYTES = 512 * 1024 + +const comparisonOperator = z.enum(['=', '>', '<', '>=', '<=', '!=']) + +const listArgsSchema = z.object({ + view: z.literal('list'), + workspaceId: z.string().optional(), + level: z.string().optional(), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + search: z.string().optional(), + workflowName: z.string().optional(), + folderName: z.string().optional(), + executionId: z.string().optional(), + costOperator: comparisonOperator.optional(), + costValue: z.coerce.number().optional(), + durationOperator: comparisonOperator.optional(), + durationValue: z.coerce.number().optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(100), + sortBy: z.enum(['date', 'duration', 'cost', 'status']).optional().default('date'), + sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), +}) + +const overviewArgsSchema = z.object({ + view: z.literal('overview'), + workspaceId: z.string().optional(), + executionId: z.string(), + pattern: z.string().optional(), +}) + +const fullArgsSchema = z.object({ + view: z.literal('full'), + workspaceId: z.string().optional(), + executionId: z.string(), + blockId: z.string().optional(), + blockName: z.string().optional(), + pattern: z.string().optional(), +}) + +const queryLogsArgsSchema = z.discriminatedUnion('view', [ + listArgsSchema, + overviewArgsSchema, + fullArgsSchema, +]) + +type QueryLogsArgs = z.infer + +function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string { + const workspaceId = args.workspaceId ?? context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') + } + return workspaceId +} + +function buildLogViewContext( + detail: { + workflowId: string | null + executionId: string + executionData?: unknown + }, + workspaceId: string, + userId: string +): LogViewContext { + return { + workspaceId, + workflowId: detail.workflowId ?? undefined, + executionId: detail.executionId, + userId, + largeValueExecutionIds: collectLargeValueExecutionIds(detail.executionData), + largeValueKeys: collectLargeValueKeys(detail.executionData), + allowLargeValueWorkflowScope: true, + } +} + +/** + * Consolidated execution/log read tool. + * + * - `view: "list"` — paginated execution summaries with the full Logs-UI filter + * set (reuses `listLogs`). + * - `view: "overview"` — a single execution's trace-span tree (timing + cost, + * no input/output). + * - `view: "full"` — a single execution's trace spans with materialized + * input/output, optionally scoped to one block via `blockId`/`blockName`. + * - `pattern` (with `overview`/`full`) — grep that execution's trace spans, + * streaming large values chunk-by-chunk. + */ +export const queryLogsServerTool: BaseServerTool = { + name: QueryLogs.id, + inputSchema: queryLogsArgsSchema, + outputSchema: z.unknown(), + async execute(args: QueryLogsArgs, context?: ServerToolContext): Promise { + if (!context?.userId) { + throw new Error('Unauthorized access') + } + const userId = context.userId + const workspaceId = resolveWorkspaceId(args, context) + + if (args.view === 'list') { + const { view: _view, ...rest } = args + const params = { ...rest, workspaceId } as ListLogsParams + logger.info('query_logs list', { workspaceId, sortBy: params.sortBy }) + return listLogs(params, userId) + } + + // overview / full / grep — single execution by id + const detail = await fetchLogDetail({ + userId, + workspaceId, + lookupColumn: 'executionId', + lookupValue: args.executionId, + }) + if (!detail) { + return { ok: false, error: `Execution not found: ${args.executionId}` } + } + + const execData = detail.executionData as + | { traceSpans?: TraceSpan[]; totalDuration?: number | null } + | undefined + const traceSpans = (execData?.traceSpans ?? []) as TraceSpan[] + const viewCtx = buildLogViewContext(detail, workspaceId, userId) + + if (args.pattern) { + logger.info('query_logs grep', { workspaceId, executionId: args.executionId }) + const { matches, truncated } = await grepSpans(traceSpans, args.pattern, viewCtx) + return { + executionId: detail.executionId, + workflowId: detail.workflowId, + status: detail.status, + pattern: args.pattern, + matches, + truncated, + } + } + + if (args.view === 'overview') { + return { + executionId: detail.executionId, + workflowId: detail.workflowId, + status: detail.status, + trigger: detail.trigger, + durationMs: execData?.totalDuration ?? null, + cost: detail.cost ?? null, + spans: toOverview(traceSpans), + } + } + + // full + const spans = await toFull(traceSpans, viewCtx, { + blockId: args.blockId, + blockName: args.blockName, + }) + const result = { + executionId: detail.executionId, + workflowId: detail.workflowId, + status: detail.status, + trigger: detail.trigger, + cost: detail.cost ?? null, + spans, + truncated: false, + } + + if (JSON.stringify(result).length > MAX_FULL_RESULT_BYTES) { + return { + executionId: detail.executionId, + workflowId: detail.workflowId, + status: detail.status, + truncated: true, + note: 'Full result too large; returning the compact overview. Scope with blockId/blockName, or use pattern to grep.', + spans: toOverview(traceSpans), + } + } + + return result + }, +} diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index ba464a4c945..f2d65252a27 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -42,6 +42,9 @@ const TEXT_TYPES = new Set([ 'text/html', 'text/xml', 'text/x-pptxgenjs', + 'text/x-docxjs', + 'text/x-python-pdf', + 'text/x-python-xlsx', 'application/json', 'application/xml', 'application/javascript', @@ -261,6 +264,7 @@ export interface FileReadResult { totalLines: number attachment?: { type: string + name?: string source: { type: 'base64' media_type: string @@ -316,6 +320,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { } describe('glob', () => { - it('matches canonical file metadata paths by id', () => { + it('matches nested file metadata paths with a single-star segment', () => { const files = vfsFromEntries([ - ['files/by-id/wf_123/meta.json', '{}'], + ['files/Reports/q1.csv/meta.json', '{}'], ['files/data.csv/meta.json', '{}'], ]) - const hits = glob(files, 'files/by-id/*/meta.json') - expect(hits).toContain('files/by-id/wf_123/meta.json') + const hits = glob(files, 'files/Reports/*/meta.json') + expect(hits).toContain('files/Reports/q1.csv/meta.json') expect(hits).not.toContain('files/data.csv/meta.json') }) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index 1456c11b2c6..dfef44a1ae5 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -21,16 +21,65 @@ export interface GrepCountEntry { count: number } +/** + * Thrown when a single-file content grep (see `WorkspaceVFS.grepFile`) hits an + * expected, user-facing condition: the path is not a single workspace file, the + * file has no searchable text (image/binary), or it exceeds the inline read cap. + * The grep handler surfaces the message verbatim instead of treating it as an + * internal failure. Defined here (rather than in `workspace-vfs.ts`) so the + * handler can reference it without pulling in the VFS module's heavy deps. + */ +export class WorkspaceFileGrepError extends Error { + readonly code = 'WORKSPACE_FILE_GREP' as const + constructor(message: string) { + super(message) + this.name = 'WorkspaceFileGrepError' + } +} + +/** + * True when file content is one of `readFileRecord`'s non-text placeholders + * (binary, unparseable, or over the inline read cap) — these carry no searchable + * content, so grepping them should report the placeholder instead. + */ +function isNonGreppablePlaceholder(content: string, totalLines: number): boolean { + if (totalLines !== 1) return false + return /^\[(File too large|Image too large|Document too large|Could not parse|Binary file|Compiled artifact too large)/.test( + content.trim() + ) +} + +/** + * Run a single-file content grep over an already-resolved file read result, + * shared by workspace-file grep (`WorkspaceVFS.grepFile`) and chat-upload grep. + * Throws {@link WorkspaceFileGrepError} when the file has no searchable text + * (image/binary attachment) or is a size/parse placeholder; otherwise greps the + * text with the standard {@link grep} engine over a one-entry map keyed by + * `path`. `readHint` is the path to suggest in the "use read(...)" message. + */ +export function grepReadResult( + path: string, + result: { content: string; totalLines: number; attachment?: unknown }, + pattern: string, + readHint: string, + options?: GrepOptions +): GrepMatch[] | string[] | GrepCountEntry[] { + if (result.attachment) { + throw new WorkspaceFileGrepError( + `Cannot grep "${path}" — it has no searchable text (image/binary). Use read("${readHint}") to view it.` + ) + } + if (isNonGreppablePlaceholder(result.content, result.totalLines)) { + throw new WorkspaceFileGrepError(result.content) + } + return grep(new Map([[path, result.content]]), pattern, undefined, options) +} + export interface ReadResult { content: string totalLines: number } -export interface DirEntry { - name: string - type: 'file' | 'dir' -} - /** * Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*` * never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace` @@ -173,6 +222,10 @@ export function glob(files: Map, pattern: string): string[] { const directories = new Set() for (const filePath of files.keys()) { + if (filePath.endsWith('/.folder')) { + directories.add(filePath.slice(0, -'/.folder'.length)) + continue + } const parts = filePath.split('/') for (let i = 1; i < parts.length; i++) { directories.add(parts.slice(0, i).join('/')) @@ -180,6 +233,7 @@ export function glob(files: Map, pattern: string): string[] { } for (const filePath of files.keys()) { + if (filePath.endsWith('/.folder')) continue if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) { result.add(filePath) } @@ -239,42 +293,6 @@ export function read( return { content, totalLines } } -/** - * List entries in a VFS directory path. - * Returns files and subdirectories at the given path level. - */ -export function list(files: Map, path: string): DirEntry[] { - const normalizedPath = path.endsWith('/') ? path : `${path}/` - const seen = new Set() - const entries: DirEntry[] = [] - - for (const filePath of files.keys()) { - if (!filePath.startsWith(normalizedPath)) continue - - const remainder = filePath.slice(normalizedPath.length) - if (!remainder) continue - - const slashIndex = remainder.indexOf('/') - if (slashIndex === -1) { - if (!seen.has(remainder)) { - seen.add(remainder) - entries.push({ name: remainder, type: 'file' }) - } - } else { - const dirName = remainder.slice(0, slashIndex) - if (!seen.has(dirName)) { - seen.add(dirName) - entries.push({ name: dirName, type: 'dir' }) - } - } - } - - return entries.sort((a, b) => { - if (a.type !== b.type) return a.type === 'dir' ? -1 : 1 - return a.name.localeCompare(b.name) - }) -} - /** * Find VFS paths similar to a missing path. * diff --git a/apps/sim/lib/copilot/vfs/path-utils.test.ts b/apps/sim/lib/copilot/vfs/path-utils.test.ts new file mode 100644 index 00000000000..b2df921be1a --- /dev/null +++ b/apps/sim/lib/copilot/vfs/path-utils.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildVfsFolderPathMap, + canonicalBlockVfsPath, + canonicalKnowledgeBaseVfsDir, + canonicalTableVfsPath, + canonicalWorkflowVfsDir, + canonicalWorkspaceFilePath, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' + +describe('VFS path utilities', () => { + it('round trips encoded nested path segments', () => { + const segments = ['Reports', 'Q4 Report (Final)', 'sales/east.csv'] + + const encoded = encodeVfsPathSegments(segments) + + expect(encoded).toBe('Reports/Q4%20Report%20(Final)/sales%2Feast.csv') + expect(decodeVfsPathSegments(encoded)).toEqual(segments) + }) + + it('builds canonical workspace file leaf paths', () => { + expect( + canonicalWorkspaceFilePath({ + folderPath: 'Reports/Q4 Report (Final)', + name: 'sales/east.csv', + }) + ).toBe('files/Reports/Q4%20Report%20(Final)/sales%2Feast.csv') + }) +}) + +describe('canonical resource VFS paths', () => { + it('builds nested + encoded folder path map', () => { + const map = buildVfsFolderPathMap([ + { folderId: 'root', folderName: 'My Folder', parentId: null }, + { folderId: 'child', folderName: 'Sub Folder', parentId: 'root' }, + ]) + expect(map.get('root')).toBe('My%20Folder') + expect(map.get('child')).toBe('My%20Folder/Sub%20Folder') + }) + + it('builds workflow dirs at root and nested in folders', () => { + expect(canonicalWorkflowVfsDir({ name: 'My Flow' })).toBe('workflows/My%20Flow') + expect( + canonicalWorkflowVfsDir({ name: 'My Flow', folderPath: 'My%20Folder/Sub%20Folder' }) + ).toBe('workflows/My%20Folder/Sub%20Folder/My%20Flow') + }) + + it('builds table, knowledge base, and block pointers', () => { + expect(canonicalTableVfsPath('Sales Data')).toBe('tables/Sales%20Data/meta.json') + expect(canonicalKnowledgeBaseVfsDir('Docs — KB')).toBe('knowledgebases/Docs%20%E2%80%94%20KB') + expect(canonicalBlockVfsPath('agent')).toBe('components/blocks/agent.json') + }) +}) diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/copilot/vfs/path-utils.ts new file mode 100644 index 00000000000..6f88e9507d9 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/path-utils.ts @@ -0,0 +1,125 @@ +const CONTROL_CHARS = /[\x00-\x1f\x7f]/g +const WHITESPACE = /\s+/g + +export class VfsPathError extends Error { + constructor(message: string) { + super(message) + this.name = 'VfsPathError' + } +} + +function normalizeDisplaySegment(segment: string): string { + return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ') +} + +export function encodeVfsSegment(segment: string): string { + const normalized = normalizeDisplaySegment(segment) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return encodeURIComponent(normalized) +} + +export function decodeVfsSegment(segment: string): string { + try { + const decoded = decodeURIComponent(segment) + const normalized = normalizeDisplaySegment(decoded) + if (!normalized || normalized === '.' || normalized === '..') { + throw new VfsPathError('VFS path segment cannot be empty or a dot segment') + } + return normalized + } catch (error) { + if (error instanceof VfsPathError) throw error + throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`) + } +} + +export function encodeVfsPathSegments(segments: string[]): string { + return segments.map(encodeVfsSegment).join('/') +} + +export function decodeVfsPathSegments(path: string): string[] { + const trimmed = path.trim().replace(/^\/+|\/+$/g, '') + if (!trimmed) return [] + return trimmed.split('/').map(decodeVfsSegment) +} + +export function canonicalizeVfsPath(path: string): string { + return encodeVfsPathSegments(decodeVfsPathSegments(path)) +} + +export function canonicalWorkspaceFilePath(parts: { + folderPath?: string | null + name: string + prefix?: 'files' | 'recently-deleted/files' +}): string { + const prefix = parts.prefix ?? 'files' + const folderSegments = parts.folderPath ? parts.folderPath.split('/').filter(Boolean) : [] + const encoded = encodeVfsPathSegments([...folderSegments, parts.name]) + return `${prefix}/${encoded}` +} + +/** + * Build a map from folderId to its canonical, per-segment-encoded VFS folder + * path (e.g. `My%20Folder/Sub`), resolving nested folders via `parentId`. + * + * Shared by the workspace VFS materializer (`workspace-vfs.ts`) and the chat + * context resolver (`process-contents.ts`) so workflow/folder pointer paths + * cannot drift from what the VFS actually serves. Works for any folder + * hierarchy that exposes `{ folderId, folderName, parentId }` rows (workflow + * folders and file folders both qualify). + */ +export function buildVfsFolderPathMap( + folders: Array<{ folderId: string; folderName: string; parentId: string | null }> +): Map { + const folderMap = new Map() + for (const f of folders) { + folderMap.set(f.folderId, { name: f.folderName, parentId: f.parentId }) + } + + const cache = new Map() + const resolve = (id: string): string => { + const cached = cache.get(id) + if (cached !== undefined) return cached + const folder = folderMap.get(id) + if (!folder) return '' + const parentPath = folder.parentId ? resolve(folder.parentId) : '' + const path = parentPath + ? `${parentPath}/${encodeVfsSegment(folder.name)}` + : encodeVfsSegment(folder.name) + cache.set(id, path) + return path + } + + for (const id of folderMap.keys()) resolve(id) + return cache +} + +/** + * Canonical VFS directory for a workflow. `folderPath` is the already + * per-segment-encoded folder path (from {@link buildVfsFolderPathMap}) or + * null/empty for a root-level workflow. Mirrors the prefix built by + * `workspace-vfs.ts` (`workflows/{folder}/{name}` or `workflows/{name}`). + */ +export function canonicalWorkflowVfsDir(parts: { + name: string + folderPath?: string | null +}): string { + const safeName = encodeVfsSegment(parts.name) + return parts.folderPath ? `workflows/${parts.folderPath}/${safeName}` : `workflows/${safeName}` +} + +/** Canonical VFS path for a table's metadata file (`tables/{name}/meta.json`). */ +export function canonicalTableVfsPath(name: string): string { + return `tables/${encodeVfsSegment(name)}/meta.json` +} + +/** Canonical VFS directory for a knowledge base (`knowledgebases/{name}`). */ +export function canonicalKnowledgeBaseVfsDir(name: string): string { + return `knowledgebases/${encodeVfsSegment(name)}` +} + +/** Canonical VFS path for a block catalog entry (`components/blocks/{type}.json`). */ +export function canonicalBlockVfsPath(blockType: string): string { + return `components/blocks/${blockType}.json` +} diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/copilot/vfs/resource-writer.test.ts new file mode 100644 index 00000000000..f0181d0a396 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/resource-writer.test.ts @@ -0,0 +1,314 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + class FileConflictError extends Error { + readonly code = 'FILE_EXISTS' as const + } + + return { + FileConflictError, + ensureWorkflowAliasBacking: vi.fn(), + ensureWorkspacePlanBacking: vi.fn(), + resolveWorkflowAliasForWorkspace: vi.fn(), + ensureWorkspaceFileFolderPath: vi.fn(), + findWorkspaceFileFolderIdByPath: vi.fn(), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), + getWorkspaceFileByName: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), + updateWorkspaceFileContent: vi.fn(), + uploadWorkspaceFile: vi.fn(), + } +}) + +vi.mock('@/lib/copilot/vfs/workflow-alias-backing', () => ({ + ensureWorkflowAliasBacking: mocks.ensureWorkflowAliasBacking, + ensureWorkspacePlanBacking: mocks.ensureWorkspacePlanBacking, +})) + +vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({ + resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName: mocks.normalizeWorkspaceFileItemName, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + FileConflictError: mocks.FileConflictError, + getWorkspaceFileByName: mocks.getWorkspaceFileByName, + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, + updateWorkspaceFileContent: mocks.updateWorkspaceFileContent, + uploadWorkspaceFile: mocks.uploadWorkspaceFile, +})) + +import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' + +describe('resource writer workflow aliases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkflowAliasBacking.mockResolvedValue({}) + mocks.ensureWorkspacePlanBacking.mockResolvedValue({}) + mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-id') + }) + + it('creates workflow plan aliases through backing workspace files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/launch.md', + backingPath: 'files/.plans/wf_1/launch.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'launch.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-plan', + name: 'launch.md', + size: 7, + type: 'text/markdown', + url: '/download', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('content'), + 'launch.md', + 'text/markdown', + { folderId: 'folder-id', exactName: true } + ) + expect(result).toMatchObject({ + id: 'file-plan', + vfsPath: 'workflows/My%20Workflow/.plans/launch.md', + backingVfsPath: 'files/.plans/wf_1/launch.md', + mode: 'create', + }) + }) + + it('overwrites workflow changelog aliases through backing workspace files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'changelog', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/changelog.md', + backingPath: 'files/.changelogs/wf_1.md', + backingFolderPath: 'files/.changelogs', + }) + mocks.getWorkspaceFileByName.mockResolvedValue({ + id: 'file-changelog', + name: 'wf_1.md', + type: 'text/markdown', + folderPath: '.changelogs', + }) + mocks.updateWorkspaceFileContent.mockResolvedValue({ + id: 'file-changelog', + name: 'wf_1.md', + size: 7, + type: 'text/markdown', + url: '/download', + folderPath: '.changelogs', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/changelog.md', + mode: 'overwrite', + }, + buffer: Buffer.from('updated'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.updateWorkspaceFileContent).toHaveBeenCalledWith( + 'workspace-1', + 'file-changelog', + 'user-1', + Buffer.from('updated'), + 'text/markdown' + ) + expect(result).toMatchObject({ + id: 'file-changelog', + vfsPath: 'workflows/My%20Workflow/changelog.md', + backingVfsPath: 'files/.changelogs/wf_1.md', + mode: 'overwrite', + }) + }) + + it('creates root workspace plan aliases through workspace backing files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: '.plans/root.md', + backingPath: 'files/.plans/workspace/root.md', + backingFolderPath: 'files/.plans/workspace', + planRelativePath: 'root.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-root-plan', + name: 'root.md', + size: 7, + type: 'text/markdown', + url: '/download', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: '.plans/root.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.ensureWorkspacePlanBacking).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.plans', 'workspace'], + }) + expect(result).toMatchObject({ + id: 'file-root-plan', + vfsPath: '.plans/root.md', + backingVfsPath: 'files/.plans/workspace/root.md', + mode: 'create', + }) + }) + + it('rejects direct writes to reserved workflow alias backing paths', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + + await expect( + writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/.plans/wf_1/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + ).rejects.toThrow( + 'Reserved workflow alias backing paths must be accessed through their alias path' + ) + + expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('rejects validation of reserved workflow alias backing paths', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null) + + await expect( + validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'files/.changelogs/wf_1.md', + mode: 'overwrite', + }, + }) + ).rejects.toThrow( + 'Reserved workflow alias backing paths must be accessed through their alias path' + ) + + expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled() + }) + + it('uses exact-name creates for alias backing files', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/launch.md', + backingPath: 'files/.plans/wf_1/launch.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'launch.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockResolvedValue({ + id: 'file-plan', + name: 'launch.md', + size: 7, + type: 'text/markdown', + url: '/download', + }) + + await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('content'), + 'launch.md', + 'text/markdown', + { folderId: 'folder-id', exactName: true } + ) + }) + + it('reports alias path when exact-name alias backing creation conflicts', async () => { + mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({ + kind: 'plan_file', + scope: 'workflow', + workflowId: 'wf_1', + workflowName: 'My Workflow', + workflowPath: 'workflows/My%20Workflow', + aliasPath: 'workflows/My%20Workflow/.plans/launch.md', + backingPath: 'files/.plans/wf_1/launch.md', + backingFolderPath: 'files/.plans/wf_1', + planRelativePath: 'launch.md', + }) + mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.uploadWorkspaceFile.mockRejectedValue(new mocks.FileConflictError('launch.md')) + + await expect( + writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + userId: 'user-1', + target: { + path: 'workflows/My%20Workflow/.plans/launch.md', + mode: 'create', + }, + buffer: Buffer.from('content'), + inferredMimeType: 'text/markdown', + }) + ).rejects.toThrow( + 'File already exists at workflows/My%20Workflow/.plans/launch.md. Use mode "overwrite" to update it.' + ) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts new file mode 100644 index 00000000000..476c9186c83 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -0,0 +1,412 @@ +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { + ensureWorkflowAliasBacking, + ensureWorkspacePlanBacking, +} from '@/lib/copilot/vfs/workflow-alias-backing' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { + isPlanAliasPath, + isWorkflowAliasBackingPath, + WORKFLOW_CHANGELOG_BACKING_FOLDER, + WORKFLOW_PLANS_BACKING_FOLDER, + WORKSPACE_PLANS_BACKING_FOLDER, + type WorkflowAliasTarget, +} from '@/lib/copilot/vfs/workflow-aliases' +import { + ensureWorkspaceFileFolderPath, + findWorkspaceFileFolderIdByPath, + normalizeWorkspaceFileItemName, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + FileConflictError, + getWorkspaceFileByName, + resolveWorkspaceFileReference, + updateWorkspaceFileContent, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export type WorkspaceFileWriteMode = 'create' | 'overwrite' + +export interface WorkspaceFileWriteTarget { + path: string + mode: WorkspaceFileWriteMode + mimeType?: string +} + +export interface WorkspaceFileWriteResult { + id: string + name: string + size: number + contentType: string + downloadUrl?: string + vfsPath: string + backingVfsPath?: string + mode: WorkspaceFileWriteMode +} + +interface ResolvedCreateTarget { + fileName: string + folderId: string | null + vfsPath: string +} + +export type WorkspaceFileWriteValidation = + | { + mode: 'create' + vfsPath: string + backingVfsPath?: string + fileName: string + folderId: string | null + } + | { + mode: 'overwrite' + vfsPath: string + backingVfsPath?: string + existingFileId: string + } + +function displayFolderPath(segments: string[]): string { + return segments.length > 0 ? `files/${segments.join('/')}` : 'files/' +} + +export function parseWorkspaceFileCreatePath(path: string): { + folderSegments: string[] + fileName: string + vfsPath: string +} { + const trimmed = path.trim().replace(/^\/+/, '') + if (!trimmed.startsWith('files/')) { + throw new Error('Workspace file paths must start with "files/"') + } + + const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) + if (decoded.length === 0) { + throw new Error('Workspace file path must include a file name') + } + + const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') + const folderSegments = decoded + .slice(0, -1) + .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + + return { + folderSegments, + fileName, + vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), + } +} + +async function resolveCreateTarget( + workspaceId: string, + path: string +): Promise { + const parsed = parseWorkspaceFileCreatePath(path) + const folderId = + parsed.folderSegments.length > 0 + ? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, { + includeReservedSystemFolders: true, + }) + : null + + if (parsed.folderSegments.length > 0 && !folderId) { + throw new Error( + `Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.` + ) + } + + const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId }) + if (existing) { + throw new Error(`File already exists at ${parsed.vfsPath}. Use mode "overwrite" to update it.`) + } + + return { + fileName: parsed.fileName, + folderId, + vfsPath: parsed.vfsPath, + } +} + +function vfsPathForRecord(record: WorkspaceFileRecord): string { + return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) +} + +function assertNotReservedWorkflowAliasBackingPath(path: string): void { + if (isWorkflowAliasBackingPath(path)) { + throw new Error( + `Reserved workflow alias backing paths must be accessed through their alias path: ${path}` + ) + } +} + +async function resolveWorkflowAliasFileTarget(args: { + workspaceId: string + userId?: string + alias: WorkflowAliasTarget +}): Promise { + if (args.alias.kind === 'plans_dir') { + throw new Error(`Cannot write file content to plan alias directory: ${args.alias.aliasPath}`) + } + + if (args.userId && args.alias.scope === 'workflow') { + await ensureWorkflowAliasBacking({ + workspaceId: args.workspaceId, + userId: args.userId, + workflowId: args.alias.workflowId, + workflowName: args.alias.workflowName, + }) + } else if (args.userId && args.alias.scope === 'workspace') { + await ensureWorkspacePlanBacking({ + workspaceId: args.workspaceId, + userId: args.userId, + }) + } + + if (args.alias.kind === 'changelog') { + const folderSegments = [WORKFLOW_CHANGELOG_BACKING_FOLDER] + const folderId = args.userId + ? await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: folderSegments, + }) + : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + throw new Error( + `Workflow changelog backing folder is not provisioned for ${args.alias.aliasPath}` + ) + } + const fileName = `${args.alias.workflowId}.md` + return { + fileName, + folderId, + vfsPath: args.alias.aliasPath, + existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), + } + } + + const relativeSegments = decodeVfsPathSegments(args.alias.planRelativePath ?? '') + if (relativeSegments.length === 0) { + throw new Error(`Workflow plan alias must include a file path: ${args.alias.aliasPath}`) + } + const fileName = normalizeWorkspaceFileItemName(relativeSegments.at(-1) ?? '', 'File') + const folderSegments = [ + WORKFLOW_PLANS_BACKING_FOLDER, + args.alias.scope === 'workflow' ? args.alias.workflowId : WORKSPACE_PLANS_BACKING_FOLDER, + ...relativeSegments.slice(0, -1), + ].map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + const folderId = args.userId + ? await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: folderSegments, + }) + : await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, { + includeReservedSystemFolders: true, + }) + if (!folderId) { + throw new Error( + `Plan backing directory is not provisioned for ${args.alias.aliasPath}. Create the plan with touch_plan first.` + ) + } + + return { + fileName, + folderId, + vfsPath: args.alias.aliasPath, + existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }), + } +} + +export async function validateWorkspaceFileWriteTarget(args: { + workspaceId: string + userId?: string + target: WorkspaceFileWriteTarget +}): Promise { + const alias = await resolveWorkflowAliasForWorkspace({ + workspaceId: args.workspaceId, + path: args.target.path, + }) + if (!alias && isPlanAliasPath(args.target.path)) { + throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) + } + if (alias) { + const resolved = await resolveWorkflowAliasFileTarget({ + workspaceId: args.workspaceId, + userId: args.userId, + alias, + }) + if (args.target.mode === 'overwrite') { + if (!resolved.existingFile) { + throw new Error(`File not found for overwrite: ${alias.aliasPath}`) + } + return { + mode: 'overwrite', + vfsPath: alias.aliasPath, + backingVfsPath: alias.backingPath, + existingFileId: resolved.existingFile.id, + } + } + if (resolved.existingFile) { + throw new Error( + `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` + ) + } + return { + mode: 'create', + vfsPath: alias.aliasPath, + backingVfsPath: alias.backingPath, + fileName: resolved.fileName, + folderId: resolved.folderId, + } + } + + assertNotReservedWorkflowAliasBackingPath(args.target.path) + + if (args.target.mode === 'overwrite') { + const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + if (!existing) { + throw new Error(`File not found for overwrite: ${args.target.path}`) + } + return { + mode: 'overwrite', + vfsPath: vfsPathForRecord(existing), + existingFileId: existing.id, + } + } + + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + return { + mode: 'create', + vfsPath: createTarget.vfsPath, + fileName: createTarget.fileName, + folderId: createTarget.folderId, + } +} + +export async function writeWorkspaceFileByPath(args: { + workspaceId: string + userId: string + target: WorkspaceFileWriteTarget + buffer: Buffer + inferredMimeType: string +}): Promise { + const contentType = args.target.mimeType || args.inferredMimeType + const alias = await resolveWorkflowAliasForWorkspace({ + workspaceId: args.workspaceId, + path: args.target.path, + }) + if (!alias && isPlanAliasPath(args.target.path)) { + throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`) + } + if (alias) { + const resolved = await resolveWorkflowAliasFileTarget({ + workspaceId: args.workspaceId, + userId: args.userId, + alias, + }) + + if (args.target.mode === 'overwrite') { + if (!resolved.existingFile) { + throw new Error(`File not found for overwrite: ${alias.aliasPath}`) + } + const updated = await updateWorkspaceFileContent( + args.workspaceId, + resolved.existingFile.id, + args.userId, + args.buffer, + contentType || resolved.existingFile.type + ) + return { + id: updated.id, + name: updated.name, + size: updated.size, + contentType: updated.type, + downloadUrl: updated.url, + vfsPath: alias.aliasPath, + backingVfsPath: vfsPathForRecord(updated), + mode: 'overwrite', + } + } + + if (resolved.existingFile) { + throw new Error( + `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` + ) + } + const uploaded = await uploadWorkspaceFile( + args.workspaceId, + args.userId, + args.buffer, + resolved.fileName, + contentType, + { folderId: resolved.folderId, exactName: true } + ).catch((error: unknown) => { + if (error instanceof FileConflictError) { + throw new Error( + `File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.` + ) + } + throw error + }) + return { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + contentType: uploaded.type, + downloadUrl: uploaded.url, + vfsPath: alias.aliasPath, + backingVfsPath: alias.backingPath, + mode: 'create', + } + } + + assertNotReservedWorkflowAliasBackingPath(args.target.path) + + if (args.target.mode === 'overwrite') { + const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + if (!existing) { + throw new Error(`File not found for overwrite: ${args.target.path}`) + } + + const updated = await updateWorkspaceFileContent( + args.workspaceId, + existing.id, + args.userId, + args.buffer, + contentType || existing.type + ) + + return { + id: updated.id, + name: updated.name, + size: updated.size, + contentType: updated.type, + downloadUrl: updated.url, + vfsPath: vfsPathForRecord(updated), + mode: 'overwrite', + } + } + + const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path) + const uploaded = await uploadWorkspaceFile( + args.workspaceId, + args.userId, + args.buffer, + createTarget.fileName, + contentType, + { folderId: createTarget.folderId } + ) + + return { + id: uploaded.id, + name: uploaded.name, + size: uploaded.size, + contentType: uploaded.type, + downloadUrl: uploaded.url, + vfsPath: createTarget.vfsPath, + mode: 'create', + } +} diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index afb284ccbdc..984f341978c 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -264,8 +264,7 @@ export function serializeConnectorOverview(connectors: SerializableConnectorConf } /** - * Serialize workspace file metadata for VFS files/{name}/meta.json - * and files/by-id/{id}/meta.json. + * Serialize workspace file metadata for VFS files/{path}/{name}/meta.json. */ export function serializeFileMeta(file: { id: string @@ -287,6 +286,8 @@ export function serializeFileMeta(file: { contentType: file.contentType, size: file.size, uploadedAt: file.uploadedAt.toISOString(), + readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, + note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', }, null, 2 @@ -619,7 +620,8 @@ export function serializeDeployments(data: DeploymentData): string { /** * Serialize deployment version history for VFS workflows/{name}/versions.json. - * Lists all versions without full state — use get_deployment_version tool to fetch a version's state. + * Lists all versions without full state — use the diff_workflows tool to compare a version, + * or load_deployment to restore one into the draft. */ export function serializeVersions( versions: Array<{ @@ -722,6 +724,9 @@ export function serializeIntegrationSchema(tool: ToolConfig): string { return JSON.stringify( { + // The full registry id is the agent-callable id (deferred tools are sent + // with this exact id; no stripping). Surface it verbatim so "copy the id + // field and load it" matches the callable tool and the block's tools.access. id: tool.id, name: tool.name, description: getCopilotToolDescription(tool, { isHosted }), diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts new file mode 100644 index 00000000000..55e4918b0f2 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-alias-backing.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + ensureWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(), + getWorkspaceFileByName: vi.fn(), + listWorkspaceFiles: vi.fn(), + uploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, + listWorkspaceFileFolders: mocks.listWorkspaceFileFolders, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFileByName: mocks.getWorkspaceFileByName, + listWorkspaceFiles: mocks.listWorkspaceFiles, + uploadWorkspaceFile: mocks.uploadWorkspaceFile, +})) + +import { ensureWorkflowAliasBacking } from './workflow-alias-backing' + +describe('workflow alias backing', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.ensureWorkspaceFileFolderPath.mockImplementation(({ pathSegments }) => + Promise.resolve(`folder:${pathSegments.join('/')}`) + ) + }) + + it('provisions reserved folders and creates a headed changelog when missing', async () => { + mocks.getWorkspaceFileByName + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'file-1', name: 'wf_1.md' }) + + const result = await ensureWorkflowAliasBacking({ + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'wf_1', + workflowName: 'My Workflow', + }) + + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.changelogs'], + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.plans', 'wf_1'], + }) + expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + userId: 'user-1', + pathSegments: ['.plans', 'workspace'], + }) + expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( + 'workspace-1', + 'user-1', + Buffer.from('# My Workflow Changelog\n', 'utf-8'), + 'wf_1.md', + 'text/markdown', + { folderId: 'folder:.changelogs' } + ) + expect(result.changelogFile).toMatchObject({ id: 'file-1' }) + }) + + it('reuses an existing changelog backing file', async () => { + mocks.getWorkspaceFileByName.mockResolvedValueOnce({ id: 'file-existing', name: 'wf_2.md' }) + + const result = await ensureWorkflowAliasBacking({ + workspaceId: 'workspace-1', + userId: 'user-1', + workflowId: 'wf_2', + }) + + expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled() + expect(result.changelogFile).toMatchObject({ id: 'file-existing' }) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts new file mode 100644 index 00000000000..05082355b3f --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-alias-backing.ts @@ -0,0 +1,204 @@ +import { db } from '@sim/db' +import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { + WORKFLOW_CHANGELOG_BACKING_FOLDER, + WORKFLOW_PLANS_BACKING_FOLDER, + WORKSPACE_PLANS_BACKING_FOLDER, +} from '@/lib/copilot/vfs/workflow-aliases' +import { + ensureWorkspaceFileFolderPath, + listWorkspaceFileFolders, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + getWorkspaceFileByName, + listWorkspaceFiles, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('WorkflowAliasBacking') + +export interface WorkflowAliasBacking { + changelogFolderId: string + plansRootFolderId: string + workflowPlansFolderId: string + workspacePlansFolderId: string + changelogFile: WorkspaceFileRecord | null +} + +function initialChangelogContent(workflowName?: string): string { + const title = workflowName?.trim() || 'Workflow' + return `# ${title} Changelog\n` +} + +export async function ensureWorkflowAliasBacking(args: { + workspaceId: string + userId: string + workflowId: string + workflowName?: string +}): Promise { + const changelogFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_CHANGELOG_BACKING_FOLDER], + }) + const plansRootFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], + }) + const workflowPlansFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, args.workflowId], + }) + const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], + }) + + if ( + !changelogFolderId || + !plansRootFolderId || + !workflowPlansFolderId || + !workspacePlansFolderId + ) { + throw new Error('Failed to provision workflow alias backing folders') + } + + const changelogName = `${args.workflowId}.md` + let changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { + folderId: changelogFolderId, + }) + if (!changelogFile) { + await uploadWorkspaceFile( + args.workspaceId, + args.userId, + Buffer.from(initialChangelogContent(args.workflowName), 'utf-8'), + changelogName, + 'text/markdown', + { folderId: changelogFolderId } + ) + changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, { + folderId: changelogFolderId, + }) + } + + return { + changelogFolderId, + plansRootFolderId, + workflowPlansFolderId, + workspacePlansFolderId, + changelogFile, + } +} + +export async function ensureWorkflowAliasBackingQuietly(args: { + workspaceId: string + userId: string + workflowId: string + workflowName?: string +}): Promise { + try { + return await ensureWorkflowAliasBacking(args) + } catch (error) { + logger.warn('Failed to ensure workflow alias backing', { + workspaceId: args.workspaceId, + workflowId: args.workflowId, + error: toError(error).message, + }) + return null + } +} + +export async function ensureWorkspacePlanBacking(args: { + workspaceId: string + userId: string +}): Promise<{ plansRootFolderId: string; workspacePlansFolderId: string }> { + const plansRootFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER], + }) + const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({ + workspaceId: args.workspaceId, + userId: args.userId, + pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER], + }) + if (!plansRootFolderId || !workspacePlansFolderId) { + throw new Error('Failed to provision workspace plan backing folders') + } + return { plansRootFolderId, workspacePlansFolderId } +} + +export async function cleanupWorkflowAliasBacking(args: { + workspaceId: string + workflowId: string + deletedAt?: Date +}): Promise<{ files: number; folders: number }> { + const deletedAt = args.deletedAt ?? new Date() + const folders = await listWorkspaceFileFolders(args.workspaceId, { + scope: 'all', + includeReservedSystemFolders: true, + }) + const files = await listWorkspaceFiles(args.workspaceId, { + scope: 'all', + folders, + includeReservedSystemFiles: true, + }) + + const ownedFileIds = files + .filter((file) => { + if (file.deletedAt) return false + const changelogMatch = + file.folderPath === WORKFLOW_CHANGELOG_BACKING_FOLDER && + file.name === `${args.workflowId}.md` + const workflowPlanMatch = + file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` || + Boolean(file.folderPath?.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}/`)) + return changelogMatch || workflowPlanMatch + }) + .map((file) => file.id) + + const ownedFolderIds = folders + .filter((folder) => { + if (folder.deletedAt) return false + return ( + folder.path === `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` || + folder.path.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}/`) + ) + }) + .map((folder) => folder.id) + + if (ownedFileIds.length > 0) { + await db + .update(workspaceFiles) + .set({ deletedAt }) + .where( + and( + eq(workspaceFiles.workspaceId, args.workspaceId), + inArray(workspaceFiles.id, ownedFileIds), + isNull(workspaceFiles.deletedAt) + ) + ) + } + + if (ownedFolderIds.length > 0) { + await db + .update(workspaceFileFolder) + .set({ deletedAt }) + .where( + and( + eq(workspaceFileFolder.workspaceId, args.workspaceId), + inArray(workspaceFileFolder.id, ownedFolderIds), + isNull(workspaceFileFolder.deletedAt) + ) + ) + } + + return { files: ownedFileIds.length, folders: ownedFolderIds.length } +} diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts new file mode 100644 index 00000000000..aebfb6baa8a --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -0,0 +1,57 @@ +import { db } from '@sim/db' +import { workflow, workflowFolder } from '@sim/db/schema' +import { and, asc, eq, isNull } from 'drizzle-orm' +import { + buildWorkflowAliasWorkflowEntries, + isPlanAliasPath, + resolveWorkflowAliasPath, + resolveWorkspacePlanAliasPath, + type WorkflowAliasTarget, +} from '@/lib/copilot/vfs/workflow-aliases' +import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' +import { canonicalizeVfsPath } from './path-utils' + +export async function resolveWorkflowAliasForWorkspace(args: { + workspaceId: string + path: string +}): Promise { + if (!isMothershipBetaFeaturesEnabled) return null + if (!isPlanAliasPath(args.path)) return null + + let canonicalPath: string + try { + canonicalPath = canonicalizeVfsPath(args.path) + } catch { + canonicalPath = args.path.trim().replace(/^\/+|\/+$/g, '') + } + + const workspacePlanAlias = resolveWorkspacePlanAliasPath(canonicalPath) + if (workspacePlanAlias) return workspacePlanAlias + + const [workflowRows, folderRows] = await Promise.all([ + db + .select({ + id: workflow.id, + name: workflow.name, + folderId: workflow.folderId, + }) + .from(workflow) + .where(and(eq(workflow.workspaceId, args.workspaceId), isNull(workflow.archivedAt))) + .orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)), + db + .select({ + folderId: workflowFolder.id, + folderName: workflowFolder.name, + parentId: workflowFolder.parentId, + }) + .from(workflowFolder) + .where( + and(eq(workflowFolder.workspaceId, args.workspaceId), isNull(workflowFolder.archivedAt)) + ) + .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)), + ]) + return resolveWorkflowAliasPath( + canonicalPath, + buildWorkflowAliasWorkflowEntries(workflowRows, folderRows) + ) +} diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts new file mode 100644 index 00000000000..33b07013584 --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-aliases.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { + buildWorkflowAliasWorkflowEntries, + isWorkflowAliasBackingPath, + resolveWorkflowAliasPath, + resolveWorkspacePlanAliasPath, + workflowChangelogBackingPath, + workspacePlanBackingPath, +} from './workflow-aliases' + +describe('workflow aliases', () => { + const folders = [ + { folderId: 'root-a', folderName: 'Folder A', parentId: null }, + { folderId: 'nested', folderName: 'Nested', parentId: 'root-a' }, + { folderId: 'root-b', folderName: 'Folder B', parentId: null }, + ] + + it('resolves root workspace plan aliases to workspace backing files', () => { + const alias = resolveWorkspacePlanAliasPath('.plans/root.md') + + expect(alias).toMatchObject({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: '.plans/root.md', + planRelativePath: 'root.md', + backingPath: workspacePlanBackingPath('root.md'), + }) + }) + + it('preserves nested root workspace plan paths in backing storage', () => { + const alias = resolveWorkspacePlanAliasPath('.plans/nested/phase-1.md') + + expect(alias).toMatchObject({ + kind: 'plan_file', + scope: 'workspace', + planRelativePath: 'nested/phase-1.md', + backingPath: 'files/.plans/workspace/nested/phase-1.md', + }) + }) + + it('rejects root plan directory paths as file aliases', () => { + expect(resolveWorkspacePlanAliasPath('.plans')).toMatchObject({ + kind: 'plans_dir', + scope: 'workspace', + }) + expect(resolveWorkspacePlanAliasPath('.plans/.folder')).toMatchObject({ + kind: 'plans_dir', + scope: 'workspace', + }) + expect(resolveWorkspacePlanAliasPath('.plans/links.json')).toBeNull() + }) + + it('resolves root workflow changelog aliases to workflow-id keyed backing files', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_123', name: 'Root Flow', folderId: null }], + [] + ) + + const alias = resolveWorkflowAliasPath('workflows/Root%20Flow/changelog.md', workflows) + + expect(alias).toMatchObject({ + kind: 'changelog', + workflowId: 'wf_123', + aliasPath: 'workflows/Root%20Flow/changelog.md', + backingPath: workflowChangelogBackingPath('wf_123'), + }) + }) + + it('resolves nested plan aliases using the workflow folder path', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_nested', name: 'Planner', folderId: 'nested' }], + folders + ) + + const alias = resolveWorkflowAliasPath( + 'workflows/Folder%20A/Nested/Planner/.plans/launch.md', + workflows + ) + + expect(alias).toMatchObject({ + kind: 'plan_file', + workflowId: 'wf_nested', + planRelativePath: 'launch.md', + backingPath: 'files/.plans/wf_nested/launch.md', + }) + }) + + it('keeps same-name workflows in different folders distinct', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [ + { id: 'wf_a', name: 'Duplicate', folderId: 'root-a' }, + { id: 'wf_b', name: 'Duplicate', folderId: 'root-b' }, + ], + folders + ) + + expect( + resolveWorkflowAliasPath('workflows/Folder%20A/Duplicate/changelog.md', workflows) + ).toMatchObject({ workflowId: 'wf_a', backingPath: 'files/.changelogs/wf_a.md' }) + expect( + resolveWorkflowAliasPath('workflows/Folder%20B/Duplicate/changelog.md', workflows) + ).toMatchObject({ workflowId: 'wf_b', backingPath: 'files/.changelogs/wf_b.md' }) + }) + + it('keeps backing paths stable across workflow rename', () => { + const before = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_stable', name: 'Old Name', folderId: null }], + [] + ) + const after = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_stable', name: 'New Name', folderId: null }], + [] + ) + + expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', before)?.backingPath).toBe( + 'files/.changelogs/wf_stable.md' + ) + expect(resolveWorkflowAliasPath('workflows/New%20Name/changelog.md', after)?.backingPath).toBe( + 'files/.changelogs/wf_stable.md' + ) + expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', after)).toBeNull() + }) + + it('rejects arbitrary workflow-local files and missing workflows', () => { + const workflows = buildWorkflowAliasWorkflowEntries( + [{ id: 'wf_123', name: 'Root Flow', folderId: null }], + [] + ) + + expect(resolveWorkflowAliasPath('workflows/Root%20Flow/random.md', workflows)).toBeNull() + expect(resolveWorkflowAliasPath('workflows/Missing/changelog.md', workflows)).toBeNull() + }) + + it('recognizes reserved backing paths after VFS segment canonicalization', () => { + expect(isWorkflowAliasBackingPath('files/.plans/wf_1/launch.md')).toBe(true) + expect(isWorkflowAliasBackingPath('files/%2Eplans/wf_1/launch.md')).toBe(true) + expect(isWorkflowAliasBackingPath('files/ordinary/launch.md')).toBe(false) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/workflow-aliases.ts b/apps/sim/lib/copilot/vfs/workflow-aliases.ts new file mode 100644 index 00000000000..2ad8e6aa0da --- /dev/null +++ b/apps/sim/lib/copilot/vfs/workflow-aliases.ts @@ -0,0 +1,360 @@ +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { + canonicalWorkspaceFilePath, + decodeVfsPathSegments, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export const WORKFLOW_CHANGELOG_ALIAS_NAME = 'changelog.md' +export const WORKFLOW_PLANS_ALIAS_DIR = '.plans' +export const WORKFLOW_ALIAS_LINKS_NAME = 'links.json' +export const WORKFLOW_CHANGELOG_BACKING_FOLDER = '.changelogs' +export const WORKFLOW_PLANS_BACKING_FOLDER = '.plans' +export const WORKSPACE_PLANS_BACKING_FOLDER = 'workspace' + +export type WorkflowAliasKind = 'changelog' | 'plan_file' | 'plans_dir' +export type WorkflowAliasScope = 'workspace' | 'workflow' + +export interface WorkflowAliasWorkflow { + id: string + name: string + folderPath?: string | null +} + +export interface WorkflowAliasWorkflowRow { + id: string + name: string + folderId?: string | null +} + +export interface WorkflowAliasFolderRow { + folderId: string + folderName: string + parentId: string | null +} + +interface BaseWorkflowAliasTarget { + kind: WorkflowAliasKind + scope: WorkflowAliasScope + aliasPath: string + backingPath: string + backingFolderPath: string + planRelativePath?: string +} + +export type WorkflowAliasTarget = + | (BaseWorkflowAliasTarget & { + kind: 'changelog' + scope: 'workflow' + workflowId: string + workflowName: string + workflowPath: string + }) + | (BaseWorkflowAliasTarget & { + kind: 'plans_dir' + scope: 'workflow' + workflowId: string + workflowName: string + workflowPath: string + }) + | (BaseWorkflowAliasTarget & { + kind: 'plan_file' + scope: 'workflow' + workflowId: string + workflowName: string + workflowPath: string + planRelativePath: string + }) + | (BaseWorkflowAliasTarget & { + kind: 'plans_dir' + scope: 'workspace' + }) + | (BaseWorkflowAliasTarget & { + kind: 'plan_file' + scope: 'workspace' + planRelativePath: string + }) + +export interface WorkflowAliasLink { + kind: WorkflowAliasKind + aliasPath: string + backingPath: string + backingFileId?: string +} + +export function workflowVfsPath(workflow: WorkflowAliasWorkflow): string { + const safeName = normalizeVfsSegment(workflow.name) + return workflow.folderPath + ? `workflows/${workflow.folderPath}/${safeName}` + : `workflows/${safeName}` +} + +export function buildWorkflowAliasWorkflowEntries( + workflows: WorkflowAliasWorkflowRow[], + folders: WorkflowAliasFolderRow[] +): WorkflowAliasWorkflow[] { + const folderMap = new Map() + for (const folder of folders) { + folderMap.set(folder.folderId, { name: folder.folderName, parentId: folder.parentId }) + } + + const folderPathCache = new Map() + const folderPath = (folderId: string): string => { + const cached = folderPathCache.get(folderId) + if (cached) return cached + + const folder = folderMap.get(folderId) + if (!folder) return '' + + const safeName = normalizeVfsSegment(folder.name) + const path = folder.parentId ? `${folderPath(folder.parentId)}/${safeName}` : safeName + folderPathCache.set(folderId, path) + return path + } + + return workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + folderPath: workflow.folderId ? folderPath(workflow.folderId) : null, + })) +} + +export function workflowChangelogBackingPath(workflowId: string): string { + return canonicalWorkspaceFilePath({ + folderPath: WORKFLOW_CHANGELOG_BACKING_FOLDER, + name: `${workflowId}.md`, + }) +} + +export function workflowPlansBackingFolderPath(workflowId: string): string { + return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(workflowId)}` +} + +export function workspacePlansBackingFolderPath(): string { + return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(WORKSPACE_PLANS_BACKING_FOLDER)}` +} + +export function workspacePlanBackingPath(planRelativePath: string): string { + const segments = decodeVfsPathSegments(planRelativePath) + if (segments.length === 0) { + throw new Error('Workspace plan alias must include a plan file path') + } + return canonicalWorkspaceFilePath({ + folderPath: [ + WORKFLOW_PLANS_BACKING_FOLDER, + WORKSPACE_PLANS_BACKING_FOLDER, + ...segments.slice(0, -1), + ].join('/'), + name: segments[segments.length - 1], + }) +} + +export function workflowPlanBackingPath(workflowId: string, planRelativePath: string): string { + const segments = decodeVfsPathSegments(planRelativePath) + if (segments.length === 0) { + throw new Error('Workflow plan alias must include a plan file path') + } + return canonicalWorkspaceFilePath({ + folderPath: [WORKFLOW_PLANS_BACKING_FOLDER, workflowId, ...segments.slice(0, -1)].join('/'), + name: segments[segments.length - 1], + }) +} + +function workflowAliasTargetForPath(workflow: WorkflowAliasWorkflow, rawPath: string) { + const workflowPath = workflowVfsPath(workflow) + const changelogPath = `${workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}` + if (rawPath === changelogPath) { + return { + kind: 'changelog' as const, + scope: 'workflow' as const, + workflowId: workflow.id, + workflowName: workflow.name, + workflowPath, + aliasPath: changelogPath, + backingPath: workflowChangelogBackingPath(workflow.id), + backingFolderPath: `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}`, + } + } + + const plansDirPath = `${workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}` + if (rawPath === plansDirPath || rawPath === `${plansDirPath}/.folder`) { + return { + kind: 'plans_dir' as const, + scope: 'workflow' as const, + workflowId: workflow.id, + workflowName: workflow.name, + workflowPath, + aliasPath: plansDirPath, + backingPath: workflowPlansBackingFolderPath(workflow.id), + backingFolderPath: workflowPlansBackingFolderPath(workflow.id), + } + } + + const plansPrefix = `${plansDirPath}/` + if (rawPath.startsWith(plansPrefix)) { + const planRelativePath = rawPath.slice(plansPrefix.length) + if (!planRelativePath || planRelativePath === '.folder') return null + return { + kind: 'plan_file' as const, + scope: 'workflow' as const, + workflowId: workflow.id, + workflowName: workflow.name, + workflowPath, + aliasPath: rawPath, + backingPath: workflowPlanBackingPath(workflow.id, planRelativePath), + backingFolderPath: workflowPlansBackingFolderPath(workflow.id), + planRelativePath, + } + } + + return null +} + +export function resolveWorkspacePlanAliasPath(path: string): WorkflowAliasTarget | null { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + if ( + normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || + normalizedPath === `${WORKFLOW_PLANS_ALIAS_DIR}/.folder` + ) { + return { + kind: 'plans_dir', + scope: 'workspace', + aliasPath: WORKFLOW_PLANS_ALIAS_DIR, + backingPath: workspacePlansBackingFolderPath(), + backingFolderPath: workspacePlansBackingFolderPath(), + } + } + + const plansPrefix = `${WORKFLOW_PLANS_ALIAS_DIR}/` + if (!normalizedPath.startsWith(plansPrefix)) return null + const planRelativePath = normalizedPath.slice(plansPrefix.length) + if ( + !planRelativePath || + planRelativePath === '.folder' || + planRelativePath === WORKFLOW_ALIAS_LINKS_NAME + ) { + return null + } + return { + kind: 'plan_file', + scope: 'workspace', + aliasPath: normalizedPath, + backingPath: workspacePlanBackingPath(planRelativePath), + backingFolderPath: workspacePlansBackingFolderPath(), + planRelativePath, + } +} + +export function resolveWorkflowAliasPath( + path: string, + workflows: WorkflowAliasWorkflow[] +): WorkflowAliasTarget | null { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + if (!normalizedPath.startsWith('workflows/')) return null + + const bySpecificity = [...workflows].sort( + (a, b) => workflowVfsPath(b).length - workflowVfsPath(a).length + ) + for (const workflow of bySpecificity) { + const target = workflowAliasTargetForPath(workflow, normalizedPath) + if (target) return target + } + return null +} + +export function isWorkflowAliasPath(path: string): boolean { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return ( + normalizedPath.startsWith('workflows/') && + (normalizedPath.endsWith(`/${WORKFLOW_CHANGELOG_ALIAS_NAME}`) || + normalizedPath.includes(`/${WORKFLOW_PLANS_ALIAS_DIR}/`) || + normalizedPath.endsWith(`/${WORKFLOW_PLANS_ALIAS_DIR}`)) + ) +} + +export function isWorkspacePlanAliasPath(path: string): boolean { + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return ( + normalizedPath === WORKFLOW_PLANS_ALIAS_DIR || + normalizedPath.startsWith(`${WORKFLOW_PLANS_ALIAS_DIR}/`) + ) +} + +export function isPlanAliasPath(path: string): boolean { + return isWorkspacePlanAliasPath(path) || isWorkflowAliasPath(path) +} + +export function isWorkflowAliasBackingPath(path: string): boolean { + const trimmedPath = path.trim().replace(/^\/+|\/+$/g, '') + let normalizedPath = trimmedPath + if (trimmedPath.startsWith('files/')) { + try { + normalizedPath = `files/${decodeVfsPathSegments(trimmedPath.slice('files/'.length)) + .map((segment) => normalizeVfsSegment(segment)) + .join('/')}` + } catch { + normalizedPath = trimmedPath + } + } + return ( + normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}` || + normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}` || + normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}/`) || + normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/`) + ) +} + +export function isReservedWorkflowAliasBackingDisplayPath(path?: string | null): boolean { + if (!path) return false + const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '') + return ( + normalizedPath === WORKFLOW_CHANGELOG_BACKING_FOLDER || + normalizedPath === WORKFLOW_PLANS_BACKING_FOLDER || + normalizedPath.startsWith(`${WORKFLOW_CHANGELOG_BACKING_FOLDER}/`) || + normalizedPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/`) + ) +} + +export function workflowAliasSandboxPath(aliasPath: string): string { + return `/home/user/${aliasPath.trim().replace(/^\/+/, '')}` +} + +export function buildWorkflowAliasLinks(args: { + workflowPath: string + workflowId: string + changelog?: WorkspaceFileRecord | null + planFiles?: WorkspaceFileRecord[] +}): WorkflowAliasLink[] { + const links: WorkflowAliasLink[] = [ + { + kind: 'changelog', + aliasPath: `${args.workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}`, + backingPath: workflowChangelogBackingPath(args.workflowId), + backingFileId: args.changelog?.id, + }, + { + kind: 'plans_dir', + aliasPath: `${args.workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}`, + backingPath: workflowPlansBackingFolderPath(args.workflowId), + }, + ] + + for (const file of args.planFiles ?? []) { + const relativePath = file.folderPath + ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}`, '') + .replace(/^\/+/, '') + const aliasRelativePath = encodeVfsPathSegments( + [relativePath, file.name].filter(Boolean).join('/').split('/') + ) + const aliasPath = [args.workflowPath, WORKFLOW_PLANS_ALIAS_DIR, aliasRelativePath].join('/') + links.push({ + kind: 'plan_file', + aliasPath, + backingPath: canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }), + backingFileId: file.id, + }) + } + + return links +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 20d67d90941..b3a12fc6713 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -18,12 +18,35 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, isNotNull, isNull, ne, sql } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' -import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/chat/workspace-context' +import { + buildWorkspaceContextMd, + buildWorkspaceMd, + type WorkspaceMdData, +} from '@/lib/copilot/chat/workspace-context' +import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' +import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' +import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract' +import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' +import { isRenderableDocExt, renderDocToGrid } from '@/lib/copilot/tools/server/files/doc-render' +import { + collectWorkflowFieldIssues, + lintEditedWorkflowState, +} from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' +import { + collectUnresolvedReferences, + UNRESOLVABLE_AT_LINT_NOTE, +} from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import type { DirEntry, GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' +import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' +import { + buildVfsFolderPathMap, + canonicalWorkflowVfsDir, + canonicalWorkspaceFilePath, + encodeVfsPathSegments, +} from '@/lib/copilot/vfs/path-utils' import type { DeploymentData } from '@/lib/copilot/vfs/serializers' import { serializeApiKeys, @@ -52,6 +75,20 @@ import { serializeVersions, serializeWorkflowMeta, } from '@/lib/copilot/vfs/serializers' +import { ensureWorkflowAliasBackingQuietly } from '@/lib/copilot/vfs/workflow-alias-backing' +import { + buildWorkflowAliasLinks, + isWorkflowAliasBackingPath, + WORKFLOW_ALIAS_LINKS_NAME, + WORKFLOW_CHANGELOG_ALIAS_NAME, + WORKFLOW_PLANS_ALIAS_DIR, + WORKFLOW_PLANS_BACKING_FOLDER, + WORKSPACE_PLANS_BACKING_FOLDER, + workflowChangelogBackingPath, + workspacePlanBackingPath, + workspacePlansBackingFolderPath, +} from '@/lib/copilot/vfs/workflow-aliases' +import { isE2BDocEnabled, isMothershipBetaFeaturesEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -66,12 +103,14 @@ import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/works import { fetchWorkspaceFileBuffer, findWorkspaceFileRecord, - getWorkspaceFile, listWorkspaceFiles, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + loadWorkflowDeploymentSnapshot, + loadWorkflowFromNormalizedTables, +} from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { listSkills } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' @@ -80,17 +119,34 @@ import { getUsersWithPermissions, getWorkspaceWithOwner, } from '@/lib/workspaces/permissions/utils' +import { computeNeedsRedeployment } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' import { CONNECTOR_REGISTRY } from '@/connectors/registry' -import { tools as toolRegistry } from '@/tools/registry' -import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' import { TRIGGER_REGISTRY } from '@/triggers/registry' const logger = createLogger('WorkspaceVFS') +const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 /** Static component files, computed once and shared across all VFS instances */ let staticComponentFiles: Map | null = null +// On-the-fly doc reads (render/extract) download the binary into the Sim process +// and base64-stage it to E2B, so bound the input like the compile path's staging +// caps — otherwise an authenticated member could OOM the worker with a multi-GB +// upload (uploads are capped at 5GB). +const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024 + +/** + * True when the buffer is an actual compiled/uploaded binary (vs a source-backed + * generated doc). OOXML (pptx/docx/xlsx) is a ZIP (starts `PK`); PDFs may carry a + * BOM or leading whitespace before `%PDF`, so scan the head rather than offset 0. + */ +function isBinaryDocBuffer(buffer: Buffer, ext: string): boolean { + if (ext === 'pdf') return buffer.subarray(0, 1024).toString('latin1').includes('%PDF') + return buffer.subarray(0, 2).toString('latin1') === 'PK' +} + /** * Build the static component files from block and tool registries. * This only needs to happen once per process. @@ -115,32 +171,15 @@ function getStaticComponentFiles(): Map { } blocksFiltered = allBlocks.length - visibleBlocks.length - const toolToService = new Map() - for (const block of visibleBlocks) { - if (!block.tools?.access) continue - const service = stripVersionSuffix(block.type) - for (const toolId of block.tools.access) { - toolToService.set(toolId, service) - } - } - - const latestTools = getLatestVersionTools(toolRegistry) let integrationCount = 0 const oauthServices = new Map() const apiKeyServices = new Map() - for (const [toolId, tool] of Object.entries(latestTools)) { - const baseName = stripVersionSuffix(toolId) - const service = toolToService.get(toolId) ?? toolToService.get(baseName) - if (!service) { - logger.debug('Tool not associated with any block, skipping VFS entry', { toolId }) - continue - } - - const prefix = `${service}_` - const operation = baseName.startsWith(prefix) ? baseName.slice(prefix.length) : baseName - + // Integration tools come from the shared exposed-tool set (latest version of + // each operation owned by a visible block), the same set used to build the + // deferred callable tools — so discovery and execution can never drift. + for (const { config: tool, service, operation } of getExposedIntegrationTools()) { const path = `components/integrations/${service}/${operation}.json` files.set(path, serializeIntegrationSchema(tool)) integrationCount++ @@ -201,9 +240,9 @@ function getStaticComponentFiles(): Map { description: 'Condition expression (for loopType "while" or "doWhile")', }, }, - sourceHandles: ['loop-start-source', 'source'], + sourceHandles: ['loop-start-source', 'loop-end-source'], notes: - 'Use "loop-start-source" to connect to blocks inside the loop. Use "source" for the edge that runs after the loop completes. Blocks inside the loop must have parentId set to the loop block ID.', + 'Use "loop-start-source" to connect to blocks INSIDE the loop. Use "loop-end-source" for the edge that runs AFTER the loop completes. Do NOT use "source" for a loop block — it is rejected; the only valid source handles are "loop-start-source", "loop-end-source", and "error". Blocks inside the loop must have parentId set to the loop block ID.', }, null, 2 @@ -232,9 +271,9 @@ function getStaticComponentFiles(): Map { description: 'Collection to distribute (for parallelType "collection")', }, }, - sourceHandles: ['parallel-start-source', 'source'], + sourceHandles: ['parallel-start-source', 'parallel-end-source'], notes: - 'Use "parallel-start-source" to connect to blocks inside the parallel container. Use "source" for the edge after all branches complete. Blocks inside must have parentId set to the parallel block ID.', + 'Use "parallel-start-source" to connect to blocks INSIDE the parallel container. Use "parallel-end-source" for the edge AFTER all branches complete. Do NOT use "source" for a parallel block — it is rejected; the only valid source handles are "parallel-start-source", "parallel-end-source", and "error". Blocks inside must have parentId set to the parallel block ID.', }, null, 2 @@ -304,9 +343,11 @@ function getStaticComponentFiles(): Map { * Virtual Filesystem that materializes workspace data into an in-memory Map. * * Structure: - * WORKSPACE.md — workspace identity, members, inventory (auto-generated) + * WORKSPACE_CONTEXT.md — full dynamic workspace/user context (auto-generated) + * WORKSPACE.md — workspace inventory summary (auto-generated) * workflows/{name}/meta.json (root-level workflows) * workflows/{name}/state.json (sanitized blocks with embedded connections) + * workflows/{name}/lint.json (sources/sinks, required-field, credential/resource issues) * workflows/{name}/executions.json * workflows/{name}/deployment.json * workflows/{folder}/{name}/... (workflows inside folders, nested folders supported) @@ -314,10 +355,9 @@ function getStaticComponentFiles(): Map { * knowledgebases/{name}/documents.json * knowledgebases/{name}/connectors.json * tables/{name}/meta.json - * files/{name}/meta.json - * files/by-id/{id}/meta.json - * files/by-id/{id}/style (dynamic — style extraction for .docx/.pptx/.pdf) - * files/by-id/{id}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?}) + * files/{name} (workspace file leaf; dynamic content on read) + * files/{path}/{name}/style (dynamic — style extraction for .docx/.pptx/.pdf) + * files/{path}/{name}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?}) * jobs/{title}/meta.json * jobs/{title}/history.json * jobs/{title}/executions.json @@ -381,24 +421,24 @@ export class WorkspaceVFS { getUsersWithPermissions(workspaceId), ]) - this.files.set( - 'WORKSPACE.md', - buildWorkspaceMd({ - workspace: wsRow, - members, - workflows: wfSummary, - knowledgeBases: kbSummary, - tables: tblSummary, - files: fileSummary, - oauthIntegrations: envSummary.oauthIntegrations, - envVariables: envSummary.envVariables, - tasks: taskSummary, - customTools: toolsSummary, - mcpServers: mcpServersSummary, - skills: skillsSummary, - jobs: jobsSummary, - }) - ) + const workspaceMdData = { + workspace: wsRow, + members, + workflows: wfSummary, + knowledgeBases: kbSummary, + tables: tblSummary, + files: fileSummary, + oauthIntegrations: envSummary.oauthIntegrations, + envVariables: envSummary.envVariables, + tasks: taskSummary, + customTools: toolsSummary, + mcpServers: mcpServersSummary, + skills: skillsSummary, + jobs: jobsSummary, + } + + this.files.set('WORKSPACE.md', buildWorkspaceMd(workspaceMdData)) + this.files.set('WORKSPACE_CONTEXT.md', buildWorkspaceContextMd(workspaceMdData)) await this.materializeRecentlyDeleted(workspaceId, userId) @@ -436,6 +476,56 @@ export class WorkspaceVFS { return ops.grep(this.filesForPath(path), pattern, path, options) } + /** + * Grep the *content* of a single workspace file (under `files/`), as opposed to + * {@link grep} which searches the in-memory VFS map (workflow JSON, metadata, + * plans, memories — workspace files appear there only as metadata). + * + * Content search applies to workspace files only and must target exactly one + * file (`files/` or `files//content`, plus the `recently-deleted/` + * variants). A folder, the whole `files/` tree, or any path that does not + * resolve to a single file leaf throws — grepping multiple workspace files at + * once is intentionally unsupported. + * + * Per file type the file's text is resolved via {@link readFileContent} (the + * same extraction `read` uses): text-like files are read as UTF-8, parseable + * documents (pdf/docx/xlsx/pptx/…) are parsed to text, and the regex runs over + * that text. Images and binary files have no searchable text and throw, as do + * files too large for the inline read cap. Reading exactly one file (bounded by + * the existing per-type read caps) keeps this from loading the workspace into + * memory. + */ + async grepFile( + path: string, + pattern: string, + options?: GrepOptions + ): Promise { + const normalized = path.replace(/^\/+/, '') + // Prefer the path verbatim when it is itself a file leaf (e.g. a file literally + // named "content"); otherwise drop a trailing "/content" read suffix. + const leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '') + + const isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) + if (!isWorkspaceFilePath || !this.files.has(leaf)) { + const suggestions = this.suggestSimilar(leaf) + const hint = + suggestions.length > 0 + ? ` Did you mean: ${suggestions.join(', ')}?` + : ' Use glob to find the exact file path, then grep that single file.' + throw new ops.WorkspaceFileGrepError( + `Grep over workspace file content must target a single workspace file (e.g. path: "files/report.csv"). "${path}" is not a single workspace file.${hint}` + ) + } + + const contentPath = `${leaf}/content` + const result = await this.readFileContent(contentPath) + if (!result) { + throw new ops.WorkspaceFileGrepError(`Workspace file content not found for "${path}".`) + } + + return ops.grepReadResult(leaf, result, pattern, contentPath, options) + } + glob(pattern: string): string[] { const target = pattern.startsWith('recently-deleted') ? this.files : this.activeFiles() return ops.glob(target, pattern) @@ -445,34 +535,317 @@ export class WorkspaceVFS { return ops.read(this.files, path, offset, limit) } - list(path: string): DirEntry[] { - return ops.list(this.filesForPath(path), path) - } - suggestSimilar(missingPath: string, max?: number): string[] { return ops.suggestSimilar(this.files, missingPath, max) } + private async resolveWorkspaceFileForDynamicRead( + path: string, + suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' + ): Promise { + if (!isMothershipBetaFeaturesEnabled && isWorkflowAliasBackingPath(path)) { + return null + } + const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) + if (!canonicalMatch?.[1]) return null + + const files = await listWorkspaceFiles(this._workspaceId, { includeReservedSystemFiles: true }) + return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) + } + + /** + * Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and + * returns it as a model readable JPEG attachment. Shared by the `/render` and + * `/compiled` reads so a binary doc is NEVER attached as a raw (non-PDF) + * `document` block — the model only reads images and application/pdf. Compiles + * the source first when needed (E2B doc sandbox, else isolated-vm); uses the + * binary directly for already-binary uploads. Throws on compile/render failure + * (the caller's try/catch reports it). + */ + private async renderDocRecordResult( + record: WorkspaceFileRecord, + ext: string, + buildMessage: (pageCount: number) => string + ): Promise { + if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File is too large to render' }), + totalLines: 1, + } + } + const buffer = await fetchWorkspaceFileBuffer(record) + if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File is too large to render' }), + totalLines: 1, + } + } + // Already-binary uploads render directly; source files are compiled first + // (E2B regime -> doc sandbox: Node pptx/docx, Python pdf; otherwise + // isolated-vm pptxgenjs/docx-js/pdf-lib). + let bin: Buffer + if (isBinaryDocBuffer(buffer, ext)) { + bin = buffer + } else { + const code = buffer.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }), + totalLines: 1, + } + } + if (isE2BDocEnabled && getE2BDocFormat(record.name)) { + bin = ( + await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) + ).buffer + } else { + const taskId = BINARY_DOC_TASKS[ext] + if (!taskId) { + return { + content: JSON.stringify({ ok: false, error: 'Cannot render this file' }), + totalLines: 1, + } + } + bin = await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + } + } + const { grid, pageCount } = await renderDocToGrid({ + binary: bin, + ext, + workspaceId: this._workspaceId, + }) + return { + content: buildMessage(pageCount), + totalLines: 1, + attachment: { + // The rendered contact sheet is a JPEG, so it must be an image block. + // Tagging it 'file' routes it to a provider document block, which only + // accepts application/pdf — Anthropic rejects image/jpeg there with a + // 400 that surfaces to the client as a "Stream error". + type: 'image', + name: `${record.name}.render.jpg`, + source: { type: 'base64', media_type: 'image/jpeg', data: grid.toString('base64') }, + }, + } + } + /** * Attempt to read dynamic workspace file content from storage. - * Handles images (base64), parseable documents (PDF, etc.), and text files. + * Handles explicit /content reads for images, PDFs, documents, and text files. * Also handles: - * `files/by-id/{id}/style` — style extraction (.docx / .pptx / .pdf) - * `files/by-id/{id}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams - * Returns null if the path doesn't match `files/{name}` / `files/by-id/{id}` or the file isn't found. + * `files/{path}/{name}/style` — style extraction (.docx / .pptx / .pdf) + * `files/{path}/{name}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams + * `files/{path}/{name}/compiled` — compile JS-source binary files and return the compiled artifact as an attachment + * Files are resolved by their sanitized canonical path only. + * Returns null if the path doesn't match a dynamic file path or the file isn't found. */ async readFileContent(path: string): Promise { - // Handle compiled-check path: files/by-id/{id}/compiled-check - const compiledCheckMatch = path.match(/^files\/by-id\/([^/]+)\/compiled-check$/) + const compiledMatch = /^files\/.+\/compiled$/.test(path) + if (compiledMatch) { + let record: WorkspaceFileRecord | null = null + try { + record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') + if (!record) return null + const ext = record.name.split('.').pop()?.toLowerCase() ?? '' + const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(record.name) : null + const taskId = BINARY_DOC_TASKS[ext] + if (!e2bFmt && !taskId) return null + + // Only PDF can be attached as a model-readable `document` block — + // Bedrock/Anthropic document blocks accept application/pdf ONLY. Attaching + // raw pptx/docx/xlsx binary is rejected by the provider (400). So for + // pptx/docx, render to page images (which the model CAN read) and return + // those directly — /compiled can never emit an invalid document block for + // these formats. xlsx isn't renderable; direct to /extract for its content. + if (ext !== 'pdf') { + if (isRenderableDocExt(ext)) { + const compiledName = record.name + return await this.renderDocRecordResult( + record, + ext, + (pageCount) => + `${compiledName}: the raw ${ext.toUpperCase()} binary isn't model-readable, so it was rendered to ${pageCount} page image(s) for inspection.` + ) + } + const extractPath = `${canonicalWorkspaceFilePath({ + folderPath: record.folderPath, + name: record.name, + })}/extract` + return { + content: `${record.name} is a spreadsheet — read "${extractPath}" for its contents.`, + totalLines: 1, + } + } + + const buffer = await fetchWorkspaceFileBuffer(record) + const code = buffer.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }), + totalLines: 1, + } + } + const compiled = e2bFmt + ? ( + await compileDoc({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + }) + ).buffer + : await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + if (compiled.length > MAX_COMPILED_ATTACHMENT_BYTES) { + return { + content: `[Compiled artifact too large: ${record.name} (${compiled.length} bytes, limit ${MAX_COMPILED_ATTACHMENT_BYTES})]`, + totalLines: 1, + } + } + return { + content: `Compiled file: ${record.name} (${compiled.length} bytes, application/pdf)`, + totalLines: 1, + attachment: { + type: 'file', + name: record.name, + source: { + type: 'base64', + media_type: 'application/pdf', + data: compiled.toString('base64'), + }, + }, + } + } catch (err) { + logger.warn('Compiled artifact read failed via VFS', { + workspaceId: this._workspaceId, + path, + fileId: record?.id, + error: toError(err).message, + }) + if (err instanceof SandboxUserCodeError) { + const json = JSON.stringify({ + ok: false, + error: toError(err).message, + errorName: err.name, + }) + return { content: json, totalLines: 1 } + } + return null + } + } + + const renderMatch = /^files\/.+\/render$/.test(path) + if (renderMatch) { + let record: WorkspaceFileRecord | null = null + try { + record = await this.resolveWorkspaceFileForDynamicRead(path, 'render') + if (!record) return null + const ext = record.name.split('.').pop()?.toLowerCase() ?? '' + if (!isRenderableDocExt(ext)) { + return { + content: JSON.stringify({ + ok: false, + error: 'Render supports .pptx, .docx, and .pdf only', + }), + totalLines: 1, + } + } + const renderName = record.name + return await this.renderDocRecordResult( + record, + ext, + (pageCount) => + `Rendered ${pageCount} page(s) of ${renderName} as a contact-sheet grid for visual QA. Inspect each page for text overflow/cutoff, overlapping elements, low contrast, misalignment, and leftover placeholder text; fix and re-render until clean.` + ) + } catch (err) { + logger.warn('Render read failed via VFS', { + workspaceId: this._workspaceId, + path, + fileId: record?.id, + error: toError(err).message, + }) + // Return an explicit error (not null) once the file resolved — a null read + // looks like a missing path and sends the agent hunting for the "correct" + // render path instead of surfacing the real compile/render failure. + return { + content: JSON.stringify({ ok: false, error: toError(err).message }), + totalLines: 1, + } + } + } + + const extractMatch = /^files\/.+\/extract$/.test(path) + if (extractMatch && isE2BDocEnabled) { + let record: WorkspaceFileRecord | null = null + try { + record = await this.resolveWorkspaceFileForDynamicRead(path, 'extract') + if (!record) return null + const ext = record.name.split('.').pop()?.toLowerCase() ?? '' + if (!isExtractableDocExt(ext)) { + return { + content: JSON.stringify({ + ok: false, + error: 'Extraction supports .pdf, .pptx, .docx, and .xlsx only', + }), + totalLines: 1, + } + } + // Bound the input before downloading + base64-staging it in-process. + if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), + totalLines: 1, + } + } + const buffer = await fetchWorkspaceFileBuffer(record) + if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { + return { + content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), + totalLines: 1, + } + } + // Extraction reads the binary. A source-backed generated doc (text source, + // no binary magic) should be read directly instead — point the agent there. + if (!isBinaryDocBuffer(buffer, ext)) { + return { + content: JSON.stringify({ + ok: false, + error: 'This is a source-backed generated file; read its content directly instead.', + }), + totalLines: 1, + } + } + const { text, truncated } = await extractDocText({ binary: buffer, ext }) + const note = truncated + ? '\n\n[... truncated — read the file directly for the full content]' + : '' + return { + content: `${text || '[no extractable text found]'}${note}`, + totalLines: 1, + } + } catch (err) { + logger.warn('Extract read failed via VFS', { + workspaceId: this._workspaceId, + path, + fileId: record?.id, + error: toError(err).message, + }) + return { + content: JSON.stringify({ ok: false, error: toError(err).message }), + totalLines: 1, + } + } + } + + const compiledCheckMatch = /^files\/.+\/compiled-check$/.test(path) if (compiledCheckMatch) { - const fileId = compiledCheckMatch[1] + let record: WorkspaceFileRecord | null = null try { - const record = await getWorkspaceFile(this._workspaceId, fileId) + record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' + const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - if (!taskId && !isMermaidFile) return null + if (!e2bFmt && !taskId && !isMermaidFile) return null const buffer = await fetchWorkspaceFileBuffer(record) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { @@ -487,15 +860,27 @@ export class WorkspaceVFS { return { content: json, totalLines: 1 } } let result: { ok: boolean; error?: string; errorName?: string } - try { - if (!taskId) return null - await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) - result = { ok: true } - } catch (err) { - if (err instanceof SandboxUserCodeError) { - result = { ok: false, error: toError(err).message, errorName: err.name } - } else { - throw err + if (e2bFmt) { + // Loads the artifact if present, else compiles once (and recalc-scans + // xlsx). Only a script error is { ok: false }; infra failures rethrow to + // the outer catch so an E2B/S3 outage isn't reported as a bad script. + result = await runE2BCompiledCheck({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + ext, + }) + } else { + try { + if (!taskId) return null + await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) + result = { ok: true } + } catch (err) { + if (err instanceof SandboxUserCodeError) { + result = { ok: false, error: toError(err).message, errorName: err.name } + } else { + throw err + } } } const json = JSON.stringify(result) @@ -503,19 +888,19 @@ export class WorkspaceVFS { } catch (err) { logger.warn('Compiled check failed via VFS', { workspaceId: this._workspaceId, - fileId, + path, + fileId: record?.id, error: toError(err).message, }) return null } } - // Handle style extraction path: files/by-id/{id}/style - const styleMatch = path.match(/^files\/by-id\/([^/]+)\/style$/) + const styleMatch = /^files\/.+\/style$/.test(path) if (styleMatch) { - const fileId = styleMatch[1] + let record: WorkspaceFileRecord | null = null try { - const record = await getWorkspaceFile(this._workspaceId, fileId) + record = await this.resolveWorkspaceFileForDynamicRead(path, 'style') if (!record) return null const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null @@ -528,15 +913,16 @@ export class WorkspaceVFS { } catch (err) { logger.warn('Failed to extract document style via VFS', { workspaceId: this._workspaceId, - fileId, + path, + fileId: record?.id, error: toError(err).message, }) return null } } - const deletedMatch = path.match(/^recently-deleted\/files\/(.+?)(?:\/content)?$/) - const activeMatch = path.match(/^files\/(.+?)(?:\/content)?$/) + const deletedMatch = path.match(/^recently-deleted\/files\/(.+)\/content$/) + const activeMatch = path.match(/^files\/(.+)\/content$/) const match = deletedMatch || activeMatch if (!match) return null const fileReference = path @@ -544,12 +930,18 @@ export class WorkspaceVFS { .replace(/\/content$/, '') .replace(/^\/+/, '') + if (!isMothershipBetaFeaturesEnabled && isWorkflowAliasBackingPath(fileReference)) { + return null + } if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { scope }) + const files = await listWorkspaceFiles(this._workspaceId, { + scope, + includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null return readFileRecord(record) @@ -570,28 +962,7 @@ export class WorkspaceVFS { private buildFolderPaths( folders: Array<{ folderId: string; folderName: string; parentId: string | null }> ): Map { - const folderMap = new Map() - for (const f of folders) { - folderMap.set(f.folderId, { name: f.folderName, parentId: f.parentId }) - } - - const cache = new Map() - const resolve = (id: string): string => { - if (cache.has(id)) return cache.get(id)! - const folder = folderMap.get(id) - if (!folder) return '' - const parentPath = folder.parentId ? resolve(folder.parentId) : '' - const path = parentPath - ? `${parentPath}/${sanitizeName(folder.name)}` - : sanitizeName(folder.name) - cache.set(id, path) - return path - } - - for (const id of folderMap.keys()) { - resolve(id) - } - return cache + return buildVfsFolderPathMap(folders) } /** @@ -603,8 +974,9 @@ export class WorkspaceVFS { */ private async materializeWorkflows( workspaceId: string, - _userId: string + userId: string ): Promise { + const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled const [workflowRows, folderRows] = await Promise.all([ listWorkflows(workspaceId), listFolders(workspaceId), @@ -612,6 +984,22 @@ export class WorkspaceVFS { const folderPaths = this.buildFolderPaths(folderRows) + if (workflowArtifactsEnabled) { + await Promise.all( + workflowRows.map((wf) => + ensureWorkflowAliasBackingQuietly({ + workspaceId, + userId, + workflowId: wf.id, + workflowName: wf.name, + }) + ) + ) + } + const workspaceFiles = workflowArtifactsEnabled + ? await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) + : [] + // Register all folders in the VFS so empty folders are discoverable. for (const { folderId } of folderRows) { const folderPath = folderPaths.get(folderId) @@ -622,14 +1010,80 @@ export class WorkspaceVFS { await Promise.all( workflowRows.map(async (wf) => { - const safeName = sanitizeName(wf.name) const folderPath = wf.folderId ? folderPaths.get(wf.folderId) : null - const prefix = folderPath - ? `workflows/${folderPath}/${safeName}/` - : `workflows/${safeName}/` + const prefix = `${canonicalWorkflowVfsDir({ name: wf.name, folderPath })}/` + const workflowPath = prefix.replace(/\/$/, '') this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf)) + if (workflowArtifactsEnabled) { + const changelog = findWorkspaceFileRecord( + workspaceFiles, + workflowChangelogBackingPath(wf.id) + ) + let changelogContent = '' + if (changelog) { + try { + changelogContent = (await readFileRecord(changelog))?.content ?? '' + } catch (err) { + logger.warn('Failed to read workflow changelog alias backing file', { + workspaceId, + workflowId: wf.id, + fileId: changelog.id, + error: toError(err).message, + }) + } + } + if (changelog) { + this.files.set(`${prefix}${WORKFLOW_CHANGELOG_ALIAS_NAME}`, changelogContent) + } + this.files.set(`${prefix}${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') + + const planFiles = workspaceFiles.filter((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}` || + file.folderPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}/`) + ) + }) + for (const planFile of planFiles) { + const relativeFolder = planFile.folderPath + ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}`, '') + .replace(/^\/+/, '') + const aliasPlanPath = [ + prefix, + `${WORKFLOW_PLANS_ALIAS_DIR}/`, + relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', + normalizeVfsSegment(planFile.name), + ].join('') + try { + this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') + } catch (err) { + logger.warn('Failed to read workflow plan alias backing file', { + workspaceId, + workflowId: wf.id, + fileId: planFile.id, + error: toError(err).message, + }) + } + } + this.files.set( + `${prefix}${WORKFLOW_ALIAS_LINKS_NAME}`, + JSON.stringify( + { + aliases: buildWorkflowAliasLinks({ + workflowPath, + workflowId: wf.id, + changelog, + planFiles, + }), + }, + null, + 2 + ) + ) + } + let normalized: Awaited> = null try { normalized = await loadWorkflowFromNormalizedTables(wf.id) @@ -641,6 +1095,61 @@ export class WorkspaceVFS { parallels: normalized.parallels, } as any) this.files.set(`${prefix}state.json`, JSON.stringify(sanitized, null, 2)) + + // Dynamically-computed validation state (lint.json), derived from + // the raw normalized state so subBlock values, advancedMode, + // canonicalModes, and subflow edges are all available. + try { + const graphLint = lintEditedWorkflowState(normalized as any) + const fieldIssues = collectWorkflowFieldIssues(normalized.blocks as any) + let unresolvedReferences: Awaited> = [] + try { + unresolvedReferences = await collectUnresolvedReferences(normalized as any, { + userId, + workspaceId, + }) + } catch (resolveErr) { + // Tier-2 resolution is best-effort; degrade to graph + config lint. + logger.warn('Failed to resolve workflow references for lint.json', { + workflowId: wf.id, + error: toError(resolveErr).message, + }) + } + + this.files.set( + `${prefix}lint.json`, + JSON.stringify( + { + ...graphLint, + fieldIssues, + unresolvedReferences, + notes: [UNRESOLVABLE_AT_LINT_NOTE], + }, + null, + 2 + ) + ) + } catch (lintErr) { + logger.warn('Failed to compute lint.json', { + workflowId: wf.id, + error: toError(lintErr).message, + }) + } + } else { + // loadWorkflowFromNormalizedTables returns null when the workflow has + // zero block rows. A block-less workflow still exists and must be + // readable, so emit an empty-but-valid state.json — otherwise + // read("workflows/{path}/state.json") 404s and suggestSimilar points the + // agent at a different, same-named workflow. dag/lint are derived from + // blocks and are omitted for the empty case. + this.files.set( + `${prefix}state.json`, + JSON.stringify( + sanitizeForCopilot({ blocks: {}, edges: [], loops: {}, parallels: {} } as any), + null, + 2 + ) + ) } } catch (err) { logger.warn('Failed to load workflow state', { @@ -680,8 +1189,7 @@ export class WorkspaceVFS { wf.id, workspaceId, wf.isDeployed, - wf.deployedAt, - normalized + wf.deployedAt ) if (deploymentData) { this.files.set(`${prefix}deployment.json`, serializeDeployments(deploymentData)) @@ -862,50 +1370,127 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const files = await listWorkspaceFiles(workspaceId) + const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled + const folders = await listWorkspaceFileFolders(workspaceId, { + includeReservedSystemFolders: true, + }) + const files = await listWorkspaceFiles(workspaceId, { + folders, + includeReservedSystemFiles: true, + }) + for (const folder of folders) { + if ( + !workflowArtifactsEnabled && + isWorkflowAliasBackingPath(`files/${encodeVfsPathSegments(folder.path.split('/'))}`) + ) { + continue + } + this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') + } for (const file of files) { - const safeName = sanitizeName(file.name) - const safeFolderPath = file.folderPath - ?.split('/') - .map((segment) => sanitizeName(segment)) - .join('/') - const fileVfsPath = safeFolderPath ? `${safeFolderPath}/${safeName}` : safeName + const filePath = canonicalWorkspaceFilePath({ + folderPath: file.folderPath, + name: file.name, + }) + if (!workflowArtifactsEnabled && isWorkflowAliasBackingPath(filePath)) { + continue + } this.files.set( - `files/${fileVfsPath}/meta.json`, + filePath, serializeFileMeta({ id: file.id, name: file.name, folderId: file.folderId, folderPath: file.folderPath, - vfsPath: `files/${fileVfsPath}`, + vfsPath: filePath, contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, }) ) + } + + if (workflowArtifactsEnabled) { + this.files.set(`${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '') + const workspacePlanFiles = files.filter((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === + `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}` || + file.folderPath.startsWith( + `${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}/` + ) + ) + }) + const workspacePlanLinks = [] + for (const planFile of workspacePlanFiles) { + const relativeFolder = planFile.folderPath + ?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}`, '') + .replace(/^\/+/, '') + const aliasRelativePath = [ + relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '', + normalizeVfsSegment(planFile.name), + ].join('') + const aliasPlanPath = `${WORKFLOW_PLANS_ALIAS_DIR}/${aliasRelativePath}` + const relativeSegments = aliasRelativePath.split('/').slice(0, -1) + for (let index = 0; index < relativeSegments.length; index++) { + this.files.set( + `${WORKFLOW_PLANS_ALIAS_DIR}/${relativeSegments.slice(0, index + 1).join('/')}/.folder`, + '' + ) + } + try { + this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '') + workspacePlanLinks.push({ + kind: 'plan_file', + scope: 'workspace', + aliasPath: aliasPlanPath, + backingPath: workspacePlanBackingPath(aliasRelativePath), + backingFileId: planFile.id, + }) + } catch (err) { + logger.warn('Failed to read workspace plan alias backing file', { + workspaceId, + fileId: planFile.id, + error: toError(err).message, + }) + } + } this.files.set( - `files/by-id/${file.id}/meta.json`, - serializeFileMeta({ - id: file.id, - name: file.name, - folderId: file.folderId, - folderPath: file.folderPath, - vfsPath: `files/${fileVfsPath}`, - contentType: file.type, - size: file.size, - uploadedAt: file.uploadedAt, - }) + `${WORKFLOW_PLANS_ALIAS_DIR}/${WORKFLOW_ALIAS_LINKS_NAME}`, + JSON.stringify( + { + aliases: [ + { + kind: 'plans_dir', + scope: 'workspace', + aliasPath: WORKFLOW_PLANS_ALIAS_DIR, + backingPath: workspacePlansBackingFolderPath(), + }, + ...workspacePlanLinks, + ], + }, + null, + 2 + ) ) } - return files.map((f) => ({ - id: f.id, - name: f.name, - type: f.type, - size: f.size, - folderPath: f.folderPath ?? null, - })) + return files + .filter( + (f) => + !isWorkflowAliasBackingPath( + canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) + ) + ) + .map((f) => ({ + id: f.id, + name: f.name, + type: f.type, + size: f.size, + folderPath: f.folderPath ?? null, + })) } catch (err) { logger.warn('Failed to materialize files', { workspaceId, @@ -923,8 +1508,7 @@ export class WorkspaceVFS { workflowId: string, workspaceId: string, isDeployed: boolean, - deployedAt: Date | null, - currentNormalized?: Awaited> + deployedAt: Date | null ): Promise { const [chatRows, mcpRows, a2aRows, versionRows, allVersionRows] = await Promise.all([ db @@ -1009,15 +1593,17 @@ export class WorkspaceVFS { let needsRedeployment: boolean | undefined const deployedVersion = versionRows[0] - if (isDeployed && deployedVersion?.state && currentNormalized) { + if (isDeployed && deployedVersion?.state) { try { - const currentState = { - blocks: currentNormalized.blocks, - edges: currentNormalized.edges, - loops: currentNormalized.loops, - parallels: currentNormalized.parallels, - } - needsRedeployment = hasWorkflowChanged(currentState as any, deployedVersion.state as any) + // Use the canonical deployment snapshot (includes variables) so this + // matches check_deployment_status exactly. The reshaped normalized load + // dropped variables, which made any workflow with deployment variables + // permanently report needsRedeployment: true. + const currentSnapshot = await loadWorkflowDeploymentSnapshot(workflowId) + needsRedeployment = computeNeedsRedeployment( + currentSnapshot, + deployedVersion.state as WorkflowState + ) } catch (err) { logger.warn('Failed to compute needsRedeployment', { workflowId, @@ -1117,7 +1703,7 @@ export class WorkspaceVFS { workspaceId: string ): Promise> { try { - const skillRows = await listSkills({ workspaceId }) + const skillRows = await listSkills({ workspaceId, includeBuiltins: false }) for (const s of skillRows) { const safeName = sanitizeName(s.name) @@ -1433,20 +2019,19 @@ export class WorkspaceVFS { } for (const file of archivedFiles) { - const safeName = sanitizeName(file.name) - const safeFolderPath = file.folderPath - ?.split('/') - .map((segment) => sanitizeName(segment)) - .join('/') - const fileVfsPath = safeFolderPath ? `${safeFolderPath}/${safeName}` : safeName + const filePath = canonicalWorkspaceFilePath({ + folderPath: file.folderPath, + name: file.name, + prefix: 'recently-deleted/files', + }) this.files.set( - `recently-deleted/files/${fileVfsPath}/meta.json`, + filePath, serializeFileMeta({ id: file.id, name: file.name, folderId: file.folderId, folderPath: file.folderPath, - vfsPath: `recently-deleted/files/${fileVfsPath}`, + vfsPath: filePath, contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, @@ -1531,10 +2116,14 @@ export class WorkspaceVFS { serializeEnvironmentVariables(personalVarNames, workspaceVarNames) ) - const oauthProviders = [...new Set(oauthCredentials.map((c) => c.providerId))] const envKeys = [...new Set(envCredentials.map((c) => c.envKey))] return { - oauthIntegrations: oauthProviders.map((key) => ({ providerId: key })), + oauthIntegrations: oauthCredentials.map((c) => ({ + id: c.id, + providerId: c.providerId, + displayName: c.displayName, + role: c.role, + })), envVariables: envKeys, } } catch (err) { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 19f6674013b..a4fd479f447 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -35,6 +35,7 @@ export const env = createEnv({ ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication + INTERNAL_JWT_SECRET: z.string().min(32).optional(), // Dedicated signing key for internal JWTs (falls back to INTERNAL_API_SECRET); separating limits blast radius if one leaks // Copilot COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication @@ -379,6 +380,7 @@ export const env = createEnv({ E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution + MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path // Credential Sets (Email Polling) - for self-hosted deployments CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements) @@ -398,6 +400,7 @@ export const env = createEnv({ // Invitations - for self-hosted deployments DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments) DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments) + MOTHERSHIP_BETA_FEATURES: z.boolean().optional(), // Enable beta Mothership planning/changelog artifact surfaces // Development Tools REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 1cc0ab6cda5..45374727689 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -174,11 +174,30 @@ export const isWorkflowColumnsEnabledClient = isTruthy( getEnv('NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED') ) +/** + * Enables beta Mothership plan/changelog artifact surfaces. + */ +export const isMothershipBetaFeaturesEnabled = isTruthy(env.MOTHERSHIP_BETA_FEATURES) + /** * Is E2B enabled for remote code execution */ export const isE2bEnabled = isTruthy(env.E2B_ENABLED) +/** + * Whether the E2B document-generation sandbox is enabled. + * + * Requires E2B (with an API key) AND a dedicated doc-generation template id. + * When true, ALL four formats compile in the E2B doc sandbox: pptx/docx via Node + * (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python + * (reportlab/openpyxl). When false, compilation stays on the JavaScript + * (isolated-vm) path, byte-identical to its prior behavior (and xlsx is + * unavailable). Drives both the Sim compile backend and the `docCompiler` flag + * sent to the copilot file subagent so the agent's output and compiler agree. + */ +export const isE2BDocEnabled = + isE2bEnabled && Boolean(env.E2B_API_KEY) && Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) + /** * Whether Ollama is configured (OLLAMA_URL is set). * When true, models that are not in the static cloud model list and have no diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 1338b35d28f..c56bfb55546 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -1,4 +1,4 @@ -import { Sandbox } from '@e2b/code-interpreter' +import type { Sandbox as E2BSandbox } from '@e2b/code-interpreter' import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' import { CodeLanguage } from '@/lib/execution/languages' @@ -15,6 +15,11 @@ export interface E2BExecutionRequest { timeoutMs: number sandboxFiles?: SandboxFile[] outputSandboxPath?: string + outputSandboxPaths?: string[] + // Which sandbox template to run in. Defaults to 'code' (mothership-shell). + // Document generation passes 'doc' so it runs in the doc template + // (mothership-docs) that has python-pptx/docx/openpyxl/reportlab installed. + sandboxKind?: 'code' | 'doc' } export interface E2BShellExecutionRequest { @@ -23,6 +28,11 @@ export interface E2BShellExecutionRequest { timeoutMs: number sandboxFiles?: SandboxFile[] outputSandboxPath?: string + outputSandboxPaths?: string[] + // Which sandbox template to run in. Defaults to 'shell' (mothership-shell). + // The Node document engines (pptxgenjs/docx + react-icons/sharp) pass 'doc' so + // they run in the doc template (mothership-docs). + sandboxKind?: 'shell' | 'doc' } export interface E2BExecutionResult { @@ -31,21 +41,90 @@ export interface E2BExecutionResult { sandboxId?: string error?: string exportedFileContent?: string + exportedFiles?: Record /** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */ images?: string[] } const logger = createLogger('E2BExecution') -export async function executeInE2B(req: E2BExecutionRequest): Promise { - const { code, language, timeoutMs, outputSandboxPath } = req - +async function createE2BSandbox(kind: 'code' | 'shell' | 'doc'): Promise { const apiKey = env.E2B_API_KEY if (!apiKey) { throw new Error('E2B_API_KEY is required when E2B is enabled') } - const sandbox = await Sandbox.create({ apiKey }) + // Document generation uses a dedicated template (python-pptx/docx/openpyxl/ + // reportlab + fonts); shell/code execution use the general shell template. + // Doc fails closed: never run LLM-authored Python in E2B's default template + // (which is not vetted for this) just because the doc template id is unset. + if (kind === 'doc' && !env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID) { + throw new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)') + } + const templateName = + kind === 'doc' ? env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID : env.MOTHERSHIP_E2B_TEMPLATE_ID + logger.info('Creating E2B sandbox', { + kind, + template: templateName || '(default)', + }) + const { Sandbox } = await import('@e2b/code-interpreter') + return templateName ? Sandbox.create(templateName, { apiKey }) : Sandbox.create({ apiKey }) +} + +function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { + const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() + const binaryExts = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.pdf', + '.zip', + '.mp3', + '.mp4', + '.docx', + '.pptx', + '.xlsx', + ]) + return binaryExts.has(ext) +} + +async function readSandboxOutputFile( + sandbox: E2BSandbox, + outputSandboxPath: string, + options?: { user?: string } +): Promise { + try { + if (shouldReadSandboxPathAsBase64(outputSandboxPath)) { + const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, options) + return b64Result.stdout + } + return await sandbox.files.read(outputSandboxPath) + } catch (error) { + logger.warn('Failed to read requested sandbox output file', { + outputSandboxPath, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} + +function requestedOutputSandboxPaths(req: { + outputSandboxPath?: string + outputSandboxPaths?: string[] +}): string[] { + const paths = [...(req.outputSandboxPaths ?? [])] + if (req.outputSandboxPath && !paths.includes(req.outputSandboxPath)) { + paths.push(req.outputSandboxPath) + } + return paths +} + +export async function executeInE2B(req: E2BExecutionRequest): Promise { + const { code, language, timeoutMs } = req + + const sandbox = await createE2BSandbox(req.sandboxKind ?? 'code') const sandboxId = sandbox.sandboxId if (req.sandboxFiles?.length) { @@ -134,36 +213,23 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise = {} + for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { + const content = await readSandboxOutputFile(sandbox, outputSandboxPath) + if (content !== undefined) { + exportedFiles[outputSandboxPath] = content } } + const exportedFileContent = req.outputSandboxPath + ? exportedFiles[req.outputSandboxPath] + : undefined return { result, stdout: cleanedStdout, sandboxId, exportedFileContent, + exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, images: images.length ? images : undefined, } } finally { @@ -176,20 +242,9 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise { - const { code, envs, timeoutMs, outputSandboxPath } = req + const { code, envs, timeoutMs } = req - const apiKey = env.E2B_API_KEY - if (!apiKey) { - throw new Error('E2B_API_KEY is required when E2B is enabled') - } - - const templateName = env.MOTHERSHIP_E2B_TEMPLATE_ID - logger.info('Creating E2B shell sandbox', { - template: templateName || '(default)', - }) - const sandbox = templateName - ? await Sandbox.create(templateName, { apiKey }) - : await Sandbox.create({ apiKey }) + const sandbox = await createE2BSandbox(req.sandboxKind ?? 'shell') const sandboxId = sandbox.sandboxId if (req.sandboxFiles?.length) { @@ -275,34 +330,26 @@ export async function executeShellInE2B( cleanedStdout = filteredLines.join('\n') } - let exportedFileContent: string | undefined - if (outputSandboxPath) { - const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() - const binaryExts = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.pdf', - '.zip', - '.mp3', - '.mp4', - '.docx', - '.pptx', - '.xlsx', - ]) - if (binaryExts.has(ext)) { - const b64Result = await sandbox.commands.run(`base64 -w0 "${outputSandboxPath}"`, { - user: 'root', - }) - exportedFileContent = b64Result.stdout - } else { - exportedFileContent = await sandbox.files.read(outputSandboxPath) + const exportedFiles: Record = {} + for (const outputSandboxPath of requestedOutputSandboxPaths(req)) { + const content = await readSandboxOutputFile(sandbox, outputSandboxPath, { + user: 'root', + }) + if (content !== undefined) { + exportedFiles[outputSandboxPath] = content } } + const exportedFileContent = req.outputSandboxPath + ? exportedFiles[req.outputSandboxPath] + : undefined - return { result: parsed, stdout: cleanedStdout, sandboxId, exportedFileContent } + return { + result: parsed, + stdout: cleanedStdout, + sandboxId, + exportedFileContent, + exportedFiles: Object.keys(exportedFiles).length ? exportedFiles : undefined, + } } finally { try { await sandbox.kill() diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 5d025f746f4..833d7b9ab17 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -1,7 +1,10 @@ import type { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/workflow-authz' -import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' +import { + checkOrgMemberUsageLimit, + checkServerSideUsageLimits, +} from '@/lib/billing/calculations/usage-monitor' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { @@ -350,6 +353,48 @@ export async function preprocessExecution( }, } } + + // Per-member org-workspace cap (hosted-only). Independent, additive gate: + // blocks an individual member's executions in org-owned workspaces once + // their personal credit limit for the org is reached, even if the pooled + // org limit still has room. + const memberUsageCheck = await checkOrgMemberUsageLimit(actorUserId, workspaceId) + if (memberUsageCheck.isExceeded) { + const memberLimitMessage = + memberUsageCheck.message || + 'Member usage limit exceeded for this organization. Ask an organization admin to raise your credit limit to continue.' + + logger.warn( + `[${requestId}] User ${actorUserId} exceeded their per-member org usage limit. Blocking execution.`, + { + currentUsage: memberUsageCheck.currentUsage, + limit: memberUsageCheck.limit, + workflowId, + triggerType, + } + ) + + await recordPreprocessingError({ + workflowId, + executionId, + triggerType, + requestId, + userId: actorUserId, + workspaceId, + errorMessage: memberLimitMessage, + loggingSession: providedLoggingSession, + triggerData, + }) + + return { + success: false, + error: { + message: memberLimitMessage, + statusCode: 402, + logCreated: true, + }, + } + } } catch (error) { logger.error(`[${requestId}] Error checking usage limits`, { error, diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index 0db2c0b5734..a8b0be0f316 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -14,6 +14,8 @@ export interface HallucinationValidationResult { error?: string score?: number reasoning?: string + /** Billable LLM cost (dollars) for the scoring call; 0 for BYOK/non-hosted. */ + cost?: number } export interface HallucinationValidationInput { @@ -107,7 +109,7 @@ async function scoreHallucinationWithLLM( providerCredentials: HallucinationValidationInput['providerCredentials'], workspaceId: string | undefined, requestId: string -): Promise<{ score: number; reasoning: string }> { +): Promise<{ score: number; reasoning: string; cost: number }> { try { const contextText = ragContext.join('\n\n---\n\n') @@ -185,6 +187,10 @@ Evaluate the consistency and provide your score and reasoning in JSON format.` throw new Error('Unexpected streaming response from LLM') } + // executeProviderRequest already zeroes cost for BYOK / non-hosted models, + // so this is the billable amount as-is. + const cost = typeof response.cost?.total === 'number' ? response.cost.total : 0 + const content = response.content.trim() let jsonContent = content @@ -209,6 +215,7 @@ Evaluate the consistency and provide your score and reasoning in JSON format.` return { score: result.score, reasoning: result.reasoning || 'No reasoning provided', + cost, } } catch (error: any) { logger.error(`[${requestId}] Error scoring with LLM`, { @@ -271,7 +278,7 @@ export async function validateHallucination( } // Step 2: Use LLM to score confidence - const { score, reasoning } = await scoreHallucinationWithLLM( + const { score, reasoning, cost } = await scoreHallucinationWithLLM( userInput, ragContext, model, @@ -293,6 +300,7 @@ export async function validateHallucination( passed, score, reasoning, + cost, error: passed ? undefined : `Low confidence: score ${score}/10 is below threshold ${threshold}`, diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index a0a17f222c3..7704a069aca 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -264,7 +264,7 @@ export const blockTypeToIconMap: Record = { exa: ExaAIIcon, extend_v2: ExtendIcon, fathom: FathomIcon, - file_v4: DocumentIcon, + file_v5: DocumentIcon, findymail: FindymailIcon, firecrawl: FirecrawlIcon, fireflies_v2: FirefliesIcon, diff --git a/apps/sim/lib/integrations/types.ts b/apps/sim/lib/integrations/types.ts index 635080476e4..d82b2ae43f3 100644 --- a/apps/sim/lib/integrations/types.ts +++ b/apps/sim/lib/integrations/types.ts @@ -58,6 +58,6 @@ export interface Integration { triggerCount: number /** Authentication mode inferred from `BlockConfig.subBlocks`. */ authType: AuthType - /** Hand-authored landing content baked in at generation time (see landing-content.ts). */ + /** Hand-authored landing content baked in at generation time (see `landing-content.ts`). */ landingContent?: IntegrationLandingContent } diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index fd0de1817e7..6e906c3bdd4 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -28,6 +28,7 @@ import { type SQL, sql, } from 'drizzle-orm' +import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' @@ -519,6 +520,7 @@ export async function processDocumentAsync( chunkingConfig: knowledgeBase.chunkingConfig, embeddingModel: knowledgeBase.embeddingModel, billedAccountUserId: workspaceTable.billedAccountUserId, + uploadedBy: document.uploadedBy, tag1: document.tag1, tag2: document.tag2, tag3: document.tag3, @@ -602,9 +604,31 @@ export async function processDocumentAsync( if (!ctx.workspaceId) { throw new Error(`Knowledge base ${knowledgeBaseId} is missing workspace billing context`) } - const billingUserId = ctx.billedAccountUserId + // Bill the uploader when known; connector/cron-synced docs (and pre-migration + // rows) have no uploader and fall back to the workspace billed account. + const billingUserId = ctx.uploadedBy ?? ctx.billedAccountUserId if (!billingUserId) { - throw new Error(`Workspace ${ctx.workspaceId} is missing billed account`) + throw new Error( + `Workspace ${ctx.workspaceId} is missing billed account and document has no uploader` + ) + } + + // Authoritative usage gate covering every indexing path (connector/cron/ + // retry/copilot plus the HTTP routes). Mark the document failed with the limit + // message — surfaced in the KB UI — rather than incurring embedding cost. + const usageGate = await checkActorUsageLimits(billingUserId, ctx.workspaceId) + if (usageGate.isExceeded) { + logger.warn(`[${documentId}] Usage limit reached — skipping document indexing`) + await db + .update(document) + .set({ + processingStatus: 'failed', + processingError: + usageGate.message ?? 'Usage limit exceeded. Please upgrade your plan to continue.', + processingCompletedAt: new Date(), + }) + .where(eq(document.id, documentId)) + return } let totalEmbeddingTokens = 0 let embeddingIsBYOK = false @@ -847,7 +871,8 @@ export async function createDocumentRecords( tag7?: string }>, knowledgeBaseId: string, - requestId: string + requestId: string, + uploadedBy: string | null = null ): Promise { return await db.transaction(async (tx) => { await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) @@ -919,6 +944,7 @@ export async function createDocumentRecords( processingStatus: 'pending' as const, enabled: true, uploadedAt: now, + uploadedBy, tag1: processedTags.tag1 ?? docData.tag1 ?? null, tag2: processedTags.tag2 ?? docData.tag2 ?? null, tag3: processedTags.tag3 ?? docData.tag3 ?? null, @@ -1341,7 +1367,8 @@ export async function createSingleDocument( tag7?: string }, knowledgeBaseId: string, - requestId: string + requestId: string, + uploadedBy: string | null = null ): Promise<{ id: string knowledgeBaseId: string @@ -1414,6 +1441,7 @@ export async function createSingleDocument( characterCount: 0, enabled: true, uploadedAt: now, + uploadedBy, ...processedTags, } diff --git a/apps/sim/lib/knowledge/embeddings.ts b/apps/sim/lib/knowledge/embeddings.ts index fa3528e3203..511101e4739 100644 --- a/apps/sim/lib/knowledge/embeddings.ts +++ b/apps/sim/lib/knowledge/embeddings.ts @@ -1,5 +1,7 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { getBYOKKey } from '@/lib/api-key/byok' +import { recordUsage } from '@/lib/billing/core/usage-log' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { env, envNumber } from '@/lib/core/config/env' import { isRetryableError, retryWithExponentialBackoff } from '@/lib/knowledge/documents/utils' @@ -11,6 +13,7 @@ import { type TokenizerProviderId, } from '@/lib/knowledge/embedding-models' import { batchByTokenLimit, estimateTokenCount } from '@/lib/tokenization' +import { calculateCost } from '@/providers/utils' const logger = createLogger('EmbeddingUtils') @@ -397,11 +400,52 @@ export async function generateSearchEmbedding( query: string, embeddingModel: string = DEFAULT_EMBEDDING_MODEL, workspaceId?: string | null -): Promise { +): Promise<{ embedding: number[]; isBYOK: boolean }> { const provider = await resolveProvider(embeddingModel, workspaceId) logger.info(`Using ${provider.modelName} for search embedding generation`) const { embeddings } = await callEmbeddingAPI([query], provider, 'query') - return embeddings[0] + return { embedding: embeddings[0], isBYOK: provider.isBYOK } +} + +/** + * Records a query embedding's hosted-key cost for callers that generate a search + * embedding directly, outside the metered `/api/knowledge/search` route (e.g. the + * v1 search API and copilot KB search). No-ops for BYOK (no Sim cost) or when + * there is no workspace to attribute to. Best-effort: never throws. + */ +export async function recordSearchEmbeddingUsage(params: { + userId: string + workspaceId?: string | null + embeddingModel: string + query: string + isBYOK: boolean + sourceReference: string +}): Promise { + const { userId, workspaceId, embeddingModel, query, isBYOK, sourceReference } = params + if (isBYOK || !workspaceId) return + try { + const { count } = estimateTokenCount( + query, + getEmbeddingModelInfo(embeddingModel).tokenizerProvider + ) + const cost = calculateCost(embeddingModel, count, 0, false) + if (!cost || cost.total <= 0) return + await recordUsage({ + userId, + workspaceId, + entries: [ + { + category: 'model', + source: 'knowledge-base', + description: embeddingModel, + cost: cost.total, + sourceReference, + }, + ], + }) + } catch (error) { + logger.warn('Failed to record search embedding usage', { error: getErrorMessage(error) }) + } } diff --git a/apps/sim/lib/logs/list-logs.test.ts b/apps/sim/lib/logs/list-logs.test.ts new file mode 100644 index 00000000000..95fdda6bea0 --- /dev/null +++ b/apps/sim/lib/logs/list-logs.test.ts @@ -0,0 +1,185 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { selectMock } = vi.hoisted(() => ({ selectMock: vi.fn() })) + +vi.mock('@sim/db', () => ({ + db: { + select: selectMock, + }, +})) + +// Local drizzle-orm mock: the global mock's `sql` lacks `.as()` and the chain +// mock doesn't support `.orderBy().limit()`. We only need condition/sql builders +// to produce truthy stubs (the mocked db ignores them). +vi.mock('drizzle-orm', () => { + const make = (): Record => { + const o: Record = {} + o.as = () => o + o.mapWith = () => o + return o + } + const sql = Object.assign((..._args: unknown[]) => make(), { + raw: (..._args: unknown[]) => make(), + join: (..._args: unknown[]) => make(), + }) + const op = + (type: string) => + (...args: unknown[]) => ({ type, args }) + return { + sql, + and: op('and'), + or: op('or'), + eq: op('eq'), + ne: op('ne'), + gt: op('gt'), + gte: op('gte'), + lt: op('lt'), + lte: op('lte'), + inArray: op('inArray'), + isNull: op('isNull'), + isNotNull: op('isNotNull'), + asc: op('asc'), + desc: op('desc'), + } +}) + +vi.mock('@/lib/logs/folder-expansion', () => ({ + expandFolderIdsWithDescendants: vi.fn(async (_ws: string, ids: string | undefined) => ids), +})) + +import type { ListLogsParams } from './list-logs' +import { decodeCursor, listLogs } from './list-logs' + +/** A chainable, thenable query-builder stub that resolves to the given rows. */ +function builder(rows: unknown[]) { + const b: Record = {} + for (const method of ['from', 'leftJoin', 'innerJoin', 'where', 'orderBy', 'limit']) { + b[method] = () => b + } + ;(b as { then: unknown }).then = (resolve: (value: unknown) => unknown) => resolve(rows) + return b +} + +function workflowRow(overrides: Record = {}) { + return { + id: 'log-1', + workflowId: 'wf-1', + executionId: 'exec-1', + deploymentVersionId: null, + level: 'info', + status: 'success', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + costTotal: '0.1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + workflowName: 'My Workflow', + workflowDescription: null, + workflowFolderId: null, + workflowUserId: 'user-1', + workflowWorkspaceId: 'ws-1', + workflowCreatedAt: new Date('2026-01-01T00:00:00.000Z'), + workflowUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + pausedStatus: null, + pausedTotalPauseCount: 0, + pausedResumedCount: 0, + deploymentVersion: null, + deploymentVersionName: null, + sortValue: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, + } +} + +function jobRow(overrides: Record = {}) { + return { + id: 'job-log-1', + executionId: 'job-exec-1', + level: 'info', + status: 'success', + trigger: 'schedule', + startedAt: new Date('2026-01-01T00:00:05.000Z'), + endedAt: new Date('2026-01-01T00:00:06.000Z'), + totalDurationMs: 1000, + cost: { total: 0.2 }, + createdAt: new Date('2026-01-01T00:00:05.000Z'), + jobTitle: 'Nightly report', + sortValue: new Date('2026-01-01T00:00:05.000Z'), + ...overrides, + } +} + +function baseParams(overrides: Partial = {}): ListLogsParams { + return { + workspaceId: 'ws-1', + limit: 100, + sortBy: 'date', + sortOrder: 'desc', + ...overrides, + } as ListLogsParams +} + +describe('listLogs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('merges workflow and job rows into summaries', async () => { + selectMock + .mockReturnValueOnce(builder([workflowRow()])) + .mockReturnValueOnce(builder([jobRow()])) + + const result = await listLogs(baseParams(), 'user-1') + + expect(result.data).toHaveLength(2) + const wf = result.data.find((r) => r.id === 'log-1')! + expect(wf).toMatchObject({ + executionId: 'exec-1', + workflowId: 'wf-1', + cost: { total: 0.1 }, + duration: '1000ms', + jobTitle: null, + }) + const job = result.data.find((r) => r.id === 'job-log-1')! + expect(job).toMatchObject({ + executionId: 'job-exec-1', + workflowId: null, + jobTitle: 'Nightly report', + }) + expect(result.nextCursor).toBeNull() + }) + + it('returns a decodable nextCursor when results exceed the limit', async () => { + // limit 1, two workflow rows → page of 1, hasMore true + selectMock + .mockReturnValueOnce( + builder([ + workflowRow({ id: 'log-a', sortValue: new Date('2026-01-02T00:00:00.000Z') }), + workflowRow({ id: 'log-b', sortValue: new Date('2026-01-01T00:00:00.000Z') }), + ]) + ) + .mockReturnValueOnce(builder([])) + + const result = await listLogs(baseParams({ limit: 1 }), 'user-1') + + expect(result.data).toHaveLength(1) + expect(result.nextCursor).not.toBeNull() + const decoded = decodeCursor(result.nextCursor!) + expect(decoded?.id).toBe('log-a') + }) + + it('excludes job logs when a workflow-specific filter is present', async () => { + selectMock.mockReturnValueOnce(builder([workflowRow()])) + + const result = await listLogs(baseParams({ workflowIds: 'wf-1' }), 'user-1') + + // Only the workflow query runs; the job query is Promise.resolve([]). + expect(selectMock).toHaveBeenCalledTimes(1) + expect(result.data).toHaveLength(1) + expect(result.data[0].workflowId).toBe('wf-1') + }) +}) diff --git a/apps/sim/lib/logs/list-logs.ts b/apps/sim/lib/logs/list-logs.ts new file mode 100644 index 00000000000..131092fcd6f --- /dev/null +++ b/apps/sim/lib/logs/list-logs.ts @@ -0,0 +1,461 @@ +import { db } from '@sim/db' +import { + jobExecutionLogs, + pausedExecutions, + permissions, + workflow, + workflowDeploymentVersion, + workflowExecutionLogs, +} from '@sim/db/schema' +import { + and, + asc, + desc, + eq, + gt, + gte, + inArray, + isNotNull, + isNull, + lt, + lte, + ne, + or, + type SQL, + sql, +} from 'drizzle-orm' +import type { z } from 'zod' +import type { + ListLogsResponse, + listLogsQuerySchema, + WorkflowLogSummary, +} from '@/lib/api/contracts/logs' +import { jobCostTotal } from '@/lib/logs/fetch-log-detail' +import { buildFilterConditions } from '@/lib/logs/filters' +import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion' + +export type ListLogsParams = z.output + +type SortBy = 'date' | 'duration' | 'cost' | 'status' +type SortOrder = 'asc' | 'desc' + +interface CursorData { + v: string | number | null + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +export function decodeCursor(cursor: string): CursorData | null { + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64').toString()) + if (typeof parsed?.id !== 'string') return null + return parsed as CursorData + } catch { + return null + } +} + +/** + * Shared logs list query used by the `/api/logs` route and the copilot `query_logs` + * tool. Builds the workflow + job execution-log query (cursor pagination, sort, + * level running/pending logic, job-log merge) from the shared filter params. The + * caller is responsible for authenticating `userId`; this function enforces + * workspace permission via the `permissions` join. + */ +export async function listLogs(params: ListLogsParams, userId: string): Promise { + const sortBy = params.sortBy as SortBy + const sortOrder = params.sortOrder as SortOrder + const cursor = params.cursor ? decodeCursor(params.cursor) : null + + // Expand selected folders to include descendants (matches the route behavior), + // without mutating the caller's params object. + const folderIds = params.folderIds + ? await expandFolderIdsWithDescendants(params.workspaceId, params.folderIds) + : params.folderIds + const p: ListLogsParams = { ...params, folderIds } + + const workflowSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${workflowExecutionLogs.totalDurationMs}` + case 'cost': + // Indexed projection of the usage_log ledger (dollars); no live aggregation. + return sql`${workflowExecutionLogs.costTotal}` + case 'status': + return sql`${workflowExecutionLogs.status}` + default: + return sql`${workflowExecutionLogs.startedAt}` + } + })() + + const jobSortExpr: SQL = (() => { + switch (sortBy) { + case 'duration': + return sql`${jobExecutionLogs.totalDurationMs}` + case 'cost': + return sql`(${jobExecutionLogs.cost}->>'total')::numeric` + case 'status': + return sql`${jobExecutionLogs.status}` + default: + return sql`${jobExecutionLogs.startedAt}` + } + })() + + const dir = sortOrder === 'asc' ? asc : desc + const nullsLast = sql`NULLS LAST` + const orderByClause = (expr: SQL): SQL => sql`${dir(expr)} ${nullsLast}` + + const buildCursorCondition = (sortExpr: unknown, idCol: unknown): SQL | undefined => { + if (!cursor) return undefined + const v = cursor.v + const id = cursor.id + const cmp = sortOrder === 'asc' ? sql`>` : sql`<` + if (v === null) { + return sql`(${sortExpr} IS NULL AND ${idCol} ${cmp} ${id})` + } + return sql`((${sortExpr} IS NOT NULL AND ${sortExpr} ${cmp} ${v}) OR (${sortExpr} = ${v} AND ${idCol} ${cmp} ${id}) OR ${sortExpr} IS NULL)` + } + + const fetchSize = p.limit + 1 + + // Build workflow log conditions + const workflowConditions: SQL[] = [eq(workflowExecutionLogs.workspaceId, p.workspaceId)] + + if (p.level && p.level !== 'all') { + const levels = p.level.split(',').filter(Boolean) + const levelConditions: SQL[] = [] + + for (const level of levels) { + if (level === 'error') { + levelConditions.push(eq(workflowExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNotNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'running') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + isNull(workflowExecutionLogs.endedAt) + ) + if (c) levelConditions.push(c) + } else if (level === 'pending') { + const c = and( + eq(workflowExecutionLogs.level, 'info'), + or( + sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, + and( + isNotNull(pausedExecutions.status), + sql`${pausedExecutions.status} != 'fully_resumed'` + ) + ) + ) + if (c) levelConditions.push(c) + } + } + + if (levelConditions.length > 0) { + workflowConditions.push( + levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)! + ) + } + } + + const commonFilters = buildFilterConditions(p, { useSimpleLevelFilter: false }) + if (commonFilters) workflowConditions.push(commonFilters) + + const workflowCursorCond = buildCursorCondition(workflowSortExpr, workflowExecutionLogs.id) + if (workflowCursorCond) workflowConditions.push(workflowCursorCond) + + // Decide whether to include job logs + const hasWorkflowSpecificFilters = !!( + p.workflowIds || + p.folderIds || + p.workflowName || + p.folderName + ) + const triggersList = p.triggers?.split(',').filter(Boolean) || [] + const triggersExcludeJobs = + triggersList.length > 0 && !triggersList.includes('all') && !triggersList.includes('mothership') + const levelList = p.level && p.level !== 'all' ? p.level.split(',').filter(Boolean) : [] + const levelExcludesJobs = + levelList.length > 0 && !levelList.some((l) => l === 'error' || l === 'info') + const includeJobLogs = !hasWorkflowSpecificFilters && !triggersExcludeJobs && !levelExcludesJobs + + const workflowQuery = db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + costTotal: workflowExecutionLogs.costTotal, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + pausedStatus: pausedExecutions.status, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, + sortValue: sql`${workflowSortExpr}`.as('sort_value'), + }) + .from(workflowExecutionLogs) + .leftJoin(pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(...workflowConditions)) + .orderBy(orderByClause(workflowSortExpr), dir(workflowExecutionLogs.id)) + .limit(fetchSize) + + const jobConditions: SQL[] = [eq(jobExecutionLogs.workspaceId, p.workspaceId)] + + if (includeJobLogs) { + jobConditions.push( + sql`EXISTS (SELECT 1 FROM ${permissions} WHERE ${permissions.entityType} = 'workspace' AND ${permissions.entityId} = ${jobExecutionLogs.workspaceId} AND ${permissions.userId} = ${userId})` + ) + + if (p.level && p.level !== 'all') { + const levels = p.level.split(',').filter(Boolean) + const jobLevelConditions: SQL[] = [] + for (const level of levels) { + if (level === 'error') { + jobLevelConditions.push(eq(jobExecutionLogs.level, 'error')) + } else if (level === 'info') { + const c = and(eq(jobExecutionLogs.level, 'info'), isNotNull(jobExecutionLogs.endedAt)) + if (c) jobLevelConditions.push(c) + } + } + if (jobLevelConditions.length > 0) { + jobConditions.push( + jobLevelConditions.length === 1 ? jobLevelConditions[0] : or(...jobLevelConditions)! + ) + } + } + + if (triggersList.length > 0 && !triggersList.includes('all')) { + jobConditions.push(inArray(jobExecutionLogs.trigger, triggersList)) + } + + if (p.startDate) { + jobConditions.push(gte(jobExecutionLogs.startedAt, new Date(p.startDate))) + } + if (p.endDate) { + jobConditions.push(lte(jobExecutionLogs.startedAt, new Date(p.endDate))) + } + + if (p.search) { + jobConditions.push(sql`${jobExecutionLogs.executionId} ILIKE ${`%${p.search}%`}`) + } + if (p.executionId) { + jobConditions.push(eq(jobExecutionLogs.executionId, p.executionId)) + } + + if (p.costOperator && p.costValue !== undefined) { + const costField = sql`(${jobExecutionLogs.cost}->>'total')::numeric` + const ops = { + '=': sql`=`, + '>': sql`>`, + '<': sql`<`, + '>=': sql`>=`, + '<=': sql`<=`, + '!=': sql`!=`, + } as const + jobConditions.push(sql`${costField} ${ops[p.costOperator]} ${p.costValue}`) + } + + if (p.durationOperator && p.durationValue !== undefined) { + const durationOps: Record< + string, + (field: typeof jobExecutionLogs.totalDurationMs, val: number) => SQL | undefined + > = { + '=': (f, v) => eq(f, v), + '>': (f, v) => gt(f, v), + '<': (f, v) => lt(f, v), + '>=': (f, v) => gte(f, v), + '<=': (f, v) => lte(f, v), + '!=': (f, v) => ne(f, v), + } + const durationCond = durationOps[p.durationOperator]?.( + jobExecutionLogs.totalDurationMs, + p.durationValue + ) + if (durationCond) jobConditions.push(durationCond) + } + + const jobCursorCond = buildCursorCondition(jobSortExpr, jobExecutionLogs.id) + if (jobCursorCond) jobConditions.push(jobCursorCond) + } + + const jobQuery = includeJobLogs + ? db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + jobTitle: sql`${jobExecutionLogs.executionData}->'trigger'->>'source'`, + sortValue: sql`${jobSortExpr}`.as('sort_value'), + }) + .from(jobExecutionLogs) + .where(and(...jobConditions)) + .orderBy(orderByClause(jobSortExpr), dir(jobExecutionLogs.id)) + .limit(fetchSize) + : Promise.resolve([]) + + const [workflowRows, jobRows] = await Promise.all([workflowQuery, jobQuery]) + + type RowWithSort = { + id: string + sortValue: unknown + summary: WorkflowLogSummary + } + + const workflowMapped: RowWithSort[] = workflowRows.map((log) => { + const totalPauseCount = Number(log.pausedTotalPauseCount ?? 0) + const resumedCount = Number(log.pausedResumedCount ?? 0) + const hasPendingPause = + (totalPauseCount > 0 && resumedCount < totalPauseCount) || + (log.pausedStatus !== null && log.pausedStatus !== 'fully_resumed') + + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt?.toISOString() ?? null, + updatedAt: log.workflowUpdatedAt?.toISOString() ?? null, + } + : null, + jobTitle: null, + // List cost is the cost_total projection (faithful ledger sum). Null until + // completion (running) or until the one-time legacy backfill populates it. + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + pauseSummary: { + status: log.pausedStatus ?? null, + total: totalPauseCount, + resumed: resumedCount, + }, + hasPendingPause, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const jobMapped: RowWithSort[] = (jobRows as Awaited).map((log) => { + const summary: WorkflowLogSummary = { + id: log.id, + workflowId: null, + executionId: log.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + workflow: null, + jobTitle: log.jobTitle ?? null, + cost: jobCostTotal(log.cost), + pauseSummary: { status: null, total: 0, resumed: 0 }, + hasPendingPause: false, + } + return { id: log.id, sortValue: log.sortValue, summary } + }) + + const compareSortValues = (a: unknown, b: unknown): number => { + if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime() + if (typeof a === 'number' && typeof b === 'number') return a - b + const aStr = String(a) + const bStr = String(b) + if (sortBy === 'date') { + return new Date(aStr).getTime() - new Date(bStr).getTime() + } + const aNum = Number(aStr) + const bNum = Number(bStr) + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) return aNum - bNum + return aStr.localeCompare(bStr) + } + + const merged = [...workflowMapped, ...jobMapped].sort((a, b) => { + const aNull = a.sortValue === null || a.sortValue === undefined + const bNull = b.sortValue === null || b.sortValue === undefined + // Mirror SQL's NULLS LAST for both ASC and DESC so the cursor stays consistent. + if (aNull && !bNull) return 1 + if (!aNull && bNull) return -1 + if (!aNull && !bNull) { + const cmp = compareSortValues(a.sortValue, b.sortValue) + if (cmp !== 0) return sortOrder === 'asc' ? cmp : -cmp + } + const idCmp = a.id.localeCompare(b.id) + return sortOrder === 'asc' ? idCmp : -idCmp + }) + + const page = merged.slice(0, p.limit) + const hasMore = merged.length > p.limit + let nextCursor: string | null = null + if (hasMore && page.length > 0) { + const last = page[page.length - 1] + const v = last.sortValue + const cursorV = + v instanceof Date + ? v.toISOString() + : typeof v === 'number' || typeof v === 'string' + ? v + : v == null + ? null + : String(v) + nextCursor = encodeCursor({ v: cursorV, id: last.id }) + } + + return { + data: page.map((row) => row.summary), + nextCursor, + } +} diff --git a/apps/sim/lib/logs/log-views.test.ts b/apps/sim/lib/logs/log-views.test.ts new file mode 100644 index 00000000000..9b31ed4feb5 --- /dev/null +++ b/apps/sim/lib/logs/log-views.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + isLargeArrayManifestMock, + isLargeValueRefMock, + readLargeArrayManifestSliceMock, + materializeLargeArrayManifestMock, + materializeLargeValueRefMock, +} = vi.hoisted(() => ({ + isLargeArrayManifestMock: vi.fn(), + isLargeValueRefMock: vi.fn(), + readLargeArrayManifestSliceMock: vi.fn(), + materializeLargeArrayManifestMock: vi.fn(), + materializeLargeValueRefMock: vi.fn(), +})) + +vi.mock('@/lib/execution/payloads/large-array-manifest-metadata', () => ({ + isLargeArrayManifest: isLargeArrayManifestMock, +})) +vi.mock('@/lib/execution/payloads/large-value-ref', () => ({ + isLargeValueRef: isLargeValueRefMock, +})) +vi.mock('@/lib/execution/payloads/large-array-manifest', () => ({ + readLargeArrayManifestSlice: readLargeArrayManifestSliceMock, + materializeLargeArrayManifest: materializeLargeArrayManifestMock, +})) +vi.mock('@/lib/execution/payloads/store', () => ({ + materializeLargeValueRef: materializeLargeValueRefMock, +})) + +import type { TraceSpan } from '@/lib/logs/types' +import { grepSpans, type LogViewContext, toFull, toOverview } from './log-views' + +const ctx: LogViewContext = { + workspaceId: 'ws-1', + workflowId: 'wf-1', + executionId: 'exec-1', +} + +// Fixture helpers — the mocked type guards key off `__sim`. +const manifest = (totalCount: number, preview: unknown[] = []) => ({ + __sim: 'manifest', + totalCount, + preview, +}) +const ref = (preview: unknown) => ({ __sim: 'ref', preview, size: 100 }) + +function span(overrides: Partial = {}): TraceSpan { + return { + id: 'span-1', + name: 'Agent 1', + type: 'agent', + duration: 100, + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:00.100Z', + ...overrides, + } as TraceSpan +} + +beforeEach(() => { + vi.clearAllMocks() + isLargeArrayManifestMock.mockImplementation((v: any) => v?.__sim === 'manifest') + isLargeValueRefMock.mockImplementation((v: any) => v?.__sim === 'ref') +}) + +describe('toOverview', () => { + it('keeps timing/cost/hierarchy and omits input/output without materializing refs', () => { + const spans: TraceSpan[] = [ + span({ + id: 'root', + cost: { total: 0.5 }, + input: { secret: 'in' }, + output: ref('out-preview') as unknown as Record, + children: [span({ id: 'child', name: 'Tool', type: 'tool' })], + }), + ] + + const out = toOverview(spans) + + expect(out[0]).toMatchObject({ + id: 'root', + name: 'Agent 1', + type: 'agent', + durationMs: 100, + cost: { total: 0.5 }, + }) + expect(out[0]).not.toHaveProperty('input') + expect(out[0]).not.toHaveProperty('output') + expect(out[0].children?.[0]).toMatchObject({ id: 'child', name: 'Tool' }) + expect(materializeLargeArrayManifestMock).not.toHaveBeenCalled() + expect(materializeLargeValueRefMock).not.toHaveBeenCalled() + }) +}) + +describe('toFull', () => { + it('includes inline input/output', async () => { + const out = await toFull([span({ input: { a: 1 }, output: { b: 2 } })], ctx) + expect(out[0]).toMatchObject({ input: { a: 1 }, output: { b: 2 } }) + }) + + it('block scoping returns only the selected subtree', async () => { + const spans: TraceSpan[] = [ + span({ id: 's1', blockId: 'blk-a', name: 'A' }), + span({ id: 's2', blockId: 'blk-b', name: 'B', output: { keep: true } }), + ] + const out = await toFull(spans, ctx, { blockId: 'blk-b' }) + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ blockId: 'blk-b', output: { keep: true } }) + }) + + it('materializes a large-array manifest field', async () => { + materializeLargeArrayManifestMock.mockResolvedValue([1, 2, 3]) + const out = await toFull([span({ output: manifest(3) as any })], ctx) + expect(out[0].output).toEqual([1, 2, 3]) + expect(materializeLargeArrayManifestMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to ref preview when a single ref is unavailable', async () => { + materializeLargeValueRefMock.mockResolvedValue(undefined) + const out = await toFull([span({ output: ref('the-preview') as any })], ctx) + expect(out[0].output).toBe('the-preview') + }) +}) + +describe('grepSpans', () => { + it('matches inline output text and error text', async () => { + const spans = [ + span({ output: { msg: 'request timeout occurred' }, errorMessage: 'boom failure' }), + ] + + const outMatch = await grepSpans(spans, 'timeout', ctx) + expect(outMatch.matches.some((m) => m.field === 'output')).toBe(true) + expect(outMatch.truncated).toBe(false) + + const errMatch = await grepSpans(spans, 'boom', ctx) + expect(errMatch.matches.some((m) => m.field === 'error')).toBe(true) + }) + + it('streams a large-array manifest slice-by-slice with advancing offsets', async () => { + // totalCount 500, batch 200 → starts 0, 200, 400 (3 slices). Needle in slice 3. + readLargeArrayManifestSliceMock.mockImplementation(async (_m: unknown, start: number) => { + if (start === 400) return [{ v: 'found the needle here' }] + return [{ v: 'nothing' }] + }) + const spans = [span({ output: manifest(500) as any })] + + const result = await grepSpans(spans, 'needle', ctx) + + expect(result.matches.some((m) => m.field === 'output')).toBe(true) + const starts = readLargeArrayManifestSliceMock.mock.calls.map((c) => c[1]) + expect(starts).toEqual([0, 200, 400]) + }) + + it('caps matches and marks truncated', async () => { + const spans = [span({ id: 'a', name: 'needle one', output: { v: 'needle two' } })] + const result = await grepSpans(spans, 'needle', ctx, { maxMatches: 1 }) + expect(result.matches).toHaveLength(1) + expect(result.truncated).toBe(true) + }) + + it('falls back to ref preview when ref access is rejected (no throw)', async () => { + materializeLargeValueRefMock.mockRejectedValue(new Error('not available in this execution')) + const spans = [span({ output: ref('secret-token') as any })] + const result = await grepSpans(spans, 'secret-token', ctx) + expect(result.matches.some((m) => m.field === 'output')).toBe(true) + }) + + it('falls back to literal substring on invalid regex', async () => { + const spans = [span({ output: { v: 'value a(b found' } })] + const result = await grepSpans(spans, '(', ctx) + expect(result.matches.some((m) => m.field === 'output')).toBe(true) + }) + + it('returns empty for empty traceSpans', async () => { + const result = await grepSpans([], 'anything', ctx) + expect(result.matches).toEqual([]) + expect(result.truncated).toBe(false) + }) +}) diff --git a/apps/sim/lib/logs/log-views.ts b/apps/sim/lib/logs/log-views.ts new file mode 100644 index 00000000000..e7e4feaceee --- /dev/null +++ b/apps/sim/lib/logs/log-views.ts @@ -0,0 +1,339 @@ +import { + materializeLargeArrayManifest, + readLargeArrayManifestSlice, +} from '@/lib/execution/payloads/large-array-manifest' +import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' +import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' +import type { LargeValueStoreContext } from '@/lib/execution/payloads/store' +import { materializeLargeValueRef } from '@/lib/execution/payloads/store' +import type { TraceSpan } from '@/lib/logs/types' + +/** + * Access/materialization context for resolving large-value refs embedded in a + * trace. Built once per request (by the caller) from the fetched execution log. + */ +export type LogViewContext = LargeValueStoreContext + +/** Cap a single (non-array) large-value ref materialization. */ +const SINGLE_REF_MAX_BYTES = 4 * 1024 * 1024 +/** Items per large-array slice while streaming a grep. */ +const ARRAY_SLICE_BATCH = 200 + +const DEFAULT_MAX_MATCHES = 50 +const DEFAULT_MAX_SNIPPET_CHARS = 500 +const DEFAULT_MAX_SLICES_SCANNED = 200 + +// --------------------------------------------------------------------------- +// Overview (Level 2): block tree with timing + cost, NO input/output. +// --------------------------------------------------------------------------- + +export interface OverviewSpan { + id: string + blockId?: string + name: string + type: string + status?: string + durationMs: number + cost?: TraceSpan['cost'] + children?: OverviewSpan[] +} + +/** Project trace spans to a compact overview tree. Never materializes refs. */ +export function toOverview(spans: TraceSpan[]): OverviewSpan[] { + return spans.map((s) => { + const node: OverviewSpan = { + id: s.id, + blockId: s.blockId, + name: s.name, + type: s.type, + status: s.status, + durationMs: s.duration ?? 0, + } + if (s.cost) node.cost = s.cost + if (s.children && s.children.length > 0) node.children = toOverview(s.children) + return node + }) +} + +// --------------------------------------------------------------------------- +// Full (Level 3): block tree WITH materialized input/output. +// --------------------------------------------------------------------------- + +export interface FullSpan extends OverviewSpan { + startTime?: string + endTime?: string + input?: unknown + output?: unknown + error?: string + children?: FullSpan[] +} + +export interface BlockSelector { + blockId?: string + blockName?: string +} + +/** + * Project trace spans to full detail, materializing large-value refs in + * input/output. When a `selector` is given, only the matching span subtree(s) + * are returned (and materialized), so a single block's I/O is loaded instead of + * the whole trace. + */ +export async function toFull( + spans: TraceSpan[], + ctx: LogViewContext, + selector?: BlockSelector +): Promise { + const roots = selectSpans(spans, selector) + return Promise.all(roots.map((s) => fullSpan(s, ctx))) +} + +function selectSpans(spans: TraceSpan[], selector?: BlockSelector): TraceSpan[] { + if (!selector || (!selector.blockId && !selector.blockName)) return spans + const out: TraceSpan[] = [] + const walk = (list: TraceSpan[]): void => { + for (const s of list) { + const matches = + (selector.blockId !== undefined && s.blockId === selector.blockId) || + (selector.blockName !== undefined && s.name === selector.blockName) + if (matches) { + out.push(s) + } else if (s.children && s.children.length > 0) { + walk(s.children) + } + } + } + walk(spans) + return out +} + +async function fullSpan(s: TraceSpan, ctx: LogViewContext): Promise { + const node: FullSpan = { + id: s.id, + blockId: s.blockId, + name: s.name, + type: s.type, + status: s.status, + durationMs: s.duration ?? 0, + startTime: s.startTime, + endTime: s.endTime, + } + if (s.cost) node.cost = s.cost + if (s.errorMessage) node.error = s.errorMessage + if (s.input !== undefined) node.input = await materializeField(s.input, ctx) + if (s.output !== undefined) node.output = await materializeField(s.output, ctx) + if (s.children && s.children.length > 0) { + node.children = await Promise.all(s.children.map((c) => fullSpan(c, ctx))) + } + return node +} + +/** + * Resolve a span field that may be inline OR a large-value ref/manifest. Falls + * back to the ref `preview` (or a placeholder) when the value is unavailable or + * exceeds caps — never throws. + */ +async function materializeField(value: unknown, ctx: LogViewContext): Promise { + if (isLargeArrayManifest(value)) { + try { + return await materializeLargeArrayManifest(value, ctx) + } catch { + return value.preview ?? '[large array unavailable]' + } + } + if (isLargeValueRef(value)) { + try { + const materialized = await materializeLargeValueRef(value, { + ...ctx, + maxBytes: ctx.maxBytes ?? SINGLE_REF_MAX_BYTES, + }) + return materialized === undefined + ? (value.preview ?? '[large value unavailable]') + : materialized + } catch { + return value.preview ?? '[large value unavailable]' + } + } + return value +} + +// --------------------------------------------------------------------------- +// Grep (single execution): stream large refs chunk-by-chunk, release each. +// --------------------------------------------------------------------------- + +export interface GrepSpanMatch { + spanId: string + blockId?: string + name: string + field: 'name' | 'type' | 'error' | 'input' | 'output' + snippet: string +} + +export interface GrepSpansResult { + matches: GrepSpanMatch[] + truncated: boolean +} + +export interface GrepSpansOptions { + maxMatches?: number + maxSnippetChars?: number + maxSlicesScanned?: number +} + +interface GrepState { + matches: GrepSpanMatch[] + slicesScanned: number + truncated: boolean + maxMatches: number + maxSnippetChars: number + maxSlicesScanned: number + regex: RegExp +} + +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function buildRegex(pattern: string): RegExp { + try { + return new RegExp(pattern, 'i') + } catch { + return new RegExp(escapeRegExp(pattern), 'i') + } +} + +function snippetAround(text: string, regex: RegExp, maxChars: number): string { + const m = regex.exec(text) + const index = m ? m.index : 0 + const half = Math.floor(maxChars / 2) + const start = Math.max(0, index - half) + const end = Math.min(text.length, start + maxChars) + const prefix = start > 0 ? '…' : '' + const suffix = end < text.length ? '…' : '' + return `${prefix}${text.slice(start, end)}${suffix}` +} + +function done(state: GrepState): boolean { + return state.truncated || state.matches.length >= state.maxMatches +} + +function recordIfMatch( + text: string, + field: GrepSpanMatch['field'], + span: TraceSpan, + state: GrepState +): void { + if (done(state)) return + state.regex.lastIndex = 0 + if (!state.regex.test(text)) return + state.regex.lastIndex = 0 + state.matches.push({ + spanId: span.id, + blockId: span.blockId, + name: span.name, + field, + snippet: snippetAround(text, state.regex, state.maxSnippetChars), + }) + if (state.matches.length >= state.maxMatches) state.truncated = true +} + +async function grepField( + value: unknown, + field: 'input' | 'output', + span: TraceSpan, + ctx: LogViewContext, + state: GrepState +): Promise { + if (done(state)) return + + if (isLargeArrayManifest(value)) { + let start = 0 + while (start < value.totalCount && !done(state)) { + if (state.slicesScanned >= state.maxSlicesScanned) { + state.truncated = true + break + } + let slice: unknown[] | null + try { + slice = await readLargeArrayManifestSlice(value, start, ARRAY_SLICE_BATCH, ctx) + } catch { + // Unavailable chunk: fall back to the manifest preview once and stop. + recordIfMatch(safeStringify(value.preview), field, span, state) + return + } + state.slicesScanned += 1 + if (slice.length === 0) break + recordIfMatch(safeStringify(slice), field, span, state) + start += ARRAY_SLICE_BATCH + // Release the batch before fetching the next so peak memory ~= one batch. + slice = null + } + return + } + + if (isLargeValueRef(value)) { + let materialized: unknown + try { + materialized = await materializeLargeValueRef(value, { + ...ctx, + maxBytes: ctx.maxBytes ?? SINGLE_REF_MAX_BYTES, + }) + } catch { + materialized = undefined + } + const text = + materialized === undefined ? safeStringify(value.preview) : safeStringify(materialized) + recordIfMatch(text, field, span, state) + return + } + + recordIfMatch(safeStringify(value), field, span, state) +} + +function safeStringify(value: unknown): string { + if (value === undefined || value === null) return '' + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +/** + * Grep a single execution's trace spans for `pattern`. Inline fields are scanned + * directly; large-array I/O is streamed slice-by-slice (each released before the + * next); single large refs are materialized under a byte cap (falling back to + * the ref preview). Only bounded match snippets are accumulated. + */ +export async function grepSpans( + spans: TraceSpan[], + pattern: string, + ctx: LogViewContext, + opts?: GrepSpansOptions +): Promise { + const state: GrepState = { + matches: [], + slicesScanned: 0, + truncated: false, + maxMatches: opts?.maxMatches ?? DEFAULT_MAX_MATCHES, + maxSnippetChars: opts?.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS, + maxSlicesScanned: opts?.maxSlicesScanned ?? DEFAULT_MAX_SLICES_SCANNED, + regex: buildRegex(pattern), + } + + const walk = async (list: TraceSpan[]): Promise => { + for (const span of list) { + if (done(state)) return + recordIfMatch(span.name, 'name', span, state) + recordIfMatch(span.type, 'type', span, state) + if (span.errorMessage) recordIfMatch(span.errorMessage, 'error', span, state) + if (span.input !== undefined) await grepField(span.input, 'input', span, ctx, state) + if (span.output !== undefined) await grepField(span.output, 'output', span, ctx, state) + if (span.children && span.children.length > 0) await walk(span.children) + } + } + + await walk(spans) + return { matches: state.matches, truncated: state.truncated } +} diff --git a/apps/sim/lib/mcp/service.test.ts b/apps/sim/lib/mcp/service.test.ts index 6745a0ffe16..f4e5817f69e 100644 --- a/apps/sim/lib/mcp/service.test.ts +++ b/apps/sim/lib/mcp/service.test.ts @@ -13,11 +13,38 @@ const { mockValidateDomain, mockValidateSsrf, mockIsDomainAllowed, + mockCacheAdapter, } = vi.hoisted(() => { const mockListTools = vi.fn() const mockConnect = vi.fn() const mockDisconnect = vi.fn() + // In-memory cache adapter so the service never touches the real Redis the + // local .env points at (unreachable in CI/sandbox → hangs). Honors TTL via + // an expiry timestamp so negative-cache assertions behave like production. + const cacheStore = new Map() + const mockCacheAdapter = { + get: async (key: string) => { + const entry = cacheStore.get(key) + if (!entry) return null + if (entry.expiry <= Date.now()) { + cacheStore.delete(key) + return null + } + return entry + }, + set: async (key: string, tools: unknown[], ttlMs: number) => { + cacheStore.set(key, { tools, expiry: Date.now() + ttlMs }) + }, + delete: async (key: string) => { + cacheStore.delete(key) + }, + clear: async () => { + cacheStore.clear() + }, + dispose: () => {}, + } return { + mockCacheAdapter, MockMcpClient: vi.fn().mockImplementation( class { constructor() { @@ -91,6 +118,11 @@ vi.mock('@/lib/mcp/resolve-config', () => ({ resolveMcpConfigEnvVars: (...args: unknown[]) => mockResolveEnvVars(...args), })) +vi.mock('@/lib/mcp/storage', () => ({ + createMcpCacheAdapter: () => mockCacheAdapter, + getMcpCacheType: () => 'memory', +})) + import { mcpService } from '@/lib/mcp/service' import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types' diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index 97c7032dac3..ca0c20fb97e 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -17,6 +17,7 @@ import { } from '@/lib/mcp/tool-limits' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' +import type { InputFormatField } from '@/lib/workflows/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { mcpPubSub } from './pubsub' import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflow-tool-schema' @@ -124,6 +125,19 @@ export async function generateParameterSchemaForWorkflow( return generateSchemaFromBlocks(deployed.blocks as Record) } +/** + * Load a workflow's active deployed state and return its start-trigger input + * format fields. Shared so callers (e.g. the copilot `deploy_mcp` tool) can + * build a parameter schema from the same input source the deploy modal uses. + */ +export async function getDeployedWorkflowInputFormat( + workflowId: string +): Promise { + const deployed = await loadDeployedWorkflowState(workflowId) + if (!deployed?.blocks) return [] + return extractInputFormatFromBlocks(deployed.blocks as Record) ?? [] +} + interface SyncOptions { workflowId: string requestId: string diff --git a/apps/sim/lib/mcp/workflow-tool-schema.ts b/apps/sim/lib/mcp/workflow-tool-schema.ts index 8ceab6e26dd..17e27e6de4f 100644 --- a/apps/sim/lib/mcp/workflow-tool-schema.ts +++ b/apps/sim/lib/mcp/workflow-tool-schema.ts @@ -187,6 +187,24 @@ export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToo } } +/** + * Generate an MCP tool parameter schema from an input format, overlaying + * caller-supplied per-parameter descriptions (keyed by field name) before + * generating the JSON Schema. This is the single source of truth shared by the + * deploy modal UI and the copilot `deploy_mcp` tool so both produce identical + * schemas for the same inputs. + */ +export function generateParameterSchema( + inputFormat: InputFormatField[], + descriptions: Record +): Record { + const fieldsWithDescriptions = inputFormat.map((field) => ({ + ...field, + description: field.name ? descriptions[field.name]?.trim() || undefined : undefined, + })) + return { ...generateToolInputSchema(fieldsWithDescriptions) } +} + /** * Generate a complete MCP tool definition from workflow metadata and input format. */ diff --git a/apps/sim/lib/media/falai-audio.ts b/apps/sim/lib/media/falai-audio.ts new file mode 100644 index 00000000000..6af06843c80 --- /dev/null +++ b/apps/sim/lib/media/falai-audio.ts @@ -0,0 +1,131 @@ +import { downloadFalMedia, extractFalMediaUrl, getFalApiKey, runFalQueue } from '@/lib/media/falai' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' + +export type AudioType = 'speech' | 'music' | 'sfx' + +// Latest-generation fal.ai audio models (2026). Speech leads the TTS arena; +// no GPT-4-tier voices. `model` on the tool can override any of these. +export const DEFAULT_AUDIO_MODELS: Record = { + speech: 'fal-ai/gemini-3.1-flash-tts', + music: 'fal-ai/minimax-music/v2.6', + sfx: 'fal-ai/elevenlabs/sound-effects/v2', +} + +// Zero-shot voice cloning from a reference sample (F5-TTS: ref_audio_url + gen_text). +export const DEFAULT_CLONE_MODEL = 'fal-ai/f5-tts' + +export interface GenerateFalAudioParams { + prompt: string + type?: AudioType + model?: string + voice?: string + duration?: number + /** For music: explicit lyrics (with optional [Verse]/[Chorus] tags). Implies a vocal track. */ + lyrics?: string + /** For music: true = instrumental (no vocals, default); false = vocal track. */ + instrumental?: boolean + /** When set, clones the voice from this reference sample (data URI) via a zero-shot clone model. */ + voiceSampleDataUri?: string +} + +export interface GeneratedAudio { + buffer: Buffer + contentType: string + type: AudioType + model: string + jobId: string + cost: FalAICostMetadata +} + +function buildInput( + type: AudioType, + params: GenerateFalAudioParams, + model: string +): Record { + const input: Record = {} + if (type === 'speech') { + // Gemini 3.1 Flash TTS takes the text (with optional inline tags) in `prompt`. + input.prompt = params.prompt + if (params.voice) input.voice = params.voice + } else if (type === 'sfx') { + // ElevenLabs sound-effects take `text`. + input.text = params.prompt + if (params.duration !== undefined) input.duration_seconds = params.duration + } else { + // Music. Two modes, both supported: + // - instrumental bed (default): no vocals, no lyrics required + // - song with vocals: explicit `lyrics`, or auto-written from the prompt + input.prompt = params.prompt + const wantsVocals = params.instrumental === false || Boolean(params.lyrics) + if (model.includes('minimax')) { + // MiniMax Music 2.6 requires `lyrics` unless is_instrumental=true, and rejects a + // top-level `duration` (that combination is the 422 we were hitting on every call). + if (wantsVocals) { + input.is_instrumental = false + if (params.lyrics) input.lyrics = params.lyrics + else input.lyrics_optimizer = true + } else { + input.is_instrumental = true + } + } else if (model.includes('elevenlabs/music')) { + if (!wantsVocals) input.force_instrumental = true + if (params.lyrics) input.prompt = `${params.prompt}\n\nLyrics:\n${params.lyrics}` + if (params.duration !== undefined) input.music_length_ms = Math.round(params.duration * 1000) + } else { + // Other music models: best-effort passthrough. + if (params.instrumental !== undefined) input.instrumental = params.instrumental + if (params.lyrics) input.lyrics = params.lyrics + if (params.duration !== undefined) input.duration = params.duration + } + } + return input +} + +export async function generateFalAudio(params: GenerateFalAudioParams): Promise { + const type: AudioType = params.type || 'speech' + const apiKey = getFalApiKey() + + // Voice cloning: a reference sample routes to a zero-shot clone model (F5-TTS), + // which conditions on the sample (ref_audio_url) and speaks the prompt in that voice. + if (params.voiceSampleDataUri) { + const model = params.model || DEFAULT_CLONE_MODEL + const input: Record = { + gen_text: params.prompt, + ref_audio_url: params.voiceSampleDataUri, + model_type: 'F5-TTS', + } + const { requestId, data } = await runFalQueue(model, input, apiKey) + const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output']) + if (!url) throw new Error('No audio URL in Fal.ai clone response') + const { buffer, contentType } = await downloadFalMedia(url) + const cost = await getFalAICostMetadata({ apiKey, endpointId: model, requestId }) + return { + buffer, + contentType: contentType.startsWith('audio/') ? contentType : 'audio/mpeg', + type: 'speech', + model, + jobId: requestId, + cost, + } + } + + const model = params.model || DEFAULT_AUDIO_MODELS[type] + const input = buildInput(type, params, model) + + // For fal audio models the model ID is the queue endpoint. + const { requestId, data } = await runFalQueue(model, input, apiKey) + const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output']) + if (!url) throw new Error('No audio URL in Fal.ai response') + + const { buffer, contentType } = await downloadFalMedia(url) + const cost = await getFalAICostMetadata({ apiKey, endpointId: model, requestId }) + + return { + buffer, + contentType: contentType.startsWith('audio/') ? contentType : 'audio/mpeg', + type, + model, + jobId: requestId, + cost, + } +} diff --git a/apps/sim/lib/media/falai-video.ts b/apps/sim/lib/media/falai-video.ts new file mode 100644 index 00000000000..1339d2520c4 --- /dev/null +++ b/apps/sim/lib/media/falai-video.ts @@ -0,0 +1,193 @@ +import { + downloadFalMedia, + extractFalMediaUrl, + getFalApiKey, + getNumberProp, + isRecord, + runFalQueue, +} from '@/lib/media/falai' +import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing' + +type DurationFormat = 'number' | 'seconds' | 'string' + +interface FalVideoModelConfig { + endpoint: string + /** Image-to-video endpoint variant, when the model supports a start-frame image. */ + i2vEndpoint?: string + durationFormat?: DurationFormat + supportsAspectRatio?: boolean + supportsResolution?: boolean + supportsGenerateAudio?: boolean + supportsNegativePrompt?: boolean + supportsPromptOptimizer?: boolean +} + +// Endpoints mirror app/api/tools/video/route.ts (FALAI_MODEL_CONFIGS), scoped to +// the latest-gen models the generate_video tool exposes. +const VIDEO_MODELS: Record = { + 'veo-3.1': { + endpoint: 'fal-ai/veo3.1', + i2vEndpoint: 'fal-ai/veo3.1/image-to-video', + durationFormat: 'seconds', + supportsAspectRatio: true, + supportsResolution: true, + supportsGenerateAudio: true, + supportsNegativePrompt: true, + }, + 'veo-3.1-fast': { + endpoint: 'fal-ai/veo3.1/fast', + i2vEndpoint: 'fal-ai/veo3.1/fast/image-to-video', + durationFormat: 'seconds', + supportsAspectRatio: true, + supportsResolution: true, + supportsGenerateAudio: true, + supportsNegativePrompt: true, + }, + 'veo-3.1-lite': { + endpoint: 'fal-ai/veo3.1/lite', + i2vEndpoint: 'fal-ai/veo3.1/lite/image-to-video', + durationFormat: 'seconds', + supportsAspectRatio: true, + supportsResolution: true, + supportsGenerateAudio: true, + supportsNegativePrompt: true, + }, + 'seedance-2.0': { + endpoint: 'bytedance/seedance-2.0/text-to-video', + i2vEndpoint: 'bytedance/seedance-2.0/image-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + supportsGenerateAudio: true, + }, + 'seedance-2.0-fast': { + endpoint: 'bytedance/seedance-2.0/fast/text-to-video', + i2vEndpoint: 'bytedance/seedance-2.0/fast/image-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsResolution: true, + supportsGenerateAudio: true, + }, + 'kling-v3-pro': { + endpoint: 'fal-ai/kling-video/v3/pro/text-to-video', + i2vEndpoint: 'fal-ai/kling-video/v3/pro/image-to-video', + durationFormat: 'string', + supportsAspectRatio: true, + supportsGenerateAudio: true, + }, + 'minimax-hailuo-2.3-pro': { + endpoint: 'fal-ai/minimax/hailuo-2.3/pro/text-to-video', + supportsPromptOptimizer: true, + }, + 'wan-2.2-a14b-turbo': { + endpoint: 'fal-ai/wan/v2.2-a14b/text-to-video/turbo', + supportsAspectRatio: true, + supportsResolution: true, + }, + 'ltx-2.3': { + endpoint: 'fal-ai/ltx-2.3/text-to-video', + durationFormat: 'number', + supportsAspectRatio: true, + supportsResolution: true, + supportsGenerateAudio: true, + }, +} + +// Default to Veo 3.1 Fast: the same Veo model family — good 1080p video with native +// 48kHz audio + lip-sync — at ~1/3 the cost of Standard (~$0.15/s vs ~$0.40/s). The +// gap is surface detail / 4K, not "good vs bad". The agent overrides to veo-3.1 +// (Standard) only when the user explicitly asks for very high / premium quality. +export const DEFAULT_VIDEO_MODEL = 'veo-3.1-fast' + +export interface GenerateFalVideoParams { + prompt: string + model?: string + aspectRatio?: string + resolution?: string + duration?: number + generateAudio?: boolean + /** Things to exclude from the generation, e.g. "no background music" (Veo models). */ + negativePrompt?: string + promptOptimizer?: boolean + /** Optional start-frame image as a data URI; when set, routes to the model's image-to-video endpoint. */ + imageDataUri?: string +} + +export interface GeneratedVideo { + buffer: Buffer + contentType: string + width?: number + height?: number + model: string + endpoint: string + jobId: string + cost: FalAICostMetadata +} + +function formatDuration( + format: DurationFormat | undefined, + duration?: number +): string | number | undefined { + if (!format || duration === undefined) return undefined + if (format === 'number') return duration + if (format === 'seconds') return `${duration}s` + return String(duration) +} + +export async function generateFalVideo(params: GenerateFalVideoParams): Promise { + const model = params.model || DEFAULT_VIDEO_MODEL + const config = VIDEO_MODELS[model] + if (!config) { + throw new Error( + `Unknown video model: ${model}. Supported: ${Object.keys(VIDEO_MODELS).join(', ')}` + ) + } + + const apiKey = getFalApiKey() + + let endpoint = config.endpoint + const input: Record = { prompt: params.prompt } + + if (params.imageDataUri) { + if (!config.i2vEndpoint) { + throw new Error( + `Image-to-video is not supported for model ${model}. Try veo-3.1, veo-3.1-fast, seedance-2.0, or kling-v3-pro.` + ) + } + endpoint = config.i2vEndpoint + input.image_url = params.imageDataUri + } + + const duration = formatDuration(config.durationFormat, params.duration) + if (duration !== undefined) input.duration = duration + if (config.supportsAspectRatio && params.aspectRatio) input.aspect_ratio = params.aspectRatio + if (config.supportsResolution && params.resolution) input.resolution = params.resolution + if (config.supportsGenerateAudio && params.generateAudio !== undefined) { + input.generate_audio = params.generateAudio + } + if (config.supportsNegativePrompt && params.negativePrompt) { + input.negative_prompt = params.negativePrompt + } + if (config.supportsPromptOptimizer && params.promptOptimizer !== undefined) { + input.prompt_optimizer = params.promptOptimizer + } + + const { requestId, data } = await runFalQueue(endpoint, input, apiKey) + const url = extractFalMediaUrl(data, ['video', 'output']) + if (!url) throw new Error('No video URL in Fal.ai response') + + const videoNode = isRecord(data.video) ? data.video : undefined + const { buffer, contentType } = await downloadFalMedia(url) + const cost = await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId }) + + return { + buffer, + contentType: contentType.startsWith('video/') ? contentType : 'video/mp4', + width: getNumberProp(videoNode, 'width'), + height: getNumberProp(videoNode, 'height'), + model, + endpoint, + jobId: requestId, + cost, + } +} diff --git a/apps/sim/lib/media/falai.ts b/apps/sim/lib/media/falai.ts new file mode 100644 index 00000000000..a2d09614f0b --- /dev/null +++ b/apps/sim/lib/media/falai.ts @@ -0,0 +1,233 @@ +import { createLogger } from '@sim/logger' +import { sleep } from '@sim/utils/helpers' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { + assertKnownSizeWithinLimit, + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, + readResponseToBufferWithLimit, +} from '@/lib/core/utils/stream-limits' + +const logger = createLogger('FalMediaClient') + +// Generated media (esp. video) can be large. +export const MAX_MEDIA_BYTES = 250 * 1024 * 1024 +const MAX_MEDIA_JSON_BYTES = 4 * 1024 * 1024 +const POLL_INTERVAL_MS = 3000 + +/** + * Resolves a hosted Fal.ai API key from the numbered env pool + * (FALAI_API_KEY_COUNT + FALAI_API_KEY_1..N), round-robined by minute, + * mirroring getRotatingApiKey. Falls back to a single FALAI_API_KEY for dev. + */ +export function getFalApiKey(): string { + const count = Number.parseInt(process.env.FALAI_API_KEY_COUNT || '0', 10) + const keys: string[] = [] + for (let i = 1; i <= count; i++) { + const key = process.env[`FALAI_API_KEY_${i}`] + if (key) keys.push(key) + } + if (keys.length === 0 && process.env.FALAI_API_KEY) { + keys.push(process.env.FALAI_API_KEY) + } + if (keys.length === 0) { + throw new Error( + 'No hosted Fal.ai API key configured. Set FALAI_API_KEY_COUNT and FALAI_API_KEY_1..N.' + ) + } + const index = new Date().getMinutes() % keys.length + return keys[index] +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function getStringProp( + record: Record | undefined, + key: string +): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +export function getNumberProp( + record: Record | undefined, + key: string +): number | undefined { + const value = record?.[key] + return typeof value === 'number' ? value : undefined +} + +function falQueueUrl(endpoint: string, requestId: string, path: 'status' | 'response'): string { + return `https://queue.fal.run/${endpoint}/requests/${requestId}/${path}` +} + +function falErrorMessage(error: unknown): string { + if (typeof error === 'string') return error + if (isRecord(error)) return getStringProp(error, 'message') || JSON.stringify(error) + return 'Unknown Fal.ai error' +} + +export interface FalQueueResult { + requestId: string + data: Record +} + +/** + * Submit input to a Fal.ai queue endpoint, poll to completion, and return the + * result JSON. Shared by the video and audio generators. + */ +export async function runFalQueue( + endpoint: string, + input: Record, + apiKey: string +): Promise { + const createResponse = await fetch(`https://queue.fal.run/${endpoint}`, { + method: 'POST', + headers: { Authorization: `Key ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (!createResponse.ok) { + const err = await readResponseTextWithLimit(createResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai create error response', + }).catch(() => '') + throw new Error(`Fal.ai API error: ${createResponse.status} - ${err}`) + } + + const createData = await readResponseJsonWithLimit(createResponse, { + maxBytes: MAX_MEDIA_JSON_BYTES, + label: 'Fal.ai create response', + }) + if (!isRecord(createData)) throw new Error('Invalid Fal.ai queue response') + + const requestId = getStringProp(createData, 'request_id') + if (!requestId) throw new Error('Fal.ai queue response missing request_id') + + const statusUrl = + getStringProp(createData, 'status_url') || falQueueUrl(endpoint, requestId, 'status') + const responseUrl = + getStringProp(createData, 'response_url') || falQueueUrl(endpoint, requestId, 'response') + + const maxAttempts = Math.ceil(getMaxExecutionTimeout() / POLL_INTERVAL_MS) + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await sleep(POLL_INTERVAL_MS) + + const statusResponse = await fetch(statusUrl, { headers: { Authorization: `Key ${apiKey}` } }) + if (!statusResponse.ok) { + const body = await readResponseTextWithLimit(statusResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai status error response', + }).catch(() => '') + throw new Error( + `Fal.ai status check failed: ${statusResponse.status}${body ? ` - ${body}` : ''}` + ) + } + + const statusData = await readResponseJsonWithLimit(statusResponse, { + maxBytes: MAX_MEDIA_JSON_BYTES, + label: 'Fal.ai status response', + }) + if (!isRecord(statusData)) throw new Error('Invalid Fal.ai status response') + + const status = getStringProp(statusData, 'status') + if (status === 'COMPLETED') { + if (statusData.error) { + throw new Error(`Fal.ai generation failed: ${falErrorMessage(statusData.error)}`) + } + const resultResponse = await fetch(getStringProp(statusData, 'response_url') || responseUrl, { + headers: { Authorization: `Key ${apiKey}` }, + }) + if (!resultResponse.ok) { + const body = await readResponseTextWithLimit(resultResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Fal.ai result error response', + }).catch(() => '') + throw new Error( + `Failed to fetch Fal.ai result: ${resultResponse.status}${body ? ` - ${body}` : ''}` + ) + } + const resultData = await readResponseJsonWithLimit(resultResponse, { + maxBytes: MAX_MEDIA_JSON_BYTES, + label: 'Fal.ai result response', + }) + if (!isRecord(resultData)) throw new Error('Invalid Fal.ai result response') + return { requestId, data: resultData } + } + + if (['ERROR', 'FAILED', 'CANCELLED'].includes(status || '')) { + throw new Error(`Fal.ai generation failed: ${falErrorMessage(statusData.error)}`) + } + } + + throw new Error('Fal.ai generation timed out') +} + +/** + * Pull the output media URL out of a Fal.ai result, tolerating the various + * shapes different models return (string url, { url }, nested arrays). + */ +export function extractFalMediaUrl( + data: Record, + keys: string[] +): string | undefined { + for (const key of keys) { + const value = data[key] + if (typeof value === 'string') return value + if (isRecord(value)) { + const url = getStringProp(value, 'url') + if (url) return url + } + if (Array.isArray(value)) { + const first = value.find(isRecord) as Record | undefined + const url = getStringProp(first, 'url') + if (url) return url + } + } + return undefined +} + +/** Securely download a generated media URL (or inline data URI) to a buffer. */ +export async function downloadFalMedia( + url: string +): Promise<{ buffer: Buffer; contentType: string }> { + if (url.startsWith('data:')) { + const match = /^data:([^;]+);base64,(.+)$/u.exec(url) + if (!match) throw new Error('Invalid data URI media response') + const buffer = Buffer.from(match[2], 'base64') + assertKnownSizeWithinLimit(buffer.length, MAX_MEDIA_BYTES, 'inline media response') + return { contentType: match[1], buffer } + } + + const validation = await validateUrlWithDNS(url, 'mediaUrl') + if (!validation.isValid || !validation.resolvedIP) { + throw new Error(validation.error || 'Generated media URL failed validation') + } + + const response = await secureFetchWithPinnedIP(url, validation.resolvedIP, { + method: 'GET', + maxResponseBytes: MAX_MEDIA_BYTES, + }) + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'generated media error response', + }).catch(() => '') + throw new Error(`Failed to download generated media: ${response.status}`) + } + + const contentType = response.headers.get('content-type') || 'application/octet-stream' + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: MAX_MEDIA_BYTES, + label: 'generated media download', + }) + return { buffer, contentType } +} + +export { logger as falMediaLogger } diff --git a/apps/sim/lib/media/ffmpeg.ts b/apps/sim/lib/media/ffmpeg.ts new file mode 100644 index 00000000000..2f3bd000285 --- /dev/null +++ b/apps/sim/lib/media/ffmpeg.ts @@ -0,0 +1,573 @@ +import { execSync } from 'node:child_process' +import fsSync from 'node:fs' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createLogger } from '@sim/logger' +import ffmpegStatic from 'ffmpeg-static' +import ffmpeg from 'fluent-ffmpeg' + +const logger = createLogger('MediaFfmpeg') + +let ffmpegInitialized = false +let ffmpegPath: string | null = null + +/** Lazy FFmpeg binary resolution (ffmpeg-static, then system), mirroring lib/audio/extractor.ts. */ +function ensureFfmpeg(): void { + if (ffmpegInitialized) { + if (!ffmpegPath) { + throw new Error( + 'FFmpeg not found. Install: brew install ffmpeg (macOS) / apk add ffmpeg (Alpine) / apt-get install ffmpeg (Ubuntu)' + ) + } + return + } + ffmpegInitialized = true + + if (ffmpegStatic && typeof ffmpegStatic === 'string') { + try { + fsSync.accessSync(ffmpegStatic, fsSync.constants.X_OK) + ffmpegPath = ffmpegStatic + ffmpeg.setFfmpegPath(ffmpegPath) + return + } catch { + // fall through to system ffmpeg + } + } + + try { + const cmd = process.platform === 'win32' ? 'where ffmpeg' : 'which ffmpeg' + ffmpegPath = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n')[0] + ffmpeg.setFfmpegPath(ffmpegPath) + } catch { + logger.warn('[FFmpeg] No FFmpeg binary found at init time') + } +} + +export type FfmpegOperation = + | 'overlay_audio' + | 'mux' + | 'mix_audio' + | 'concat' + | 'trim' + | 'scale_pad' + | 'overlay_image' + | 'add_text' + | 'fade' + | 'extract_audio' + | 'convert' + | 'thumbnail' + | 'probe' + +export interface MediaFile { + buffer: Buffer + mimeType: string + name?: string +} + +export interface FfmpegOptions { + text?: string + position?: string + start?: number + end?: number + width?: number + height?: number + aspectRatio?: string + volume?: number + musicVolume?: number + loopToVideo?: boolean + format?: string +} + +export interface MediaProbe { + durationSeconds: number + format: string + width?: number + height?: number + videoCodec?: string + audioCodec?: string + hasAudio: boolean + hasVideo: boolean +} + +export interface FfmpegResult { + buffer?: Buffer + contentType?: string + ext?: string + probe?: MediaProbe +} + +const MIME_TO_EXT: Record = { + 'video/mp4': 'mp4', + 'video/mpeg': 'mp4', + 'video/quicktime': 'mov', + 'video/x-quicktime': 'mov', + 'video/x-msvideo': 'avi', + 'video/avi': 'avi', + 'video/x-matroska': 'mkv', + 'video/webm': 'webm', + 'audio/mpeg': 'mp3', + 'audio/mp3': 'mp3', + 'audio/mp4': 'm4a', + 'audio/x-m4a': 'm4a', + 'audio/wav': 'wav', + 'audio/x-wav': 'wav', + 'audio/wave': 'wav', + 'audio/ogg': 'ogg', + 'audio/flac': 'flac', + 'audio/x-flac': 'flac', + 'audio/aac': 'aac', + 'audio/opus': 'opus', + 'audio/webm': 'weba', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +} + +const EXT_TO_MIME: Record = { + mp4: 'video/mp4', + mov: 'video/quicktime', + webm: 'video/webm', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + wav: 'audio/wav', + ogg: 'audio/ogg', + flac: 'audio/flac', + aac: 'audio/aac', + opus: 'audio/opus', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', +} + +function extFromMime(mime: string): string { + return MIME_TO_EXT[mime] || mime.split('/')[1] || 'bin' +} + +function mimeFromExt(ext: string): string { + return EXT_TO_MIME[ext] || 'application/octet-stream' +} + +const ASPECT_TARGETS: Record = { + '16:9': { w: 1920, h: 1080 }, + '9:16': { w: 1080, h: 1920 }, + '1:1': { w: 1080, h: 1080 }, + '4:3': { w: 1440, h: 1080 }, + '3:4': { w: 1080, h: 1440 }, + '4:5': { w: 1080, h: 1350 }, + '21:9': { w: 2560, h: 1080 }, +} + +const OVERLAY_POSITION: Record = { + 'top-left': '10:10', + top: '(W-w)/2:10', + 'top-right': 'W-w-10:10', + center: '(W-w)/2:(H-h)/2', + 'bottom-left': '10:H-h-10', + bottom: '(W-w)/2:H-h-10', + 'bottom-right': 'W-w-10:H-h-10', +} + +const TEXT_POSITION: Record = { + top: { x: '(w-text_w)/2', y: 'h*0.08' }, + center: { x: '(w-text_w)/2', y: '(h-text_h)/2' }, + bottom: { x: '(w-text_w)/2', y: 'h*0.86' }, + 'top-left': { x: 'w*0.05', y: 'h*0.08' }, + 'top-right': { x: 'w*0.95-text_w', y: 'h*0.08' }, + 'bottom-left': { x: 'w*0.05', y: 'h*0.86' }, + 'bottom-right': { x: 'w*0.95-text_w', y: 'h*0.86' }, +} + +function escapeDrawtext(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/%/g, '\\%') +} + +async function withTempDir(fn: (dir: string) => Promise): Promise { + ensureFfmpeg() + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'media-ffmpeg-')) + try { + return await fn(dir) + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) + } +} + +async function writeInput(dir: string, file: MediaFile, index: number): Promise { + const ext = extFromMime(file.mimeType) + const filePath = path.join(dir, `in-${index}.${ext}`) + await fs.writeFile(filePath, file.buffer) + return filePath +} + +function runCommand(command: ffmpeg.FfmpegCommand, outputPath: string): Promise { + return new Promise((resolve, reject) => { + command + .on('end', () => resolve()) + .on('error', (err) => reject(new Error(`FFmpeg error: ${err.message}`))) + .save(outputPath) + }) +} + +export async function probeMedia(file: MediaFile): Promise { + return withTempDir(async (dir) => { + const inputPath = await writeInput(dir, file, 0) + return probeFile(inputPath) + }) +} + +function probeFile(filePath: string): Promise { + ensureFfmpeg() + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) { + reject(new Error(`FFprobe error: ${err.message}`)) + return + } + const video = metadata.streams.find((s) => s.codec_type === 'video') + const audio = metadata.streams.find((s) => s.codec_type === 'audio') + resolve({ + durationSeconds: Number(metadata.format?.duration) || 0, + format: metadata.format?.format_name || 'unknown', + width: video?.width, + height: video?.height, + videoCodec: video?.codec_name, + audioCodec: audio?.codec_name, + hasAudio: Boolean(audio), + hasVideo: Boolean(video), + }) + }) + }) +} + +/** + * Run a single FFmpeg media operation on the provided input files. + * All inputs/outputs are buffers; temp files are created and cleaned up internally. + */ +export async function runFfmpegOperation( + operation: FfmpegOperation, + inputs: MediaFile[], + options: FfmpegOptions = {} +): Promise { + if (inputs.length === 0) { + throw new Error('At least one input file is required') + } + + if (operation === 'probe') { + return { probe: await probeMedia(inputs[0]) } + } + + return withTempDir(async (dir) => { + const inputPaths = await Promise.all(inputs.map((f, i) => writeInput(dir, f, i))) + + switch (operation) { + case 'overlay_audio': + case 'mux': + return overlayAudio(dir, inputPaths, options) + case 'mix_audio': + return mixAudio(dir, inputPaths, options) + case 'concat': + return concat(dir, inputPaths) + case 'trim': + return trim(dir, inputPaths[0], inputs[0], options) + case 'scale_pad': + return scalePad(dir, inputPaths[0], options) + case 'overlay_image': + return overlayImage(dir, inputPaths, options) + case 'add_text': + return addText(dir, inputPaths[0], options) + case 'fade': + return fade(dir, inputPaths[0], inputs[0], options) + case 'extract_audio': + return extractAudio(dir, inputPaths[0], options) + case 'convert': + return convert(dir, inputPaths[0], options) + case 'thumbnail': + return thumbnail(dir, inputPaths[0], options) + default: + throw new Error(`Unsupported ffmpeg operation: ${operation}`) + } + }) +} + +async function readOut(outputPath: string, ext: string): Promise { + const buffer = await fs.readFile(outputPath) + return { buffer, ext, contentType: mimeFromExt(ext) } +} + +async function overlayAudio( + dir: string, + inputPaths: string[], + options: FfmpegOptions +): Promise { + if (inputPaths.length < 2) throw new Error('overlay_audio requires [video, audio]') + const outputPath = path.join(dir, 'out.mp4') + const command = ffmpeg().input(inputPaths[0]) + if (options.loopToVideo) { + command.input(inputPaths[1]).inputOptions(['-stream_loop', '-1']) + } else { + command.input(inputPaths[1]) + } + command.outputOptions([ + '-map', + '0:v:0', + '-map', + '1:a:0', + '-c:v', + 'copy', + '-c:a', + 'aac', + '-shortest', + ]) + await runCommand(command, outputPath) + return readOut(outputPath, 'mp4') +} + +async function mixAudio( + dir: string, + inputPaths: string[], + options: FfmpegOptions +): Promise { + if (inputPaths.length < 2) throw new Error('mix_audio requires [voice, music]') + const outputPath = path.join(dir, 'out.mp3') + const voiceVol = options.volume ?? 1 + const musicVol = options.musicVolume ?? 0.3 + const command = ffmpeg() + .input(inputPaths[0]) + .input(inputPaths[1]) + .complexFilter([ + `[0:a]volume=${voiceVol}[v]`, + `[1:a]volume=${musicVol}[m]`, + `[v][m]amix=inputs=2:duration=longest:dropout_transition=0[a]`, + ]) + .outputOptions(['-map', '[a]']) + await runCommand(command, outputPath) + return readOut(outputPath, 'mp3') +} + +async function concat(dir: string, inputPaths: string[]): Promise { + if (inputPaths.length < 2) throw new Error('concat requires at least 2 clips') + const probes = await Promise.all(inputPaths.map(probeFile)) + probes.forEach((p, i) => { + if (!p.hasVideo) { + throw new Error( + `concat input ${i} has no video stream; concat joins video clips (use mix_audio/overlay_audio for audio-only files).` + ) + } + }) + const width = probes[0].width || 1280 + const height = probes[0].height || 720 + const fps = 30 + + // Normalize every clip to identical codec/size/fps/pixfmt, and SYNTHESIZE silent + // audio for clips that have no audio stream. Clips generated without native audio + // (generateAudio:false) otherwise break the concat filtergraph (it referenced a + // non-existent [i:a]), which is the "Error binding filtergraph inputs/outputs" failure. + const normalized: string[] = [] + for (let i = 0; i < inputPaths.length; i++) { + const out = path.join(dir, `norm-${i}.mp4`) + const cmd = ffmpeg().input(inputPaths[i]) + const maps: string[] = ['-map', '0:v:0'] + const extra: string[] = [] + if (probes[i].hasAudio) { + maps.push('-map', '0:a:0') + } else { + cmd + .input('anullsrc=channel_layout=stereo:sample_rate=48000') + .inputOptions(['-f', 'lavfi', '-t', String(probes[i].durationSeconds || 1)]) + maps.push('-map', '1:a:0') + extra.push('-shortest') + } + cmd + .videoFilters( + `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},format=yuv420p` + ) + .outputOptions([ + ...maps, + '-c:v', + 'libx264', + '-preset', + 'medium', + '-crf', + '18', + '-pix_fmt', + 'yuv420p', + '-r', + String(fps), + '-video_track_timescale', + '90000', + '-c:a', + 'aac', + '-b:a', + '192k', + '-ar', + '48000', + '-ac', + '2', + ...extra, + ]) + await runCommand(cmd, out) + normalized.push(out) + } + + // Concatenate the now-uniform clips with the concat demuxer (stream copy: fast + reliable). + const listPath = path.join(dir, 'concat-list.txt') + await fs.writeFile( + listPath, + normalized.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n') + ) + const outputPath = path.join(dir, 'out.mp4') + const concatCmd = ffmpeg() + .input(listPath) + .inputOptions(['-f', 'concat', '-safe', '0']) + .outputOptions(['-c', 'copy', '-movflags', '+faststart']) + await runCommand(concatCmd, outputPath) + return readOut(outputPath, 'mp4') +} + +async function trim( + dir: string, + inputPath: string, + input: MediaFile, + options: FfmpegOptions +): Promise { + const ext = extFromMime(input.mimeType) + const outputPath = path.join(dir, `out.${ext}`) + const start = options.start ?? 0 + const command = ffmpeg(inputPath).setStartTime(start) + if (options.end !== undefined) { + command.setDuration(Math.max(0, options.end - start)) + } + await runCommand(command, outputPath) + return readOut(outputPath, ext) +} + +async function scalePad( + dir: string, + inputPath: string, + options: FfmpegOptions +): Promise { + let width = options.width + let height = options.height + if ((!width || !height) && options.aspectRatio && ASPECT_TARGETS[options.aspectRatio]) { + width = ASPECT_TARGETS[options.aspectRatio].w + height = ASPECT_TARGETS[options.aspectRatio].h + } + if (!width || !height) { + throw new Error('scale_pad requires width+height or a known aspectRatio (e.g. 9:16)') + } + const outputPath = path.join(dir, 'out.mp4') + const command = ffmpeg(inputPath) + .videoFilters( + `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1` + ) + .outputOptions(['-c:a', 'copy']) + await runCommand(command, outputPath) + return readOut(outputPath, 'mp4') +} + +async function overlayImage( + dir: string, + inputPaths: string[], + options: FfmpegOptions +): Promise { + if (inputPaths.length < 2) throw new Error('overlay_image requires [video, image]') + const xy = OVERLAY_POSITION[options.position || 'top-right'] || OVERLAY_POSITION['top-right'] + const outputPath = path.join(dir, 'out.mp4') + const command = ffmpeg() + .input(inputPaths[0]) + .input(inputPaths[1]) + .complexFilter([`[0:v][1:v]overlay=${xy}[v]`]) + .outputOptions(['-map', '[v]', '-map', '0:a?', '-c:a', 'copy']) + await runCommand(command, outputPath) + return readOut(outputPath, 'mp4') +} + +async function addText( + dir: string, + inputPath: string, + options: FfmpegOptions +): Promise { + if (!options.text) throw new Error('add_text requires text') + const pos = TEXT_POSITION[options.position || 'bottom'] || TEXT_POSITION.bottom + const drawtext = [ + `text='${escapeDrawtext(options.text)}'`, + 'fontcolor=white', + 'fontsize=h/18', + 'box=1', + 'boxcolor=black@0.5', + 'boxborderw=20', + `x=${pos.x}`, + `y=${pos.y}`, + ].join(':') + const outputPath = path.join(dir, 'out.mp4') + const command = ffmpeg(inputPath) + .videoFilters(`drawtext=${drawtext}`) + .outputOptions(['-c:a', 'copy']) + await runCommand(command, outputPath) + return readOut(outputPath, 'mp4') +} + +async function fade( + dir: string, + inputPath: string, + input: MediaFile, + _options: FfmpegOptions +): Promise { + const probe = await probeFile(inputPath) + const duration = probe.durationSeconds || 0 + const fadeDur = Math.min(0.5, duration / 4 || 0.5) + const outStart = Math.max(0, duration - fadeDur) + const isVideo = input.mimeType.startsWith('video/') || probe.hasVideo + const ext = isVideo ? 'mp4' : extFromMime(input.mimeType) + const outputPath = path.join(dir, `out.${ext}`) + const command = ffmpeg(inputPath) + if (isVideo) { + command.videoFilters([`fade=t=in:st=0:d=${fadeDur}`, `fade=t=out:st=${outStart}:d=${fadeDur}`]) + } + command.audioFilters([`afade=t=in:st=0:d=${fadeDur}`, `afade=t=out:st=${outStart}:d=${fadeDur}`]) + await runCommand(command, outputPath) + return readOut(outputPath, ext) +} + +async function extractAudio( + dir: string, + inputPath: string, + options: FfmpegOptions +): Promise { + const ext = (options.format || 'mp3').toLowerCase() + const outputPath = path.join(dir, `out.${ext}`) + const command = ffmpeg(inputPath).noVideo() + await runCommand(command, outputPath) + return readOut(outputPath, ext) +} + +async function convert( + dir: string, + inputPath: string, + options: FfmpegOptions +): Promise { + if (!options.format) throw new Error('convert requires a target format') + const ext = options.format.toLowerCase() + const outputPath = path.join(dir, `out.${ext}`) + await runCommand(ffmpeg(inputPath), outputPath) + return readOut(outputPath, ext) +} + +async function thumbnail( + dir: string, + inputPath: string, + options: FfmpegOptions +): Promise { + const outputPath = path.join(dir, 'out.jpg') + const command = ffmpeg(inputPath) + .seekInput(options.start ?? 0) + .frames(1) + await runCommand(command, outputPath) + return readOut(outputPath, 'jpg') +} + +export { extFromMime, mimeFromExt } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 871dfa033a7..db9583a8fe2 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -15,12 +15,12 @@ import { chatPubSub } from '@/lib/copilot/chat-status' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' import type { OrchestratorResult } from '@/lib/copilot/request/types' -import { isHosted } from '@/lib/core/config/feature-flags' +import { isE2BDocEnabled, isHosted } from '@/lib/core/config/feature-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' -import { buildMothershipToolsForRequest } from '@/lib/mothership/settings/runtime' +import { buildUserSkillTool } from '@/lib/mothership/skills' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -97,7 +97,7 @@ export async function executeInboxTask(taskId: string): Promise { const chatResult = await resolveOrCreateChat({ userId, workspaceId: ws.id, - model: 'claude-opus-4-6', + model: 'claude-opus-4-8', type: 'mothership', }) chatId = chatResult.chatId @@ -111,7 +111,7 @@ export async function executeInboxTask(taskId: string): Promise { requestChatTitle({ message: titleInput, - model: 'claude-opus-4-6', + model: 'claude-opus-4-8', userId, }) .then(async (title) => { @@ -169,26 +169,15 @@ export async function executeInboxTask(taskId: string): Promise { return { attachments, ...downloaded } } - const [ - attachmentResult, - workspaceContext, - integrationTools, - mothershipToolRuntime, - userPermission, - ] = await Promise.all([ - fetchAttachments(), - generateWorkspaceContext(ws.id, userId), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - buildMothershipToolsForRequest({ workspaceId: ws.id, userId }), - getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), - ]) + const [attachmentResult, workspaceContext, integrationTools, userSkillTool, userPermission] = + await Promise.all([ + fetchAttachments(), + generateWorkspaceContext(ws.id, userId), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + buildUserSkillTool(ws.id), + getUserEntityPermissions(userId, 'workspace', ws.id).catch(() => null), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult - const workspaceContextWithMothershipTools = [ - workspaceContext, - mothershipToolRuntime.catalogContext, - ] - .filter(Boolean) - .join('\n\n') const truncatedTask = { ...inboxTask, @@ -204,11 +193,10 @@ export async function executeInboxTask(taskId: string): Promise { mode: 'agent', messageId: userMessageId, isHosted, - workspaceContext: workspaceContextWithMothershipTools, + workspaceContext, + ...(isE2BDocEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(mothershipToolRuntime.tools.length > 0 - ? { mothershipTools: mothershipToolRuntime.tools } - : {}), + ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}), ...(userPermission ? { userPermission } : {}), ...(fileAttachments.length > 0 ? { fileAttachments } : {}), } diff --git a/apps/sim/lib/mothership/settings/operations.ts b/apps/sim/lib/mothership/settings/operations.ts deleted file mode 100644 index 5ab090634f5..00000000000 --- a/apps/sim/lib/mothership/settings/operations.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { customTools, db, mcpServers, mothershipSettings, skill } from '@sim/db' -import { and, eq, inArray, isNull } from 'drizzle-orm' -import type { - MothershipCustomToolRef, - MothershipMcpToolRef, - MothershipSettings, - MothershipSkillRef, -} from '@/lib/api/contracts/mothership-settings' - -type MothershipSettingsInput = { - workspaceId: string - mcpTools: MothershipMcpToolRef[] - customTools: MothershipCustomToolRef[] - skills: MothershipSkillRef[] -} - -function dedupeBy(items: T[], getKey: (item: T) => string): T[] { - const seen = new Set() - const result: T[] = [] - for (const item of items) { - const key = getKey(item) - if (seen.has(key)) continue - seen.add(key) - result.push(item) - } - return result -} - -function defaultSettings(workspaceId: string): MothershipSettings { - return { - workspaceId, - mcpTools: [], - customTools: [], - skills: [], - } -} - -function mapRowToSettings(row: typeof mothershipSettings.$inferSelect): MothershipSettings { - return { - workspaceId: row.workspaceId, - mcpTools: Array.isArray(row.mcpToolRefs) ? (row.mcpToolRefs as MothershipMcpToolRef[]) : [], - customTools: Array.isArray(row.customToolRefs) - ? (row.customToolRefs as MothershipCustomToolRef[]) - : [], - skills: Array.isArray(row.skillRefs) ? (row.skillRefs as MothershipSkillRef[]) : [], - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} - -export async function getMothershipSettings(workspaceId: string): Promise { - const [row] = await db - .select() - .from(mothershipSettings) - .where(eq(mothershipSettings.workspaceId, workspaceId)) - .limit(1) - - return row ? mapRowToSettings(row) : defaultSettings(workspaceId) -} - -export async function updateMothershipSettings( - input: MothershipSettingsInput -): Promise { - const mcpTools = await filterMcpToolRefs(input.workspaceId, input.mcpTools) - const customToolRefs = await filterCustomToolRefs(input.workspaceId, input.customTools) - const skillRefs = await filterSkillRefs(input.workspaceId, input.skills) - const now = new Date() - - const [row] = await db - .insert(mothershipSettings) - .values({ - workspaceId: input.workspaceId, - mcpToolRefs: mcpTools, - customToolRefs, - skillRefs, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: mothershipSettings.workspaceId, - set: { - mcpToolRefs: mcpTools, - customToolRefs, - skillRefs, - updatedAt: now, - }, - }) - .returning() - - return mapRowToSettings(row) -} - -async function filterMcpToolRefs( - workspaceId: string, - refs: MothershipMcpToolRef[] -): Promise { - const deduped = dedupeBy(refs, (ref) => `${ref.serverId}:${ref.toolName}`) - const serverIds = [...new Set(deduped.map((ref) => ref.serverId))] - if (serverIds.length === 0) return [] - - const serverRows = await db - .select({ id: mcpServers.id, name: mcpServers.name }) - .from(mcpServers) - .where( - and( - eq(mcpServers.workspaceId, workspaceId), - inArray(mcpServers.id, serverIds), - isNull(mcpServers.deletedAt) - ) - ) - - const serversById = new Map(serverRows.map((server) => [server.id, server.name])) - return deduped - .filter((ref) => serversById.has(ref.serverId)) - .map((ref) => ({ - serverId: ref.serverId, - serverName: serversById.get(ref.serverId) ?? ref.serverName, - toolName: ref.toolName, - title: ref.title ?? ref.toolName, - })) -} - -async function filterCustomToolRefs( - workspaceId: string, - refs: MothershipCustomToolRef[] -): Promise { - const deduped = dedupeBy(refs, (ref) => ref.customToolId) - const toolIds = deduped.map((ref) => ref.customToolId) - if (toolIds.length === 0) return [] - - const toolRows = await db - .select({ id: customTools.id, title: customTools.title }) - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, toolIds))) - - const titlesById = new Map(toolRows.map((tool) => [tool.id, tool.title])) - return deduped - .filter((ref) => titlesById.has(ref.customToolId)) - .map((ref) => ({ - customToolId: ref.customToolId, - title: titlesById.get(ref.customToolId) ?? ref.title, - })) -} - -async function filterSkillRefs( - workspaceId: string, - refs: MothershipSkillRef[] -): Promise { - const deduped = dedupeBy(refs, (ref) => ref.skillId) - const skillIds = deduped.map((ref) => ref.skillId) - if (skillIds.length === 0) return [] - - const skillRows = await db - .select({ id: skill.id, name: skill.name }) - .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))) - - const namesById = new Map(skillRows.map((row) => [row.id, row.name])) - return deduped - .filter((ref) => namesById.has(ref.skillId)) - .map((ref) => ({ - skillId: ref.skillId, - name: namesById.get(ref.skillId) ?? ref.name, - })) -} diff --git a/apps/sim/lib/mothership/settings/runtime.test.ts b/apps/sim/lib/mothership/settings/runtime.test.ts deleted file mode 100644 index 258abc4d020..00000000000 --- a/apps/sim/lib/mothership/settings/runtime.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildMothershipToolsForRequest } from './runtime' - -const { dbMock, getMothershipSettingsMock, mockRows } = vi.hoisted(() => { - const mockRows: any[] = [] - const dbMock = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - leftJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(async () => mockRows), - })), - })), - })), - })), - } - const getMothershipSettingsMock = vi.fn(async (workspaceId: string) => ({ - workspaceId, - mcpTools: [], - customTools: [], - skills: [], - })) - return { dbMock, getMothershipSettingsMock, mockRows } -}) - -vi.mock('@sim/db', () => ({ - db: dbMock, - customTools: { - id: 'customTools.id', - workspaceId: 'customTools.workspaceId', - title: 'customTools.title', - }, - settings: { - userId: 'settings.userId', - superUserModeEnabled: 'settings.superUserModeEnabled', - }, - skill: { - id: 'skill.id', - workspaceId: 'skill.workspaceId', - name: 'skill.name', - description: 'skill.description', - }, - user: { - id: 'user.id', - role: 'user.role', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(() => ({})), - eq: vi.fn(() => ({})), - inArray: vi.fn(() => ({})), -})) - -vi.mock('@/lib/mcp/utils', () => ({ - createMcpToolId: (serverId: string, toolName: string) => `${serverId}_${toolName}`, -})) - -vi.mock('@/executor/constants', () => ({ - AGENT: { CUSTOM_TOOL_PREFIX: 'custom_' }, -})) - -vi.mock('./operations', () => ({ - getMothershipSettings: getMothershipSettingsMock, -})) - -describe('buildMothershipToolsForRequest', () => { - beforeEach(() => { - mockRows.length = 0 - dbMock.select.mockClear() - getMothershipSettingsMock.mockClear() - }) - - it('does not expose configured tools to non-superusers', async () => { - mockRows.push({ role: 'user', superUserModeEnabled: true }) - - await expect( - buildMothershipToolsForRequest({ workspaceId: 'workspace-1', userId: 'user-1' }) - ).resolves.toEqual({ tools: [] }) - - expect(getMothershipSettingsMock).not.toHaveBeenCalled() - }) - - it('loads workspace settings for effective superusers', async () => { - mockRows.push({ role: 'admin', superUserModeEnabled: true }) - - await buildMothershipToolsForRequest({ workspaceId: 'workspace-1', userId: 'admin-1' }) - - expect(getMothershipSettingsMock).toHaveBeenCalledWith('workspace-1') - }) -}) diff --git a/apps/sim/lib/mothership/settings/runtime.ts b/apps/sim/lib/mothership/settings/runtime.ts deleted file mode 100644 index a20b95f4fe2..00000000000 --- a/apps/sim/lib/mothership/settings/runtime.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { customTools, db, skill, user, settings as userSettings } from '@sim/db' -import { and, eq, inArray } from 'drizzle-orm' -import type { ToolSchema } from '@/lib/copilot/chat/payload' -import { createMcpToolId } from '@/lib/mcp/utils' -import { AGENT } from '@/executor/constants' -import { getMothershipSettings } from './operations' - -interface BuildMothershipToolsParams { - workspaceId: string - userId: string -} - -interface MothershipToolRuntimePayload { - tools: ToolSchema[] - catalogContext?: string -} - -function isObjectSchema(value: unknown): Record { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Record - } - return { type: 'object', properties: {} } -} - -function customToolParameters(schema: unknown): Record { - if (!schema || typeof schema !== 'object') return { type: 'object', properties: {} } - const fn = (schema as { function?: { parameters?: unknown } }).function - return isObjectSchema(fn?.parameters) -} - -function customToolDescription(schema: unknown, fallback: string): string { - if (!schema || typeof schema !== 'object') return fallback - const description = (schema as { function?: { description?: unknown } }).function?.description - return typeof description === 'string' && description.trim() ? description : fallback -} - -async function isEffectiveSuperUser(userId: string): Promise { - if (!userId) return false - - const [row] = await db - .select({ - role: user.role, - superUserModeEnabled: userSettings.superUserModeEnabled, - }) - .from(user) - .leftJoin(userSettings, eq(userSettings.userId, user.id)) - .where(eq(user.id, userId)) - .limit(1) - - return row?.role === 'admin' && (row.superUserModeEnabled ?? false) -} - -export async function buildMothershipToolsForRequest({ - workspaceId, - userId, -}: BuildMothershipToolsParams): Promise { - if (!(await isEffectiveSuperUser(userId))) { - return { tools: [] } - } - - const settings = await getMothershipSettings(workspaceId) - const tools: ToolSchema[] = [] - const catalogLines: string[] = [] - - if (settings.mcpTools.length > 0) { - const selectedKeys = new Set( - settings.mcpTools.map((tool) => `${tool.serverId}:${tool.toolName}`) - ) - const { mcpService } = await import('@/lib/mcp/service') - const discoveredTools = await mcpService.discoverTools(userId, workspaceId) - for (const tool of discoveredTools) { - if (!selectedKeys.has(`${tool.serverId}:${tool.name}`)) continue - const catalogName = `${tool.serverName} / ${tool.name}` - tools.push({ - name: createMcpToolId(tool.serverId, tool.name), - description: tool.description || `MCP tool: ${tool.name} (${tool.serverName})`, - input_schema: { ...tool.inputSchema }, - defer_loading: true, - params: { - mothershipToolKind: 'mcp', - mothershipToolName: catalogName, - mothershipToolTitle: tool.name, - }, - }) - catalogLines.push(`- MCP: ${catalogName} (load with type "mcp" and name "${catalogName}")`) - } - } - - if (settings.customTools.length > 0) { - const customToolIds = settings.customTools.map((tool) => tool.customToolId) - const rows = await db - .select() - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), inArray(customTools.id, customToolIds))) - - for (const tool of rows) { - tools.push({ - name: `${AGENT.CUSTOM_TOOL_PREFIX}${tool.id}`, - description: customToolDescription(tool.schema, tool.title), - input_schema: customToolParameters(tool.schema), - defer_loading: true, - params: { - mothershipToolKind: 'custom_tool', - mothershipToolName: tool.title, - mothershipToolTitle: tool.title, - }, - }) - catalogLines.push( - `- Custom tool: ${tool.title} (load with type "custom_tool" and name "${tool.title}")` - ) - } - } - - if (settings.skills.length > 0) { - const skillIds = settings.skills.map((s) => s.skillId) - const rows = await db - .select({ id: skill.id, name: skill.name, description: skill.description }) - .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), inArray(skill.id, skillIds))) - - for (const s of rows) { - tools.push({ - name: `load_skill_${s.id}`, - description: `Load the "${s.name}" skill to get specialized instructions. ${s.description}`, - input_schema: { type: 'object', properties: {} }, - defer_loading: true, - params: { - mothershipToolKind: 'skill', - mothershipToolName: s.name, - mothershipToolTitle: s.name, - }, - }) - catalogLines.push( - `- Skill: ${s.name} - ${s.description} (load with type "skill" and name "${s.name}")` - ) - } - } - - return { - tools, - catalogContext: - catalogLines.length > 0 - ? [ - '## Mothership Tool Catalog', - 'The following workspace tools are available on request. Use `load_custom_tool` to load one before calling it.', - ...catalogLines, - ].join('\n') - : undefined, - } -} diff --git a/apps/sim/lib/mothership/skills.test.ts b/apps/sim/lib/mothership/skills.test.ts new file mode 100644 index 00000000000..67f66e146d2 --- /dev/null +++ b/apps/sim/lib/mothership/skills.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { whereMock } = vi.hoisted(() => ({ whereMock: vi.fn() })) + +vi.mock('@sim/db', () => ({ + db: { select: () => ({ from: () => ({ where: whereMock }) }) }, + skill: { workspaceId: 'workspaceId', name: 'name', description: 'description' }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) +vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) })) + +import { buildUserSkillTool, LOAD_USER_SKILL_TOOL_NAME } from './skills' + +describe('buildUserSkillTool', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null without a workspace id', async () => { + expect(await buildUserSkillTool('')).toBeNull() + expect(whereMock).not.toHaveBeenCalled() + }) + + it('returns null when the workspace has no user skills', async () => { + whereMock.mockResolvedValue([]) + expect(await buildUserSkillTool('ws-1')).toBeNull() + }) + + it('builds one load_user_skill tool with an enum of workspace skill names', async () => { + whereMock.mockResolvedValue([ + { name: 'posthog-playbook', description: 'PostHog steps' }, + { name: 'brand-voice', description: 'Tone rules' }, + ]) + + const tool = await buildUserSkillTool('ws-1') + + expect(tool?.name).toBe(LOAD_USER_SKILL_TOOL_NAME) + // Must NOT be executeLocally — it dispatches with executor "sim", not the browser client. + expect(tool?.executeLocally).toBeUndefined() + expect(tool?.params).toMatchObject({ mothershipToolKind: 'skill' }) + expect(tool?.input_schema).toMatchObject({ + type: 'object', + properties: { skill_name: { enum: ['posthog-playbook', 'brand-voice'] } }, + required: ['skill_name'], + }) + expect(tool?.description).toContain('posthog-playbook') + expect(tool?.description).toContain('brand-voice') + }) + + it('returns null when the skill query fails', async () => { + whereMock.mockRejectedValue(new Error('db down')) + expect(await buildUserSkillTool('ws-1')).toBeNull() + }) +}) diff --git a/apps/sim/lib/mothership/skills.ts b/apps/sim/lib/mothership/skills.ts new file mode 100644 index 00000000000..cdb2b42ef95 --- /dev/null +++ b/apps/sim/lib/mothership/skills.ts @@ -0,0 +1,67 @@ +import { db, skill } from '@sim/db' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { ToolSchema } from '@/lib/copilot/chat/payload' + +const logger = createLogger('MothershipUserSkills') + +export const LOAD_USER_SKILL_TOOL_NAME = 'load_user_skill' + +/** + * Build the single load_user_skill tool that exposes all workspace + * user-created skills to the mothership and its subagents. + * + * User skills live in the `skill` table (builtins are code-only and are treated + * as defaults, so they are excluded here). The tool is a non-deferred, + * sim-executed request-local tool: when the model calls it, Go forwards the + * call and sim resolves the content via resolveSkillContent (see tools/index.ts). + * It is available to all users — there is no super-admin gate, unlike the curated + * mothership_settings tools. + * + * Embedded copilot/internal skills are not handled here: those are autoloaded + * into each agent's system prompt on the Go side and never sent as loadable. + */ +export async function buildUserSkillTool(workspaceId: string): Promise { + if (!workspaceId) return null + + let rows: { name: string; description: string }[] + try { + rows = await db + .select({ name: skill.name, description: skill.description }) + .from(skill) + .where(eq(skill.workspaceId, workspaceId)) + } catch (error) { + logger.error('Failed to load workspace skills for load_user_skill tool', { error, workspaceId }) + return null + } + + if (rows.length === 0) return null + + const skillNames = rows.map((r) => r.name) + const catalog = rows.map((r) => `- ${r.name}: ${r.description}`).join('\n') + + return { + name: LOAD_USER_SKILL_TOOL_NAME, + description: `Load a user-created skill's full instructions. You MUST call this before following a skill: the list below only tells you which skills exist and when each applies — it is NOT the instructions. To use a skill, call load_user_skill with its exact name and follow the content it returns; never act on a skill's name or description alone. Available skills:\n${catalog}`, + input_schema: { + type: 'object', + properties: { + skill_name: { + type: 'string', + description: 'Exact name of the user skill to load.', + enum: skillNames, + }, + }, + required: ['skill_name'], + additionalProperties: false, + }, + // Do NOT set executeLocally: skill content is resolved on the sim backend + // (DB), so Go must dispatch this with executor "sim". executeLocally maps to + // ClientExecutable, which routes the call to the browser client (no handler) + // and the load hangs. mothershipToolKind 'skill' is enough for sim routing. + params: { + mothershipToolKind: 'skill', + mothershipToolName: LOAD_USER_SKILL_TOOL_NAME, + }, + } +} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index dad68cdf073..68c1506139d 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -567,6 +567,8 @@ export interface PostHogEventMap { | 'knowledge_base' | 'page' | 'docs' + | 'connected_account' + | 'integration' query_length: number workspace_id: string } diff --git a/apps/sim/lib/pptx-renderer/core/viewer.ts b/apps/sim/lib/pptx-renderer/core/viewer.ts index 6c401a78765..efdb5ac5e02 100644 --- a/apps/sim/lib/pptx-renderer/core/viewer.ts +++ b/apps/sim/lib/pptx-renderer/core/viewer.ts @@ -587,6 +587,31 @@ export class PptxViewer extends EventTarget { } } + /** + * Yield to the event loop between render batches so the browser can paint. + * + * We intentionally do NOT rely solely on `requestAnimationFrame`: rAF callbacks + * are paused while the document is hidden (backgrounded tab). A render kicked + * off in that state would otherwise stall forever on the batch yield and the + * `open()` promise would never settle (perma-loading preview). The `setTimeout` + * fallback fires regardless of visibility, so the render always completes — + * matching the server-side renderer, which has no visibility dependency. + */ + private yieldToNextFrame(): Promise { + return new Promise((resolve) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + resolve() + } + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(finish) + } + setTimeout(finish, 32) + }) + } + private disposeAllCharts(): void { for (const chart of this.chartInstances) { if (!chart.isDisposed()) { @@ -704,7 +729,7 @@ export class PptxViewer extends EventTarget { if ((i + 1) % batchSize === 0) { this.container.appendChild(batchFragment) batchFragment = document.createDocumentFragment() - await new Promise((resolve) => requestAnimationFrame(() => resolve())) + await this.yieldToNextFrame() } } @@ -733,7 +758,7 @@ export class PptxViewer extends EventTarget { if ((i + 1) % batchSize === 0) { this.container.appendChild(batchFragment) batchFragment = document.createDocumentFragment() - await new Promise((resolve) => requestAnimationFrame(() => resolve())) + await this.yieldToNextFrame() } } diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index e881102df32..998b701363d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -60,6 +60,8 @@ export interface DispatchRow { /** Units of `limit.type` already consumed (eligible rows dispatched). */ processedCount: number isManualRun: boolean + /** User who triggered the run (for usage attribution); null for auto-fire. */ + triggeredByUserId: string | null requestedAt: Date } @@ -156,6 +158,7 @@ export async function insertDispatch(input: { scope: DispatchScope limit?: DispatchLimit | null isManualRun: boolean + triggeredByUserId?: string | null }): Promise { const id = `tdsp_${generateId().replace(/-/g, '')}` await db.insert(tableRunDispatches).values({ @@ -172,6 +175,7 @@ export async function insertDispatch(input: { // correctly excludes already-processed rows. cursor: -1, isManualRun: input.isManualRun, + triggeredByUserId: input.triggeredByUserId ?? null, }) return id } @@ -298,6 +302,7 @@ export async function listActiveDispatches(tableId: string): Promise ({ ...p, dispatchId })) + }).map((p) => ({ ...p, dispatchId, triggeredByUserId: dispatch.triggeredByUserId ?? undefined })) // Cursor advances to the last position in this chunk regardless of // eligibility — otherwise a window full of skipped cells loops forever. @@ -717,6 +723,7 @@ export async function markActiveDispatchesCancelled(tableId: string): Promise logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err)) return insertedRow @@ -1619,7 +1620,7 @@ export async function batchInsertRows( requestId: string ): Promise { const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId)) - dispatchAfterBatchInsert(table, result, requestId) + dispatchAfterBatchInsert(table, result, requestId, data.userId) return result } @@ -1726,7 +1727,8 @@ export async function batchInsertRowsWithTx( export function dispatchAfterBatchInsert( table: TableDefinition, result: TableRow[], - requestId: string + requestId: string, + actorUserId?: string | null ): void { void fireTableTrigger(table.id, table.name, 'insert', result, null, table.schema, requestId) // Scope to the newly-inserted row ids so the dispatcher doesn't walk every @@ -1740,6 +1742,7 @@ export function dispatchAfterBatchInsert( mode: 'new', isManualRun: false, requestId, + triggeredByUserId: actorUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err)) } @@ -2393,6 +2396,7 @@ export async function upsertRow( mode: 'new', isManualRun: false, requestId, + triggeredByUserId: data.userId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err)) return result @@ -3042,6 +3046,7 @@ export async function updateRow( rowIds: [data.rowId], groupIds: inFlightDownstreamGroups, requestId, + triggeredByUserId: data.actorUserId, }) } catch (err) { logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err) @@ -3055,6 +3060,7 @@ export async function updateRow( mode: 'new', isManualRun: false, requestId, + triggeredByUserId: data.actorUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err)) return updatedRow @@ -3212,6 +3218,7 @@ export async function updateRowsByFilter( mode: 'new', isManualRun: false, requestId, + triggeredByUserId: data.actorUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err)) return { @@ -3392,6 +3399,7 @@ export async function batchUpdateRows( rowIds: [rowId], groupIds: inFlightDownstreamGroups, requestId, + triggeredByUserId: data.actorUserId, }) } } catch (err) { @@ -3409,6 +3417,7 @@ export async function batchUpdateRows( mode: 'new', isManualRun: false, requestId, + triggeredByUserId: data.actorUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) return { @@ -4087,6 +4096,7 @@ export async function addWorkflowGroup( isManualRun: false, groupIds: [data.group.id], requestId, + triggeredByUserId: data.actorUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err)) } @@ -4417,6 +4427,7 @@ export async function updateWorkflowGroup( outputs: added, overwrite: false, requestId, + actorUserId: data.actorUserId, }) } catch (err) { logger.warn( @@ -4434,6 +4445,7 @@ export async function updateWorkflowGroup( outputs: remappedOutputs, overwrite: true, requestId, + actorUserId: data.actorUserId, }) } catch (err) { logger.warn( @@ -4454,6 +4466,7 @@ export async function updateWorkflowGroup( isManualRun: false, groupIds: [data.groupId], requestId, + triggeredByUserId: data.actorUserId, }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err) ) @@ -4477,6 +4490,8 @@ export async function addWorkflowGroupOutput( path: string /** Optional override; defaults to a slug derived from `path`. */ columnName?: string + /** The member adding the output — billed/gated for any backfill-triggered re-run. */ + actorUserId?: string | null }, requestId: string ): Promise { @@ -4689,6 +4704,7 @@ export async function addWorkflowGroupOutput( outputs: [newOutput], overwrite: false, requestId, + actorUserId: data.actorUserId, }) } catch (err) { logger.warn( @@ -4873,8 +4889,9 @@ async function backfillGroupOutputsFromLogs(opts: { outputs: WorkflowGroupOutput[] overwrite: boolean requestId: string + actorUserId?: string | null }): Promise { - const { table, groupId, outputs, overwrite, requestId } = opts + const { table, groupId, outputs, overwrite, requestId, actorUserId } = opts if (outputs.length === 0) return const { pluckByPath } = await import('./pluck') @@ -4967,6 +4984,7 @@ async function backfillGroupOutputsFromLogs(opts: { tableId: table.id, updates, workspaceId: table.workspaceId, + actorUserId, }, table, requestId diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index c51e3368df2..df36bc8f465 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -423,12 +423,21 @@ export interface UpdateRowData { * state. `updateRow` returns `null` when the guard rejects the write. */ cancellationGuard?: { groupId: string; executionId: string } + /** + * The member who performed this write. Billed and usage-gated for any + * enrichment the write triggers (auto-fire or dependency-cascade re-run), so + * costs land on the editor's per-member meter rather than the workspace billed + * account. Omitted only for internal `executionsPatch`-only writes. + */ + actorUserId?: string | null } export interface BulkUpdateData { filter: Filter data: RowData limit?: number + /** The member who performed this write — billed/gated for triggered enrichment. */ + actorUserId?: string | null } export interface BatchUpdateByIdData { @@ -439,6 +448,8 @@ export interface BatchUpdateByIdData { executionsPatch?: Record }> workspaceId: string + /** The member who performed this write — billed/gated for triggered enrichment. */ + actorUserId?: string | null } export interface BulkDeleteData { @@ -504,6 +515,8 @@ export interface AddWorkflowGroupData { * `true` (UI behavior). Mothership passes `false` so groups can be staged * without firing every dep-satisfied row. */ autoRun?: boolean + /** The member adding the group — billed/gated for the auto-run enrichment pass. */ + actorUserId?: string | null } /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ @@ -532,6 +545,8 @@ export interface UpdateWorkflowGroupData { type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ autoRun?: boolean + /** The member updating the group — billed/gated for any triggered re-run. */ + actorUserId?: string | null } export interface DeleteWorkflowGroupData { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 63c5d45ac52..ed5379fbe51 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -334,6 +334,10 @@ export interface WorkflowGroupCellPayload { * on a hard stop (e.g. usage limit). Absent for cascade/auto-fire payloads * that aren't driven by a dispatch. */ dispatchId?: string + /** User who triggered the run, for per-member usage attribution. Absent for + * auto-fire (row writes, CSV import) → billing falls back to the workspace + * billed account. */ + triggeredByUserId?: string } /** Per-table concurrency cap. Mirrors trigger.dev's `concurrencyLimit: 20`. */ @@ -590,8 +594,12 @@ export async function runWorkflowColumn(opts: { * cells as terminal — appropriate for auto-fire after row writes or * schema changes. Defaults to true (user-initiated "Run column"). */ isManualRun?: boolean + /** User who triggered the run, for usage attribution. Omitted by auto-fire + * callers (row writes, CSV import) → falls back to the workspace billed + * account at billing time. */ + triggeredByUserId?: string | null }): Promise<{ dispatchId: string | null }> { - const { tableId, workspaceId, mode, requestId, groupIds, rowIds, limit } = opts + const { tableId, workspaceId, mode, requestId, groupIds, rowIds, limit, triggeredByUserId } = opts const isManualRun = opts.isManualRun ?? true // Empty `rowIds` array means "scope explicitly empty" — auto-fire callers // (CSV import on zero matches, etc.) end up here. Skip the dispatch entirely @@ -675,6 +683,7 @@ export async function runWorkflowColumn(opts: { }, limit, isManualRun, + triggeredByUserId, }) logger.info( diff --git a/apps/sim/lib/tools/falai-pricing.test.ts b/apps/sim/lib/tools/falai-pricing.test.ts new file mode 100644 index 00000000000..da230e998f3 --- /dev/null +++ b/apps/sim/lib/tools/falai-pricing.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS, + FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS, + FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS, + getFalAICostMetadata, +} from './falai-pricing' + +// Avoid the real inter-attempt backoff so the fallback path resolves instantly. +vi.mock('@sim/utils/helpers', () => ({ sleep: vi.fn().mockResolvedValue(undefined) })) + +describe('getFalAICostMetadata fallback floor', () => { + const originalFetch = global.fetch + + beforeEach(() => { + // Both fal cost endpoints (billing-events + pricing estimate) fail, forcing + // the provider-cost floor selection by endpoint category. + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({}), + text: async () => 'error', + }) as unknown as typeof fetch + }) + + afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() + }) + + it.each([ + ['fal-ai/f5-tts', FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS], + ['fal-ai/gemini-3.1-flash-tts', FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS], + ['fal-ai/minimax-music/v2.6', FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS], + ['fal-ai/elevenlabs/sound-effects/v2', FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS], + ['fal-ai/nano-banana', FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS], + ['fal-ai/veo-3.1', FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS], + ['fal-ai/seedance-2.0', FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS], + ])('uses the correct provider-cost floor for %s', async (endpointId, expected) => { + const result = await getFalAICostMetadata({ + apiKey: 'test-key', + endpointId, + requestId: 'req_123', + }) + + expect(result.source).toBe('fallback_floor') + expect(result.costDollars).toBe(expected) + }) + + it('never bills an audio clip at the video floor', () => { + expect(FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS).toBeLessThan( + FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS + ) + }) +}) diff --git a/apps/sim/lib/tools/falai-pricing.ts b/apps/sim/lib/tools/falai-pricing.ts index f94f4d5d060..5714e0c9784 100644 --- a/apps/sim/lib/tools/falai-pricing.ts +++ b/apps/sim/lib/tools/falai-pricing.ts @@ -4,6 +4,7 @@ import { sleep } from '@sim/utils/helpers' export const FALAI_HOSTED_KEY_MARKUP_MULTIPLIER = 1.5 export const FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS = 0.05 +export const FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS = 0.02 export const FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS = 0.25 const FALAI_BILLING_EVENT_ATTEMPTS = 2 const FALAI_BILLING_EVENT_RETRY_MS = 500 @@ -40,16 +41,27 @@ function getNumber(value: unknown): number | undefined { function getFalAIFallbackProviderCostDollars(endpointId: string): number { const normalizedEndpointId = endpointId.toLowerCase() + const isImageEndpoint = normalizedEndpointId.includes('image') || normalizedEndpointId.includes('nano-banana') || normalizedEndpointId.includes('seedream') || normalizedEndpointId.includes('flux') || normalizedEndpointId.includes('grok-imagine') - - return isImageEndpoint - ? FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS - : FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS + if (isImageEndpoint) return FALAI_IMAGE_FALLBACK_PROVIDER_COST_DOLLARS + + // Audio (TTS/voice clone, music, sound effects) is far cheaper than video, so it + // must not fall through to the video floor — that would over-bill a short clip. + const isAudioEndpoint = + normalizedEndpointId.includes('tts') || + normalizedEndpointId.includes('speech') || + normalizedEndpointId.includes('music') || + normalizedEndpointId.includes('sound-effect') || + normalizedEndpointId.includes('audio') || + normalizedEndpointId.includes('elevenlabs') + if (isAudioEndpoint) return FALAI_AUDIO_FALLBACK_PROVIDER_COST_DOLLARS + + return FALAI_VIDEO_FALLBACK_PROVIDER_COST_DOLLARS } function parseBillingEvent(value: unknown): FalAIBillingEvent | undefined { diff --git a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts index 6c5145fa1b4..2e9b3fdf93d 100644 --- a/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts @@ -31,6 +31,9 @@ const SUPPORTED_FILE_TYPES = [ 'text/xml', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/x-pptxgenjs', + 'text/x-docxjs', + 'text/x-python-pdf', + 'text/x-python-xlsx', ] /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 683d210004d..b524a094a73 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' +import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') @@ -246,8 +247,16 @@ export async function getWorkspaceFileFolderPath( export async function findWorkspaceFileFolderIdByPath( workspaceId: string, - pathSegments: string[] + pathSegments: string[], + options?: { includeReservedSystemFolders?: boolean } ): Promise { + if ( + !options?.includeReservedSystemFolders && + isReservedWorkflowAliasBackingDisplayPath(pathSegments.join('/')) + ) { + return null + } + let parentId: string | null = null for (const rawSegment of pathSegments) { @@ -268,9 +277,9 @@ export async function findWorkspaceFileFolderIdByPath( export async function listWorkspaceFileFolders( workspaceId: string, - options?: { scope?: WorkspaceFileFolderScope } + options?: { scope?: WorkspaceFileFolderScope; includeReservedSystemFolders?: boolean } ): Promise { - const { scope = 'active' } = options ?? {} + const { scope = 'active', includeReservedSystemFolders = false } = options ?? {} const rows = await db .select() .from(workspaceFileFolder) @@ -290,7 +299,12 @@ export async function listWorkspaceFileFolders( .orderBy(asc(workspaceFileFolder.sortOrder), asc(workspaceFileFolder.createdAt)) const paths = buildWorkspaceFileFolderPathMap(rows) - return rows.map((row) => mapFolder(row, paths)) + return rows + .map((row) => mapFolder(row, paths)) + .filter( + (folder) => + includeReservedSystemFolders || !isReservedWorkflowAliasBackingDisplayPath(folder.path) + ) } export async function getWorkspaceFileFolder( diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts index 3e8d95423e3..72caa27a0a6 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.test.ts @@ -29,27 +29,27 @@ function makeFileRecord(): WorkspaceFileRecord { } describe('workspace file reference normalization', () => { - it('normalizes canonical by-id VFS paths to the raw file id', () => { - expect(normalizeWorkspaceFileReference(`files/by-id/${FILE_ID}/content`)).toBe(FILE_ID) - expect(normalizeWorkspaceFileReference(`files/by-id/${FILE_ID}/meta.json`)).toBe(FILE_ID) - expect(normalizeWorkspaceFileReference(`by-id/${FILE_ID}`)).toBe(FILE_ID) - expect(normalizeWorkspaceFileReference(`recently-deleted/files/by-id/${FILE_ID}/content`)).toBe( - FILE_ID + it('normalizes canonical VFS paths to their sanitized display path', () => { + expect(normalizeWorkspaceFileReference('files/Reports/q1.csv/content')).toBe('Reports/q1.csv') + expect(normalizeWorkspaceFileReference('files/Reports/q1.csv/meta.json')).toBe('Reports/q1.csv') + expect(normalizeWorkspaceFileReference('recently-deleted/files/data.csv/content')).toBe( + 'data.csv' ) }) - it('finds files from canonical by-id content paths', () => { + it('still resolves a raw file id passed directly', () => { const files = [makeFileRecord()] - expect(findWorkspaceFileRecord(files, `files/by-id/${FILE_ID}/content`)).toMatchObject({ + expect(findWorkspaceFileRecord(files, FILE_ID)).toMatchObject({ id: FILE_ID, name: 'the_last_cartographer_of_vael.md', }) + }) - expect(findWorkspaceFileRecord(files, `by-id/${FILE_ID}`)).toMatchObject({ - id: FILE_ID, - name: 'the_last_cartographer_of_vael.md', - }) + it('does not resolve id-based VFS paths', () => { + const files = [makeFileRecord()] + + expect(findWorkspaceFileRecord(files, `files/by-id/${FILE_ID}/content`)).toBeNull() }) it('resolves duplicate names by folder-aware VFS path', () => { diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 89b3bd37d91..781c1f377f7 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -16,6 +16,9 @@ import { incrementStorageUsage, } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' +import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { getServePathPrefix } from '@/lib/uploads' import { @@ -75,6 +78,7 @@ interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] hydrateFolderPaths?: boolean + includeReservedSystemFiles?: boolean } /** @@ -167,12 +171,13 @@ export async function uploadWorkspaceFile( fileBuffer: Buffer, fileName: string, contentType: string, - options?: { folderId?: string | null } + options?: { folderId?: string | null; exactName?: boolean } ): Promise { logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`) const folderId = await assertWorkspaceFileFolderTarget(workspaceId, options?.folderId) const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File') + const exactName = options?.exactName ?? false const quotaCheck = await checkStorageQuota(userId, fileBuffer.length) if (!quotaCheck.allowed) { @@ -180,12 +185,14 @@ export async function uploadWorkspaceFile( } let lastError: unknown - for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { - const uniqueName = await allocateUniqueWorkspaceFileName( - workspaceId, - normalizedFileName, - folderId - ) + const maxAttempts = exactName ? 1 : MAX_UPLOAD_UNIQUE_RETRIES + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const uniqueName = exactName + ? normalizedFileName + : await allocateUniqueWorkspaceFileName(workspaceId, normalizedFileName, folderId) + if (exactName && (await fileExistsInWorkspace(workspaceId, uniqueName, folderId))) { + throw new FileConflictError(uniqueName) + } const storageKey = generateWorkspaceFileKey(workspaceId, uniqueName) let fileId = `wf_${generateShortId()}` @@ -280,6 +287,9 @@ export async function uploadWorkspaceFile( throw error } if (getPostgresErrorCode(error) === '23505') { + if (exactName) { + throw new FileConflictError(normalizedFileName) + } logger.warn( `Unique name conflict on upload (attempt ${attempt + 1}/${MAX_UPLOAD_UNIQUE_RETRIES}), retrying with a new name` ) @@ -629,7 +639,11 @@ export async function listWorkspaceFiles( options?: ListWorkspaceFilesOptions ): Promise { try { - const { scope = 'active', hydrateFolderPaths = true } = options ?? {} + const { + scope = 'active', + hydrateFolderPaths = true, + includeReservedSystemFiles = false, + } = options ?? {} const files = await db .select() .from(workspaceFiles) @@ -653,13 +667,24 @@ export async function listWorkspaceFiles( ) .orderBy(workspaceFiles.uploadedAt) - const needsFolderPaths = hydrateFolderPaths && files.some((file) => file.folderId) + const needsFolderPaths = + files.some((file) => file.folderId) && (hydrateFolderPaths || !includeReservedSystemFiles) const folders = needsFolderPaths - ? (options?.folders ?? (await listWorkspaceFileFolders(workspaceId, { scope: 'all' }))) + ? includeReservedSystemFiles && options?.folders + ? options.folders + : await listWorkspaceFileFolders(workspaceId, { + scope: 'all', + includeReservedSystemFolders: true, + }) : [] const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map() - return files.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + return files + .map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths)) + .filter((file) => { + if (includeReservedSystemFiles) return true + return !isReservedWorkflowAliasBackingDisplayPath(file.folderPath) + }) } catch (error) { logger.error(`Failed to list workspace files for ${workspaceId}:`, error) return [] @@ -668,8 +693,8 @@ export async function listWorkspaceFiles( /** * Normalize a workspace file reference to either a display name or canonical file ID. - * Supports raw IDs, `files/{name}`, `files/{name}/content`, `files/{name}/meta.json`, - * and canonical VFS aliases like `files/by-id/{fileId}/content`. + * Supports raw IDs, `files/{name}`, `files/{name}/content`, and `files/{name}/meta.json`. + * Files are addressed by their sanitized canonical path; id-based VFS paths are not supported. */ export function normalizeWorkspaceFileReference(fileReference: string): string { const trimmed = fileReference.trim().replace(/^\/+/, '') @@ -677,44 +702,27 @@ export function normalizeWorkspaceFileReference(fileReference: string): string { ? trimmed.slice('recently-deleted/'.length) : trimmed - if (withoutDeletedPrefix.startsWith('files/by-id/')) { - const byIdRef = withoutDeletedPrefix.slice('files/by-id/'.length) - const match = byIdRef.match(/^([^/]+)(?:\/(?:meta\.json|content))?$/) - if (match?.[1]) { - return match[1] - } - } - - if (withoutDeletedPrefix.startsWith('by-id/')) { - const match = withoutDeletedPrefix - .slice('by-id/'.length) - .match(/^([^/]+)(?:\/(?:meta\.json|content))?$/) - if (match?.[1]) { - return match[1] - } - } - if (withoutDeletedPrefix.startsWith('files/')) { const withoutPrefix = withoutDeletedPrefix.slice('files/'.length) if (withoutPrefix.endsWith('/meta.json')) { - return withoutPrefix.slice(0, -'/meta.json'.length) + return decodeVfsPathSegments(withoutPrefix.slice(0, -'/meta.json'.length)).join('/') } if (withoutPrefix.endsWith('/content')) { - return withoutPrefix.slice(0, -'/content'.length) + return decodeVfsPathSegments(withoutPrefix.slice(0, -'/content'.length)).join('/') } - return withoutPrefix + return decodeVfsPathSegments(withoutPrefix).join('/') } - return withoutDeletedPrefix + return decodeVfsPathSegments(withoutDeletedPrefix).join('/') } /** * Canonical sandbox mount path for an existing workspace file. */ export function getSandboxWorkspaceFilePath( - file: Pick + file: Pick ): string { - return `/home/user/files/${file.id}/${file.name}` + return `/home/user/${canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name })}` } /** @@ -769,7 +777,9 @@ async function getWorkspaceFileByExactReference( return getWorkspaceFileByName(workspaceId, segments[0], { folderId: null }) } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1), { + includeReservedSystemFolders: true, + }) return folderId ? getWorkspaceFileByName(workspaceId, segments.at(-1) ?? '', { folderId }) : null } @@ -780,6 +790,12 @@ export async function resolveWorkspaceFileReference( workspaceId: string, fileReference: string ): Promise { + const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: fileReference }) + if (alias) { + if (alias.kind === 'plans_dir') return null + return resolveWorkspaceFileReference(workspaceId, alias.backingPath) + } + const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { const file = await getWorkspaceFile(workspaceId, normalizedReference) @@ -792,7 +808,7 @@ export async function resolveWorkspaceFileReference( ) if (exactReferenceFile) return exactReferenceFile - const files = await listWorkspaceFiles(workspaceId) + const files = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true }) return findWorkspaceFileRecord(files, fileReference) } diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts new file mode 100644 index 00000000000..641f17ad25b --- /dev/null +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { slackHandler } from '@/lib/webhooks/providers/slack' + +const ctx = (body: unknown) => ({ + webhook: {}, + workflow: { id: 'wf', userId: 'u' }, + body, + headers: {}, + requestId: 'slack-test', +}) + +const eventOf = (input: unknown) => + (input as { event: Record }).event as Record + +describe('slackHandler formatInput - Events API', () => { + it('maps an app_mention event', async () => { + const { input } = await slackHandler.formatInput!( + ctx({ + team_id: 'T1', + event_id: 'Ev1', + event: { + type: 'app_mention', + channel: 'C1', + user: 'U1', + text: 'hey <@bot> hello', + ts: '111.222', + thread_ts: '111.000', + }, + }) + ) + const event = eventOf(input) + expect(event.event_type).toBe('app_mention') + expect(event.channel).toBe('C1') + expect(event.user).toBe('U1') + expect(event.text).toBe('hey <@bot> hello') + expect(event.timestamp).toBe('111.222') + expect(event.thread_ts).toBe('111.000') + expect(event.team_id).toBe('T1') + expect(event.event_id).toBe('Ev1') + // Interactivity-only fields stay empty for Events API payloads. + expect(event.command).toBe('') + expect(event.action_value).toBe('') + expect(event.actions).toEqual([]) + }) +}) + +describe('slackHandler formatInput - interactivity (block_actions)', () => { + it('carries the button action value, channel, user, and response_url through', async () => { + const { input } = await slackHandler.formatInput!( + ctx({ + type: 'block_actions', + api_app_id: 'A123', + team: { id: 'T1', domain: 'acme' }, + user: { id: 'U1', username: 'alice' }, + channel: { id: 'C1', name: 'general' }, + trigger_id: 'trigger-1', + response_url: 'https://hooks.slack.com/actions/abc', + container: { message_ts: '999.000' }, + message: { ts: '999.000', text: 'Approve this?', thread_ts: '999.aaa' }, + actions: [ + { + action_id: 'approve_btn', + block_id: 'b1', + value: 'approve_42', + action_ts: '1234.5678', + }, + ], + }) + ) + const event = eventOf(input) + expect(event.event_type).toBe('block_actions') + expect(event.channel).toBe('C1') + expect(event.channel_name).toBe('general') + expect(event.user).toBe('U1') + expect(event.user_name).toBe('alice') + expect(event.team_id).toBe('T1') + expect(event.action_id).toBe('approve_btn') + expect(event.action_value).toBe('approve_42') + expect(event.text).toBe('Approve this?') + expect(event.message_ts).toBe('999.000') + expect(event.timestamp).toBe('999.000') + expect(event.thread_ts).toBe('999.aaa') + expect(event.response_url).toBe('https://hooks.slack.com/actions/abc') + expect(event.trigger_id).toBe('trigger-1') + expect(event.api_app_id).toBe('A123') + expect(Array.isArray(event.actions)).toBe(true) + expect((event.actions as unknown[]).length).toBe(1) + }) + + it('normalizes a static_select value and falls back to action value for text', async () => { + const { input } = await slackHandler.formatInput!( + ctx({ + type: 'block_actions', + user: { id: 'U2', name: 'bob' }, + channel: { id: 'C9' }, + actions: [ + { + action_id: 'pick', + type: 'static_select', + selected_option: { value: 'opt_b', text: { type: 'plain_text', text: 'Option B' } }, + }, + ], + }) + ) + const event = eventOf(input) + expect(event.action_value).toBe('opt_b') + // No message text on the payload -> text falls back to the action value. + expect(event.text).toBe('opt_b') + expect(event.user_name).toBe('bob') + }) +}) + +describe('slackHandler formatInput - slash commands', () => { + it('maps flat slash-command form fields', async () => { + const { input } = await slackHandler.formatInput!( + ctx({ + command: '/deploy', + text: 'staging now', + team_id: 'T1', + channel_id: 'C1', + channel_name: 'ops', + user_id: 'U1', + user_name: 'alice', + api_app_id: 'A123', + response_url: 'https://hooks.slack.com/commands/abc', + trigger_id: 'trigger-2', + }) + ) + const event = eventOf(input) + expect(event.event_type).toBe('slash_command') + expect(event.command).toBe('/deploy') + expect(event.text).toBe('staging now') + expect(event.channel).toBe('C1') + expect(event.channel_name).toBe('ops') + expect(event.user).toBe('U1') + expect(event.user_name).toBe('alice') + expect(event.team_id).toBe('T1') + expect(event.response_url).toBe('https://hooks.slack.com/commands/abc') + expect(event.trigger_id).toBe('trigger-2') + expect(event.api_app_id).toBe('A123') + }) +}) + +describe('slackHandler extractIdempotencyId', () => { + it('uses event_id for Events API payloads', () => { + expect(slackHandler.extractIdempotencyId!({ event_id: 'Ev1' })).toBe('Ev1') + }) + + it('uses trigger_id for interactivity and slash-command payloads', () => { + expect( + slackHandler.extractIdempotencyId!({ type: 'block_actions', trigger_id: 'trigger-1' }) + ).toBe('trigger-1') + expect( + slackHandler.extractIdempotencyId!({ command: '/deploy', trigger_id: 'trigger-2' }) + ).toBe('trigger-2') + }) + + it('returns null when no identifier is present', () => { + expect(slackHandler.extractIdempotencyId!({})).toBeNull() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index a48032c08d5..1682b277394 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -21,6 +21,201 @@ const SLACK_MAX_FILES = 15 const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) +/** + * Interactivity payload types Slack POSTs to the request URL as a form-encoded + * `payload` field (button clicks, selects, shortcuts, modal submits). These have + * no Events-API `event` envelope, so they need their own mapping. + * See https://api.slack.com/interactivity/handling#payloads + */ +const SLACK_INTERACTIVE_TYPES = new Set([ + 'block_actions', + 'interactive_message', + 'message_action', + 'shortcut', + 'view_submission', + 'view_closed', + 'block_suggestion', +]) + +interface SlackDownloadedFile { + name: string + data: string + mimeType: string + size: number +} + +/** + * Unified output shape for the Slack trigger across all three payload families + * (Events API, interactivity, slash commands). Every key is always present so + * downstream blocks never resolve to `undefined`. + */ +interface SlackTriggerEvent { + event_type: string + subtype: string + channel: string + channel_name: string + channel_type: string + user: string + user_name: string + bot_id: string + text: string + timestamp: string + thread_ts: string + team_id: string + event_id: string + reaction: string + item_user: string + command: string + action_id: string + action_value: string + actions: unknown[] + response_url: string + trigger_id: string + callback_id: string + api_app_id: string + message_ts: string + hasFiles: boolean + files: SlackDownloadedFile[] +} + +function createSlackEvent(): SlackTriggerEvent { + return { + event_type: 'unknown', + subtype: '', + channel: '', + channel_name: '', + channel_type: '', + user: '', + user_name: '', + bot_id: '', + text: '', + timestamp: '', + thread_ts: '', + team_id: '', + event_id: '', + reaction: '', + item_user: '', + command: '', + action_id: '', + action_value: '', + actions: [], + response_url: '', + trigger_id: '', + callback_id: '', + api_app_id: '', + message_ts: '', + hasFiles: false, + files: [], + } +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** + * Normalize the "value" carried by a Slack interactive action across the + * different element types (button, static/multi/external select, datepicker, + * timepicker, overflow, radio/checkbox, conversations/channels/users select). + */ +function extractActionValue(action: Record | undefined): string { + if (!action) return '' + if (typeof action.value === 'string') return action.value + + const selectedOption = action.selected_option as Record | undefined + if (selectedOption && typeof selectedOption.value === 'string') { + return selectedOption.value + } + + const selectedOptions = action.selected_options as Array> | undefined + if (Array.isArray(selectedOptions)) { + return selectedOptions + .map((o) => (typeof o?.value === 'string' ? o.value : '')) + .filter(Boolean) + .join(',') + } + + for (const key of [ + 'selected_date', + 'selected_time', + 'selected_date_time', + 'selected_conversation', + 'selected_channel', + 'selected_user', + ] as const) { + if (typeof action[key] === 'string') { + return action[key] as string + } + } + + return '' +} + +/** + * Slash commands arrive as flat `application/x-www-form-urlencoded` fields + * (no JSON `payload`, no `event` envelope), identified by a leading-slash + * `command`. See https://api.slack.com/interactivity/slash-commands + */ +function formatSlackSlashCommand(b: Record): SlackTriggerEvent { + const event = createSlackEvent() + event.event_type = 'slash_command' + event.command = asString(b.command) + event.text = asString(b.text) + event.channel = asString(b.channel_id) + event.channel_name = asString(b.channel_name) + event.user = asString(b.user_id) + event.user_name = asString(b.user_name) + event.team_id = asString(b.team_id) + event.response_url = asString(b.response_url) + event.trigger_id = asString(b.trigger_id) + event.api_app_id = asString(b.api_app_id) + return event +} + +/** + * Interactivity payloads (button clicks, selects, shortcuts, modal submits). + * The actionable data lives in `actions[]` / `view`, plus `response_url` and + * `trigger_id` which are needed to respond to or follow up on the interaction. + */ +function formatSlackInteractive(b: Record): SlackTriggerEvent { + const event = createSlackEvent() + event.event_type = asString(b.type) || 'block_actions' + + const actions = Array.isArray(b.actions) ? (b.actions as Array>) : [] + event.actions = actions + const firstAction = actions[0] + event.action_id = asString(firstAction?.action_id) + event.action_value = extractActionValue(firstAction) + + const channel = b.channel as Record | undefined + event.channel = asString(channel?.id) + event.channel_name = asString(channel?.name) + + const user = b.user as Record | undefined + event.user = asString(user?.id) + event.user_name = asString(user?.username) || asString(user?.name) + + const team = b.team as Record | undefined + event.team_id = asString(team?.id) || asString(user?.team_id) + + const container = b.container as Record | undefined + const message = b.message as Record | undefined + event.message_ts = asString(message?.ts) || asString(container?.message_ts) + event.timestamp = event.message_ts || asString(firstAction?.action_ts) + event.thread_ts = asString(message?.thread_ts) + // Prefer the source message text; fall back to the triggering action's value + // so a blocks-only message still surfaces something useful in `text`. + event.text = asString(message?.text) || event.action_value + + event.response_url = asString(b.response_url) + event.trigger_id = asString(b.trigger_id) + const view = b.view as Record | undefined + event.callback_id = asString(b.callback_id) || asString(view?.callback_id) + event.api_app_id = asString(b.api_app_id) + + return event +} + async function resolveSlackFileInfo( fileId: string, botToken: string @@ -282,6 +477,12 @@ export const slackHandler: WebhookProviderHandler = { return `${obj.team_id}:${event.ts}` } + // Interactivity and slash-command payloads carry a unique `trigger_id` + // per interaction, which Slack reuses across retries of the same payload. + if (obj.trigger_id) { + return String(obj.trigger_id) + } + return null }, @@ -299,6 +500,22 @@ export const slackHandler: WebhookProviderHandler = { const botToken = providerConfig.botToken as string | undefined const includeFiles = Boolean(providerConfig.includeFiles) + // Slash commands: flat form fields identified by a leading-slash `command`. + if (typeof b?.command === 'string' && b.command.startsWith('/')) { + return { input: { event: formatSlackSlashCommand(b) } } + } + + // Interactivity (button clicks, selects, shortcuts, modal submits): a JSON + // `payload` with an interactive `type` and no Events-API `event` envelope. + if ( + !b?.event && + ((typeof b?.type === 'string' && SLACK_INTERACTIVE_TYPES.has(b.type)) || + Array.isArray(b?.actions)) + ) { + return { input: { event: formatSlackInteractive(b) } } + } + + // Events API (app_mention, message, reaction_added, ...). const rawEvent = b?.event as Record | undefined if (!rawEvent) { @@ -328,35 +545,31 @@ export const slackHandler: WebhookProviderHandler = { const rawFiles: unknown[] = (rawEvent?.files as unknown[]) ?? [] const hasFiles = rawFiles.length > 0 - let files: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + let files: SlackDownloadedFile[] = [] if (hasFiles && includeFiles && botToken) { files = await downloadSlackFiles(rawFiles, botToken) } else if (hasFiles && includeFiles && !botToken) { logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') } - return { - input: { - event: { - event_type: eventType, - subtype: (rawEvent?.subtype as string) ?? '', - channel, - channel_name: '', - channel_type: (rawEvent?.channel_type as string) ?? '', - user: (rawEvent?.user as string) || '', - user_name: '', - bot_id: (rawEvent?.bot_id as string) ?? '', - text, - timestamp: messageTs, - thread_ts: (rawEvent?.thread_ts as string) || '', - team_id: (b?.team_id as string) || (rawEvent?.team as string) || '', - event_id: (b?.event_id as string) || '', - reaction: (rawEvent?.reaction as string) || '', - item_user: (rawEvent?.item_user as string) || '', - hasFiles, - files, - }, - }, - } + const event = createSlackEvent() + event.event_type = eventType + event.subtype = asString(rawEvent?.subtype) + event.channel = channel + event.channel_type = asString(rawEvent?.channel_type) + event.user = asString(rawEvent?.user) + event.bot_id = asString(rawEvent?.bot_id) + event.text = text + event.timestamp = messageTs + event.thread_ts = asString(rawEvent?.thread_ts) + event.team_id = asString(b?.team_id) || asString(rawEvent?.team) + event.event_id = asString(b?.event_id) + event.reaction = asString(rawEvent?.reaction) + event.item_user = asString(rawEvent?.item_user) + event.message_ts = messageTs + event.hasFiles = hasFiles + event.files = files + + return { input: { event } } }, } diff --git a/apps/sim/lib/workflows/comparison/index.ts b/apps/sim/lib/workflows/comparison/index.ts index 3dfe2d14ff1..400dd278eee 100644 --- a/apps/sim/lib/workflows/comparison/index.ts +++ b/apps/sim/lib/workflows/comparison/index.ts @@ -1,4 +1,8 @@ -export { hasWorkflowChanged } from './compare' +export { + generateWorkflowDiffSummary, + hasWorkflowChanged, + type WorkflowDiffSummary, +} from './compare' export { normalizedStringify, normalizeWorkflowState, diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index 3fd2d215c12..862b823209a 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -57,6 +57,34 @@ export async function getExecutionStateForWorkflow( return extractExecutionState(row?.executionData) } +/** + * Returns the workflow input recorded for a past execution so a new run can + * reuse it by reference. `found` distinguishes a missing execution from an + * execution that recorded no input. + */ +export async function getExecutionInputForWorkflow( + executionId: string, + workflowId: string +): Promise<{ found: boolean; input?: unknown }> { + const [row] = await db + .select({ executionData: workflowExecutionLogs.executionData }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + if (!row) { + return { found: false } + } + + const data = row.executionData as { workflowInput?: unknown } | null | undefined + return { found: true, input: data?.workflowInput } +} + export async function getLatestExecutionState( workflowId: string ): Promise { diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts index 21c06dd47c5..7dafcea6560 100644 --- a/apps/sim/lib/workflows/lifecycle.ts +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -13,6 +13,7 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, isNull } from 'drizzle-orm' +import { cleanupWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { env } from '@/lib/core/config/env' import { getRedisClient } from '@/lib/core/config/redis' import { PlatformEvents } from '@/lib/core/telemetry' @@ -208,6 +209,22 @@ export async function archiveWorkflow( await notifyWorkflowArchived(workflowId, options.requestId) } + if (existingWorkflow.workspaceId) { + try { + await cleanupWorkflowAliasBacking({ + workspaceId: existingWorkflow.workspaceId, + workflowId, + deletedAt: now, + }) + } catch (error) { + logger.warn(`[${options.requestId}] Failed to clean up workflow alias backing`, { + workflowId, + workspaceId: existingWorkflow.workspaceId, + error, + }) + } + } + await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId) if (existingWorkflow.workspaceId && mcpPubSub && affectedWorkflowMcpServers.length > 0) { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 8b153a6afeb..8288766b900 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -16,6 +16,10 @@ export interface ChatDeployPayload { identifier: string title: string description?: string + /** Summary of what changed in this deployment version (distinct from the chat-facing `description`). */ + versionDescription?: string + /** Short name/label for this deployment version. */ + versionName?: string customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string } authType?: 'public' | 'password' | 'email' | 'sso' password?: string | null @@ -60,7 +64,12 @@ export async function performChatDeploy( ...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}), } - const deployResult = await performFullDeploy({ workflowId, userId }) + const deployResult = await performFullDeploy({ + workflowId, + userId, + versionDescription: params.versionDescription, + versionName: params.versionName, + }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } } diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 196c520a66e..1b6089cc5b3 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -54,6 +54,18 @@ export interface PerformFullDeployParams { workflowId: string userId: string workflowName?: string + /** + * Optional summary of what changed, stored on the created deployment version. + * The copilot deploy tools require this; the UI deploy route sets it + * separately via the version metadata endpoint, so it stays optional here. + */ + versionDescription?: string + /** + * Optional name/label for the created deployment version. The copilot deploy + * tools require this; the UI deploy route sets it via the version metadata + * endpoint, so it stays optional here. + */ + versionName?: string requestId?: string /** * Optional NextRequest for external webhook subscriptions. @@ -107,6 +119,8 @@ export async function performFullDeploy( workflowId, deployedBy: actorId, workflowName: workflowName || workflowRecord.name || undefined, + description: params.versionDescription, + name: params.versionName, validateWorkflowState: async (workflowState) => { const scheduleValidation = validateWorkflowSchedules(workflowState.blocks) if (!scheduleValidation.isValid) { diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index d30b03d5312..1974ef12991 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -497,10 +497,63 @@ type DeployWorkflowValidationResult = | { success: true } | { success: false; error: string; errorCode?: 'validation' } +/** + * Update the name and/or description metadata of an existing deployment version. + * Shared by the workflow deployment-version PATCH route and the copilot + * `update_deployment_version` tool so both behave identically. Returns the + * resulting name/description, or null if the version does not exist. + */ +export async function updateDeploymentVersionMetadata(params: { + workflowId: string + version: number + name?: string | null + description?: string | null +}): Promise<{ name: string | null; description: string | null } | null> { + const updateData: { name?: string | null; description?: string | null } = {} + if (params.name !== undefined) updateData.name = params.name + if (params.description !== undefined) updateData.description = params.description + + if (Object.keys(updateData).length === 0) { + const [row] = await db + .select({ + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, params.workflowId), + eq(workflowDeploymentVersion.version, params.version) + ) + ) + .limit(1) + return row ?? null + } + + const [updated] = await db + .update(workflowDeploymentVersion) + .set(updateData) + .where( + and( + eq(workflowDeploymentVersion.workflowId, params.workflowId), + eq(workflowDeploymentVersion.version, params.version) + ) + ) + .returning({ + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + }) + return updated ?? null +} + export async function deployWorkflow(params: { workflowId: string deployedBy: string workflowName?: string + /** Optional human-readable summary of what changed, stored on the deployment version. */ + description?: string | null + /** Optional human-readable name/label for the deployment version. */ + name?: string | null workflowState?: WorkflowState validateWorkflowState?: ( workflowState: WorkflowState @@ -585,6 +638,8 @@ export async function deployWorkflow(params: { isActive: true, createdBy: deployedBy, createdAt: now, + description: params.description?.trim() || null, + name: params.name?.trim() || null, }) const updateData: Record = { diff --git a/apps/sim/lib/workflows/skills/builtin-skills.test.ts b/apps/sim/lib/workflows/skills/builtin-skills.test.ts new file mode 100644 index 00000000000..d785c410c22 --- /dev/null +++ b/apps/sim/lib/workflows/skills/builtin-skills.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + BUILTIN_SKILLS, + getBuiltinSkillById, + getBuiltinSkillByName, + isBuiltinSkillId, +} from './builtin-skills' + +describe('builtin skills', () => { + it('ships the four template skills with stable ids and valid fields', () => { + expect(BUILTIN_SKILLS.map((s) => s.id)).toEqual([ + 'builtin-connect-integration', + 'builtin-research', + 'builtin-create-table', + 'builtin-deploy-workflow', + ]) + for (const s of BUILTIN_SKILLS) { + expect(s.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/) + expect(s.description.length).toBeGreaterThan(0) + expect(s.content.length).toBeGreaterThan(0) + } + }) + + it('resolves by id and name (case-insensitive) and reports membership', () => { + expect(getBuiltinSkillById('builtin-research')?.name).toBe('research') + expect(getBuiltinSkillByName('RESEARCH')?.id).toBe('builtin-research') + expect(isBuiltinSkillId('builtin-deploy-workflow')).toBe(true) + expect(isBuiltinSkillId('sk-some-db-id')).toBe(false) + expect(getBuiltinSkillById('does-not-exist')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workflows/skills/builtin-skills.ts b/apps/sim/lib/workflows/skills/builtin-skills.ts new file mode 100644 index 00000000000..e7486a237d3 --- /dev/null +++ b/apps/sim/lib/workflows/skills/builtin-skills.ts @@ -0,0 +1,234 @@ +/** + * Built-in (template) skills that ship with every workspace. + * + * These are NOT stored in the `skill` database table. They are merged into the + * skill list at read time (see `listSkills` / the `/api/skills` route) so they + * show up everywhere real skills do — the Skills page, the mothership `/` + * mention menu, and the agent block — and they resolve when tagged. They are + * marked read-only so the UI hides edit/delete, and the upsert/delete paths + * reject their ids so they can never be persisted to a user's workspace row. + * + * This module is pure data (no DB imports) so it is safe to import from both + * server operations and the agent executor. + */ + +export interface BuiltinSkill { + id: string + name: string + description: string + content: string +} + +const DEPLOY_WORKFLOW_CONTENT = `# Deploy Workflow + +How to take a finished workflow and make it usable by the outside world. + +## What deploying actually means + +Deploying publishes a snapshot of a workflow. The version people are editing and the version that's actually live are two separate things — changes to the editable copy don't reach anyone until you deploy again. Every deploy is a saved version you can compare against or roll back to. The same workflow can be published in more than one form at once. + +## The three ways to publish + +- **As an API** — when the workflow is a behind-the-scenes pipeline that other software calls: data goes in, a result comes back. Best for transforms, lookups, and functions invoked by code. +- **As a chat interface** — when a person should talk to the workflow through a shareable link. Best when there's a conversational agent at the center. Note that publishing a chat also publishes an API alongside it, so you end up with both. +- **As a tool other AI agents can call** — when the workflow should show up as an action inside an AI assistant or coding environment. This needs a place for the tool to live, which can be an existing one or a new one created for it. + +If the user doesn't specify: a workflow where a person sends input and an agent replies is a chat; a pure input-to-output transform with no conversation is an API; a single-purpose action meant for another AI to trigger is a tool. + +## Before you deploy + +- Make sure the workflow is complete and wired — it has a clear starting point and a clear ending point. +- Make sure every service it touches is connected. If a credential is missing, sort that out first rather than shipping something that breaks at runtime. +- Make sure it actually runs. Don't tell the user it works if it's never been tested. +- If it's already live, understand exactly what's changed since last time so you're deploying on purpose, not by accident. + +## Doing it well per type + +- **API:** publish it, then hand back the real endpoint and a working example of how to call it. Don't invent the URL. +- **Chat:** figure out which block's output should actually be shown to the user, and use the genuine output — not the friendly display label, which often isn't the real field. If the workflow can end at more than one place depending on conditions, make sure every possible ending is included so the user always sees a reply. +- **Tool for AI agents:** give it a clear name and description, describe each input plainly, and hand back setup instructions for the user's AI client. + +## Options worth setting + +For chat especially: a clean link slug (keep it lowercase with hyphens), a title, a description, a welcome message, and the access level — open to anyone, password-protected, or limited to specific email addresses. If you choose password or email access, you must actually supply the password or the allowed list, or access won't behave as intended. + +## Keys and access + +Calling a published API or AI tool needs an access key. First check whether one already exists — if it does, don't create another; just point the user to it. Only generate a new key when none exists. When you do create one, the full key is shown only once — tell the user to save it, because it can't be recovered later. + +## Ways an API can be called + +It can be called and waited on for the full result, called as a live stream that sends pieces as they're produced, or called as a background job that you check on later. Mention whichever fits how the user plans to use it. + +## After it's live + +Confirm the real status rather than assuming it's live just because a link exists. If the live copy has fallen behind the edited copy, that's worth flagging — it means recent changes aren't being served yet. You can refresh the live version to match the latest edits, change its configuration, roll production back to an earlier version, or pull an old version back into the editor to work from. + +## Easy mistakes to avoid + +- Showing the wrong thing in a chat because you used a display label instead of the real output — always confirm what actually comes out. +- A link slug with capitals or spaces. +- Forgetting to mention that a chat also exposes an API. +- Claiming "deployed" when the live copy is stale. +- Choosing protected access but not supplying the password or allowed emails. +- Deploying with a service still unconnected, so it fails the moment it runs. +- Generating a new access key when one already exists instead of just pointing to it. +- Losing the access key (gone for good), or fabricating a URL. +` + +const CONNECT_INTEGRATION_CONTENT = `# Connect an Integration + +How to connect a service and choose the right account credential. + +## First, figure out how the service authenticates + +Some services connect by signing in through the provider (the "log in with…" flow). Others connect with an API key you paste in. A few support both. Always check which kind a service is before doing anything — don't assume. If it supports both, mention both routes. If it's not a recognized integration at all, say so honestly instead of pretending. + +## For sign-in style services + +First check whether the user already has that account connected. If they do and they didn't ask for a different account, just use the existing connection — don't make them reconnect. If nothing's connected, generate a connect link and surface it so they can authorize in one click. + +The most important thing here is matching the service to the correct provider identity. Services are often more specific than they look — a single broad provider name can secretly point to several different sub-services, so always connect to the precise one for the service in question rather than the generic parent. Getting this wrong hands the user a link that authorizes the wrong thing. + +## For API-key style services + +Tell the user plainly what key is needed and where to get it. Check whether they've already saved one. Store it as a workspace setting (use a personal one only if they ask), and from then on the workflow refers to it by name rather than embedding the secret. Don't try to push an API-key service through the sign-in flow — it won't work. + +## When there's more than one account + +If the user has connected several accounts for the same provider, don't guess which one to use. Show them the choices in human terms — which account, when it was connected — and let them pick. Only auto-select when there's exactly one. + +## A separate kind of key + +There's also a key used to call the user's own published workflows from outside. That's a different thing from connecting a third-party service — don't confuse the two. + +## Easy mistakes to avoid + +- Connecting to a generic provider name when the service needs a specific sub-service — quietly authorizes the wrong thing. +- Treating the service's own name as its provider identity; they're often different. +- Routing an API-key service through the sign-in flow, or vice versa. +- Assuming "connected" when the existing connection doesn't actually cover what this task needs. +- Auto-picking an account when several exist. +- Ever showing a raw key, token, or link in plain text — these should always be presented as protected, copyable credentials. +` + +const RESEARCH_CONTENT = `# Research + +How to research well and come back with something genuinely useful. + +## Pick the right kind of source + +- For a known library or framework, go straight to its official documentation — don't web-search what the docs answer authoritatively. +- For a question about this platform's own features, use the platform documentation. +- For a general topic, comparison, or anything where recency matters, search the open web; you can bias toward news, research, code, or company sources depending on the question. +- When you already know the exact pages you need, just read them — and read several at once rather than one at a time. +- For a single content-heavy page, read that page directly; only crawl across many pages of a site when you genuinely need that breadth, since it's slow and expensive. + +## How to actually do it + +Search broadly to find a handful of authoritative sources, then read them in depth — search snippets are leads, not answers. Favor primary and official sources over SEO blogs. Run independent lookups together instead of in sequence. When the research relates to something the user is building, ground it in their own materials so the findings actually connect to their situation. + +## How to report back + +Lead with the single most important takeaway, then the supporting findings — each tied to where it came from. If you can't point to a source for a claim, don't make the claim. Cross-check anything important against more than one source. Finish with what it means for the user and what to do with it, not just a pile of facts. + +## When sources disagree or go stale + +Pay attention to dates. For fast-moving things — pricing, APIs, model versions — trust the most recent reliable source and check it live rather than relying on memory. When there's a real conflict, show both sides and flag it instead of silently picking one. If the user is on a specific version of something, pin your research to that version. Be honest about uncertainty. + +## Easy mistakes to avoid + +- Defaulting to a web search for everything — libraries and platform features have better canonical sources. +- Crawling a whole site when reading a few pages would do. +- Quoting or citing a page you didn't actually read, or inventing a link. +- Burying the answer under raw information instead of synthesizing it. +- Wandering off into tangents before answering what was actually asked. +` + +const CREATE_TABLE_CONTENT = `# Create a Table + +How to set up structured data so it's actually useful later, not just a dump. + +## Design the columns first + +Decide the shape before loading anything. Give each column the narrowest correct type — text, number, true/false, date, or a nested bag for genuinely complex values. Keep numbers as numbers and dates as dates so you can actually sort and compare them later; storing them as plain text quietly breaks filtering. Mark the natural identifier (like an email or an external ID) as unique so duplicates can't sneak in. Don't hide fields you'll want to search inside a nested blob — promote them to real columns. + +## Getting data in + +Pick the lightest path: + +- Start empty with a defined structure and fill it in afterward. +- Build the table directly from an existing data file and let it infer the structure — then sanity-check that dates and yes/no fields came through as the right type. +- Load a data file into a table that already exists, either adding to what's there or replacing it wholesale. +- When the incoming data doesn't line up neatly — needs joining, splitting, or computed columns — reshape it first, then load the clean result. + +If there's no real data to load right now, seed the table with a few dummy rows that match the column types, so the structure is visible and obviously correct rather than sitting empty. Make clear they're placeholders the user can clear out. + +## Querying and bulk edits + +You can filter, sort, and page through rows with the usual comparisons (equals, greater/less than, "is one of," contains, and/or combinations), and send results straight out to a file when someone needs an export. For mass changes you can update or delete everything matching a condition, or apply different values across many specific rows at once. When exporting, let the format follow the file name rather than hand-building it. + +## Automating per row + +You can attach an existing workflow to a table so it runs once for every row, with chosen results landing in their own columns. Some columns act as prerequisites — a row only runs once those are filled. A few things to get right: + +- Always confirm which real outputs a workflow produces before wiring them into columns, rather than guessing. +- Newly attached automation stays paused by default — nothing runs until you start it, which is intentional so you're not burning runs while still setting things up. Kick it off deliberately, or only auto-run immediately if the user explicitly asked for that. +- You can run a single cell, a whole row, or an entire column on demand. +- When one step should wait on another, make it depend on the column the earlier step fills, not on the step itself. + +## Easy mistakes to avoid + +- Loading data before the structure exists when you're filling it programmatically. +- Leaving a table completely empty when there's no data yet — seed a few placeholder rows instead. +- Letting duplicates break a whole import — surface the conflict instead of blindly retrying; switching to a full replace fixes clashes with existing rows but not duplicates within the new data itself. +- Treating ordinary pre-import warnings (a missing or unmapped required column) as failures — they're usually a quick fix. +- Overflowing the table's row limit when adding to existing data. +- Wondering why "nothing ran" — attached automation is paused on purpose until you start it. +- Wiring columns to outputs you assumed existed instead of confirming them. +- Acting on rows by name instead of nailing down the exact table and rows first. +` + +export const BUILTIN_SKILLS: readonly BuiltinSkill[] = [ + { + id: 'builtin-connect-integration', + name: 'connect-integration', + description: 'How to connect a service and choose the right account credential.', + content: CONNECT_INTEGRATION_CONTENT, + }, + { + id: 'builtin-research', + name: 'research', + description: 'How to research well and come back with something genuinely useful.', + content: RESEARCH_CONTENT, + }, + { + id: 'builtin-create-table', + name: 'create-table', + description: "How to set up structured data so it's actually useful later, not just a dump.", + content: CREATE_TABLE_CONTENT, + }, + { + id: 'builtin-deploy-workflow', + name: 'deploy-workflow', + description: 'How to take a finished workflow and make it usable by the outside world.', + content: DEPLOY_WORKFLOW_CONTENT, + }, +] as const + +const BUILTIN_BY_ID = new Map(BUILTIN_SKILLS.map((s) => [s.id, s])) +const BUILTIN_BY_NAME = new Map( + BUILTIN_SKILLS.map((s) => [s.name.toLowerCase(), s]) +) + +export function isBuiltinSkillId(id: string): boolean { + return BUILTIN_BY_ID.has(id) +} + +export function getBuiltinSkillById(id: string): BuiltinSkill | undefined { + return BUILTIN_BY_ID.get(id) +} + +export function getBuiltinSkillByName(name: string): BuiltinSkill | undefined { + return BUILTIN_BY_NAME.get(name.toLowerCase()) +} diff --git a/apps/sim/lib/workflows/skills/operations.test.ts b/apps/sim/lib/workflows/skills/operations.test.ts new file mode 100644 index 00000000000..cd61dad4abf --- /dev/null +++ b/apps/sim/lib/workflows/skills/operations.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { orderByMock } = vi.hoisted(() => ({ orderByMock: vi.fn() })) + +vi.mock('@sim/db', () => ({ + db: { select: () => ({ from: () => ({ where: () => ({ orderBy: orderByMock }) }) }) }, +})) +vi.mock('@sim/db/schema', () => ({ + skill: { workspaceId: 'workspaceId', name: 'name', createdAt: 'createdAt' }, +})) +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})) +vi.mock('@sim/utils/id', () => ({ generateShortId: () => 'gen-id' })) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'req-id' })) +vi.mock('drizzle-orm', () => ({ + and: vi.fn(() => ({})), + desc: vi.fn(() => ({})), + eq: vi.fn(() => ({})), + ne: vi.fn(() => ({})), +})) + +import { listSkills } from './operations' + +describe('listSkills includeBuiltins', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('prepends builtin template skills by default', async () => { + orderByMock.mockResolvedValue([]) + const result = await listSkills({ workspaceId: 'ws-1' }) + expect(result.length).toBeGreaterThan(0) + expect(result.every((s) => s.id.startsWith('builtin-'))).toBe(true) + }) + + // The mothership skill registry passes includeBuiltins: false so it never sees + // the code-only template skills (it loads workspace skills via load_user_skill). + it('excludes builtin template skills when includeBuiltins is false', async () => { + orderByMock.mockResolvedValue([ + { id: 'sk-1', name: 'mine', description: 'd', content: 'c', workspaceId: 'ws-1' }, + ]) + const result = await listSkills({ workspaceId: 'ws-1', includeBuiltins: false }) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('sk-1') + expect(result.some((s) => s.id.startsWith('builtin-'))).toBe(false) + }) +}) diff --git a/apps/sim/lib/workflows/skills/operations.ts b/apps/sim/lib/workflows/skills/operations.ts index 9445c32bfaa..361ef667178 100644 --- a/apps/sim/lib/workflows/skills/operations.ts +++ b/apps/sim/lib/workflows/skills/operations.ts @@ -4,18 +4,74 @@ import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { and, desc, eq, ne } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' +import { + BUILTIN_SKILLS, + type BuiltinSkill, + getBuiltinSkillById, + isBuiltinSkillId, +} from '@/lib/workflows/skills/builtin-skills' const logger = createLogger('SkillsOperations') +/** Stable epoch timestamp for built-in (template) skills, which have no DB row. */ +const BUILTIN_SKILL_TIMESTAMP = new Date(0) + +/** Shape a built-in skill as a `skill` table row so it can ride alongside DB skills. */ +function builtinSkillRow(workspaceId: string, builtin: BuiltinSkill): typeof skill.$inferSelect { + return { + id: builtin.id, + workspaceId, + userId: null, + name: builtin.name, + description: builtin.description, + content: builtin.content, + createdAt: BUILTIN_SKILL_TIMESTAMP, + updatedAt: BUILTIN_SKILL_TIMESTAMP, + } +} + /** - * List all skills for a workspace, ordered by createdAt desc. + * List skills for a workspace, ordered by createdAt desc. Built-in template + * skills are prepended (they live in code, not the DB) so they appear wherever + * real skills do. A workspace skill that shares a built-in's name overrides it. + * + * Pass `includeBuiltins: false` to return only user-created skills. The + * mothership uses this for the skill registry it sees, since it loads workspace + * skills via load_user_skill and never the code-only templates. */ -export async function listSkills(params: { workspaceId: string }) { - return db +export async function listSkills(params: { workspaceId: string; includeBuiltins?: boolean }) { + const dbRows = await db .select() .from(skill) .where(eq(skill.workspaceId, params.workspaceId)) .orderBy(desc(skill.createdAt)) + + if (params.includeBuiltins === false) { + return dbRows + } + + const dbNames = new Set(dbRows.map((r) => r.name.toLowerCase())) + const builtins = BUILTIN_SKILLS.filter((b) => !dbNames.has(b.name.toLowerCase())).map((b) => + builtinSkillRow(params.workspaceId, b) + ) + return [...builtins, ...dbRows] +} + +/** + * Fetch a single skill by id, scoped to a workspace. Built-in template skills + * resolve from code; otherwise returns the DB row, or null when the skill does + * not exist or belongs to a different workspace. + */ +export async function getSkillById(params: { skillId: string; workspaceId: string }) { + const builtin = getBuiltinSkillById(params.skillId) + if (builtin) return builtinSkillRow(params.workspaceId, builtin) + + const rows = await db + .select() + .from(skill) + .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) + .limit(1) + return rows[0] ?? null } /** @@ -26,6 +82,9 @@ export async function deleteSkill(params: { skillId: string workspaceId: string }): Promise { + // Built-in template skills have no DB row and cannot be deleted. + if (isBuiltinSkillId(params.skillId)) return false + const existing = await db .select({ id: skill.id }) .from(skill) @@ -76,6 +135,11 @@ export async function upsertSkills(params: { }): Promise { const { skills, workspaceId, userId, requestId = generateRequestId() } = params + // Built-in template skills are read-only and must never be written to the DB. + if (skills.some((s) => s.id && isBuiltinSkillId(s.id))) { + throw new Error('Built-in skills are read-only and cannot be modified') + } + return await db.transaction(async (tx) => { const touched: TouchedSkill[] = [] diff --git a/apps/sim/lib/workflows/triggers/run-options.test.ts b/apps/sim/lib/workflows/triggers/run-options.test.ts new file mode 100644 index 00000000000..071abf491fc --- /dev/null +++ b/apps/sim/lib/workflows/triggers/run-options.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + type TriggerInputKind, + type TriggerRunOption, + validateTriggerInput, +} from '@/lib/workflows/triggers/run-options' +import { StartBlockPath } from '@/lib/workflows/triggers/triggers' +import type { InputFormatField } from '@/lib/workflows/types' + +function makeOption(overrides: Partial): TriggerRunOption { + const inputKind: TriggerInputKind = overrides.inputKind ?? 'fields' + return { + triggerBlockId: 'blk_1', + blockName: 'Test Trigger', + triggerType: 'api_trigger', + path: StartBlockPath.SPLIT_API, + isDefault: true, + inputKind, + inputSchema: { type: 'object' }, + mockPayload: {}, + inputFormat: [], + ...overrides, + } +} + +const fields = (...f: InputFormatField[]): InputFormatField[] => f + +describe('validateTriggerInput', () => { + describe('fields', () => { + it('accepts input that provides all declared fields with correct types', () => { + const option = makeOption({ + inputFormat: fields({ name: 'city', type: 'string' }, { name: 'days', type: 'number' }), + }) + expect(validateTriggerInput(option, { city: 'SF', days: 3 }).ok).toBe(true) + }) + + it('rejects a missing required field (no default)', () => { + const option = makeOption({ inputFormat: fields({ name: 'city', type: 'string' }) }) + const result = validateTriggerInput(option, {}) + expect(result.ok).toBe(false) + expect(result.error).toContain('city') + }) + + it('treats a field with an author default as optional (matches executor)', () => { + const option = makeOption({ + inputFormat: fields( + { name: 'city', type: 'string' }, + { name: 'limit', type: 'number', value: 10 } + ), + }) + // limit omitted -> still valid because the workflow defaults it + expect(validateTriggerInput(option, { city: 'SF' }).ok).toBe(true) + }) + + it('rejects a wrong field type', () => { + const option = makeOption({ inputFormat: fields({ name: 'days', type: 'number' }) }) + expect(validateTriggerInput(option, { days: 'three' }).ok).toBe(false) + }) + + it('rejects unknown keys for non-UNIFIED triggers', () => { + const option = makeOption({ + path: StartBlockPath.SPLIT_API, + inputFormat: fields({ name: 'city', type: 'string' }), + }) + expect(validateTriggerInput(option, { city: 'SF', extra: 1 }).ok).toBe(false) + }) + + it('allows passthrough keys for UNIFIED start blocks', () => { + const option = makeOption({ + path: StartBlockPath.UNIFIED, + triggerType: 'start_trigger', + inputFormat: fields({ name: 'city', type: 'string' }), + }) + expect(validateTriggerInput(option, { city: 'SF', files: [], conversationId: 'c1' }).ok).toBe( + true + ) + }) + + it('accepts an empty object when the trigger declares no fields', () => { + const option = makeOption({ inputFormat: [] }) + expect(validateTriggerInput(option, {}).ok).toBe(true) + expect(validateTriggerInput(option, undefined).ok).toBe(true) + }) + + it('rejects non-object input when fields are declared', () => { + const option = makeOption({ inputFormat: fields({ name: 'city', type: 'string' }) }) + expect(validateTriggerInput(option, 'SF').ok).toBe(false) + }) + }) + + describe('event_payload', () => { + const option = makeOption({ + inputKind: 'event_payload', + path: StartBlockPath.EXTERNAL_TRIGGER, + triggerType: 'gmail', + }) + + it('accepts a non-empty object', () => { + expect(validateTriggerInput(option, { email: { from: 'a@b.com' } }).ok).toBe(true) + }) + + it('rejects an empty object', () => { + expect(validateTriggerInput(option, {}).ok).toBe(false) + }) + + it('rejects missing/non-object input', () => { + expect(validateTriggerInput(option, undefined).ok).toBe(false) + expect(validateTriggerInput(option, []).ok).toBe(false) + }) + }) + + describe('chat', () => { + const option = makeOption({ + inputKind: 'chat', + path: StartBlockPath.SPLIT_CHAT, + triggerType: 'chat_trigger', + }) + + it('accepts a non-empty input string', () => { + expect(validateTriggerInput(option, { input: 'hello' }).ok).toBe(true) + }) + + it('rejects empty or missing input', () => { + expect(validateTriggerInput(option, {}).ok).toBe(false) + expect(validateTriggerInput(option, { input: '' }).ok).toBe(false) + }) + }) + + describe('none', () => { + const option = makeOption({ + inputKind: 'none', + path: StartBlockPath.EXTERNAL_TRIGGER, + triggerType: 'schedule', + }) + + it('accepts any input (no input required)', () => { + expect(validateTriggerInput(option, undefined).ok).toBe(true) + expect(validateTriggerInput(option, { anything: 1 }).ok).toBe(true) + }) + }) +}) diff --git a/apps/sim/lib/workflows/triggers/run-options.ts b/apps/sim/lib/workflows/triggers/run-options.ts new file mode 100644 index 00000000000..a35adf99dc5 --- /dev/null +++ b/apps/sim/lib/workflows/triggers/run-options.ts @@ -0,0 +1,398 @@ +import { z } from 'zod' +import { generateToolInputSchema, generateToolZodSchema } from '@/lib/mcp/workflow-tool-schema' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' +import { + extractTriggerMockPayload, + selectBestTrigger, +} from '@/lib/workflows/triggers/trigger-utils' +import { + getLegacyStarterMode, + resolveStartCandidates, + type StartBlockCandidate, + StartBlockPath, +} from '@/lib/workflows/triggers/triggers' +import type { InputFormatField } from '@/lib/workflows/types' +import { getBlock } from '@/blocks' +import { coerceValue } from '@/executor/utils/start-block' +import { getTrigger } from '@/triggers' + +/** + * How a trigger expects its run-time input, surfaced to the agent so it can tell + * the difference between building flat form fields and an event payload. + */ +export type TriggerInputKind = 'fields' | 'event_payload' | 'chat' | 'none' + +/** Minimal block shape needed to resolve and describe a trigger. */ +interface TriggerBlockLike { + type: string + name?: string + enabled?: boolean + triggerMode?: boolean + subBlocks?: Record +} + +export interface TriggerRunOption { + /** The block ID to pass to run_workflow's triggerBlockId. */ + triggerBlockId: string + blockName: string + triggerType: string + path: StartBlockPath + isDefault: boolean + inputKind: TriggerInputKind + /** JSON-Schema-ish description of the input the agent should build. */ + inputSchema: Record + /** A ready-to-use example the agent may copy only if it can't build its own. */ + mockPayload: unknown + /** + * Raw input fields used for strict validation. Internal — callers serializing + * to the agent should omit this (see toPublicRunOption). + */ + inputFormat: InputFormatField[] +} + +export interface TriggerInputValidationResult { + ok: boolean + error?: string +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readSubBlockValue(block: TriggerBlockLike, key: string): unknown { + const raw = (block.subBlocks as Record | undefined)?.[key] + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return (raw as { value?: unknown }).value + } + return undefined +} + +function mapOutputType(type: string): string { + switch (type) { + case 'json': + return 'object' + case 'number': + case 'boolean': + case 'array': + case 'object': + case 'string': + return type + default: + return 'string' + } +} + +function outputFieldToSchema(field: unknown): Record { + if ( + field && + typeof field === 'object' && + 'type' in field && + typeof (field as { type: unknown }).type === 'string' + ) { + const typed = field as { + type: string + properties?: Record + items?: unknown + } + if ((typed.type === 'object' || typed.type === 'json') && typed.properties) { + const properties: Record = {} + for (const [key, value] of Object.entries(typed.properties)) { + properties[key] = outputFieldToSchema(value) + } + return { type: 'object', properties } + } + if (typed.type === 'array' && typed.items) { + return { type: 'array', items: outputFieldToSchema(typed.items) } + } + return { type: mapOutputType(typed.type) } + } + + if (field && typeof field === 'object' && !Array.isArray(field)) { + const properties: Record = {} + for (const [key, value] of Object.entries(field)) { + properties[key] = outputFieldToSchema(value) + } + return { type: 'object', properties } + } + + return { type: 'string' } +} + +function triggerOutputsToJsonSchema(outputs: Record): Record { + const properties: Record = {} + for (const [key, value] of Object.entries(outputs)) { + if (key === 'visualization') continue + properties[key] = outputFieldToSchema(value) + } + return { type: 'object', properties } +} + +function mockValueForType(type: string | undefined, name: string): unknown { + switch (type) { + case 'number': + return 42 + case 'boolean': + return true + case 'array': + return [] + case 'object': + return {} + case 'files': + case 'file[]': + return [] + default: + return `mock_${name}` + } +} + +function buildFieldsSample(inputFormat: InputFormatField[]): Record { + const sample: Record = {} + for (const field of inputFormat) { + if (!field.name) continue + sample[field.name] = + field.value !== undefined && field.value !== null + ? coerceValue(field.type, field.value) + : mockValueForType(field.type, field.name) + } + return sample +} + +function resolveEventTriggerId(block: TriggerBlockLike): string { + const selected = readSubBlockValue(block, 'selectedTriggerId') + if (typeof selected === 'string' && selected) { + return selected + } + const blockConfig = getBlock(block.type) + if (blockConfig?.triggers?.available?.length === 1) { + return blockConfig.triggers.available[0] + } + return block.type +} + +function safeTriggerOutputs(triggerId: string): Record | undefined { + try { + const trigger = getTrigger(triggerId) + return trigger?.outputs as Record | undefined + } catch { + return undefined + } +} + +function extractEventMockPayload(candidate: StartBlockCandidate): unknown { + const triggerId = resolveEventTriggerId(candidate.block) + const sampleRaw = + readSubBlockValue(candidate.block, `samplePayload_${triggerId}`) ?? + readSubBlockValue(candidate.block, 'samplePayload') + + if (typeof sampleRaw === 'string' && sampleRaw.trim()) { + try { + return JSON.parse(sampleRaw) + } catch { + // fall through to generated mock + } + } else if (sampleRaw && typeof sampleRaw === 'object') { + return sampleRaw + } + + return extractTriggerMockPayload(candidate) +} + +function resolveInputKind(path: StartBlockPath, block: TriggerBlockLike): TriggerInputKind { + if (path === StartBlockPath.SPLIT_CHAT) return 'chat' + if (path === StartBlockPath.LEGACY_STARTER) { + return getLegacyStarterMode(block) === 'chat' ? 'chat' : 'fields' + } + if (path === StartBlockPath.EXTERNAL_TRIGGER) { + return block.type === 'schedule' ? 'none' : 'event_payload' + } + return 'fields' +} + +function buildTriggerRunOption( + candidate: StartBlockCandidate, + isDefault: boolean +): TriggerRunOption { + const { blockId, block, path } = candidate + const blockConfig = getBlock(block.type) + const blockName = block.name || blockConfig?.name || block.type + const inputKind = resolveInputKind(path, block) + const inputFormat = normalizeInputFormatValue(readSubBlockValue(block, 'inputFormat')) + + let inputSchema: Record + let mockPayload: unknown + + switch (inputKind) { + case 'fields': { + inputSchema = generateToolInputSchema(inputFormat) + mockPayload = buildFieldsSample(inputFormat) + break + } + case 'event_payload': { + const triggerId = resolveEventTriggerId(block) + const outputs = safeTriggerOutputs(triggerId) + inputSchema = outputs + ? triggerOutputsToJsonSchema(outputs) + : { type: 'object', properties: {} } + mockPayload = extractEventMockPayload(candidate) + break + } + case 'chat': { + inputSchema = { + type: 'object', + required: ['input'], + properties: { + input: { type: 'string', description: 'User message' }, + conversationId: { type: 'string', description: 'Optional conversation ID' }, + }, + } + mockPayload = { input: 'mock_message' } + break + } + default: { + inputSchema = { type: 'object', properties: {} } + mockPayload = {} + break + } + } + + return { + triggerBlockId: blockId, + blockName, + triggerType: block.type, + path, + isDefault, + inputKind, + inputSchema, + mockPayload, + inputFormat, + } +} + +/** + * Enumerates every runnable trigger in a workflow (across manual + chat entry + * kinds), marking the one the executor would pick by default. Used by the + * get_workflow_run_options tool (to describe) and run_workflow (to validate), + * guaranteeing describe == enforce. + */ +export function resolveTriggerRunOptions( + blocks: Record, + edges?: Array<{ source: string; target: string }> +): TriggerRunOption[] { + const manual = resolveStartCandidates(blocks, { execution: 'manual' }) + const chat = resolveStartCandidates(blocks, { execution: 'chat' }) + + const byId = new Map>() + for (const candidate of [...manual, ...chat]) { + if (!byId.has(candidate.blockId)) { + byId.set(candidate.blockId, candidate) + } + } + + const candidates = [...byId.values()] + if (candidates.length === 0) { + return [] + } + + // Single overall default (no edges => one best); ties broken by trigger priority. + const defaultBlockId = selectBestTrigger(candidates)[0]?.blockId + + return candidates.map((candidate) => + buildTriggerRunOption(candidate, candidate.blockId === defaultBlockId) + ) +} + +/** Strips internal fields so the option can be returned to the agent. */ +export function toPublicRunOption(option: TriggerRunOption): { + triggerBlockId: string + blockName: string + triggerType: string + isDefault: boolean + inputKind: TriggerInputKind + inputSchema: Record + mockPayload: unknown +} { + const { inputFormat: _inputFormat, path: _path, ...rest } = option + return rest +} + +/** + * Strictly validates an agent-supplied workflow_input against a trigger. There + * are no fallbacks: anything incorrect returns an error so the agent retries. + */ +export function validateTriggerInput( + option: TriggerRunOption, + input: unknown +): TriggerInputValidationResult { + switch (option.inputKind) { + case 'none': + return { ok: true } + + case 'chat': { + if (!isPlainObject(input) || typeof input.input !== 'string' || input.input.trim() === '') { + return { + ok: false, + error: `Chat trigger "${option.blockName}" requires workflow_input shaped like { "input": "" }.`, + } + } + return { ok: true } + } + + case 'event_payload': { + if (!isPlainObject(input) || Object.keys(input).length === 0) { + return { + ok: false, + error: + `Trigger "${option.blockName}" (${option.triggerType}) requires a non-empty event payload. ` + + `Build workflow_input matching this shape, or run with useMockPayload: true. ` + + `Expected shape: ${JSON.stringify(option.inputSchema)}`, + } + } + return { ok: true } + } + default: { + const baseShape = generateToolZodSchema(option.inputFormat) + if (!baseShape) { + // Trigger declares no input fields — accept an object (including {}). + if (input === undefined || input === null) return { ok: true } + if (!isPlainObject(input)) { + return { + ok: false, + error: `Trigger "${option.blockName}" expects a JSON object for workflow_input.`, + } + } + return { ok: true } + } + + // A field with an author-configured default is optional: the executor fills + // the default when it's omitted (deriveInputFromFormat), so requiring it + // would reject a run the workflow itself accepts. + const shape: Record = {} + for (const [name, baseType] of Object.entries(baseShape)) { + const zodType = baseType as z.ZodTypeAny + const field = option.inputFormat.find((f) => f.name === name) + const hasDefault = field?.value !== undefined && field?.value !== null + shape[name] = hasDefault ? zodType.optional() : zodType + } + + // UNIFIED start blocks pass arbitrary keys through to their output, so + // unknown keys are valid there; other trigger kinds only consume declared + // fields, so unknown keys signal a mistake and are rejected. + const objectSchema = z.object(shape) + const schema = + option.path === StartBlockPath.UNIFIED ? objectSchema.passthrough() : objectSchema.strict() + const result = schema.safeParse(input ?? {}) + if (!result.success) { + const issues = result.error.issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; ') + return { + ok: false, + error: + `workflow_input does not match trigger "${option.blockName}" (${issues}). ` + + `Expected: ${JSON.stringify(option.inputSchema)}`, + } + } + return { ok: true } + } + } +} diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 0e28812f211..4f653da8af0 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -6,6 +6,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz' import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing' import { materializeInlineExecutionValue } from '@/lib/execution/payloads/inline-materialization.server' import type { ExecutionMaterializationContext } from '@/lib/execution/payloads/materialization.server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -106,6 +107,7 @@ export type WorkflowResolutionResult = | { status: 'resolved' workflowId: string + workspaceId: string workflowName?: string } | { @@ -141,7 +143,18 @@ export async function resolveWorkflowIdForUser( } } const wf = await getWorkflowById(workflowId) - return { status: 'resolved', workflowId, workflowName: wf?.name || undefined } + if (!wf?.workspaceId) { + return { + status: 'not_found', + message: 'No workflows found. Create a workflow first or provide a valid workflowId.', + } + } + return { + status: 'resolved', + workflowId, + workspaceId: wf.workspaceId, + workflowName: wf.name || undefined, + } } const workspaceIds = await db @@ -160,7 +173,7 @@ export async function resolveWorkflowIdForUser( } } - const workflows = await db + const workflowRows = await db .select() .from(workflowTable) .where( @@ -168,6 +181,11 @@ export async function resolveWorkflowIdForUser( ) .orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt), asc(workflowTable.id)) + const workflows = workflowRows.filter( + (workflow): workflow is (typeof workflowRows)[number] & { workspaceId: string } => + workflow.workspaceId !== null + ) + if (workflows.length === 0) { return { status: 'not_found', @@ -187,6 +205,7 @@ export async function resolveWorkflowIdForUser( return { status: 'resolved', workflowId: match.id, + workspaceId: match.workspaceId, workflowName: match.name || undefined, } } @@ -211,6 +230,7 @@ export async function resolveWorkflowIdForUser( return { status: 'resolved', workflowId: workflows[0].id, + workspaceId: workflows[0].workspaceId, workflowName: workflows[0].name || undefined, } } @@ -432,6 +452,8 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { throw new Error(saveResult.error || 'Failed to save workflow state') } + await ensureWorkflowAliasBacking({ workspaceId, userId, workflowId, workflowName: name }) + return { workflowId, name, workspaceId, folderId, sortOrder, createdAt: now, updatedAt: now } } diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 7addcc8b697..4bbcb456b1a 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -82,6 +82,8 @@ const nextConfig: NextConfig = { 'fluent-ffmpeg', 'ws', 'isolated-vm', + '@e2b/code-interpreter', + 'e2b', ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], diff --git a/apps/sim/serializer/field-analysis.test.ts b/apps/sim/serializer/field-analysis.test.ts new file mode 100644 index 00000000000..41d8dd6f201 --- /dev/null +++ b/apps/sim/serializer/field-analysis.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + * + * Tests for the exported field-analysis helpers in serializer/index.ts + * (collectBlockFieldIssues / extractBlockParams) — the single source of truth + * shared by the serializer's required-field validation and the copilot lint. + */ +import { blocksMock, toolsUtilsMock } from '@sim/testing/mocks' +import { describe, expect, it, vi } from 'vitest' + +const { svcConfig } = vi.hoisted(() => ({ svcConfig: { value: null as any } })) + +vi.mock('@/tools/utils', () => toolsUtilsMock) +vi.mock('@/blocks', () => ({ + ...blocksMock, + getBlock: (type: string) => (type === 'svc' ? svcConfig.value : blocksMock.getBlock(type)), +})) + +import { collectBlockFieldIssues, extractBlockParams } from '@/serializer/index' + +function block(overrides: Record = {}) { + return { + id: 'b1', + type: 'x', + name: 'My Block', + enabled: true, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + data: {}, + ...overrides, + } as any +} + +function config(subBlocks: any[], overrides: Record = {}) { + return { + name: 'X', + category: 'tools', + tools: { access: [] }, + subBlocks, + ...overrides, + } as any +} + +describe('collectBlockFieldIssues', () => { + it('reports a missing required field (active mode empty)', () => { + const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }]) + const issues = collectBlockFieldIssues(block({ subBlocks: { apiKey: { value: '' } } }), cfg, {}) + expect(issues.missingRequiredFields).toEqual(['API Key']) + expect(issues.inactiveModeValues).toEqual([]) + }) + + it('does not report a required field that is set', () => { + const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }]) + const issues = collectBlockFieldIssues( + block({ subBlocks: { apiKey: { value: 'sk-123' } } }), + cfg, + { apiKey: 'sk-123' } + ) + expect(issues.missingRequiredFields).toEqual([]) + }) + + it('skips disabled blocks', () => { + const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }]) + const issues = collectBlockFieldIssues(block({ enabled: false }), cfg, {}) + expect(issues).toEqual({ missingRequiredFields: [], inactiveModeValues: [] }) + }) + + it('skips trigger-mode blocks', () => { + const cfg = config([{ id: 'apiKey', title: 'API Key', type: 'short-input', required: true }]) + const issues = collectBlockFieldIssues(block({ triggerMode: true }), cfg, {}) + expect(issues).toEqual({ missingRequiredFields: [], inactiveModeValues: [] }) + }) + + it('only flags a condition-gated required field when its condition is met', () => { + const cfg = config([ + { id: 'mode', type: 'dropdown' }, + { + id: 'topic', + title: 'Topic', + type: 'short-input', + required: { field: 'mode', value: 'advanced' }, + }, + ]) + + const inactive = collectBlockFieldIssues( + block({ subBlocks: { mode: { value: 'basic' } } }), + cfg, + { mode: 'basic' } + ) + expect(inactive.missingRequiredFields).toEqual([]) + + const active = collectBlockFieldIssues( + block({ subBlocks: { mode: { value: 'advanced' } } }), + cfg, + { mode: 'advanced' } + ) + expect(active.missingRequiredFields).toEqual(['Topic']) + }) + + it('flags a credential value stranded on the inactive member', () => { + const cfg = config([ + { + id: 'credential', + title: 'Account', + type: 'oauth-input', + canonicalParamId: 'cred', + mode: 'basic', + required: true, + }, + { + id: 'manualCredential', + title: 'Account', + type: 'short-input', + canonicalParamId: 'cred', + mode: 'advanced', + required: true, + }, + ]) + + // canonicalModes forces 'basic', but the value lives on the advanced member. + const issues = collectBlockFieldIssues( + block({ + advancedMode: false, + data: { canonicalModes: { cred: 'basic' } }, + subBlocks: { credential: { value: '' }, manualCredential: { value: 'cred_123' } }, + }), + cfg, + { cred: '' } + ) + + expect(issues.inactiveModeValues).toEqual([ + { + canonicalId: 'cred', + activeMemberId: 'credential', + inactiveMemberId: 'manualCredential', + kind: 'credential', + }, + ]) + // The active (basic) member is empty + required -> also a missing field. + expect(issues.missingRequiredFields).toEqual(['Account']) + }) + + it('does not flag a credential value on the correct active member', () => { + const cfg = config([ + { + id: 'credential', + title: 'Account', + type: 'oauth-input', + canonicalParamId: 'cred', + mode: 'basic', + required: true, + }, + { + id: 'manualCredential', + title: 'Account', + type: 'short-input', + canonicalParamId: 'cred', + mode: 'advanced', + required: true, + }, + ]) + + // No override: an empty basic + filled advanced resolves to 'advanced', so + // the value is on the active member and nothing is stranded. + const issues = collectBlockFieldIssues( + block({ + advancedMode: false, + subBlocks: { credential: { value: '' }, manualCredential: { value: 'cred_123' } }, + }), + cfg, + { cred: 'cred_123' } + ) + + expect(issues.inactiveModeValues).toEqual([]) + expect(issues.missingRequiredFields).toEqual([]) + }) +}) + +describe('extractBlockParams', () => { + it('returns {} for subflow containers', () => { + expect(extractBlockParams(block({ type: 'loop' }))).toEqual({}) + expect(extractBlockParams(block({ type: 'parallel' }))).toEqual({}) + }) + + it('resolves a canonical pair to its canonical id (advanced value wins when basic empty)', () => { + svcConfig.value = config([ + { + id: 'credential', + title: 'Account', + type: 'oauth-input', + canonicalParamId: 'cred', + mode: 'basic', + }, + { + id: 'manualCredential', + title: 'Account', + type: 'short-input', + canonicalParamId: 'cred', + mode: 'advanced', + }, + ]) + + const params = extractBlockParams( + block({ + type: 'svc', + advancedMode: false, + subBlocks: { credential: { value: '' }, manualCredential: { value: 'cred_123' } }, + }) + ) + + expect(params.cred).toBe('cred_123') + expect(params.credential).toBeUndefined() + expect(params.manualCredential).toBeUndefined() + }) +}) diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index e660a9d4ee6..e97934c7954 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -227,7 +227,7 @@ export class Serializer { } // Extract parameters from UI state - const params = this.extractParams(block) + const params = extractBlockParams(block) const isTriggerCategory = blockConfig.category === 'triggers' if (block.triggerMode === true || isTriggerCategory) { @@ -239,7 +239,13 @@ export class Serializer { // Validate required fields that only users can provide (before execution starts) if (options.validateRequired) { - this.validateRequiredFieldsBeforeExecution(block, blockConfig, params) + const { missingRequiredFields } = collectBlockFieldIssues(block, blockConfig, params) + if (missingRequiredFields.length > 0) { + const blockName = block.name || blockConfig.name || 'Block' + throw new Error( + `${blockName} is missing required fields: ${missingRequiredFields.join(', ')}` + ) + } } let toolId = '' @@ -255,7 +261,7 @@ export class Serializer { // For non-custom tools, we determine the tool ID const nonCustomTools = tools.filter((tool: any) => tool.type !== 'custom-tool') if (nonCustomTools.length > 0) { - toolId = this.selectToolId(blockConfig, params) + toolId = selectToolId(blockConfig, params) } } catch (error) { logger.error('Error processing tools in agent block:', { error }) @@ -264,7 +270,7 @@ export class Serializer { } } else { // For non-agent blocks, get tool ID from block config as usual - toolId = this.selectToolId(blockConfig, params) + toolId = selectToolId(blockConfig, params) } // Get inputs from block config @@ -303,263 +309,6 @@ export class Serializer { return serialized } - private extractParams(block: BlockState): Record { - if (block.type === 'loop' || block.type === 'parallel') { - return {} - } - - const blockConfig = getBlock(block.type) - if (!blockConfig) { - throw new Error(`Invalid block type: ${block.type}`) - } - - const params: Record = {} - const legacyAdvancedMode = block.advancedMode ?? false - const canonicalModeOverrides = block.data?.canonicalModes - const isStarterBlock = block.type === 'starter' - const isAgentBlock = block.type === 'agent' - const isTriggerContext = block.triggerMode ?? false - const isTriggerCategory = blockConfig.category === 'triggers' - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) - const allValues = buildSubBlockValues(block.subBlocks) - - Object.entries(block.subBlocks).forEach(([id, subBlock]) => { - const matchingConfigs = blockConfig.subBlocks.filter((config) => config.id === id) - - const hasStarterInputFormatValues = - isStarterBlock && - id === 'inputFormat' && - Array.isArray(subBlock.value) && - subBlock.value.length > 0 - - const isLegacyAgentField = - isAgentBlock && ['systemPrompt', 'userPrompt', 'memories'].includes(id) - - const shouldInclude = - matchingConfigs.length === 0 || - matchingConfigs.some((config) => - shouldSerializeSubBlock( - config, - allValues, - legacyAdvancedMode, - isTriggerContext, - isTriggerCategory, - canonicalIndex, - canonicalModeOverrides - ) - ) - - if ( - (matchingConfigs.length > 0 && shouldInclude) || - hasStarterInputFormatValues || - isLegacyAgentField - ) { - params[id] = subBlock.value - } - }) - - blockConfig.subBlocks.forEach((subBlockConfig) => { - const id = subBlockConfig.id - if ( - params[id] == null && - subBlockConfig.value && - shouldSerializeSubBlock( - subBlockConfig, - allValues, - legacyAdvancedMode, - isTriggerContext, - isTriggerCategory, - canonicalIndex, - canonicalModeOverrides - ) - ) { - params[id] = subBlockConfig.value(params) - } - }) - - Object.values(canonicalIndex.groupsById).forEach((group) => { - const { basicValue, advancedValue } = getCanonicalValues(group, params) - const hasExplicitOverride = canonicalModeOverrides?.[group.canonicalId] != null - const pairMode = - hasExplicitOverride || !legacyAdvancedMode - ? resolveCanonicalMode(group, allValues, canonicalModeOverrides) - : 'advanced' - const chosen = pairMode === 'advanced' ? advancedValue : basicValue - - const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] - sourceIds.forEach((id) => delete params[id]) - - if (chosen !== undefined) { - params[group.canonicalId] = chosen - } - }) - - return params - } - - private validateRequiredFieldsBeforeExecution( - block: BlockState, - blockConfig: any, - params: Record - ) { - // Skip validation if the block is disabled - if (block.enabled === false) { - return - } - - // Skip validation if the block is used as a trigger - if ( - block.triggerMode === true || - blockConfig.category === 'triggers' || - params.triggerMode === true - ) { - logger.info('Skipping validation for block in trigger mode', { - blockId: block.id, - blockType: block.type, - }) - return - } - - const missingFields: string[] = [] - const displayAdvancedOptions = block.advancedMode ?? false - const isTriggerContext = block.triggerMode ?? false - const isTriggerCategory = blockConfig.category === 'triggers' - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || []) - const canonicalModeOverrides = block.data?.canonicalModes - const allValues = buildSubBlockValues(block.subBlocks) - - // Get the tool configuration to check parameter visibility - const toolAccess = blockConfig.tools?.access - const currentToolId = toolAccess?.length > 0 ? this.selectToolId(blockConfig, params) : null - const currentTool = currentToolId ? getTool(currentToolId) : null - - // Validate tool parameters (for blocks with tools). - // Lookup contract: a tool param's value lives under its own paramId in `params`. - // Block subBlocks must align via either `id === paramId` or `canonicalParamId === paramId` - // (enforced by apps/sim/scripts/check-block-registry.ts), so this validator never has to invoke - // the block's `tools.config.params` mapper. - if (currentTool) { - Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]) => { - if (paramConfig.required && paramConfig.visibility === 'user-only') { - const matchingConfigs = - blockConfig.subBlocks?.filter( - (sb: any) => sb.id === paramId || sb.canonicalParamId === paramId - ) || [] - - let shouldValidateParam = true - - if (matchingConfigs.length > 0) { - shouldValidateParam = matchingConfigs.some((subBlockConfig: any) => { - const includedByMode = shouldSerializeSubBlock( - subBlockConfig, - allValues, - displayAdvancedOptions, - isTriggerContext, - isTriggerCategory, - canonicalIndex, - canonicalModeOverrides - ) - - const isRequired = (() => { - if (!subBlockConfig.required) return false - if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required - return evaluateSubBlockCondition(subBlockConfig.required, params) - })() - - return includedByMode && isRequired - }) - } - - if (!shouldValidateParam) { - return - } - - const fieldValue = params[paramId] - if (fieldValue === undefined || fieldValue === null || fieldValue === '') { - const activeConfig = matchingConfigs.find((config: any) => - shouldSerializeSubBlock( - config, - allValues, - displayAdvancedOptions, - isTriggerContext, - isTriggerCategory, - canonicalIndex, - canonicalModeOverrides - ) - ) - const displayName = activeConfig?.title || paramId - missingFields.push(displayName) - } - } - }) - } - - // Validate required subBlocks not covered by tool params (e.g., blocks with empty tools.access) - const validatedByTool = new Set(currentTool ? Object.keys(currentTool.params || {}) : []) - - blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => { - // Skip if already validated via tool params (either by id or canonical bridge) - if (validatedByTool.has(subBlockConfig.id)) { - return - } - if (subBlockConfig.canonicalParamId && validatedByTool.has(subBlockConfig.canonicalParamId)) { - return - } - - // Check if subBlock is visible - const isVisible = shouldSerializeSubBlock( - subBlockConfig, - allValues, - displayAdvancedOptions, - isTriggerContext, - isTriggerCategory, - canonicalIndex, - canonicalModeOverrides - ) - - if (!isVisible) { - return - } - - // Check if subBlock is required - const isRequired = (() => { - if (!subBlockConfig.required) return false - if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required - return evaluateSubBlockCondition(subBlockConfig.required, params) - })() - - if (!isRequired) { - return - } - - // Check if value is missing - // For canonical subBlocks, look up the canonical param value (original IDs were deleted) - const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id] - const fieldValue = canonicalId ? params[canonicalId] : params[subBlockConfig.id] - if (fieldValue === undefined || fieldValue === null || fieldValue === '') { - missingFields.push(subBlockConfig.title || subBlockConfig.id) - } - }) - - if (missingFields.length > 0) { - const blockName = block.name || blockConfig.name || 'Block' - throw new Error(`${blockName} is missing required fields: ${missingFields.join(', ')}`) - } - } - - private selectToolId(blockConfig: any, params: Record): string { - try { - return blockConfig.tools.config?.tool - ? blockConfig.tools.config.tool(params) - : blockConfig.tools.access[0] - } catch (error) { - logger.warn('Tool selection failed during serialization, using default:', { - error: toError(error).message, - }) - return blockConfig.tools.access[0] - } - } - deserializeWorkflow(workflow: SerializedWorkflow): { blocks: Record edges: Edge[] @@ -641,3 +390,319 @@ export class Serializer { } } } + +/** A canonical pair where the active member is empty but an inactive member holds a value that will be silently dropped. */ +export interface InactiveModeValue { + canonicalId: string + /** The member the active mode reads from (where the value should live). */ + activeMemberId?: string + /** The member that currently holds the stranded value. */ + inactiveMemberId: string + kind: 'credential' | 'resource' | 'other' +} + +export interface BlockFieldIssues { + missingRequiredFields: string[] + inactiveModeValues: InactiveModeValue[] +} + +/** + * Select the tool id for a block given its resolved params. + */ +export function selectToolId(blockConfig: any, params: Record): string { + try { + return blockConfig.tools.config?.tool + ? blockConfig.tools.config.tool(params) + : blockConfig.tools.access[0] + } catch (error) { + logger.warn('Tool selection failed during serialization, using default:', { + error: toError(error).message, + }) + return blockConfig.tools.access[0] + } +} + +/** + * Resolve a block's UI sub-block state into the flat `params` map the runtime + * sees. Loop/parallel containers have no params; unknown block types throw. + * + * Exported as the single source of truth so the copilot workflow lint resolves + * params exactly the way execution (serializeBlock) does. + */ +export function extractBlockParams(block: BlockState): Record { + if (block.type === 'loop' || block.type === 'parallel') { + return {} + } + + const blockConfig = getBlock(block.type) + if (!blockConfig) { + throw new Error(`Invalid block type: ${block.type}`) + } + + const params: Record = {} + const legacyAdvancedMode = block.advancedMode ?? false + const canonicalModeOverrides = block.data?.canonicalModes + const isStarterBlock = block.type === 'starter' + const isAgentBlock = block.type === 'agent' + const isTriggerContext = block.triggerMode ?? false + const isTriggerCategory = blockConfig.category === 'triggers' + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const allValues = buildSubBlockValues(block.subBlocks) + + Object.entries(block.subBlocks).forEach(([id, subBlock]) => { + const matchingConfigs = blockConfig.subBlocks.filter((config) => config.id === id) + + const hasStarterInputFormatValues = + isStarterBlock && + id === 'inputFormat' && + Array.isArray(subBlock.value) && + subBlock.value.length > 0 + + const isLegacyAgentField = + isAgentBlock && ['systemPrompt', 'userPrompt', 'memories'].includes(id) + + const shouldInclude = + matchingConfigs.length === 0 || + matchingConfigs.some((config) => + shouldSerializeSubBlock( + config, + allValues, + legacyAdvancedMode, + isTriggerContext, + isTriggerCategory, + canonicalIndex, + canonicalModeOverrides + ) + ) + + if ( + (matchingConfigs.length > 0 && shouldInclude) || + hasStarterInputFormatValues || + isLegacyAgentField + ) { + params[id] = subBlock.value + } + }) + + blockConfig.subBlocks.forEach((subBlockConfig) => { + const id = subBlockConfig.id + if ( + params[id] == null && + subBlockConfig.value && + shouldSerializeSubBlock( + subBlockConfig, + allValues, + legacyAdvancedMode, + isTriggerContext, + isTriggerCategory, + canonicalIndex, + canonicalModeOverrides + ) + ) { + params[id] = subBlockConfig.value(params) + } + }) + + Object.values(canonicalIndex.groupsById).forEach((group) => { + const { basicValue, advancedValue } = getCanonicalValues(group, params) + const hasExplicitOverride = canonicalModeOverrides?.[group.canonicalId] != null + const pairMode = + hasExplicitOverride || !legacyAdvancedMode + ? resolveCanonicalMode(group, allValues, canonicalModeOverrides) + : 'advanced' + const chosen = pairMode === 'advanced' ? advancedValue : basicValue + + const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] + sourceIds.forEach((id) => delete params[id]) + + if (chosen !== undefined) { + params[group.canonicalId] = chosen + } + }) + + return params +} + +/** + * Classify a canonical group as a credential/resource selector based on the + * sub-block type of its members (oauth-input -> credential, *-selector -> + * resource). + */ +function classifyCanonicalKind( + blockConfig: any, + memberIds: string[] +): 'credential' | 'resource' | 'other' { + for (const id of memberIds) { + const cfg = blockConfig.subBlocks?.find((sb: any) => sb.id === id) + const type = cfg?.type + if (type === 'oauth-input') return 'credential' + if (typeof type === 'string' && type.endsWith('-selector')) return 'resource' + } + return 'other' +} + +/** + * Non-throwing analysis of a block's required fields and canonical-mode value + * placement. `serializeBlock` wraps this and throws on missing required fields + * (execution ground truth); the copilot workflow lint consumes the structured + * result. Single source of truth shared by both, so they can never drift. + */ +export function collectBlockFieldIssues( + block: BlockState, + blockConfig: any, + params: Record +): BlockFieldIssues { + // Disabled blocks and trigger-mode blocks are not validated (mirrors runtime). + if (block.enabled === false) { + return { missingRequiredFields: [], inactiveModeValues: [] } + } + if ( + block.triggerMode === true || + blockConfig.category === 'triggers' || + params.triggerMode === true + ) { + return { missingRequiredFields: [], inactiveModeValues: [] } + } + + const missingFields: string[] = [] + const displayAdvancedOptions = block.advancedMode ?? false + const isTriggerContext = block.triggerMode ?? false + const isTriggerCategory = blockConfig.category === 'triggers' + const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || []) + const canonicalModeOverrides = block.data?.canonicalModes + const allValues = buildSubBlockValues(block.subBlocks) + + // Get the tool configuration to check parameter visibility + const toolAccess = blockConfig.tools?.access + const currentToolId = toolAccess?.length > 0 ? selectToolId(blockConfig, params) : null + const currentTool = currentToolId ? getTool(currentToolId) : null + + // Validate tool parameters (for blocks with tools). + // Lookup contract: a tool param's value lives under its own paramId in `params`. + // Block subBlocks align via either `id === paramId` or `canonicalParamId === paramId`. + if (currentTool) { + Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]: [string, any]) => { + if (paramConfig.required && paramConfig.visibility === 'user-only') { + const matchingConfigs = + blockConfig.subBlocks?.filter( + (sb: any) => sb.id === paramId || sb.canonicalParamId === paramId + ) || [] + + let shouldValidateParam = true + + if (matchingConfigs.length > 0) { + shouldValidateParam = matchingConfigs.some((subBlockConfig: any) => { + const includedByMode = shouldSerializeSubBlock( + subBlockConfig, + allValues, + displayAdvancedOptions, + isTriggerContext, + isTriggerCategory, + canonicalIndex, + canonicalModeOverrides + ) + + const isRequired = (() => { + if (!subBlockConfig.required) return false + if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required + return evaluateSubBlockCondition(subBlockConfig.required, params) + })() + + return includedByMode && isRequired + }) + } + + if (!shouldValidateParam) { + return + } + + const fieldValue = params[paramId] + if (fieldValue === undefined || fieldValue === null || fieldValue === '') { + const activeConfig = matchingConfigs.find((config: any) => + shouldSerializeSubBlock( + config, + allValues, + displayAdvancedOptions, + isTriggerContext, + isTriggerCategory, + canonicalIndex, + canonicalModeOverrides + ) + ) + const displayName = activeConfig?.title || paramId + missingFields.push(displayName) + } + } + }) + } + + // Validate required subBlocks not covered by tool params (e.g., blocks with empty tools.access) + const validatedByTool = new Set(currentTool ? Object.keys(currentTool.params || {}) : []) + + blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => { + if (validatedByTool.has(subBlockConfig.id)) { + return + } + if (subBlockConfig.canonicalParamId && validatedByTool.has(subBlockConfig.canonicalParamId)) { + return + } + + const isVisible = shouldSerializeSubBlock( + subBlockConfig, + allValues, + displayAdvancedOptions, + isTriggerContext, + isTriggerCategory, + canonicalIndex, + canonicalModeOverrides + ) + + if (!isVisible) { + return + } + + const isRequired = (() => { + if (!subBlockConfig.required) return false + if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required + return evaluateSubBlockCondition(subBlockConfig.required, params) + })() + + if (!isRequired) { + return + } + + // For canonical subBlocks, look up the canonical param value (original IDs were deleted) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id] + const fieldValue = canonicalId ? params[canonicalId] : params[subBlockConfig.id] + if (fieldValue === undefined || fieldValue === null || fieldValue === '') { + missingFields.push(subBlockConfig.title || subBlockConfig.id) + } + }) + + // Detect canonical pairs whose active member is empty while an inactive member + // holds a value (the value is silently dropped at serialize time). + const inactiveModeValues: InactiveModeValue[] = [] + for (const group of Object.values(canonicalIndex.groupsById)) { + if (!isCanonicalPair(group)) continue + const mode = resolveCanonicalMode(group, allValues, canonicalModeOverrides) + const { basicValue, advancedValue } = getCanonicalValues(group, allValues) + const activeValue = mode === 'advanced' ? advancedValue : basicValue + if (isNonEmptyValue(activeValue)) continue + + const memberIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] + const activeMemberId = mode === 'advanced' ? group.advancedIds[0] : group.basicId + const inactiveMemberId = memberIds.find( + (id) => id !== activeMemberId && isNonEmptyValue(allValues[id]) + ) + if (inactiveMemberId) { + inactiveModeValues.push({ + canonicalId: group.canonicalId, + activeMemberId, + inactiveMemberId, + kind: classifyCanonicalKind(blockConfig, memberIds), + }) + } + } + + return { missingRequiredFields: missingFields, inactiveModeValues } +} diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 7f5ebce96d1..38d08ad3b97 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -1,8 +1,6 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { COPILOT_STATS_API_PATH } from '@/lib/copilot/constants' import { stripWorkflowDiffMarkers, WorkflowDiffEngine } from '@/lib/workflows/diff' import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' @@ -307,7 +305,6 @@ export const useWorkflowDiffStore = create { - logger.warn('Failed to send diff-accepted stats', { - error: toError(error).message, - messageId: triggerMessageId, - }) - }) - } notifyDiffSettled(activeWorkflowId) }, rejectChanges: async (options) => { - const { baselineWorkflow, baselineWorkflowId, _triggerMessageId, diffAnalysis } = get() + const { baselineWorkflow, baselineWorkflowId, diffAnalysis } = get() const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (!baselineWorkflow || !baselineWorkflowId) { @@ -454,22 +434,6 @@ export const useWorkflowDiffStore = create { - logger.warn('Failed to send diff-rejected stats', { - error: toError(error).message, - messageId: _triggerMessageId, - }) - }) - } get().setWorkflowReconciliationError(baselineWorkflowId, null) get().setWorkflowReconciliationInProgress(baselineWorkflowId, false) notifyDiffSettled(baselineWorkflowId) diff --git a/apps/sim/tools/file/get.ts b/apps/sim/tools/file/get.ts index 3143e270343..b8d235c6797 100644 --- a/apps/sim/tools/file/get.ts +++ b/apps/sim/tools/file/get.ts @@ -60,7 +60,6 @@ const createFileReadTool = (config: { }, outputs: { - file: { type: 'file', description: 'Workspace file object' }, files: { type: 'file[]', description: 'Workspace file objects' }, }, }) @@ -116,3 +115,60 @@ export const fileReadTool = createFileReadTool({ name: 'File Read', description: 'Read workspace file objects from selected files or canonical workspace file IDs.', }) + +interface FileGetContentParams { + fileId?: string | string[] + fileInput?: unknown + workspaceId?: string + _context?: WorkflowToolExecutionContext +} + +export const fileGetContentTool: ToolConfig = { + id: 'file_get_content', + name: 'File Get Content', + description: + 'Extract the text content of one or more workspace files from selected file objects or canonical workspace file IDs.', + version: '1.0.0', + + params: { + fileId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Canonical workspace file ID, or an array of canonical workspace file IDs.', + }, + fileInput: { + type: 'file', + required: false, + visibility: 'user-only', + description: 'Selected workspace file object, or an array of file objects.', + }, + }, + + request: { + url: '/api/tools/file/manage', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + operation: 'content', + fileId: params.fileId, + fileInput: params.fileInput, + workspaceId: params.workspaceId || params._context?.workspaceId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || !data.success) { + return { success: false, output: {}, error: data.error || 'Failed to read file content' } + } + return { success: true, output: data.data } + }, + + outputs: { + contents: { + type: 'array', + description: 'Array of file text contents, one entry per file in input order', + }, + }, +} diff --git a/apps/sim/tools/file/index.ts b/apps/sim/tools/file/index.ts index cde0e491b63..c05a3a19959 100644 --- a/apps/sim/tools/file/index.ts +++ b/apps/sim/tools/file/index.ts @@ -6,7 +6,7 @@ import { } from '@/tools/file/parser' export { fileAppendTool } from '@/tools/file/append' -export { fileGetTool, fileReadTool } from '@/tools/file/get' +export { fileGetContentTool, fileGetTool, fileReadTool } from '@/tools/file/get' export { fileWriteTool } from '@/tools/file/write' export const fileParseTool = fileParserTool diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index b174634e57f..2b4a95f9ed1 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -61,9 +61,11 @@ describe('Function Execute Tool', () => { language: 'javascript', outputFormat: undefined, outputMimeType: undefined, + overwriteFileId: undefined, outputPath: undefined, outputSandboxPath: undefined, outputTable: undefined, + title: undefined, timeout: 5000, workflowId: undefined, executionId: undefined, @@ -98,9 +100,11 @@ describe('Function Execute Tool', () => { language: 'javascript', outputFormat: undefined, outputMimeType: undefined, + overwriteFileId: undefined, outputPath: undefined, outputSandboxPath: undefined, outputTable: undefined, + title: undefined, workflowId: undefined, executionId: undefined, workspaceId: undefined, @@ -126,9 +130,11 @@ describe('Function Execute Tool', () => { language: 'javascript', outputFormat: undefined, outputMimeType: undefined, + overwriteFileId: undefined, outputPath: undefined, outputSandboxPath: undefined, outputTable: undefined, + title: undefined, workflowId: undefined, executionId: undefined, workspaceId: undefined, diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 36360fb8de7..857799f65ef 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -38,6 +38,12 @@ export const functionExecuteTool: ToolConfig + directories?: Array<{ path: string; sandboxPath?: string }> + tables?: Array<{ path?: string; tableId?: string; sandboxPath?: string }> + } + outputs?: { + files?: Array<{ + path: string + mode: 'create' | 'overwrite' + sandboxPath?: string + format?: 'json' | 'csv' | 'txt' | 'md' | 'html' + mimeType?: string + }> + } envVars?: Record workflowVariables?: Record blockData?: Record @@ -37,7 +53,7 @@ export interface CodeExecutionInput { copilotToolExecution?: boolean } isCustomTool?: boolean - _sandboxFiles?: Array<{ path: string; content: string }> + _sandboxFiles?: Array<{ path: string; content: string; encoding?: 'base64' }> } export interface CodeExecutionOutput extends ToolResponse { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 0cabe09af66..ca122a6c054 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -26,10 +26,7 @@ import { hostedKeyMetrics } from '@/lib/monitoring/metrics' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-check' import { isCustomTool, isMcpTool } from '@/executor/constants' -import { - resolveSkillContent, - resolveSkillContentById, -} from '@/executor/handlers/agent/skills-resolver' +import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' import type { ExecutionContext, UserFile } from '@/executor/types' import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' @@ -931,7 +928,7 @@ export async function executeTool( const scope = resolveToolScope(params, executionContext) const toolKind: 'skill' | 'custom' | 'mcp' | undefined = - normalizedToolId === 'load_skill' || toolId.startsWith('load_skill_') + normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill' ? 'skill' : isCustomTool(normalizedToolId) ? 'custom' @@ -948,30 +945,7 @@ export async function executeTool( }) } - if (toolId.startsWith('load_skill_')) { - const skillId = toolId.slice('load_skill_'.length) - if (!skillId || !scope.workspaceId) { - return { - success: false, - output: { error: 'Missing skill id or workspace context' }, - error: 'Missing skill id or workspace context', - } - } - const loadedSkill = await resolveSkillContentById(skillId, scope.workspaceId) - if (!loadedSkill) { - return { - success: false, - output: { error: `Skill "${skillId}" not found` }, - error: `Skill "${skillId}" not found`, - } - } - return { - success: true, - output: { name: loadedSkill.name, content: loadedSkill.content }, - } - } - - if (normalizedToolId === 'load_skill') { + if (normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill') { const skillName = params.skill_name if (!skillName || !scope.workspaceId) { return { diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index 3a01ef05afb..f3a028c8b5b 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -121,6 +121,9 @@ export const knowledgeSearchTool: ToolConfig = { ...(rerankerApiKey && { rerankerApiKey }), }), ...(workflowId && { workflowId }), + // The executor rolls this search's cost up at workflow completion, so + // tell the route not to also meter it (avoids double-billing). + skipUsageBilling: true, } return requestBody diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index b944103dbfe..004ac43f70e 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -744,6 +744,7 @@ import { import { fileAppendTool, fileFetchTool, + fileGetContentTool, fileGetTool, fileParserV2Tool, fileParserV3Tool, @@ -3584,6 +3585,7 @@ export const tools: Record = { file_append: fileAppendTool, file_fetch: fileFetchTool, file_get: fileGetTool, + file_get_content: fileGetContentTool, file_read: fileReadTool, file_write: fileWriteTool, firecrawl_scrape: firecrawlScrapeTool, diff --git a/apps/sim/triggers/slack/webhook.ts b/apps/sim/triggers/slack/webhook.ts index c0d136aadbd..e87db1f7127 100644 --- a/apps/sim/triggers/slack/webhook.ts +++ b/apps/sim/triggers/slack/webhook.ts @@ -69,7 +69,8 @@ export const slackWebhookTrigger: TriggerConfig = { properties: { event_type: { type: 'string', - description: 'Type of Slack event (e.g., app_mention, message)', + description: + 'Type of Slack payload: an Events API event (e.g., app_mention, message), an interactivity type (e.g., block_actions), or "slash_command" for slash commands', }, subtype: { type: 'string', @@ -103,7 +104,8 @@ export const slackWebhookTrigger: TriggerConfig = { }, text: { type: 'string', - description: 'Message text content', + description: + 'Message text content. For slash commands, the text after the command. For interactivity, the source message text (falls back to the triggering action value)', }, timestamp: { type: 'string', @@ -131,6 +133,50 @@ export const slackWebhookTrigger: TriggerConfig = { description: 'User ID of the original message author. Present for reaction_added/reaction_removed events', }, + command: { + type: 'string', + description: + 'Slash command name including the leading slash (e.g., /deploy). Present for slash commands', + }, + action_id: { + type: 'string', + description: + 'action_id of the first interactive element triggered. Present for block_actions (button/select clicks)', + }, + action_value: { + type: 'string', + description: + 'Value carried by the first interactive element (button value, selected option, date, etc.). Present for block_actions', + }, + actions: { + type: 'json', + description: + 'Full array of interactive actions from the payload, preserving every element and its value. Present for block_actions', + }, + response_url: { + type: 'string', + description: + 'Temporary URL to post a response back to the originating message or command. Present for interactivity and slash commands', + }, + trigger_id: { + type: 'string', + description: + 'Short-lived trigger ID used to open a modal in response. Present for interactivity and slash commands', + }, + callback_id: { + type: 'string', + description: + 'Callback ID of the shortcut or view. Present for shortcuts and modal submissions', + }, + api_app_id: { + type: 'string', + description: 'Slack app ID. Present for interactivity and slash commands', + }, + message_ts: { + type: 'string', + description: + 'Timestamp of the message the interaction originated from. Present for block_actions', + }, hasFiles: { type: 'boolean', description: 'Whether the message has file attachments', diff --git a/bun.lock b/bun.lock index 42d93b34c8d..6ccdd5bd67f 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -498,23 +497,23 @@ "@a2a-js/sdk": ["@a2a-js/sdk@0.3.7", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["express"] }, "sha512-1WBghkOjgiKt4rPNje8jlB9VateVQXqyjlc887bY/H8yM82Hlf0+5JW8zB98BPExKAplI5XqtXVH980J6vqi+w=="], - "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.99", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.79", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-d/WsYOlqjQeEwTewawjrlhoWfHt3q1vRT5/XdFJ6U+KYd/3HnAlrA3rg0+T7xMk98XmctaILJb45Ct/8zrGxSA=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.101", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.81", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MU4KXBasSVTcP9U0mtfcnW9ME8fo9Hsf9ZOaz0SK0qHAYwxck9Dmh4dyBGZqcopYHkhYQPskTzYJq0ARm0hHsg=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.79", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-K0U09FPDO1kmLPjRLXFcNSvmnKHJBMARCb8r3Ulw7wU6/+Zh9djWcFDiPPNsklg6yAezcdLTcYPszgWJJ6iOTA=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.81", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0iqx9hZc9xqdhxOdZkYJAKuCs9o+5a86gStYl0M7IBZzmx6jTDrynXiOigDjH3SQrmLclLCspTjW5E6YFrlyHQ=="], - "@ai-sdk/azure": ["@ai-sdk/azure@2.0.108", "", { "dependencies": { "@ai-sdk/openai": "2.0.106", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/F+lx3glCDiqJfqkZP9IOCubYlWABX2Jg9Yzm/JIxZR5qHfo9rsLwS4zVtghbELVbEjxakaFlDT/c6uTBj0uug=="], + "@ai-sdk/azure": ["@ai-sdk/azure@2.0.109", "", { "dependencies": { "@ai-sdk/openai": "2.0.106", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0Cd/YzLkF12v8NEyowNdZ3WLGXzdaQeBd4I6EreuQuCnSgO+SwhsS7RbnVB4/FVISgoctSf8+/ojkO+gAC47Sg=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.44", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2w7+jq0bWEF6McgWPb2gjaEx1TpqdUq4eyX/gPLTp7HzfDZKEVmmVXRvnKvjzBP/VH7xW4OT5jhTpTPTfYNYYQ=="], - "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.40", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-T5zW4s4/aKZLTjUulB9hUOFB6kxNCiVGPUP3xuZyVAlOWBye51KELmI3pCHSfMCrJcuA5Xhlg7ykO2JRW9Qq3Q=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.41", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7L2Do5wk0xRe0Ox8CVRF9B5b5SPemZP16ZbyBUAlNtO16EMFLSX8LXGeQREZ2SOQ4pC95BwSXThcTkt1JbFNlA=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.88", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-H62l0gxr4K0rdR2WHbvck2wOKMsocAjdZg41Exsj9Qf5/TyAuHzcNt9jKNv5t2vRFXFZaCpbC5uCCxgUC/GiaA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.98", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JNMc5Fbz8AwiLIR3Ar/lV2egbLFE+A5nfwbRKrdfgusoVN2VjgMX2U2KCLux5iWD/Q9+rg9+njHPZNw4HmzBJQ=="], - "@ai-sdk/google": ["@ai-sdk/google@2.0.72", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-BjDY6l+rV4CmHKjZe4H0uRXW3M2o+g7PaYM8oFpW+9PP1qKNEybnJ6//Si7BSf6DT+86dKARrtEl09lxSSaMaA=="], + "@ai-sdk/google": ["@ai-sdk/google@2.0.74", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Lhw1742RXc+4pRIvqVXa0jdl5+qdpmw8lj0lm6OchUg9rVGHzymlaxe7CDiYX5U2af4jbjKeTY22LDi3bIycgQ=="], - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.137", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.79", "@ai-sdk/google": "2.0.72", "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vDtCmwMy4CzVsv3PESmkE96qDSqnsArDDEc22eggujZI/WxmIeKa+8vyUYjJUx9HZLOCPo7HhYDXjH0R2mcM+Q=="], + "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.141", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.81", "@ai-sdk/google": "2.0.74", "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+PjbZu63x+7RABQpAnNcJ0+EEZjKt3nQESQszA4Gyv9rLajob+FvxRJWeiLcKDsGIQdEFBknDrI5KLLSm7Doeg=="], "@ai-sdk/groq": ["@ai-sdk/groq@2.0.40", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1EL8D1tyjOKjCFUt8XspDoA6zxDcalMsLR2O56ji8QklWsAPaf4TuMJAvf5x5KDrkuJaSAjk94KvPH5hOX+VNQ=="], @@ -532,7 +531,7 @@ "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V9reHPfWeaIt6fu03lVbjZDuxfdplS5jdmzVchVBeUug9VqIK+9KQELcPvdWKdxf+ov+sCoShN/O6dYfPPD5Ng=="], - "@ai-sdk/xai": ["@ai-sdk/xai@2.0.72", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RXpfCTliybesXOmc+jGB7NhobJzzZc2rr7gSy7kGj0eHDYXkCmoo4/llpE8yKIUJMwU098DP1cBGdltPezNRiw=="], + "@ai-sdk/xai": ["@ai-sdk/xai@2.0.73", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.39", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U+/rdtqgDcloNSX7TIdRjYQooVydYdauQvLSP74oQcnE5N0/DD81yi+RvQXYYq47dDIn2H4exgr7XkBm4x1yDw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -564,6 +563,8 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.4", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Pt1JEVLu02jTWzpcUzUHciiWScyZg3JpHCTB1h9DtDPWY3dBufBnFJAevVHali/bAkmMdMhYUD8tH/VvPuBkUg=="], + "@aws-sdk/client-appconfig": ["@aws-sdk/client-appconfig@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.16", "tslib": "^2.6.2" } }, "sha512-WcS820syhSamz1PcZUvdUXf7FUm3cpze+hfMvDKzPojrh/zFO5eVopzhBGEkDFXiHFD0qel1ZgE5s5AkmH9fyg=="], "@aws-sdk/client-appconfigdata": ["@aws-sdk/client-appconfigdata@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-gh/cmEFDN97XJBWRLT0usnWnTDEm+cqgOIffTJGP68xCgj28EkSHnN5vtdy2QaZjj7/n/sKOlqIKONZUeonRpA=="], @@ -600,103 +601,99 @@ "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/signature-v4-multi-region": "^3.996.18", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FCLc5VWb+yz1xb/Jv0sXFGqIIs+bHZQWBKbPQKCuypF3wU/7UFygXuSXo9uJfwISKNGVHJwp+0136f8mqmzRcA=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.22", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw=="], - - "@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.7", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.20", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/credential-provider-env": "^3.972.34", "@aws-sdk/credential-provider-http": "^3.972.36", "@aws-sdk/credential-provider-login": "^3.972.38", "@aws-sdk/credential-provider-process": "^3.972.34", "@aws-sdk/credential-provider-sso": "^3.972.38", "@aws-sdk/credential-provider-web-identity": "^3.972.38", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", "@aws-sdk/credential-provider-login": "^3.972.51", "@aws-sdk/credential-provider-process": "^3.972.46", "@aws-sdk/credential-provider-sso": "^3.972.51", "@aws-sdk/credential-provider-web-identity": "^3.972.51", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-szg1nnebqC+Svv6Vfsdf6P/QK8x5g/ghG2CKa/1WkHifRnq0BBmDELj2Qnqk9nPsUvEu/OEcYic97CPLpKqF9g=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-csHFsH+/VjnI40oqm1l1OqMY4B4kza36DbfcbHcgcbobgjebasqUbTU34xvwUkvtoNGGizbfyMSlMzJWUPv3dQ=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.39", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.34", "@aws-sdk/credential-provider-http": "^3.972.36", "@aws-sdk/credential-provider-ini": "^3.972.38", "@aws-sdk/credential-provider-process": "^3.972.34", "@aws-sdk/credential-provider-sso": "^3.972.38", "@aws-sdk/credential-provider-web-identity": "^3.972.38", "@aws-sdk/types": "^3.973.8", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.54", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", "@aws-sdk/credential-provider-ini": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.46", "@aws-sdk/credential-provider-sso": "^3.972.51", "@aws-sdk/credential-provider-web-identity": "^3.972.51", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-vinTSQtziNHxi2nqXF+76jr2sO44q88Ind1qFFVaotNgBaC1rcWDjBug8yoE8n0ov33s21xks9WY5XDHH9SENw=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.34", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/token-providers": "3.1041.0", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/token-providers": "3.1065.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-60qhpQcSDIKIr0AuBlmJezKX0b5nbJPCINiR49N9yJXrEI5tTRwsXVBr0IdSvvsNJyqgiINyoBd++Ed0yvggbw=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.51", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0X5eWsUIp8ItRJeJBBrhQAPzc9AQelDetRTVTsycCAISCCzM17R4hs/vFAPeQ0o0B35sciLiqe/Pwmml909cZA=="], - "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.8", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@smithy/core": "^3.23.17", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-dYQ/cQqHZd23hcl8oEGwPphTqyGnmvf2HrVmz4J90Q5Bv89oJjlwcBcifiiTvApqsVpx7Pr0IebMpkYwWJvZlQ=="], + "@aws-sdk/dynamodb-codec": ["@aws-sdk/dynamodb-codec@3.973.20", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SFfxiqVgWeIe+RsJJNAMD//2IfehT4bLpGyNJRB0MgHmOIJtdcfMnR1k7KYyaHokSoQVdncVa9O9DIGa4eqcwg=="], - "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.5", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA=="], + "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.972.7", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-LkwS3ZOUNL5kHzmz3dDx8lE3HOhZmf2VGjbJ/tMUZJYWWl3J0RJTZM7RFz1MLt06WDVvlShcAjY/RzhYlqLL7g=="], - "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA=="], "@aws-sdk/lib-dynamodb": ["@aws-sdk/lib-dynamodb@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/util-dynamodb": "^3.996.2", "@smithy/core": "^3.23.15", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1032.0" } }, "sha512-rYGhqP1H0Fy4r1yvWTmEAx0qqy1Zd9OzI8pPkXo6KSEDjZ4EwU+6QN1V+KLX3XTU6FQouF5LTvqLtl/CW4gxyQ=="], - "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.23", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-X0kemMevWxLa/ai/iBV6Lh6V+DqdKbuY5zX4nJ0HlEL3jgPdRSnxTNrGO33Er+2N+fLLriDyriw1O3DFFRR+zw=="], - "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.11", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.5", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-vXARCZVFQHdsd6qPPZyC/hh+5x2XsCYKqUQDCqnUlpGpChMpDojOOacQWdLJ+FFXKN8X3cmLOGrtgx/zysCKqQ=="], + "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.972.18", "", { "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.7", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1vKJt/6MBB/MBMRM3qzCMdW70syJY8u2DH+dq7yCnPn7wVJmyeAzAa/sK1lIbbYh8BVLbM5FspsT4zbe885gOw=="], - "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA=="], - "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.19", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-jg1aPMLMCBcaF34ZyqvP9Fbv2s9xlbkEMiQZWsT5F3k9bulA/wrCejLMgAQxHSCruvuK5IEmi4MErST/Q2ZAzQ=="], - "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.16", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.8", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA=="], + "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.29", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.4", "tslib": "^2.6.2" } }, "sha512-ALTHDXk6YWVDfAWIHzXyaTZ82QFoMWhHENXlO61lv4ZqSMl3cvh2s0ZVOS89qbtw9LRJhIDoZaaC9FYo/Z4KLQ=="], - "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg=="], + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-+SRZL54/T9Ryxa/NmHM7WZAliU6wnfzeosT/+4IVuTgq0zSCpPx2j3yaEP4JFZlvWvoOCbKgr+4tBqSAG/dl5Q=="], - "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ=="], + "@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-c7qT6vMXwzdDbXjexG8jknN7itfa4N1thiZMEmZzTn/t/ev/j0J2HF/60ympIO/iYq69qHOprU6WZXBcppDDJQ=="], - "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ=="], + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-8oOvo7XNDeOlwa5ys2Q0UTLCKmtvRiqf8rOU63lKniPmP3dnI5lnoy9dteZ79lxb9hmXCrO28aZMqds6C5AEoA=="], - "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ=="], + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-q4H/CoOYrTbyAW0d9RrHf9kTYKVXpAwoK0VEy3UT2Asad+6aa6vzQgz35dh20tRA7zlEo/Nsyjy9PVlHgdq0Vg=="], - "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.37", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA=="], + "@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-yOXn9mmJQQODpbmwQB224IX1PLLneyqInX2Fv2nEmSHWpJj54nrzdrUT1TGQk/s8mr+XPssDQy1at/8GS4EFVQ=="], - "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.22", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA=="], + "@aws-sdk/middleware-sdk-sqs": ["@aws-sdk/middleware-sdk-sqs@3.972.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-PVAj7VgWK/ZxCXnkgC4B7cdJyUN99Nsr7IEduHt4A1GieuB+ZnU5bSifHwapbr17wrFkmdxfSh+aA0Lj+Ads6w=="], - "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw=="], + "@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.16", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.50", "tslib": "^2.6.2" } }, "sha512-o0oPX8JkZd2Fep0TFAE0VY4dzi86Q5alxSwd2O3wR2M9zg8/zJ4dEpkw9kGCr3mRghP3E5nWLgsfzJ9RKFwVnQ=="], - "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.38", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" } }, "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A=="], + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.50", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-5JIDcDFWy3DUW95sOlXLbeb7RkGFUcNh1QidKsznqtlm5YGsXP0EGWaqzxBTvVmOhqKs2RmNmI6w9V/5dS3CLQ=="], - "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/eventstream-codec": "^4.2.14", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.28", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.6", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.8", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/region-config-resolver": "^3.972.13", "@aws-sdk/signature-v4-multi-region": "^3.996.25", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.8", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.24", "@smithy/config-resolver": "^4.4.17", "@smithy/core": "^3.23.17", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-retry": "^4.5.7", "@smithy/middleware-serde": "^4.2.20", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.6.1", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.49", "@smithy/util-defaults-mode-node": "^4.2.54", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.19", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", "@aws-sdk/signature-v4-multi-region": "^3.996.33", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-P2Otgf15GBJMKzG6j5Ddf7w+Kz6z2jvesMy874TD3jlMfDWNK7clJeUd7hgigdeVOotjoUP4emcTWVdS9sfZDw=="], - "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.13", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A=="], + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.24", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-WY6uVMsq0EvxY4BcYhZmG2Ivd1EzNvZAqsXFlL3pTPMG0P4J83TYVQIs8P0nd5lc+Bp3llrYwggruvXzrfUtsQ=="], "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1032.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "^3.996.18", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-LFaI5JQhiOmJDjKK02ir9oERU9AmxdyEvzv332oPDzAzWeNH06sZ1WsF3xRBBE5tbEH2jIc79N8EqDCY0s5kKQ=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.25", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.37", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.33", "", { "dependencies": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug=="], "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-n+PU8Z+gll7p3wDrH+Wo6fkt8sPrVnq30YYM6Ryga95oJlEneNMEbDHj0iqjMX3V7gaGdJo/hJWyPo4lscP+mA=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-sdk/types": ["@aws-sdk/types@3.973.12", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA=="], - "@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="], + "@aws-sdk/util-dynamodb": ["@aws-sdk/util-dynamodb@3.996.4", "", { "dependencies": { "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1064.0" } }, "sha512-Gel6Qiof4NWdtGT548FxoAkmmKmvsEVQYzbtC4RJnwgh9z33gmiYSJOGnKZHvZJWC2SgjS6AKe6DfuGCqU4vdg=="], - "@aws-sdk/util-dynamodb": ["@aws-sdk/util-dynamodb@3.996.2", "", { "dependencies": { "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.1003.0" } }, "sha512-ddpwaZmjBzcApYN7lgtAXjk+u+GO8fiPsxzuc59UqP+zqdxI1gsenPvkyiHiF9LnYnyRGijz6oN2JylnN561qQ=="], + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.19", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-W79LutZYmCV1WGe0UVGALMna3xLGP3wv2zLzrBEgc73CtzxKBxb5IpMedbS6Ej80LCknSgqFjsTtECG0IInckw=="], - "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.8", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" } }, "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g=="], + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.22", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-ygzBtG3xDxvMWTg3EjlZ5FSYxUiRCsCZvKPk+sOhXeMcDTdzPqIGQipiUiIYJm/Om8h8qXyhchMb0baW1PhE+w=="], - "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.7", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA=="], - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.21", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-Y9MPH4VZaIebwxiVK6dMzSt5L04oyNiK3NNwFe4qP5B2Hfo+pmEVpSJSa+gARPtcJeRyehUMPu5/I9DLdW0cBg=="], - "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="], + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.36", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "tslib": "^2.6.2" } }, "sha512-wsmSmHTrK9lV+5SKBekp1J7W6PufdZE08X0xv5Lz1OdgYRmyuYDy/++SslKdXhcVQjxLtzMTZaLqqLZmTx6OaQ=="], - "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.24", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.38", "@aws-sdk/types": "^3.973.8", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw=="], - - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.22", "", { "dependencies": { "@nodable/entities": "2.1.0", "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" } }, "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.29", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ=="], "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@azure-rest/core-client": ["@azure-rest/core-client@2.6.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ=="], + "@azure-rest/core-client": ["@azure-rest/core-client@2.6.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-KzI10qnkWTsVS2yRBUdc8NLUJ1rOm+292mYs7Pe9wqAj/jv4bRskVm1l8XkKeVTN0OCQtrU5RG0Yhjbz1Wmg7g=="], "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], - "@azure/communication-common": ["@azure/communication-common@2.4.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "events": "^3.3.0", "jwt-decode": "^4.0.0", "tslib": "^2.8.1" } }, "sha512-wwn4AoOgTgoA9OZkO34SKBpQg7/kfcABnzbaYEbc+9bCkBtwwjgMEk6xM+XLEE/uuODZ8q8jidUoNcZHQyP5AQ=="], + "@azure/communication-common": ["@azure/communication-common@2.4.2", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "events": "^3.3.0", "jwt-decode": "^4.0.0", "tslib": "^2.8.1" } }, "sha512-5ZdPoBPmXG5+tlvCIZ9lnLxunZd8a9NCQunYJHLcaNJGTsg3ZXaHczel2ByP5PnHyLJIJNor9oHJsv6gY65oYg=="], "@azure/communication-email": ["@azure/communication-email@1.0.0", "", { "dependencies": { "@azure/communication-common": "^2.2.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.3.2", "@azure/core-lro": "^2.5.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/logger": "^1.0.0", "tslib": "^1.9.3", "uuid": "^8.3.2" } }, "sha512-aY/qE3u4gadd6I895WOJPXrbKaPqeFDxGOK5xgAAqHkqNadI+hCp/D59q5Kfcj5Qcxal6mLm1GwZ1Cka0x4KZw=="], "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], - "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], + "@azure/core-client": ["@azure/core-client@1.10.2", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ=="], "@azure/core-http-compat": ["@azure/core-http-compat@2.4.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2" }, "peerDependencies": { "@azure/core-client": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0" } }, "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA=="], @@ -704,7 +701,7 @@ "@azure/core-paging": ["@azure/core-paging@1.6.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA=="], - "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.23.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="], + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.24.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg=="], "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], @@ -716,45 +713,45 @@ "@azure/storage-blob": ["@azure/storage-blob@12.27.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.4.0", "@azure/core-client": "^1.6.2", "@azure/core-http-compat": "^2.0.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.1.1", "@azure/core-rest-pipeline": "^1.10.1", "@azure/core-tracing": "^1.1.2", "@azure/core-util": "^1.6.1", "@azure/core-xml": "^1.4.3", "@azure/logger": "^1.0.0", "events": "^3.0.0", "tslib": "^2.2.0" } }, "sha512-IQjj9RIzAKatmNca3D6bT0qJ+Pkox1WZGOg2esJF2YLHb45pQKOwGPIAV+w3rfgkj7zV3RMxpn/c6iftzSOZJQ=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], @@ -800,9 +797,9 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], - "@browserbasehq/sdk": ["@browserbasehq/sdk@2.10.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-pOL4yW8P8AI2+N5y6zEP6XXKqIXtYyKunr1JXppqQDOyKLxxvZEDqQCHJXWUzqgx3R1tGWpn7m9AjXN7MeYInA=="], + "@browserbasehq/sdk": ["@browserbasehq/sdk@2.14.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-DqVfjlgt74vPWfWiCp4VPtiMNJxg2yBZwio2uOfnpV1aJM0OWZR4AJrt/4df+xgQQ/guRpds5do41ycrDaYt3w=="], - "@browserbasehq/stagehand": ["@browserbasehq/stagehand@3.3.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.10.0", "@google/genai": "^1.22.0", "@langchain/openai": "^0.4.4", "@modelcontextprotocol/sdk": "^1.17.2", "ai": "^5.0.133", "devtools-protocol": "^0.0.1464554", "fetch-cookie": "^3.1.0", "openai": "^4.87.1", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "uuid": "^11.1.0", "ws": "^8.18.0", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.73", "@ai-sdk/anthropic": "^2.0.34", "@ai-sdk/azure": "^2.0.54", "@ai-sdk/cerebras": "^1.0.25", "@ai-sdk/deepseek": "^1.0.23", "@ai-sdk/google": "^2.0.53", "@ai-sdk/google-vertex": "^3.0.70", "@ai-sdk/groq": "^2.0.24", "@ai-sdk/mistral": "^2.0.19", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/perplexity": "^2.0.13", "@ai-sdk/togetherai": "^1.0.23", "@ai-sdk/xai": "^2.0.26", "@langchain/core": "^0.3.80", "bufferutil": "^4.0.9", "chrome-launcher": "^1.2.0", "ollama-ai-provider-v2": "^1.5.0", "patchright-core": "^1.55.2", "playwright": "^1.52.0", "playwright-core": "^1.54.1", "puppeteer-core": "^22.8.0" }, "peerDependencies": { "deepmerge": "^4.3.1", "zod": "^3.25.76 || ^4.2.0" } }, "sha512-eIYsId85c0pPXBAuqHdI3arBB1ecJn9E3eTzVaa968U+beoxOtqOJmx65K4xcZQSfd9BonQeIBsE7ujrHaq9VQ=="], + "@browserbasehq/stagehand": ["@browserbasehq/stagehand@3.5.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@anthropic-ai/sdk": "0.39.0", "@browserbasehq/sdk": "^2.10.0", "@google/genai": "^1.22.0", "@modelcontextprotocol/sdk": "^1.29.0", "ai": "^5.0.133", "devtools-protocol": "^0.0.1464554", "fetch-cookie": "^3.1.0", "openai": "^4.104.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "uuid": "^11.1.1", "ws": "^8.21.0", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.73", "@ai-sdk/anthropic": "^2.0.34", "@ai-sdk/azure": "^2.0.54", "@ai-sdk/cerebras": "^1.0.25", "@ai-sdk/deepseek": "^1.0.23", "@ai-sdk/google": "^2.0.53", "@ai-sdk/google-vertex": "^3.0.70", "@ai-sdk/groq": "^2.0.24", "@ai-sdk/mistral": "^2.0.19", "@ai-sdk/openai": "^2.0.53", "@ai-sdk/perplexity": "^2.0.13", "@ai-sdk/togetherai": "^1.0.23", "@ai-sdk/xai": "^2.0.26", "bufferutil": "^4.0.9", "chrome-launcher": "^1.2.0", "ollama-ai-provider-v2": "^1.5.0" }, "peerDependencies": { "patchright-core": "^1.55.2", "playwright-core": "^1.55.1", "puppeteer-core": "^24.43.0", "zod": "^3.25.76 || ^4.2.0" }, "optionalPeers": ["patchright-core", "playwright-core", "puppeteer-core"] }, "sha512-nmKmi37keeZ8naUTJ4l6nfR8sTCDc5gUIn+fKSzMH8Xy4IW/sFNaA14Gl3OQbUWpthr977yGCBj556WSWONg0w=="], "@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="], @@ -810,8 +807,6 @@ "@cerebras/cerebras_cloud_sdk": ["@cerebras/cerebras_cloud_sdk@1.64.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eQ0udGHS9xrWANi56yCS/FMcbwtysugD73YWipp89+zarbm2pd5hxqrmGlFqafS4Pwyo7cU7Qv31am5jdjqXFg=="], - "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], - "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@connectrpc/connect": ["@connectrpc/connect@2.0.0-rc.3", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }, "sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ=="], @@ -834,11 +829,15 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@e2b/code-interpreter": ["@e2b/code-interpreter@2.4.2", "", { "dependencies": { "e2b": "^2.19.4" } }, "sha512-udLYysT+Jrue5citQc6Wr6N7Et9Eiw8FTeQpf6NQdLEg4RM6aZJoi7QFC0oqr+rv6g+I4W1KGdrxW1eBtKbnRw=="], + "@e2b/code-interpreter": ["@e2b/code-interpreter@2.6.0", "", { "dependencies": { "e2b": "^2.28.0" } }, "sha512-Xp3pajVf2LQ2rcXZynE/jYfZw4yyKTZM/LkVPB2vSqVft87GxqEUFDfWxssb811B4571uAMfJxKSHHIa8tMprA=="], "@electric-sql/client": ["@electric-sql/client@1.0.14", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -916,7 +915,7 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], @@ -980,7 +979,7 @@ "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], @@ -1002,10 +1001,6 @@ "@jsonhero/path": ["@jsonhero/path@1.0.21", "", {}, "sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q=="], - "@langchain/core": ["@langchain/core@0.3.80", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": "^0.3.67", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", "zod": "^3.25.32", "zod-to-json-schema": "^3.22.3" } }, "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA=="], - - "@langchain/openai": ["@langchain/openai@0.4.9", "", { "dependencies": { "js-tiktoken": "^1.0.12", "openai": "^4.87.3", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.3" }, "peerDependencies": { "@langchain/core": ">=0.3.39 <0.4.0" } }, "sha512-NAsaionRHNdqaMjVLPkFCyjUDze+OqRHghA1Cn4fPoAafz+FXcl9c7LlEl9Xo0FH6/8yiCl7Rw2t780C/SBVxQ=="], - "@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="], "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA=="], @@ -1050,6 +1045,8 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], @@ -1072,7 +1069,7 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1162,16 +1159,20 @@ "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.7.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.7.1", "@opentelemetry/core": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@posthog/core": ["@posthog/core@1.24.4", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-S+TolwBHSSJz7WWtgaELQWQqXviSm3uf1e+qorWUts0bZcgPwWzhnmhCUZAhvn0NVpTQHDJ3epv+hHbPLl5dHg=="], "@posthog/types": ["@posthog/types@1.364.4", "", {}, "sha512-U7NpIy9XWrzz1q/66xyDu8Wm12a7avNRKRn5ISPT5kuCJQRaeAaHuf+dpgrFnuqjCCgxg+oIY/ReJdlZ+8/z4Q=="], @@ -1186,13 +1187,13 @@ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="], + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], @@ -1200,103 +1201,101 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], - "@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="], - - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.13", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA=="], - "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.16", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ=="], - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], - "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A=="], - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA=="], - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ=="], - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw=="], - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg=="], "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.9", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ=="], - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg=="], - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.0", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ=="], - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw=="], - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.5", "", { "dependencies": { "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA=="], - "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.9", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw=="], - "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg=="], - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.11", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.0", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.12", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.9", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.0", "@radix-ui/react-portal": "1.1.11", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-slot": "1.2.5", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.5", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA=="], - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.0", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.9", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], - "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.0", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA=="], - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-roving-focus": "1.1.12", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA=="], - "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-primitive": "2.1.5", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="], - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], - "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.2", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw=="], "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="], - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg=="], - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], "@react-email/body": ["@react-email/body@0.1.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ=="], @@ -1364,57 +1363,39 @@ "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.0", "", {}, "sha512-C7c51Nn4yTxXFKvgh2txJFNweaVcfUPQxwEUFw4aWsCmfiBDJsTSwviIF8EcwjQ6k8bPyMWCl1vw4BdxE569Cg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.61.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA=="], "@s2-dev/streamstore": ["@s2-dev/streamstore@0.22.5", "", { "dependencies": { "@protobuf-ts/runtime": "^2.11.1", "debug": "^4.4.3" } }, "sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ=="], @@ -1464,107 +1445,87 @@ "@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"], - "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], - - "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="], - - "@smithy/config-resolver": ["@smithy/config-resolver@4.4.17", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.4.2", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ=="], - - "@smithy/core": ["@smithy/core@3.23.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ=="], + "@smithy/config-resolver": ["@smithy/config-resolver@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-AXbvUX9aNY2qCLOMCikpl1Df5w2CNFEqbEb6XafG81FJbAbB8avIT7BOx1KDqiO86J/38qKQ3YuakfAfY3iBkQ=="], - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.14", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg=="], + "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.8", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg=="], - "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-Ussyv240JxwQP8AmkYdm26wGP/1I8QmIv0ZosgDJDlSzD73FEdj1BOpXMc06VrxX5KxTKhadFNomT2SWutUnpg=="], - "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA=="], + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-BQao/dBhLCJqo953N1hadkcF3M/9G+i6qIgnMupfdpBQomwyhfV7Xfc5jjpCkm8HxfzaWAGrM/2nNnzronFqVQ=="], - "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.14", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw=="], + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-OUoNRXJGZMM4ivoU7QIzOvCLbavD1YnadNEairrtYhTi+gmGhyn3c2wToL9CxEs4Cw2Ab/KeQM39T1K+/e9YdQ=="], - "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.14", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg=="], + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-M6FeKRMi3oecpTy4EL5n1hLPWydw+xInFYQIzjbGYGBnFtW7IlJjnXrKr/Ev1GpMtmw44QCmrl8+ACEFPmRsIg=="], - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.17", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], - "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.15", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA=="], + "@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-/8D8rOFs2VEwvHwsx68sb6nE7XfVr2wbJTbC1YuKBHPhHeMnOt7IHxr7CoT5wBWujdV4fjVoLPn1BXXP4Ijlow=="], - "@smithy/hash-node": ["@smithy/hash-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g=="], + "@smithy/hash-node": ["@smithy/hash-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-lIZyQ7gDxURrnfkjalM0lKmDnfZYuPzNBYlkza3czPTQNVYsg4e0o90Zx/RpxhamKKOGsQGCsopp0ULsJqltNQ=="], - "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ=="], + "@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-Ziap41FoxpKqmlO9IE68NeFwPKhUJD4PVNcCQ2tl6IUCPSj0KykIuAPnJNWIQbWXvApwCauhRNlAFdt9KRvDpw=="], - "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw=="], + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-jUH1Eth7Sgn4KPBX5OKYDRpNjzul7AzsIhxKXT1rHXPTSfY00/7Kb9RtNil5SDAlPPsxaUiesR/rql2wjackmw=="], - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@smithy/md5-js": ["@smithy/md5-js@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA=="], + "@smithy/md5-js": ["@smithy/md5-js@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-LYcuBrO9oiajdRFHyFx3FJAWNKrP89s0grI6mcfpwTAeX2ZJ/9Xyi7Imghh9LT6CIcAy6/k6/MpoUiPNjXr1/w=="], - "@smithy/middleware-compression": ["@smithy/middleware-compression@4.3.46", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-utf8": "^4.2.2", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-9f4AZ5dKqKRmO49MPhOoxFoQBLfBgxE9YKG8bQ6lsW9xk+Bn8rkfGlpW8OYlvhuarN+8mja9PjhEudFiR8wGFQ=="], + "@smithy/middleware-compression": ["@smithy/middleware-compression@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-wZQpnjrGSO2IFxhwWNaeRzHh2swSwRGWaCVgQN9zqYdtP98tcNYyqI7YvPeVTwf9CvQTas7xlmR3NY5L1i32mg=="], - "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.14", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw=="], + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-nfpYCrzSFAgfIXmIHFTjOGNeTV3DVF5E5rfi3ZuNfsOjKSpePBOJF3rjyXlWYND0anvxVoqioIwClWCNdKt4Og=="], - "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.32", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-serde": "^4.2.20", "@smithy/node-config-provider": "^4.3.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q=="], + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-zdG5bJZOiM2PRgL2lwcgui6uwZ+s5y6Qsk/rk05Q69sZJT6oi1x+v8Kn++V/q9VY94EgOtEe5kivpu+eGau0wQ=="], - "@smithy/middleware-retry": ["@smithy/middleware-retry@4.5.7", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/service-error-classification": "^4.3.1", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.6", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg=="], + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.6.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-MWppaYUlc+W4cU2JZnYuMFeOxCWbKO4A57BWti6aCb7hRBK3+CL6llADGpX084hjImsqr3EvCGewArOj7G81eA=="], - "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.20", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ=="], + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-I3fPVYKKEog3a3qdqt1nttP1NBuQOAlNoQxEp6j5pMogSx0HHfid63difhcDgslV6p1XsTXG6D6ieTe13ycJtQ=="], - "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA=="], + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-QhNiWfg47Kl4SJHmuQvnlzCtlD1eX1J7d/vuuttIE17Ra2YUKp9Srv5lCwa3OvoYaSNWMKYn0PjGIsfCLMJsEA=="], - "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.14", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg=="], + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-M+gG6eQ0y073mSmNB+erRXJvwpsqsN72ol2w6vcd8FEKeG7pqYK0JvzfVqONkPj2ElBB2pg+cU13I850b//Wag=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.6.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.14", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A=="], - "@smithy/property-provider": ["@smithy/property-provider@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ=="], + "@smithy/property-provider": ["@smithy/property-provider@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-0rhHv1Ww27kajF6qewme2aRtJmKFtSwE6EZ2dj5KxdX/R3ANsUugqTnH0tvpZwGiQ3MOMhetuCGFAeKVv3/Onw=="], - "@smithy/protocol-http": ["@smithy/protocol-http@5.3.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ=="], + "@smithy/protocol-http": ["@smithy/protocol-http@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-H6S7NyaaL+7qO8kIL7VQ7KyrGnKXdllGzJqvtp3hvDen25UOydKV51qGDVK0UciW125jV3CoLJQy/ihc0OEC6A=="], - "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A=="], + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-In8gYD2R66EKlGAq9QrNKVrMOGaGBD7LUNp2kUjeQ4V9zNktFIXBPmrCySr4YYo+jVeVL6CnWj26sOamcF0qIg=="], - "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], - "@smithy/service-error-classification": ["@smithy/service-error-classification@4.3.1", "", { "dependencies": { "@smithy/types": "^4.14.1" } }, "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw=="], + "@smithy/smithy-client": ["@smithy/smithy-client@4.13.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-tAf35/JW/DvMlACcazcoIOKOV0JBqyOvxjPTEME9W+m9wLcE0G1rwADc7Ntu38rY5C9OH8jZjpo4tbtjmIjEBQ=="], - "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.9", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ=="], + "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.3.14", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA=="], + "@smithy/url-parser": ["@smithy/url-parser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-9MRJzwUrlswwHogOR7raDcykuzojZn74qGdQdbEQLVaixlvJuMiIT0g/CejKcmAIgrUVs8brBrnGtmYmBc0iuA=="], - "@smithy/smithy-client": ["@smithy/smithy-client@4.12.13", "", { "dependencies": { "@smithy/core": "^3.23.17", "@smithy/middleware-endpoint": "^4.4.32", "@smithy/middleware-stack": "^4.2.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" } }, "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA=="], + "@smithy/util-base64": ["@smithy/util-base64@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA=="], - "@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-+3vGcNHuvzuFLVWL9/wJgucOuQWufhuGhb3oxVDj9SWFGtwkOmtC2nFUwVC2IJoPe45uhs6TAb8bgE4IXDSPzA=="], - "@smithy/url-parser": ["@smithy/url-parser@4.2.14", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ=="], + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-T15zTQJ/xKYdS0/3CFckhz1QBbhxmhk/xjL6FKvHKgkJPN4E985If2FI9CcV2kh2v0sfiWMfXVEOKFbqgw4m4w=="], - "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-dRCZKu05AL7KQWrVuRJPotfjCRnvGkCjV56XNP067CRfyTtvgi/Ygu44qrBKb814Hsa52bWwDJ+Vt3pd04BjPA=="], - "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-tTR8tayMoa0WeRhtMH7j3WpHUtggBXjh7rBdf7j6POYI69R85gpWBW6B32kaJRnlQU8+0gOGAzJj50S7SU1Egw=="], - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.5.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-kaB41eVUYC7ajVWUsZRqagxwRaa3VupjQ/Z2Z2v/Vffh/gJ/fFOS25s6mTyR2Lw1FrnBbRWo1iShR9BhekpPeQ=="], - "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], + "@smithy/util-middleware": ["@smithy/util-middleware@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-TrAgOcL63TRi7G92arTzq0n+VDrmZifwP1I1T9y2xU3lJpybsHdm33S2d3xaFfG0c8zJNIF9yYRqLSe6rbhH/A=="], - "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.49", "", { "dependencies": { "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw=="], + "@smithy/util-retry": ["@smithy/util-retry@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-E/kFnvWQL6rIPr0Ucjk8oDgJSkKx2bv0nJkJ/cB3ywys7xCqeL1AXP9liHjgYONdQ+MKw/xT06IQK3vgbtu2Ww=="], - "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.54", "", { "dependencies": { "@smithy/config-resolver": "^4.4.17", "@smithy/credential-provider-imds": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/property-provider": "^4.2.14", "@smithy/smithy-client": "^4.12.13", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw=="], + "@smithy/util-stream": ["@smithy/util-stream@4.6.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-g+hQ45sPnaIDU4CnaG8EufmeWwziQlcpIvPG6hVY7v65RcUgasM63J/WNfSsXEcZ1zFu9rS/r/qqfDxkIrQtDw=="], - "@smithy/util-endpoints": ["@smithy/util-endpoints@3.4.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg=="], + "@smithy/util-utf8": ["@smithy/util-utf8@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-tAa4sePYB7mlJzdYbdBqdv37KwFKWixmM/r3ihcI0HFOVjf+a5oGvtcLXcGm4S1bY4DFsLAIOHgjubtp+oRufw=="], - "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], - - "@smithy/util-middleware": ["@smithy/util-middleware@4.2.14", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw=="], - - "@smithy/util-retry": ["@smithy/util-retry@4.3.8", "", { "dependencies": { "@smithy/service-error-classification": "^4.3.1", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw=="], - - "@smithy/util-stream": ["@smithy/util-stream@4.5.25", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.17", "@smithy/node-http-handler": "^4.6.1", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA=="], - - "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], - - "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], - - "@smithy/util-waiter": ["@smithy/util-waiter@4.3.0", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA=="], - - "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@smithy/util-waiter": ["@smithy/util-waiter@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-oTt3OP9NcJkrySCSCCdSbP6XLSMNgOmt/ulaiYtb0Ng6tfEWtXQ1mwfyqmLd+GapmDUjbU2mgkf7QIq9H4ij/g=="], "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], @@ -1634,8 +1595,6 @@ "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], - "@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="], "@trigger.dev/core": ["@trigger.dev/core@4.4.3", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", "@opentelemetry/exporter-metrics-otlp-http": "0.203.0", "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@opentelemetry/host-metrics": "^0.37.0", "@opentelemetry/instrumentation": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@s2-dev/streamstore": "0.22.5", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-4srm2UGoDEcHO29Lqp4Isioq+b6au0EjW9/pjYmzOSxXqGPFDjPquK0BnKYGHyAbKYxuBx8wr2T/ru+zbY0/Jg=="], @@ -1656,6 +1615,8 @@ "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1764,13 +1725,13 @@ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], "@types/micromatch": ["@types/micromatch@4.0.10", "", { "dependencies": { "@types/braces": "*" } }, "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + "@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], @@ -1780,14 +1741,12 @@ "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-window": ["@types/react-window@2.0.0", "", { "dependencies": { "react-window": "*" } }, "sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ=="], - "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], - "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], @@ -1802,8 +1761,6 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], - "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -1812,9 +1769,7 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.6", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], @@ -1826,23 +1781,23 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.7", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.7", "vitest": "4.1.7" }, "optionalPeers": ["@vitest/browser"] }, "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], - "@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], + "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], - "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], + "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], - "@vitest/runner": ["@vitest/runner@4.1.7", "", { "dependencies": { "@vitest/utils": "4.1.7", "pathe": "^2.0.3" } }, "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw=="], + "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="], - "@vitest/spy": ["@vitest/spy@4.1.7", "", {}, "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q=="], + "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="], - "@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], + "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], - "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], + "@webgpu/types": ["@webgpu/types@0.1.70", "", {}, "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA=="], "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], @@ -1864,7 +1819,7 @@ "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ai": ["ai@5.0.186", "", { "dependencies": { "@ai-sdk/gateway": "2.0.88", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0HVwYO9k/x5eSNggqya/75uirBLjkZoL5QdNp9ftjOCl/IXWSzqys/SzsL3ifWBz603a0KbW+EZyYVtmbFJrTQ=="], + "ai": ["ai@5.0.197", "", { "dependencies": { "@ai-sdk/gateway": "2.0.98", "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iUzFb2M3ZUL/Bbmfonh75DIZ354svWO5xh8VPC2wYNR6zzEMFghPOlJG5rtEpqRa037lHfdcjt0qmzg3em/WDw=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -1880,6 +1835,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "anynum": ["anynum@1.0.0", "", {}, "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -1896,9 +1853,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], - - "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.2", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="], "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], @@ -1914,33 +1869,17 @@ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - "axios": ["axios@1.16.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w=="], - - "b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], + "axios": ["axios@1.17.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], - - "bare-fs": ["bare-fs@4.7.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw=="], - - "bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="], - - "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], - - "bare-stream": ["bare-stream@2.13.1", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="], - - "bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], - - "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], @@ -1980,8 +1919,6 @@ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -2006,7 +1943,7 @@ "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], @@ -2036,8 +1973,6 @@ "chrome-launcher": ["chrome-launcher@1.2.1", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A=="], - "chromium-bidi": ["chromium-bidi@0.6.3", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A=="], - "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -2062,7 +1997,7 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -2094,8 +2029,6 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "console-table-printer": ["console-table-printer@2.15.0", "", { "dependencies": { "simple-wcswidth": "^1.1.2" } }, "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -2146,7 +2079,7 @@ "csv-parse": ["csv-parse@6.1.0", "", {}, "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw=="], - "cytoscape": ["cytoscape@3.33.3", "", {}, "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], @@ -2226,14 +2159,12 @@ "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], - "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "debounce": ["debounce@2.2.0", "", {}, "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], - "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -2250,8 +2181,6 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], - "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -2282,7 +2211,7 @@ "docs": ["docs@workspace:apps/docs"], - "docx": ["docx@9.6.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ=="], + "docx": ["docx@9.7.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg=="], "docx-preview": ["docx-preview@0.3.7", "", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg=="], @@ -2294,7 +2223,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], + "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -2312,7 +2241,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "e2b": ["e2b@2.19.5", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-pd1LvDrf5CWn1kHRK0CzKvnHJGeoaH/4//QOhkV+oyFPhBNtvPnUvSv60Zd1vdcuNRFM5b3j0Krg+44hVkLuRg=="], + "e2b": ["e2b@2.28.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-ZlP8Qw5SA0o+SLynqXugNwNoWMoQYyZWf8v/Z2oUSvzNxglH2SUQYcRCklscsH5WBsoB0X0biOh2S6P7LSWa8w=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -2324,7 +2253,7 @@ "effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], - "electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="], + "electron-to-chromium": ["electron-to-chromium@1.5.370", "", {}, "sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -2338,13 +2267,13 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "engine.io": ["engine.io@6.6.7", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3" } }, "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ=="], + "engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="], - "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + "engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.21.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ=="], + "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], @@ -2358,7 +2287,7 @@ "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -2376,13 +2305,9 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "esrap": ["esrap@2.2.6", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "esrap": ["esrap@2.2.11", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="], "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], @@ -2400,8 +2325,6 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -2410,11 +2333,9 @@ "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], "evt": ["evt@2.5.9", "", { "dependencies": { "minimal-polyfills": "^2.2.3", "run-exclusive": "^2.2.19", "tsafe": "^1.8.5" } }, "sha512-GpjX476FSlttEGWHT8BdVMoI8wGXQGbEOtKcP4E+kggg+yJzXBZN2n4x7TS/zPBJ1DZqWI+rguZZApjjzQ0HpA=="], @@ -2426,7 +2347,7 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], @@ -2434,8 +2355,6 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], @@ -2444,8 +2363,6 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], @@ -2456,19 +2373,17 @@ "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "fetch-cookie": ["fetch-cookie@3.2.0", "", { "dependencies": { "set-cookie-parser": "^2.4.8", "tough-cookie": "^6.0.0" } }, "sha512-n61pQIxP25C6DRhcJxn7BDzgHP/+S56Urowb5WFxtcRMpU6drqXD90xjyAsVQYsNSNNVbaCcYY1DuHsdkZLuiA=="], - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "ffmpeg-static": ["ffmpeg-static@5.3.0", "", { "dependencies": { "@derhuerst/http-basic": "^8.2.0", "env-paths": "^2.2.0", "https-proxy-agent": "^5.0.0", "progress": "^2.0.3" } }, "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg=="], @@ -2498,7 +2413,7 @@ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], - "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], "free-email-domains": ["free-email-domains@1.2.25", "", {}, "sha512-Uf2rJUjo/agIgQzt6od9XcHrR6rfIMD6TwsNVSJVJCHzjPWWsqjCb+EaQ2VVVY9M55+JB3V0k6ru5sHTGx/ZfA=="], @@ -2518,9 +2433,9 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="], - "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "gcp-metadata": ["gcp-metadata@8.1.3", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w=="], "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], @@ -2540,8 +2455,6 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], @@ -2556,7 +2469,7 @@ "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], - "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "google-logging-utils": ["google-logging-utils@1.1.4", "", {}, "sha512-LXxE6ND+JHhDPYtPFt3oeWrjxFPrnRZCZ4At6lpbi1ZDSAN7bmGET9s53vvARbxT93p+xpeDUbc/nCR4C+7vcw=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -2580,7 +2493,7 @@ "hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="], - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], @@ -2612,7 +2525,7 @@ "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], - "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], @@ -2656,7 +2569,7 @@ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - "import-in-the-middle": ["import-in-the-middle@3.0.1", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA=="], + "import-in-the-middle": ["import-in-the-middle@3.0.2", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ=="], "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], @@ -2674,7 +2587,7 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="], + "ioredis": ["ioredis@5.11.1", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -2778,7 +2691,7 @@ "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], - "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], @@ -2788,8 +2701,6 @@ "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], - "langsmith": ["langsmith@0.3.87", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q=="], - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], "leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="], @@ -2798,7 +2709,7 @@ "libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="], - "libphonenumber-js": ["libphonenumber-js@1.13.0", "", {}, "sha512-N12qmdu0BM1wVNkMKYOoJR4fTOZDblrKNsOqGbKoUZrYsYLX2zx1O5X+vhK0WJPBU/+/kh9tCr8x0a7t1puGWg=="], + "libphonenumber-js": ["libphonenumber-js@1.13.6", "", {}, "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q=="], "libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="], @@ -2846,12 +2757,8 @@ "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], - "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], - "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], - "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], - "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], @@ -3052,8 +2959,6 @@ "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -3068,16 +2973,14 @@ "mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="], - "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], + "motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="], - "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], - "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], - "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], "mysql2": ["mysql2@3.14.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.6.3", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-fD6MLV8XJ1KiNFIF0bS7Msl8eZyhlTDCDl75ajU5SJtpdx9ZPEACulJcqJWr1Y8OYyxsFc4j3+nflpmhxCU5aQ=="], @@ -3086,7 +2989,7 @@ "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], - "nan": ["nan@2.26.2", "", {}, "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw=="], + "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], "nano-spawn": ["nano-spawn@1.0.3", "", {}, "sha512-jtpsQDetTnvS2Ts1fiRdci5rx0VYws5jGyC+4IYOTnIQ/wwdf6JdomlHBwqC3bJYOvaKu0C2GSZ1A60anrYpaA=="], @@ -3104,8 +3007,6 @@ "neo4j-driver-core": ["neo4j-driver-core@6.0.1", "", {}, "sha512-5I2KxICAvcHxnWdJyDqwu8PBAQvWVTlQH2ve3VQmtVdJScPqWhpXN1PiX5IIl+cRF3pFpz9GQF53B5n6s0QQUQ=="], - "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], "next-mdx-remote": ["next-mdx-remote@6.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="], @@ -3128,7 +3029,7 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], "node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="], @@ -3144,7 +3045,7 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], "nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="], @@ -3156,7 +3057,7 @@ "obliterator": ["obliterator@1.6.1", "", {}, "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig=="], - "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "obug": ["obug@2.1.2", "", {}, "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg=="], "officeparser": ["officeparser@5.2.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.10", "concat-stream": "^2.0.0", "file-type": "^16.5.4", "node-ensure": "^0.0.0", "pdfjs-dist": "^5.3.31", "yauzl": "^3.1.3" }, "bin": { "officeparser": "officeParser.js" } }, "sha512-5JrV1CZFqTv/27fXy2bcf+3g6BpDZiJ3XoSRW3fb2i2EFex0DduqjTxiU2RsJ08WBsk4Hp0nZoGi9ZtHMZFaPA=="], @@ -3186,18 +3087,6 @@ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], - "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], - - "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], - - "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], - - "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], - - "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], - - "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], - "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -3222,8 +3111,6 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "patchright-core": ["patchright-core@1.59.4", "", { "bin": { "patchright-core": "cli.js" } }, "sha512-7/vyX0XK0cpGKlcnUD+Rhjv5o9rrmZQl4v/NI+EUBed+VaU5EORpkOF0Gdi+fP698fLhY0tXwacKBUqKE38jQA=="], - "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -3256,7 +3143,7 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pidtree": ["pidtree@0.6.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw=="], "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], @@ -3276,15 +3163,11 @@ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], - "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], - - "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], - "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], @@ -3306,11 +3189,11 @@ "pptxgenjs": ["pptxgenjs@4.0.1", "", { "dependencies": { "@types/node": "^22.8.1", "https": "^1.0.0", "image-size": "^1.2.1", "jszip": "^3.10.1" } }, "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A=="], - "preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], + "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -3326,25 +3209,21 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "protobufjs": ["protobufjs@8.0.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], - "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer-core": ["puppeteer-core@22.15.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "chromium-bidi": "0.6.3", "debug": "^4.3.6", "devtools-protocol": "0.0.1312386", "ws": "^8.18.0" } }, "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -3368,7 +3247,7 @@ "react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="], - "react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="], + "react-hook-form": ["react-hook-form@7.78.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA=="], "react-pdf": ["react-pdf@10.4.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="], @@ -3464,15 +3343,15 @@ "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], - "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -3522,7 +3401,7 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -3544,7 +3423,7 @@ "shiki": ["shiki@4.0.0", "", { "dependencies": { "@shikijs/core": "4.0.0", "@shikijs/engine-javascript": "4.0.0", "@shikijs/engine-oniguruma": "4.0.0", "@shikijs/langs": "4.0.0", "@shikijs/themes": "4.0.0", "@shikijs/types": "4.0.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-rjKoiw30ZaFsM0xnPPwxco/Jftz/XXqZkcQZBTX4LGheDw8gCDEH87jdgaKDEG3FZO2bFOK27+sR/sDHhbBXfg=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -3564,8 +3443,6 @@ "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], - "simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw=="], - "simstudio": ["simstudio@workspace:packages/cli"], "simstudio-ts-sdk": ["simstudio-ts-sdk@workspace:packages/ts-sdk"], @@ -3580,7 +3457,7 @@ "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], - "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="], + "socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="], "socket.io-client": ["socket.io-client@4.8.1", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ=="], @@ -3588,8 +3465,6 @@ "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], - "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -3628,12 +3503,12 @@ "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], - "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], - "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -3642,6 +3517,8 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], @@ -3654,7 +3531,7 @@ "stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "strnum": ["strnum@2.4.0", "", { "dependencies": { "anynum": "^1.0.0" } }, "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], @@ -3678,7 +3555,7 @@ "systeminformation": ["systeminformation@5.23.8", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-Osd24mNKe6jr/YoXLLK3k8TMdzaxDffhpCxgkfgBHcapykIkd50HXThM3TCEuHO2pPuCsSx2ms/SunqhU5MmsQ=="], - "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], @@ -3686,7 +3563,7 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="], + "tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="], "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], @@ -3694,15 +3571,11 @@ "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], - "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], - - "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], - "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], - "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "thread-stream": ["thread-stream@3.2.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw=="], "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], @@ -3714,15 +3587,15 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], - "tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], + "tldts-core": ["tldts-core@7.4.2", "", {}, "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -3752,7 +3625,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -3764,7 +3637,7 @@ "type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], @@ -3776,8 +3649,6 @@ "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], - "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], - "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], @@ -3816,8 +3687,6 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="], - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -3842,15 +3711,15 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], - "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], + "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -3862,7 +3731,7 @@ "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], + "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], @@ -3878,9 +3747,11 @@ "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }], @@ -3910,13 +3781,13 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yauzl": ["yauzl@3.3.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ=="], + "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -3932,7 +3803,7 @@ "zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="], - "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -3944,9 +3815,9 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1041.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.8", "@aws-sdk/nested-clients": "^3.997.6", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1065.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/nested-clients": "^3.997.19", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-qdHQntq82gMqG6Tf8xrgmhJxacaYkxW4PEeDg/ISMVJ84EWe7iD6JyCTgbyox3uNDH6vqEJ8GUiTaXCq307zVw=="], - "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="], + "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], "@azure/communication-email/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -3978,15 +3849,7 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - "@grpc/proto-loader/protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], - - "@langchain/core/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "@langchain/core/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], - - "@langchain/core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@langchain/openai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -3996,33 +3859,109 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], - "@puppeteer/browsers/tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + + "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collapsible/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dropdown-menu/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], - "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-menu/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menu/@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-menu/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-menu/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-menu/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-menu/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + "@radix-ui/react-navigation-menu/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.5", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + + "@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-popper/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], - "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-select/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], - "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + "@radix-ui/react-select/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.5", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg=="], - "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slider/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], - "@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + "@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-use-effect-event/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-use-escape-keydown/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-use-size/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], @@ -4042,6 +3981,10 @@ "@reactflow/node-toolbar/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@redis/client/cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], "@sim/realtime/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4054,11 +3997,11 @@ "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -4114,15 +4057,15 @@ "@trigger.dev/sdk/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], - "@types/busboy/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/busboy/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], - "@types/cors/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/cors/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@types/fluent-ffmpeg/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@types/jsdom/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], - "@types/node-fetch/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/node-fetch/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@types/nodemailer/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], @@ -4130,11 +4073,9 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@types/through/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - - "@types/ws/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/through/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "@types/yauzl/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/ws/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -4144,6 +4085,8 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -4156,18 +4099,18 @@ "c12/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "c12/magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], - "c12/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], - "chrome-launcher/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - - "chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], + "chrome-launcher/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "cmdk/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "cmdk/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -4184,7 +4127,7 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "docx/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "docx/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "docx/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], @@ -4196,20 +4139,14 @@ "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "engine.io/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], - - "engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "engine.io/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "engine.io/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - - "extract-zip/yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -4224,25 +4161,27 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "fumadocs-core/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "fumadocs-core/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "fumadocs-mdx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], - "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "fumadocs-openapi/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], "fumadocs-openapi/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "fumadocs-openapi/lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + "fumadocs-openapi/lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + + "fumadocs-openapi/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], - "fumadocs-openapi/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg=="], - "fumadocs-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "fumadocs-ui/lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], - "fumadocs-ui/lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + "fumadocs-ui/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], - "fumadocs-ui/shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], - "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], "gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], @@ -4270,10 +4209,6 @@ "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "langsmith/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "langsmith/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], - "libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], @@ -4330,8 +4265,6 @@ "ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -4340,8 +4273,6 @@ "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -4352,16 +4283,10 @@ "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], - "protobufjs/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - - "proxy-agent/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - - "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1312386", "", {}, "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA=="], - "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-email/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -4382,7 +4307,9 @@ "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -4394,17 +4321,17 @@ "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], - "simstudio/@types/node": ["@types/node@20.19.40", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q=="], + "simstudio/@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="], "simstudio/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "simstudio-ts-sdk/@types/node": ["@types/node@20.19.40", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q=="], + "simstudio-ts-sdk/@types/node": ["@types/node@20.19.42", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], - "socket.io-adapter/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "socket.io-adapter/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -4414,6 +4341,8 @@ "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -4426,18 +4355,16 @@ "tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], - "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "twilio/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], "twilio/xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="], - "unbzip2-stream/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], - "vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="], @@ -4450,12 +4377,6 @@ "zrender/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@better-auth/sso/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], @@ -4516,19 +4437,29 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], - "@puppeteer/browsers/tar-fs/tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], + "@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-menu/@radix-ui/react-dismissable-layer/@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@radix-ui/react-separator/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-menu/@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], @@ -4538,7 +4469,7 @@ "@trigger.dev/core/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="], @@ -4556,9 +4487,9 @@ "@trigger.dev/core/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="], - "@trigger.dev/core/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@trigger.dev/core/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@trigger.dev/core/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + "@trigger.dev/core/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], "@trigger.dev/core/@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw=="], @@ -4570,15 +4501,15 @@ "@trigger.dev/core/socket.io-client/engine.io-client": ["engine.io-client@6.5.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.0.0" } }, "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ=="], - "@types/busboy/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/busboy/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - "@types/cors/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/cors/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/fluent-ffmpeg/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "@types/jsdom/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], - "@types/node-fetch/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@types/nodemailer/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], @@ -4586,19 +4517,19 @@ "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@types/through/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/through/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@types/ws/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - - "@types/yauzl/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "chrome-launcher/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "chrome-launcher/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "cli-truncate/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -4614,9 +4545,9 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "docx/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "engine.io/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -4624,17 +4555,17 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "fumadocs-core/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + "fumadocs-core/shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "fumadocs-core/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "fumadocs-core/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "fumadocs-core/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "fumadocs-core/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "fumadocs-core/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "fumadocs-core/shiki/@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "fumadocs-core/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "fumadocs-core/shiki/@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], - "fumadocs-core/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "fumadocs-core/shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "fumadocs-mdx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], @@ -4688,29 +4619,29 @@ "fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], - "fumadocs-openapi/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + "fumadocs-openapi/shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "fumadocs-openapi/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "fumadocs-openapi/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "fumadocs-openapi/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "fumadocs-openapi/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "fumadocs-openapi/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "fumadocs-openapi/shiki/@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "fumadocs-openapi/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "fumadocs-openapi/shiki/@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], - "fumadocs-openapi/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "fumadocs-openapi/shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "fumadocs-ui/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + "fumadocs-ui/shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "fumadocs-ui/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "fumadocs-ui/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "fumadocs-ui/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "fumadocs-ui/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "fumadocs-ui/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "fumadocs-ui/shiki/@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "fumadocs-ui/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "fumadocs-ui/shiki/@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], - "fumadocs-ui/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "fumadocs-ui/shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], @@ -4816,7 +4747,7 @@ "posthog-js/@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - "protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -4824,6 +4755,10 @@ "restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "sim/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "sim/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -4836,118 +4771,60 @@ "tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], - "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], - "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], - "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], - "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], - "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], - "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], - "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], - "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], - "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], - "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], - "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], - "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], - "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], - "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], - "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], - "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], - "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], - "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], - "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], - "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], - "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], - "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], - "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], - "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], - "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], "twilio/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - - "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - - "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - - "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - - "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - - "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - - "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - - "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - - "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - - "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - - "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - - "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - - "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - - "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - - "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - - "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - - "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - - "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - - "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - - "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - - "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - - "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - - "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - - "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - - "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - - "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - - "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - "@browserbasehq/sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "@browserbasehq/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -4960,13 +4837,19 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-menu/@radix-ui/react-roving-focus/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], "@trigger.dev/core/@opentelemetry/instrumentation/import-in-the-middle/cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], @@ -4974,7 +4857,7 @@ "@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="], - "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@trigger.dev/core/socket.io/engine.io/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@trigger.dev/core/socket.io/engine.io/cookie": ["cookie@0.4.2", "", {}, "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA=="], @@ -4982,11 +4865,11 @@ "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "fumadocs-core/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "fumadocs-core/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], - "fumadocs-openapi/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "fumadocs-openapi/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], - "fumadocs-ui/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "fumadocs-ui/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], "groq-sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -5034,7 +4917,11 @@ "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + + "rimraf/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "sim/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -5044,13 +4931,13 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], - "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "lint-staged/listr2/cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -5066,20 +4953,32 @@ "log-update/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + + "rimraf/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "rimraf/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "sim/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "lint-staged/listr2/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "lint-staged/listr2/log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "rimraf/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "rimraf/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], } } diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 4cf8771a58c..9f238723141 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -128,6 +128,7 @@ export const AuditAction = { ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', + ORG_MEMBER_USAGE_LIMIT_CHANGED: 'org_member.usage_limit_changed', ORG_INVITATION_CREATED: 'org_invitation.created', ORG_INVITATION_UPDATED: 'org_invitation.updated', ORG_INVITATION_ACCEPTED: 'org_invitation.accepted', diff --git a/packages/db/migrations/0230_thick_stranger.sql b/packages/db/migrations/0230_thick_stranger.sql new file mode 100644 index 00000000000..214a4dba519 --- /dev/null +++ b/packages/db/migrations/0230_thick_stranger.sql @@ -0,0 +1,19 @@ +CREATE TABLE "organization_member_usage_limit" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "usage_limit" numeric NOT NULL, + "set_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "uploaded_by" text;--> statement-breakpoint +ALTER TABLE "table_run_dispatches" ADD COLUMN "triggered_by_user_id" text;--> statement-breakpoint +ALTER TABLE "organization_member_usage_limit" ADD CONSTRAINT "organization_member_usage_limit_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_member_usage_limit" ADD CONSTRAINT "organization_member_usage_limit_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_member_usage_limit" ADD CONSTRAINT "organization_member_usage_limit_set_by_user_id_fk" FOREIGN KEY ("set_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "org_member_usage_limit_org_user_unique" ON "organization_member_usage_limit" USING btree ("organization_id","user_id");--> statement-breakpoint +CREATE INDEX "org_member_usage_limit_organization_id_idx" ON "organization_member_usage_limit" USING btree ("organization_id");--> statement-breakpoint +ALTER TABLE "document" ADD CONSTRAINT "document_uploaded_by_user_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "table_run_dispatches" ADD CONSTRAINT "table_run_dispatches_triggered_by_user_id_user_id_fk" FOREIGN KEY ("triggered_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/migrations/meta/0230_snapshot.json b/packages/db/migrations/meta/0230_snapshot.json new file mode 100644 index 00000000000..63a4016a4e9 --- /dev/null +++ b/packages/db/migrations/meta/0230_snapshot.json @@ -0,0 +1,16602 @@ +{ + "id": "b6675160-a7cc-4e1b-bbc3-4b050284b789", + "prevId": "f009b302-a53f-40ae-82c3-5fd6a874f7ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_archived_partial_idx": { + "name": "a2a_agent_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"a2a_agent\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_name_unique": { + "name": "permission_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_auto_add_unique": { + "name": "permission_group_workspace_auto_add_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_id_workspace_id_fk", + "tableFrom": "permission_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_workspace_user_unique": { + "name": "permission_group_member_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_workspace_id_workspace_id_fk": { + "name": "permission_group_member_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "import_status": { + "name": "import_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_id": { + "name": "import_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_error": { + "name": "import_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_rows_processed": { + "name": "import_rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "import_started_at": { + "name": "import_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 6355d7c5520..55ca91ceaf0 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1604,6 +1604,13 @@ "when": 1780946000000, "tag": "0229_backfill_column_ids", "breakpoints": true + }, + { + "idx": 230, + "version": "7", + "when": 1781027249389, + "tag": "0230_thick_stranger", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 331e4182652..a24c15d8c79 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1179,6 +1179,43 @@ export const member = pgTable( }) ) +/** + * Per-member usage limit (in dollars) scoped to a single organization. + * + * Keyed by `(organizationId, userId)` so it covers both organization members + * (rows in `member`) and external members (users with workspace permissions in + * org-owned workspaces but no `member` row). Independent of + * `user_stats.current_usage_limit`, which is the user's personal subscription + * cap and is nulled for org-scoped members. An absent row means "no per-member + * cap" (only the pooled org limit applies). Enforced for usage in org-owned + * workspaces; hosted-only. + */ +export const organizationMemberUsageLimit = pgTable( + 'organization_member_usage_limit', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + usageLimit: decimal('usage_limit').notNull(), + /** Admin who set the cap (audit only). Soft FK: nulled if that user is + * deleted so the member's limit row survives — never cascade-deleted. */ + setBy: text('set_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => ({ + orgUserUnique: uniqueIndex('org_member_usage_limit_org_user_unique').on( + table.organizationId, + table.userId + ), + organizationIdIdx: index('org_member_usage_limit_organization_id_idx').on(table.organizationId), + }) +) + export const invitationKindEnum = pgEnum('invitation_kind', ['organization', 'workspace']) export type InvitationKind = (typeof invitationKindEnum.enumValues)[number] @@ -1618,6 +1655,11 @@ export const document = pgTable( contentHash: text('content_hash'), sourceUrl: text('source_url'), + /** User who uploaded the document, for usage attribution. Null for + * connector/cron-synced docs (and pre-migration rows) → indexing billing + * falls back to the workspace billed account. */ + uploadedBy: text('uploaded_by').references(() => user.id, { onDelete: 'set null' }), + // Timestamps uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), }, @@ -3282,6 +3324,12 @@ export const tableRunDispatches = pgTable( * CSV import, addWorkflowGroup) set this to false so the dispatch * honors the autoRun toggle. */ isManualRun: boolean('is_manual_run').notNull().default(true), + /** User who triggered the run, for per-member usage attribution. Null for + * auto-fire (row insert/update, CSV import) with no human initiator — + * those fall back to the workspace billed account. */ + triggeredByUserId: text('triggered_by_user_id').references(() => user.id, { + onDelete: 'set null', + }), requestedAt: timestamp('requested_at').notNull().defaultNow(), completedAt: timestamp('completed_at'), cancelledAt: timestamp('cancelled_at'), diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index d1140fa129b..3cd9d624aff 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -112,6 +112,7 @@ export const auditMock = { ORG_MEMBER_ADDED: 'org_member.added', ORG_MEMBER_REMOVED: 'org_member.removed', ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', + ORG_MEMBER_USAGE_LIMIT_CHANGED: 'org_member.usage_limit_changed', ORG_INVITATION_CREATED: 'org_invitation.created', ORG_INVITATION_UPDATED: 'org_invitation.updated', ORG_INVITATION_ACCEPTED: 'org_invitation.accepted', diff --git a/packages/testing/src/mocks/feature-flags.mock.ts b/packages/testing/src/mocks/feature-flags.mock.ts index b89da5ed7e0..da512c99a8d 100644 --- a/packages/testing/src/mocks/feature-flags.mock.ts +++ b/packages/testing/src/mocks/feature-flags.mock.ts @@ -31,6 +31,7 @@ export const featureFlagsMock = { isAuditLogsEnabled: false, isDataRetentionEnabled: false, isE2bEnabled: false, + isE2BDocEnabled: false, isOllamaConfigured: false, isAzureConfigured: false, isInvitationsDisabled: false, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9921da3ab46..b639bedd2b0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -53,12 +53,6 @@ const INDIRECT_ZOD_ROUTES = new Set([ // client-supplied input"; query params from external callers are not // consumed. 'apps/sim/app/api/schedules/execute/route.ts', - // Document preview routes delegate validation to - // `createDocumentPreviewRoute(...)`, which calls `safeParse` on the - // contract-owned `routeParamsSchema` and `previewBodySchema`. - 'apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts', - 'apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts', - 'apps/sim/app/api/workspaces/[id]/docx/preview/route.ts', // Routes with no client-supplied input. Auth is handled via session/cron/internal // tokens and there are no params, query, or body to validate. Previously had // no-op `validateSchema(noInputSchema, {})` guards. @@ -112,7 +106,6 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ 'apps/sim/app/api/copilot/api-keys/generate/route.ts', 'apps/sim/app/api/copilot/api-keys/validate/route.ts', 'apps/sim/app/api/copilot/chat/abort/route.ts', - 'apps/sim/app/api/copilot/stats/route.ts', 'apps/sim/app/api/folders/[id]/restore/route.ts', 'apps/sim/app/api/invitations/[id]/accept/route.ts', 'apps/sim/app/api/invitations/[id]/reject/route.ts',